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


##########
grails-converters/src/main/groovy/grails/converters/JSON.java:
##########
@@ -220,6 +228,26 @@ else if (o instanceof Number) {
         }
     }
 
+    /**
+     * @return the JsonMapper this converter writes with
+     * @since 9.0
+     */
+    public JsonMapperSupport getJsonMapper() {
+        return jsonMapper != null ? jsonMapper : 
ConvertersConfigurationHolder.getJsonMapper();
+    }
+
+    /**
+     * The JSON object key for a map key, as the JsonMapper writes it: a 
{@code String} key as it is, and a
+     * {@code Date} key, for example, in the mapper's date format.
+     *
+     * @param key a non-null map key
+     * @return the JSON object key
+     * @since 9.0
+     */
+    public String formatKey(Object key) {
+        return getJsonMapper().formatKey(key);

Review Comment:
   Map keys go through the application mapper's key serializers in legacy mode 
too. Grails 8 used `JsonDateFormat.formatKey`, which is `toString()` for 
everything except dates. With legacy on:
   
   - a `Date(0)` key, with `spring.jackson.time-zone: America/New_York`, 
renders `"1969-12-31T19:00:00.000-05:00"`; Grails 8 wrote 
`"1970-01-01T00:00:00.000Z"`
   - a `Class` key renders `"java.lang.String"`; Grails 8 wrote `"class 
java.lang.String"`
   - a key whose class has `@JsonValue` renders the `@JsonValue`; Grails 8 
wrote `toString()`
   
   The `Class` and `@JsonValue` cases differ even with a default mapper. 
Branching here on `ConvertersConfigurationHolder.isLegacyJson()` to 
`JsonDateFormat.formatKey` would restore it. The `Class` key change in the new 
mode isn't in the upgrade guide's table either.



##########
grails-converters/src/main/groovy/org/grails/web/converters/configuration/ConvertersConfigurationInitializer.java:
##########
@@ -97,6 +116,8 @@ public void initialize() {
         if (applicationContext != null) {
             ConvertersConfigurationHolder.setObservationRegistry(
                     
applicationContext.getBeanProvider(ObservationRegistry.class).getIfAvailable(() 
-> ObservationRegistry.NOOP));
+            // the application's JsonMapper, or a default one when there is 
none, or more than one and none is primary
+            
ConvertersConfigurationHolder.setJsonMapper(applicationContext.getBeanProvider(JsonMapper.class).getIfUnique());

Review Comment:
   The application's mapper is installed even when 
`grails.converters.json.legacy` is true. Legacy text is then written by a 
generator copied from it, so ordinary Boot write settings change the output 
that is meant to be Grails 8's. Legacy mode with a mapper that has 
`INDENT_OUTPUT`, `WRITE_NUMBERS_AS_STRINGS` and `WRITE_BIGDECIMAL_AS_PLAIN` 
enabled renders `[n: 42, b: 1E+3]` as:
   
   ```
   {
     "n" : "42",
     "b" : "1000"
   }
   ```
   
   Grails 8 wrote `{"n":42,"b":1E+3}`. `toString(false)` is indented as well. 
These map to `spring.jackson.serialization.indent-output`, 
`spring.jackson.json.write.*` and `spring.jackson.write.*`, which an app may 
already have set for its `@RestController`s. Could legacy mode use 
`JsonMapperSupport.DEFAULT` (or another fixed mapper) instead? A case with a 
non-default mapper in `Grails8JsonRenderingSpec` would cover it.



##########
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:
   `JsonMapperValueMarshaller` sits ahead of `SimpleEnumMarshaller`. For an 
enum with a `@JsonValue` method, Jackson's serializer is a 
`JsonValueSerializer`, not an `EnumSerializer`, so `rendersValue` is true and 
the mapper renders it. `enum Status { ACTIVE('A'); @JsonValue String getCode() 
{ code } }` renders `"A"`, where Grails 8 and legacy render `"ACTIVE"`.
   
   Posting that back binds `null` without an error, because 
`SimpleDataBinder.convertStringToEnum` swallows the `IllegalArgumentException`. 
That contradicts `upgrading90x.adoc:56` and `defaultRenderers.adoc:93` ("Enums 
render by `name()` … so that the JSON binds back"). The `@JsonValue` row in the 
table says Grails 8 wrote "bean objects", but for an enum it wrote `name()`.
   
   Either let `SimpleEnumMarshaller` claim enums (other than `Month`) first, or 
give enums their own row and drop the binds-back sentence.



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

Review Comment:
   This doesn't override `unwrappingSerializer(NameTransformer)` or 
`isUnwrappingSerializer()`, so `@JsonUnwrapped` on a property holding a domain 
instance stops unwrapping. `UnwrappingBeanPropertyWriter` writes the name and 
nests the object. `class Wrapper { @JsonUnwrapped Book book; String note }` 
renders `{"bookTitle":"G","id":1,"version":null,"note":"n"}` without the module 
and `{"book":{"id":1,"bookTitle":"G"},"note":"n"}` with it. I haven't checked 
Spring HATEOAS, but `EntityModel<T>` holds its content as `@JsonUnwrapped`, so 
`EntityModel<Book>` is likely to change shape too. An unwrapping variant would 
fix it: it skips the start and end of the object and passes property names 
through the `NameTransformer`.



##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassJacksonModule.java:
##########
@@ -0,0 +1,102 @@
+/*
+ *  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.util.Objects;
+import java.util.function.Supplier;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import tools.jackson.core.Version;
+import tools.jackson.databind.BeanDescription;
+import tools.jackson.databind.JacksonModule;
+import tools.jackson.databind.JavaType;
+import tools.jackson.databind.SerializationConfig;
+import tools.jackson.databind.ValueSerializer;
+import tools.jackson.databind.ser.Serializers;
+
+/**
+ * A Jackson module that serializes domain class instances, and proxies of 
them, with a {@link DomainClassSerializer},
+ * as {@code grails.converters.JSON} renders them. A Grails application 
registers it with the application's
+ * {@code JsonMapper}, so that writing a domain class instance with the 
mapper, including returning one from a
+ * Spring MVC controller, renders it as {@code render ... as JSON} does.
+ *
+ * <p>A domain class with its own {@code @JsonSerialize} serializer keeps it.
+ *
+ * @since 9.0
+ */
+public class DomainClassJacksonModule extends JacksonModule {
+
+    private final Supplier<DomainClassRendering> rendering;
+
+    /**
+     * @param rendering how to render domain class instances
+     */
+    public DomainClassJacksonModule(DomainClassRendering rendering) {
+        this(() -> rendering);
+    }
+
+    /**
+     * @param rendering supplies how to render domain class instances, when 
the first one is serialized
+     */
+    public DomainClassJacksonModule(Supplier<DomainClassRendering> rendering) {
+        this.rendering = Objects.requireNonNull(rendering, "rendering cannot 
be null");
+    }
+
+    @Override
+    public String getModuleName() {
+        return "grails-domain-classes";
+    }
+
+    @Override
+    public Version version() {
+        return Version.unknownVersion();
+    }
+
+    @Override
+    public void setupModule(SetupContext context) {
+        context.addSerializers(new DomainClassSerializers(rendering));
+    }
+
+    private static final class DomainClassSerializers implements Serializers {
+
+        private final Supplier<DomainClassRendering> rendering;
+
+        private volatile DomainClassSerializer serializer;
+
+        private DomainClassSerializers(Supplier<DomainClassRendering> 
rendering) {
+            this.rendering = rendering;
+        }
+
+        @Override
+        public ValueSerializer<?> findSerializer(SerializationConfig config, 
JavaType type,
+                BeanDescription.Supplier beanDescRef, JsonFormat.Value 
formatOverrides) {
+            DomainClassSerializer domainClassSerializer = serializer();
+            return 
domainClassSerializer.getRendering().isDomainClass(type.getRawClass()) ? 
domainClassSerializer : null;

Review Comment:
   Jackson asks module `Serializers` before it looks at `@JsonValue` and 
`JacksonSerializable`. In `BeanSerializerFactory._createSerializer2`, 
`findSerializerByAnnotations` comes after the module lookup, and only a 
class-level `@JsonSerialize` is checked earlier. So a domain class that uses 
either of them loses it on the application's mapper. `@Entity class Code { 
String code; @JsonValue String jsonValue() { code } }` returned from a 
`@RestController` renders `"X1"` without the module and `{"id":2,"code":"X1"}` 
with it. Returning `null` here when `beanDescRef.get().findJsonValueAccessor() 
!= null`, or when the type implements `JacksonSerializable`, would leave those 
to Jackson. Otherwise they should go in the guide next to `@JsonSerialize`.



##########
grails-converters/src/main/groovy/org/grails/web/converters/jackson/DomainClassRendering.java:
##########
@@ -0,0 +1,220 @@
+/*
+ *  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.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import groovy.lang.GroovyObject;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import grails.core.GrailsApplication;
+import grails.core.support.proxy.DefaultProxyHandler;
+import grails.core.support.proxy.EntityProxyHandler;
+import grails.core.support.proxy.ProxyHandler;
+import org.grails.core.artefact.DomainClassArtefactHandler;
+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.reflect.ClassPropertyFetcher;
+import org.grails.web.converters.ConverterUtil;
+import org.grails.web.converters.marshaller.ByDatasourceDomainClassFetcher;
+import 
org.grails.web.converters.marshaller.ByGrailsApplicationDomainClassFetcher;
+import org.grails.web.converters.marshaller.DomainClassFetcher;
+
+/**
+ * How a {@link DomainClassSerializer} renders domain class instances: whether 
it writes the version and the class
+ * name, whether it renders associated domain class instances in full (deep) 
or as references of their id, and which
+ * properties it writes.
+ *
+ * <p>The defaults are the converter's JSON defaults ({@code 
grails.converters.json.domain.include.version},
+ * {@code grails.converters.json.domain.include.class} and {@code 
grails.converters.json.default.deep}), and every
+ * property is written except those named by {@code @JsonIgnoreProperties} on 
the domain class or a superclass, as
+ * Jackson leaves them out. GORM adds one to every domain class that names its 
own properties, such as
+ * {@code errors} and {@code tenantId}; the version is written when it is 
included regardless. Subclasses override the
+ * methods to render differently.
+ *
+ * @since 9.0
+ */
+public class DomainClassRendering {
+
+    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()) {
+                JsonIgnoreProperties ignoreProperties = 
current.getAnnotation(JsonIgnoreProperties.class);
+                if (ignoreProperties != null && 
!ignoreProperties.allowGetters()) {
+                    ignored.addAll(Arrays.asList(ignoreProperties.value()));
+                }
+            }
+            return Set.copyOf(ignored);
+        }
+    };
+
+    private final GrailsApplication grailsApplication;
+
+    private final ProxyHandler proxyHandler;
+
+    private final boolean includeVersion;
+
+    private final boolean includeClass;
+
+    private final boolean deep;
+
+    private final List<DomainClassFetcher> domainClassFetchers;
+
+    /**
+     * @param grailsApplication the application whose domain classes are 
rendered
+     * @param proxyHandler unwraps proxied domain class instances
+     * @param includeVersion whether to write the version
+     * @param includeClass whether to write the class name
+     * @param deep whether to render associated domain class instances in 
full, rather than as references of their id
+     */
+    public DomainClassRendering(GrailsApplication grailsApplication, 
ProxyHandler proxyHandler, boolean includeVersion,
+            boolean includeClass, boolean deep) {
+        this.grailsApplication = grailsApplication;
+        this.proxyHandler = proxyHandler != null ? proxyHandler : new 
DefaultProxyHandler();
+        this.includeVersion = includeVersion;
+        this.includeClass = includeClass;
+        this.deep = deep;
+        this.domainClassFetchers = grailsApplication != null ?
+                List.of(new 
ByGrailsApplicationDomainClassFetcher(grailsApplication), new 
ByDatasourceDomainClassFetcher()) :
+                List.of(new ByDatasourceDomainClassFetcher());
+    }
+
+    /**
+     * @return whether the version is written
+     */
+    public boolean isIncludeVersion() {
+        return includeVersion;
+    }
+
+    /**
+     * @return whether the class name is written, of an instance and of the 
references to associated instances
+     */
+    public boolean isIncludeClass() {
+        return includeClass;
+    }
+
+    /**
+     * @return whether associated domain class instances are rendered in full, 
rather than as references of their id
+     */
+    public boolean isDeep() {
+        return deep;
+    }
+
+    /**
+     * @param object a domain class instance
+     * @param property the name of one of its properties, or {@code class} or 
{@code version}
+     * @return whether the property is written
+     */
+    public boolean includes(Object object, String property) {
+        return GormProperties.VERSION.equals(property) || 
!IGNORED_PROPERTIES.get(object.getClass()).contains(property);
+    }
+
+    /**
+     * @param type a type
+     * @return whether the type is a domain class, or a proxy class of one
+     */
+    public boolean isDomainClass(Class<?> type) {
+        if (grailsApplication == null) {
+            return false;
+        }
+        for (Class<?> current = type; current != null && current != 
Object.class; current = current.getSuperclass()) {
+            if 
(grailsApplication.isArtefactOfType(DomainClassArtefactHandler.TYPE,

Review Comment:
   `isDomainClass` walks superclasses, but `DomainClassMarshaller.supports` 
only matches the exact artefact name, so a non-artefact subclass of a domain 
class falls between them. That covers `class BookSub extends Book`, a Spock 
`Spy(Book)` or an anonymous subclass. `DomainClassSerializer` isn't in 
`rendersValue`'s exclusion list, so `JsonMapperValueMarshaller` claims what 
`DomainClassMarshaller` declined. Both `jsonMapper.writeValueAsString(sub)` and 
`new JSON(sub).toString()` then fail with `IllegalStateException: No GORM 
implementation configured for class [...BookSub]`. Before this PR, the 
converter rendered it with `GroovyBeanMarshaller` and Boot's mapper as a bean. 
Matching only a name that trims to an artefact (the class itself or its proxy 
classes) would leave other subclasses to the bean serializer.



##########
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:
   Property names (and `class`, the id name and `version` above) are written as 
they are, so the mapper's `PropertyNamingStrategy` no longer applies to domain 
classes on the mapper path. With `spring.jackson.property-naming-strategy: 
SNAKE_CASE`, a domain class renders `{"book_title":"G","id":1,"version":null}` 
without the module and `{"id":1,"bookTitle":"G"}` with it. The same goes for 
`@JsonNaming` and `spring.jackson.mapper.sort-properties-alphabetically`. This 
is a mapper setting rather than an annotation, so it isn't covered by the guide 
thread. The converter writes through a copy of the same mapper, so applying the 
strategy in `serialize()` only, not in the converter's `value()` path, would 
keep `render as JSON` unchanged.



##########
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:
   For breaking default changes in a major, Grails 8 did what this PR does. The 
new behaviour became the default, and an application could restore the old one 
with a setting:
   
   - Unconstrained GORM properties became nullable, restored with 
`grails.gorm.default.nullable: false` ([upgrade guide 
§27](https://github.com/apache/grails-core/blob/3d6db18699d30a459f2dd1a277ad6d3ba61311ee/grails-doc/src/en/guide/upgrading/upgrading80x.adoc#L1448-L1488)).
   - Native queries return `java.time` types, restored with 
`hibernate.query.native.prefer_jdbc_datetime_types: true` "during a phased 
migration" 
([§26.5](https://github.com/apache/grails-core/blob/3d6db18699d30a459f2dd1a277ad6d3ba61311ee/grails-doc/src/en/guide/upgrading/upgrading80x.adoc#L1288-L1301)).
   - The `Accept` header is honoured for browsers, restored by setting 
`grails.mime.disable.accept.header.userAgents` 
([§30](https://github.com/apache/grails-core/blob/3d6db18699d30a459f2dd1a277ad6d3ba61311ee/grails-doc/src/en/guide/upgrading/upgrading80x.adoc#L1614-L1646)).
   - `defaultViewResolver` and the welcome page are removed, restored with 
`grails.web.removeDefaultViewResolverBean: false` and 
`grails.web.removeWelcomePageMapping: false` (§31).
   
   The Grails 8 change closest to this one, the enum JSON shape, had no switch 
at all 
([§8](https://github.com/apache/grails-core/blob/3d6db18699d30a459f2dd1a277ad6d3ba61311ee/grails-doc/src/en/guide/upgrading/upgrading80x.adoc#L307-L330):
 "There is no Grails 8 configuration switch that restores the verbose 
enum-object shape").
   
   So I'd keep the new output as the 9.0 default, with 
`grails.converters.json.legacy: true` as the way back. That holds once the 
legacy-mode gaps in my review are fixed, so that the switch really gives Grails 
8's text.
   
   Where I agree is the timeline. None of the Grails 8 switches above was 
deprecated for removal in the release that added it. Making this one 
`forRemoval` and logging a warning from day one leaves less room than we gave 
there. I'd keep the setting without a deprecation in 9, or at least not name 
Grails 10 for its removal until we know how many applications depend on it.



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