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]
