MartinJesusDev opened a new issue, #16660:
URL: https://github.com/apache/grails-core/issues/16660

   ### Expected Behavior
   
   The same `java.util.Date` rendered through `g.render` should use the JSON 
Views generator
   (`grails.views.json.generator.dateFormat`, 
`grails.views.json.generator.timeZone`,
   `grails.views.json.generator.locale`) regardless of its position in the 
object graph: a direct bean
   property, a nested bean property, or a property of an element of a 
`List<DTO>`, a `DTO[]` or a raw list.
   
   With the default configuration every date in the example below should be
   `2025-10-08T07:48:46.407Z`. The JSON Views guide documents that "Values 
rendered with `g.render` are
   written the same way" 
(`grails-doc/src/en/guide/theWebLayer/gson/jsonConfiguration.adoc`).
   
   ### Actual Behaviour
   
   ```groovy
   class DatedItem { Date date }
   
   class Holder {
       Date direct
       DatedItem item
       List<DatedItem> items
       DatedItem[] array
       List<Object> rawItems
       List<Date> dates
   }
   ```
   
   `grails-app/views/holder/show.gson`:
   
   ```groovy
   model { Object object }
   json g.render(object)
   ```
   
   Output on the current 8.0.x sources with the default configuration (real 
values from the failing
   regression test):
   
   ```json
   {
     "direct"  : "2025-10-08T07:48:46.407Z",
     "item"    : {"date": "2025-10-08T07:48:46.407Z"},
     "items"   : [{"date": "2025-10-08T07:48:46+0000"}],
     "array"   : [{"date": "2025-10-08T07:48:46+0000"}],
     "rawItems": [{"date": "2025-10-08T07:48:46+0000"}],
     "dates"   : ["2025-10-08T07:48:46.407Z"]
   }
   ```
   
   The elements of `items`, `array` and `rawItems` are written by Groovy's 
default generator
   (`yyyy-MM-dd'T'HH:mm:ssZ`): the milliseconds are lost and the offset 
representation differs. Accepting
   `+0000` in a client does not recover the lost precision. JSON object key 
order is irrelevant.
   
   The same inconsistency affects three more rendering paths (all verified 
failing before the prepared
   fix, see "Source-level explanation"):
   
   * `g.inline(object)` writes `"direct":"2025-10-08T07:48:46+0000"`.
   * A child view that `inherits` a parent and does `json g.render(object)` 
(inline rendering) writes
     `{"foo":"bar","direct":"2025-10-08T07:48:46+0000"}` for a POGO and
     `{"foo":"bar","name":"Fred","created":"2025-10-08T07:48:46+0000"}` for a 
domain instance.
   * `hal.embedded { item(object) { created object.created } }` writes the 
embedded date as
     `2025-10-08T07:48:46+0000` instead of `2025-10-08T07:48:46.407Z`.
   
   ### Steps To Reproduce
   
   **A. Regression test against the Grails sources (8.0.x).** The prepared PR 
adds this test to
   `grails-views-gson`; it fails on the current sources and passes with the fix:
   
   ```groovy
   class DateTimeHelperRenderingSpec extends Specification implements 
JsonViewUnitTest {
       void "g.render of a POGO writes a Date in a collection or array of DTOs 
the same way as its direct property"() {
           given:
           def date = Date.from(Instant.parse('2025-10-08T07:48:46.407Z'))
           def holder = new CollectionDateHolder(
                   direct: date,
                   item: new DatedItem(date: date),
                   items: [new DatedItem(date: date)],
                   array: [new DatedItem(date: date)] as DatedItem[],
                   rawItems: [new DatedItem(date: date)],
                   dates: [date] as Date[])
   
           when:
           def result = render('''
               model { Object object }
               json g.render(object)
           ''', [object: holder])
   
           then:
           result.json.direct == '2025-10-08T07:48:46.407Z'
           result.json.item.date == result.json.direct
           result.json.items*.date == [result.json.direct]
           result.json.array*.date == [result.json.direct]
           result.json.rawItems*.date == [result.json.direct]
           result.json.dates == [result.json.direct]
       }
   }
   ```
   
   Result before the fix (rest of the tests in the same run printed the 
expected values):
   
   ```
   result.json.items*.date == [result.json.direct]
   |      |    |      |    |   |      |
   |      |    |      |    |   |      [array:[[date:2025-10-08T07:48:46+0000]],
   |      |    |      |    |   |       dates:[2025-10-08T07:48:46.407Z],
   |      |    |      |    |   |       direct:2025-10-08T07:48:46.407Z,
   |      |    |      |    |   |       item:[date:2025-10-08T07:48:46.407Z],
   |      |    |      |    |   |       items:[[date:2025-10-08T07:48:46+0000]],
   |      |    |      |    |   |       
rawItems:[[date:2025-10-08T07:48:46+0000]]]
   |      |    |      |    |   |       false
   |      |    |      |    |   2025-10-08T07:48:46.407Z
   ```
   
   **B. Standalone reproducer against published artifacts (verified on 
7.2.4).** Create a directory with
   three files and run `gradle --no-daemon --no-parallel --max-workers=1 
reproduce` with JDK 25 and
   Gradle 9.1.0. No application, database, authentication or HTTP server is 
