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]