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


##########
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:
   Fixed in f520e7dcbf. `DomainClassSerializer` overrides 
`unwrappingSerializer(NameTransformer)` and `isUnwrappingSerializer()`. The 
unwrapping variant skips the start and end of the object and passes every name 
through the transformer.
   
   The spec has `@JsonUnwrapped` with and without a prefix. Unwrapped 
properties land where the property is, in the order the mapper writes the 
enclosing bean.
   
   I haven't checked Spring HATEOAS's `EntityModel`. Since its content is a 
plain `@JsonUnwrapped` property, it should take the same path.



##########
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:
   Fixed in f520e7dcbf, as you suggested, in `serialize()` only. On the mapper 
path, property names come from Jackson's own introspection of the class 
(`BeanPropertyDefinition.getName()`, cached per type), so the naming strategy, 
`@JsonNaming` and `@JsonProperty("rename")` apply. The converter's `value()` 
path writes the names as they are, so `render as JSON` is unchanged. The spec 
covers `SNAKE_CASE`, `@JsonNaming(KebabCaseStrategy)` and a renamed property, 
and checks the converter alongside.
   
   I left sorting out on purpose. Jackson 3 enables 
`SORT_PROPERTIES_ALPHABETICALLY` by default, so honouring it would reorder 
every domain class on the mapper away from the converter's order (id, version, 
then the persistent properties), and the module exists to match that order. The 
spec pins this with and without the feature, and the guide says properties keep 
the converter's order.



##########
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:
   Fixed in f520e7dcbf. `isDomainClass` now matches the class itself, after 
`trimProxySuffix`, or a proxy class of a domain class: a GORM `EntityProxy`, or 
a Hibernate `HibernateProxy`, checked by interface name. A plain subclass, a 
Spock `Spy` or an anonymous subclass is left to the bean serializer.
   
   The spec renders a `VolumeSubclass` through the mapper and the converter 
without the `IllegalStateException`. Its stand-in proxy now implements 
`EntityProxy`, as GORM's proxies do.



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