required; it invokes the JSON
   Views engine directly.
   
   `settings.gradle`
   
   ```groovy
   rootProject.name = 'json-view-generator-reproducer'
   ```
   
   `build.gradle`
   
   ```groovy
   repositories {
       mavenCentral()
       maven { url = uri('https://repo.grails.org/grails/restricted') }
   }
   configurations { probe }
   dependencies {
       probe platform('org.apache.grails:grails-bom:7.2.4')
       probe 'org.apache.grails:grails-views-gson'
       probe 'jakarta.servlet:jakarta.servlet-api'
   }
   tasks.register('reproduce', JavaExec) {
       classpath = configurations.probe
       mainClass = 'groovy.ui.GroovyMain'
       args layout.projectDirectory.file('reproduce.groovy').asFile.absolutePath
   }
   ```
   
   `reproduce.groovy`
   
   ```groovy
   package reproducer
   
   import grails.plugin.json.view.JsonViewTemplateEngine
   import groovy.json.JsonSlurper
   import java.time.Instant
   
   class Item { Date date }
   class Envelope {
       Date direct
       Item nested
       List<Item> items
   }
   
   Date date = Date.from(Instant.parse('2021-03-02T00:00:00.123Z'))
   def envelope = new Envelope(direct: date, nested: new Item(date: date), 
items: [new Item(date: date)])
   def engine = new JsonViewTemplateEngine(getClass().classLoader)
   def template = engine.createTemplate('import groovy.transform.Field\n@Field 
Object object\njson g.render(object)')
   def writer = new StringWriter()
   template.make([object: envelope]).writeTo(writer)
   String output = writer.toString()
   println output
   def result = new JsonSlurper().parseText(output)
   assert result.direct == '2021-03-02T00:00:00.123Z'
   assert result.nested.date == result.direct
   assert result.items[0].date == result.direct : 'Collection element ignores 
the Views generator and loses milliseconds'
   ```
   
   Verified result on 7.2.4:
   
   ```json
   
{"direct":"2021-03-02T00:00:00.123Z","items":[{"date":"2021-03-02T00:00:00+0000"}],"nested":{"date":"2021-03-02T00:00:00.123Z"}}
   ```
   
   The assertions for the direct and nested bean dates pass; the last one fails 
with
   `Collection element ignores the Views generator and loses milliseconds`. The 
non-zero Gradle exit is
   the expected reproduction of the defect, not a passing regression test. DTOs 
deliberately have a
   package: default-package classes trigger an unrelated template-name lookup 
error. An explicit Servlet
   API dependency is required on a standalone classpath.
   
   ### Environment Information
   
   - Operating System: Linux
   - JDK Version: 25 (GraalVM CE 25.2.4+7.1 and JBR 25.0.4+1.61067)
   - Groovy: 5.1.x (8.0.x sources), 4.0.33 (7.2.4)
   - Gradle: 9.8.1 (repository), 9.1.0 (standalone reproducer)
   
   ### Example Application
   
   N/A — the two self-contained reproducers above (a Spock test and a 
standalone Gradle project)
   reproduce the issue without an application.
   
   ### Version
   
   8.0.x (`8.0.1-SNAPSHOT` sources at `ba72113c99`). Also reproduces with the 
published 7.2.4 artifacts.
   
   ---
   
   ### Source-level explanation (8.0.x)
   
   `DefaultGrailsJsonViewHelper.processSimple` (lines 429-443) handles a bean 
