codeconsole opened a new pull request, #16411:
URL: https://github.com/apache/grails-core/pull/16411

   ## Description
   
   Fixes #16406, and makes Grails 8 render date and time values in JSON exactly 
the way Spring Boot 4.1 does (its default Jackson 3 `JsonMapper`), in both 
`grails.converters.JSON` (`render ... as JSON`, `respond`) and JSON views.
   
   ### The regression in #16406
   
   Two 8.0.x-only commits from #15432 changed the default JSON 
`DateMarshaller`/`CalendarMarshaller` (and the XML `DateMarshaller`):
   
   - `2fcf83213a` replaced `FastDateFormat` with `date.toInstant()`. 
`java.sql.Date#toInstant()` and `java.sql.Time#toInstant()` always throw 
`UnsupportedOperationException`, so rendering either one failed (HTTP 500 from 
a controller).
   - `d3c82799f0` switched to `DateTimeFormatter.ISO_INSTANT`, which drops a 
zero fraction (`...T03:00:00Z` instead of `...T03:00:00.000Z`) and writes every 
nanosecond of a `Timestamp`.
   
   Grails 7.x never had either problem. Its `.SSS'Z'` output is what the 7.0 
upgrade guide documents, and what Spring Boot renders.
   
   ### Spring Boot parity
   
   With this change a Grails app and a Spring Boot app return the same JSON for 
the same values:
   
   | Type | Rendered as (same as Spring Boot 4.1) |
   |---|---|
   | `Date`, `java.sql.Date`, `Timestamp`, `Calendar`, `XMLGregorianCalendar` | 
`"2025-10-08T07:48:46.407Z"`: UTC, always millisecond precision, `Timestamp` 
nanos truncated |
   | `java.sql.Time` | `"01:48:46"` (`Time#toString()`) |
   | `Instant`, `LocalDate`, `LocalDateTime`, `OffsetDateTime`, `ZonedDateTime` 
| unchanged ISO-8601 |
   | `LocalTime` | `"01:48:46.407254"` (`ISO_LOCAL_TIME`) |
   | `OffsetTime`, `YearMonth`, `MonthDay`, `Duration`, `Period`, 
`javax.xml.datatype.Duration` | `toString()`, e.g. `"03:00-03:00"`, 
`"2026-09"`, `"--09-25"`, `"PT1H30M"` |
   | `Year`, `Month` | JSON numbers: `2026`, `9` |
   | `ZoneId`, `ZoneOffset`, `TimeZone` | the ID: `"America/Sao_Paulo"`, 
`"-03:00"` |
   | `Date`/`Calendar`/`java.sql` map keys | formatted as their values are |
   | `ZonedDateTime` map keys | `ISO_OFFSET_DATE_TIME` |
   
   `Date`/`Calendar` formatting lives in a new 
`org.grails.web.json.JsonDateFormat` (grails-web-common), shared by both 
modules. It follows Jackson's `StdDateFormat`: it works from epoch millis, so 
the `java.sql` types are safe; it uses the Julian calendar before 1582, as 
`java.util.Date` does; and it writes ISO 8601 expanded years (`+12345`, 
`-0043`).
   
   **`grails.converters.JSON`:** new per-type marshallers, registered in 
`ConvertersConfigurationInitializer`. `SqlTimeMarshaller` is ahead of 
`DateMarshaller`, and `MonthMarshaller` is ahead of `SimpleEnumMarshaller`. 
`MapMarshaller` and the `JSON` builder write date keys through 
`JsonDateFormat.formatKey`. Marshallers registered with 
`JSON.registerObjectMarshaller(...)` still take precedence.
   
   **JSON views:** new per-type `JsonGenerator.Converter`s. 
`OffsetTimeJsonConverter` now uses `toString()`, as Jackson does. A small 
`DefaultJsonGenerator` subclass, `JsonViewGenerator`, writes dates with 
`JsonDateFormat` and writes date map keys, keeping keys that format to the same 
text, as Jackson does.
   - `grails.views.json.generator.dateFormat` no longer has a default. When 
unset, dates render as Spring Boot renders them, in the configured `timeZone` 
with that zone's offset, as `spring.jackson.time-zone` does. When set, the 
pattern (with `timeZone`/`locale`) is used for `Date`/`Calendar` values and 
keys, and, as with `spring.jackson.date-format`, not for `java.sql.Time` values.
   - `g.render` (`DefaultGrailsJsonViewHelper`) now goes through the generator 
for these types:
     - any type the generator has a converter for counts as a simple value; 
before, `Year`/`Duration`/`ZoneId`… rendered as `{}`;
     - the enum `name()` shortcut no longer bypasses a converter, so a domain 
`Month` property renders as `9`;
     - map keys are formatted by the generator;
     - collection elements are written by the view's generator instead of 
Groovy's default one.
   - Converters registered through `ServiceLoader` still take precedence.
   
   **Data binding:** because `Month` now renders as a number, it must bind back 
from one. Without this, `{"month":9}` would bind through Spring's 
`IntegerToEnumConverterFactory` by ordinal, silently giving `OCTOBER`. A new 
`monthValueConverter` in `Jsr310ConvertersConfiguration` binds `9`/`"9"` to 
`SEPTEMBER`, matching Jackson's reading; month names still bind as before.
   
   **XML:** only the #16406 crash is fixed. `java.sql.Date`/`Time` no longer 
