Skip to content

8.0 (fix): Render dates and times in JSON the same way as Spring Boot, and bind them back as rendered - #16411

Merged
jdaugherty merged 22 commits into
apache:8.0.xfrom
codeconsole:fix/16406-date-rendering-8.0.x
Sep 28, 2026
Merged

jdaugherty merged 22 commits into
apache:8.0.xfrom
codeconsole:fix/16406-date-rendering-8.0.x

Conversation

@codeconsole

@codeconsole codeconsole commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Description

Fixes #16406. Grails 8 renders date and time values in JSON the way Spring Boot 4.1 does (its default Jackson 3 JsonMapper), in both grails.converters.JSON (render ... as JSON, respond) and JSON views. It also keeps what Grails 7 applications rely on: enums, Month included, rendered by name, OffsetTime, and date binding.

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. This PR restores it.

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
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()), which binds back to the same time of day, to the second
Instant, LocalDate, LocalDateTime, OffsetDateTime, ZonedDateTime unchanged ISO-8601
LocalTime "01:48:46.407254" (ISO_LOCAL_TIME)
OffsetTime "01:48:46.407254-06:00" (ISO_OFFSET_TIME, as JSON views already rendered it)
YearMonth, MonthDay, Duration, Period, javax.xml.datatype.Duration toString(), e.g. "2026-09", "--09-25", "PT1H30M"
Year a JSON number: 2026
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 in grails.converters.JSON lives in a new org.grails.web.json.JsonDateFormat (grails-web-common). 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;
  • it writes ISO 8601 expanded years (+12345, -0043).

grails.converters.JSON:

  • The added types are registrations of one internal SimpleTypeMarshaller in ConvertersConfigurationInitializer.
  • java.sql.Time is ahead of DateMarshaller, and Month is ahead of SimpleEnumMarshaller.
  • MapMarshaller and the JSON builder write date keys through JsonDateFormat.formatKey. They keep that default form even where a registered Date marshaller or the javascript date format changes how the values render (documented).
  • Marshallers registered with JSON.registerObjectMarshaller(...) still take precedence. The marshallers Grails 7 shipped are unchanged.

JSON views: the added types are registrations of one internal SimpleTypeJsonConverter. A small DefaultJsonGenerator subclass, JsonViewGenerator, writes date map keys with the configured dateFormat, timeZone and locale, as the values are written. Keys that format to the same text are all kept, as Jackson keeps them.

  • The default grails.views.json.generator.dateFormat becomes yyyy-MM-dd'T'HH:mm:ss.SSSXXX. It writes the same UTC instant as Spring Boot in the default GMT, and a configured timeZone with its offset in hours and minutes, as spring.jackson.time-zone does. The single X of Grails 7 wrote only the hours, so a zone such as Asia/Kolkata named an instant 30 minutes off.
  • As with spring.jackson.date-format, the date format does not apply to java.sql.Time values.
  • g.render (DefaultGrailsJsonViewHelper) now goes through the generator for these types:
    • a date or time type the generator has a converter for counts as a simple value; before, Year/Duration/ZoneId… rendered as {}. A converter an application registers for any other type does not change how g.render renders it: its template, or its properties, still apply;
    • the enum name() shortcut no longer bypasses a converter, so an application's converter for Month applies to a domain property too;
    • 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. The converters Grails 7 shipped are unchanged.

Data binding: a Month binds from its number, as Spring Boot writes and reads it, as well as from its name.

  • Before, {"month":9} bound 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.
  • A number that is not a whole month from 1 to 12, such as 13 or 9.7, is a binding error.

Dates and times bind back as they render, too:

  • A date and time in its ISO 8601 form with an offset, as Grails, JSON views and Spring Boot render one, is bound to what it names before the grails.databinding.dateFormats are tried.
  • A Date is read in the calendar it is written in, Julian before 1582, as it renders.
  • The java.time types try their ISO 8601 form first, through the new Jsr310DateValueConverter.convert(value, iso, callable). convert(value, callable) still passes the String pattern to its closure, as in Grails 7.
  • A format that reads all of a value is used before an earlier one that reads only its start. A value no format reads all of is bound as in Grails 7, by the first format that reads its start.

Before, on a server outside UTC:

  • 2024-05-01T10:00:00Z was bound in the server's zone.
  • A +02:00 offset without milliseconds was dropped.
  • .5 was read as 5 milliseconds.
  • An OffsetDateTime or ZonedDateTime, and a LocalDateTime or LocalTime with a fraction, could not be bound from the form Grails renders it in.

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, Month among them, still render by name() (upgrade guide §8), as the XML converter writes them. Jackson 3 writes an enum's toString(), and a Month as its number.
    • They differ for Month ("SEPTEMBER" here, 9 in Spring Boot) and for enums that override toString(), e.g. ChronoUnit.SECONDS renders as "SECONDS" here and "Seconds" in Spring Boot.
    • The guides show a marshaller and a JSON views converter that render a Month as its number.
  • OffsetTime keeps its seconds and writes a fraction with only the digits it needs ("03:00:00-03:00"), as JSON views have since Grails 7. Jackson writes "03:00-03:00".
  • JSON views dates before 1 AD or after 9999 render as in Grails 7.
  • HalJsonRenderer (application/hal+json) uses Groovy's default JSON generator and is untouched.
  • GraphQL's date scalars read grails.databinding.dateFormats as before.
  • Marshaller priorities: the default JSON marshallers' implicit negative priorities shift because of the new entries. Custom marshallers at the default priority (0) are unaffected.

These notes are in the upgrade guide.

Documentation

  • Upgrade guide §77:
    • 77.1: what 8.0.0 restores from Grails 7 after 8.0.0-RC1;
    • 77.2: the values that render differently than in Grails 7, including the JSON views offset, with Month binding and the JSON views notes;
    • 77.3: the types Grails 7 did not render as values;
    • 77.4: how to keep the Grails 7 rendering of java.sql.Time and date map keys, and how to render a Month as its number.
  • Upgrade guide §78 covers the date binding change.
  • The upgrade guide also corrects the level and number of a few unrelated subsections: 1.1 and 17.1 become level-5 headings, 35.1 becomes 37.1, and 51.x becomes 52.x, to match their sections.
  • New "Date and Time Rendering" sections in the REST default renderers guide and the JSON views configuration guide, and a paragraph on ISO 8601 values in the data binding 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(...).
    • They use one shared set of values in grails-web-common's test fixtures (DateTimeValues), including BC and 5-digit years and 15 map-key types.
    • The values listed under "Not changed" are pinned literally instead.
  • Literal tests pin:
  • DateTimeHelperRenderingSpec covers:
    • g.render of a domain Month, a POGO and a map, compared with Jackson;
    • timeZone compared with Jackson's defaultTimeZone, including Asia/Kolkata and Asia/Kathmandu, dateFormat with java.sql.Time, and colliding date keys;
    • an application converter for a POGO loaded through ServiceLoader: g.render still renders that POGO property by property.
  • Binding:
    • MonthBindingSpec binds 9, "9" and "SEPTEMBER" through GrailsWebDataBinder, and rejects 13 and 9.7.
    • Jsr310ConvertersConfigurationSpec checks that a subclass written for Grails 7 (convert(value) { String format -> … }) reads only the configured formats.
    • DateConversionHelperSpec pins the Grails 7 result for each value no format reads all of, with the default formats in America/Denver.
  • Date round trip: DateTimeRoundTripBindingSpec renders values with as JSON and binds them back through a controller, with the JVM in America/Denver.
    • It covers a Date, dates before 1582, an OffsetDateTime, a ZonedDateTime, a LocalDateTime with a fraction and a java.sql.Time.
    • It also binds client-sent offsets and fractions, and a value a format reads only the start of.
  • Test runs: 1,326 tests, 0 failures, run fresh without the build cache:
    • grails-web-common 116, grails-converters 194, grails-views-gson 266, grails-views-core 2;
    • grails-databinding-core 92, grails-databinding 49, grails-web-databinding 58;
    • grails-test-suite-web 443, grails-test-suite-persistence 106.
  • Code style: codeStyle passes for grails-web-common, grails-converters, grails-views-gson, grails-databinding-core and grails-databinding.
  • End to end: the datetime demo app on 8.0.0-SNAPSHOT with grails-web-common, grails-converters, grails-views-gson, grails-databinding-core and grails-databinding from this branch returns JSON byte-identical to a Spring Boot 4.1.1 app, through both respond and a JSON view using g.render, for every date and time field but month, which renders by its name.

