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


##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassSerializer.java:
##########
@@ -0,0 +1,441 @@
+/*
+ *  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.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+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 java.util.concurrent.ConcurrentHashMap;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.core.JsonToken;
+import tools.jackson.core.type.WritableTypeId;
+import tools.jackson.databind.JacksonSerializable;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.ValueSerializer;
+import tools.jackson.databind.introspect.BeanPropertyDefinition;
+import tools.jackson.databind.jsontype.TypeSerializer;
+import tools.jackson.databind.ser.std.StdSerializer;
+import tools.jackson.databind.util.NameTransformer;
+
+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.
+ *
+ * <p>Written by a mapper, rather than by the JSON converter, an instance's 
property names are those Jackson gives the
+ * class's properties, so the mapper's naming strategy, {@code @JsonNaming} 
and {@code @JsonProperty} names apply. The
+ * properties are written in the converter's order, whether or not the mapper 
sorts properties. The serializer writes
+ * the type id of a class with {@code @JsonTypeInfo}, and can write an 
instance's properties into an enclosing object,
+ * for a property annotated with {@code @JsonUnwrapped}.
+ *
+ * @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;
+
+    private final NameTransformer unwrapper;
+
+    private final Map<Class<?>, Names> namesByType = new ConcurrentHashMap<>();
+
+    /**
+     * @param rendering how to render domain class instances
+     */
+    public DomainClassSerializer(DomainClassRendering rendering) {
+        this(rendering, null);
+    }
+
+    private DomainClassSerializer(DomainClassRendering rendering, 
NameTransformer unwrapper) {
+        super(Object.class);
+        this.rendering = rendering;
+        this.unwrapper = unwrapper;
+    }
+
+    /**
+     * @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 ValueSerializer<Object> unwrappingSerializer(NameTransformer 
nameTransformer) {
+        return new DomainClassSerializer(rendering,
+                unwrapper == null ? nameTransformer : 
NameTransformer.chainedTransformer(nameTransformer, unwrapper));
+    }
+
+    @Override
+    public boolean isUnwrappingSerializer() {
+        return unwrapper != null;
+    }
+
+    @Override
+    public void serialize(Object value, JsonGenerator gen, 
SerializationContext ctxt) {
+        Object object = rendering.unwrap(value);
+        boolean wrapped = unwrapper == null;
+        if (wrapped) {
+            gen.writeStartObject(object);
+        }
+        writeContents(object, gen, ctxt);
+        if (wrapped) {
+            gen.writeEndObject();
+        }
+    }
+
+    @Override
+    public void serializeWithType(Object value, JsonGenerator gen, 
SerializationContext ctxt, TypeSerializer typeSer) {
+        if (unwrapper != null) {
+            serialize(value, gen, ctxt);
+            return;
+        }
+        Object object = rendering.unwrap(value);
+        WritableTypeId typeId = typeSer.writeTypePrefix(gen, ctxt, 
typeSer.typeId(object, JsonToken.START_OBJECT));
+        writeContents(object, gen, ctxt);
+        typeSer.writeTypeSuffix(gen, ctxt, typeId);
+    }
+
+    /**
+     * Writes an instance's properties, or a reference to it when it is 
already being written further up.
+     */
+    private void writeContents(Object object, JsonGenerator gen, 
SerializationContext ctxt) {
+        Names names = names(object.getClass(), ctxt);
+        Set<Object> instances = renderingInstances(ctxt);
+        if (!instances.add(object)) {
+            writeReferenceProperties(object, entity(object, rendering), gen, 
rendering, names);

Review Comment:
   A custom `DomainClassRendering.writeReference` is no longer called for a 
cycle reference. At ebf560153b the cycle path went through 
`writeReference(object, …)`, which asked `rendering.writeReference(...)` first. 
Now `writeContents` writes the reference properties directly, after 
`serialize`/`serializeWithType` has already written the start of the object or 
the type prefix, so the hook can't take over there. On a deep cycle with a 
rendering whose `writeReference` throws, the output is 
`{"id":1,"name":"first","partner":{"id":2,"name":"second","partner":{"id":1}}}` 
and the hook is never called. The shallow association path still calls it. 
Detecting the cycle in `serialize`/`serializeWithType` before anything is 
written would let `rendering.writeReference(...)` decide again, as it does for 
associations.



##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassSerializer.java:
##########
@@ -0,0 +1,441 @@
+/*
+ *  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.HashMap;
+import java.util.HashSet;
+import java.util.IdentityHashMap;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+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 java.util.concurrent.ConcurrentHashMap;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import tools.jackson.core.JsonGenerator;
+import tools.jackson.core.JsonToken;
+import tools.jackson.core.type.WritableTypeId;
+import tools.jackson.databind.JacksonSerializable;
+import tools.jackson.databind.SerializationContext;
+import tools.jackson.databind.ValueSerializer;
+import tools.jackson.databind.introspect.BeanPropertyDefinition;
+import tools.jackson.databind.jsontype.TypeSerializer;
+import tools.jackson.databind.ser.std.StdSerializer;
+import tools.jackson.databind.util.NameTransformer;
+
+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.
+ *
+ * <p>Written by a mapper, rather than by the JSON converter, an instance's 
property names are those Jackson gives the
+ * class's properties, so the mapper's naming strategy, {@code @JsonNaming} 
and {@code @JsonProperty} names apply. The
+ * properties are written in the converter's order, whether or not the mapper 
sorts properties. The serializer writes
+ * the type id of a class with {@code @JsonTypeInfo}, and can write an 
instance's properties into an enclosing object,
+ * for a property annotated with {@code @JsonUnwrapped}.
+ *
+ * @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;
+
+    private final NameTransformer unwrapper;
+
+    private final Map<Class<?>, Names> namesByType = new ConcurrentHashMap<>();
+
+    /**
+     * @param rendering how to render domain class instances
+     */
+    public DomainClassSerializer(DomainClassRendering rendering) {
+        this(rendering, null);
+    }
+
+    private DomainClassSerializer(DomainClassRendering rendering, 
NameTransformer unwrapper) {
+        super(Object.class);
+        this.rendering = rendering;
+        this.unwrapper = unwrapper;
+    }
+
+    /**
+     * @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 ValueSerializer<Object> unwrappingSerializer(NameTransformer 
nameTransformer) {
+        return new DomainClassSerializer(rendering,
+                unwrapper == null ? nameTransformer : 
NameTransformer.chainedTransformer(nameTransformer, unwrapper));
+    }
+
+    @Override
+    public boolean isUnwrappingSerializer() {
+        return unwrapper != null;
+    }
+
+    @Override
+    public void serialize(Object value, JsonGenerator gen, 
SerializationContext ctxt) {
+        Object object = rendering.unwrap(value);
+        boolean wrapped = unwrapper == null;
+        if (wrapped) {
+            gen.writeStartObject(object);
+        }
+        writeContents(object, gen, ctxt);
+        if (wrapped) {
+            gen.writeEndObject();
+        }
+    }
+
+    @Override
+    public void serializeWithType(Object value, JsonGenerator gen, 
SerializationContext ctxt, TypeSerializer typeSer) {
+        if (unwrapper != null) {

Review Comment:
   For the unwrapping variant this drops the type id silently. `@JsonUnwrapped 
Dog dog` with `@JsonTypeInfo(use = NAME)` on `Animal` renders 
`{"id":2,"name":"Rex","note":"n"}`. Jackson's own 
`UnwrappingBeanSerializer.serializeWithType` throws 
`InvalidDefinitionException` here, because 
`SerializationFeature.FAIL_ON_UNWRAPPED_TYPE_IDENTIFIERS` is on by default. I 
checked that with an equivalent plain bean. Matching it would be `if 
(ctxt.isEnabled(SerializationFeature.FAIL_ON_UNWRAPPED_TYPE_IDENTIFIERS)) 
ctxt.reportBadDefinition(handledType(), …)` before falling through to 
`serialize`.



##########
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:
   The `String.valueOf` fallback fixes the loop, but it now also catches a 
`Number` subclass that has its own Jackson serializer, because `JSON.value` 
sends every `Number` to `writer.value(Number)` before any marshaller is looked 
up. A `Money extends Number` with a module serializer writing `"$5"`: the 
mapper writes `{"m":"$5"}`, and the converter now writes `{"m":Money(5)}`, 
which is invalid JSON. At ebf560153b the fallback went through the mapper and 
honoured the module. That contradicts the guide's promise that types a 
registered module serializes render as the mapper renders them. Legacy is 
unaffected, since 8.0.x wrote the same `toString()`.
   
   One way out: in `JSON.value`, short-circuit only the JDK number types 
(`Integer`, `Long`, `Short`, `Byte`, `Double`, `Float`, `BigDecimal`, 
`BigInteger`, and the atomics if you like). Other `Number`s would then go 
through marshaller lookup, so `JsonMapperValueMarshaller` claims them through 
`rendersValue`. That top-level path writes a `JsonMapperValue`, so it doesn't 
re-enter the nested writer. The `String.valueOf` fallback can stay for a 
`Number` passed to `writer.value(Number)` directly.



##########
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);
+        ConvertersConfigurationHolder.setLegacyJson(legacy);
+        if (legacy) {
+            LOG.warn(SETTING_CONVERTERS_JSON_LEGACY + " renders JSON as Grails 
8 did. It is deprecated, and will be removed in Grails 10.");
+            addLegacyJsonMarshallers(marshallers, grailsConfig, proxyHandler);
+        }
+        else {
+            addJsonMarshallers(marshallers, grailsConfig, proxyHandler);
+        }
 
-        marshallers.add(new 
org.grails.web.converters.marshaller.ProxyUnwrappingMarshaller<>());
+        DefaultConverterConfiguration<JSON> cfg = new 
DefaultConverterConfiguration<>(marshallers, proxyHandler);
+        cfg.setEncoding(grailsConfig.getProperty(SETTING_CONVERTERS_ENCODING, 
"UTF-8"));
+        String defaultCirRefBehaviour = 
grailsConfig.getProperty(SETTING_CONVERTERS_CIRCULAR_REFERENCE_BEHAVIOUR, 
"DEFAULT");
+        
cfg.setCircularReferenceBehaviour(Converter.CircularReferenceBehaviour.valueOf(
+                
grailsConfig.getProperty(SETTING_CONVERTERS_JSON_CIRCULAR_REFERENCE_BEHAVIOUR, 
String.class,
+                      defaultCirRefBehaviour, 
Converter.CircularReferenceBehaviour.allowedValues())));
+
+        Boolean defaultPrettyPrint = 
grailsConfig.getProperty(SETTING_CONVERTERS_PRETTY_PRINT, Boolean.class, false);
+        Boolean prettyPrint = 
grailsConfig.getProperty(SETTING_CONVERTERS_JSON_PRETTY_PRINT, Boolean.class, 
defaultPrettyPrint);
+        cfg.setPrettyPrint(prettyPrint);
+        
cfg.setCacheObjectMarshallerByClass(grailsConfig.getProperty(SETTING_CONVERTERS_JSON_CACHE_OBJECTS,
 Boolean.class, true));
+
+        registerObjectMarshallersFromApplicationContext(cfg, JSON.class);
 
+        ConvertersConfigurationHolder.setDefaultConfiguration(JSON.class, new 
ChainedConverterConfiguration<>(cfg, proxyHandler));
+    }
+
+    private void addJsonMarshallers(List<ObjectMarshaller<JSON>> marshallers, 
Config grailsConfig, ProxyHandler proxyHandler) {
         if 
("javascript".equals(grailsConfig.getProperty(SETTING_CONVERTERS_JSON_DATE, 
String.class, "default", Arrays.asList("javascript", "default")))) {
             if (LOG.isDebugEnabled()) {
                 LOG.debug("Using Javascript JSON Date Marshaller.");
             }
             marshallers.add(new 
org.grails.web.converters.marshaller.json.JavascriptDateMarshaller());
         }
+        // domain classes, and proxies of them, are claimed first: the 
application's JsonMapper may have a serializer for
+        // them, and they are rendered with the converter's includes, excludes 
and deep setting
+        marshallers.add(new 
org.grails.web.converters.marshaller.ProxyUnwrappingMarshaller<>());
+        marshallers.add(domainClassMarshaller(grailsConfig, proxyHandler));
+        // dates and times and the other single values the application's 
JsonMapper has a serializer for are written
+        // by the mapper, as Spring Boot writes them
+        marshallers.add(new 
org.grails.web.converters.marshaller.json.JsonMapperValueMarshaller());

Review Comment:
   This still misses an enum constant with a body. For `ACTIVE('A') { … }`, the 
runtime class is the constant's anonymous subclass. Jackson resolves a 
`JsonValueSerializer` for it, so the new check in `rendersValue` returns false, 
which is right. But `SimpleEnumMarshaller.supports` uses 
`object.getClass().isEnum()`, which is false for that subclass. It falls 
through to `GroovyBeanMarshaller`: `[s: BodyStatus.ACTIVE]` renders 
`{"s":{"code":"A","declaringClass":"…BodyStatus","name":"ACTIVE","ordinal":0}}`.
 At ebf560153b it rendered `"A"`, and the guide promises `"ACTIVE"`. Constants 
without a body are fine.
   
   Using `object instanceof Enum` in `supports` for the new mode would fix it, 
since `name()` is already found through the superclass. Legacy can keep 
`isEnum()` to stay identical to Grails 8, where a body constant also rendered 
as a bean.



##########
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) {
+        Object object = rendering.unwrap(value);
+        Set<Object> instances = renderingInstances(ctxt);
+        if (!instances.add(object)) {
+            writeReference(object, entity(object, rendering), gen, rendering);
+            return;
+        }
+        try {
+            write(object, gen, ctxt, rendering);
+        }
+        finally {
+            instances.remove(object);
+        }
+    }
+
+    private static void write(Object value, JsonGenerator gen, 
SerializationContext ctxt,
+            DomainClassRendering rendering) {
+        Object object = rendering.unwrap(value);
+        PersistentEntity entity = entity(object, rendering);
+        Set<String> ignored = IGNORED_PROPERTIES.get(object.getClass());
+        gen.writeStartObject(object);
+        if (rendering.isIncludeClass() && rendering.includes(object, "class")) 
{
+            gen.writeStringProperty("class", object.getClass().getName());
+        }
+        // a composite key has no identity, and is not written
+        PersistentProperty identity = entity.getIdentity();
+        if (identity != null && !ignored.contains(identity.getName()) && 
rendering.includes(object, identity.getName())) {
+            Object id = rendering.propertyValue(object, identity);
+            if (id != null) {
+                gen.writeName(identity.getName());
+                gen.writePOJO(id);
+            }
+        }
+        if (rendering.isIncludeVersion() && 
!ignored.contains(GormProperties.VERSION) &&
+                rendering.includes(object, GormProperties.VERSION)) {
+            Object version = rendering.propertyValue(object, 
entity.getVersion());
+            if (version != null) {
+                gen.writeName(GormProperties.VERSION);
+                gen.writePOJO(version);
+            }
+        }
+        BeanWrapper bean = new BeanWrapperImpl(object);
+        for (PersistentProperty property : entity.getPersistentProperties()) {
+            if (property.equals(entity.getVersion()) || 
ignored.contains(property.getName()) ||
+                    !rendering.includes(object, property.getName())) {
+                continue;
+            }
+            gen.writeName(property.getName());

Review Comment:
   The instance's own names follow the strategy now, but references don't do so 
consistently. Under `UPPER_CAMEL_CASE`, an instance writes 
`{"Id":3,"Version":4,"Name":"top"}`. An association reference writes 
`"Shelf":{"id":3}`, because it uses `Names.AS_THEY_ARE`. A cycle reference 
writes `"Partner":{"Id":1}`, with the instance's names. So the same reference 
shape is `{"id":…}` or `{"Id":…}` depending on how it was reached. Likewise, 
with `@JsonProperty('key') Long id`, the instance writes `"key"` but every 
reference to it writes `"id"`. Either rule is fine, raw names for references or 
the referenced entity's mapped id name, but the association and cycle paths 
should use the same one.



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