call `toInstant()`. The XML date format (ISO offset date-time since #15432) is 
unchanged; its difference from 7.x is now documented in the upgrade guide.
   
   ### Not changed
   
   - **Enums other than `Month`** still render by `name()` (upgrade guide §8), 
while Jackson 3 writes `toString()`. The two differ only for enums that 
override `toString()`, e.g. `ChronoUnit.SECONDS` renders as `"SECONDS"` here 
and `"Seconds"` in Spring Boot.
   - **`HalJsonRenderer`** (`application/hal+json`) uses Groovy's default JSON 
generator and is untouched.
   - **Binding JSON back into date types**, beyond `Month`, is unchanged. 
Several ISO forms (e.g. a `LocalDateTime` with microseconds, an 
`OffsetDateTime`) don't bind today with the default binding date formats; 
that's a separate, pre-existing issue.
   - **Marshaller priorities:** the default JSON marshallers' implicit negative 
priorities shift because of the new entries. Custom marshallers at the default 
priority (0) are unaffected.
   
   The enum, HAL and priority notes are in the upgrade guide.
   
   ### Documentation
   
   - Upgrade guide §75 covers:
     - every value whose output differs from Grails 7, including `g.render` 
collections and date map keys;
     - the JSON views `dateFormat`/`timeZone` changes;
     - `Month` binding;
     - the XML date format change from 7.x;
     - what 8.0.0-RC1 did differently;
     - how to keep a Grails 7 rendering.
   - New "Date and Time Rendering" sections in the REST default renderers guide 
and the JSON views configuration guide.
   - What's New entry.
   
   ### Verification
   
   - **Parity tests use Jackson as the oracle.** `JsonDateTimeRenderingSpec` 
(converters) and `DateTimeRenderingSpec` (views, rendered through an actual 
view) compare Grails output with 
`JsonMapper.builder().build().writeValueAsString(...)` for 55 values (including 
BC and 5-digit years) and 15 map-key types. A plain Jackson 3 `JsonMapper` and 
Spring Boot 4.1.1's auto-configured one give identical output for all of them.
   - **Literal tests** pin the expected strings per type, plus the exact #16406 
reproducer (`[d: java.sql.Date.valueOf('2026-09-25')] as JSON`), `javascript` 
date mode, and the `JSON` builder.
   - **`DateTimeHelperRenderingSpec`** covers `g.render` of a domain `Month`, a 
POGO and a map (compared with Jackson), `timeZone` against Jackson's 
`defaultTimeZone`, `dateFormat` with `java.sql.Time`, and colliding date keys. 
With the helper changes reverted, 3 of its tests fail.
   - **Binding:** `MonthBindingSpec` binds `9`, `"9"` and `"SEPTEMBER"` through 
`GrailsWebDataBinder`, and rejects `13`.
   - **Zone independence:** parity was also checked with the JVM in 
`America/Sao_Paulo`.
   - **Test runs:** 1,338 tests, 0 failures, in:
     - `grails-web-common`, `grails-converters`, `grails-views-gson`, 
`grails-views-core`, `grails-databinding`, `grails-web-databinding`, 
`grails-rest-transforms`, `grails-test-suite-web`;
     - the JSON/converter/render/binding specs of `grails-test-suite-uber`;
     - the `grails.web.databinding` specs of `grails-test-suite-persistence`;
     - `functionaltests.marshaller.*` in `app1` and `hibernate7/app1`.
   - **Code style:** `codeStyle` passes for the four changed modules.
   - **Guide:** the guide builds (`publishGuide`), and the new tables render.
   - **End to end:** a demo app on 8.0.0-RC1 with `grails-web-common`, 
`grails-converters` and `grails-views-gson` from this branch returns JSON 
**byte-identical** to a Spring Boot 4.1.1 app for 37 date/time fields, through 
both `respond` and a JSON view using `g.render`. Unpatched RC1 returns HTTP 500 
for the same request.
   
   ## Contributor Checklist
   
   ### Issue and Scope
   
   - [ ] This PR is linked to an existing issue that has been **acknowledged or 
approved** by the project team. If no approved issue exists, please give 
background on why this change is necessary.  Tickets are preferred for release 
change log history.
   - [x] This PR addresses the **complete scope** of the linked issue. Partial 
implementations or unfinished work should not be submitted for review.
   - [x] This PR contains a **single, focused change**. Unrelated changes 
should be submitted as separate pull requests.
   - [x] This PR targets the **correct branch** for the type of change
   
   ### Code Quality
   
   - [x] I have **added or updated tests** that cover the changes introduced in 
this PR. All code contributions are expected to include appropriate test 
coverage.
   - [ ] I have verified that all existing tests pass by running `./gradlew 
build --rerun-tasks`.
   - [x] My code follows the project's **code style** guidelines. I have run 
`./gradlew codeStyle` and resolved any violations.
   - [x] This PR does **not** include mass reformatting, style-only changes, or 
large-scale refactoring unless it was **explicitly approved** in the linked 
issue.
   - [ ] If generative AI tooling was used in preparing this contribution, a 
quality model was used to ensure contributions are **consistent with the 
project's quality standards**.
   
   ### Licensing and Attribution
   
   - [x] All contributed code is provided under the Apache License 2.0, and new 
source files include the appropriate **Apache license header**.
   - [ ] I have the necessary rights to submit this contribution and confirm it 
is my own original work.
   - [ ] If generative AI tooling was used in preparing this contribution, I 
have followed the Apache Software Foundation's policy on generative tooling and 
have properly attributed its use.
   
   ### Documentation
   
   - [x] If this PR introduces user-facing changes, I have included or updated 
the relevant documentation.
   - [x] If this PR adds a new feature, I have updated the **What's New** 
section of the Grails Guide.
   - [x] If this PR introduces breaking changes or changes that require user 
action during an upgrade, I have updated the **Upgrade Notes** for the 
corresponding version in the Grails Guide.
   - [x] The PR description clearly explains **what** was changed and **why**.
   


-- 
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