Grails 8 now writes date and time values in JSON exactly as Spring Boot
4.1's default Jackson JsonMapper does, in both grails.converters.JSON
and JSON views, including g.render.

Fixes apache#16406: the JSON and XML DateMarshaller called toInstant(), which
java.sql.Date and java.sql.Time do not support, and the JSON marshallers
dropped a zero millisecond fraction and wrote every nanosecond of a
Timestamp.

- Date, java.sql.Date, Timestamp, Calendar and XMLGregorianCalendar
  render as a UTC instant with millisecond precision, formatted by the
  new JsonDateFormat, which follows Jackson's StdDateFormat
- java.sql.Time renders as HH:mm:ss and LocalTime as ISO_LOCAL_TIME
- OffsetTime, YearMonth, MonthDay, Duration, Period, ZoneId and
  javax.xml.datatype.Duration render as their ISO-8601 strings, Year
  and Month as numbers, TimeZone as its ID
- Date, Calendar and ZonedDateTime map keys render as Jackson writes
  them
- JSON views write dates in the configured timeZone with its offset
  unless grails.views.json.generator.dateFormat is set, and g.render
  writes these types through the view's generator
- A Month binds from its number, so rendered JSON binds back instead
  of binding by ordinal

The differences from Grails 7 are listed in the 8.0 upgrade guide.
@codecov

codecov Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.62304% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.3488%. Comparing base (1ef26cb) to head (ede7ecf).
⚠️ Report is 45 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...y/grails/plugin/json/view/JsonViewGenerator.groovy 80.5556% 2 Missing and 5 partials ⚠️
...son/view/api/internal/DefaultJsonViewHelper.groovy 50.0000% 0 Missing and 3 partials ⚠️
...ng/converters/Jsr310ConvertersConfiguration.groovy 91.3044% 0 Missing and 2 partials ⚠️
...ew/api/internal/DefaultGrailsJsonViewHelper.groovy 77.7778% 0 Missing and 2 partials ⚠️
...erters/src/main/groovy/grails/converters/JSON.java 50.0000% 0 Missing and 1 partial ⚠️
...databinding/converters/DateConversionHelper.groovy 95.6522% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16411        +/-   ##
==================================================
+ Coverage     58.2100%   58.3488%   +0.1388%     
- Complexity      23964      24073       +109     
==================================================
  Files            2170       2174         +4     
  Lines          106815     106979       +164     
  Branches        19451      19479        +28     
==================================================
+ Hits            62177      62421       +244     
+ Misses          35825      35734        -91     
- Partials         8813       8824        +11     
Files with missing lines Coverage Δ
...nverters/internal/json/SimpleTypeMarshaller.groovy 100.0000% <100.0000%> (ø)
...figuration/ConvertersConfigurationInitializer.java 89.9281% <100.0000%> (+2.6265%) ⬆️
...converters/marshaller/json/CalendarMarshaller.java 86.6667% <100.0000%> (ø)
...web/converters/marshaller/json/DateMarshaller.java 86.6667% <100.0000%> (ø)
.../web/converters/marshaller/json/MapMarshaller.java 92.3077% <100.0000%> (ø)
.../web/converters/marshaller/xml/DateMarshaller.java 90.0000% <100.0000%> (+1.7647%) ⬆️
...ing/converters/DefaultConvertersConfiguration.java 100.0000% <100.0000%> (ø)
...ils/plugin/json/view/JsonViewTemplateEngine.groovy 89.2857% <100.0000%> (+4.1793%) ⬆️
...internal/converters/SimpleTypeJsonConverter.groovy 100.0000% <100.0000%> (ø)
...ain/groovy/org/grails/web/json/JsonDateFormat.java 100.0000% <100.0000%> (ø)
... and 6 more

... and 14 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

codeconsole added a commit to codeconsole/grails-core that referenced this pull request Sep 26, 2026
@codeconsole codeconsole changed the title Render dates and times in JSON the same way as Spring Boot Render dates and times in JSON the same way as Spring Boot, and bind them back as rendered Sep 26, 2026
@codeconsole codeconsole added this to the grails:8.0.0-RC2 milestone Sep 26, 2026
…dering-8.0.x

# Conflicts:
#	grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@codeconsole codeconsole changed the title Render dates and times in JSON the same way as Spring Boot, and bind them back as rendered 8.0 (fix): Render dates and times in JSON the same way as Spring Boot, and bind them back as rendered Sep 26, 2026

@matrei matrei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at head 65a5a5b215 against 8.0.x (334007a8b5); merges cleanly.

What I ran, fresh (cleanTest + --no-build-cache, checked result XML timestamps):

Module Tests Failures
grails-web-common 116 0
grails-converters 195 0
grails-views-gson 266 0
grails-views-core 2 0
grails-databinding-core 84 0
grails-databinding 42 0
grails-web-databinding 58 0
grails-test-suite-web 443 0
grails-test-suite-persistence (MonthBindingSpec) 4 0

codeStyle passes for grails-web-common, grails-converters, grails-views-gson, grails-databinding-core and grails-databinding.

Overall this looks good. The #16406 crash is fixed where it started, since the formatting now works from epoch millis. The Jackson-as-oracle parity specs are a strong way to hold the "same as Spring Boot" claim. I have one question about scope and a few smaller items.

Question

1. g.render now defers to any generator converter, not only the date/time ones.
DefaultJsonViewHelper.groovy:137, DefaultGrailsJsonViewHelper.groovy:491/494

hasConverter(type) asks the view's generator, which also holds the converters that applications register through ServiceLoader. As a result, when an application has a converter for one of its own types (a POGO or a domain class), that type now counts as a simple value in g.render:

  • As a map value or collection element (handleValue, line 336), it's written by the converter. Before, it went through renderTemplateOrDefault, so a _foo.gson template for the type is now bypassed.
  • As a POGO property (processSimple, line 412), it's written by the converter instead of being rendered property by property.

This is arguably more consistent, because json { foo value } already used the converter. It is still a behaviour change outside dates and times. If it's intended, could §76 say so? If not, hasConverter could be limited to the built-in converter types.

Minor

2. grails.converters.JSON date map keys ignore the configured Date marshaller. MapMarshaller.java:47, JSON.java:551

