jdaugherty commented on code in PR #16414:
URL: https://github.com/apache/grails-core/pull/16414#discussion_r4202090931


##########
grails-converters/src/main/groovy/org/grails/plugins/converters/ConvertersGrailsPlugin.groovy:
##########
@@ -72,6 +79,20 @@ class ConvertersGrailsPlugin extends Plugin {
                 }
             }
 
+            // Spring Boot registers every JacksonModule bean with the 
application's JsonMapper
+            if 
(environment.getProperty(ConvertersConfigurationInitializer.SETTING_CONVERTERS_JSON_DOMAIN_JACKSON_ENABLED,
 Boolean, true) &&

Review Comment:
   This registers the domain-class module with Boot's shared `JsonMapper` by 
default, so every `@RestController` or other Jackson user that writes a domain 
instance changes shape on upgrade: associations become `{"id":3}` references 
and proxies are unwrapped, where 8.0.x wrote Jackson's bean view. That is the 
same kind of silent default change as the converter one, on a path a Grails 
user may not think of as Grails at all. Please make 
`grails.converters.json.domain.jackson.enabled` default to false in 9.x (or tie 
it to the same switch as the converter) and flip it in 10, so section 2 of the 
guide describes an opt-in.



##########
grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/DomainClassMarshaller.java:
##########
@@ -120,154 +99,18 @@ public void setIncludeVersion(boolean includeVersion) {
 
     public boolean supports(Object object) {
         String name = 
ConverterUtil.trimProxySuffix(object.getClass().getName());
-        return application.isArtefactOfType(DomainClassArtefactHandler.TYPE, 
name);
+        return application != null && 
application.isArtefactOfType(DomainClassArtefactHandler.TYPE, name);
     }
 
-    @SuppressWarnings({ "unchecked", "rawtypes" })
     public void marshalObject(Object value, JSON json) throws 
ConverterException {
-        JSONWriter writer = json.getWriter();
-        value = proxyHandler.unwrapIfProxy(value);
-        Class<?> clazz = value.getClass();
-
-        List<String> excludes = json.getExcludes(clazz);
-        List<String> includes = json.getIncludes(clazz);
-        IncludeExcludeSupport<String> includeExcludeSupport = new 
IncludeExcludeSupport<>();
-
-        BeanWrapper beanWrapper = new BeanWrapperImpl(value);
-
-        writer.object();
-
-        if (includeClass && shouldInclude(includeExcludeSupport, includes, 
excludes, value, "class")) {
-            writer.key("class").value(clazz.getName());
-        }
-
-        PersistentEntity domainClass = findDomainClass(value);
-
-        if (domainClass == null) {
-            throw new GrailsConfigurationException("Could not retrieve the 
respective entity for domain " + value.getClass().getName() + " in the mapping 
context API");
-        }
-
-        PersistentProperty id = domainClass.getIdentity();
-        if (id != null) {
-            //Composite keys dont return an identity. They also do not render 
in the JSON.
-            //If using Composite keys, it may be advisable to use a customer 
Marshaller.
-            if (shouldInclude(includeExcludeSupport, includes, excludes, 
value, id.getName())) {
-                Object idValue = extractValue(value, id);
-                if (idValue != null) {
-                    json.property(id.getName(), idValue);
-                }
-            }
-        }
-
-        if (shouldInclude(includeExcludeSupport, includes, excludes, value, 
GormProperties.VERSION) && isIncludeVersion()) {
-            PersistentProperty versionProperty = domainClass.getVersion();
-            Object version = extractValue(value, versionProperty);
-            if (version != null) {
-                json.property(GormProperties.VERSION, version);
-            }
-        }
-
-        List<PersistentProperty> properties = 
domainClass.getPersistentProperties();
-
-        for (PersistentProperty property : properties) {
-            if (property.equals(domainClass.getVersion())) {
-                continue;
-            }
-
-            if (!shouldInclude(includeExcludeSupport, includes, excludes, 
value, property.getName())) continue;
-
-            writer.key(property.getName());
-            if (!(property instanceof Association)) {
-                // Write non-relation property
-                Object val = beanWrapper.getPropertyValue(property.getName());
-                json.convertAnother(val);
-            }
-            else {
-                Object referenceObject = 
beanWrapper.getPropertyValue(property.getName());
-                if (isRenderDomainClassRelations()) {
-                    if (referenceObject == null) {
-                        writer.valueNull();
-                    }
-                    else {
-                        referenceObject = 
proxyHandler.unwrapIfProxy(referenceObject);
-                        if (referenceObject instanceof SortedMap) {
-                            referenceObject = new TreeMap((SortedMap) 
referenceObject);
-                        }
-                        else if (referenceObject instanceof SortedSet) {
-                            referenceObject = new TreeSet((SortedSet) 
referenceObject);
-                        }
-                        else if (referenceObject instanceof Set) {
-                            referenceObject = new LinkedHashSet((Set) 
referenceObject);
-                        }
-                        else if (referenceObject instanceof Map) {
-                            referenceObject = new LinkedHashMap((Map) 
referenceObject);
-                        }
-                        else if (referenceObject instanceof Collection) {
-                            referenceObject = new ArrayList((Collection) 
referenceObject);
-                        }
-                        json.convertAnother(referenceObject);
-                    }
-                }
-                else {
-                    if (referenceObject == null) {
-                        json.value(null);
-                    }
-                    else {
-
-                        PersistentEntity referencedDomainClass = 
((Association) property).getAssociatedEntity();
-
-                        // Embedded are now always fully rendered
-                        if (referencedDomainClass == null || ((Association) 
property).isEmbedded() || property.getType().isEnum()) {
-                            json.convertAnother(referenceObject);
-                        }
-                        else if ((property instanceof OneToOne) || (property 
instanceof ManyToOne) || ((Association) property).isEmbedded()) {
-                            asShortObject(referenceObject, json, 
referencedDomainClass.getIdentity(), referencedDomainClass);
-                        }
-                        else {
-                            PersistentProperty referencedIdProperty = 
referencedDomainClass.getIdentity();
-                            @SuppressWarnings("unused")
-                            String refPropertyName = ((Association) 
property).getReferencedPropertyName();
-                            if (referenceObject instanceof Collection) {
-                                Collection o = (Collection) referenceObject;
-                                writer.array();
-                                for (Object el : o) {
-                                    asShortObject(el, json, 
referencedIdProperty, referencedDomainClass);
-                                }
-                                writer.endArray();
-                            }
-                            else if (referenceObject instanceof Map) {
-                                Map<Object, Object> map = (Map<Object, 
Object>) referenceObject;
-                                for (Map.Entry<Object, Object> entry : 
map.entrySet()) {
-                                    String key = 
String.valueOf(entry.getKey());
-                                    Object o = entry.getValue();
-                                    writer.object();
-                                    writer.key(key);
-                                    asShortObject(o, json, 
referencedIdProperty, referencedDomainClass);
-                                    writer.endObject();
-                                }
-                            }
-                        }
-                    }
-                }
-            }
-        }
-        writer.endObject();
-    }
-
-    private PersistentEntity findDomainClass(Object value) {
-        for (DomainClassFetcher fetcher : domainClassFetchers) {
-            PersistentEntity domain = fetcher.findDomainClass(value);
-            if (domain != null) {
-                return domain;
-            }
-        }
-        return null;
-    }
-
-    private boolean shouldInclude(IncludeExcludeSupport<String> 
includeExcludeSupport, List<String> includes, List<String> excludes, Object 
object, String propertyName) {
-        return includeExcludeSupport.shouldInclude(includes, excludes, 
propertyName) && shouldInclude(object, propertyName);
+        Object object = proxyHandler.unwrapIfProxy(value);
+        JsonMapperValueMarshaller.write(DomainClassSerializer.value(object, 
new Rendering(json, object.getClass())), json);

Review Comment:
   `grails.converters.json.legacy` restores the Grails 8 marshaller list, but 
`DomainClassMarshaller` itself now renders through `DomainClassSerializer` and 
the mapper, so a domain instance under the switch is written by the new 
serializer, not by the Grails 8 marshaller whose 176 lines were removed here. 
`Grails8JsonRenderingSpec` renders no domain instance (its mapping context 
holds no entities), so the byte-for-byte claim in the guide is untested for the 
values most applications `render ... as JSON`: entities with associations, 
proxies, embedded values, `includes`/`excludes`, `deep`, a null id and version, 
`includeVersion` and `includeClass`. Please keep the Grails 8 marshaller body 
as the legacy implementation, selected by the switch like the other legacy 
marshallers, and add those cases, rendered by 8.0.x, to the fidelity spec.



##########
grails-web-common/src/main/groovy/org/grails/web/json/JSONWriter.java:
##########
@@ -297,65 +322,194 @@ public JSONWriter value(double d) {
      * @return this
      */
     public JSONWriter value(long l) {
-        return append(Long.toString(l));
+        return write(() -> generator.writeNumber(l), l);
     }
 
     /**
-     * Append a number value
+     * Append a number value.
      *
-     * @param number
-     * @return
+     * @param number A Number.
+     * @return this
      */
     public JSONWriter value(Number number) {
-        return number != null ? append(number.toString()) : valueNull();
+        return number != null ? write(() -> writeNumber(number), number) : 
valueNull();
     }
 
+    /**
+     * Append the value <code>null</code>.
+     *
+     * @return this
+     */
     public JSONWriter valueNull() {
-        return append(nullWritable);
+        return write(generator::writeNull, null);
     }
 
-    static Writable nullWritable = new NullWritable();
-
-    private static class NullWritable implements Writable {
-        @Override
-        public Writer writeTo(Writer out) throws IOException {
-            out.write("null");
-            return out;
+    /**
+     * Append an object value: a {@link JsonMapperValue} as its mapper writes 
it, {@code null} (and
+     * {@link JSONObject#NULL}), numbers and booleans as JSON literals, a 
{@code Date} as a JavaScript
+     * {@code new Date(...)} (for the javascript JSON date format), a {@link 
JSONElement} as the JSON text it writes, a
+     * {@code String} as a JSON string, and any other value as the quoted, 
JSON encoded text of {@link JSONObject}.
+     *
+     * @param o The object to append.
+     * @return this
+     */
+    public JSONWriter value(Object o) {
+        if (o == null || o.equals(null)) {
+            return valueNull();
+        }
+        if (o instanceof JsonMapperValue mapperValue && 
mapperValue.getJsonMapper() == jsonMapper) {
+            return write(() -> jsonMapper.writeValue(generator, 
mapperValue.getValue(), mapperValue.getNestedValueWriter()), o);
+        }
+        if (o instanceof Number number) {
+            return value(number);
+        }
+        if (o instanceof Boolean b) {
+            return value(b.booleanValue());
+        }
+        if (o instanceof Date date) {
+            return write(() -> generator.writeRawValue("new Date(" + 
date.getTime() + ")"), o);
+        }
+        if (o instanceof JSONElement element) {
+            return write(() -> generator.writeRawValue(text(element)), o);
+        }
+        if (o.getClass() == String.class || o.getClass() == 
StringBuilder.class || o.getClass() == StringBuffer.class) {
+            return write(() -> generator.writeString(o.toString()), o);
         }
+        return write(() -> generator.writeRawValue(quoted(o)), o);
     }
 
     /**
-     * Append an object value.
+     * Writes a value nested in a {@link JsonMapperValue} that this writer is 
writing, such as a value that a Jackson
+     * serializer writes with {@link JsonGenerator#writePOJO(Object)}, from 
the value's nested value writer. The writer
+     * accepts one value, as at the start of a JSON text, and then returns to 
the state it was in.
      *
-     * @param o The object to append. It can be null, or a Boolean, Number,
-     *          String, JSONObject, or JSONArray.
-     * @return this
+     * @param writeValue writes one value to this writer
+     * @throws JSONException if {@code writeValue} does not write one complete 
value
+     * @since 9.0
      */
-    public JSONWriter value(Object o) {
-        return o != null ? append(new QuotedWritable(o)) : valueNull();
+    public void writeNested(Runnable writeValue) {
+        Mode outerMode = this.mode;
+        Stack<Mode> outerStack = this.stack;
+        boolean outerComma = this.comma;
+        this.mode = INIT;
+        this.stack = new Stack<>();
+        this.nesting++;
+        try {
+            writeValue.run();
+            if (this.mode != DONE) {
+                throw new JSONException("Incomplete nested value: expected 
mode of DONE but was " + this.mode);
+            }
+        }
+        finally {
+            this.mode = outerMode;
+            this.stack = outerStack;
+            this.comma = outerComma;
+            this.nesting--;
+        }
     }
 
-    private static class QuotedWritable implements Writable {
-        Object o;
+    /**
+     * The location of the nested value that {@link #writeNested(Runnable)} is 
about to write, relative to the
+     * {@link JsonMapperValue} it is nested in, such as {@code .inner} or 
{@code [0]}.
+     */
+    String nestedPath() {
+        return generator != null ? NestedValueSerializer.nestedPath(generator) 
: "";
+    }
 
-        QuotedWritable(Object o) {
-            this.o = o;
+    /**
+     * Writes the JSON text buffered so far to the {@link Writer}, without 
flushing the {@code Writer}.
+     *
+     * @since 9.0
+     */
+    public void flush() {
+        if (generator != null) {
+            generate(generator::flush);
         }
+    }
 
-        @Override
-        public Writer writeTo(Writer out) throws IOException {
-            JSONObject.writeValue(out, o);
-            return out;
+    private JSONWriter write(Runnable writeValue, Object value) {
+        if (this.mode == INIT) {
+            generate(writeValue);
+            this.mode = DONE;
+            closeIfDone();
+            return this;
         }
-
-        public String toString() {
-            return String.valueOf(o);
+        if (this.mode == OBJECT || this.mode == ARRAY) {
+            generate(writeValue);
+            if (this.mode == OBJECT) {
+                this.mode = KEY;
+            }
+            this.comma = true;
+            return this;
         }
+        throw new JSONException("Value out of sequence: expected mode to be 
OBJECT or ARRAY when writing '" + value + "' but was " + this.mode);
     }
 
     /**
-     * Enumeration of the possible modes of the JSONWriter
+     * Once the JSON text is complete, closes the generator, which writes the 
rest of the text to the {@link Writer}
+     * and returns its buffers to Jackson.
      */
+    private void closeIfDone() {
+        if (this.mode == DONE && this.nesting == 0) {
+            generate(generator::close);
+        }
+    }
+
+    private void writeNumber(Number number) {
+        if (number instanceof Integer || number instanceof Short || number 
instanceof Byte) {
+            generator.writeNumber(number.intValue());
+        }
+        else if (number instanceof Long) {
+            generator.writeNumber(number.longValue());
+        }
+        else if (number instanceof Double) {
+            generator.writeNumber(number.doubleValue());
+        }
+        else if (number instanceof Float) {
+            generator.writeNumber(number.floatValue());
+        }
+        else if (number instanceof BigDecimal bigDecimal) {
+            generator.writeNumber(bigDecimal);
+        }
+        else if (number instanceof BigInteger bigInteger) {
+            generator.writeNumber(bigInteger);
+        }
+        else {
+            jsonMapper.writeValue(generator, number);

Review Comment:
   A `Number` outside the list above (`AtomicInteger`, `AtomicLong`, 
`LongAdder`, any custom `Number`) is handed back to the generator through 
`writePOJO`, and while a nested hand-off is active 
`NestedValueSerializer.writtenAsNested` re-offers it to the nested value 
writer. A marshaller that outranks `JsonMapperValueMarshaller` for such a type 
and renders it with `json.value(number)` is re-entered on every call and never 
returns (a probe stopped it after 51 offers; a `Long` in the same setup is 
written once). The old writer wrote `number.toString()` for every `Number`, and 
Jackson's `NumberSerializer` produces the same text for these, so 
`generator.writeNumber(String.valueOf(number))` here keeps the output and 
removes the re-entry. Please also say on 
`JsonMapperSupport.writeValue(JsonGenerator, Object)` that it takes part in an 
active nested write.



##########
grails-converters/src/main/groovy/org/grails/web/converters/marshaller/json/RecordMarshaller.java:
##########
@@ -0,0 +1,57 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.web.converters.marshaller.json;
+
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.RecordComponent;
+
+import grails.converters.JSON;
+import org.grails.web.converters.exceptions.ConverterException;
+import org.grails.web.converters.marshaller.ObjectMarshaller;
+import org.grails.web.json.JSONException;
+import org.grails.web.json.JSONWriter;
+
+/**
+ * JSON ObjectMarshaller which renders a record as an object of its 
components, in declaration order, as Spring Boot's
+ * JsonMapper does. Each component value is rendered by the converter, as any 
other value is.
+ *
+ * @since 9.0
+ */
+public class RecordMarshaller implements ObjectMarshaller<JSON> {
+
+    public boolean supports(Object object) {
+        return object.getClass().isRecord();
+    }
+
+    public void marshalObject(Object object, JSON converter) throws 
ConverterException {
+        JSONWriter writer = converter.getWriter();
+        try {
+            writer.object();
+            for (RecordComponent component : 
object.getClass().getRecordComponents()) {

Review Comment:
   The javadoc says a record renders as Spring Boot's `JsonMapper` does, but 
this iterates `getRecordComponents()` and ignores the Jackson annotations the 
mapper honours: `record Person(@JsonProperty("first_name") String firstName, 
@JsonIgnore String ssn)` renders `{"firstName":..,"ssn":..}` here and 
`{"first_name":..}` in a `@RestController`. The guide only promises "an object 
of their components", so either the javadoc comes down to that or the 
marshaller consults the annotations. Separately, `setAccessible(true)` on a 
record from a named module not opened to grails-converters throws 
`InaccessibleObjectException`, a `RuntimeException` the `catch` below does not 
cover, so it escapes unwrapped rather than as a `ConverterException`.



##########
grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java:
##########
@@ -111,26 +132,75 @@ private void initJSONConfiguration() {
 
         List<ObjectMarshaller<JSON>> marshallers = new ArrayList<>();
         marshallers.addAll(getPreviouslyConfiguredMarshallers(JSON.class));
-        marshallers.add(new 
org.grails.web.converters.marshaller.json.ArrayMarshaller());
-        marshallers.add(new 
org.grails.web.converters.marshaller.json.ByteArrayMarshaller());
-        marshallers.add(new 
org.grails.web.converters.marshaller.json.CollectionMarshaller());
-        marshallers.add(new 
org.grails.web.converters.marshaller.json.MapMarshaller());
-        marshallers.add(new 
org.grails.web.converters.marshaller.json.SimpleEnumMarshaller());
 
         Config grailsConfig = getGrailsConfig();
+        ProxyHandler proxyHandler = getProxyHandler();
+        boolean legacy = 
grailsConfig.getProperty(SETTING_CONVERTERS_JSON_LEGACY, Boolean.class, false);

Review Comment:
   This is the line that decides the sequence. As written, Grails 9 renders the 
new output for every application, and the Grails 8 output is an opt-out that is 
deprecated and warns from the release that introduces it 
(`SETTING_CONVERTERS_JSON_LEGACY` is `forRemoval` on line 85 from day one).
   
   Please invert it for 9.x: default to the Grails 8 marshallers and layout, 
log this warning once when an application is on them (it already says what to 
do), and let applications opt into the mapper with the setting. Flip the 
default in Grails 10 and remove the Grails 8 path in 11, and move the "removed 
in Grails 10" statements with it (guide lines 115, 133, 137 and 160, the 
marshaller javadocs, and `PrettyPrintJSONWriter`/`JSONParser`). With that order 
the setting is a migration window rather than an escape hatch an application 
discovers after its clients break.



##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -0,0 +1,189 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails and Related Dependencies
+
+This guide outlines the changes to review when you upgrade a Grails project 
from Grails 8 to Grails 9.
+
+==== 1. `grails.converters.JSON` Writes JSON With Spring Boot's JsonMapper
+
+`render ... as JSON`, `respond` for a JSON request and every other use of 
`grails.converters.JSON` now write JSON through
+the application's Jackson `JsonMapper`, the one Spring Boot auto-configures. 
The mapper writes the JSON text (numbers and
+pretty printing), and writes every single value that it has a dedicated 
serializer for: dates and times, `Month`,
+`UUID`, `Locale`, `byte[]`, types with a `@JsonValue`, and the types of any 
Jackson module registered with the
+application. Those values render as Spring Boot renders them, and 
`spring.jackson.*` properties apply to them. For
+example, `spring.jackson.time-zone` writes `Date` and `Calendar` values in 
that time zone, and converts `OffsetDateTime`
+and `ZonedDateTime` values to it. When the application context has no 
`JsonMapper`, or has several and none is primary,
+as in a unit test, a default `JsonMapper` is used. To keep rendering JSON as 
Grails 8 did while you migrate, see
+<<renderingJsonAsGrails8,Rendering JSON As Grails 8 Did>>.
+
+Parsing JSON does not change. `JSON.parse`, `request.JSON` and `new 
JSONObject(String)` parse with Grails' own
+`JSONTokener`, as before: they accept the same syntax beyond strict JSON 
(single quotes, comments, unquoted values,
+uppercase literals, trailing commas, `new Date(...)`), return the same number 
types (`Integer`, `Long`, `BigInteger`,
+`Double` or `BigDecimal`, depending on the number) and the same `JSONObject` 
and `JSONArray` containers, which implement
+`Map` and `List`.
+
+Grails marshallers still render domain classes, beans, enums, collections, 
maps and arrays, and now records (as an
+object of their components) and `Optional` (as its value), so the values they 
contain are rendered as any other value
+is. A marshaller that an application registers, with 
`JSON.registerObjectMarshaller(...)` or an
+`ObjectMarshallerRegisterer`, still takes precedence over the mapper for the 
types it supports.

Review Comment:
   This sentence, and the `registerObjectMarshaller` examples at lines 167-169, 
present a registered marshaller as the supported way to keep a Grails 8 format 
per type. #16237, also on 9.0.x, deprecates `JSON.registerObjectMarshaller` for 
removal and uses a registered marshaller as the trigger that keeps `respond()` 
on the legacy converter, and its guide says `render value as JSON` still uses 
the legacy converter, which stops being true once this PR merges. The two PRs 
also add two switches with different meanings, `grails.converters.json.legacy` 
here and `grails.web.rendering.json.spring` there, and after both merge the 
"legacy converter" in #16237 is this PR's mapper-backed converter unless both 
settings are on. Please settle one story across the two before either merges: 
one switch, one deprecation timeline, and `registerObjectMarshaller` either the 
migration tool or the thing being migrated away from.



##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassSerializer.java:
##########
@@ -0,0 +1,321 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package org.grails.web.converters.jackson;
+
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.SortedMap;
+import java.util.SortedSet;
+import java.util.TreeMap;
+import java.util.TreeSet;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.databind.JacksonSerializable;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.jsontype.TypeSerializer;
+import tools.jackson.databind.ser.std.StdSerializer;
+
+import org.springframework.beans.BeanWrapper;
+import org.springframework.beans.BeanWrapperImpl;
+
+import org.grails.core.exceptions.GrailsConfigurationException;
+import org.grails.datastore.mapping.model.PersistentEntity;
+import org.grails.datastore.mapping.model.PersistentProperty;
+import org.grails.datastore.mapping.model.config.GormProperties;
+import org.grails.datastore.mapping.model.types.Association;
+import org.grails.datastore.mapping.model.types.ManyToOne;
+import org.grails.datastore.mapping.model.types.OneToOne;
+import org.grails.datastore.mapping.reflect.NameUtils;
+import org.grails.datastore.mapping.reflect.ReflectionUtils;
+
+/**
+ * Serializes domain class instances as {@code grails.converters.JSON} renders 
them: an object of the id, the version
+ * and the class name when they are included, and the persistent properties. 
An associated domain class instance is a
+ * reference of its id, such as {@code {"id":1}}, or, when rendering deep, the 
instance in full. Embedded instances and
+ * collections of basic values are written in full.
+ *
+ * <p>Property values are written with {@link 
JsonGenerator#writePOJO(Object)}, so a mapper writes them as it writes any
+ * other value, and a converter renders them with its marshallers. When 
rendering deep, an instance that is already
+ * being written, further up, is written as a reference.
+ *
+ * <p>A property annotated with {@code @JsonIgnore}, or with {@code 
@JsonProperty(access = WRITE_ONLY)}, on its field
+ * or getter, is not written. The {@link DomainClassRendering} decides about 
the others.
+ *
+ * @since 9.0
+ */
+public final class DomainClassSerializer extends StdSerializer<Object> {
+
+    private static final String RENDERING_INSTANCES = 
DomainClassSerializer.class.getName() + ".instances";
+
+    private static final ClassValue<Set<String>> IGNORED_PROPERTIES = new 
ClassValue<>() {
+        @Override
+        protected Set<String> computeValue(Class<?> type) {
+            Set<String> ignored = new HashSet<>();
+            for (Class<?> current = type; current != null && current != 
Object.class; current = current.getSuperclass()) {
+                for (Field field : current.getDeclaredFields()) {
+                    if (ignoredForSerialization(field)) {
+                        ignored.add(field.getName());
+                    }
+                }
+                for (Method method : current.getDeclaredMethods()) {
+                    if (ReflectionUtils.isGetter(method.getName(), 
method.getParameterTypes()) &&
+                            ignoredForSerialization(method)) {
+                        
ignored.add(NameUtils.getPropertyNameForGetterOrSetter(method.getName()));
+                    }
+                }
+            }
+            return Set.copyOf(ignored);
+        }
+    };
+
+    private final DomainClassRendering rendering;
+
+    /**
+     * @param rendering how to render domain class instances
+     */
+    public DomainClassSerializer(DomainClassRendering rendering) {
+        super(Object.class);
+        this.rendering = rendering;
+    }
+
+    /**
+     * @return how this serializer renders domain class instances
+     */
+    public DomainClassRendering getRendering() {
+        return rendering;
+    }
+
+    /**
+     * A value that a mapper writes as a domain class instance rendered in a 
particular way, whether or not the mapper
+     * has a {@link DomainClassJacksonModule}.
+     *
+     * @param object a domain class instance
+     * @param rendering how to render it
+     * @return the value to write
+     */
+    public static Object value(Object object, DomainClassRendering rendering) {
+        return new JacksonSerializable.Base() {
+            @Override
+            public void serialize(JsonGenerator generator, 
SerializationContext context) {
+                write(object, generator, context, rendering);
+            }
+
+            @Override
+            public void serializeWithType(JsonGenerator generator, 
SerializationContext context,
+                    TypeSerializer typeSerializer) {
+                serialize(generator, context);
+            }
+        };
+    }
+
+    @Override
+    public void serialize(Object value, JsonGenerator gen, 
SerializationContext ctxt) {

Review Comment:
   Only `serialize` is overridden; the `value()` wrapper above overrides 
`serializeWithType`, this serializer does not, and 
`ValueSerializer.serializeWithType` throws unless the type-id inclusion is 
`NOTHING`. So any domain class that carries polymorphic type info (an abstract 
superclass with `@JsonTypeInfo(use = NAME)`, a typed property, or default 
typing on the mapper), returned from a `@RestController` or passed to 
`jsonMapper.writeValueAsString`, now fails with `InvalidDefinitionException: 
Type id handling (method serializeWithType()) not implemented for type 
java.lang.Object (by serializer of type DomainClassSerializer)`, where Boot's 
mapper wrote `{"@type":"Dog",...}` before the module existed. Reproduced with a 
`Serializers`-supplied `StdSerializer<Object>` on Jackson 3.1.6 for a root 
`Dog` and a `Holder { Animal pet }`. Please implement `serializeWithType` with 
the usual `typeSer.typeId`/`writeTypePrefix`/`writeTypeSuffix` dance and add a 
`@JsonTypeInfo` case to `DomainClassJ
 acksonModuleSpec`.



##########
grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java:
##########
@@ -65,6 +67,23 @@ public class ConvertersConfigurationInitializer implements 
ApplicationContextAwa
 
     public static final String SETTING_CONVERTERS_JSON_DATE = 
"grails.converters.json.date";
     public static final String SETTING_CONVERTERS_JSON_DEFAULT_DEEP = 
"grails.converters.json.default.deep";
+    /**
+     * Whether domain classes are registered with the application's {@code 
JsonMapper}, so that it renders them as the
+     * JSON converter does. Defaults to {@code true}.
+     *
+     * @since 9.0
+     */
+    public static final String SETTING_CONVERTERS_JSON_DOMAIN_JACKSON_ENABLED 
= "grails.converters.json.domain.jackson.enabled";
+    /**
+     * Whether the JSON converter renders values as Grails 8 did, with the 
marshallers of Grails 8 rather than the
+     * application's {@code JsonMapper}, and the application's {@code 
JsonMapper} renders domain classes as Jackson beans.
+     * Defaults to {@code false}.
+     *
+     * @since 9.0
+     * @deprecated a transition to the rendering of Grails 9, to be removed in 
Grails 10
+     */
+    @Deprecated(since = "9.0", forRemoval = true)
+    public static final String SETTING_CONVERTERS_JSON_LEGACY = 
"grails.converters.json.legacy";

Review Comment:
   Neither `grails.converters.json.legacy` nor 
`grails.converters.json.domain.jackson.enabled` has configuration metadata; the 
`grails.converters.*` entries live in 
`grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json`.
 Every settable key in this repository is documented that way, so please add 
both with their defaults, and the deprecation on the legacy one.



##########
grails-doc/src/en/guide/upgrading/upgrading90x.adoc:
##########
@@ -0,0 +1,189 @@
+////
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+////
+
+=== Upgrade Instructions for Grails and Related Dependencies
+
+This guide outlines the changes to review when you upgrade a Grails project 
from Grails 8 to Grails 9.
+
+==== 1. `grails.converters.JSON` Writes JSON With Spring Boot's JsonMapper
+
+`render ... as JSON`, `respond` for a JSON request and every other use of 
`grails.converters.JSON` now write JSON through
+the application's Jackson `JsonMapper`, the one Spring Boot auto-configures. 
The mapper writes the JSON text (numbers and
+pretty printing), and writes every single value that it has a dedicated 
serializer for: dates and times, `Month`,
+`UUID`, `Locale`, `byte[]`, types with a `@JsonValue`, and the types of any 
Jackson module registered with the
+application. Those values render as Spring Boot renders them, and 
`spring.jackson.*` properties apply to them. For
+example, `spring.jackson.time-zone` writes `Date` and `Calendar` values in 
that time zone, and converts `OffsetDateTime`
+and `ZonedDateTime` values to it. When the application context has no 
`JsonMapper`, or has several and none is primary,
+as in a unit test, a default `JsonMapper` is used. To keep rendering JSON as 
Grails 8 did while you migrate, see
+<<renderingJsonAsGrails8,Rendering JSON As Grails 8 Did>>.
+
+Parsing JSON does not change. `JSON.parse`, `request.JSON` and `new 
JSONObject(String)` parse with Grails' own
+`JSONTokener`, as before: they accept the same syntax beyond strict JSON 
(single quotes, comments, unquoted values,
+uppercase literals, trailing commas, `new Date(...)`), return the same number 
types (`Integer`, `Long`, `BigInteger`,
+`Double` or `BigDecimal`, depending on the number) and the same `JSONObject` 
and `JSONArray` containers, which implement
+`Map` and `List`.
+
+Grails marshallers still render domain classes, beans, enums, collections, 
maps and arrays, and now records (as an
+object of their components) and `Optional` (as its value), so the values they 
contain are rendered as any other value
+is. A marshaller that an application registers, with 
`JSON.registerObjectMarshaller(...)` or an
+`ObjectMarshallerRegisterer`, still takes precedence over the mapper for the 
types it supports.
+
+A Jackson module registered with the application, such as a `JacksonModule` 
bean or a `@JacksonComponent` serializer,
+now applies to `grails.converters.JSON`, where Grails 8 ignored it. When a 
module provides a serializer for a type that
+Grails 8 rendered as a bean, the serializer renders it instead. The values 
that the serializer writes, such as a property
+it writes with `JsonGenerator#writePOJO`, are rendered by the converter as any 
other value is, so registered marshallers,
+enum names and the circular reference behaviour apply to them. A marshaller 
registered for the type itself takes
+precedence over the module's serializer.
+
+Two things deliberately differ from Spring Boot, as they did in Grails 8:
+
+* Enums render by `name()`, where Jackson 3 writes `toString()`, so that the 
JSON binds back to the enum.
+* Strings are escaped for an HTML `<script>` element: `</` is written as 
`<\u002f`, and U+2028 and U+2029 as `\u2028`
+and `\u2029`. The parsed values are the same as Spring Boot's.
+
+The following values render differently than in Grails 8:
+
+[cols="2,2,3", options="header"]
+|===
+| Value
+| Grails 8
+| Grails 9
+
+| `OffsetTime`
+| `"03:00:00-03:00"`, `"03:00:00.5+05:30"`
+| `"03:00-03:00"`, `"03:00:00.500+05:30"`, from `OffsetTime#toString()`
+
+| `Month`
+| `"SEPTEMBER"`
+| `9`, a JSON number. A `Month` binds back from its number.
+
+| `java.util.Locale`
+| `"zh_TW_#Hant"`, from `Locale#toString()`
+| `"zh-Hant-TW"`, its language tag
+
+| `char`, `Character`
+| `{}`
+| `"c"`
+
+| `byte[]`
+| `[1,2,3]`
+| `"AQID"`, Base64
+
+| `UUID`, `URI`, a type with a `@JsonValue`
+| an object of its bean properties
+| its value, such as `"123e4567-e89b-12d3-a456-426614174000"`
+
+| `Optional`
+| `{"empty":false,"present":true}`
+| its value, or `null` when it is empty
+
+| A `NaN` or infinite `double`
+| `NaN`, which is not valid JSON
+| `"NaN"`
+
+| Pretty printed JSON (`grails.converters.json.pretty.print`, 
`JSON#toString(true)`)
+| `"a": 1`, with every array element on its own line, or, from 
`toString(true)`, indented by three spaces
+| `"a" : 1`, the layout of the mapper's default pretty printer
+
+| A single value, such as `render Role.HEAD as JSON`
+| a `ConverterException`
+| the JSON value, `"HEAD"`
+|===
+
+The other default date and time formats are unchanged from Grails 8.0.0, which 
already renders `Date`, `Calendar`,
+`java.sql.Date` and `java.sql.Timestamp` with millisecond precision and 
supports the additional date and time types.
+Map keys now use the mapper's key serializers, including its configured date 
format and time zone, instead of Grails'
+fixed date formats or the key's `toString()`.
+
+The JSON marshallers that rendered these values are no longer registered, and 
are deprecated, for removal in Grails 10:
+`DateMarshaller`, `CalendarMarshaller`, `ToStringBeanMarshaller`, 
`ByteArrayMarshaller`, `InstantMarshaller`,
+`LocalDateMarshaller`, `LocalDateTimeMarshaller`, `OffsetDateTimeMarshaller` 
and `ZonedDateTimeMarshaller`, in the
+`org.grails.web.converters.marshaller.json` package. To render one of these 
types differently, register a marshaller for
+it, or configure the `JsonMapper`. The XML marshallers of the same names are 
not changed. The default JSON marshallers have implicit priorities that follow 
their order (-1, -2, and so on),
+and that order has changed, so a marshaller registered with an explicit 
negative priority may now come before or after
+different defaults.
+
+`org.grails.web.json.JSONWriter`, which the converter and its marshallers 
write to, now writes through a Jackson
+`JsonGenerator`, and `new JSONWriter(writer)` writes with a default 
`JsonMapper`:
+
+* It keeps its public methods, rejects the same misplaced calls, and writes 
the same JSON, except as listed above. It
+also accepts a single value outside any object or array, where it threw a 
`JSONException`.
+* The generator buffers the text. It reaches the `Writer` as the buffer fills, 
once the JSON text is complete, and when
+`flush()` is called, rather than as each value is written. The `Writer` itself 
is not flushed or closed.
+* `array()`, `object()`, `key()` and the `value` methods no longer go through 
the protected `append` and `comma`
+methods. A subclass that overrides those to change the output, or writes to 
the `writer` field directly, must use the
+public methods instead.
+* `PrettyPrintJSONWriter` still indents as Grails 8 did, and is deprecated, 
for removal in Grails 10. Use
+`new JSONWriter(writer, new JsonMapperSupport(jsonMapper), true)`, which 
indents with the mapper's default pretty printer.
+
+The JavaCC JSON parser in `org.grails.web.json.parser` (`JSONParser`), which 
Grails does not use, is deprecated, for
+removal in Grails 10. Use `JSON.parse` or `new JSONObject(String)`.
+
+With the `PATH` circular reference behaviour 
(`grails.converters.json.circular.reference.behaviour`), an object with a
+`null` property, or a single value, now renders instead of failing, and a 
reference that follows a number property now
+has the right path.
+
+JSON views, the XML converter and the HAL renderer are not changed.
+
+[[renderingJsonAsGrails8]]
+===== Rendering JSON As Grails 8 Did
+
+To move to the new rendering at your own pace, set 
`grails.converters.json.legacy` to `true`:
+
+[source,yaml]
+----
+grails:
+    converters:
+        json:
+            legacy: true
+----
+
+The JSON converter then renders values with the marshallers of Grails 8, and 
indents pretty printed JSON as Grails 8 did,
+so `render ... as JSON` and `JSON#toString` return the same text as in Grails 
8. The application's `JsonMapper` renders
+domain classes as Jackson beans, as it did in Grails 8. What Grails 8 failed 
to render, or rendered as invalid JSON, such
+as a single value or `NaN`, renders as in Grails 9. The setting is deprecated, 
and will be removed in Grails 10.
+
+To keep the Grails 8 rendering of some types only, register a marshaller for 
each, for example in `BootStrap.init`:
+
+[source,groovy]
+----
+JSON.registerObjectMarshaller(Month) { Month month -> month.name() }
+JSON.registerObjectMarshaller(Locale) { Locale locale -> locale.toString() }
+JSON.registerObjectMarshaller(byte[]) { byte[] bytes -> bytes as List }
+----
+
+==== 2. Domain Classes Render Through Jackson
+
+The JSON converter renders domain classes with a Jackson serializer, and 
Grails registers it, as a Jackson module, with
+the application's `JsonMapper`. Writing a domain class instance with the 
application's `JsonMapper`, including returning
+one from a Spring MVC controller, now renders it as `render ... as JSON` does: 
associated instances as references of their
+id, such as `{"id":3}`, rather than in full, and proxies as the instances they 
proxy. Properties that Jackson annotations

Review Comment:
   This sentence promises more than the serializer does. It honours three 
things: `@JsonIgnore` and `@JsonProperty(access = WRITE_ONLY)` on a field or 
getter, and class-level `@JsonIgnoreProperties`. Every other annotation 
Jackson's bean serializer applied to a domain class is dropped on the mapper 
path, because values go through `writePOJO` with no property context: 
`@JsonInclude(NON_NULL)` and 
`spring.jackson.default-property-inclusion=non_null` (nulls are now always 
written), `@JsonView`, `@JsonFilter`, `@JsonIgnoreType`, mix-ins, 
`@JsonAutoDetect`, and the per-property `@JsonProperty("rename")`, 
`@JsonFormat(pattern = ...)`, `@JsonSerialize(using = ...)`, 
`@JsonPropertyOrder`, `@JsonUnwrapped`, `@JsonAnyGetter`. A `@RestController` 
returning a domain class whose `dateCreated` has `@JsonFormat(pattern = 
"yyyy-MM-dd")` now renders the ISO instant, and one with 
`@JsonInclude(NON_NULL)` now renders `"shelf":null`. The bullet in 
`defaultRenderers.adoc` lists only the three honoured ca
 ses, so please narrow this sentence to match it, or honour property inclusion 
at least, since that one is a Boot property every application can have set.



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