property whose generic
   component type is not simple. At line 439 it constructs
   `new groovy.json.StreamingJsonBuilder.StreamingJsonDelegate(out, true)` 
**without a generator**.
   Dates inside those elements are then written with the delegate's generator, 
which falls back to
   Groovy's default (`groovy.json.JsonOutput.DEFAULT_GENERATOR`, pattern 
`yyyy-MM-dd'T'HH:mm:ssZ`;
   `groovy-json` 4.0.33 and 5.1.3). The neighbouring branches of the same 
method already use the view
   generator: line 435 uses `getGenerator().toJson(...)` and the nested-bean 
branch at lines 451-456
   receives a delegate cloned from the surrounding generator-aware delegate, 
which is why the direct and
   nested bean dates are correct.
   
   Changing only `grails.views.json.generator.dateFormat` cannot repair a 
branch that never receives the
   generator. Groovy is behaving according to its defaults; JSON Views should 
propagate the options it
   owns.
   
   The same omission exists in the other delegate construction sites. All of 
them were verified failing
   with a test before the prepared fix:
   
   | Site (8.0.x)                                  | Reachable through          
                                                       | Evidence     |
   
|-----------------------------------------------|-----------------------------------------------------------------------------------|--------------|
   | `DefaultGrailsJsonViewHelper:439`             | `List<DTO>`, `DTO[]`, raw 
`List` property of a POGO                               | verified red |
   | `DefaultGrailsJsonViewHelper:107`             | `g.inline(object)`         
                                                       | verified red |
   | `DefaultGrailsJsonViewHelper:204`, `:233`     | child view that `inherits` 
a parent and does `json g.render(object)` (inline)      | verified red |
   | `DefaultHalViewHelper:551`, `:558`, `:649`    | `hal.embedded { 
name(object) { ... } }` and `HalStreamingJsonDelegate`             | verified 
red |
   
   In 7.2.4 the same branch lived at helper line 445, and the neighbouring 
simple-element branch used
   the static `JsonOutput.toJson(o)`. On 8.0.x that neighbouring branch already 
uses
   `getGenerator().toJson(...)` (line 435), so only the delegate propagation 
part still matches the
   7.2.4 report.
   
   ### Historical comparison
   
   The inspected `views-json:1.2.10` sources also construct a delegate without 
an explicit generator in
   the equivalent collection branch (helper line 420), but they use Grails' 
former builder, whose
   `JsonOutput.java:62-77` configured the fallback as 
`yyyy-MM-dd'T'HH:mm:ss'Z'` in GMT. Switching to
   Groovy's builder changed the fallback to `+0000` and dropped milliseconds. 
This is source
   comparison, not a full Grails 3 runtime test; the expected precision in this 
report comes from the
   configured Grails 7/8 generator.
   
   ### Suggested Fix
   
   Pass the configured Views generator (`getGenerator()`) when constructing 
those delegates, and use it
   for values that should honour generator options. Do not change Groovy's 
global default or perform
   string replacement on serialized JSON.
   
   ### Prepared fix
   
   A fix with regression tests is prepared and will be submitted as a PR 
referencing this issue
   (proposed branch `fix/json-views-generator-propagation`, squash commit 
`Fixes #<this issue>`):
   
   * `grails-views-gson` only; no API changes, no new dependencies, no 
documentation change needed
     (the behaviour is already documented for `g.render`).
   * Tests added: `List<DTO>`, `DTO[]`, raw `List`, `List<Date>` control, root 
`List<DTO>` control,
     configured `dateFormat`/`timeZone`, `g.inline`, template inheritance (POGO 
and domain instance)
     and HAL embedded callables. Each one fails before the fix and passes after 
it.
   * Validation: `./gradlew :grails-views-gson:test` -> 274 tests, 0 failures;
     `./gradlew clean aggregateViolations :grails-test-report:check --continue` 
-> Checkstyle, CodeNarc,
     PMD, SpotBugs and REPOSITORY_CONVENTIONS reports with no violations. (The 
repository-wide test
     phase of that command also reports pre-existing locale-dependent failures 
on a JVM started with
     `-Duser.language=es`, in modules unrelated to this change.)
   
   Please let me know if the scope should stay limited to the DTO collection 
branch only; the prepared
   PR currently fixes all the sites in the table above.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to