Keys always go through JsonDateFormat.formatKey, which is fixed UTC ISO. So with grails.converters.json.date = 'javascript', a DateMarshaller(format), or JSON.registerObjectMarshaller(Date) {...}, values follow the configuration and keys don't. It isn't a regression, since before the key was toString(). But the REST guide says keys render "the same way as those values", and JSON views do apply dateFormat to keys. Either qualify that sentence or note it in the upgrade guide.

3. Jsr310DateValueConverter.convert(Object, Closure) was replaced by convert(Object, DateTimeFormatter, Closure).
Jsr310ConvertersConfiguration.groovy:447

It's a public abstract class, so anything that subclasses it breaks. Being non-static inner makes that unlikely, but keeping the old signature as an overload that delegates would cost nothing.

4. monthValueConverter accepts non-integral numbers.
Jsr310ConvertersConfiguration.groovy:363

9.7 binds to SEPTEMBER through intValue(). Rejecting a Number with a fractional part would match how 13 is rejected.

5. grails-views-gson now uses org.grails.web.json.JsonDateFormat without declaring grails-web-common.
It resolves transitively today (through views-core/rest-transforms → web-core). An explicit implementation project(':grails-web-common') would follow the module's own convention of listing what it uses.

Nits

  • DateTimeValues.groovy exists twice with identical content (136 lines), in grails-converters and grails-views-gson tests. grails-web-common already has testFixtures, so it could live there once.

  • The wording is hard to parse in a few places:

    • dataBinding.adoc:737: "a value written as ISO 8601 writes the type is bound as it names"
    • upgrading80x.adoc:4282: "written as ISO 8601 writes it"
    • the Javadoc of Jsr310DateValueConverter.convert

    Something like "A value in the ISO 8601 form of the target type (as Grails renders it in JSON) is bound first, before any of those formats are tried" reads more easily.

  • The upgrade guide also fixes unrelated subsection levels and numbers (1.1, 17.1, 35.1→37.1, 51.x→52.x). The fixes are correct and fine to keep, but the PR description doesn't mention them.

  • The PR description refers to upgrade guide §75/§76. After the merge from 8.0.x these are §76/§77.

Verified as correct

  • JsonDateFormat matches Jackson's StdDateFormat:
    • it works from epoch millis, so java.sql.Date/Time never call toInstant();
    • it reads fields from GregorianCalendar, so it uses the Julian calendar before 1582;
    • BC years follow _formatBCEYear (1 BC → +0000, 2 BC → -0001);
    • years after 9999 get +;
    • the offset is written in minutes.
  • The parity specs include Month, Year, BC and 5-digit years, and pass against Jackson 3's default JsonMapper.
  • Marshaller ordering is correct: SqlTimeMarshaller comes before DateMarshaller (only in default date mode; javascript mode still writes new Date(...) for Time), and MonthMarshaller comes before SimpleEnumMarshaller. The priority shift is documented.
  • For JSON views:
    • JsonViewGenerator.writeMap keeps DefaultJsonGenerator's null-key, excluded-value and excluded-field handling;
    • it takes the custom path only when a date key is present;
    • Calendar values reach the overridden writeDate through DefaultJsonGenerator.writeObject;
    • an unset dateFormat with the default GMT gives the same output as the old default pattern, and the change for non-UTC timeZone (-04 → -04:00) is documented.
  • DateConversionHelper:
    • the ISO path applies only to values with an offset (ZonedDateTime.parse), so datetime-local style values still go to the configured formats;
    • the Julian-calendar read round-trips what JsonDateFormat writes before 1582;
    • parseAll rejects a value that was only partly read;
    • the SimpleDataBinderSpec change fixes a test that only passed in a UTC JVM.
  • The specs that change TimeZone.default restore it in cleanup.
  • The docs reference only public API. §8 and §24 are the right cross-references.

…r fixes

- g.render treats a value as simple only when it is a date or time that the
  view's generator has a converter for. A converter an application registers
  for its own type no longer bypasses that type's template or property
  rendering (hasConverter becomes hasDateTimeConverter).
- Keep Jsr310DateValueConverter.convert(value, callable), which reads only
  the configured formats, next to convert(value, iso, callable).
- monthValueConverter rejects a number with a fractional part, such as 9.7,
  as it rejects 13.
- grails-views-gson declares grails-web-common, for JsonDateFormat.
- DateTimeValues lives once, in grails-web-common's test fixtures.
- The guide says that date map keys keep the default form with a registered
  Date marshaller or the javascript date format, and rewords the ISO 8601
  data binding sentences.

@matrei matrei left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at head a7d265dbef. It still merges cleanly into 8.0.x (334007a8b5).

Thanks, all five points and the nits are addressed.

What I ran, fresh (cleanTest + --no-build-cache, checked result XML timestamps):

Module Tests Failures
grails-web-common 116 0
grails-converters 195 0
grails-views-gson 267 0
grails-views-core 2 0
grails-databinding-core 84 0
grails-databinding 49 0
grails-web-databinding 58 0
grails-test-suite-web 443 0
grails-test-suite-persistence 106 0

codeStyle passes for grails-web-common, grails-converters, grails-views-gson, grails-databinding-core and grails-databinding.

Checked

  1. g.render and application converters:
    • hasDateTimeConverter covers the type of every converter JSON views add: Month, Year, YearMonth and MonthDay through TemporalAccessor, Duration and Period through TemporalAmount, java.sql.Time through Date, plus ZoneId, TimeZone, XMLGregorianCalendar and the XML Duration.
    • An application converter for its own type now leaves g.render as it was before this PR. That includes the enum name() shortcut, which again ignores converters for enums other than Month.
    • I tested the new ServiceLoader test against the old behaviour. With hasDateTimeConverter changed back to accept any converter, the test fails, and it passes again with the PR's code.
  2. Date map keys: the REST guide and upgrade guide §76 now say that keys keep the default form when a Date marshaller or javascript is configured.
  3. convert(value, callable): it's back, and reads only the configured formats. FormatsOnlyLocalTimeConverter shows that a subclass using it still compiles and doesn't pick up the ISO 8601 path.
  4. monthValueConverter:
    • It uses intValueExact(), so 9.7, 9.5f and NaN are rejected, and 9.0 still binds to SEPTEMBER.
    • MonthBindingSpec checks that 9.7 gives a binding error through GrailsWebDataBinder.
  5. grails-web-common: grails-views-gson now declares it.

The nits are addressed too:

  • DateTimeValues now lives once, in grails-web-common test fixtures.
  • The ISO 8601 sentences in dataBinding.adoc, upgrade guide §77 and the Javadoc read clearly now.
  • The PR description has the right section numbers and mentions the subsection fixes.

Nit

  • DateTimeHelperRenderingSpec creates a URLClassLoader over the @TempDir and never closes it. Wrapping it in try/finally with classLoader.close() (or a cleanup: block) would avoid holding the directory open, which on Windows can make the @TempDir cleanup fail.

LGTM.

The URLClassLoader over the @tempdir is closed in a cleanup block, so that the temporary directory can be deleted on Windows too.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Thanks! Closed the class loader in a cleanup: block in 030c485.

@testlens-app

This comment has been minimized.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough work here, and for the very complete upgrade notes. I checked the branch against the 7.0.x sources for every touched type, and ran the six touched modules locally (769 tests, 0 failures). The #16406 fix is solid. My concern is about what else rides along with it, given where we are in the release cycle.

What this PR contains, split by kind of change

1. Regression fix: restores the Grails 7 output. The DateMarshaller/CalendarMarshaller changes, JsonDateFormat, the XML toInstant() guard, and the .SSS millisecond restoration. Date, Calendar, java.sql.Date and Timestamp render exactly as in 7.0.x again. This is what #16406 asks for, it carries no compatibility risk relative to 7, and it belongs in the next RC.

2. New behavior changes relative to Grails 7. These are Spring Boot / Jackson parity decisions rather than fixes. The upgrade guide documents all of them honestly, but each one changes output or binding that worked in 7.0.x:

  • java.time.Month renders as a number (9). 7.x rendered the enum object, 8.0 already documents "SEPTEMBER" (section 8), and this would be the third shape for one type across two releases. It also needs the new monthValueConverter to round-trip.
  • java.sql.Time renders as "01:48:46" via toString() in the JVM default zone. 7.x rendered it as a UTC instant in both grails.converters.JSON and JSON views. This is the one type that rendered fine in 7 and now changes both shape and time-zone semantics.
  • Date/Calendar map keys switch from toString() to ISO, in the converters, the JSON builder and JSON views.
  • JSON views with a configured timeZone change the offset form from -04 to -04:00, and dateFormat no longer applies to java.sql.Time.
  • OffsetTime in JSON views drops zero seconds.
  • Date binding becomes strict: a configured format must consume the whole value (details below).
  • Jsr310DateValueConverter.convert(value, closure) now passes a DateTimeFormatter to the closure instead of the String pattern.

The remaining new marshallers/converters (Year, YearMonth, Duration, ZoneId, TimeZone, ...) replace property-bag output or {} in 7, and application marshallers still take precedence, so I consider those low risk.

Binding: what a 7.x app sees

I replayed the 7.0.x DateConversionHelper loop and the new one over the default dateFormats, with the server in America/Denver:

Input Grails 7 This PR
2024-05-01T10:00:00Z 16:00Z (read in server zone) 10:00Z
2024-05-01T10:00:00+02:00 16:00Z (offset ignored) 08:00Z
2024-05-01T10:00:00.5Z .005 .500
2024-05-01T10:00 (HTML datetime-local) midnight, time lost binding error
2024-05-01 10:00:00 midnight, time lost binding error
2024-05-01T10:00:00.123 (no zone) 10:00 local, millis dropped binding error
2024-05-01 10:00:00Z midnight binding error

The first three rows are correctness fixes, but they silently shift persisted instants for any client that sends Z without milliseconds and was relying on the server-zone reading. The last four are the new whole-value rule. Three of those were silent data loss in 7, but the zone-less fraction case gave a usable answer in 7 and is now a hard error.

Proposal

We are on 8.0.0-RC1. Anything that lands after RC2 forces an RC3, so I would like to keep RC2 to the regression fix and take the parity changes to a wider discussion before deciding whether they are 8.0 or 8.1 material.

Concretely, I suggest splitting this PR:

  • PR A (for RC2): group 1 above, plus the new marshallers/converters for the types that were unrenderable or rendered as property bags in 7. No Month, java.sql.Time, map-key or binding changes.
  • PR B (needs discussion on the weekly / dev list): Month as a number plus its binding converter, java.sql.Time as toString(), date map keys, the JSON views timeZone/OffsetTime changes, and strict binding.

For PR B, two mitigations would remove most of the compatibility cost if we do decide to take it:

  • Binding: try ISO first, then whole-value formats, then fall back to the 7.x prefix read as a last resort. That keeps every row above that 7 bound while still fixing the offset and fraction rows.
  • Month: keep "SEPTEMBER" for 8.0 and still add monthValueConverter (it only claims numeric input), so clients sending either form bind.

Happy to help with the split if that is useful.


public void marshalObject(Object object, JSON converter) throws ConverterException {
try {
converter.getWriter().value(((Month) object).getValue());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the third shape for Month across two releases: the enum object in 7.x, "SEPTEMBER" per section 8 of the 8.0 guide, and now 9. It is also the only enum that behaves differently from every other enum, and it needs monthValueConverter just to round-trip.

I would keep "SEPTEMBER" for 8.0 and leave the numeric form for the wider parity discussion. monthValueConverter can stay either way, since it only claims numeric input and makes both forms bind.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept as a number.

  • On 9.0, Write grails.converters.JSON through Spring Boot's JsonMapper #16414 renders Month through Boot's JsonMapper as 9 anyway, so "SEPTEMBER" in 8.0 would mean a second change in 9 for grails.converters.JSON users.
  • JSON views users see a single change, since 7 already wrote "SEPTEMBER".
  • monthValueConverter binds both forms, and §76.4 shows the opt-out.
  • The registration is in ConvertersConfigurationInitializer now (f54c009).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two points the reply doesn't cover:

  • Apps that followed the 7.0.2 deprecation notice already render "SEPTEMBER". 7.0.x reads grails.converters.json.enum.format: simple, the setting §8 now tells people they can remove. Those apps render Month as "SEPTEMBER" in grails.converters.JSON today, as JSON views do. For them, and for every JSON views user, 9 is a new change. It also breaks, for this one enum, the §8 contract we announced ("enums are serialized as simple string values"). The XML converter still writes SEPTEMBER, so the same object would render as 9 in JSON and SEPTEMBER in XML.
  • Write grails.converters.JSON through Spring Boot's JsonMapper #16414 is an open PR against 9.0.x, not a decided direction. Whether 9.0 renders grails.converters.JSON through Boot's JsonMapper is a 9.0 question, and a PR still under review shouldn't settle 8.0 behavior.

Please keep Month rendering as "SEPTEMBER" in 8.0:

  • Drop the Month registration from ConvertersConfigurationInitializer and JsonViewTemplateEngine.
  • Keep monthValueConverter, which binds 9, "9" and "SEPTEMBER" and is a good change either way.

I tried this locally. The only failures are the assertions that pin Month as a number, in JsonDateTimeRenderingSpec, DateTimeRenderingSpec and DateTimeHelperRenderingSpec. MonthBindingSpec and DateTimeRoundTripBindingSpec still pass.

On the docs side, the Month rows in §76.2, the Month snippet in §76.4, and the Month entries in defaultRenderers.adoc and jsonConfiguration.adoc come out. The §76.4 enum note would then cover Month too: Grails renders every enum by name(), where Jackson 3 writes Month as its number.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f8ef378. Month renders as "SEPTEMBER" in both renderers, and monthValueConverter stays.

  • The Month rows and snippets are out of the guides, and the §76.4 enum note covers Month.
  • The guides show a marshaller and a JSON views converter that render its number, as Spring Boot does, and tests cover both.


public void marshalObject(Object object, JSON converter) throws ConverterException {
try {
converter.getWriter().value(object.toString());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In 7.0.x a java.sql.Time went through DateMarshaller and rendered as a UTC instant ("1970-01-01T01:48:46.000Z"), and JSON views did the same through writeDate. This changes it to a local time-of-day in the JVM default zone, so both the shape and the zone semantics move for a type that rendered fine in 7.

Jackson does write toString() here, so the parity claim is right, but this is not part of the #16406 regression. I would leave Time on the Date path for RC2 and decide the parity question separately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept.

  • The 7.x form doesn't bind back: "1970-01-01T08:48:46.000Z" is a typeMismatch for a java.sql.Time property.
  • "01:48:46" binds back to the same Time (bd89825).
  • §76.4 shows a marshaller that restores the 7.x string, and a test covers it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: binding "1970-01-01T08:48:46.000Z" to a java.sql.Time property is a typeMismatch (I ran it), so the 7.x form never round-tripped. I'm fine keeping toString().

One precision point for §76.2 (line 4210): "binds back to the same java.sql.Time" holds only for a Time on 1970-01-01 with zero milliseconds, like the Time.valueOf('01:48:46') in the test. new Time(1759909726407L) (2025-10-08T07:48:46.407Z) binds back as that time of day on 1970-01-01, without the milliseconds, as with Jackson. "binds back to the same time of day, to the second" would be accurate.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 76d01c9: "which binds back to the same time of day, to the second, as with Spring Boot".

try {
formatter.lenient = dateParsingLenient
dateValue = formatter.parse((String) value)
dateValue = parseAll(formatter, (String) value)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the part of the binding change I think needs discussion before it goes into an RC. With the default dateFormats, which still include yyyy-MM-dd, the whole-value rule turns these 7.x results into binding errors:

Input 7.0.x now
2024-05-01T10:00 (HTML datetime-local) midnight error
2024-05-01 10:00:00 midnight error
2024-05-01T10:00:00.123 (no zone) 10:00 local, millis dropped error

The first two were silent data loss in 7, so an error is arguably better, but the third gave a usable value. A cheap way to keep 7.x compatibility while still fixing the offset and fraction cases: ISO first, then whole-value formats, then fall back to the 7.x prefix read (formatter.parse(value)) only when nothing else matched.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done as you suggested, in 778d072: ISO, then a format that reads the whole value, then the 7.x read.

  • Rows 4–7 bind as in 7.x again.
  • The fallback also covers …T10:00:00.123+0200, a custom format matching only the start, and Timestamp#toString() with nanos, which the whole-value rule had also broken.

if (dateValue == null) {
try {
dateValue = (T) callable.call(format)
dateValue = (T) callable.call(DateTimeFormatter.ofPattern(format))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The closure passed to convert(value, callable) used to receive the String pattern and now receives a DateTimeFormatter. The subclass test was rewritten for that, but a 7.x subclass written as convert(value) { String format -> X.parse(value, DateTimeFormatter.ofPattern(format)) } now fails at runtime with a MissingMethodException.

This is an org.grails internal, so it may be acceptable, but if we keep it the upgrade guide should say so. Alternatively keep passing the pattern string and build the formatter inside the closure as before.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 77fa0e0.

  • convert(value, callable) passes the String pattern again, so a 7.x subclass works unchanged.
  • FormatsOnlyLocalTimeConverter in the spec is written the 7.x way now ({ String format -> … }).

render(file: new File(absolutePath), inline: true)
----

==== 76. JSON Rendering of Dates and Times Matches Spring Boot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The table here is accurate and I appreciate how complete it is. One suggestion once the split is settled: separate what 8.0.0 restores from 7 (the .SSS millisecond form and the java.sql types) from what it changes relative to 7, so a reader upgrading from 7.x can tell at a glance which rows require action.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in ecee9bf: §76.1 is what 8.0.0 restores from 7, §76.2 what renders differently than in 7, §76.3 what 7 didn't render as a value, and §76.4 how to keep the 7 rendering.

@sbglasius sbglasius left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated code review: a few hunk-visible correctness inconsistencies spotted while reviewing the date/time rendering changes. Overall the change is thoroughly tested; these are edge cases worth a second look.

MappingFactory mappingFactory = jsonView.mappingContext?.mappingFactory
if (mappingFactory != null) {
return mappingFactory.isSimpleType(propertyType) || (value instanceof Enum) || (value instanceof Map)
return mappingFactory.isSimpleType(propertyType) || (value instanceof Enum) || (value instanceof Map) || hasDateTimeConverter(propertyType)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This override of isSimpleType(Class propertyType, value) checks hasDateTimeConverter(propertyType) (the declared type), whereas the base DefaultJsonViewHelper.isSimpleType and the sibling check in processSimpleProperty (!hasDateTimeConverter(value.getClass())) both use the runtime value's class.

For a GORM property declared as a supertype/interface (e.g. Object) that at runtime holds a Month/Year/Duration value, this would evaluate hasDateTimeConverter against the non-date declared type and return false, so the property would not be classified as simple and would be traversed as a complex object instead of going through the registered date/time converter. Should this use value?.getClass() ?: propertyType like the other call sites?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That line is in isSimpleValue(Object value), where propertyType is value.getClass(). So it checks the runtime type, as the other call sites do.


private static boolean isDateTimeType(Class<?> type) {
TemporalAccessor.isAssignableFrom(type) || TemporalAmount.isAssignableFrom(type) ||
ZoneId.isAssignableFrom(type) || TimeZone.isAssignableFrom(type) || Date.isAssignableFrom(type) ||

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isDateTimeType checks Date.isAssignableFrom(type) but doesn't include Calendar, even though Calendar is treated symmetrically with Date everywhere else in this file (formatMapKey, hasDateKey/isDateKey, formatDate). As written, hasDateTimeConverter(Calendar) always returns false, even though Calendar values are in fact written through the date/time formatting path. Was the omission intentional?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional.

  • No converter handles Calendar, so hasDateTimeConverter(Calendar) would be false even if isDateTimeType included it.
  • Calendar is already a MappingFactory simple type, and the generator writes it with the date format.

Map.Entry element = (Map.Entry) o;
writer.key(String.valueOf(element.getKey())); //.value(element.getValue());
Object elementKey = element.getKey();
writer.key(elementKey == null ? "null" : JsonDateFormat.formatKey(elementKey));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For a null key this writes the literal string "null", but MapMarshaller.java (touched in this same PR, also now calling JsonDateFormat.formatKey) silently skips map entries whose key is null instead. That means the same map with a null key serializes differently depending on whether it goes through the JSON builder DSL (json.build { keyed(...) }) or through ([...] as JSON)/MapMarshaller. Not introduced by this line specifically (the null handling predates this diff), but worth reconciling now that both paths route through JsonDateFormat.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both paths behave as in 7.x: the builder wrote String.valueOf(key), which gives "null", and MapMarshaller skipped the entry. This PR only changes how date keys are written.

borinquenkid added a commit that referenced this pull request Sep 28, 2026
#16411 added a dedicated ObjectMarshaller/JsonGenerator.Converter class
per java.time/java.sql/javax.xml value type, following the pre-existing
one-class-per-type convention in these packages. Each of those new
classes was 100% boilerplate: an instanceof/isAssignableFrom check and
one line computing the JSON value.

Replace the 13 single-type JSON marshaller classes and 10 single-type
JSON-views converter classes the PR added with two small generic
implementations, SimpleTypeMarshaller<T> and SimpleTypeJsonConverter,
each parameterized by the type and a value-extracting function.
Registration order (and therefore marshaller/converter priority) is
unchanged. Pre-existing marshallers/converters that predate #16411 are
left as-is.

Verified behavior-preserving: grails-converters (195 tests) and
grails-views-gson (266 tests) pass unchanged, including the parity
specs added by #16411 that compare output against a real Jackson
JsonMapper. checkstyle and CodeNarc are clean for both modules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
borinquenkid added a commit that referenced this pull request Sep 28, 2026
Extend the previous commit's generic marshaller/converter to the 5
JSON marshallers and 8 JSON-views converters that predate #16411
(Instant, LocalDate, LocalDateTime, LocalTime, OffsetDateTime,
OffsetTime, Period, ZonedDateTime), removing the same one-line-of-logic
boilerplate from the pre-existing code, not just the code #16411 added.

These files carried individual @author tags. The repo's established
convention is not to carry personal attribution in source (recent
examples: e6fa4eb, 59d78b2, b7ab39b, 87fb269, and
17debfa removing a real author's name/email from a plugin
descriptor "as this information is not necessary for core plugins") —
git history is the attribution record, so dropping them here follows
existing practice rather than breaking from it.

Verified behavior-preserving the same way as the previous commit:
grails-converters (195 tests) and grails-views-gson (266 tests) pass
unchanged, checkstyle/CodeNarc clean on both modules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
borinquenkid added a commit that referenced this pull request Sep 28, 2026
jdaugherty's review on #16411 proposes splitting the PR: a PR A for
RC2 (the #16406 regression fix plus new marshallers for types that
were unrenderable in 7.x, called out as low risk) and a PR B, needing
wider discussion, for the actual Spring Boot/Jackson parity behavior
changes (Month as a number, java.sql.Time as toString() with a
different time zone, and the JSON-views OffsetTime format change,
among others not touched here).

Rewrite the dedup work (previously 3 commits, now squashed into this
one and its test-coverage follow-up) to leave every file jdaugherty
flagged as PR-B material completely untouched:
- MonthMarshaller.java / MonthJsonConverter.groovy (Month as a number)
- SqlTimeMarshaller.java / SqlTimeJsonConverter.groovy (java.sql.Time
  as toString())
- OffsetTimeJsonConverter.groovy (the JSON-views format change from
  ISO_OFFSET_TIME to toString(), predates this collapse but is the
  same compatibility-risk item)

Every other single-type marshaller/converter this PR added or that
predates it — Year, YearMonth, MonthDay, Duration, Period, ZoneId,
TimeZone, XMLGregorianCalendar, the javax.xml Duration, LocalTime,
OffsetTime (grails.converters.JSON only, a new addition with no
pre-8.0 behavior to diverge from), Instant, LocalDate, LocalDateTime,
OffsetDateTime and ZonedDateTime — collapses into SimpleTypeMarshaller
(grails-converters) / SimpleTypeJsonConverter (grails-views-gson) as
before.

Zero diff on the five untouched files, confirmed with git diff --stat.
This makes the branch apply the same way regardless of whether it
lands on the full PR or on a future split-off PR A: it never creates
or resolves a conflict against the parts that split would carve out.

Verified: grails-converters (199 tests) and grails-views-gson
(270 tests) pass, checkstyle/CodeNarc clean on both modules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@borinquenkid

Copy link
Copy Markdown
Member

There's a branch, refactor/16411-marshaller-dedup (based on this PR's current head), that consolidates the single-type ObjectMarshaller/JsonGenerator.Converter classes this PR adds — and several that predate it — into two small generic classes:

  • SimpleTypeMarshaller<T> (grails-converters) — takes a Class<T> and a Function<T, Object> value extractor.
  • SimpleTypeJsonConverter (grails-views-gson) — the same idea for JSON views.

Year, YearMonth, MonthDay, Duration, Period, ZoneId, TimeZone, XMLGregorianCalendar, the javax.xml.datatype.Duration, LocalTime, OffsetTime (JSON only), plus the pre-existing Instant/LocalDate/LocalDateTime/OffsetDateTime/ZonedDateTime marshallers, were each ~45-50 lines of identical supports()/handles() plus a one-line format call. The branch replaces all of them with one-line registrations against the two generic classes instead. Net: 35 files changed, +264/-1414 lines. Registration order/priority and rendered output are unchanged — the existing suites pass as-is (199 grails-converters tests, 270 grails-views-gson tests), plus two new direct unit tests for the generic classes themselves, and checkstyle/CodeNarc are clean.

It deliberately leaves MonthMarshaller/MonthJsonConverter, SqlTimeMarshaller/SqlTimeJsonConverter, and the JSON-views OffsetTimeJsonConverter completely untouched (zero diff), so it doesn't interfere with the PR A / PR B split being discussed above — everything it touches is material either bucket would keep.

Given how much of this PR's diff is that repeated boilerplate, I'd highly suggest merging this consolidation in — either into this PR or as a fast-follow — since it meaningfully shrinks the surface reviewers have to read through.

…at that reads its start

A format that reads all of a value is still tried first, so it wins over an earlier format that
reads only the start of it. Values such as 2024-05-01T10:00, 2024-05-01T10:00:00.123 and
2024-05-01T10:00:00.123+0200 bind as they did in Grails 7 instead of failing.
…ails 7 did

A Jsr310DateValueConverter subclass written for Grails 7 receives the String pattern again. The
built-in converters use convert(value, iso, callable), which tries the ISO 8601 form first and
passes a DateTimeFormatter.
JSON views keep their default dateFormat, yyyy-MM-dd'T'HH:mm:ss.SSSX, so a configured timeZone
writes the offset as before, and OffsetTime keeps its ISO_OFFSET_TIME form, which
grails.converters.JSON now uses too. In the default GMT, dates render as Spring Boot renders them.
…aller and converter

The 13 JSON marshallers and 10 JSON view converters added for Month, java.sql.Time, LocalTime,
OffsetTime, Year, YearMonth, MonthDay, Duration, Period, ZoneId, TimeZone, XMLGregorianCalendar and
the XML Duration become registrations of an internal SimpleTypeMarshaller and
SimpleTypeJsonConverter, in the same order, so they render and take precedence as before. The
marshallers and converters that Grails 7 shipped are unchanged.

Adapted from Walter Duque de Estrada's refactor/16411-marshaller-dedup branch.
…at changes from Grails 7

The note lists what 8.0.0-RC1 changed and 8.0.0 restores, the values that render differently than in
Grails 7, and the types Grails 7 did not render as values, with a marshaller for java.sql.Time and
Month that keeps their Grails 7 rendering, tested as the guide shows it.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Kept as one PR. Your mitigations are in, and three changes stay, with the reasons below.

  • 778d072: binding tries ISO first, then a format that reads the whole value, then the 7.x read of the start of the value. Rows 4–7 of your table bind as in 7.x again, and rows 1–3 keep the fix. The fallback also covers three cases the whole-value rule had broken: …T10:00:00.123+0200, a custom format that matches only the start, and Timestamp#toString() with nanos.
  • 77fa0e0: convert(value, callable) passes the String pattern again, so a 7.x subclass works unchanged. The built-in converters use the new convert(value, iso, callable).
  • 71359da: JSON views keep the 7.x default dateFormat, so a configured timeZone writes -04 as before. OffsetTime keeps ISO_OFFSET_TIME (03:00:00-03:00) in views, and grails.converters.JSON now uses the same form.
  • ecee9bf: §76 is split into:
    • what 8.0.0 restores from 7 (76.1);
    • what renders differently than in 7 (76.2);
    • what 7 did not render as a value (76.3);
    • how to keep the 7 rendering (76.4, with a test).
  • f54c009: the marshallers and converters this PR adds are one generic marshaller and one generic converter now (from @borinquenkid's branch). The classes 7.0 shipped are unchanged.

Kept, since without them a Grails app renders these differently than the same Spring Boot app:

  • Month as 9.
  • java.sql.Time as "01:48:46".
    • The 7.x form put a 1970 date and a UTC shift on a time of day, and it doesn't bind back: "1970-01-01T08:48:46.000Z" is a typeMismatch for a java.sql.Time property.
    • "01:48:46" binds back to the same Time (bd89825).
  • Date map keys in ISO.
    • The 7.x key was Date#toString(), such as "Wed Oct 08 01:48:46 MDT 2025": server zone, second precision, and not parseable by a JSON client, next to ISO values.

Each of the three is one registration plus its docs. If the weekly prefers the 7.x form for any of them, it's one commit on this PR.

@codeconsole

Copy link
Copy Markdown
Contributor Author

@borinquenkid Thanks. f54c009 collapses the marshallers and converters this PR adds into SimpleTypeMarshaller and SimpleTypeJsonConverter, which are internal classes under org.apache.grails.*.internal. The 13 that 7.0 shipped stay, so no public class is removed in an RC:

  • Instant, LocalDate, LocalDateTime, OffsetDateTime, ZonedDateTime marshallers;
  • the grails.plugin.json.converters ones.

@codeconsole
codeconsole dismissed jdaugherty’s stale review September 28, 2026 15:51

addressed and pending re-review

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 3705691ce2, which merges cleanly into 8.0.x. Thanks for taking the mitigations on. I re-ran the touched modules fresh (grails-web-common, grails-converters, grails-views-gson, grails-databinding-core, grails-databinding, grails-web-databinding, grails-test-suite-web, MonthBindingSpec): 1,225 tests, 0 failures, and codeStyle is clean.

Checked against the 7.0.x sources:

  • Binding (778d072): ISO first, then a format that reads the whole value, then the 7.x read of the start of the value. Every row of my earlier table that bound in 7 binds the same way again, and the offset and fraction rows keep the fix. The Denver-zone rows in DateConversionHelperSpec cover exactly those cases.
  • convert(value, callable) (77fa0e0) passes the String pattern again, and the ISO overload is additive, so a 7.x subclass works unchanged.
  • JSON views (71359da): the dateFormat default, the unconditional options.dateFormat(...) and OffsetTimeJsonConverter match 7.0.x again, so a configured timeZone and OffsetTime render as in 7.
  • Generic marshaller and converter (f54c009): none of the 23 removed classes exists in 7.0.x or 8.0.0-RC1, so nothing public goes away, and the registration order is unchanged.
  • Upgrade guide (ecee9bf): the 76.1 to 76.4 split reads well.

Of the three changes you kept, the java.sql.Time change and the map keys hold up for me (details on the Time thread). Two things I'd like changed before this merges, each on its own thread:

  1. Month should keep rendering as "SEPTEMBER" in 8.0.
  2. The JSON views default date format writes the wrong instant for a configured timeZone whose offset is not a whole number of hours.


public void marshalObject(Object object, JSON converter) throws ConverterException {
try {
converter.getWriter().value(((Month) object).getValue());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two points the reply doesn't cover:

  • Apps that followed the 7.0.2 deprecation notice already render "SEPTEMBER". 7.0.x reads grails.converters.json.enum.format: simple, the setting §8 now tells people they can remove. Those apps render Month as "SEPTEMBER" in grails.converters.JSON today, as JSON views do. For them, and for every JSON views user, 9 is a new change. It also breaks, for this one enum, the §8 contract we announced ("enums are serialized as simple string values"). The XML converter still writes SEPTEMBER, so the same object would render as 9 in JSON and SEPTEMBER in XML.
  • Write grails.converters.JSON through Spring Boot's JsonMapper #16414 is an open PR against 9.0.x, not a decided direction. Whether 9.0 renders grails.converters.JSON through Boot's JsonMapper is a 9.0 question, and a PR still under review shouldn't settle 8.0 behavior.

Please keep Month rendering as "SEPTEMBER" in 8.0:

  • Drop the Month registration from ConvertersConfigurationInitializer and JsonViewTemplateEngine.
  • Keep monthValueConverter, which binds 9, "9" and "SEPTEMBER" and is a good change either way.

I tried this locally. The only failures are the assertions that pin Month as a number, in JsonDateTimeRenderingSpec, DateTimeRenderingSpec and DateTimeHelperRenderingSpec. MonthBindingSpec and DateTimeRoundTripBindingSpec still pass.

On the docs side, the Month rows in §76.2, the Month snippet in §76.4, and the Month entries in defaultRenderers.adoc and jsonConfiguration.adoc come out. The §76.4 enum note would then cover Month too: Grails renders every enum by name(), where Jackson 3 writes Month as its number.


public void marshalObject(Object object, JSON converter) throws ConverterException {
try {
converter.getWriter().value(object.toString());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: binding "1970-01-01T08:48:46.000Z" to a java.sql.Time property is a typeMismatch (I ran it), so the 7.x form never round-tripped. I'm fine keeping toString().

One precision point for §76.2 (line 4210): "binds back to the same java.sql.Time" holds only for a Time on 1970-01-01 with zero milliseconds, like the Time.valueOf('01:48:46') in the test. new Time(1759909726407L) (2025-10-08T07:48:46.407Z) binds back as that time of day on 1970-01-01, without the milliseconds, as with Jackson. "binds back to the same time of day, to the second" would be accurate.

* default {@link #timeZone} it writes a UTC instant with millisecond precision, such as
* {@code 2024-06-15T14:30:45.123Z}, as Spring Boot does.
*/
String dateFormat = /yyyy-MM-dd'T'HH:mm:ss.SSSX/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restoring the 7.x default, which I asked for, also restores a 7.x bug. With a single X, SimpleDateFormat writes only the hours of the offset ("any fraction of an hour is ignored"). So with a configured timeZone whose offset is not a whole number of hours, the rendered instant is wrong. I rendered 2025-10-08T07:48:46.407Z through a views generator in each zone and parsed the result back:

timeZone written with X (this default) parses back as written with XXX
Asia/Kolkata 2025-10-08T13:18:46.407+05 08:18:46.407Z, 30 minutes late 2025-10-08T13:18:46.407+05:30
Asia/Kathmandu 2025-10-08T13:33:46.407+05 08:33:46.407Z, 45 minutes late 2025-10-08T13:33:46.407+05:45
America/New_York 2025-10-08T03:48:46.407-04 correct 2025-10-08T03:48:46.407-04:00
GMT (default) 2025-10-08T07:48:46.407Z correct 2025-10-08T07:48:46.407Z

Please default to yyyy-MM-dd'T'HH:mm:ss.SSSXXX. The default GMT output doesn't change, every zone parses back to the right instant, and the XXX column matches JsonMapper with the same default time zone character for character. Whole-hour zones change from -04 to -04:00, which needs a §76.2 row.

With that change locally, the only failing test is the America/New_York row of DateTimeHelperRenderingSpec ("a configured timeZone writes … as Grails 7 did"). Please also add a half-hour row there, such as Asia/Kolkata, since only whole-hour zones are covered. The -04 form also appears in:

  • the Time Zone and Date Format sections of jsonConfiguration.adoc;
  • the defaultValue in additional-spring-configuration-metadata.json;
  • the last §76.4 note.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 09bf0ce. The default is yyyy-MM-dd'T'HH:mm:ss.SSSXXX.

  • The timeZone test compares with JsonMapper for GMT, America/New_York, Asia/Kolkata and Asia/Kathmandu.
  • §76.2 has a row for the offset change, and jsonConfiguration.adoc, the metadata defaultValue and the last §76.4 note are updated.

…as well

Month keeps rendering as "SEPTEMBER" in grails.converters.JSON and JSON views, as section 8 of the
upgrade guide describes for every enum and as the XML converter writes it. monthValueConverter still
binds 9 and "9" to SEPTEMBER. The guide shows a marshaller and a JSON views converter that render a
Month as its number, as Spring Boot does, and tests cover both, including a domain property that
g.render writes.
…s Spring Boot does

The default grails.views.json.generator.dateFormat becomes yyyy-MM-dd'T'HH:mm:ss.SSSXXX. The single X
of Grails 7 wrote only the hours of the offset, so a configured timeZone such as Asia/Kolkata named an
instant 30 minutes off. The default GMT output does not change, and a configured timeZone now renders
as Spring Boot renders it with spring.jackson.time-zone.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Both changed:

  • f8ef378: Month renders by its name again in grails.converters.JSON and JSON views, and monthValueConverter stays.
    • The Month rows and snippets are out of §76.2, defaultRenderers.adoc and jsonConfiguration.adoc, and the §76.4 enum note covers Month.
    • The guides show a marshaller and a JSON views converter that render a Month as its number, as Spring Boot does. Tests cover both, including a domain property that g.render writes.
  • 09bf0ce: the JSON views default date format is yyyy-MM-dd'T'HH:mm:ss.SSSXXX.
    • The timeZone test compares with JsonMapper for GMT, America/New_York, Asia/Kolkata and Asia/Kathmandu.
    • §76.2 has a row for the offset change, and jsonConfiguration.adoc, the metadata defaultValue and the last §76.4 note are updated.
  • 76d01c9: the java.sql.Time row says it binds back to the same time of day, to the second.

@jdaugherty jdaugherty left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 76d01c9dd1. Thanks, both asks are in:

  • Month renders as "SEPTEMBER" with both renderers again, monthValueConverter still binds 9, and the guides show how to get Spring Boot's number. The new g.render test pins the converter path: with the !hasDateTimeConverter(...) guard taken off the enum branch of DefaultGrailsJsonViewHelper, it fails with {"month":"SEPTEMBER"}.
  • JSON views default to yyyy-MM-dd'T'HH:mm:ss.SSSXXX, and the timeZone test now compares with JsonMapper for GMT, New York, Kolkata and Kathmandu.

I re-ran the touched modules fresh (grails-web-common, grails-converters, grails-views-gson, grails-databinding-core, grails-databinding, grails-web-databinding, grails-test-suite-web, MonthBindingSpec): 1,223 tests, 0 failures, and codeStyle is clean for the three modules this round changed.

Approving, on the condition that these two are fixed before this merges:

  1. The merge conflict with 8.0.x in upgrading80x.adoc. #16275 added its own section 76, "A List of Objects in Configuration Binds to Its Declared Type", after the same section 75, so these two sections become 77 and 78, with subsections 77.1 to 77.4. Nothing else in the guides refers to them by number.
  2. The three comments that still say Grails renders a Month as its number:
    • the monthValueConverter Javadoc in Jsr310ConvertersConfiguration (suggestion inline)
    • the class Javadoc of MonthBindingSpec
    • the feature name monthValueConverter binds a month number, as grails.converters.JSON and JSON views render a Month in Jsr310ConvertersConfigurationSpec

Comment on lines +355 to +357
* Binds a {@link Month} from its number, 1 for January through 12 for December, which is how
* {@code grails.converters.JSON}, JSON views and Spring Boot render a Month. Without it a number would bind
* through Spring's conversion service by ordinal, one month late. A month name still binds as any enum does.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since f8ef378 this no longer holds: grails.converters.JSON and JSON views render a Month as "SEPTEMBER", and only Spring Boot writes the number. Something like:

Suggested change
* Binds a {@link Month} from its number, 1 for January through 12 for December, which is how
* {@code grails.converters.JSON}, JSON views and Spring Boot render a Month. Without it a number would bind
* through Spring's conversion service by ordinal, one month late. A month name still binds as any enum does.
* Binds a {@link Month} from its number, 1 for January through 12 for December, which is how Spring Boot
* renders a Month. Without it a number would bind through Spring's conversion service by ordinal, one month
* late. The name that {@code grails.converters.JSON} and JSON views render binds as any enum does.

Two tests make the same claim:

  • the class Javadoc of MonthBindingSpec (line 30)
  • the feature name monthValueConverter binds a month number, as grails.converters.JSON and JSON views render a Month in Jsr310ConvertersConfigurationSpec (line 299)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in ede7ecf, with the MonthBindingSpec class Javadoc and the Jsr310ConvertersConfigurationSpec feature name.

…dering-8.0.x

# Conflicts:
#	grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@jdaugherty
jdaugherty merged commit b5b3629 into apache:8.0.x Sep 28, 2026
80 of 81 checks passed
@codeconsole

Copy link
Copy Markdown
Contributor Author

Both done:

antobinary added a commit to antobinary/bigbluebutton that referenced this pull request Oct 2, 2026
Apache Grails tagged 8.0.0-RC2 on 2026-09-29. RC1 has since been
promoted to Maven Central, but RC2 is again in the ASF staging group
only while its release vote runs, so the TEMP staging-repository entries
in build.gradle stay and their comments are retargeted at RC2. RC2's BOM
keeps Spring Boot 4.1.1 and Groovy 5.1.3; the cloud.wondrify
asset-pipeline plugin follows the BOM to 5.2.0-RC3 (on Maven Central).

RC2 itself is built on Gradle 9.8.0, the current release, so the wrapper
moves from 9.7.1 to 9.8.0 to stay on the combination Grails tests. This
is alignment rather than a requirement: RC2 still assembles on 9.7.1.

.gitlab-ci.yml moves to bbb-build:grails-8--2026-10-02-140644, rebuilt
today with the Grails 8.0.0-RC2 CLI. It also picks up the toolchain the
current v4.0.x-release image has (Go 1.27.0, Node 22.23.2), which the
previous grails-8 image predated.

Behavior change that comes with RC2 (apache/grails-core#15967): Grails
now sends browser-hardening headers on every response by default, so
every bbb-web response gains
  X-Content-Type-Options: nosniff
  X-Frame-Options: SAMEORIGIN
  Referrer-Policy: strict-origin-when-cross-origin
  X-XSS-Protection: 0
The defaults are left in place. Joining through a cross-origin iframe
keeps working: the join call answers with a redirect, and the framed
document is the client served by nginx, which carries no such header.
What changes is that a document rendered by bbb-web itself (for example
the XML of /bigbluebutton/api) is no longer displayed inside a
cross-origin frame. A deployment that needs that can opt out per header
in /etc/bigbluebutton/bbb-web.properties, e.g.
grails.security.headers.frame-options.enabled=false.

Checked, nothing to do: the RC2 JSON date/time rendering change
(apache/grails-core#16411) concerns grails.converters.JSON and JSON
views, while bbb-web builds its JSON with groovy.json.JsonBuilder and
ships no gson views; the link-resolution change
(apache/grails-core#16272) has nothing to act on, bbb-web's only
generated link is one redirect(action:).

Verified together with the Tomcat pin in the next commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

8.0.0-RC1: default JSON/XML DateMarshaller fails on java.sql.Date and drops .000 millis (undocumented change from 7.x)

5 participants