diff --git a/CHANGELOG.md b/CHANGELOG.md index a823493a6..3d1ffacba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ for the CLI, to [npm](https://www.npmjs.com/package/@storm-orm/cli) ## [Unreleased] +- Fixed `@Json(failOnUnknown = true)` on Jackson 3 and `@Json(failOnMissing = true)` on Jackson 2 and 3, which accepted the JSON they were asked to refuse: the converters only ever turned a check off, and Jackson's defaults differ per version and per check. Both converters now set `failOnUnknown` and `failOnMissing` explicitly, so a field that asks for either check gets it (#409). The Jackson 2, Jackson 3 and kotlinx.serialization modules share their library-independent code through storm-core, and the two Jackson modules run the same test suite. Three smaller differences went with it: a `@Json` array field of a sealed type, such as `Shape[]`, registers the permitted subtypes as a `List` field does; a raw id read for a ref to a type without a `@PK` field is an `Integer` when it fits, as the documented fallback said, where it was always a `Long`; and `{"@entity": null}` reads as no ref on Jackson 2 as it does on Jackson 3, where it was an error. + - The failures Storm's own logic detects after a statement ran pass through the template's `ExceptionMapper` (#240): an optimistic lock conflict, a single result that is missing or not unique, a write that affected no row. With the Spring Boot starter they translate as Spring's JPA integration translates their counterparts, `OptimisticLockException` to `OptimisticLockingFailureException`, `NoResultException` to `EmptyResultDataAccessException` and `NonUniqueResultException` to `IncorrectResultSizeDataAccessException`, each with the Storm exception as its cause, so a retry setup keyed on `OptimisticLockingFailureException` sees Storm's conflicts. The default mapper keeps them Storm-typed. The `OptimisticLockException` of a single update now carries the entity. `QueryTemplate.exceptionMapper()` and `QueryFactory.exceptionMapper()` return the configured mapper. - Upgrade note: a Spring catch block or retry rule that names `OptimisticLockException`, `NoResultException` or `NonUniqueResultException` with the starter's exception translation on now sees the Spring type instead; name the Spring type, or read the Storm exception from the cause. diff --git a/storm-core/src/main/java/module-info.java b/storm-core/src/main/java/module-info.java index c570762d7..a68d59012 100644 --- a/storm-core/src/main/java/module-info.java +++ b/storm-core/src/main/java/module-info.java @@ -28,6 +28,7 @@ storm.mysql, storm.oracle, storm.postgresql, + storm.serialization, storm.spring, storm.sqlite, storm.test; diff --git a/storm-core/src/main/java/st/orm/core/template/impl/JsonConverterSupport.java b/storm-core/src/main/java/st/orm/core/template/impl/JsonConverterSupport.java new file mode 100644 index 000000000..b27dae08d --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/template/impl/JsonConverterSupport.java @@ -0,0 +1,243 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.template.impl; + +import static java.util.Collections.singletonList; +import static java.util.Objects.requireNonNull; + +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import org.jspecify.annotations.Nullable; +import st.orm.Json; +import st.orm.SqlTemplateException; +import st.orm.core.spi.JsonString; +import st.orm.core.spi.Name; +import st.orm.core.spi.ORMConverter; +import st.orm.core.spi.ORMReflection; +import st.orm.core.spi.Providers; +import st.orm.core.spi.RefFactory; +import st.orm.mapping.RecordField; + +/** + * The part of a converter for a {@link Json} column that does not depend on the JSON library: the column the + * converter serves, the conversion of the field's value to and from the database, the ref factory of the + * conversion in progress, and the walk of the field's type for the sealed types whose subtypes the library has to + * know. A converter binds a JSON library by implementing {@link #write} and {@link #read}. + * + * @since 1.16 + */ +public abstract class JsonConverterSupport implements ORMConverter { + + private static final ORMReflection REFLECTION = Providers.getORMReflection(); + + /** The ref factory of the conversion in progress on this thread, for the refs the JSON holds. */ + private static final ThreadLocal REF_FACTORY = new ThreadLocal<>(); + + /** The field the converter serves. */ + protected final RecordField field; + + /** + * Creates the converter for the given field. + * + * @param field the field the converter serves. + */ + protected JsonConverterSupport(RecordField field) { + this.field = requireNonNull(field, "field"); + } + + /** + * Returns the ref factory of the conversion in progress on this thread, or {@code null} outside a conversion. + * A deserializer of refs resolves the factory through this method at the moment it creates a ref, so one + * cached mapper serves every conversion. + * + * @return the ref factory of the conversion in progress, or {@code null}. + */ + public static @Nullable RefFactory currentRefFactory() { + return REF_FACTORY.get(); + } + + /** + * Writes the field's value as JSON. + * + * @param value the value; never {@code null}. + * @return the JSON. + * @throws Exception if the library refuses the value. + */ + protected abstract String write(Object value) throws Exception; + + /** + * Reads the field's value from JSON. + * + * @param json the JSON; never {@code null}. + * @return the value; {@code null} when the JSON is the null literal. + * @throws Exception if the library refuses the JSON. + */ + protected abstract @Nullable Object read(String json) throws Exception; + + @Override + public int getParameterCount() { + return 1; + } + + @Override + public List> getParameterTypes() { + return List.of(String.class); + } + + @Override + public List getColumns(NameResolver nameResolver) throws SqlTemplateException { + return List.of(nameResolver.getName(field)); + } + + @Override + public List<@Nullable Object> toDatabase(@Nullable Object record) throws SqlTemplateException { + try { + Object value = record == null ? null : REFLECTION.invoke(field, record); + return singletonList(value == null ? null : new JsonString(write(value))); + } catch (Throwable e) { + throw new SqlTemplateException(e); + } + } + + @Override + public @Nullable Object fromDatabase(@Nullable Object[] values, RefFactory refFactory) throws SqlTemplateException { + Object value = values[0]; + if (value == null) { + return null; + } + // A custom deserializer may issue a query, nesting another fromDatabase call on this thread. The previous + // factory is therefore restored rather than removed, so the remaining fields of the outer value still + // deserialize with the outer factory and produce attached refs. + RefFactory outerRefFactory = REF_FACTORY.get(); + REF_FACTORY.set(refFactory); + try { + return read((String) value); + } catch (Exception e) { + throw new SqlTemplateException(e); + } finally { + if (outerRefFactory == null) { + REF_FACTORY.remove(); + } else { + REF_FACTORY.set(outerRefFactory); + } + } + } + + /** + * The key under which a converter caches the mapper of a field: the field's {@link Json} options, the sealed + * types in its type, and its custom serializer and deserializer classes. A custom serializer is registered + * against the field's raw type, so that type is part of the key whenever one is present; without it, fields of + * different types sharing a serializer class would share a mapper that only serves the first field's type. + * + * @param json the field's {@link Json} options. + * @param sealedTypes the sealed types in the field's type, whose subtypes the mapper registers. + * @param targetType the field's raw type when a custom serializer or deserializer is present, else {@code null}. + * @param serializer the custom serializer class, or {@code null}. + * @param deserializer the custom deserializer class, or {@code null}. + */ + public record MapperKey(Json json, + List> sealedTypes, + @Nullable Class targetType, + @Nullable Class serializer, + @Nullable Class deserializer) { + + /** + * Creates the key for a field of the given type. + * + * @param json the field's {@link Json} options. + * @param type the field's generic type. + * @param serializer the custom serializer class, or {@code null}. + * @param deserializer the custom deserializer class, or {@code null}. + * @return the key. + */ + public static MapperKey of(Json json, + Type type, + @Nullable Class serializer, + @Nullable Class deserializer) { + Class targetType = serializer != null || deserializer != null + ? rawType(type) + : null; + return new MapperKey(requireNonNull(json, "json"), JsonConverterSupport.sealedTypes(type), targetType, + serializer, deserializer); + } + } + + /** + * Returns the raw class of the given type: the class itself, or the raw type of a parameterized type. + * + * @param type the type. + * @return the raw class; {@code Object} for a type without one, such as a type variable. + */ + public static Class rawType(Type type) { + if (type instanceof ParameterizedType parameterizedType) { + return (Class) parameterizedType.getRawType(); + } + if (type instanceof Class clazz) { + return clazz; + } + return Object.class; + } + + /** + * Returns the permitted subclasses of the given sealed class, through the reflection provider in use. + * + * @param sealedClass the sealed class. + * @return the permitted subclasses. + */ + public static List> permittedSubclasses(Class sealedClass) { + return List.copyOf(REFLECTION.getPermittedSubclasses(sealedClass)); + } + + /** + * Collects the sealed classes appearing anywhere in the given generic type, so that permitted subtypes are + * registered for container-typed fields such as {@code List} as well as top-level sealed fields. + * + * @param type the field's generic type. + * @return the sealed classes, in order of appearance. + */ + public static List> sealedTypes(Type type) { + var sealedTypes = new LinkedHashSet>(); + collectSealedTypes(type, sealedTypes); + return List.copyOf(sealedTypes); + } + + private static void collectSealedTypes(Type type, Set> sealedTypes) { + if (type instanceof Class clazz) { + if (clazz.isArray()) { + // An array of a plain class is a class itself; its element type is where a sealed type can be. + collectSealedTypes(clazz.getComponentType(), sealedTypes); + } else if (clazz.isSealed()) { + sealedTypes.add(clazz); + } + } else if (type instanceof ParameterizedType parameterizedType) { + collectSealedTypes(parameterizedType.getRawType(), sealedTypes); + for (Type typeArgument : parameterizedType.getActualTypeArguments()) { + collectSealedTypes(typeArgument, sealedTypes); + } + } else if (type instanceof GenericArrayType arrayType) { + collectSealedTypes(arrayType.getGenericComponentType(), sealedTypes); + } else if (type instanceof WildcardType wildcardType) { + for (Type bound : wildcardType.getUpperBounds()) { + collectSealedTypes(bound, sealedTypes); + } + } + } +} diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RefJsonSupport.java b/storm-core/src/main/java/st/orm/core/template/impl/RefJsonSupport.java new file mode 100644 index 000000000..84152b3c9 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/template/impl/RefJsonSupport.java @@ -0,0 +1,176 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.template.impl; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; +import org.jspecify.annotations.Nullable; +import st.orm.Data; +import st.orm.Entity; +import st.orm.PK; +import st.orm.Projection; +import st.orm.Ref; +import st.orm.core.spi.ORMReflection; +import st.orm.core.spi.Providers; +import st.orm.core.spi.RefFactory; +import st.orm.mapping.RecordField; + +/** + * The JSON form of a {@link Ref} and the part of reading and writing it that does not depend on the JSON library. + * + *

A loaded ref writes as an object: {@code {"@entity": {...}}} for an entity, {@code {"@id": ..., "@projection": + * {...}}} for a projection or other data, whose id cannot be taken from the value. An unloaded ref writes as its + * id: a raw value, or the inline record of a compound key as a plain object. Reading accepts each of these forms; + * a raw id and a compound key object create a ref through the ref factory of the conversion in progress, when + * there is one, so the ref is attached.

+ * + * @since 1.16 + */ +public final class RefJsonSupport { + + /** The field of the loaded projection form that carries the id. */ + public static final String ID_FIELD = "@id"; + + /** The field of the loaded entity form that carries the entity. */ + public static final String ENTITY_FIELD = "@entity"; + + /** The field of the loaded projection form that carries the projection. */ + public static final String PROJECTION_FIELD = "@projection"; + + private static final ORMReflection REFLECTION = Providers.getORMReflection(); + + // ConcurrentHashMap does NOT allow computeIfAbsent mapping functions to return null. + private static final Class NO_PK = Void.class; + private static final Map, Class> PK_CACHE = new ConcurrentHashMap<>(); + + private RefJsonSupport() { + } + + /** + * Returns the type of the primary key of the given data type. + * + * @param target the data type a ref points at. + * @return the type of its {@link PK} field, or {@code null} for a type without one. + */ + public static @Nullable Class pkType(Class target) { + Class cached = PK_CACHE.computeIfAbsent(target, ignore -> { + var type = REFLECTION.getRecordType(target).fields().stream() + .filter(field -> field.isAnnotationPresent(PK.class)) + .findFirst() + .map(RecordField::type) + .orElse(null); + return type == null ? NO_PK : type; + }); + return cached == NO_PK ? null : cached; + } + + /** + * Returns whether the given primary key type is a record, the inline record of a compound key, which an + * unloaded ref writes as a plain object. + * + * @param pkType the primary key type. + * @return {@code true} for a compound key. + */ + public static boolean isCompoundKey(Class pkType) { + return REFLECTION.findRecordType(pkType).isPresent(); + } + + /** + * Returns the given integral id as an {@code Integer} when it fits, else as a {@code Long}; the form of a raw + * id read for a type whose key type is unknown. + * + * @param value the id. + * @return the id as an {@code Integer} or a {@code Long}. + */ + public static Object integralId(long value) { + // Two returns rather than one conditional, which would promote both branches to long. + if (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) { + return (int) value; + } + return value; + } + + /** + * Creates the unloaded ref for the given id: through the ref factory of the conversion in progress, so the + * ref is attached, or detached without one. + * + * @param targetClass the data type the ref points at. + * @param id the id, or {@code null} for no ref. + * @param refFactorySupplier the supplier of the ref factory, or {@code null} for detached refs. + * @return the ref, or {@code null} for a {@code null} id. + * @param the data type. + */ + public static @Nullable Ref refOf(Class targetClass, + @Nullable Object id, + @Nullable Supplier<@Nullable RefFactory> refFactorySupplier) { + if (id == null) { + return null; + } + RefFactory refFactory = refFactorySupplier != null ? refFactorySupplier.get() : null; + if (refFactory != null) { + return refFactory.create(targetClass, id); + } + return Ref.of(targetClass, id); + } + + /** + * Creates the loaded ref for the entity read from the {@value #ENTITY_FIELD} field. + * + * @param entity the value read, or {@code null} for no ref. + * @return the ref, or {@code null} for a {@code null} value. + * @throws IllegalArgumentException if the value is not an entity; the message says so. + */ + @SuppressWarnings({"rawtypes"}) + public static @Nullable Ref loadedRef(@Nullable Data entity) { + if (entity == null) { + return null; + } + if (entity instanceof Entity e) { + return Ref.of((Entity) e); + } + throw new IllegalArgumentException("@entity must deserialize to an Entity"); + } + + /** + * Creates the loaded ref for the value read from the {@value #PROJECTION_FIELD} field with the id read from + * the {@value #ID_FIELD} field. + * + * @param targetClass the data type the ref points at. + * @param data the value read, or {@code null} for no ref. + * @param id the id read, or {@code null} when the field held none. + * @return the ref, or {@code null} for a {@code null} value. + * @throws IllegalArgumentException if the value is neither an entity nor a projection and no id was read; the + * message says so. + */ + @SuppressWarnings({"rawtypes", "unchecked"}) + public static @Nullable Ref loadedRef(Class targetClass, @Nullable Data data, @Nullable Object id) { + if (data == null) { + return null; + } + if (data instanceof Entity e) { + return Ref.of((Entity) e); + } + if (data instanceof Projection p) { + return Ref.of((Projection) p, id); + } + if (id != null) { + return Ref.of((Class) targetClass, id); + } + // Should not happen for @projection, since @id is required. + throw new IllegalArgumentException("@projection requires a non-null @id"); + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/JsonConverterSupportTest.java b/storm-core/src/test/java/st/orm/core/template/impl/JsonConverterSupportTest.java new file mode 100644 index 000000000..21e39436a --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/JsonConverterSupportTest.java @@ -0,0 +1,241 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.template.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.Json; +import st.orm.Ref; +import st.orm.SqlTemplateException; +import st.orm.core.spi.JsonString; +import st.orm.core.spi.Name; +import st.orm.core.spi.Providers; +import st.orm.core.spi.RefFactory; +import st.orm.core.template.SqlDialect; +import st.orm.core.template.impl.JsonConverterSupport.MapperKey; +import st.orm.mapping.RecordField; + +/** + * The library-independent part of a JSON column converter: what a conversion writes and reads, the ref factory it + * exposes while it runs, and the walk of a field's type for the sealed types a library has to know. + */ +class JsonConverterSupportTest { + + sealed interface Shape permits Circle, Square {} + + record Circle(double radius) implements Shape {} + + record Square(double side) implements Shape {} + + record Sample(@Json Shape shape, + @Json List shapes, + @Json Shape[] array, + @Json List[] genericArray, + @Json Map bounded, + @Json(failOnUnknown = true) String plain) implements Data {} + + record Generic(@Json T value) implements Data {} + + /** A converter whose library is a pair of functions, so the test controls what writing and reading do. */ + static class StubConverter extends JsonConverterSupport { + final List written = new ArrayList<>(); + @Nullable RefFactory seenWhileReading; + @Nullable RuntimeException failure; + + StubConverter(RecordField field) { + super(field); + } + + @Override + protected String write(Object value) { + if (failure != null) { + throw failure; + } + written.add(value); + return "\"" + value + "\""; + } + + @Override + protected @Nullable Object read(String json) { + if (failure != null) { + throw failure; + } + seenWhileReading = currentRefFactory(); + return json.equals("null") ? null : json.substring(1, json.length() - 1); + } + } + + /** A ref factory that creates detached refs; each instance stands for the factory of one conversion. */ + static final class DetachedRefFactory implements RefFactory { + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public Ref create(Class type, ID id) { + return Ref.of((Class) type, id); + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public Ref create(T record, ID id) { + return Ref.of((Class) record.getClass(), id); + } + } + + private static final RefFactory DETACHED = new DetachedRefFactory(); + + private static RecordField field(Class type, String name) { + return Providers.getORMReflection().getRecordType(type).fields().stream() + .filter(field -> field.name().equals(name)) + .findFirst() + .orElseThrow(); + } + + private static Type type(String name) { + try { + return Sample.class.getDeclaredField(name).getGenericType(); + } catch (NoSuchFieldException e) { + throw new IllegalArgumentException(e); + } + } + + @Test + void theConverterServesOneStringColumn() throws SqlTemplateException { + var converter = new StubConverter(field(Sample.class, "plain")); + assertEquals(1, converter.getParameterCount()); + assertEquals(List.of(String.class), converter.getParameterTypes()); + var columns = converter.getColumns(field -> new Name() { + @Override + public String name() { + return field.name(); + } + + @Override + public String qualified(SqlDialect dialect) { + return field.name(); + } + }); + assertEquals(List.of("plain"), columns.stream().map(Name::name).toList()); + } + + @Test + void writingTakesTheFieldOfTheRecordAndNullForNoRecord() throws SqlTemplateException { + var converter = new StubConverter(field(Sample.class, "plain")); + var written = converter.toDatabase(new Sample(null, List.of(), new Shape[0], null, Map.of(), "text")); + assertEquals(1, written.size()); + assertEquals("\"text\"", ((JsonString) written.getFirst()).value()); + assertEquals(List.of("text"), converter.written); + assertEquals(Collections.singletonList(null), converter.toDatabase(null)); + assertEquals(Collections.singletonList(null), + converter.toDatabase(new Sample(null, List.of(), new Shape[0], null, Map.of(), null))); + } + + @Test + void aWriteFailureSurfacesAsATemplateException() { + var converter = new StubConverter(field(Sample.class, "plain")); + converter.failure = new IllegalStateException("refused"); + var sample = new Sample(null, List.of(), new Shape[0], null, Map.of(), "text"); + var thrown = assertThrows(SqlTemplateException.class, () -> converter.toDatabase(sample)); + assertSame(converter.failure, thrown.getCause()); + } + + @Test + void readingExposesTheRefFactoryOfTheConversionAndNullForNull() throws SqlTemplateException { + var converter = new StubConverter(field(Sample.class, "plain")); + assertNull(JsonConverterSupport.currentRefFactory()); + assertEquals("text", converter.fromDatabase(new Object[] {"\"text\""}, DETACHED)); + assertSame(DETACHED, converter.seenWhileReading); + assertNull(JsonConverterSupport.currentRefFactory(), "the factory is bound to the conversion only"); + assertNull(converter.fromDatabase(new Object[] {null}, DETACHED)); + } + + @Test + void aNestedConversionRestoresTheOuterRefFactory() throws SqlTemplateException { + var inner = new StubConverter(field(Sample.class, "plain")); + var outer = new StubConverter(field(Sample.class, "plain")) { + @Override + protected @Nullable Object read(String json) { + try { + // A custom deserializer that queries the database nests a conversion with another factory. + inner.fromDatabase(new Object[] {"\"nested\""}, DETACHED); + } catch (SqlTemplateException e) { + throw new IllegalStateException(e); + } + return super.read(json); + } + }; + RefFactory outerFactory = new DetachedRefFactory(); + assertEquals("text", outer.fromDatabase(new Object[] {"\"text\""}, outerFactory)); + assertSame(DETACHED, inner.seenWhileReading); + assertSame(outerFactory, outer.seenWhileReading, "the outer conversion keeps its own factory"); + assertNull(JsonConverterSupport.currentRefFactory()); + } + + @Test + void aReadFailureSurfacesAsATemplateExceptionAndUnbindsTheFactory() { + var converter = new StubConverter(field(Sample.class, "plain")); + converter.failure = new IllegalStateException("refused"); + var thrown = assertThrows(SqlTemplateException.class, + () -> converter.fromDatabase(new Object[] {"\"text\""}, DETACHED)); + assertSame(converter.failure, thrown.getCause()); + assertNull(JsonConverterSupport.currentRefFactory()); + } + + @Test + void theRawTypeIsTheClassOrTheClassOfAParameterizedTypeAndObjectOtherwise() { + assertEquals(Shape.class, JsonConverterSupport.rawType(type("shape"))); + assertEquals(List.class, JsonConverterSupport.rawType(type("shapes"))); + assertEquals(Shape[].class, JsonConverterSupport.rawType(type("array"))); + assertEquals(Object.class, JsonConverterSupport.rawType(field(Generic.class, "value").genericType())); + } + + @Test + void theSealedTypesAreFoundAtTheTopInTypeArgumentsInArraysAndInBounds() { + assertEquals(List.of(Shape.class), JsonConverterSupport.sealedTypes(type("shape"))); + assertEquals(List.of(Shape.class), JsonConverterSupport.sealedTypes(type("shapes"))); + assertEquals(List.of(Shape.class), JsonConverterSupport.sealedTypes(type("array"))); + assertEquals(List.of(Shape.class), JsonConverterSupport.sealedTypes(type("genericArray"))); + assertEquals(List.of(Shape.class), JsonConverterSupport.sealedTypes(type("bounded"))); + assertEquals(List.of(), JsonConverterSupport.sealedTypes(type("plain"))); + assertEquals(List.of(), JsonConverterSupport.sealedTypes(field(Generic.class, "value").genericType())); + assertEquals(List.of(Circle.class, Square.class), JsonConverterSupport.permittedSubclasses(Shape.class)); + } + + @Test + void theMapperKeyCarriesTheRawTypeOnlyWithACustomSerializerOrDeserializer() { + var json = field(Sample.class, "plain").getAnnotation(Json.class); + var plain = MapperKey.of(json, type("shapes"), null, null); + assertNull(plain.targetType()); + assertEquals(List.of(Shape.class), plain.sealedTypes()); + assertTrue(plain.json().failOnUnknown()); + assertEquals(List.class, MapperKey.of(json, type("shapes"), String.class, null).targetType()); + assertEquals(List.class, MapperKey.of(json, type("shapes"), null, String.class).targetType()); + assertEquals(MapperKey.of(json, type("shapes"), null, String.class), + MapperKey.of(json, type("shapes"), null, String.class)); + assertInstanceOf(MapperKey.class, plain); + } +} diff --git a/storm-core/src/test/java/st/orm/core/template/impl/RefJsonSupportTest.java b/storm-core/src/test/java/st/orm/core/template/impl/RefJsonSupportTest.java new file mode 100644 index 000000000..44eb562cb --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/template/impl/RefJsonSupportTest.java @@ -0,0 +1,140 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.core.template.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import st.orm.Data; +import st.orm.Entity; +import st.orm.PK; +import st.orm.Projection; +import st.orm.Ref; +import st.orm.core.spi.RefFactory; + +/** + * The part of reading and writing a ref as JSON that does not depend on the JSON library: the key type of the + * target, and the refs created for an id, an entity and a projection. + */ +class RefJsonSupportTest { + + record Owner(@PK Integer id, String name) implements Entity {} + + record OwnerSummary(@PK Integer id, String name) implements Projection {} + + record Plain(String name) implements Data {} + + record VisitKey(int petId, int day) {} + + record Visit(@PK VisitKey key, String note) implements Entity {} + + /** A ref factory that counts the refs it creates, standing in for the attached factory of a template. */ + static final class CountingRefFactory implements RefFactory { + int created; + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public Ref create(Class type, ID id) { + created++; + return Ref.of((Class) type, id); + } + + @Override + @SuppressWarnings({"unchecked", "rawtypes"}) + public Ref create(T record, ID id) { + return Ref.of((Class) record.getClass(), id); + } + } + + @Test + void theKeyTypeIsThatOfThePkFieldAndNullWithoutOne() { + assertEquals(Integer.class, RefJsonSupport.pkType(Owner.class)); + assertEquals(Integer.class, RefJsonSupport.pkType(Owner.class), "cached"); + assertEquals(VisitKey.class, RefJsonSupport.pkType(Visit.class)); + assertNull(RefJsonSupport.pkType(Plain.class)); + assertNull(RefJsonSupport.pkType(Plain.class), "cached"); + } + + @Test + void aRecordKeyIsACompoundKey() { + assertTrue(RefJsonSupport.isCompoundKey(VisitKey.class)); + assertFalse(RefJsonSupport.isCompoundKey(Integer.class)); + } + + @Test + void anIntegralIdIsAnIntegerWhenItFitsAndALongOtherwise() { + assertEquals(42, RefJsonSupport.integralId(42)); + assertEquals(Integer.MAX_VALUE, RefJsonSupport.integralId(Integer.MAX_VALUE)); + assertEquals((long) Integer.MAX_VALUE + 1, RefJsonSupport.integralId((long) Integer.MAX_VALUE + 1)); + assertEquals((long) Integer.MIN_VALUE - 1, RefJsonSupport.integralId((long) Integer.MIN_VALUE - 1)); + } + + @Test + void anIdBecomesARefThroughTheFactoryWhenThereIsOne() { + var factory = new CountingRefFactory(); + var attached = RefJsonSupport.refOf(Owner.class, 7, () -> factory); + assertNotNull(attached); + assertEquals(7, attached.id()); + assertEquals(1, factory.created); + var detached = RefJsonSupport.refOf(Owner.class, 7, null); + assertNotNull(detached); + assertEquals(7, detached.id()); + var suppliedNothing = RefJsonSupport.refOf(Owner.class, 7, () -> null); + assertNotNull(suppliedNothing); + assertEquals(7, suppliedNothing.id()); + assertNull(RefJsonSupport.refOf(Owner.class, null, () -> factory)); + assertEquals(1, factory.created, "no ref for no id"); + } + + @Test + void anEntityBecomesALoadedRefAndAnythingElseIsRefused() { + var owner = new Owner(1, "Betty"); + var ref = RefJsonSupport.loadedRef(owner); + assertNotNull(ref); + assertSame(owner, ref.getOrNull()); + assertNull(RefJsonSupport.loadedRef((Data) null)); + var thrown = assertThrows(IllegalArgumentException.class, + () -> RefJsonSupport.loadedRef(new OwnerSummary(1, "Betty"))); + assertEquals("@entity must deserialize to an Entity", thrown.getMessage()); + } + + @Test + void aProjectionBecomesALoadedRefWithTheIdItWasReadWith() { + var summary = new OwnerSummary(1, "Betty"); + var ref = RefJsonSupport.loadedRef(OwnerSummary.class, summary, 1); + assertNotNull(ref); + assertSame(summary, ref.getOrNull()); + assertEquals(1, ref.id()); + var owner = new Owner(2, "George"); + var entityRef = RefJsonSupport.loadedRef(Owner.class, owner, 2); + assertNotNull(entityRef); + assertSame(owner, entityRef.getOrNull()); + var plain = RefJsonSupport.loadedRef(Plain.class, new Plain("x"), 3); + assertNotNull(plain); + assertEquals(3, plain.id()); + assertNull(plain.getOrNull(), "plain data cannot be loaded into a ref; its id is kept"); + assertNull(RefJsonSupport.loadedRef(Plain.class, null, 3)); + var thrown = assertThrows(IllegalArgumentException.class, + () -> RefJsonSupport.loadedRef(Plain.class, new Plain("x"), null)); + assertEquals("@projection requires a non-null @id", thrown.getMessage()); + } +} diff --git a/storm-jackson2/src/main/java/st/orm/jackson/StormModule.java b/storm-jackson2/src/main/java/st/orm/jackson/StormModule.java index bc26879dd..4548df51a 100644 --- a/storm-jackson2/src/main/java/st/orm/jackson/StormModule.java +++ b/storm-jackson2/src/main/java/st/orm/jackson/StormModule.java @@ -15,12 +15,26 @@ */ package st.orm.jackson; -import static st.orm.core.spi.Providers.getORMReflection; +import static st.orm.core.template.impl.RefJsonSupport.ENTITY_FIELD; +import static st.orm.core.template.impl.RefJsonSupport.ID_FIELD; +import static st.orm.core.template.impl.RefJsonSupport.PROJECTION_FIELD; +import static st.orm.core.template.impl.RefJsonSupport.integralId; +import static st.orm.core.template.impl.RefJsonSupport.isCompoundKey; +import static st.orm.core.template.impl.RefJsonSupport.loadedRef; +import static st.orm.core.template.impl.RefJsonSupport.pkType; +import static st.orm.core.template.impl.RefJsonSupport.refOf; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; -import com.fasterxml.jackson.databind.*; +import com.fasterxml.jackson.databind.BeanProperty; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.deser.ContextualDeserializer; import com.fasterxml.jackson.databind.deser.std.StdDeserializer; import com.fasterxml.jackson.databind.module.SimpleModule; @@ -28,18 +42,12 @@ import com.fasterxml.jackson.databind.ser.ContextualSerializer; import com.fasterxml.jackson.databind.ser.std.StdSerializer; import java.io.IOException; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; import st.orm.Data; import st.orm.Entity; -import st.orm.PK; -import st.orm.Projection; import st.orm.Ref; -import st.orm.core.spi.ORMReflection; import st.orm.core.spi.RefFactory; -import st.orm.mapping.RecordField; /** * Jackson module for serializing and deserializing Storm ORM Data types. @@ -66,16 +74,6 @@ */ public class StormModule extends SimpleModule { - private static final String ID_FIELD = "@id"; - private static final String ENTITY_FIELD = "@entity"; - private static final String PROJECTION_FIELD = "@projection"; - - private static final ORMReflection REFLECTION = getORMReflection(); - - // ConcurrentHashMap does NOT allow computeIfAbsent mapping functions to return null. - private static final Class NO_PK = Void.class; - private static final Map, Class> PK_CACHE = new ConcurrentHashMap<>(); - /** * Creates a StormModule without a RefFactory. * Ref fields with only an id will be deserialized as DetachedRefs. @@ -133,34 +131,7 @@ private static JavaType resolveRefTargetType(@Nullable JavaType type) { } /** - * Resolves the primary key type for the given target class. - * - * @param target the entity/data class - * @return the PK field type, or null if not found - */ - @Nullable - private static Class resolvePkType(Class target) { - Class cached = PK_CACHE.computeIfAbsent(target, ignore -> { - var type = REFLECTION.getRecordType(target).fields().stream() - .filter(field -> field.isAnnotationPresent(PK.class)) - .findFirst() - .map(RecordField::type) - .orElse(null); - return type == null ? NO_PK : type; - }); - return cached == NO_PK ? null : cached; - } - - /** - * Serializer for Ref instances. - * - *

Serialization formats: - *

    - *
  • Entity (loaded): {@code {"@entity": {...}}}
  • - *
  • Projection (loaded): {@code {"@id": ..., "@projection": {...}}}
  • - *
  • Unloaded: raw id value (e.g., {@code 42} or {@code "abc"}), or the PK record as a plain object for - * compound PKs (e.g., {@code {"vetId":1,"specialtyId":2}})
  • - *
+ * Serializer for Ref instances; the forms are those of {@link st.orm.core.template.impl.RefJsonSupport}. */ private static class RefSerializer extends StdSerializer> implements ContextualSerializer { @@ -173,61 +144,50 @@ private static class RefSerializer extends StdSerializer> implements Cont } @Override - public JsonSerializer createContextual(SerializerProvider prov, @Nullable BeanProperty property) { + public JsonSerializer createContextual(SerializerProvider ctxt, @Nullable BeanProperty property) { JavaType base = property != null ? property.getType() : null; JavaType refTargetType = resolveRefTargetType(base); if (refTargetType == null) { return this; } - Class targetClass = refTargetType.getRawClass(); - Class resolvedPkType = resolvePkType(targetClass); - return new RefSerializer(resolvedPkType); + return new RefSerializer(pkType(refTargetType.getRawClass())); } @Override - public void serialize(Ref ref, JsonGenerator gen, SerializerProvider provider) throws IOException { + public void serialize(Ref ref, JsonGenerator gen, SerializerProvider ctxt) throws IOException { Object loaded = ref.getOrNull(); if (loaded != null) { // Loaded - serialize as object with @entity or @projection gen.writeStartObject(); if (loaded instanceof Entity) { gen.writeFieldName(ENTITY_FIELD); - provider.defaultSerializeValue(loaded, gen); + ctxt.defaultSerializeValue(loaded, gen); } else { // Projection or other Data - include id since it can't be extracted gen.writeFieldName(ID_FIELD); - serializeId(ref.id(), gen, provider); + serializeId(ref.id(), gen, ctxt); gen.writeFieldName(PROJECTION_FIELD); - provider.defaultSerializeValue(loaded, gen); + ctxt.defaultSerializeValue(loaded, gen); } gen.writeEndObject(); } else { // Unloaded - serialize as raw id value - serializeId(ref.id(), gen, provider); + serializeId(ref.id(), gen, ctxt); } } - private void serializeId(Object id, JsonGenerator gen, SerializerProvider provider) throws IOException { + private void serializeId(Object id, JsonGenerator gen, SerializerProvider ctxt) throws IOException { if (id != null && pkType != null) { - JsonSerializer serializer = provider.findValueSerializer(pkType); - serializer.serialize(id, gen, provider); + JsonSerializer serializer = ctxt.findValueSerializer(pkType); + serializer.serialize(id, gen, ctxt); } else { - provider.defaultSerializeValue(id, gen); + ctxt.defaultSerializeValue(id, gen); } } } /** - * Deserializer for Ref instances. - * - *

Handles four input formats: - *

    - *
  • {@code {"@entity": {...}}} - Entity, uses {@code Ref.of()}
  • - *
  • {@code {"@id": ..., "@projection": {...}}} - Projection with id, uses {@code Ref.of()}
  • - *
  • Raw id value (e.g., {@code 42}) - uses RefFactory if available, otherwise {@code Ref.of()}
  • - *
  • Plain object without {@code @entity}/{@code @projection} (e.g., {@code {"vetId":1,"specialtyId":2}}) - - * compound PK, uses RefFactory if available, otherwise {@code Ref.of()}
  • - *
+ * Deserializer for Ref instances; the forms are those of {@link st.orm.core.template.impl.RefJsonSupport}. * *

Also supports {@code null} values (e.g. nullable elements in {@code List>}). */ @@ -257,7 +217,7 @@ public JsonDeserializer createContextual(DeserializationContext context, @Nul JavaType refTargetType = resolveRefTargetType(base); Class resolvedPkType = null; if (refTargetType != null) { - resolvedPkType = resolvePkType(refTargetType.getRawClass()); + resolvedPkType = pkType(refTargetType.getRawClass()); } return new RefDeserializer(refFactorySupplier, refTargetType, resolvedPkType); } @@ -280,10 +240,8 @@ public Ref deserialize(JsonParser parser, DeserializationContext context) thr Class targetClass = (Class) targetType.getRawClass(); return switch (parser.currentToken()) { case START_OBJECT -> deserializeObject(parser, context, targetClass); - case VALUE_NUMBER_INT, VALUE_NUMBER_FLOAT, VALUE_STRING -> { - Object id = deserializeId(parser, context); - yield createRefFromId(targetClass, id); - } + case VALUE_NUMBER_INT, VALUE_NUMBER_FLOAT, VALUE_STRING -> + refOf(targetClass, deserializeId(parser, context), refFactorySupplier); default -> throw JsonMappingException.from( parser, "Unexpected token " + parser.currentToken() + " when deserializing Ref" @@ -294,25 +252,28 @@ public Ref deserialize(JsonParser parser, DeserializationContext context) thr private Ref deserializeObject(JsonParser parser, DeserializationContext ctx, Class targetClass) throws IOException { ObjectNode node = parser.readValueAsTree(); - if (node.has(ENTITY_FIELD)) { - JsonNode entityNode = node.get(ENTITY_FIELD); - Data entity = ctx.readTreeAsValue(entityNode, targetType); - return createLoadedRef(parser, entity); - } else if (node.has(PROJECTION_FIELD)) { - if (!node.has(ID_FIELD)) { - throw JsonMappingException.from(parser, "@projection requires @id field"); + try { + if (node.has(ENTITY_FIELD)) { + JsonNode entityNode = node.get(ENTITY_FIELD); + Data entity = entityNode.isNull() ? null : ctx.readTreeAsValue(entityNode, targetType); + return loadedRef(entity); + } else if (node.has(PROJECTION_FIELD)) { + if (!node.has(ID_FIELD)) { + throw JsonMappingException.from(parser, "@projection requires @id field"); + } + Object id = deserializeIdFromNode(node.get(ID_FIELD), ctx); + JsonNode projectionNode = node.get(PROJECTION_FIELD); + Data projection = projectionNode.isNull() ? null : ctx.readTreeAsValue(projectionNode, targetType); + return loadedRef(targetClass, projection, id); + } else if (pkType != null && isCompoundKey(pkType)) { + // A plain object is the inline record of a compound PK; unloaded refs serialize compound PKs + // this way. + return refOf(targetClass, deserializeIdFromNode(node, ctx), refFactorySupplier); + } else { + throw JsonMappingException.from(parser, "Ref object must contain @entity or @projection field"); } - Object id = deserializeIdFromNode(node.get(ID_FIELD), ctx); - JsonNode projectionNode = node.get(PROJECTION_FIELD); - Data projection = ctx.readTreeAsValue(projectionNode, targetType); - return createLoadedRefWithId(parser, targetClass, projection, id); - } else if (pkType != null && REFLECTION.findRecordType(pkType).isPresent()) { - // A plain object is the inline record of a compound PK; unloaded refs serialize compound PKs - // this way. - Object id = deserializeIdFromNode(node, ctx); - return createRefFromId(targetClass, id); - } else { - throw JsonMappingException.from(parser, "Ref object must contain @entity or @projection field"); + } catch (IllegalArgumentException e) { + throw JsonMappingException.from(parser, e.getMessage()); } } @@ -340,59 +301,12 @@ private Object deserializeId(JsonParser parser, DeserializationContext ctx) thro } // Fallback for unknown PK type. return switch (parser.currentToken()) { - case VALUE_NUMBER_INT -> { - long value = parser.getLongValue(); - yield (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) - ? (int) value - : value; - } + case VALUE_NUMBER_INT -> integralId(parser.getLongValue()); case VALUE_NUMBER_FLOAT -> parser.getDoubleValue(); case VALUE_STRING -> parser.getText(); default -> throw JsonMappingException.from(parser, "Cannot deserialize id from %s.".formatted(parser.currentToken())); }; } - - @SuppressWarnings({"rawtypes"}) - private Ref createLoadedRef(JsonParser parser, Data entity) - throws JsonMappingException { - if (entity == null) { - return null; - } - if (entity instanceof Entity e) { - return Ref.of((Entity) e); - } - throw JsonMappingException.from(parser, "@entity must deserialize to an Entity"); - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private Ref createLoadedRefWithId(JsonParser parser, Class targetClass, Data data, Object id) - throws JsonMappingException { - if (data == null) { - return null; - } - if (data instanceof Entity e) { - return Ref.of((Entity) e); - } - if (data instanceof Projection p) { - return Ref.of((Projection) p, id); - } - if (id != null) { - return Ref.of(targetClass, id); - } - // Should not happen for @projection, since @id is required. - throw JsonMappingException.from(parser, "@projection requires a non-null @id"); - } - - private Ref createRefFromId(Class targetClass, Object id) { - if (id == null) { - return null; - } - RefFactory refFactory = refFactorySupplier != null ? refFactorySupplier.get() : null; - if (refFactory != null) { - return refFactory.create(targetClass, id); - } - return Ref.of(targetClass, id); - } } } diff --git a/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java b/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java index 2da6ce000..03c943be7 100644 --- a/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java +++ b/storm-jackson2/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java @@ -17,38 +17,22 @@ import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES; import static com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; -import static java.util.Collections.singletonList; import static java.util.Objects.requireNonNull; -import static java.util.Optional.empty; import com.fasterxml.jackson.annotation.JsonTypeName; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonDeserializer; import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectWriter; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.jsontype.NamedType; import com.fasterxml.jackson.databind.module.SimpleModule; -import java.lang.reflect.GenericArrayType; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.lang.reflect.WildcardType; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import org.jspecify.annotations.Nullable; import st.orm.Json; -import st.orm.SqlTemplateException; -import st.orm.core.spi.JsonString; -import st.orm.core.spi.Name; import st.orm.core.spi.ORMConverter; -import st.orm.core.spi.ORMReflection; -import st.orm.core.spi.Providers; -import st.orm.core.spi.RefFactory; +import st.orm.core.template.impl.JsonConverterSupport; import st.orm.core.template.impl.SegmentedLruCache; import st.orm.jackson.StormModule; import st.orm.mapping.RecordField; @@ -57,29 +41,19 @@ * Implementation of {@link ORMConverter} that converts JSON fields to and from the database. It uses Jackson for JSON * serialization and deserialization. */ -public final class JsonORMConverterImpl implements ORMConverter { - private static final ORMReflection REFLECTION = Providers.getORMReflection(); - private static final SegmentedLruCache OBJECT_MAPPER = new SegmentedLruCache<>(1024); - private static final ThreadLocal REF_FACTORY = new ThreadLocal<>(); +public final class JsonORMConverterImpl extends JsonConverterSupport { + private static final SegmentedLruCache MAPPER_CACHE = new SegmentedLruCache<>(1024); - private final RecordField field; private final TypeReference typeReference; - private final ObjectMapper mapper; + private final JsonMapper mapper; private final ObjectWriter writer; - record CacheKey(Json json, - List> sealedTypes, - @Nullable Class targetType, - @Nullable Class> serializer, - @Nullable Class> deserializer) {} - @SuppressWarnings({"rawtypes", "unchecked"}) public JsonORMConverterImpl(RecordField field, TypeReference typeReference, Json json) { - this.field = requireNonNull(field, "field"); + super(field); this.typeReference = requireNonNull(typeReference, "typeReference"); - var sealedTypes = getSealedTypes(typeReference.getType()); // Check for custom serializer/deserializer annotations. var serializeAnnotation = field.getAnnotation(JsonSerialize.class); var deserializeAnnotation = field.getAnnotation(JsonDeserialize.class); @@ -91,97 +65,48 @@ public JsonORMConverterImpl(RecordField field, deserializeAnnotation != null && deserializeAnnotation.using() != JsonDeserializer.None.class ? (Class>) deserializeAnnotation.using() : null; - // Custom serializers are registered against the raw field type, so that type is part of the cache key - // whenever one is present; without it, fields of different types sharing a serializer class would share - // a mapper that only serves the first field's type. - Class targetType = serializerClass != null || deserializerClass != null - ? getRawType(typeReference.getType()).orElse(Object.class) - : null; - this.mapper = OBJECT_MAPPER.getOrCompute( - new CacheKey(requireNonNull(json, "json"), sealedTypes, targetType, serializerClass, deserializerClass), - () -> { - var mapper = new ObjectMapper(); - mapper.findAndRegisterModules(); - if (!json.failOnUnknown()) { - mapper.disable(FAIL_ON_UNKNOWN_PROPERTIES); - } - if (!json.failOnMissing()) { - mapper.disable(FAIL_ON_MISSING_CREATOR_PROPERTIES); - } - for (var sealedType : sealedTypes) { - mapper.registerSubtypes(getPermittedSubtypes(sealedType)); + var key = MapperKey.of(json, typeReference.getType(), serializerClass, deserializerClass); + this.mapper = MAPPER_CACHE.getOrCompute(key, () -> { + var builder = JsonMapper.builder(); + builder.findAndAddModules(); + // Set, not left to the library's defaults, which differ between Jackson versions. + builder.configure(FAIL_ON_UNKNOWN_PROPERTIES, json.failOnUnknown()); + builder.configure(FAIL_ON_MISSING_CREATOR_PROPERTIES, json.failOnMissing()); + for (var sealedType : key.sealedTypes()) { + builder.registerSubtypes(getPermittedSubtypes(sealedType)); + } + // Register StormModule with supplier for dynamic RefFactory resolution. + builder.addModule(new StormModule(JsonConverterSupport::currentRefFactory)); + // Register custom serializers/deserializers if specified. + if (serializerClass != null || deserializerClass != null) { + var customModule = new SimpleModule(); + if (serializerClass != null) { + try { + JsonSerializer serializerInstance = serializerClass.getDeclaredConstructor().newInstance(); + customModule.addSerializer((Class) key.targetType(), serializerInstance); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate custom serializer: " + serializerClass, e); } - // Register StormModule with supplier for dynamic RefFactory resolution. - mapper.registerModule(new StormModule(REF_FACTORY::get)); - // Register custom serializers/deserializers if specified. - if (serializerClass != null || deserializerClass != null) { - var customModule = new SimpleModule(); - if (serializerClass != null) { - try { - JsonSerializer serializerInstance = serializerClass.getDeclaredConstructor().newInstance(); - customModule.addSerializer((Class) targetType, serializerInstance); - } catch (Exception e) { - throw new RuntimeException("Failed to instantiate custom serializer: " + serializerClass, e); - } - } - if (deserializerClass != null) { - try { - JsonDeserializer deserializerInstance = deserializerClass.getDeclaredConstructor().newInstance(); - customModule.addDeserializer((Class) targetType, deserializerInstance); - } catch (Exception e) { - throw new RuntimeException("Failed to instantiate custom deserializer: " + deserializerClass, e); - } - } - mapper.registerModule(customModule); + } + if (deserializerClass != null) { + try { + JsonDeserializer deserializerInstance = deserializerClass.getDeclaredConstructor().newInstance(); + customModule.addDeserializer((Class) key.targetType(), deserializerInstance); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate custom deserializer: " + deserializerClass, e); } - return mapper; - }); + } + builder.addModule(customModule); + } + return builder.build(); + }); // Serialization carries the declared field type rather than the erased runtime type, so polymorphic // values write the discriminator that reading with the same declared type expects. this.writer = mapper.writerFor(typeReference); } - private static Optional> getRawType(Type type) { - if (type instanceof ParameterizedType) { - return Optional.of((Class) ((ParameterizedType) type).getRawType()); - } else if (type instanceof Class) { - return Optional.of((Class) type); - } else { - return empty(); - } - } - - /** - * Collects the sealed classes appearing anywhere in the field's generic type, so that permitted subtypes are - * registered for container-typed fields such as {@code List} as well as top-level sealed fields. - */ - private static List> getSealedTypes(Type type) { - var sealedTypes = new LinkedHashSet>(); - collectSealedTypes(type, sealedTypes); - return List.copyOf(sealedTypes); - } - - private static void collectSealedTypes(Type type, Set> sealedTypes) { - if (type instanceof Class clazz) { - if (clazz.isSealed()) { - sealedTypes.add(clazz); - } - } else if (type instanceof ParameterizedType parameterizedType) { - collectSealedTypes(parameterizedType.getRawType(), sealedTypes); - for (Type typeArgument : parameterizedType.getActualTypeArguments()) { - collectSealedTypes(typeArgument, sealedTypes); - } - } else if (type instanceof GenericArrayType arrayType) { - collectSealedTypes(arrayType.getGenericComponentType(), sealedTypes); - } else if (type instanceof WildcardType wildcardType) { - for (Type bound : wildcardType.getUpperBounds()) { - collectSealedTypes(bound, sealedTypes); - } - } - } - private static NamedType[] getPermittedSubtypes(Class sealedClass) { - return REFLECTION.getPermittedSubclasses(sealedClass).stream() + return permittedSubclasses(sealedClass).stream() .map(subclass -> { JsonTypeName typeNameAnnotation = subclass.getAnnotation(JsonTypeName.class); String typeName = typeNameAnnotation != null ? typeNameAnnotation.value() : subclass.getSimpleName(); @@ -191,51 +116,12 @@ private static NamedType[] getPermittedSubtypes(Class sealedClass) { } @Override - public int getParameterCount() { - return 1; - } - - @Override - public List> getParameterTypes() { - return List.of(String.class); - } - - @Override - public List getColumns(NameResolver nameResolver) throws SqlTemplateException { - return List.of(nameResolver.getName(field)); - } - - @Override - public List<@Nullable Object> toDatabase(@Nullable Object record) throws SqlTemplateException { - try { - Object o = record == null ? null : REFLECTION.invoke(field, record); - return singletonList(o == null ? null : new JsonString(writer.writeValueAsString(o))); - } catch (Throwable e) { - throw new SqlTemplateException(e); - } + protected String write(Object value) throws JsonProcessingException { + return writer.writeValueAsString(value); } @Override - public @Nullable Object fromDatabase(@Nullable Object[] values, RefFactory refFactory) throws SqlTemplateException { - Object value = values[0]; - if (value == null) { - return null; - } - // A custom deserializer may issue a query, nesting another fromDatabase call on this thread. The previous - // factory is therefore restored rather than removed, so the remaining fields of the outer value still - // deserialize with the outer factory and produce attached refs. - RefFactory outerRefFactory = REF_FACTORY.get(); - REF_FACTORY.set(refFactory); - try { - return mapper.readValue((String) value, typeReference); - } catch (JsonProcessingException e) { - throw new SqlTemplateException(e); - } finally { - if (outerRefFactory == null) { - REF_FACTORY.remove(); - } else { - REF_FACTORY.set(outerRefFactory); - } - } + protected Object read(String json) throws JsonProcessingException { + return mapper.readValue(json, typeReference); } } diff --git a/storm-jackson2/src/test/java/st/orm/jackson/JsonIntegrationTest.java b/storm-jackson2/src/test/java/st/orm/jackson/JsonIntegrationTest.java index 258d544df..20a3304a3 100644 --- a/storm-jackson2/src/test/java/st/orm/jackson/JsonIntegrationTest.java +++ b/storm-jackson2/src/test/java/st/orm/jackson/JsonIntegrationTest.java @@ -1,11 +1,11 @@ package st.orm.jackson; +import static com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS; import static java.util.Optional.ofNullable; import static org.junit.jupiter.api.Assertions.assertEquals; import static st.orm.core.template.ORMTemplate.of; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.fasterxml.jackson.databind.json.JsonMapper; import java.time.LocalDate; import javax.sql.DataSource; import org.jspecify.annotations.Nullable; @@ -43,10 +43,10 @@ public void ownerEntityWithJsonAddressShouldRoundTripThroughJackson() throws Exc // to JSON using Jackson, the entity should produce valid JSON and deserialize back to an equal object. var orm = of(dataSource); var owner = orm.entity(Owner.class).getById(1); - ObjectMapper objectMapper = new ObjectMapper(); - var json = objectMapper.writeValueAsString(owner); + var mapper = JsonMapper.builder().build(); + var json = mapper.writeValueAsString(owner); assertEquals("{\"id\":1,\"firstName\":\"Betty\",\"lastName\":\"Davis\",\"address\":{\"address\":\"638 Cardinal Ave.\",\"city\":\"Sun Prairie\"},\"telephone\":\"6085551749\"}", json); - var fromJson = objectMapper.readValue(json, Owner.class); + var fromJson = mapper.readValue(json, Owner.class); assertEquals(owner, fromJson); } @@ -69,27 +69,26 @@ public void entityWithMultipleJsonFieldsShouldRoundTripIncludingComputedJsonColu var orm = of(dataSource); var query = orm.query("SELECT id, JSON_OBJECT('firstName' VALUE first_name, 'lastName' VALUE last_name) AS person, address, telephone FROM owner LIMIT 1"); var owner = query.getSingleResult(OwnerWithJsonPerson.class); - ObjectMapper objectMapper = new ObjectMapper(); - var json = objectMapper.writeValueAsString(owner); + var mapper = JsonMapper.builder().build(); + var json = mapper.writeValueAsString(owner); assertEquals(""" {"id":1,"person":{"firstName":"Betty","lastName":"Davis"},"address":{"address":"638 Cardinal Ave.","city":"Sun Prairie"},"telephone":"6085551749"}""", json); - var fromJson = objectMapper.readValue(json, OwnerWithJsonPerson.class); + var fromJson = mapper.readValue(json, OwnerWithJsonPerson.class); assertEquals(owner, fromJson); } @Test public void entityWithFkRelationshipsShouldSerializeNestedEntitiesInline() throws Exception { // Pet id=1 is "Leo", a cat owned by Betty Davis. The entity graph includes FK-joined - // PetType and Owner entities. When serialized with Jackson + JavaTimeModule, all nested - // entities should appear inline with their full field values, and the result should round-trip. + // PetType and Owner entities. When serialized with Jackson, dates as ISO strings, + // all nested entities should appear inline with their full field values, and the result should round-trip. var orm = of(dataSource); var pet = orm.entity(Pet.class).getById(1); - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - var json = objectMapper.writeValueAsString(pet); + var mapper = JsonMapper.builder().findAndAddModules().disable(WRITE_DATES_AS_TIMESTAMPS).build(); + var json = mapper.writeValueAsString(pet); assertEquals(""" - {"id":1,"name":"Leo","birthDate":[2020,9,7],"petType":{"id":1,"name":"cat"},"owner":{"id":1,"firstName":"Betty","lastName":"Davis","address":{"address":"638 Cardinal Ave.","city":"Sun Prairie"},"telephone":"6085551749"}}""", json); - var fromJson = objectMapper.readValue(json, Pet.class); + {"id":1,"name":"Leo","birthDate":"2020-09-07","petType":{"id":1,"name":"cat"},"owner":{"id":1,"firstName":"Betty","lastName":"Davis","address":{"address":"638 Cardinal Ave.","city":"Sun Prairie"},"telephone":"6085551749"}}""", json); + var fromJson = mapper.readValue(json, Pet.class); assertEquals(pet, fromJson); } @@ -109,13 +108,15 @@ public void unloadedEntityRefShouldSerializeToRawPrimaryKeyValue() throws Except // StormModule must be registered to enable Ref serialization support. var orm = of(dataSource); var pet = orm.entity(PetWithRefOwner.class).getById(1); - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.registerModule(new StormModule()); - var json = objectMapper.writeValueAsString(pet); + var mapper = JsonMapper.builder() + .findAndAddModules() + .disable(WRITE_DATES_AS_TIMESTAMPS) + .addModule(new StormModule()) + .build(); + var json = mapper.writeValueAsString(pet); assertEquals(""" - {"id":1,"name":"Leo","birthDate":[2020,9,7],"petType":{"id":1,"name":"cat"},"owner":1}""", json); - var fromJson = objectMapper.readValue(json, PetWithRefOwner.class); + {"id":1,"name":"Leo","birthDate":"2020-09-07","petType":{"id":1,"name":"cat"},"owner":1}""", json); + var fromJson = mapper.readValue(json, PetWithRefOwner.class); assertEquals(pet, fromJson); } @@ -127,13 +128,15 @@ public void loadedEntityRefShouldSerializeWithEntityWrapper() throws Exception { var orm = of(dataSource); var pet = orm.entity(PetWithRefOwner.class).getById(1); var owner = ofNullable(pet.owner()).map(Ref::fetch).orElseThrow(); - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.registerModule(new StormModule()); - var json = objectMapper.writeValueAsString(pet); + var mapper = JsonMapper.builder() + .findAndAddModules() + .disable(WRITE_DATES_AS_TIMESTAMPS) + .addModule(new StormModule()) + .build(); + var json = mapper.writeValueAsString(pet); assertEquals(""" - {"id":1,"name":"Leo","birthDate":[2020,9,7],"petType":{"id":1,"name":"cat"},"owner":{"@entity":{"id":1,"firstName":"Betty","lastName":"Davis","address":{"address":"638 Cardinal Ave.","city":"Sun Prairie"},"telephone":"6085551749"}}}""", json); - var fromJson = objectMapper.readValue(json, PetWithRefOwner.class); + {"id":1,"name":"Leo","birthDate":"2020-09-07","petType":{"id":1,"name":"cat"},"owner":{"@entity":{"id":1,"firstName":"Betty","lastName":"Davis","address":{"address":"638 Cardinal Ave.","city":"Sun Prairie"},"telephone":"6085551749"}}}""", json); + var fromJson = mapper.readValue(json, PetWithRefOwner.class); assertEquals(pet, fromJson); assertEquals(owner, ofNullable(fromJson.owner()).map(Ref::fetch).orElseThrow()); } @@ -163,13 +166,15 @@ public void unloadedProjectionRefShouldSerializeToRawPrimaryKeyValue() throws Ex // to just the primary key value, same as entity Refs. var orm = of(dataSource); var pet = orm.entity(PetWithProjectionRefOwner.class).getById(1); - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.registerModule(new StormModule()); - var json = objectMapper.writeValueAsString(pet); + var mapper = JsonMapper.builder() + .findAndAddModules() + .disable(WRITE_DATES_AS_TIMESTAMPS) + .addModule(new StormModule()) + .build(); + var json = mapper.writeValueAsString(pet); assertEquals(""" - {"id":1,"name":"Leo","birthDate":[2020,9,7],"petType":{"id":1,"name":"cat"},"owner":1}""", json); - var fromJson = objectMapper.readValue(json, PetWithProjectionRefOwner.class); + {"id":1,"name":"Leo","birthDate":"2020-09-07","petType":{"id":1,"name":"cat"},"owner":1}""", json); + var fromJson = mapper.readValue(json, PetWithProjectionRefOwner.class); assertEquals(pet, fromJson); } @@ -181,13 +186,15 @@ public void loadedProjectionRefShouldSerializeWithProjectionWrapperAndId() throw var orm = of(dataSource); var pet = orm.entity(PetWithProjectionRefOwner.class).getById(1); var owner = ofNullable(pet.owner()).map(Ref::fetch).orElseThrow(); - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - objectMapper.registerModule(new StormModule()); - var json = objectMapper.writeValueAsString(pet); + var mapper = JsonMapper.builder() + .findAndAddModules() + .disable(WRITE_DATES_AS_TIMESTAMPS) + .addModule(new StormModule()) + .build(); + var json = mapper.writeValueAsString(pet); assertEquals(""" - {"id":1,"name":"Leo","birthDate":[2020,9,7],"petType":{"id":1,"name":"cat"},"owner":{"@id":1,"@projection":{"id":1,"firstName":"Betty","lastName":"Davis","address":{"address":"638 Cardinal Ave.","city":"Sun Prairie"},"telephone":"6085551749"}}}""", json); - var fromJson = objectMapper.readValue(json, PetWithProjectionRefOwner.class); + {"id":1,"name":"Leo","birthDate":"2020-09-07","petType":{"id":1,"name":"cat"},"owner":{"@id":1,"@projection":{"id":1,"firstName":"Betty","lastName":"Davis","address":{"address":"638 Cardinal Ave.","city":"Sun Prairie"},"telephone":"6085551749"}}}""", json); + var fromJson = mapper.readValue(json, PetWithProjectionRefOwner.class); assertEquals(pet, fromJson); assertEquals(owner, ofNullable(fromJson.owner()).map(Ref::fetch).orElseThrow()); } diff --git a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterImplTest.java b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterImplTest.java deleted file mode 100644 index ea696c37e..000000000 --- a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterImplTest.java +++ /dev/null @@ -1,448 +0,0 @@ -package st.orm.jackson; - -import static com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static st.orm.core.template.ORMTemplate.of; - -import com.fasterxml.jackson.annotation.JsonTypeInfo; -import com.fasterxml.jackson.annotation.JsonTypeName; -import com.fasterxml.jackson.core.JsonGenerator; -import com.fasterxml.jackson.core.JsonParser; -import com.fasterxml.jackson.databind.DeserializationContext; -import com.fasterxml.jackson.databind.JsonDeserializer; -import com.fasterxml.jackson.databind.JsonSerializer; -import com.fasterxml.jackson.databind.SerializerProvider; -import com.fasterxml.jackson.databind.annotation.JsonDeserialize; -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import java.util.List; -import java.util.Map; -import javax.sql.DataSource; -import lombok.Builder; -import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import st.orm.DbTable; -import st.orm.Entity; -import st.orm.Json; -import st.orm.PK; -import st.orm.PersistenceException; -import st.orm.Ref; -import st.orm.jackson.model.Address; -import st.orm.jackson.model.Owner; -import st.orm.test.StormTest; - -/** - * Tests for {@link st.orm.jackson.spi.JsonORMConverterImpl} targeting uncovered branches: - *

    - *
  • Custom @JsonSerialize/@JsonDeserialize annotations on @Json fields
  • - *
  • Sealed type (polymorphic) JSON fields with @JsonTypeName
  • - *
  • toDatabase with null record (null field value path)
  • - *
  • failOnUnknown and failOnMissing enabled paths
  • - *
- */ -@StormTest(scripts = "/data.sql") -public class JsonORMConverterImplTest { - - private DataSource dataSource; - - @BeforeEach - void bindDataSource(DataSource dataSource) { - this.dataSource = dataSource; - } - - // Custom Serializer/Deserializer for Address - - public static class AddressSerializer extends JsonSerializer
{ - @Override - public void serialize(Address value, JsonGenerator gen, SerializerProvider serializers) - throws java.io.IOException { - gen.writeString(value.address() + " | " + value.city()); - } - } - - public static class AddressDeserializer extends JsonDeserializer
{ - @Override - public Address deserialize(JsonParser parser, DeserializationContext context) - throws java.io.IOException { - String text = parser.getText(); - String[] parts = text.split(" \\| "); - return new Address(parts[0], parts[1]); - } - } - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithCustomSerializers( - @PK Integer id, - String firstName, - String lastName, - @Json - @JsonSerialize(using = AddressSerializer.class) - @JsonDeserialize(using = AddressDeserializer.class) - Address address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void customJsonSerializerAndDeserializerShouldBeUsedForJsonField() { - // Exercises the custom @JsonSerialize/@JsonDeserialize annotation branches - // in JsonORMConverterImpl constructor (lines 106-126 in jackson2). - var orm = of(dataSource); - var repository = orm.entity(OwnerWithCustomSerializers.class); - var address = new Address("123 Main St", "Springfield"); - var owner = OwnerWithCustomSerializers.builder() - .firstName("Test") - .lastName("User") - .address(address) - .telephone("555") - .build(); - var inserted = repository.insertAndFetch(owner); - // The custom serializer stores "address | city" as a plain string. - // The custom deserializer should parse it back. - assertEquals("123 Main St", inserted.address().address()); - assertEquals("Springfield", inserted.address().city()); - } - - @Test - public void customSerializerAndDeserializerShouldRoundTripThroughDatabase() { - // Exercises both custom serializer (write) and deserializer (read) in a full round trip. - var orm = of(dataSource); - var repository = orm.entity(OwnerWithCustomSerializers.class); - var address = new Address("789 Pine Rd", "Seattle"); - var owner = OwnerWithCustomSerializers.builder() - .firstName("Round") - .lastName("Trip") - .address(address) - .telephone("555") - .build(); - var inserted = repository.insertAndFetch(owner); - assertEquals("789 Pine Rd", inserted.address().address()); - assertEquals("Seattle", inserted.address().city()); - - // Verify another read from DB also uses the custom deserializer. - var fetched = repository.getById(inserted.id()); - assertEquals("789 Pine Rd", fetched.address().address()); - assertEquals("Seattle", fetched.address().city()); - } - - // Sealed type / polymorphic JSON - - @JsonTypeInfo(use = NAME) - public sealed interface PolymorphicPerson permits PersonA, PersonB {} - - public record PersonA(String firstName, String lastName) implements PolymorphicPerson {} - - public record PersonB(String firstName, String lastName) implements PolymorphicPerson {} - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithPolymorphicPerson( - @PK Integer id, - @Json PolymorphicPerson person, - @Json Address address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void polymorphicJsonDeserializationShouldResolveCorrectSubtypeViaDiscriminator() { - // Exercises the sealed type branch (type != null) in JsonORMConverterImpl constructor. - // The sealed interface PolymorphicPerson has permitted subtypes PersonA and PersonB. - var orm = of(dataSource); - var query = orm.query("SELECT id, JSON_OBJECT('@type' VALUE 'PersonA', 'firstName' VALUE first_name, 'lastName' VALUE last_name) AS person, address, telephone FROM owner"); - var owners = query.getResultList(OwnerWithPolymorphicPerson.class); - assertEquals(10, owners.size()); - assertTrue(owners.stream().allMatch(x -> x.person instanceof PersonA)); - } - - // Sealed interface with @JsonTypeName annotations on subtypes - - @JsonTypeInfo(use = NAME) - public sealed interface NamedPerson permits NamedPersonA, NamedPersonB {} - - @JsonTypeName("A") - public record NamedPersonA(String firstName, String lastName) implements NamedPerson {} - - @JsonTypeName("B") - public record NamedPersonB(String firstName, String lastName) implements NamedPerson {} - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithNamedPolymorphicPerson( - @PK Integer id, - @Json NamedPerson person, - @Json Address address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void polymorphicJsonWithExplicitTypeNamesShouldResolveSubtype() { - // Exercises the branch in getPermittedSubtypes where typeNameAnnotation is non-null. - var orm = of(dataSource); - var query = orm.query("SELECT id, JSON_OBJECT('@type' VALUE 'A', 'firstName' VALUE first_name, 'lastName' VALUE last_name) AS person, address, telephone FROM owner"); - var owners = query.getResultList(OwnerWithNamedPolymorphicPerson.class); - assertEquals(10, owners.size()); - assertTrue(owners.stream().allMatch(x -> x.person instanceof NamedPersonA)); - } - - // Nullable JSON field (toDatabase null path) - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithNullableAddress( - @PK Integer id, - String firstName, - String lastName, - @Nullable @Json Address address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void insertEntityWithNullJsonFieldShouldPersistNullAndReadBackAsNull() { - // Exercises the toDatabase path when the @Json field value is null. - var orm = of(dataSource); - var repository = orm.entity(OwnerWithNullableAddress.class); - var owner = OwnerWithNullableAddress.builder() - .firstName("NullAddr") - .lastName("Test") - .address(null) - .telephone("555") - .build(); - var inserted = repository.insertAndFetch(owner); - assertNull(inserted.address()); - } - - @Test - public void selectEntityWithNullJsonFieldShouldReturnNull() { - // Insert a row with null address directly, then select it. - var orm = of(dataSource); - orm.query("INSERT INTO owner (first_name, last_name, address, telephone) VALUES ('NullSel', 'Test', NULL, '555')") - .executeUpdate(); - var result = orm.query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE first_name = 'NullSel'") - .getSingleResult(OwnerWithNullableAddress.class); - assertNull(result.address()); - } - - // failOnUnknown enabled - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithFailOnUnknown( - @PK Integer id, - @Json(failOnUnknown = true) Address address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void jsonWithFailOnUnknownTrueShouldSucceedWithValidJson() { - // Exercises the failOnUnknown = true branch in the constructor. - // Owner id=1 has a valid address, so deserialization should succeed. - var orm = of(dataSource); - var result = orm.query("SELECT id, address, telephone FROM owner WHERE id = 1") - .getSingleResult(OwnerWithFailOnUnknown.class); - assertNotNull(result.address()); - } - - @Test - public void jsonWithFailOnUnknownTrueShouldRejectExtraProperties() { - // JSON with an extra unknown property should cause failure when failOnUnknown is true. - var orm = of(dataSource); - var query = orm.query(""" - SELECT id, - '{"address":"test","city":"test","extraField":"unexpected"}' AS address, - telephone - FROM owner WHERE id = 1"""); - assertThrows(PersistenceException.class, - () -> query.getSingleResult(OwnerWithFailOnUnknown.class)); - } - - // failOnMissing enabled - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithFailOnMissing( - @PK Integer id, - @Json(failOnMissing = true) Address address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void jsonWithFailOnMissingTrueShouldSucceedWithCompleteJson() { - // Exercises the failOnMissing = true branch in the constructor. - var orm = of(dataSource); - var result = orm.query("SELECT id, address, telephone FROM owner WHERE id = 1") - .getSingleResult(OwnerWithFailOnMissing.class); - assertNotNull(result.address()); - } - - // Nested deserialization: a custom deserializer that issues a query mid-deserialization. - - public static class NestedQueryMarkerDeserializer extends JsonDeserializer { - static DataSource nestedDataSource; - - @Override - public String deserialize(JsonParser parser, DeserializationContext context) - throws java.io.IOException { - String text = parser.getText(); - // Maps Owner, whose @Json address field enters fromDatabase re-entrantly on this thread. - of(nestedDataSource) - .query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE id = 2") - .getSingleResult(Owner.class); - return text; - } - } - - public record OwnerSnapshot( - @JsonDeserialize(using = NestedQueryMarkerDeserializer.class) String marker, - Ref owner - ) {} - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithSnapshot( - @PK Integer id, - @Json OwnerSnapshot snapshot, - @Nullable String telephone - ) implements Entity {} - - @Test - public void refDeserializedAfterNestedConversionShouldRemainAttached() { - // The marker field deserializes first and issues a nested query, which binds and unbinds the nested - // conversion's RefFactory. The owner field that follows must still see the outer factory: the - // resulting ref is attached and fetches the owner. - NestedQueryMarkerDeserializer.nestedDataSource = dataSource; - var orm = of(dataSource); - var query = orm.query("SELECT id, JSON_OBJECT('marker' VALUE 'audit', 'owner' VALUE id) AS snapshot, telephone FROM owner WHERE id = 1"); - var result = query.getSingleResult(OwnerWithSnapshot.class); - var ownerRef = result.snapshot().owner(); - assertTrue(ownerRef.isFetchable()); - assertEquals("Betty", ownerRef.fetch().firstName()); - } - - // Two @Json fields of different types sharing one custom serializer class. - - public static class TypeNameMarkerSerializer extends JsonSerializer { - @Override - public void serialize(Object value, JsonGenerator gen, SerializerProvider serializers) - throws java.io.IOException { - gen.writeString("marker:" + value.getClass().getSimpleName()); - } - } - - public record Phone(String number) {} - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithSharedSerializer( - @PK Integer id, - String firstName, - String lastName, - @Json @JsonSerialize(using = TypeNameMarkerSerializer.class) Address address, - @Json @JsonSerialize(using = TypeNameMarkerSerializer.class) Phone telephone - ) implements Entity {} - - public record RawOwnerRow(String address, String telephone) {} - - @Test - public void fieldsOfDifferentTypesSharingSerializerClassShouldEachUseTheSerializer() { - // Both fields use the same serializer class but have different types, so each field needs a mapper with - // the serializer registered for its own type. - var orm = of(dataSource); - var repository = orm.entity(OwnerWithSharedSerializer.class); - var owner = OwnerWithSharedSerializer.builder() - .firstName("Shared") - .lastName("Serializer") - .address(new Address("1 Way", "Town")) - .telephone(new Phone("555")) - .build(); - repository.insert(owner); - var row = orm.query("SELECT address, telephone FROM owner WHERE first_name = 'Shared'") - .getSingleResult(RawOwnerRow.class); - assertEquals("\"marker:Address\"", row.address()); - assertEquals("\"marker:Phone\"", row.telephone()); - } - - // Sealed element type inside a container. - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithPolymorphicPersonList( - @PK Integer id, - @Json List address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void polymorphicJsonListShouldResolveSubtypesViaDiscriminator() { - // The sealed interface appears as the List element type; its permitted subtypes are registered from the - // generic type, not just the raw List type. - var orm = of(dataSource); - var query = orm.query(""" - SELECT id, - '[{"@type":"PersonA","firstName":"Jane","lastName":"Doe"},{"@type":"PersonB","firstName":"John","lastName":"Doe"}]' AS address, - telephone - FROM owner WHERE id = 1"""); - var result = query.getSingleResult(OwnerWithPolymorphicPersonList.class); - assertEquals(2, result.address().size()); - assertTrue(result.address().get(0) instanceof PersonA); - assertTrue(result.address().get(1) instanceof PersonB); - } - - @Test - public void polymorphicJsonListShouldRoundTripThroughDatabase() { - var orm = of(dataSource); - var repository = orm.entity(OwnerWithPolymorphicPersonList.class); - var owner = OwnerWithPolymorphicPersonList.builder() - .address(List.of(new PersonA("Jane", "Doe"), new PersonB("John", "Doe"))) - .telephone("555") - .build(); - var inserted = repository.insertAndFetch(owner); - assertEquals(List.of(new PersonA("Jane", "Doe"), new PersonB("John", "Doe")), inserted.address()); - } - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithPolymorphicAddress( - @PK Integer id, - @Json PolymorphicPerson address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void polymorphicJsonFieldShouldRoundTripThroughDatabase() { - var orm = of(dataSource); - var repository = orm.entity(OwnerWithPolymorphicAddress.class); - var owner = OwnerWithPolymorphicAddress.builder() - .address(new PersonB("Jane", "Doe")) - .telephone("555") - .build(); - var inserted = repository.insertAndFetch(owner); - assertEquals(new PersonB("Jane", "Doe"), inserted.address()); - } - - @Builder(toBuilder = true) - @DbTable("owner") - public record OwnerWithPolymorphicPersonMap( - @PK Integer id, - @Json Map address, - @Nullable String telephone - ) implements Entity {} - - @Test - public void polymorphicJsonMapValuesShouldRoundTripThroughDatabase() { - var orm = of(dataSource); - var repository = orm.entity(OwnerWithPolymorphicPersonMap.class); - var persons = Map.of( - "primary", (PolymorphicPerson) new PersonA("Jane", "Doe"), - "secondary", new PersonB("John", "Doe")); - var owner = OwnerWithPolymorphicPersonMap.builder() - .address(persons) - .telephone("555") - .build(); - var inserted = repository.insertAndFetch(owner); - assertEquals(persons, inserted.address()); - } -} diff --git a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java index 07450df9d..b421db048 100644 --- a/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java +++ b/storm-jackson2/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java @@ -3,6 +3,8 @@ import static com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static st.orm.core.template.ORMTemplate.of; @@ -266,6 +268,269 @@ public record OwnerWithPolymorphicPerson( ) implements Entity { } + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithNullableAddress( + @PK Integer id, + String firstName, + String lastName, + @Nullable @Json Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void insertEntityWithNullJsonFieldShouldPersistNullAndReadBackAsNull() { + // When the @Json field is nullable and the value is null, toDatabase should serialize as null + // and fromDatabase should return null when reading from the database. + var orm = of(dataSource); + var repository = orm.entity(OwnerWithNullableAddress.class); + var owner = OwnerWithNullableAddress.builder() + .firstName("NullAddr") + .lastName("Test") + .address(null) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertNull(inserted.address()); + } + + @Test + public void selectEntityWithNullJsonFieldShouldReturnNull() { + // Insert a row with null address directly, then select it. + var orm = of(dataSource); + orm.query("INSERT INTO owner (first_name, last_name, address, telephone) VALUES ('Null', 'Test', NULL, '555')") + .executeUpdate(); + var result = orm.query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE first_name = 'Null'") + .getSingleResult(OwnerWithNullableAddress.class); + assertNull(result.address()); + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithFailOnUnknown( + @PK Integer id, + @Json(failOnUnknown = true) Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void jsonWithFailOnUnknownTrueShouldCreateMapperWithStrictMode() { + // Exercises the @Json(failOnUnknown = true) branch in JsonORMConverterImpl constructor, + // which skips calling builder.disable(FAIL_ON_UNKNOWN_PROPERTIES). + // Owner id=1 has a valid address, so deserialization should succeed. + var orm = of(dataSource); + var result = orm.query("SELECT id, address, telephone FROM owner WHERE id = 1") + .getSingleResult(OwnerWithFailOnUnknown.class); + assertNotNull(result.address()); + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithFailOnMissing( + @PK Integer id, + @Json(failOnMissing = true) Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void jsonWithFailOnMissingTrueShouldRejectMissingProperties() { + var orm = of(dataSource); + var query = orm.query(""" + SELECT id, + '{"address":"test"}' AS address, + telephone + FROM owner WHERE id = 1"""); + assertThrows(PersistenceException.class, () -> query.getSingleResult(OwnerWithFailOnMissing.class)); + } + + @Test + public void jsonWithFailOnMissingTrueShouldCreateMapperWithStrictMode() { + // Exercises the @Json(failOnMissing = true) branch in JsonORMConverterImpl constructor, + // which skips calling builder.disable(FAIL_ON_MISSING_CREATOR_PROPERTIES). + // Owner id=1 has a valid address with all fields, so deserialization should succeed. + var orm = of(dataSource); + var result = orm.query("SELECT id, address, telephone FROM owner WHERE id = 1") + .getSingleResult(OwnerWithFailOnMissing.class); + assertNotNull(result.address()); + } + + // Custom serializer for Address. + public static class AddressSerializer extends com.fasterxml.jackson.databind.ser.std.StdSerializer
{ + public AddressSerializer() { + super(Address.class); + } + + @Override + public void serialize(Address value, com.fasterxml.jackson.core.JsonGenerator gen, + com.fasterxml.jackson.databind.SerializerProvider ctxt) + throws java.io.IOException { + gen.writeString(value.address() + " | " + value.city()); + } + } + + // Custom deserializer for Address. + public static class AddressDeserializer extends com.fasterxml.jackson.databind.deser.std.StdDeserializer
{ + public AddressDeserializer() { + super(Address.class); + } + + @Override + public Address deserialize(com.fasterxml.jackson.core.JsonParser parser, + com.fasterxml.jackson.databind.DeserializationContext ctxt) + throws java.io.IOException { + String text = parser.getText(); + String[] parts = text.split(" \\| "); + return new Address(parts[0], parts[1]); + } + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithCustomSerializers( + @PK Integer id, + String firstName, + String lastName, + @Json + @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = AddressSerializer.class) + @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = AddressDeserializer.class) + Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void customJsonSerializerAndDeserializerShouldBeUsedForJsonField() { + // Exercises the custom @JsonSerialize/@JsonDeserialize annotation branches + // in JsonORMConverterImpl constructor (lines 106-126). + var orm = of(dataSource); + var repository = orm.entity(OwnerWithCustomSerializers.class); + var address = new Address("123 Main St", "Springfield"); + var owner = OwnerWithCustomSerializers.builder() + .firstName("Test") + .lastName("User") + .address(address) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + // The custom serializer stores "address | city" as a plain string. + // The custom deserializer should parse it back. + assertEquals("123 Main St", inserted.address().address()); + assertEquals("Springfield", inserted.address().city()); + // A later read from the database uses the custom deserializer as well. + var fetched = repository.getById(inserted.id()); + assertEquals("123 Main St", fetched.address().address()); + assertEquals("Springfield", fetched.address().city()); + // The raw column value proves the serializer engaged; a bypassed pair would round trip plain JSON. + var row = orm.query("SELECT address, telephone FROM owner WHERE id = " + inserted.id()) + .getSingleResult(RawOwnerRow.class); + assertEquals("\"123 Main St | Springfield\"", row.address()); + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithCustomDeserializerOnly( + @PK Integer id, + String firstName, + String lastName, + @Json + @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = AddressDeserializer.class) + Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void customDeserializerAloneShouldBeUsedForJsonField() { + // The mapper is keyed on the deserializer and the field's type when only a deserializer is present. + var orm = of(dataSource); + orm.query("INSERT INTO owner (first_name, last_name, address, telephone) " + + "VALUES ('Deser', 'Only', '\"1 Way | Town\"', '555')").executeUpdate(); + var owner = orm.query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE first_name = 'Deser'") + .getSingleResult(OwnerWithCustomDeserializerOnly.class); + assertEquals(new Address("1 Way", "Town"), owner.address()); + } + + // Custom serializer and deserializer without a constructor the converter can call. + + public static class UnconstructibleSerializer extends com.fasterxml.jackson.databind.ser.std.StdSerializer
{ + public UnconstructibleSerializer(String unused) { + super(Address.class); + } + + @Override + public void serialize(Address value, com.fasterxml.jackson.core.JsonGenerator gen, + com.fasterxml.jackson.databind.SerializerProvider ctxt) + throws java.io.IOException { + gen.writeString(value.address()); + } + } + + public static class UnconstructibleDeserializer extends com.fasterxml.jackson.databind.deser.std.StdDeserializer
{ + public UnconstructibleDeserializer(String unused) { + super(Address.class); + } + + @Override + public Address deserialize(com.fasterxml.jackson.core.JsonParser parser, + com.fasterxml.jackson.databind.DeserializationContext ctxt) + throws java.io.IOException { + return new Address(parser.getText(), ""); + } + } + + @DbTable("owner") + public record OwnerWithUnconstructibleSerializer( + @PK Integer id, + @Json + @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = UnconstructibleSerializer.class) + Address address + ) implements Entity { + } + + @DbTable("owner") + public record OwnerWithUnconstructibleDeserializer( + @PK Integer id, + @Json + @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = UnconstructibleDeserializer.class) + Address address + ) implements Entity { + } + + @Test + public void aSerializerWithoutANoArgConstructorIsRefusedWhenTheFieldIsFirstUsed() { + var orm = of(dataSource); + var serializer = assertThrows(RuntimeException.class, + () -> orm.query("SELECT id, address FROM owner WHERE id = 1") + .getSingleResult(OwnerWithUnconstructibleSerializer.class)); + assertTrue(rootMessage(serializer).contains("custom serializer"), rootMessage(serializer)); + var deserializer = assertThrows(RuntimeException.class, + () -> orm.query("SELECT id, address FROM owner WHERE id = 1") + .getSingleResult(OwnerWithUnconstructibleDeserializer.class)); + assertTrue(rootMessage(deserializer).contains("custom deserializer"), rootMessage(deserializer)); + } + + private static String rootMessage(Throwable thrown) { + var messages = new StringBuilder(); + for (Throwable cause = thrown; cause != null; cause = cause.getCause()) { + messages.append(cause.getMessage()).append('\n'); + } + return messages.toString(); + } + + @Test + public void jsonWithFailOnUnknownTrueShouldRejectExtraProperties() { + var orm = of(dataSource); + var query = orm.query(""" + SELECT id, + '{"address":"test","city":"test","extraField":"unexpected"}' AS address, + telephone + FROM owner WHERE id = 1"""); + assertThrows(PersistenceException.class, () -> query.getSingleResult(OwnerWithFailOnUnknown.class)); + } + @Test public void polymorphicJsonDeserializationShouldResolveCorrectSubtypeViaDiscriminator() { // Uses a sealed interface with @JsonTypeInfo(use = NAME) and two permitted subtypes. @@ -308,4 +573,186 @@ public void polymorphicJsonWithExplicitTypeNamesShouldResolveSubtype() { assertEquals(10, owner.size()); assertTrue(owner.stream().allMatch(x -> x.person instanceof NamedPersonA)); } + + // Nested deserialization: a custom deserializer that issues a query mid-deserialization. + + public static class NestedQueryMarkerDeserializer extends com.fasterxml.jackson.databind.deser.std.StdDeserializer { + static DataSource nestedDataSource; + + public NestedQueryMarkerDeserializer() { + super(String.class); + } + + @Override + public String deserialize(com.fasterxml.jackson.core.JsonParser parser, + com.fasterxml.jackson.databind.DeserializationContext ctxt) + throws java.io.IOException { + String text = parser.getText(); + // Maps Owner, whose @Json address field enters fromDatabase re-entrantly on this thread. + of(nestedDataSource) + .query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE id = 2") + .getSingleResult(Owner.class); + return text; + } + } + + public record OwnerSnapshot( + @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = NestedQueryMarkerDeserializer.class) String marker, + Ref owner + ) {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithSnapshot( + @PK Integer id, + @Json OwnerSnapshot snapshot, + @Nullable String telephone + ) implements Entity {} + + @Test + public void refDeserializedAfterNestedConversionShouldRemainAttached() { + // The marker field deserializes first and issues a nested query, which binds and unbinds the nested + // conversion's RefFactory. The owner field that follows must still see the outer factory: the + // resulting ref is attached and fetches the owner. + NestedQueryMarkerDeserializer.nestedDataSource = dataSource; + var orm = of(dataSource); + var query = orm.query("SELECT id, JSON_OBJECT('marker' VALUE 'audit', 'owner' VALUE id) AS snapshot, telephone FROM owner WHERE id = 1"); + var result = query.getSingleResult(OwnerWithSnapshot.class); + var ownerRef = result.snapshot().owner(); + assertTrue(ownerRef.isFetchable()); + assertEquals("Betty", ownerRef.fetch().firstName()); + } + + // Two @Json fields of different types sharing one custom serializer class. + + public static class TypeNameMarkerSerializer extends com.fasterxml.jackson.databind.ser.std.StdSerializer { + public TypeNameMarkerSerializer() { + super(Object.class); + } + + @Override + public void serialize(Object value, com.fasterxml.jackson.core.JsonGenerator gen, + com.fasterxml.jackson.databind.SerializerProvider ctxt) + throws java.io.IOException { + gen.writeString("marker:" + value.getClass().getSimpleName()); + } + } + + public record Phone(String number) {} + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithSharedSerializer( + @PK Integer id, + String firstName, + String lastName, + @Json + @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = TypeNameMarkerSerializer.class) + Address address, + @Json + @com.fasterxml.jackson.databind.annotation.JsonSerialize(using = TypeNameMarkerSerializer.class) + Phone telephone + ) implements Entity {} + + public record RawOwnerRow(String address, String telephone) {} + + @Test + public void fieldsOfDifferentTypesSharingSerializerClassShouldEachUseTheSerializer() { + // Both fields use the same serializer class but have different types, so each field needs a mapper with + // the serializer registered for its own type. + var orm = of(dataSource); + var repository = orm.entity(OwnerWithSharedSerializer.class); + var owner = OwnerWithSharedSerializer.builder() + .firstName("Shared") + .lastName("Serializer") + .address(new Address("1 Way", "Town")) + .telephone(new Phone("555")) + .build(); + repository.insert(owner); + var row = orm.query("SELECT address, telephone FROM owner WHERE first_name = 'Shared'") + .getSingleResult(RawOwnerRow.class); + assertEquals("\"marker:Address\"", row.address()); + assertEquals("\"marker:Phone\"", row.telephone()); + } + + // Sealed element type inside a container. + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithPolymorphicPersonList( + @PK Integer id, + @Json List address, + @Nullable String telephone + ) implements Entity {} + + @Test + public void polymorphicJsonListShouldResolveSubtypesViaDiscriminator() { + // The sealed interface appears as the List element type; its permitted subtypes are registered from the + // generic type, not just the raw List type. + var orm = of(dataSource); + var query = orm.query(""" + SELECT id, + '[{"@type":"PersonA","firstName":"Jane","lastName":"Doe"},{"@type":"PersonB","firstName":"John","lastName":"Doe"}]' AS address, + telephone + FROM owner WHERE id = 1"""); + var result = query.getSingleResult(OwnerWithPolymorphicPersonList.class); + assertEquals(2, result.address().size()); + assertTrue(result.address().get(0) instanceof PersonA); + assertTrue(result.address().get(1) instanceof PersonB); + } + + @Test + public void polymorphicJsonListShouldRoundTripThroughDatabase() { + var orm = of(dataSource); + var repository = orm.entity(OwnerWithPolymorphicPersonList.class); + var owner = OwnerWithPolymorphicPersonList.builder() + .address(List.of(new PersonA("Jane", "Doe"), new PersonB("John", "Doe"))) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertEquals(List.of(new PersonA("Jane", "Doe"), new PersonB("John", "Doe")), inserted.address()); + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithPolymorphicAddress( + @PK Integer id, + @Json PolymorphicPerson address, + @Nullable String telephone + ) implements Entity {} + + @Test + public void polymorphicJsonFieldShouldRoundTripThroughDatabase() { + var orm = of(dataSource); + var repository = orm.entity(OwnerWithPolymorphicAddress.class); + var owner = OwnerWithPolymorphicAddress.builder() + .address(new PersonB("Jane", "Doe")) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertEquals(new PersonB("Jane", "Doe"), inserted.address()); + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithPolymorphicPersonMap( + @PK Integer id, + @Json Map address, + @Nullable String telephone + ) implements Entity {} + + @Test + public void polymorphicJsonMapValuesShouldRoundTripThroughDatabase() { + var orm = of(dataSource); + var repository = orm.entity(OwnerWithPolymorphicPersonMap.class); + var persons = Map.of( + "primary", (PolymorphicPerson) new PersonA("Jane", "Doe"), + "secondary", new PersonB("John", "Doe")); + var owner = OwnerWithPolymorphicPersonMap.builder() + .address(persons) + .telephone("555") + .build(); + var inserted = repository.insertAndFetch(owner); + assertEquals(persons, inserted.address()); + } } diff --git a/storm-jackson2/src/test/java/st/orm/jackson/StormModuleTest.java b/storm-jackson2/src/test/java/st/orm/jackson/StormModuleTest.java index 518cc3011..83669267b 100644 --- a/storm-jackson2/src/test/java/st/orm/jackson/StormModuleTest.java +++ b/storm-jackson2/src/test/java/st/orm/jackson/StormModuleTest.java @@ -1,13 +1,14 @@ package st.orm.jackson; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.fasterxml.jackson.databind.JsonMappingException; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DatabindException; +import com.fasterxml.jackson.databind.json.JsonMapper; import java.util.List; import java.util.Map; import java.util.Set; @@ -22,9 +23,13 @@ import st.orm.jackson.model.VetSpecialty; import st.orm.jackson.model.VetSpecialtyPK; -public class StormModuleTest { - - // Test entities and projections. +/** + * The JSON forms of a {@code Ref}, written and read through {@link StormModule}: a loaded entity as + * {@code {"@entity": ...}}, a loaded projection as {@code {"@id": ..., "@projection": ...}}, an unloaded ref as its + * id, a compound key as a plain object, inside containers and with {@code null}, for keys of every type and for a + * data type without a key, whose id is read by its JSON form. + */ +class StormModuleTest { public record SimpleEntity(@PK Integer id, String name) implements Entity {} @@ -32,851 +37,422 @@ public record StringIdEntity(@PK String id, String name) implements Entity {} - public record SimpleProjection(@PK Integer id, String label) implements Projection {} - - // A Data record without @PK annotation - triggers null pkType fallback paths. - public record NoPkData(String value) implements Data {} - - // Wrapper records with Ref fields. - - public record EntityRefHolder(Ref entity) {} - - public record StringIdRefHolder(Ref entity) {} - - public record LongIdRefHolder(Ref entity) {} - - public record ProjectionRefHolder(@Nullable Ref projection) {} + public record DoubleIdEntity(@PK Double id, String name) implements Entity {} - public record NullableRefHolder(@Nullable Ref entity) {} + public record SimpleProjection(@PK Integer id, String label) implements Projection {} - public record RefListHolder(List> entities) {} + /** A data type without a key: the id of its refs is read by its JSON form. */ + public record NoPkData(Integer id, String value) implements Data {} - public record RefSetHolder(Set> entities) {} + /** A projection without a key: the id of the loaded form is read by its JSON form. */ + public record NoPkProjection(String label) implements Projection {} - public record RefMapHolder(Map> entities) {} + public record EntityHolder(@Nullable Ref entity) {} - public record NoPkRefHolder(@Nullable Ref data) {} + public record StringIdHolder(Ref entity) {} - public record NoPkRefListHolder(List> data) {} + public record LongIdHolder(Ref entity) {} - public record CompoundPkRefHolder(Ref entity) {} + public record DoubleIdHolder(Ref entity) {} - // Tests for constructor variations. + public record ProjectionHolder(@Nullable Ref projection) {} - @Test - public void constructorWithNullRefFactoryShouldCreateModuleWithDetachedRefs() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule((RefFactory) null)); + public record CompoundPkHolder(@Nullable Ref entity) {} - String json = "{\"entity\":1}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(1, holder.entity().id()); - } + public record NoPkHolder(@Nullable Ref data) {} - @Test - public void constructorWithRefFactoryShouldUseFactoryForDeserialization() throws Exception { - RefFactory factory = new RefFactory() { - @Override - public Ref create(Class type, ID pk) { - return Ref.of(type, pk); - } + public record NoPkProjectionHolder(@Nullable Ref projection) {} - @Override - public Ref create(T record, ID pk) { - return Ref.of(type(record), pk); - } + public record EntityListHolder(List> entities) {} - @SuppressWarnings("unchecked") - private Class type(T record) { - return (Class) record.getClass(); - } - }; - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule(factory)); + public record EntitySetHolder(Set> entities) {} - String json = "{\"entity\":42}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(42, holder.entity().id()); - } + public record EntityMapHolder(Map> entities) {} - @Test - public void constructorWithSupplierShouldUseSuppliedFactory() throws Exception { - RefFactory factory = new RefFactory() { - @Override - public Ref create(Class type, ID pk) { - return Ref.of(type, pk); - } + public record ProjectionListHolder(List> projections) {} - @Override - public Ref create(T record, ID pk) { - return Ref.of(type(record), pk); - } + public record NoPkListHolder(List> data) {} - @SuppressWarnings("unchecked") - private Class type(T record) { - return (Class) record.getClass(); - } - }; - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule(() -> factory)); + public record MultiTypeHolder(@Nullable Ref entity, @Nullable Ref projection) {} - String json = "{\"entity\":7}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(7, holder.entity().id()); + private static JsonMapper mapper() { + return mapper(new StormModule()); } - @Test - public void constructorWithNullSupplierResultShouldCreateDetachedRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule(() -> null)); - - String json = "{\"entity\":7}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(7, holder.entity().id()); + private static JsonMapper mapper(StormModule module) { + return JsonMapper.builder().addModule(module).build(); } - // Tests for Ref serialization. + /** A ref factory that counts the refs it creates, standing in for the attached factory of a template. */ + private static final class CountingRefFactory implements RefFactory { + int created; - @Test - public void unloadedRefShouldSerializeAsRawId() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Ref create(Class type, ID id) { + created++; + return (Ref) Ref.of((Class) type, id); + } - Ref ref = Ref.of(SimpleEntity.class, 42); - EntityRefHolder holder = new EntityRefHolder(ref); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":42}", json); + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Ref create(T record, ID id) { + return (Ref) Ref.of((Class) record.getClass(), id); + } } - @Test - public void loadedEntityRefShouldSerializeWithEntityWrapper() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - SimpleEntity entity = new SimpleEntity(1, "Alice"); - Ref ref = Ref.of(entity); - EntityRefHolder holder = new EntityRefHolder(ref); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":{\"@entity\":{\"id\":1,\"name\":\"Alice\"}}}", json); - } + // Writing. @Test - public void loadedProjectionRefShouldSerializeWithProjectionWrapperAndId() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - SimpleProjection projection = new SimpleProjection(5, "Test"); - Ref ref = Ref.of(projection, 5); - ProjectionRefHolder holder = new ProjectionRefHolder(ref); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"projection\":{\"@id\":5,\"@projection\":{\"id\":5,\"label\":\"Test\"}}}", json); + void anUnloadedRefWritesAsItsId() throws Exception { + assertEquals("{\"entity\":42}", mapper().writeValueAsString(new EntityHolder(Ref.of(SimpleEntity.class, 42)))); + assertEquals("{\"entity\":\"abc-123\"}", + mapper().writeValueAsString(new StringIdHolder(Ref.of(StringIdEntity.class, "abc-123")))); + assertEquals("{\"entity\":9999999999}", + mapper().writeValueAsString(new LongIdHolder(Ref.of(LongIdEntity.class, 9999999999L)))); + assertEquals("{\"entity\":3.14}", + mapper().writeValueAsString(new DoubleIdHolder(Ref.of(DoubleIdEntity.class, 3.14)))); } @Test - public void unloadedRefWithStringIdShouldSerializeAsStringValue() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Ref ref = Ref.of(StringIdEntity.class, "abc-123"); - StringIdRefHolder holder = new StringIdRefHolder(ref); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":\"abc-123\"}", json); + void aLoadedEntityRefWritesAsTheEntityWrapper() throws Exception { + var holder = new EntityHolder(Ref.of(new SimpleEntity(7, "Test"))); + assertEquals("{\"entity\":{\"@entity\":{\"id\":7,\"name\":\"Test\"}}}", mapper().writeValueAsString(holder)); } @Test - public void unloadedRefWithLongIdShouldSerializeAsLongValue() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Ref ref = Ref.of(LongIdEntity.class, 9999999999L); - LongIdRefHolder holder = new LongIdRefHolder(ref); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":9999999999}", json); + void aLoadedProjectionRefWritesAsTheProjectionWrapperWithItsId() throws Exception { + var holder = new ProjectionHolder(Ref.of(new SimpleProjection(3, "Label"), 3)); + assertEquals("{\"projection\":{\"@id\":3,\"@projection\":{\"id\":3,\"label\":\"Label\"}}}", + mapper().writeValueAsString(holder)); } - // Tests for Ref deserialization. - @Test - public void rawIntegerIdShouldDeserializeToUnloadedRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entity\":42}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(42, holder.entity().id()); - assertNull(holder.entity().getOrNull()); + void anUnloadedCompoundKeyRefWritesAsTheKeyObject() throws Exception { + var holder = new CompoundPkHolder(Ref.of(VetSpecialty.class, new VetSpecialtyPK(1, 2))); + assertEquals("{\"entity\":{\"vetId\":1,\"specialtyId\":2}}", mapper().writeValueAsString(holder)); } @Test - public void rawStringIdShouldDeserializeToUnloadedRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entity\":\"abc-123\"}"; - StringIdRefHolder holder = mapper.readValue(json, StringIdRefHolder.class); - assertNotNull(holder.entity()); - assertEquals("abc-123", holder.entity().id()); + void aNullRefWritesAsNull() throws Exception { + assertEquals("{\"entity\":null}", mapper().writeValueAsString(new EntityHolder(null))); + assertEquals("{\"projection\":null}", mapper().writeValueAsString(new ProjectionHolder(null))); } @Test - public void rawLongIdShouldDeserializeToUnloadedRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entity\":9999999999}"; - LongIdRefHolder holder = mapper.readValue(json, LongIdRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(9999999999L, holder.entity().id()); + void aRefOfADataTypeWithoutAKeyWritesItsIdAsIs() throws Exception { + // Without a key type to find a serializer for, the id serializes as the value it is. + assertTrue(mapper().writeValueAsString(new NoPkHolder(Ref.of(NoPkData.class, 42))).contains("42")); + assertTrue(mapper().writeValueAsString(new NoPkHolder(Ref.of(NoPkData.class, "text-id"))).contains("text-id")); } @Test - public void entityObjectShouldDeserializeToLoadedRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entity\":{\"@entity\":{\"id\":1,\"name\":\"Alice\"}}}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(1, holder.entity().id()); - SimpleEntity loadedEntity = holder.entity().getOrNull(); - assertNotNull(loadedEntity); - assertEquals("Alice", loadedEntity.name()); + void aRefWritesOutsideAPropertyToo() throws Exception { + // Serialized directly, the serializer has no property to resolve the key type from. + var json = mapper().writeValueAsString(Ref.of(SimpleEntity.class, 42)); + assertTrue(json.contains("42"), json); } @Test - public void projectionObjectShouldDeserializeToLoadedRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"projection\":{\"@id\":5,\"@projection\":{\"id\":5,\"label\":\"Test\"}}}"; - ProjectionRefHolder holder = mapper.readValue(json, ProjectionRefHolder.class); - assertNotNull(holder.projection()); - assertEquals(5, holder.projection().id()); - SimpleProjection loadedProjection = holder.projection().getOrNull(); - assertNotNull(loadedProjection); - assertEquals("Test", loadedProjection.label()); + void aFieldThatIsNotARefIsLeftToJackson() throws Exception { + record NonRefHolder(String value) {} + assertEquals("{\"value\":\"test\"}", mapper().writeValueAsString(new NonRefHolder("test"))); } - @Test - public void nullRefShouldDeserializeToNull() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entity\":null}"; - NullableRefHolder holder = mapper.readValue(json, NullableRefHolder.class); - assertNull(holder.entity()); - } + // Reading. @Test - public void refListWithNullElementsShouldDeserializeCorrectly() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entities\":[null,1,2]}"; - RefListHolder holder = mapper.readValue(json, RefListHolder.class); - assertEquals(3, holder.entities().size()); - assertNull(holder.entities().get(0)); - assertEquals(1, holder.entities().get(1).id()); - assertEquals(2, holder.entities().get(2).id()); + void anIdReadsAsAnUnloadedRef() throws Exception { + var entity = mapper().readValue("{\"entity\":42}", EntityHolder.class).entity(); + assertNotNull(entity); + assertEquals(42, entity.id()); + assertNull(entity.getOrNull()); + assertEquals("abc", mapper().readValue("{\"entity\":\"abc\"}", StringIdHolder.class).entity().id()); + assertEquals(9999999999L, mapper().readValue("{\"entity\":9999999999}", LongIdHolder.class).entity().id()); + assertEquals(3.14, mapper().readValue("{\"entity\":3.14}", DoubleIdHolder.class).entity().id()); } @Test - public void refObjectWithoutEntityOrProjectionFieldShouldThrowException() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entity\":{\"unknown\":\"field\"}}"; - assertThrows(JsonMappingException.class, () -> - mapper.readValue(json, EntityRefHolder.class)); - } - - @Test - public void projectionObjectWithoutIdFieldShouldThrowException() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"projection\":{\"@projection\":{\"id\":5,\"label\":\"Test\"}}}"; - assertThrows(JsonMappingException.class, () -> - mapper.readValue(json, ProjectionRefHolder.class)); + void anEntityWrapperReadsAsALoadedRef() throws Exception { + var holder = mapper().readValue("{\"entity\":{\"@entity\":{\"id\":7,\"name\":\"Test\"}}}", EntityHolder.class); + assertNotNull(holder.entity()); + assertTrue(holder.entity().isLoaded()); + var entity = holder.entity().fetch(); + assertInstanceOf(SimpleEntity.class, entity); + assertEquals(7, entity.id()); + assertEquals("Test", entity.name()); } @Test - public void entityRefSerializationShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Unloaded ref round-trip. - Ref unloadedRef = Ref.of(SimpleEntity.class, 42); - EntityRefHolder unloadedHolder = new EntityRefHolder(unloadedRef); - String unloadedJson = mapper.writeValueAsString(unloadedHolder); - EntityRefHolder deserializedUnloaded = mapper.readValue(unloadedJson, EntityRefHolder.class); - assertEquals(unloadedHolder, deserializedUnloaded); - - // Loaded ref round-trip. - SimpleEntity entity = new SimpleEntity(1, "Alice"); - Ref loadedRef = Ref.of(entity); - EntityRefHolder loadedHolder = new EntityRefHolder(loadedRef); - String loadedJson = mapper.writeValueAsString(loadedHolder); - EntityRefHolder deserializedLoaded = mapper.readValue(loadedJson, EntityRefHolder.class); - assertEquals(loadedHolder, deserializedLoaded); + void aProjectionWrapperReadsAsALoadedRef() throws Exception { + var json = "{\"projection\":{\"@id\":3,\"@projection\":{\"id\":3,\"label\":\"Label\"}}}"; + var holder = mapper().readValue(json, ProjectionHolder.class); + assertNotNull(holder.projection()); + assertTrue(holder.projection().isLoaded()); + var projection = holder.projection().fetch(); + assertInstanceOf(SimpleProjection.class, projection); + assertEquals(3, projection.id()); + assertEquals("Label", projection.label()); } @Test - public void projectionRefSerializationShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Unloaded projection ref round-trip. - Ref unloadedRef = Ref.of(SimpleProjection.class, 5); - ProjectionRefHolder unloadedHolder = new ProjectionRefHolder(unloadedRef); - String unloadedJson = mapper.writeValueAsString(unloadedHolder); - ProjectionRefHolder deserializedUnloaded = mapper.readValue(unloadedJson, ProjectionRefHolder.class); - assertEquals(unloadedHolder, deserializedUnloaded); - - // Loaded projection ref round-trip. - SimpleProjection projection = new SimpleProjection(5, "Test"); - Ref loadedRef = Ref.of(projection, 5); - ProjectionRefHolder loadedHolder = new ProjectionRefHolder(loadedRef); - String loadedJson = mapper.writeValueAsString(loadedHolder); - ProjectionRefHolder deserializedLoaded = mapper.readValue(loadedJson, ProjectionRefHolder.class); - assertEquals(loadedHolder, deserializedLoaded); + void aProjectionWrapperHoldingAnEntityReadsAsALoadedEntityRef() throws Exception { + var json = "{\"entity\":{\"@id\":1,\"@projection\":{\"id\":1,\"name\":\"Via Projection Path\"}}}"; + var holder = mapper().readValue(json, EntityHolder.class); + assertNotNull(holder.entity()); + assertEquals(1, holder.entity().id()); + var loaded = holder.entity().getOrNull(); + assertNotNull(loaded); + assertEquals("Via Projection Path", loaded.name()); } @Test - public void unloadedCompoundPkRefShouldSerializeAsPkObject() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Ref ref = Ref.of(VetSpecialty.class, new VetSpecialtyPK(1, 2)); - String json = mapper.writeValueAsString(new CompoundPkRefHolder(ref)); - assertEquals("{\"entity\":{\"vetId\":1,\"specialtyId\":2}}", json); + void aProjectionWrapperHoldingPlainDataReadsAsARefOfItsId() throws Exception { + // Neither entity nor projection: the id of the wrapper is the ref's id. + var json = "{\"data\":{\"@id\":42,\"@projection\":{\"id\":42,\"value\":\"test\"}}}"; + var holder = mapper().readValue(json, NoPkHolder.class); + assertNotNull(holder.data()); + assertEquals(42, ((Number) holder.data().id()).intValue()); } @Test - public void compoundPkObjectShouldDeserializeToUnloadedRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entity\":{\"vetId\":1,\"specialtyId\":2}}"; - CompoundPkRefHolder holder = mapper.readValue(json, CompoundPkRefHolder.class); + void aCompoundKeyObjectReadsAsAnUnloadedRef() throws Exception { + var holder = mapper().readValue("{\"entity\":{\"vetId\":1,\"specialtyId\":2}}", CompoundPkHolder.class); assertNotNull(holder.entity()); assertEquals(new VetSpecialtyPK(1, 2), holder.entity().id()); } @Test - public void compoundPkRefSerializationShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Ref ref = Ref.of(VetSpecialty.class, new VetSpecialtyPK(3, 4)); - CompoundPkRefHolder holder = new CompoundPkRefHolder(ref); - String json = mapper.writeValueAsString(holder); - CompoundPkRefHolder deserialized = mapper.readValue(json, CompoundPkRefHolder.class); - assertEquals(holder, deserialized); - } - - @Test - public void stringIdRefShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Ref ref = Ref.of(StringIdEntity.class, "my-id"); - StringIdRefHolder holder = new StringIdRefHolder(ref); - String json = mapper.writeValueAsString(holder); - StringIdRefHolder deserialized = mapper.readValue(json, StringIdRefHolder.class); - assertEquals(holder, deserialized); - } - - @Test - public void loadedStringIdEntityRefShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - StringIdEntity entity = new StringIdEntity("abc", "Test"); - Ref ref = Ref.of(entity); - StringIdRefHolder holder = new StringIdRefHolder(ref); - String json = mapper.writeValueAsString(holder); - StringIdRefHolder deserialized = mapper.readValue(json, StringIdRefHolder.class); - assertEquals(holder, deserialized); + void nullReadsAsNoRef() throws Exception { + assertNull(mapper().readValue("{\"entity\":null}", EntityHolder.class).entity()); + assertNull(mapper().readValue("{\"projection\":null}", ProjectionHolder.class).projection()); } @Test - public void loadedLongIdEntityRefShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - LongIdEntity entity = new LongIdEntity(999L, "Test"); - Ref ref = Ref.of(entity); - LongIdRefHolder holder = new LongIdRefHolder(ref); - String json = mapper.writeValueAsString(holder); - LongIdRefHolder deserialized = mapper.readValue(json, LongIdRefHolder.class); - assertEquals(holder, deserialized); + void aWrapperHoldingNullReadsAsNoRef() throws Exception { + assertNull(mapper().readValue("{\"entity\":{\"@entity\":null}}", EntityHolder.class).entity()); + assertNull(mapper().readValue("{\"projection\":{\"@id\":3,\"@projection\":null}}", ProjectionHolder.class) + .projection()); } - // Tests for fallback deserialization paths (when pkType is null). + // Round trips. @Test - public void noPkDataWithSmallIntegerIdShouldDeserializeViaFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // NoPkData has no @PK field, so pkType will be null, triggering the fallback integer path. - String json = "{\"data\":42}"; - NoPkRefHolder holder = mapper.readValue(json, NoPkRefHolder.class); - assertNotNull(holder.data()); - assertEquals(42, ((Number) holder.data().id()).intValue()); + void anEntityRefRoundTripsLoadedAndUnloaded() throws Exception { + var unloaded = new EntityHolder(Ref.of(SimpleEntity.class, 42)); + assertEquals(unloaded, mapper().readValue(mapper().writeValueAsString(unloaded), EntityHolder.class)); + var entity = new SimpleEntity(5, "RoundTrip"); + var loaded = new EntityHolder(Ref.of(entity)); + var restored = mapper().readValue(mapper().writeValueAsString(loaded), EntityHolder.class); + assertEquals(loaded, restored); + assertTrue(restored.entity().isLoaded()); + assertEquals(entity, restored.entity().fetch()); } @Test - public void noPkDataWithLargeIntegerIdShouldDeserializeAsLongViaFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Large number exceeding int range triggers the long branch in fallback. - String json = "{\"data\":9999999999}"; - NoPkRefHolder holder = mapper.readValue(json, NoPkRefHolder.class); - assertNotNull(holder.data()); - assertEquals(9999999999L, holder.data().id()); + void aProjectionRefRoundTripsLoadedAndUnloaded() throws Exception { + var unloaded = new ProjectionHolder(Ref.of(SimpleProjection.class, 5)); + assertEquals(unloaded, mapper().readValue(mapper().writeValueAsString(unloaded), ProjectionHolder.class)); + var projection = new SimpleProjection(10, "Proj"); + var loaded = new ProjectionHolder(Ref.of(projection, 10)); + var restored = mapper().readValue(mapper().writeValueAsString(loaded), ProjectionHolder.class); + assertEquals(loaded, restored); + assertTrue(restored.projection().isLoaded()); + assertEquals(projection, restored.projection().fetch()); } @Test - public void noPkDataWithFloatIdShouldDeserializeAsDoubleViaFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Float value triggers VALUE_NUMBER_FLOAT branch in fallback. - String json = "{\"data\":3.14}"; - NoPkRefHolder holder = mapper.readValue(json, NoPkRefHolder.class); - assertNotNull(holder.data()); - assertEquals(3.14, holder.data().id()); + void aCompoundKeyRefRoundTrips() throws Exception { + var holder = new CompoundPkHolder(Ref.of(VetSpecialty.class, new VetSpecialtyPK(3, 4))); + assertEquals(holder, mapper().readValue(mapper().writeValueAsString(holder), CompoundPkHolder.class)); } @Test - public void noPkDataWithStringIdShouldDeserializeAsStringViaFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // String value triggers VALUE_STRING branch in fallback. - String json = "{\"data\":\"my-id\"}"; - NoPkRefHolder holder = mapper.readValue(json, NoPkRefHolder.class); - assertNotNull(holder.data()); - assertEquals("my-id", holder.data().id()); + void refsOfEveryKeyTypeRoundTrip() throws Exception { + var string = new StringIdHolder(Ref.of(StringIdEntity.class, "my-id")); + assertEquals(string, mapper().readValue(mapper().writeValueAsString(string), StringIdHolder.class)); + var loadedString = new StringIdHolder(Ref.of(new StringIdEntity("abc", "Test"))); + assertEquals(loadedString, mapper().readValue(mapper().writeValueAsString(loadedString), StringIdHolder.class)); + var loadedLong = new LongIdHolder(Ref.of(new LongIdEntity(999L, "Test"))); + assertEquals(loadedLong, mapper().readValue(mapper().writeValueAsString(loadedLong), LongIdHolder.class)); + var loadedDouble = new DoubleIdHolder(Ref.of(new DoubleIdEntity(2.71, "Euler"))); + var json = mapper().writeValueAsString(loadedDouble); + assertTrue(json.contains("@entity"), json); + assertEquals(loadedDouble, mapper().readValue(json, DoubleIdHolder.class)); } - // Tests for deserializeIdFromNode fallback paths (projection deserialization). - - public record NoPkProjection(String label) implements Projection {} - - public record NoPkProjectionRefHolder(@Nullable Ref projection) {} + // Containers. @Test - public void noPkProjectionWithIntIdNodeShouldDeserializeViaNodeFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Projection path with @id node and no @PK on target - exercises deserializeIdFromNode int fallback. - String json = "{\"projection\":{\"@id\":42,\"@projection\":{\"label\":\"Test\"}}}"; - NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); - assertNotNull(holder.projection()); - assertEquals(42, holder.projection().id()); + void refsInAListReadWithNullElements() throws Exception { + var holder = mapper().readValue("{\"entities\":[42, null, 7]}", EntityListHolder.class); + assertEquals(3, holder.entities().size()); + assertEquals(42, holder.entities().get(0).id()); + assertNull(holder.entities().get(1)); + assertEquals(7, holder.entities().get(2).id()); + assertEquals(0, mapper().readValue("{\"entities\":[]}", EntityListHolder.class).entities().size()); } @Test - public void noPkProjectionWithLongIdNodeShouldDeserializeViaNodeFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Long value in node triggers isLong fallback. - String json = "{\"projection\":{\"@id\":9999999999,\"@projection\":{\"label\":\"Test\"}}}"; - NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); - assertNotNull(holder.projection()); - assertEquals(9999999999L, holder.projection().id()); + void refsInAListReadLoadedAndUnloadedAlike() throws Exception { + var json = "{\"entities\":[null,1,{\"@entity\":{\"id\":2,\"name\":\"Bob\"}}]}"; + var holder = mapper().readValue(json, EntityListHolder.class); + assertEquals(3, holder.entities().size()); + assertNull(holder.entities().get(0)); + assertEquals(1, holder.entities().get(1).id()); + assertNull(holder.entities().get(1).getOrNull()); + assertEquals(2, holder.entities().get(2).id()); + assertNotNull(holder.entities().get(2).getOrNull()); } @Test - public void noPkProjectionWithDoubleIdNodeShouldDeserializeViaNodeFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Double value in node triggers isDouble fallback. - String json = "{\"projection\":{\"@id\":3.14,\"@projection\":{\"label\":\"Test\"}}}"; - NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); - assertNotNull(holder.projection()); - assertEquals(3.14, holder.projection().id()); + void refsInAListRoundTrip() throws Exception { + var entities = new EntityListHolder(List.of( + Ref.of(new SimpleEntity(1, "Alice")), + Ref.of(SimpleEntity.class, 2), + Ref.of(new SimpleEntity(3, "Charlie")))); + var restored = mapper().readValue(mapper().writeValueAsString(entities), EntityListHolder.class); + assertEquals(3, restored.entities().size()); + assertNotNull(restored.entities().get(0).getOrNull()); + assertNull(restored.entities().get(1).getOrNull()); + assertNotNull(restored.entities().get(2).getOrNull()); + var projections = new ProjectionListHolder(List.of( + Ref.of(new SimpleProjection(1, "First"), 1), + Ref.of(SimpleProjection.class, 2))); + var restoredProjections = mapper().readValue(mapper().writeValueAsString(projections), ProjectionListHolder.class); + assertEquals(2, restoredProjections.projections().size()); + assertNotNull(restoredProjections.projections().get(0).getOrNull()); + assertNull(restoredProjections.projections().get(1).getOrNull()); } @Test - public void noPkProjectionWithStringIdNodeShouldDeserializeViaNodeFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // String value in node triggers isTextual fallback. - String json = "{\"projection\":{\"@id\":\"abc\",\"@projection\":{\"label\":\"Test\"}}}"; - NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); - assertNotNull(holder.projection()); - assertEquals("abc", holder.projection().id()); + void refsInASetAndAMapValueRoundTrip() throws Exception { + var set = new EntitySetHolder(Set.of(Ref.of(SimpleEntity.class, 1), Ref.of(SimpleEntity.class, 2))); + assertEquals(set, mapper().readValue(mapper().writeValueAsString(set), EntitySetHolder.class)); + assertEquals(3, mapper().readValue("{\"entities\":[1,2,3]}", EntitySetHolder.class).entities().size()); + var map = new EntityMapHolder(Map.of("a", Ref.of(SimpleEntity.class, 1), "b", Ref.of(SimpleEntity.class, 2))); + assertEquals(map, mapper().readValue(mapper().writeValueAsString(map), EntityMapHolder.class)); + var read = mapper().readValue("{\"entities\":{\"a\":1,\"b\":2}}", EntityMapHolder.class); + assertEquals(1, read.entities().get("a").id()); + assertEquals(2, read.entities().get("b").id()); } @Test - public void noPkProjectionWithObjectIdNodeShouldDeserializeViaObjectFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Object value in node triggers the final Object fallback. - String json = "{\"projection\":{\"@id\":{\"key\":\"val\"},\"@projection\":{\"label\":\"Test\"}}}"; - NoPkProjectionRefHolder holder = mapper.readValue(json, NoPkProjectionRefHolder.class); + void refsOfDifferentTypesInOneHolderReadEachByTheirOwnType() throws Exception { + var holder = mapper().readValue("{\"entity\":42,\"projection\":7}", MultiTypeHolder.class); + assertNotNull(holder.entity()); + assertEquals(42, holder.entity().id()); assertNotNull(holder.projection()); - assertNotNull(holder.projection().id()); + assertEquals(7, holder.projection().id()); } - // Tests for Ref in collections (Set, Map). + // The ref factory. @Test - public void refInSetShouldDeserializeCorrectly() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entities\":[1,2,3]}"; - RefSetHolder holder = mapper.readValue(json, RefSetHolder.class); + void anIdReadsThroughTheRefFactoryWhenThereIsOne() throws Exception { + var factory = new CountingRefFactory(); + var holder = mapper(new StormModule(factory)).readValue("{\"entities\":[1,2,3]}", EntityListHolder.class); assertEquals(3, holder.entities().size()); + assertEquals(3, factory.created); + var supplied = new CountingRefFactory(); + assertEquals(99, mapper(new StormModule(() -> supplied)).readValue("{\"entity\":99}", EntityHolder.class).entity().id()); + assertEquals(1, supplied.created); } @Test - public void refInMapValueShouldDeserializeCorrectly() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entities\":{\"a\":1,\"b\":2}}"; - RefMapHolder holder = mapper.readValue(json, RefMapHolder.class); - assertEquals(2, holder.entities().size()); - assertEquals(1, holder.entities().get("a").id()); - assertEquals(2, holder.entities().get("b").id()); + void withoutARefFactoryAnIdReadsAsADetachedRef() throws Exception { + for (var module : List.of(new StormModule(), new StormModule((RefFactory) null), new StormModule(() -> null))) { + var entity = mapper(module).readValue("{\"entity\":7}", EntityHolder.class).entity(); + assertNotNull(entity); + assertEquals(7, entity.id()); + assertNull(entity.getOrNull()); + } } - // Tests for error paths. + // A data type without a key: the id is read by its JSON form. @Test - public void booleanTokenShouldThrowExceptionDuringRefDeserialization() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Boolean token is not a valid Ref value, should trigger the default case in deserialize. - String json = "{\"entity\":true}"; - assertThrows(JsonMappingException.class, () -> - mapper.readValue(json, EntityRefHolder.class)); + void anIdOfADataTypeWithoutAKeyReadsByItsJsonForm() throws Exception { + assertEquals(42, ((Number) mapper().readValue("{\"data\":42}", NoPkHolder.class).data().id()).intValue()); + assertEquals("abc", mapper().readValue("{\"data\":\"abc\"}", NoPkHolder.class).data().id()); + assertEquals(3.14, mapper().readValue("{\"data\":3.14}", NoPkHolder.class).data().id()); + long large = (long) Integer.MAX_VALUE + 1L; + assertEquals(large, mapper().readValue("{\"data\":" + large + "}", NoPkHolder.class).data().id()); + long negative = (long) Integer.MIN_VALUE - 1L; + assertEquals(negative, mapper().readValue("{\"data\":" + negative + "}", NoPkHolder.class).data().id()); } @Test - public void arrayTokenShouldThrowExceptionDuringRefDeserialization() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Array token is not a valid Ref value. - String json = "{\"entity\":[1,2,3]}"; - assertThrows(JsonMappingException.class, () -> - mapper.readValue(json, EntityRefHolder.class)); - } - - // Tests for list deserialization with NoPk data (fallback path in list context). - - @Test - public void noPkRefListWithMixedIdTypesShouldDeserializeViaFallback() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"data\":[1,\"two\",3]}"; - NoPkRefListHolder holder = mapper.readValue(json, NoPkRefListHolder.class); - assertEquals(3, holder.data().size()); + void idsOfADataTypeWithoutAKeyReadInAList() throws Exception { + var holder = mapper().readValue("{\"data\":[1,\"two\",3,null]}", NoPkListHolder.class); + assertEquals(4, holder.data().size()); assertEquals(1, ((Number) holder.data().get(0).id()).intValue()); assertEquals("two", holder.data().get(1).id()); assertEquals(3, ((Number) holder.data().get(2).id()).intValue()); + assertNull(holder.data().get(3)); } - // Test for serialization of loaded Data ref (not Entity, not Projection) - // when Data type is used as loaded ref via @entity path. - - @Test - public void unloadedNoPkRefShouldSerializeAsRawId() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Ref ref = Ref.of(NoPkData.class, 42); - NoPkRefHolder holder = new NoPkRefHolder(ref); - String json = mapper.writeValueAsString(holder); - // With no pkType, falls through to defaultSerializeValue. - assertTrue(json.contains("42")); - } - - // Tests for the resolveRefTargetType edge cases. - - @Test - public void refSetSerializationShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Set> refs = Set.of( - Ref.of(SimpleEntity.class, 1), - Ref.of(SimpleEntity.class, 2)); - RefSetHolder holder = new RefSetHolder(refs); - String json = mapper.writeValueAsString(holder); - assertNotNull(json); - // Verify it deserializes back. - RefSetHolder deserialized = mapper.readValue(json, RefSetHolder.class); - assertEquals(2, deserialized.entities().size()); - } - - @Test - public void refMapSerializationShouldWork() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Map> refs = Map.of( - "a", Ref.of(SimpleEntity.class, 1), - "b", Ref.of(SimpleEntity.class, 2)); - RefMapHolder holder = new RefMapHolder(refs); - String json = mapper.writeValueAsString(holder); - assertNotNull(json); - } - - // Tests for createLoadedRefWithId - Entity via @projection path. - - @Test - public void projectionPathWithEntityDataShouldCreateEntityRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // When @projection contains an Entity, createLoadedRefWithId should take the Entity branch. - // Use EntityRefHolder but with @projection format instead of @entity format. - String json = "{\"entity\":{\"@id\":1,\"@projection\":{\"id\":1,\"name\":\"Alice\"}}}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(1, holder.entity().id()); - // Since SimpleEntity is an Entity, it should take the Entity branch in createLoadedRefWithId. - assertNotNull(holder.entity().getOrNull()); - } - - // Tests for createLoadedRefWithId - Data that is not Entity or Projection, with id. - - public record DataRefHolder(@Nullable Ref data) {} - - @Test - public void projectionPathWithPlainDataAndIdShouldCreateUnloadedRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // NoPkData is Data but not Entity or Projection. - // @projection with @id should take the fallback "if (id != null) return Ref.of(targetClass, id)" path. - String json = "{\"data\":{\"@id\":42,\"@projection\":{\"value\":\"test\"}}}"; - DataRefHolder holder = mapper.readValue(json, DataRefHolder.class); - assertNotNull(holder.data()); - assertEquals(42, ((Number) holder.data().id()).intValue()); - } - - // Test for createLoadedRef with non-Entity in @entity path. - - @Test - public void entityPathWithNonEntityDataShouldThrowException() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // NoPkData is Data but not Entity. Using @entity with non-Entity data should throw. - String json = "{\"data\":{\"@entity\":{\"value\":\"test\"}}}"; - assertThrows(JsonMappingException.class, () -> - mapper.readValue(json, DataRefHolder.class)); - } - - // Test for resolvePkType returning NO_PK. - @Test - public void noPkDataSerializerShouldHandleMissingPkTypeGracefully() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // NoPkData has no @PK, so serializeId falls through to defaultSerializeValue. - Ref ref = Ref.of(NoPkData.class, "text-id"); - DataRefHolder holder = new DataRefHolder(ref); - String json = mapper.writeValueAsString(holder); - assertTrue(json.contains("text-id")); + void theIdOfAProjectionWrapperWithoutAKeyTypeReadsByItsJsonForm() throws Exception { + assertEquals(42, projectionId("42")); + assertEquals(9999999999L, projectionId("9999999999")); + assertEquals(3.14, projectionId("3.14")); + assertEquals("abc", projectionId("\"abc\"")); + assertEquals(Map.of("key", "val"), projectionId("{\"key\":\"val\"}")); } - // Test for null nullable projection ref in holder. - - @Test - public void nullProjectionRefShouldSerializeToNull() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - ProjectionRefHolder holder = new ProjectionRefHolder(null); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"projection\":null}", json); + private static Object projectionId(String idJson) throws Exception { + var json = "{\"projection\":{\"@id\":" + idJson + ",\"@projection\":{\"label\":\"Test\"}}}"; + var holder = mapper().readValue(json, NoPkProjectionHolder.class); + assertNotNull(holder.projection()); + assertTrue(holder.projection().isLoaded()); + return holder.projection().id(); } @Test - public void nullProjectionRefShouldDeserializeFromNull() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"projection\":null}"; - ProjectionRefHolder holder = mapper.readValue(json, ProjectionRefHolder.class); - assertNull(holder.projection()); + void aProjectionWrapperOfATypeWithoutAKeyReadsItsIdByItsJsonForm() throws Exception { + var json = "{\"data\":{\"@id\":3000000000,\"@projection\":{\"id\":5,\"value\":\"test\"}}}"; + assertEquals(3000000000L, ((Number) mapper().readValue(json, NoPkHolder.class).data().id()).longValue()); + json = "{\"data\":{\"@id\":\"uuid-value\",\"@projection\":{\"id\":5,\"value\":\"test\"}}}"; + assertEquals("uuid-value", mapper().readValue(json, NoPkHolder.class).data().id()); } - // Test for deserializeId default token case. + // Refusals. @Test - public void noPkDataWithBooleanShouldThrowExceptionViaFallbackPath() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Boolean values trigger the default case in the main deserialize switch (not NUMBER_INT/FLOAT/STRING). - String json = "{\"data\":true}"; - assertThrows(JsonMappingException.class, () -> - mapper.readValue(json, NoPkRefHolder.class)); + void anObjectWithoutAWrapperFieldOrACompoundKeyIsRefused() { + assertThrows(DatabindException.class, + () -> mapper().readValue("{\"entity\":{\"unknown\":1}}", EntityHolder.class)); + // A type without a key has no compound key an object could be. + assertThrows(DatabindException.class, + () -> mapper().readValue("{\"data\":{\"unknown\":1}}", NoPkHolder.class)); } - // Test for createRefFromId with null id. - @Test - public void noPkRefWithNullIdInListShouldReturnNullRef() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Test null handling within list deserialization for NoPkData refs. - String json = "{\"data\":[null, 1]}"; - NoPkRefListHolder holder = mapper.readValue(json, NoPkRefListHolder.class); - assertEquals(2, holder.data().size()); - assertNull(holder.data().get(0)); - assertNotNull(holder.data().get(1)); + void aProjectionWrapperWithoutAnIdIsRefused() { + assertThrows(DatabindException.class, () -> mapper().readValue( + "{\"projection\":{\"@projection\":{\"id\":5,\"label\":\"Test\"}}}", ProjectionHolder.class)); + // A null id is no id either, when the value is neither entity nor projection. + assertThrows(DatabindException.class, () -> mapper().readValue( + "{\"data\":{\"@id\":null,\"@projection\":{\"id\":5,\"value\":\"test\"}}}", NoPkHolder.class)); } - // Test for Ref field with Double PK type. - - public record DoubleIdEntity(@PK Double id, String name) implements Entity {} - - public record DoubleIdRefHolder(Ref entity) {} - @Test - public void doubleIdRefShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Ref ref = Ref.of(DoubleIdEntity.class, 3.14); - DoubleIdRefHolder holder = new DoubleIdRefHolder(ref); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":3.14}", json); - DoubleIdRefHolder deserialized = mapper.readValue(json, DoubleIdRefHolder.class); - assertEquals(3.14, deserialized.entity().id()); + void anEntityWrapperHoldingSomethingElseThanAnEntityIsRefused() { + assertThrows(DatabindException.class, () -> mapper().readValue( + "{\"data\":{\"@entity\":{\"id\":42,\"value\":\"test\"}}}", NoPkHolder.class)); } @Test - public void loadedDoubleIdEntityRefShouldRoundTrip() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - DoubleIdEntity entity = new DoubleIdEntity(2.71, "Euler"); - Ref ref = Ref.of(entity); - DoubleIdRefHolder holder = new DoubleIdRefHolder(ref); - String json = mapper.writeValueAsString(holder); - assertTrue(json.contains("@entity")); - DoubleIdRefHolder deserialized = mapper.readValue(json, DoubleIdRefHolder.class); - assertEquals(2.71, deserialized.entity().id()); + void aTokenThatIsNotAnIdOrAnObjectIsRefused() { + assertThrows(DatabindException.class, () -> mapper().readValue("{\"entity\":true}", EntityHolder.class)); + assertThrows(DatabindException.class, () -> mapper().readValue("{\"entity\":[1,2,3]}", EntityHolder.class)); + assertThrows(DatabindException.class, () -> mapper().readValue("{\"data\":true}", NoPkHolder.class)); } - // Test for deserializing raw Ref without type information should throw. - @SuppressWarnings("rawtypes") @Test - public void rawRefWithoutTypeInfoShouldThrowOnDeserialization() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // Deserializing a raw Ref (no generic type info) should throw because targetType is null. - String json = "42"; - assertThrows(JsonMappingException.class, () -> - mapper.readValue(json, Ref.class)); - } - - @Test - public void serializerShouldHandleDirectRefSerializationWithoutProperty() throws Exception { - // Serializing a raw Ref directly (not as a field of another object) means - // createContextual may be called with null property. - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - Ref ref = Ref.of(SimpleEntity.class, 42); - String json = mapper.writeValueAsString(ref); - // Without property context, the serializer uses default serialize path. - assertNotNull(json); - assertTrue(json.contains("42")); - } - - @Test - public void nonRefFieldShouldNotAffectSerialization() throws Exception { - // This record has a non-Ref field. The StormModule should not interfere with it. - record NonRefHolder(String value) {} - - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - NonRefHolder holder = new NonRefHolder("test"); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"value\":\"test\"}", json); - } - - @Test - public void noPkDataWithProjectionFormatAndNullIdShouldThrow() { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - // @projection with @id:null for NoPkData type. - // This should trigger the "cannot be null" path or a different error. - String json = "{\"data\":{\"@id\":null,\"@projection\":{\"value\":\"test\"}}}"; - // With a null @id, Jackson may still parse it as null, then createLoadedRefWithId - // checks if data is null (no), then Entity (no), then Projection (no), - // then id != null (false since null), then throw (line 376). - assertThrows(JsonMappingException.class, () -> - mapper.readValue(json, NoPkRefHolder.class)); - } - - public record ProjectionRefListHolder(List> projections) {} - - @Test - public void loadedProjectionRefInListShouldSerializeAndDeserialize() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - List> refs = List.of( - Ref.of(new SimpleProjection(1, "First"), 1), - Ref.of(SimpleProjection.class, 2)); - ProjectionRefListHolder holder = new ProjectionRefListHolder(refs); - String json = mapper.writeValueAsString(holder); - assertNotNull(json); - - ProjectionRefListHolder deserialized = mapper.readValue(json, ProjectionRefListHolder.class); - assertEquals(2, deserialized.projections().size()); - assertNotNull(deserialized.projections().get(0).getOrNull()); - assertNull(deserialized.projections().get(1).getOrNull()); - } - - public record MultiTypeHolder( - @Nullable Ref entity, - @Nullable Ref projection) {} - - @Test - public void multipleRefTypesInSameHolderShouldDeserializeCorrectly() throws Exception { - ObjectMapper mapper = new ObjectMapper(); - mapper.registerModule(new StormModule()); - - String json = "{\"entity\":42,\"projection\":7}"; - MultiTypeHolder holder = mapper.readValue(json, MultiTypeHolder.class); - assertNotNull(holder.entity()); - assertEquals(42, holder.entity().id()); - assertNotNull(holder.projection()); - assertEquals(7, holder.projection().id()); + void aRawRefWithoutATargetTypeIsRefused() { + assertThrows(DatabindException.class, () -> mapper().readValue("42", Ref.class)); } } diff --git a/storm-jackson3/pom.xml b/storm-jackson3/pom.xml index d3fee179d..7aa196ead 100644 --- a/storm-jackson3/pom.xml +++ b/storm-jackson3/pom.xml @@ -71,6 +71,47 @@ + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + 21 + + + + test-compile + process-test-sources + test-compile + + + ${project.basedir}/src/test/java + ${project.basedir}/src/test/kotlin + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + 3.6.0 + + + add-kotlin-test + generate-test-sources + add-test-source + + + src/test/kotlin + + + + + @@ -113,6 +154,21 @@ ${project.version} test + + + st.orm + storm-kotlin + ${project.version} + test + + + + tools.jackson.module + jackson-module-kotlin + 3.0.4 + test + org.projectlombok lombok diff --git a/storm-jackson3/src/main/java/st/orm/jackson/StormModule.java b/storm-jackson3/src/main/java/st/orm/jackson/StormModule.java index 19d35cf92..922cf4970 100644 --- a/storm-jackson3/src/main/java/st/orm/jackson/StormModule.java +++ b/storm-jackson3/src/main/java/st/orm/jackson/StormModule.java @@ -15,20 +15,21 @@ */ package st.orm.jackson; -import static st.orm.core.spi.Providers.getORMReflection; +import static st.orm.core.template.impl.RefJsonSupport.ENTITY_FIELD; +import static st.orm.core.template.impl.RefJsonSupport.ID_FIELD; +import static st.orm.core.template.impl.RefJsonSupport.PROJECTION_FIELD; +import static st.orm.core.template.impl.RefJsonSupport.integralId; +import static st.orm.core.template.impl.RefJsonSupport.isCompoundKey; +import static st.orm.core.template.impl.RefJsonSupport.loadedRef; +import static st.orm.core.template.impl.RefJsonSupport.pkType; +import static st.orm.core.template.impl.RefJsonSupport.refOf; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; import org.jspecify.annotations.Nullable; import st.orm.Data; import st.orm.Entity; -import st.orm.PK; -import st.orm.Projection; import st.orm.Ref; -import st.orm.core.spi.ORMReflection; import st.orm.core.spi.RefFactory; -import st.orm.mapping.RecordField; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonGenerator; import tools.jackson.core.JsonParser; @@ -71,16 +72,6 @@ */ public class StormModule extends SimpleModule { - private static final String ID_FIELD = "@id"; - private static final String ENTITY_FIELD = "@entity"; - private static final String PROJECTION_FIELD = "@projection"; - - private static final ORMReflection REFLECTION = getORMReflection(); - - // ConcurrentHashMap does NOT allow computeIfAbsent mapping functions to return null. - private static final Class NO_PK = Void.class; - private static final Map, Class> PK_CACHE = new ConcurrentHashMap<>(); - /** * Creates a StormModule without a RefFactory. * Ref fields with only an id will be deserialized as DetachedRefs. @@ -138,34 +129,7 @@ private static JavaType resolveRefTargetType(@Nullable JavaType type) { } /** - * Resolves the primary key type for the given target class. - * - * @param target the entity/data class - * @return the PK field type, or null if not found - */ - @Nullable - private static Class resolvePkType(Class target) { - Class cached = PK_CACHE.computeIfAbsent(target, ignore -> { - var type = REFLECTION.getRecordType(target).fields().stream() - .filter(field -> field.isAnnotationPresent(PK.class)) - .findFirst() - .map(RecordField::type) - .orElse(null); - return type == null ? NO_PK : type; - }); - return cached == NO_PK ? null : cached; - } - - /** - * Serializer for Ref instances. - * - *

Serialization formats: - *

    - *
  • Entity (loaded): {@code {"@entity": {...}}}
  • - *
  • Projection (loaded): {@code {"@id": ..., "@projection": {...}}}
  • - *
  • Unloaded: raw id value (e.g., {@code 42} or {@code "abc"}), or the PK record as a plain object for - * compound PKs (e.g., {@code {"vetId":1,"specialtyId":2}})
  • - *
+ * Serializer for Ref instances; the forms are those of {@link st.orm.core.template.impl.RefJsonSupport}. */ private static class RefSerializer extends StdSerializer> { @@ -184,9 +148,7 @@ public ValueSerializer createContextual(SerializationContext ctxt, @Nullable if (refTargetType == null) { return this; } - Class targetClass = refTargetType.getRawClass(); - Class resolvedPkType = resolvePkType(targetClass); - return new RefSerializer(resolvedPkType); + return new RefSerializer(pkType(refTargetType.getRawClass())); } @Override @@ -223,16 +185,7 @@ private void serializeId(Object id, JsonGenerator gen, SerializationContext ctxt } /** - * Deserializer for Ref instances. - * - *

Handles four input formats: - *

    - *
  • {@code {"@entity": {...}}} - Entity, uses {@code Ref.of()}
  • - *
  • {@code {"@id": ..., "@projection": {...}}} - Projection with id, uses {@code Ref.of()}
  • - *
  • Raw id value (e.g., {@code 42}) - uses RefFactory if available, otherwise {@code Ref.of()}
  • - *
  • Plain object without {@code @entity}/{@code @projection} (e.g., {@code {"vetId":1,"specialtyId":2}}) - - * compound PK, uses RefFactory if available, otherwise {@code Ref.of()}
  • - *
+ * Deserializer for Ref instances; the forms are those of {@link st.orm.core.template.impl.RefJsonSupport}. * *

Also supports {@code null} values (e.g. nullable elements in {@code List>}). */ @@ -262,7 +215,7 @@ public ValueDeserializer createContextual(DeserializationContext context, @Nu JavaType refTargetType = resolveRefTargetType(base); Class resolvedPkType = null; if (refTargetType != null) { - resolvedPkType = resolvePkType(refTargetType.getRawClass()); + resolvedPkType = pkType(refTargetType.getRawClass()); } return new RefDeserializer(refFactorySupplier, refTargetType, resolvedPkType); } @@ -285,10 +238,8 @@ public Ref deserialize(JsonParser parser, DeserializationContext context) thr Class targetClass = (Class) targetType.getRawClass(); return switch (parser.currentToken()) { case START_OBJECT -> deserializeObject(parser, context, targetClass); - case VALUE_NUMBER_INT, VALUE_NUMBER_FLOAT, VALUE_STRING -> { - Object id = deserializeId(parser, context); - yield createRefFromId(targetClass, id); - } + case VALUE_NUMBER_INT, VALUE_NUMBER_FLOAT, VALUE_STRING -> + refOf(targetClass, deserializeId(parser, context), refFactorySupplier); default -> throw DatabindException.from( parser, "Unexpected token " + parser.currentToken() + " when deserializing Ref" @@ -299,25 +250,28 @@ public Ref deserialize(JsonParser parser, DeserializationContext context) thr private Ref deserializeObject(JsonParser parser, DeserializationContext ctx, Class targetClass) throws JacksonException { ObjectNode node = parser.readValueAsTree(); - if (node.has(ENTITY_FIELD)) { - JsonNode entityNode = node.get(ENTITY_FIELD); - Data entity = ctx.readTreeAsValue(entityNode, targetType); - return createLoadedRef(parser, entity); - } else if (node.has(PROJECTION_FIELD)) { - if (!node.has(ID_FIELD)) { - throw DatabindException.from(parser, "@projection requires @id field"); + try { + if (node.has(ENTITY_FIELD)) { + JsonNode entityNode = node.get(ENTITY_FIELD); + Data entity = entityNode.isNull() ? null : ctx.readTreeAsValue(entityNode, targetType); + return loadedRef(entity); + } else if (node.has(PROJECTION_FIELD)) { + if (!node.has(ID_FIELD)) { + throw DatabindException.from(parser, "@projection requires @id field"); + } + Object id = deserializeIdFromNode(node.get(ID_FIELD), ctx); + JsonNode projectionNode = node.get(PROJECTION_FIELD); + Data projection = projectionNode.isNull() ? null : ctx.readTreeAsValue(projectionNode, targetType); + return loadedRef(targetClass, projection, id); + } else if (pkType != null && isCompoundKey(pkType)) { + // A plain object is the inline record of a compound PK; unloaded refs serialize compound PKs + // this way. + return refOf(targetClass, deserializeIdFromNode(node, ctx), refFactorySupplier); + } else { + throw DatabindException.from(parser, "Ref object must contain @entity or @projection field"); } - Object id = deserializeIdFromNode(node.get(ID_FIELD), ctx); - JsonNode projectionNode = node.get(PROJECTION_FIELD); - Data projection = ctx.readTreeAsValue(projectionNode, targetType); - return createLoadedRefWithId(parser, targetClass, projection, id); - } else if (pkType != null && REFLECTION.findRecordType(pkType).isPresent()) { - // A plain object is the inline record of a compound PK; unloaded refs serialize compound PKs - // this way. - Object id = deserializeIdFromNode(node, ctx); - return createRefFromId(targetClass, id); - } else { - throw DatabindException.from(parser, "Ref object must contain @entity or @projection field"); + } catch (IllegalArgumentException e) { + throw DatabindException.from(parser, e.getMessage()); } } @@ -345,59 +299,12 @@ private Object deserializeId(JsonParser parser, DeserializationContext ctx) thro } // Fallback for unknown PK type. return switch (parser.currentToken()) { - case VALUE_NUMBER_INT -> { - long value = parser.getLongValue(); - yield (value >= Integer.MIN_VALUE && value <= Integer.MAX_VALUE) - ? (int) value - : value; - } + case VALUE_NUMBER_INT -> integralId(parser.getLongValue()); case VALUE_NUMBER_FLOAT -> parser.getDoubleValue(); case VALUE_STRING -> parser.getText(); default -> throw DatabindException.from(parser, "Cannot deserialize id from %s.".formatted(parser.currentToken())); }; } - - @SuppressWarnings({"rawtypes"}) - private Ref createLoadedRef(JsonParser parser, Data entity) - throws DatabindException { - if (entity == null) { - return null; - } - if (entity instanceof Entity e) { - return Ref.of((Entity) e); - } - throw DatabindException.from(parser, "@entity must deserialize to an Entity"); - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private Ref createLoadedRefWithId(JsonParser parser, Class targetClass, Data data, Object id) - throws DatabindException { - if (data == null) { - return null; - } - if (data instanceof Entity e) { - return Ref.of((Entity) e); - } - if (data instanceof Projection p) { - return Ref.of((Projection) p, id); - } - if (id != null) { - return Ref.of(targetClass, id); - } - // Should not happen for @projection, since @id is required. - throw DatabindException.from(parser, "@projection requires a non-null @id"); - } - - private Ref createRefFromId(Class targetClass, Object id) { - if (id == null) { - return null; - } - RefFactory refFactory = refFactorySupplier != null ? refFactorySupplier.get() : null; - if (refFactory != null) { - return refFactory.create(targetClass, id); - } - return Ref.of(targetClass, id); - } } } diff --git a/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java b/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java index 63d2ef878..f5e13f7bb 100644 --- a/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java +++ b/storm-jackson3/src/main/java/st/orm/jackson/spi/JsonORMConverterImpl.java @@ -15,34 +15,17 @@ */ package st.orm.jackson.spi; -import static java.util.Collections.singletonList; import static java.util.Objects.requireNonNull; -import static java.util.Optional.empty; import static tools.jackson.databind.DeserializationFeature.FAIL_ON_MISSING_CREATOR_PROPERTIES; import static tools.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES; import com.fasterxml.jackson.annotation.JsonTypeName; -import java.lang.reflect.GenericArrayType; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.lang.reflect.WildcardType; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import org.jspecify.annotations.Nullable; import st.orm.Json; -import st.orm.SqlTemplateException; -import st.orm.core.spi.JsonString; -import st.orm.core.spi.Name; import st.orm.core.spi.ORMConverter; -import st.orm.core.spi.ORMReflection; -import st.orm.core.spi.Providers; -import st.orm.core.spi.RefFactory; +import st.orm.core.template.impl.JsonConverterSupport; import st.orm.core.template.impl.SegmentedLruCache; import st.orm.jackson.StormModule; import st.orm.mapping.RecordField; -import tools.jackson.core.JacksonException; import tools.jackson.core.type.TypeReference; import tools.jackson.databind.ObjectWriter; import tools.jackson.databind.ValueDeserializer; @@ -57,29 +40,19 @@ * Implementation of {@link ORMConverter} that converts JSON fields to and from the database. It uses Jackson for JSON * serialization and deserialization. */ -public final class JsonORMConverterImpl implements ORMConverter { - private static final ORMReflection REFLECTION = Providers.getORMReflection(); - private static final SegmentedLruCache MAPPER_CACHE = new SegmentedLruCache<>(1024); - private static final ThreadLocal REF_FACTORY = new ThreadLocal<>(); +public final class JsonORMConverterImpl extends JsonConverterSupport { + private static final SegmentedLruCache MAPPER_CACHE = new SegmentedLruCache<>(1024); - private final RecordField field; private final TypeReference typeReference; private final JsonMapper mapper; private final ObjectWriter writer; - record CacheKey(Json json, - List> sealedTypes, - @Nullable Class targetType, - @Nullable Class> serializer, - @Nullable Class> deserializer) {} - @SuppressWarnings({"rawtypes", "unchecked"}) public JsonORMConverterImpl(RecordField field, TypeReference typeReference, Json json) { - this.field = requireNonNull(field, "field"); + super(field); this.typeReference = requireNonNull(typeReference, "typeReference"); - var sealedTypes = getSealedTypes(typeReference.getType()); // Check for custom serializer/deserializer annotations. var serializeAnnotation = field.getAnnotation(JsonSerialize.class); var deserializeAnnotation = field.getAnnotation(JsonDeserialize.class); @@ -91,97 +64,48 @@ public JsonORMConverterImpl(RecordField field, deserializeAnnotation != null && deserializeAnnotation.using() != ValueDeserializer.None.class ? (Class>) deserializeAnnotation.using() : null; - // Custom serializers are registered against the raw field type, so that type is part of the cache key - // whenever one is present; without it, fields of different types sharing a serializer class would share - // a mapper that only serves the first field's type. - Class targetType = serializerClass != null || deserializerClass != null - ? getRawType(typeReference.getType()).orElse(Object.class) - : null; - this.mapper = MAPPER_CACHE.getOrCompute( - new CacheKey(requireNonNull(json, "json"), sealedTypes, targetType, serializerClass, deserializerClass), - () -> { - var builder = JsonMapper.builder(); - builder.findAndAddModules(); - if (!json.failOnUnknown()) { - builder.disable(FAIL_ON_UNKNOWN_PROPERTIES); - } - if (!json.failOnMissing()) { - builder.disable(FAIL_ON_MISSING_CREATOR_PROPERTIES); - } - for (var sealedType : sealedTypes) { - builder.registerSubtypes(getPermittedSubtypes(sealedType)); + var key = MapperKey.of(json, typeReference.getType(), serializerClass, deserializerClass); + this.mapper = MAPPER_CACHE.getOrCompute(key, () -> { + var builder = JsonMapper.builder(); + builder.findAndAddModules(); + // Set, not left to the library's defaults, which differ between Jackson versions. + builder.configure(FAIL_ON_UNKNOWN_PROPERTIES, json.failOnUnknown()); + builder.configure(FAIL_ON_MISSING_CREATOR_PROPERTIES, json.failOnMissing()); + for (var sealedType : key.sealedTypes()) { + builder.registerSubtypes(getPermittedSubtypes(sealedType)); + } + // Register StormModule with supplier for dynamic RefFactory resolution. + builder.addModule(new StormModule(JsonConverterSupport::currentRefFactory)); + // Register custom serializers/deserializers if specified. + if (serializerClass != null || deserializerClass != null) { + var customModule = new SimpleModule(); + if (serializerClass != null) { + try { + ValueSerializer serializerInstance = serializerClass.getDeclaredConstructor().newInstance(); + customModule.addSerializer((Class) key.targetType(), serializerInstance); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate custom serializer: " + serializerClass, e); } - // Register StormModule with supplier for dynamic RefFactory resolution. - builder.addModule(new StormModule(REF_FACTORY::get)); - // Register custom serializers/deserializers if specified. - if (serializerClass != null || deserializerClass != null) { - var customModule = new SimpleModule(); - if (serializerClass != null) { - try { - ValueSerializer serializerInstance = serializerClass.getDeclaredConstructor().newInstance(); - customModule.addSerializer((Class) targetType, serializerInstance); - } catch (Exception e) { - throw new RuntimeException("Failed to instantiate custom serializer: " + serializerClass, e); - } - } - if (deserializerClass != null) { - try { - ValueDeserializer deserializerInstance = deserializerClass.getDeclaredConstructor().newInstance(); - customModule.addDeserializer((Class) targetType, deserializerInstance); - } catch (Exception e) { - throw new RuntimeException("Failed to instantiate custom deserializer: " + deserializerClass, e); - } - } - builder.addModule(customModule); + } + if (deserializerClass != null) { + try { + ValueDeserializer deserializerInstance = deserializerClass.getDeclaredConstructor().newInstance(); + customModule.addDeserializer((Class) key.targetType(), deserializerInstance); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate custom deserializer: " + deserializerClass, e); } - return builder.build(); - }); + } + builder.addModule(customModule); + } + return builder.build(); + }); // Serialization carries the declared field type rather than the erased runtime type, so polymorphic // values write the discriminator that reading with the same declared type expects. this.writer = mapper.writerFor(typeReference); } - private static Optional> getRawType(Type type) { - if (type instanceof ParameterizedType) { - return Optional.of((Class) ((ParameterizedType) type).getRawType()); - } else if (type instanceof Class) { - return Optional.of((Class) type); - } else { - return empty(); - } - } - - /** - * Collects the sealed classes appearing anywhere in the field's generic type, so that permitted subtypes are - * registered for container-typed fields such as {@code List} as well as top-level sealed fields. - */ - private static List> getSealedTypes(Type type) { - var sealedTypes = new LinkedHashSet>(); - collectSealedTypes(type, sealedTypes); - return List.copyOf(sealedTypes); - } - - private static void collectSealedTypes(Type type, Set> sealedTypes) { - if (type instanceof Class clazz) { - if (clazz.isSealed()) { - sealedTypes.add(clazz); - } - } else if (type instanceof ParameterizedType parameterizedType) { - collectSealedTypes(parameterizedType.getRawType(), sealedTypes); - for (Type typeArgument : parameterizedType.getActualTypeArguments()) { - collectSealedTypes(typeArgument, sealedTypes); - } - } else if (type instanceof GenericArrayType arrayType) { - collectSealedTypes(arrayType.getGenericComponentType(), sealedTypes); - } else if (type instanceof WildcardType wildcardType) { - for (Type bound : wildcardType.getUpperBounds()) { - collectSealedTypes(bound, sealedTypes); - } - } - } - private static NamedType[] getPermittedSubtypes(Class sealedClass) { - return REFLECTION.getPermittedSubclasses(sealedClass).stream() + return permittedSubclasses(sealedClass).stream() .map(subclass -> { JsonTypeName typeNameAnnotation = subclass.getAnnotation(JsonTypeName.class); String typeName = typeNameAnnotation != null ? typeNameAnnotation.value() : subclass.getSimpleName(); @@ -191,51 +115,12 @@ private static NamedType[] getPermittedSubtypes(Class sealedClass) { } @Override - public int getParameterCount() { - return 1; - } - - @Override - public List> getParameterTypes() { - return List.of(String.class); - } - - @Override - public List getColumns(NameResolver nameResolver) throws SqlTemplateException { - return List.of(nameResolver.getName(field)); - } - - @Override - public List<@Nullable Object> toDatabase(@Nullable Object record) throws SqlTemplateException { - try { - Object o = record == null ? null : REFLECTION.invoke(field, record); - return singletonList(o == null ? null : new JsonString(writer.writeValueAsString(o))); - } catch (Throwable e) { - throw new SqlTemplateException(e); - } + protected String write(Object value) { + return writer.writeValueAsString(value); } @Override - public @Nullable Object fromDatabase(@Nullable Object[] values, RefFactory refFactory) throws SqlTemplateException { - Object value = values[0]; - if (value == null) { - return null; - } - // A custom deserializer may issue a query, nesting another fromDatabase call on this thread. The previous - // factory is therefore restored rather than removed, so the remaining fields of the outer value still - // deserialize with the outer factory and produce attached refs. - RefFactory outerRefFactory = REF_FACTORY.get(); - REF_FACTORY.set(refFactory); - try { - return mapper.readValue((String) value, typeReference); - } catch (JacksonException e) { - throw new SqlTemplateException(e); - } finally { - if (outerRefFactory == null) { - REF_FACTORY.remove(); - } else { - REF_FACTORY.set(outerRefFactory); - } - } + protected Object read(String json) { + return mapper.readValue(json, typeReference); } } diff --git a/storm-jackson3/src/test/java/st/orm/jackson/JsonIntegrationTest.java b/storm-jackson3/src/test/java/st/orm/jackson/JsonIntegrationTest.java index 598b75fda..0b7430924 100644 --- a/storm-jackson3/src/test/java/st/orm/jackson/JsonIntegrationTest.java +++ b/storm-jackson3/src/test/java/st/orm/jackson/JsonIntegrationTest.java @@ -3,6 +3,7 @@ import static java.util.Optional.ofNullable; import static org.junit.jupiter.api.Assertions.assertEquals; import static st.orm.core.template.ORMTemplate.of; +import static tools.jackson.databind.cfg.DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS; import java.time.LocalDate; import javax.sql.DataSource; @@ -79,11 +80,11 @@ public void entityWithMultipleJsonFieldsShouldRoundTripIncludingComputedJsonColu @Test public void entityWithFkRelationshipsShouldSerializeNestedEntitiesInline() throws Exception { // Pet id=1 is "Leo", a cat owned by Betty Davis. The entity graph includes FK-joined - // PetType and Owner entities. When serialized with Jackson (JavaTimeModule is built into Jackson 3), + // PetType and Owner entities. When serialized with Jackson, dates as ISO strings, // all nested entities should appear inline with their full field values, and the result should round-trip. var orm = of(dataSource); var pet = orm.entity(Pet.class).getById(1); - var mapper = JsonMapper.builder().findAndAddModules().build(); + var mapper = JsonMapper.builder().findAndAddModules().disable(WRITE_DATES_AS_TIMESTAMPS).build(); var json = mapper.writeValueAsString(pet); assertEquals(""" {"id":1,"name":"Leo","birthDate":"2020-09-07","petType":{"id":1,"name":"cat"},"owner":{"id":1,"firstName":"Betty","lastName":"Davis","address":{"address":"638 Cardinal Ave.","city":"Sun Prairie"},"telephone":"6085551749"}}""", json); @@ -109,6 +110,7 @@ public void unloadedEntityRefShouldSerializeToRawPrimaryKeyValue() throws Except var pet = orm.entity(PetWithRefOwner.class).getById(1); var mapper = JsonMapper.builder() .findAndAddModules() + .disable(WRITE_DATES_AS_TIMESTAMPS) .addModule(new StormModule()) .build(); var json = mapper.writeValueAsString(pet); @@ -128,6 +130,7 @@ public void loadedEntityRefShouldSerializeWithEntityWrapper() throws Exception { var owner = ofNullable(pet.owner()).map(Ref::fetch).orElseThrow(); var mapper = JsonMapper.builder() .findAndAddModules() + .disable(WRITE_DATES_AS_TIMESTAMPS) .addModule(new StormModule()) .build(); var json = mapper.writeValueAsString(pet); @@ -165,6 +168,7 @@ public void unloadedProjectionRefShouldSerializeToRawPrimaryKeyValue() throws Ex var pet = orm.entity(PetWithProjectionRefOwner.class).getById(1); var mapper = JsonMapper.builder() .findAndAddModules() + .disable(WRITE_DATES_AS_TIMESTAMPS) .addModule(new StormModule()) .build(); var json = mapper.writeValueAsString(pet); @@ -184,6 +188,7 @@ public void loadedProjectionRefShouldSerializeWithProjectionWrapperAndId() throw var owner = ofNullable(pet.owner()).map(Ref::fetch).orElseThrow(); var mapper = JsonMapper.builder() .findAndAddModules() + .disable(WRITE_DATES_AS_TIMESTAMPS) .addModule(new StormModule()) .build(); var json = mapper.writeValueAsString(pet); diff --git a/storm-jackson3/src/test/java/st/orm/jackson/JsonMetamodelTest.java b/storm-jackson3/src/test/java/st/orm/jackson/JsonMetamodelTest.java new file mode 100644 index 000000000..2985122cc --- /dev/null +++ b/storm-jackson3/src/test/java/st/orm/jackson/JsonMetamodelTest.java @@ -0,0 +1,49 @@ +package st.orm.jackson; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static st.orm.core.template.ORMTemplate.of; + +import java.util.List; +import javax.sql.DataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import st.orm.Metamodel; +import st.orm.Operator; +import st.orm.jackson.model.Owner; +import st.orm.test.StormTest; + +/** + * A converted column is stored as one type and held as another: the address lives in a single JSON column while the + * record holds an {@code Address}. The metamodel has to address it as the column it is, not as the parts of the + * record it holds. + */ +@StormTest(scripts = "/data.sql") +public class JsonMetamodelTest { + + private DataSource dataSource; + + @BeforeEach + void bindDataSource(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Test + public void convertedFieldIsAColumnRatherThanAnInlineRecord() { + var path = Metamodel.of(Owner.class, "address"); + assertTrue(path.isColumn(), "A converted field is stored as its own column."); + assertFalse(path.isInline(), "A converted field is not the parts of the record it holds."); + } + + @Test + public void convertedColumnIsFilteredByItsStoredValue() { + var orm = of(dataSource); + List owners = orm.entity(Owner.class).select() + .where(Metamodel.of(Owner.class, "address"), Operator.EQUALS, + "{\"address\":\"638 Cardinal Ave.\",\"city\":\"Sun Prairie\"}") + .getResultList(); + assertEquals(1, owners.size()); + assertEquals("Betty", owners.getFirst().firstName()); + } +} diff --git a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java index e325f02f4..493dd1647 100644 --- a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java +++ b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterIntegrationTest.java @@ -335,6 +335,17 @@ public record OwnerWithFailOnMissing( ) implements Entity { } + @Test + public void jsonWithFailOnMissingTrueShouldRejectMissingProperties() { + var orm = of(dataSource); + var query = orm.query(""" + SELECT id, + '{"address":"test"}' AS address, + telephone + FROM owner WHERE id = 1"""); + assertThrows(PersistenceException.class, () -> query.getSingleResult(OwnerWithFailOnMissing.class)); + } + @Test public void jsonWithFailOnMissingTrueShouldCreateMapperWithStrictMode() { // Exercises the @Json(failOnMissing = true) branch in JsonORMConverterImpl constructor, @@ -408,6 +419,116 @@ public void customJsonSerializerAndDeserializerShouldBeUsedForJsonField() { // The custom deserializer should parse it back. assertEquals("123 Main St", inserted.address().address()); assertEquals("Springfield", inserted.address().city()); + // A later read from the database uses the custom deserializer as well. + var fetched = repository.getById(inserted.id()); + assertEquals("123 Main St", fetched.address().address()); + assertEquals("Springfield", fetched.address().city()); + // The raw column value proves the serializer engaged; a bypassed pair would round trip plain JSON. + var row = orm.query("SELECT address, telephone FROM owner WHERE id = " + inserted.id()) + .getSingleResult(RawOwnerRow.class); + assertEquals("\"123 Main St | Springfield\"", row.address()); + } + + @Builder(toBuilder = true) + @DbTable("owner") + public record OwnerWithCustomDeserializerOnly( + @PK Integer id, + String firstName, + String lastName, + @Json + @tools.jackson.databind.annotation.JsonDeserialize(using = AddressDeserializer.class) + Address address, + @Nullable String telephone + ) implements Entity { + } + + @Test + public void customDeserializerAloneShouldBeUsedForJsonField() { + // The mapper is keyed on the deserializer and the field's type when only a deserializer is present. + var orm = of(dataSource); + orm.query("INSERT INTO owner (first_name, last_name, address, telephone) " + + "VALUES ('Deser', 'Only', '\"1 Way | Town\"', '555')").executeUpdate(); + var owner = orm.query("SELECT id, first_name, last_name, address, telephone FROM owner WHERE first_name = 'Deser'") + .getSingleResult(OwnerWithCustomDeserializerOnly.class); + assertEquals(new Address("1 Way", "Town"), owner.address()); + } + + // Custom serializer and deserializer without a constructor the converter can call. + + public static class UnconstructibleSerializer extends tools.jackson.databind.ser.std.StdSerializer

{ + public UnconstructibleSerializer(String unused) { + super(Address.class); + } + + @Override + public void serialize(Address value, tools.jackson.core.JsonGenerator gen, + tools.jackson.databind.SerializationContext ctxt) + throws tools.jackson.core.JacksonException { + gen.writeString(value.address()); + } + } + + public static class UnconstructibleDeserializer extends tools.jackson.databind.deser.std.StdDeserializer
{ + public UnconstructibleDeserializer(String unused) { + super(Address.class); + } + + @Override + public Address deserialize(tools.jackson.core.JsonParser parser, + tools.jackson.databind.DeserializationContext ctxt) + throws tools.jackson.core.JacksonException { + return new Address(parser.getText(), ""); + } + } + + @DbTable("owner") + public record OwnerWithUnconstructibleSerializer( + @PK Integer id, + @Json + @tools.jackson.databind.annotation.JsonSerialize(using = UnconstructibleSerializer.class) + Address address + ) implements Entity { + } + + @DbTable("owner") + public record OwnerWithUnconstructibleDeserializer( + @PK Integer id, + @Json + @tools.jackson.databind.annotation.JsonDeserialize(using = UnconstructibleDeserializer.class) + Address address + ) implements Entity { + } + + @Test + public void aSerializerWithoutANoArgConstructorIsRefusedWhenTheFieldIsFirstUsed() { + var orm = of(dataSource); + var serializer = assertThrows(RuntimeException.class, + () -> orm.query("SELECT id, address FROM owner WHERE id = 1") + .getSingleResult(OwnerWithUnconstructibleSerializer.class)); + assertTrue(rootMessage(serializer).contains("custom serializer"), rootMessage(serializer)); + var deserializer = assertThrows(RuntimeException.class, + () -> orm.query("SELECT id, address FROM owner WHERE id = 1") + .getSingleResult(OwnerWithUnconstructibleDeserializer.class)); + assertTrue(rootMessage(deserializer).contains("custom deserializer"), rootMessage(deserializer)); + } + + private static String rootMessage(Throwable thrown) { + var messages = new StringBuilder(); + for (Throwable cause = thrown; cause != null; cause = cause.getCause()) { + messages.append(cause.getMessage()).append('\n'); + } + return messages.toString(); + } + + @Test + public void jsonWithFailOnUnknownTrueShouldRejectExtraProperties() { + var orm = of(dataSource); + var query = orm.query(""" + SELECT id, + '{"address":"test","city":"test","extraField":"unexpected"}' AS address, + telephone + FROM owner WHERE id = 1"""); + assertThrows(PersistenceException.class, () -> query.getSingleResult(OwnerWithFailOnUnknown.class)); } @Test diff --git a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterTest.java b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterTest.java index 80265e39f..73dc56e7c 100644 --- a/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterTest.java +++ b/storm-jackson3/src/test/java/st/orm/jackson/JsonORMConverterTest.java @@ -73,6 +73,7 @@ public record OwnerWithNullableAddress( @Test public void failOnUnknownTrueShouldStillWorkWithValidJson() { + // Standard address JSON has no unknown properties, so this should succeed. var orm = of(dataSource); var owner = orm.entity(OwnerWithFailOnUnknown.class).getById(1); assertNotNull(owner.address()); @@ -81,6 +82,7 @@ public void failOnUnknownTrueShouldStillWorkWithValidJson() { @Test public void failOnMissingTrueShouldStillWorkWithCompleteJson() { + // Standard address JSON has all required properties, so this should succeed. var orm = of(dataSource); var owner = orm.entity(OwnerWithFailOnMissing.class).getById(1); assertNotNull(owner.address()); @@ -89,6 +91,7 @@ public void failOnMissingTrueShouldStillWorkWithCompleteJson() { @Test public void failOnUnknownAndFailOnMissingBothTrueShouldWork() { + // Both options enabled should work with valid complete JSON. var orm = of(dataSource); var owner = orm.entity(OwnerWithBothFailOptions.class).getById(1); assertNotNull(owner.address()); @@ -97,6 +100,7 @@ public void failOnUnknownAndFailOnMissingBothTrueShouldWork() { @Test public void insertOwnerShouldSerializeJsonAddressToDatabase() { + // Tests the toDatabase path with a non-null JSON field. var orm = of(dataSource); var repository = orm.entity(Owner.class); var address = new Address("271 University Ave", "Palo Alto"); @@ -114,6 +118,7 @@ public void insertOwnerShouldSerializeJsonAddressToDatabase() { @Test public void toDatabaseWithNullJsonFieldShouldProduceNull() { + // Tests the toDatabase path when the JSON field is null (nullable address). var orm = of(dataSource); var repository = orm.entity(OwnerWithNullableAddress.class); var owner = OwnerWithNullableAddress.builder() @@ -128,6 +133,7 @@ public void toDatabaseWithNullJsonFieldShouldProduceNull() { @Test public void fromDatabaseWithNullJsonValueShouldReturnNullForNullableField() { + // Fetch an owner whose address was set to null. var orm = of(dataSource); var repository = orm.entity(OwnerWithNullableAddress.class); var owner = OwnerWithNullableAddress.builder() @@ -142,14 +148,29 @@ public void fromDatabaseWithNullJsonValueShouldReturnNullForNullableField() { @Test public void fromDatabaseWithInvalidJsonShouldThrowException() { + // Query that produces invalid JSON for the address column. var orm = of(dataSource); var query = orm.query("SELECT id, first_name, last_name, 'not-valid-json' AS address, telephone FROM owner WHERE id = 1"); assertThrows(PersistenceException.class, () -> query.getSingleResult(OwnerWithFailOnUnknown.class)); } + @Test + public void failOnUnknownShouldRejectJsonWithExtraProperties() { + // Use raw query to inject JSON with an extra property. + var orm = of(dataSource); + var query = orm.query(""" + SELECT id, first_name, last_name, + '{"address":"test","city":"test","extraField":"unexpected"}' AS address, + telephone + FROM owner WHERE id = 1"""); + assertThrows(PersistenceException.class, + () -> query.getSingleResult(OwnerWithFailOnUnknown.class)); + } + @Test public void selectAllOwnersWithFailOnUnknownShouldReturnAll() { + // All 10 standard owners have valid address JSON with no unknown properties. var orm = of(dataSource); var owners = orm.entity(OwnerWithFailOnUnknown.class).select().getResultList(); assertEquals(10, owners.size()); @@ -157,6 +178,7 @@ public void selectAllOwnersWithFailOnUnknownShouldReturnAll() { @Test public void selectAllOwnersWithFailOnMissingShouldReturnAll() { + // All 10 standard owners have complete address JSON. var orm = of(dataSource); var owners = orm.entity(OwnerWithFailOnMissing.class).select().getResultList(); assertEquals(10, owners.size()); @@ -164,6 +186,7 @@ public void selectAllOwnersWithFailOnMissingShouldReturnAll() { @Test public void updateOwnerJsonFieldShouldPersistChanges() { + // Tests the toDatabase path during an update. var orm = of(dataSource); var repository = orm.entity(Owner.class); var owner = repository.getById(1); @@ -176,6 +199,7 @@ public void updateOwnerJsonFieldShouldPersistChanges() { @Test public void updateOwnerNullableAddressToNullAndBackShouldRoundTrip() { + // Insert with address, update to null, read back (null), update back to address, read back. var orm = of(dataSource); var repository = orm.entity(OwnerWithNullableAddress.class); var address = new Address("test", "city"); @@ -202,6 +226,7 @@ public void updateOwnerNullableAddressToNullAndBackShouldRoundTrip() { @Test public void selectNullableAddressWithNullValueShouldReturnNull() { + // Directly query for a row with NULL address via raw SQL to exercise fromDatabase null path. var orm = of(dataSource); var repository = orm.entity(OwnerWithNullableAddress.class); var owner = OwnerWithNullableAddress.builder() @@ -212,6 +237,7 @@ public void selectNullableAddressWithNullValueShouldReturnNull() { .build(); repository.insert(owner); + // Use select to get all including the new null-address owner. var allOwners = repository.select().getResultList(); long nullAddressCount = allOwners.stream().filter(o -> o.address() == null).count(); assertTrue(nullAddressCount >= 1); diff --git a/storm-jackson3/src/test/java/st/orm/jackson/StormModuleTest.java b/storm-jackson3/src/test/java/st/orm/jackson/StormModuleTest.java index a476bd764..491b7d5c2 100644 --- a/storm-jackson3/src/test/java/st/orm/jackson/StormModuleTest.java +++ b/storm-jackson3/src/test/java/st/orm/jackson/StormModuleTest.java @@ -23,408 +23,31 @@ import tools.jackson.databind.DatabindException; import tools.jackson.databind.json.JsonMapper; +/** + * The JSON forms of a {@code Ref}, written and read through {@link StormModule}: a loaded entity as + * {@code {"@entity": ...}}, a loaded projection as {@code {"@id": ..., "@projection": ...}}, an unloaded ref as its + * id, a compound key as a plain object, inside containers and with {@code null}, for keys of every type and for a + * data type without a key, whose id is read by its JSON form. + */ class StormModuleTest { - public record SimpleEntity(@PK Integer id, String name) - implements Entity {} + public record SimpleEntity(@PK Integer id, String name) implements Entity {} - public record SimpleProjection(@PK Integer id, String label) - implements Projection {} - - public record EntityHolder(@Nullable Ref entity) {} - - public record ProjectionHolder(@Nullable Ref projection) {} - - public record EntityListHolder(@Nullable List> entities) {} - - // RefSerializer tests - - @Test - void serializeUnloadedEntityRefToRawId() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new EntityHolder(Ref.of(SimpleEntity.class, 42)); - var json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":42}", json); - } - - @Test - void serializeLoadedEntityRefWithEntityWrapper() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var entity = new SimpleEntity(7, "Test"); - var holder = new EntityHolder(Ref.of(entity)); - var json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":{\"@entity\":{\"id\":7,\"name\":\"Test\"}}}", json); - } - - @Test - void serializeLoadedProjectionRefWithProjectionWrapper() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var projection = new SimpleProjection(3, "Label"); - var holder = new ProjectionHolder(Ref.of(projection, 3)); - var json = mapper.writeValueAsString(holder); - assertEquals( - "{\"projection\":{\"@id\":3,\"@projection\":{\"id\":3,\"label\":\"Label\"}}}", - json); - } - - @Test - void serializeNullRefFieldToNull() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new EntityHolder(null); - var json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":null}", json); - } - - // RefDeserializer tests - - @Test - void deserializeRawIdToDetachedRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"entity\":42}", EntityHolder.class); - assertNotNull(holder.entity()); - assertEquals(42, holder.entity().id()); - } - - @Test - void deserializeRawIdWithRefFactory() throws Exception { - RefFactory factory = new RefFactory() { - @SuppressWarnings({"unchecked", "rawtypes"}) - @Override - public Ref create(Class type, ID id) { - return (Ref) Ref.of((Class) type, id); - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - @Override - public Ref create(T record, ID id) { - return (Ref) Ref.of((Class) record.getClass(), id); - } - }; - var mapper = JsonMapper.builder() - .addModule(new StormModule(factory)) - .build(); - var holder = mapper.readValue("{\"entity\":99}", EntityHolder.class); - assertNotNull(holder.entity()); - assertEquals(99, holder.entity().id()); - } - - @Test - void deserializeEntityObjectToLoadedRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var json = "{\"entity\":{\"@entity\":{\"id\":7,\"name\":\"Test\"}}}"; - var holder = mapper.readValue(json, EntityHolder.class); - assertNotNull(holder.entity()); - assertTrue(holder.entity().isLoaded()); - var entity = holder.entity().fetch(); - assertInstanceOf(SimpleEntity.class, entity); - assertEquals(7, entity.id()); - assertEquals("Test", entity.name()); - } - - @Test - void deserializeProjectionObjectToLoadedRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var json = "{\"projection\":{\"@id\":3,\"@projection\":{\"id\":3,\"label\":\"Label\"}}}"; - var holder = mapper.readValue(json, ProjectionHolder.class); - assertNotNull(holder.projection()); - assertTrue(holder.projection().isLoaded()); - var projection = holder.projection().fetch(); - assertInstanceOf(SimpleProjection.class, projection); - assertEquals(3, projection.id()); - assertEquals("Label", projection.label()); - } - - @Test - void deserializeNullRefFieldToNull() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"entity\":null}", EntityHolder.class); - assertNull(holder.entity()); - } - - @Test - void deserializeObjectWithoutEntityOrProjectionFieldShouldThrow() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - assertThrows(DatabindException.class, - () -> mapper.readValue("{\"entity\":{\"unknown\":1}}", EntityHolder.class)); - } - - @Test - void deserializeStringIdToDetachedRef() throws Exception { - // Test VALUE_STRING token path - record StringIdEntity(@PK String id, String name) - implements Entity {} - record StringIdHolder(@Nullable Ref entity) {} - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"entity\":\"abc\"}", StringIdHolder.class); - assertNotNull(holder.entity()); - assertEquals("abc", holder.entity().id()); - } - - @Test - void deserializeListOfRefsWithNullElement() throws Exception { - // Test List> with null elements - exercises the container unwrapping + null handling - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var json = "{\"entities\":[42, null, 7]}"; - var holder = mapper.readValue(json, EntityListHolder.class); - assertNotNull(holder.entities()); - assertEquals(3, holder.entities().size()); - assertEquals(42, holder.entities().get(0).id()); - assertNull(holder.entities().get(1)); - assertEquals(7, holder.entities().get(2).id()); - } - - @Test - void roundTripEntityRefShouldPreserveLoadedState() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var entity = new SimpleEntity(5, "RoundTrip"); - var original = new EntityHolder(Ref.of(entity)); - var json = mapper.writeValueAsString(original); - var restored = mapper.readValue(json, EntityHolder.class); - assertNotNull(restored.entity()); - assertTrue(restored.entity().isLoaded()); - assertEquals(entity, restored.entity().fetch()); - } - - @Test - void roundTripProjectionRefShouldPreserveLoadedState() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var projection = new SimpleProjection(10, "Proj"); - var original = new ProjectionHolder(Ref.of(projection, 10)); - var json = mapper.writeValueAsString(original); - var restored = mapper.readValue(json, ProjectionHolder.class); - assertNotNull(restored.projection()); - assertTrue(restored.projection().isLoaded()); - assertEquals(projection, restored.projection().fetch()); - } - - // Tests for compound PK refs (plain object without @entity/@projection) - - public record CompoundPkRefHolder(@Nullable Ref entity) {} - - @Test - void serializeUnloadedCompoundPkRefToPkObject() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new CompoundPkRefHolder(Ref.of(VetSpecialty.class, new VetSpecialtyPK(1, 2))); - var json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":{\"vetId\":1,\"specialtyId\":2}}", json); - } + public record StringIdEntity(@PK String id, String name) implements Entity {} - @Test - void deserializeCompoundPkObjectToUnloadedRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"entity\":{\"vetId\":1,\"specialtyId\":2}}"; - var holder = mapper.readValue(json, CompoundPkRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(new VetSpecialtyPK(1, 2), holder.entity().id()); - } + public record LongIdEntity(@PK Long id, String name) implements Entity {} - @Test - void roundTripCompoundPkRefShouldPreserveId() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var original = new CompoundPkRefHolder(Ref.of(VetSpecialty.class, new VetSpecialtyPK(3, 4))); - var json = mapper.writeValueAsString(original); - var restored = mapper.readValue(json, CompoundPkRefHolder.class); - assertEquals(original, restored); - } + public record DoubleIdEntity(@PK Double id, String name) implements Entity {} - // Tests for Data record without @PK (fallback deserialization paths) + public record SimpleProjection(@PK Integer id, String label) implements Projection {} - // Data record without @PK annotation, so resolvePkType returns null and fallback paths execute. + /** A data type without a key: the id of its refs is read by its JSON form. */ public record NoPkData(Integer id, String value) implements Data {} - public record NoPkDataHolder(@Nullable Ref data) {} - - public record NoPkDataProjection(Integer id, String value) - implements Projection {} - - public record NoPkDataProjectionHolder(@Nullable Ref data) {} - - @Test - void deserializeIntIdFallbackForNoPkData() throws Exception { - // When pkType is null (no @PK on target), the fallback deserializeId path is used. - // This exercises the VALUE_NUMBER_INT fallback in deserializeId. - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"data\":42}", NoPkDataHolder.class); - assertNotNull(holder.data()); - assertEquals(42, ((Number) holder.data().id()).intValue()); - } - - @Test - void deserializeStringIdFallbackForNoPkData() throws Exception { - // Exercises the VALUE_STRING fallback in deserializeId. - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"data\":\"abc\"}", NoPkDataHolder.class); - assertNotNull(holder.data()); - assertEquals("abc", holder.data().id()); - } - - @Test - void deserializeFloatIdFallbackForNoPkData() throws Exception { - // Exercises the VALUE_NUMBER_FLOAT fallback in deserializeId. - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"data\":3.14}", NoPkDataHolder.class); - assertNotNull(holder.data()); - assertEquals(3.14, holder.data().id()); - } - - @Test - void deserializeLargeIntIdFallbackForNoPkData() throws Exception { - // Exercises the VALUE_NUMBER_INT fallback with a value > Integer.MAX_VALUE. - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - long largeValue = (long) Integer.MAX_VALUE + 1L; - var holder = mapper.readValue("{\"data\":" + largeValue + "}", NoPkDataHolder.class); - assertNotNull(holder.data()); - assertEquals(largeValue, holder.data().id()); - } - - @Test - void deserializeNegativeLargeIntIdFallbackForNoPkData() throws Exception { - // Exercises the VALUE_NUMBER_INT fallback with a value < Integer.MIN_VALUE. - // This covers the short-circuit branch when value < Integer.MIN_VALUE. - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - long negativeValue = (long) Integer.MIN_VALUE - 1L; - var holder = mapper.readValue("{\"data\":" + negativeValue + "}", NoPkDataHolder.class); - assertNotNull(holder.data()); - assertEquals(negativeValue, holder.data().id()); - } - - @Test - void deserializeProjectionObjectWithIntIdNodeFallbackForNoPk() throws Exception { - // Exercises the deserializeIdFromNode fallback path for a Projection without @PK. - // When pkType is null, the node.isInt() path is taken. - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var json = "{\"data\":{\"@id\":5,\"@projection\":{\"id\":5,\"value\":\"hello\"}}}"; - var holder = mapper.readValue(json, NoPkDataProjectionHolder.class); - assertNotNull(holder.data()); - assertTrue(holder.data().isLoaded()); - assertEquals(5, holder.data().id()); - } - - @Test - void deserializeProjectionObjectWithStringIdNodeFallbackForNoPk() throws Exception { - // Exercises the deserializeIdFromNode node.isTextual() path. - record NoPkStringProjection(String id, String value) - implements Projection {} - record NoPkStringProjectionHolder(@Nullable Ref data) {} - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var json = "{\"data\":{\"@id\":\"abc\",\"@projection\":{\"id\":\"abc\",\"value\":\"hello\"}}}"; - var holder = mapper.readValue(json, NoPkStringProjectionHolder.class); - assertNotNull(holder.data()); - assertTrue(holder.data().isLoaded()); - assertEquals("abc", holder.data().id()); - } - - @Test - void deserializeProjectionObjectWithLongIdNodeFallbackForNoPk() throws Exception { - // Exercises the deserializeIdFromNode node.isLong() path. - record NoPkLongProjection(Long id, String value) - implements Projection {} - record NoPkLongProjectionHolder(@Nullable Ref data) {} - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - long largeValue = (long) Integer.MAX_VALUE + 1L; - var json = "{\"data\":{\"@id\":" + largeValue - + ",\"@projection\":{\"id\":" + largeValue + ",\"value\":\"hello\"}}}"; - var holder = mapper.readValue(json, NoPkLongProjectionHolder.class); - assertNotNull(holder.data()); - assertTrue(holder.data().isLoaded()); - assertEquals(largeValue, holder.data().id()); - } - - @Test - void deserializeProjectionObjectWithDoubleIdNodeFallbackForNoPk() throws Exception { - // Exercises the deserializeIdFromNode node.isDouble() path. - record NoPkDoubleProjection(Double id, String value) - implements Projection {} - record NoPkDoubleProjectionHolder(@Nullable Ref data) {} - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var json = "{\"data\":{\"@id\":3.14,\"@projection\":{\"id\":3.14,\"value\":\"hello\"}}}"; - var holder = mapper.readValue(json, NoPkDoubleProjectionHolder.class); - assertNotNull(holder.data()); - assertTrue(holder.data().isLoaded()); - assertEquals(3.14, holder.data().id()); - } - - @Test - void constructorWithRefFactoryShouldPassThroughToSupplier() throws Exception { - RefFactory factory = new RefFactory() { - @SuppressWarnings({"unchecked", "rawtypes"}) - @Override - public Ref create(Class type, ID id) { - return (Ref) Ref.of((Class) type, id); - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - @Override - public Ref create(T record, ID id) { - return (Ref) Ref.of((Class) record.getClass(), id); - } - }; - var module = new StormModule(factory); - var mapper = JsonMapper.builder() - .addModule(module) - .build(); - var holder = mapper.readValue("{\"entity\":123}", EntityHolder.class); - assertNotNull(holder.entity()); - assertEquals(123, holder.entity().id()); - } - - // Additional record types for broader coverage - - public record StringIdEntity(@PK String id, String name) implements Entity {} - - public record LongIdEntity(@PK Long id, String name) implements Entity {} + /** A projection without a key: the id of the loaded form is read by its JSON form. */ + public record NoPkProjection(String label) implements Projection {} - public record DoubleIdEntity(@PK Double id, String name) implements Entity {} + public record EntityHolder(@Nullable Ref entity) {} public record StringIdHolder(Ref entity) {} @@ -432,710 +55,404 @@ public record LongIdHolder(Ref entity) {} public record DoubleIdHolder(Ref entity) {} - public record RefSetHolder(Set> entities) {} - - public record RefMapHolder(Map> entities) {} + public record ProjectionHolder(@Nullable Ref projection) {} - public record NoPkRefListHolder(List> data) {} + public record CompoundPkHolder(@Nullable Ref entity) {} - public record NoPkProjection(String label) implements Projection {} + public record NoPkHolder(@Nullable Ref data) {} public record NoPkProjectionHolder(@Nullable Ref projection) {} - public record DataRefHolder(@Nullable Ref data) {} + public record EntityListHolder(List> entities) {} - // Constructor with null RefFactory + public record EntitySetHolder(Set> entities) {} - @Test - void constructorWithNullRefFactoryShouldCreateModuleWithDetachedRefs() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule((RefFactory) null)) - .build(); - var holder = mapper.readValue("{\"entity\":1}", EntityHolder.class); - assertNotNull(holder.entity()); - assertEquals(1, holder.entity().id()); - } + public record EntityMapHolder(Map> entities) {} - @Test - void constructorWithNullSupplierResultShouldCreateDetachedRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule(() -> null)) - .build(); - var holder = mapper.readValue("{\"entity\":7}", EntityHolder.class); - assertNotNull(holder.entity()); - assertEquals(7, holder.entity().id()); - } + public record ProjectionListHolder(List> projections) {} - // Serialization for different ID types + public record NoPkListHolder(List> data) {} - @Test - void unloadedRefWithStringIdShouldSerializeAsStringValue() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new StringIdHolder(Ref.of(StringIdEntity.class, "abc-123")); - var json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":\"abc-123\"}", json); + public record MultiTypeHolder(@Nullable Ref entity, @Nullable Ref projection) {} + + private static JsonMapper mapper() { + return mapper(new StormModule()); } - @Test - void unloadedRefWithLongIdShouldSerializeAsLongValue() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new LongIdHolder(Ref.of(LongIdEntity.class, 9999999999L)); - var json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":9999999999}", json); + private static JsonMapper mapper(StormModule module) { + return JsonMapper.builder().addModule(module).build(); } - // Deserialization for different ID types + /** A ref factory that counts the refs it creates, standing in for the attached factory of a template. */ + private static final class CountingRefFactory implements RefFactory { + int created; - @Test - void rawLongIdShouldDeserializeToUnloadedRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"entity\":9999999999}", LongIdHolder.class); - assertNotNull(holder.entity()); - assertEquals(9999999999L, holder.entity().id()); + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Ref create(Class type, ID id) { + created++; + return (Ref) Ref.of((Class) type, id); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Override + public Ref create(T record, ID id) { + return (Ref) Ref.of((Class) record.getClass(), id); + } } - // Deserialization error paths + // Writing. @Test - void projectionObjectWithoutIdFieldShouldThrowException() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - assertThrows(DatabindException.class, - () -> mapper.readValue("{\"projection\":{\"@projection\":{\"id\":5,\"label\":\"Test\"}}}", ProjectionHolder.class)); + void anUnloadedRefWritesAsItsId() throws Exception { + assertEquals("{\"entity\":42}", mapper().writeValueAsString(new EntityHolder(Ref.of(SimpleEntity.class, 42)))); + assertEquals("{\"entity\":\"abc-123\"}", + mapper().writeValueAsString(new StringIdHolder(Ref.of(StringIdEntity.class, "abc-123")))); + assertEquals("{\"entity\":9999999999}", + mapper().writeValueAsString(new LongIdHolder(Ref.of(LongIdEntity.class, 9999999999L)))); + assertEquals("{\"entity\":3.14}", + mapper().writeValueAsString(new DoubleIdHolder(Ref.of(DoubleIdEntity.class, 3.14)))); } @Test - void booleanTokenShouldThrowExceptionDuringRefDeserialization() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - assertThrows(DatabindException.class, - () -> mapper.readValue("{\"entity\":true}", EntityHolder.class)); + void aLoadedEntityRefWritesAsTheEntityWrapper() throws Exception { + var holder = new EntityHolder(Ref.of(new SimpleEntity(7, "Test"))); + assertEquals("{\"entity\":{\"@entity\":{\"id\":7,\"name\":\"Test\"}}}", mapper().writeValueAsString(holder)); } @Test - void arrayTokenShouldThrowExceptionDuringRefDeserialization() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - assertThrows(DatabindException.class, - () -> mapper.readValue("{\"entity\":[1,2,3]}", EntityHolder.class)); + void aLoadedProjectionRefWritesAsTheProjectionWrapperWithItsId() throws Exception { + var holder = new ProjectionHolder(Ref.of(new SimpleProjection(3, "Label"), 3)); + assertEquals("{\"projection\":{\"@id\":3,\"@projection\":{\"id\":3,\"label\":\"Label\"}}}", + mapper().writeValueAsString(holder)); } - // Set and Map container types - @Test - void refInSetShouldDeserializeCorrectly() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"entities\":[1,2,3]}", RefSetHolder.class); - assertEquals(3, holder.entities().size()); + void anUnloadedCompoundKeyRefWritesAsTheKeyObject() throws Exception { + var holder = new CompoundPkHolder(Ref.of(VetSpecialty.class, new VetSpecialtyPK(1, 2))); + assertEquals("{\"entity\":{\"vetId\":1,\"specialtyId\":2}}", mapper().writeValueAsString(holder)); } @Test - void refInMapValueShouldDeserializeCorrectly() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"entities\":{\"a\":1,\"b\":2}}", RefMapHolder.class); - assertEquals(2, holder.entities().size()); - assertEquals(1, holder.entities().get("a").id()); - assertEquals(2, holder.entities().get("b").id()); + void aNullRefWritesAsNull() throws Exception { + assertEquals("{\"entity\":null}", mapper().writeValueAsString(new EntityHolder(null))); + assertEquals("{\"projection\":null}", mapper().writeValueAsString(new ProjectionHolder(null))); } @Test - void refSetSerializationShouldRoundTrip() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var refs = Set.of( - Ref.of(SimpleEntity.class, 1), - Ref.of(SimpleEntity.class, 2)); - var holder = new RefSetHolder(refs); - var json = mapper.writeValueAsString(holder); - assertNotNull(json); - var deserialized = mapper.readValue(json, RefSetHolder.class); - assertEquals(2, deserialized.entities().size()); + void aRefOfADataTypeWithoutAKeyWritesItsIdAsIs() throws Exception { + // Without a key type to find a serializer for, the id serializes as the value it is. + assertTrue(mapper().writeValueAsString(new NoPkHolder(Ref.of(NoPkData.class, 42))).contains("42")); + assertTrue(mapper().writeValueAsString(new NoPkHolder(Ref.of(NoPkData.class, "text-id"))).contains("text-id")); } @Test - void refMapSerializationShouldWork() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var refs = Map.of( - "a", Ref.of(SimpleEntity.class, 1), - "b", Ref.of(SimpleEntity.class, 2)); - var holder = new RefMapHolder(refs); - var json = mapper.writeValueAsString(holder); - assertNotNull(json); + void aRefWritesOutsideAPropertyToo() throws Exception { + // Serialized directly, the serializer has no property to resolve the key type from. + var json = mapper().writeValueAsString(Ref.of(SimpleEntity.class, 42)); + assertTrue(json.contains("42"), json); } - // NoPk projection node fallback paths - @Test - void noPkProjectionWithIntIdNodeShouldDeserializeViaNodeFallback() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue( - "{\"projection\":{\"@id\":42,\"@projection\":{\"label\":\"Test\"}}}", - NoPkProjectionHolder.class); - assertNotNull(holder.projection()); - assertEquals(42, holder.projection().id()); + void aFieldThatIsNotARefIsLeftToJackson() throws Exception { + record NonRefHolder(String value) {} + assertEquals("{\"value\":\"test\"}", mapper().writeValueAsString(new NonRefHolder("test"))); } - @Test - void noPkProjectionWithLongIdNodeShouldDeserializeViaNodeFallback() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var json = "{\"projection\":{\"@id\":9999999999,\"@projection\":{\"label\":\"Test\"}}}"; - var holder = mapper.readValue(json, NoPkProjectionHolder.class); - assertNotNull(holder.projection()); - assertEquals(9999999999L, holder.projection().id()); - } + // Reading. @Test - void noPkProjectionWithDoubleIdNodeShouldDeserializeViaNodeFallback() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue( - "{\"projection\":{\"@id\":3.14,\"@projection\":{\"label\":\"Test\"}}}", - NoPkProjectionHolder.class); - assertNotNull(holder.projection()); - assertEquals(3.14, holder.projection().id()); + void anIdReadsAsAnUnloadedRef() throws Exception { + var entity = mapper().readValue("{\"entity\":42}", EntityHolder.class).entity(); + assertNotNull(entity); + assertEquals(42, entity.id()); + assertNull(entity.getOrNull()); + assertEquals("abc", mapper().readValue("{\"entity\":\"abc\"}", StringIdHolder.class).entity().id()); + assertEquals(9999999999L, mapper().readValue("{\"entity\":9999999999}", LongIdHolder.class).entity().id()); + assertEquals(3.14, mapper().readValue("{\"entity\":3.14}", DoubleIdHolder.class).entity().id()); } @Test - void noPkProjectionWithStringIdNodeShouldDeserializeViaNodeFallback() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue( - "{\"projection\":{\"@id\":\"abc\",\"@projection\":{\"label\":\"Test\"}}}", - NoPkProjectionHolder.class); - assertNotNull(holder.projection()); - assertEquals("abc", holder.projection().id()); + void anEntityWrapperReadsAsALoadedRef() throws Exception { + var holder = mapper().readValue("{\"entity\":{\"@entity\":{\"id\":7,\"name\":\"Test\"}}}", EntityHolder.class); + assertNotNull(holder.entity()); + assertTrue(holder.entity().isLoaded()); + var entity = holder.entity().fetch(); + assertInstanceOf(SimpleEntity.class, entity); + assertEquals(7, entity.id()); + assertEquals("Test", entity.name()); } @Test - void noPkProjectionWithObjectIdNodeShouldDeserializeViaObjectFallback() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue( - "{\"projection\":{\"@id\":{\"key\":\"val\"},\"@projection\":{\"label\":\"Test\"}}}", - NoPkProjectionHolder.class); + void aProjectionWrapperReadsAsALoadedRef() throws Exception { + var json = "{\"projection\":{\"@id\":3,\"@projection\":{\"id\":3,\"label\":\"Label\"}}}"; + var holder = mapper().readValue(json, ProjectionHolder.class); assertNotNull(holder.projection()); - assertNotNull(holder.projection().id()); + assertTrue(holder.projection().isLoaded()); + var projection = holder.projection().fetch(); + assertInstanceOf(SimpleProjection.class, projection); + assertEquals(3, projection.id()); + assertEquals("Label", projection.label()); } - // Entity via @projection path and non-Entity via @entity path - @Test - void projectionPathWithEntityDataShouldCreateEntityRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var json = "{\"entity\":{\"@id\":1,\"@projection\":{\"id\":1,\"name\":\"Alice\"}}}"; - var holder = mapper.readValue(json, EntityHolder.class); + void aProjectionWrapperHoldingAnEntityReadsAsALoadedEntityRef() throws Exception { + var json = "{\"entity\":{\"@id\":1,\"@projection\":{\"id\":1,\"name\":\"Via Projection Path\"}}}"; + var holder = mapper().readValue(json, EntityHolder.class); assertNotNull(holder.entity()); assertEquals(1, holder.entity().id()); - assertNotNull(holder.entity().getOrNull()); - } - - @Test - void entityPathWithNonEntityDataShouldThrowException() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - assertThrows(DatabindException.class, - () -> mapper.readValue("{\"data\":{\"@entity\":{\"id\":42,\"value\":\"test\"}}}", DataRefHolder.class)); + var loaded = holder.entity().getOrNull(); + assertNotNull(loaded); + assertEquals("Via Projection Path", loaded.name()); } @Test - void projectionPathWithPlainDataAndIdShouldCreateUnloadedRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue( - "{\"data\":{\"@id\":42,\"@projection\":{\"id\":42,\"value\":\"test\"}}}", - DataRefHolder.class); + void aProjectionWrapperHoldingPlainDataReadsAsARefOfItsId() throws Exception { + // Neither entity nor projection: the id of the wrapper is the ref's id. + var json = "{\"data\":{\"@id\":42,\"@projection\":{\"id\":42,\"value\":\"test\"}}}"; + var holder = mapper().readValue(json, NoPkHolder.class); assertNotNull(holder.data()); assertEquals(42, ((Number) holder.data().id()).intValue()); } - // NoPk data serialization and list tests - - @Test - void unloadedNoPkRefShouldSerializeAsRawId() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new NoPkDataHolder(Ref.of(NoPkData.class, 42)); - var json = mapper.writeValueAsString(holder); - assertTrue(json.contains("42")); - } - - @Test - void noPkDataSerializerShouldHandleMissingPkTypeGracefully() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new DataRefHolder(Ref.of(NoPkData.class, "text-id")); - var json = mapper.writeValueAsString(holder); - assertTrue(json.contains("text-id")); - } - - @Test - void noPkRefListWithMixedIdTypesShouldDeserializeViaFallback() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"data\":[1,\"two\",3]}", NoPkRefListHolder.class); - assertEquals(3, holder.data().size()); - assertEquals(1, ((Number) holder.data().get(0).id()).intValue()); - assertEquals("two", holder.data().get(1).id()); - assertEquals(3, ((Number) holder.data().get(2).id()).intValue()); - } - - @Test - void noPkRefWithNullIdInListShouldReturnNullRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"data\":[null, 1]}", NoPkRefListHolder.class); - assertEquals(2, holder.data().size()); - assertNull(holder.data().get(0)); - assertNotNull(holder.data().get(1)); - } - - // Double ID round-trip - - @Test - void doubleIdRefShouldRoundTrip() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new DoubleIdHolder(Ref.of(DoubleIdEntity.class, 3.14)); - var json = mapper.writeValueAsString(holder); - assertEquals("{\"entity\":3.14}", json); - var deserialized = mapper.readValue(json, DoubleIdHolder.class); - assertEquals(3.14, deserialized.entity().id()); - } - @Test - void loadedDoubleIdEntityRefShouldRoundTrip() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var entity = new DoubleIdEntity(2.71, "Euler"); - var holder = new DoubleIdHolder(Ref.of(entity)); - var json = mapper.writeValueAsString(holder); - assertTrue(json.contains("@entity")); - var deserialized = mapper.readValue(json, DoubleIdHolder.class); - assertEquals(2.71, deserialized.entity().id()); - } - - // Additional round-trip tests - - @Test - void stringIdRefShouldRoundTrip() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new StringIdHolder(Ref.of(StringIdEntity.class, "my-id")); - var json = mapper.writeValueAsString(holder); - var deserialized = mapper.readValue(json, StringIdHolder.class); - assertEquals(holder, deserialized); + void aCompoundKeyObjectReadsAsAnUnloadedRef() throws Exception { + var holder = mapper().readValue("{\"entity\":{\"vetId\":1,\"specialtyId\":2}}", CompoundPkHolder.class); + assertNotNull(holder.entity()); + assertEquals(new VetSpecialtyPK(1, 2), holder.entity().id()); } @Test - void loadedStringIdEntityRefShouldRoundTrip() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var entity = new StringIdEntity("abc", "Test"); - var holder = new StringIdHolder(Ref.of(entity)); - var json = mapper.writeValueAsString(holder); - var deserialized = mapper.readValue(json, StringIdHolder.class); - assertEquals(holder, deserialized); + void nullReadsAsNoRef() throws Exception { + assertNull(mapper().readValue("{\"entity\":null}", EntityHolder.class).entity()); + assertNull(mapper().readValue("{\"projection\":null}", ProjectionHolder.class).projection()); } @Test - void loadedLongIdEntityRefShouldRoundTrip() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var entity = new LongIdEntity(999L, "Test"); - var holder = new LongIdHolder(Ref.of(entity)); - var json = mapper.writeValueAsString(holder); - var deserialized = mapper.readValue(json, LongIdHolder.class); - assertEquals(holder, deserialized); + void aWrapperHoldingNullReadsAsNoRef() throws Exception { + assertNull(mapper().readValue("{\"entity\":{\"@entity\":null}}", EntityHolder.class).entity()); + assertNull(mapper().readValue("{\"projection\":{\"@id\":3,\"@projection\":null}}", ProjectionHolder.class) + .projection()); } - // Null projection ref + // Round trips. @Test - void nullProjectionRefShouldSerializeToNull() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = new ProjectionHolder(null); - var json = mapper.writeValueAsString(holder); - assertEquals("{\"projection\":null}", json); + void anEntityRefRoundTripsLoadedAndUnloaded() throws Exception { + var unloaded = new EntityHolder(Ref.of(SimpleEntity.class, 42)); + assertEquals(unloaded, mapper().readValue(mapper().writeValueAsString(unloaded), EntityHolder.class)); + var entity = new SimpleEntity(5, "RoundTrip"); + var loaded = new EntityHolder(Ref.of(entity)); + var restored = mapper().readValue(mapper().writeValueAsString(loaded), EntityHolder.class); + assertEquals(loaded, restored); + assertTrue(restored.entity().isLoaded()); + assertEquals(entity, restored.entity().fetch()); } @Test - void nullProjectionRefShouldDeserializeFromNull() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - var holder = mapper.readValue("{\"projection\":null}", ProjectionHolder.class); - assertNull(holder.projection()); + void aProjectionRefRoundTripsLoadedAndUnloaded() throws Exception { + var unloaded = new ProjectionHolder(Ref.of(SimpleProjection.class, 5)); + assertEquals(unloaded, mapper().readValue(mapper().writeValueAsString(unloaded), ProjectionHolder.class)); + var projection = new SimpleProjection(10, "Proj"); + var loaded = new ProjectionHolder(Ref.of(projection, 10)); + var restored = mapper().readValue(mapper().writeValueAsString(loaded), ProjectionHolder.class); + assertEquals(loaded, restored); + assertTrue(restored.projection().isLoaded()); + assertEquals(projection, restored.projection().fetch()); } - // Raw Ref without type info - - @SuppressWarnings("rawtypes") @Test - void rawRefWithoutTypeInfoShouldThrowOnDeserialization() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - assertThrows(DatabindException.class, - () -> mapper.readValue("42", Ref.class)); + void aCompoundKeyRefRoundTrips() throws Exception { + var holder = new CompoundPkHolder(Ref.of(VetSpecialty.class, new VetSpecialtyPK(3, 4))); + assertEquals(holder, mapper().readValue(mapper().writeValueAsString(holder), CompoundPkHolder.class)); } - // NoPk boolean error - @Test - void noPkDataWithBooleanShouldThrowExceptionViaFallbackPath() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - assertThrows(DatabindException.class, - () -> mapper.readValue("{\"data\":true}", NoPkDataHolder.class)); + void refsOfEveryKeyTypeRoundTrip() throws Exception { + var string = new StringIdHolder(Ref.of(StringIdEntity.class, "my-id")); + assertEquals(string, mapper().readValue(mapper().writeValueAsString(string), StringIdHolder.class)); + var loadedString = new StringIdHolder(Ref.of(new StringIdEntity("abc", "Test"))); + assertEquals(loadedString, mapper().readValue(mapper().writeValueAsString(loadedString), StringIdHolder.class)); + var loadedLong = new LongIdHolder(Ref.of(new LongIdEntity(999L, "Test"))); + assertEquals(loadedLong, mapper().readValue(mapper().writeValueAsString(loadedLong), LongIdHolder.class)); + var loadedDouble = new DoubleIdHolder(Ref.of(new DoubleIdEntity(2.71, "Euler"))); + var json = mapper().writeValueAsString(loadedDouble); + assertTrue(json.contains("@entity"), json); + assertEquals(loadedDouble, mapper().readValue(json, DoubleIdHolder.class)); } - public record SimpleNoPkData(String value) implements Data {} - - public record EntityRefHolder(Ref entity) {} - - public record NullableEntityRefHolder(@Nullable Ref entity) {} - - public record ProjectionRefHolder(@Nullable Ref projection) {} - - public record SimpleNoPkRefHolder(@Nullable Ref data) {} - - public record RefListHolder(List> entities) {} + // Containers. @Test - void serializerShouldHandleDirectRefSerializationWithoutProperty() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - Ref ref = Ref.of(SimpleEntity.class, 42); - String json = mapper.writeValueAsString(ref); - assertNotNull(json); - assertTrue(json.contains("42")); - } - - @Test - void nonRefFieldShouldNotAffectSerialization() throws Exception { - record NonRefHolder(String value) {} - - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - NonRefHolder holder = new NonRefHolder("test"); - String json = mapper.writeValueAsString(holder); - assertEquals("{\"value\":\"test\"}", json); + void refsInAListReadWithNullElements() throws Exception { + var holder = mapper().readValue("{\"entities\":[42, null, 7]}", EntityListHolder.class); + assertEquals(3, holder.entities().size()); + assertEquals(42, holder.entities().get(0).id()); + assertNull(holder.entities().get(1)); + assertEquals(7, holder.entities().get(2).id()); + assertEquals(0, mapper().readValue("{\"entities\":[]}", EntityListHolder.class).entities().size()); } @Test - void refListWithLoadedEntitiesAndNullsShouldDeserializeCorrectly() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"entities\":[null,1,{\"@entity\":{\"id\":2,\"name\":\"Bob\"}}]}"; - RefListHolder holder = mapper.readValue(json, RefListHolder.class); + void refsInAListReadLoadedAndUnloadedAlike() throws Exception { + var json = "{\"entities\":[null,1,{\"@entity\":{\"id\":2,\"name\":\"Bob\"}}]}"; + var holder = mapper().readValue(json, EntityListHolder.class); assertEquals(3, holder.entities().size()); assertNull(holder.entities().get(0)); assertEquals(1, holder.entities().get(1).id()); + assertNull(holder.entities().get(1).getOrNull()); assertEquals(2, holder.entities().get(2).id()); assertNotNull(holder.entities().get(2).getOrNull()); } @Test - void supplierReturningNullFactoryShouldCreateDetachedRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule(() -> null)) - .build(); - String json = "{\"entity\":42}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(42, holder.entity().id()); - assertNull(holder.entity().getOrNull()); + void refsInAListRoundTrip() throws Exception { + var entities = new EntityListHolder(List.of( + Ref.of(new SimpleEntity(1, "Alice")), + Ref.of(SimpleEntity.class, 2), + Ref.of(new SimpleEntity(3, "Charlie")))); + var restored = mapper().readValue(mapper().writeValueAsString(entities), EntityListHolder.class); + assertEquals(3, restored.entities().size()); + assertNotNull(restored.entities().get(0).getOrNull()); + assertNull(restored.entities().get(1).getOrNull()); + assertNotNull(restored.entities().get(2).getOrNull()); + var projections = new ProjectionListHolder(List.of( + Ref.of(new SimpleProjection(1, "First"), 1), + Ref.of(SimpleProjection.class, 2))); + var restoredProjections = mapper().readValue(mapper().writeValueAsString(projections), ProjectionListHolder.class); + assertEquals(2, restoredProjections.projections().size()); + assertNotNull(restoredProjections.projections().get(0).getOrNull()); + assertNull(restoredProjections.projections().get(1).getOrNull()); } @Test - void defaultConstructorShouldCreateModuleWithDetachedRefs() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"entity\":99}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); - assertNotNull(holder.entity()); - assertEquals(99, holder.entity().id()); - assertNull(holder.entity().getOrNull()); + void refsInASetAndAMapValueRoundTrip() throws Exception { + var set = new EntitySetHolder(Set.of(Ref.of(SimpleEntity.class, 1), Ref.of(SimpleEntity.class, 2))); + assertEquals(set, mapper().readValue(mapper().writeValueAsString(set), EntitySetHolder.class)); + assertEquals(3, mapper().readValue("{\"entities\":[1,2,3]}", EntitySetHolder.class).entities().size()); + var map = new EntityMapHolder(Map.of("a", Ref.of(SimpleEntity.class, 1), "b", Ref.of(SimpleEntity.class, 2))); + assertEquals(map, mapper().readValue(mapper().writeValueAsString(map), EntityMapHolder.class)); + var read = mapper().readValue("{\"entities\":{\"a\":1,\"b\":2}}", EntityMapHolder.class); + assertEquals(1, read.entities().get("a").id()); + assertEquals(2, read.entities().get("b").id()); } @Test - void projectionWithEntityTypeInProjectionPathShouldCreateEntityRef() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"entity\":{\"@id\":1,\"@projection\":{\"id\":1,\"name\":\"Via Projection Path\"}}}"; - EntityRefHolder holder = mapper.readValue(json, EntityRefHolder.class); + void refsOfDifferentTypesInOneHolderReadEachByTheirOwnType() throws Exception { + var holder = mapper().readValue("{\"entity\":42,\"projection\":7}", MultiTypeHolder.class); assertNotNull(holder.entity()); - assertEquals(1, holder.entity().id()); - SimpleEntity loaded = holder.entity().getOrNull(); - assertNotNull(loaded); - assertEquals("Via Projection Path", loaded.name()); + assertEquals(42, holder.entity().id()); + assertNotNull(holder.projection()); + assertEquals(7, holder.projection().id()); } + // The ref factory. + @Test - void refFactorySupplierShouldBeCalledDuringDeserialization() throws Exception { - var factoryCallCount = new int[]{0}; - RefFactory factory = new RefFactory() { - @SuppressWarnings({"unchecked", "rawtypes"}) - @Override - public Ref create(Class type, ID pk) { - factoryCallCount[0]++; - return (Ref) Ref.of((Class) type, pk); - } - - @SuppressWarnings({"unchecked", "rawtypes"}) - @Override - public Ref create(T record, ID pk) { - return (Ref) Ref.of((Class) record.getClass(), pk); - } - }; - var mapper = JsonMapper.builder() - .addModule(new StormModule(() -> factory)) - .build(); - String json = "{\"entities\":[1,2,3]}"; - RefListHolder holder = mapper.readValue(json, RefListHolder.class); + void anIdReadsThroughTheRefFactoryWhenThereIsOne() throws Exception { + var factory = new CountingRefFactory(); + var holder = mapper(new StormModule(factory)).readValue("{\"entities\":[1,2,3]}", EntityListHolder.class); assertEquals(3, holder.entities().size()); - assertEquals(3, factoryCallCount[0]); + assertEquals(3, factory.created); + var supplied = new CountingRefFactory(); + assertEquals(99, mapper(new StormModule(() -> supplied)).readValue("{\"entity\":99}", EntityHolder.class).entity().id()); + assertEquals(1, supplied.created); } @Test - void simpleNoPkDataWithProjectionFormatAndNullIdShouldThrow() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":{\"@id\":null,\"@projection\":{\"value\":\"test\"}}}"; - assertThrows(DatabindException.class, () -> - mapper.readValue(json, SimpleNoPkRefHolder.class)); + void withoutARefFactoryAnIdReadsAsADetachedRef() throws Exception { + for (var module : List.of(new StormModule(), new StormModule((RefFactory) null), new StormModule(() -> null))) { + var entity = mapper(module).readValue("{\"entity\":7}", EntityHolder.class).entity(); + assertNotNull(entity); + assertEquals(7, entity.id()); + assertNull(entity.getOrNull()); + } } - public record SimpleNoPkDataProjectionHolder(@Nullable Ref data) {} + // A data type without a key: the id is read by its JSON form. @Test - void simpleNoPkDataWithProjectionFormatAndNonNullIdShouldCreateRefWithId() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":{\"@id\":42,\"@projection\":{\"value\":\"test\"}}}"; - SimpleNoPkDataProjectionHolder holder = mapper.readValue(json, SimpleNoPkDataProjectionHolder.class); - assertNotNull(holder.data()); - assertEquals(42, ((Number) holder.data().id()).intValue()); + void anIdOfADataTypeWithoutAKeyReadsByItsJsonForm() throws Exception { + assertEquals(42, ((Number) mapper().readValue("{\"data\":42}", NoPkHolder.class).data().id()).intValue()); + assertEquals("abc", mapper().readValue("{\"data\":\"abc\"}", NoPkHolder.class).data().id()); + assertEquals(3.14, mapper().readValue("{\"data\":3.14}", NoPkHolder.class).data().id()); + long large = (long) Integer.MAX_VALUE + 1L; + assertEquals(large, mapper().readValue("{\"data\":" + large + "}", NoPkHolder.class).data().id()); + long negative = (long) Integer.MIN_VALUE - 1L; + assertEquals(negative, mapper().readValue("{\"data\":" + negative + "}", NoPkHolder.class).data().id()); } @Test - void emptyRefListShouldDeserializeCorrectly() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"entities\":[]}"; - RefListHolder holder = mapper.readValue(json, RefListHolder.class); - assertNotNull(holder.entities()); - assertEquals(0, holder.entities().size()); - } - - @Test - void loadedEntityRefInListShouldSerializeAndDeserialize() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - List> refs = List.of( - Ref.of(new SimpleEntity(1, "Alice")), - Ref.of(SimpleEntity.class, 2), - Ref.of(new SimpleEntity(3, "Charlie"))); - RefListHolder holder = new RefListHolder(refs); - String json = mapper.writeValueAsString(holder); - assertNotNull(json); - - RefListHolder deserialized = mapper.readValue(json, RefListHolder.class); - assertEquals(3, deserialized.entities().size()); - assertNotNull(deserialized.entities().get(0).getOrNull()); - assertNull(deserialized.entities().get(1).getOrNull()); - assertNotNull(deserialized.entities().get(2).getOrNull()); + void idsOfADataTypeWithoutAKeyReadInAList() throws Exception { + var holder = mapper().readValue("{\"data\":[1,\"two\",3,null]}", NoPkListHolder.class); + assertEquals(4, holder.data().size()); + assertEquals(1, ((Number) holder.data().get(0).id()).intValue()); + assertEquals("two", holder.data().get(1).id()); + assertEquals(3, ((Number) holder.data().get(2).id()).intValue()); + assertNull(holder.data().get(3)); } - public record ProjectionRefListHolder(List> projections) {} - @Test - void loadedProjectionRefInListShouldSerializeAndDeserialize() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - List> refs = List.of( - Ref.of(new SimpleProjection(1, "First"), 1), - Ref.of(SimpleProjection.class, 2)); - ProjectionRefListHolder holder = new ProjectionRefListHolder(refs); - String json = mapper.writeValueAsString(holder); - assertNotNull(json); - - ProjectionRefListHolder deserialized = mapper.readValue(json, ProjectionRefListHolder.class); - assertEquals(2, deserialized.projections().size()); - assertNotNull(deserialized.projections().get(0).getOrNull()); - assertNull(deserialized.projections().get(1).getOrNull()); + void theIdOfAProjectionWrapperWithoutAKeyTypeReadsByItsJsonForm() throws Exception { + assertEquals(42, projectionId("42")); + assertEquals(9999999999L, projectionId("9999999999")); + assertEquals(3.14, projectionId("3.14")); + assertEquals("abc", projectionId("\"abc\"")); + assertEquals(Map.of("key", "val"), projectionId("{\"key\":\"val\"}")); } - public record MultiTypeHolder( - @Nullable Ref entity, - @Nullable Ref projection) {} - - @Test - void multipleRefTypesInSameHolderShouldDeserializeCorrectly() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"entity\":42,\"projection\":7}"; - MultiTypeHolder holder = mapper.readValue(json, MultiTypeHolder.class); - assertNotNull(holder.entity()); - assertEquals(42, holder.entity().id()); + private static Object projectionId(String idJson) throws Exception { + var json = "{\"projection\":{\"@id\":" + idJson + ",\"@projection\":{\"label\":\"Test\"}}}"; + var holder = mapper().readValue(json, NoPkProjectionHolder.class); assertNotNull(holder.projection()); - assertEquals(7, holder.projection().id()); - } - - @Test - void entityPathWithSimpleNoPkDataShouldThrowException() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":{\"@entity\":{\"value\":\"test\"}}}"; - assertThrows(DatabindException.class, () -> - mapper.readValue(json, SimpleNoPkDataProjectionHolder.class)); - } - - @Test - void simpleNoPkDataSerializerShouldHandleMissingPkTypeGracefully() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - Ref ref = Ref.of(SimpleNoPkData.class, "text-id"); - SimpleNoPkDataProjectionHolder holder = new SimpleNoPkDataProjectionHolder(ref); - String json = mapper.writeValueAsString(holder); - assertTrue(json.contains("text-id")); - } - - @Test - void deserializeIdWithFloatValueForSimpleNoPkShouldWork() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":3.14}"; - SimpleNoPkRefHolder holder = mapper.readValue(json, SimpleNoPkRefHolder.class); - assertNotNull(holder.data()); - assertEquals(3.14, ((Number) holder.data().id()).doubleValue(), 0.001); + assertTrue(holder.projection().isLoaded()); + return holder.projection().id(); } @Test - void deserializeIdWithStringValueForSimpleNoPkShouldWork() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":\"abc-123\"}"; - SimpleNoPkRefHolder holder = mapper.readValue(json, SimpleNoPkRefHolder.class); - assertNotNull(holder.data()); - assertEquals("abc-123", holder.data().id()); + void aProjectionWrapperOfATypeWithoutAKeyReadsItsIdByItsJsonForm() throws Exception { + var json = "{\"data\":{\"@id\":3000000000,\"@projection\":{\"id\":5,\"value\":\"test\"}}}"; + assertEquals(3000000000L, ((Number) mapper().readValue(json, NoPkHolder.class).data().id()).longValue()); + json = "{\"data\":{\"@id\":\"uuid-value\",\"@projection\":{\"id\":5,\"value\":\"test\"}}}"; + assertEquals("uuid-value", mapper().readValue(json, NoPkHolder.class).data().id()); } - @Test - void deserializeIdWithLongValueForSimpleNoPkShouldReturnLong() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":3000000000}"; - SimpleNoPkRefHolder holder = mapper.readValue(json, SimpleNoPkRefHolder.class); - assertNotNull(holder.data()); - assertEquals(3000000000L, ((Number) holder.data().id()).longValue()); - } + // Refusals. @Test - void deserializeIdFromNodeWithLongValueForSimpleNoPkShouldWork() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":{\"@id\":3000000000,\"@projection\":{\"value\":\"test\"}}}"; - SimpleNoPkDataProjectionHolder holder = mapper.readValue(json, SimpleNoPkDataProjectionHolder.class); - assertNotNull(holder.data()); - assertEquals(3000000000L, ((Number) holder.data().id()).longValue()); + void anObjectWithoutAWrapperFieldOrACompoundKeyIsRefused() { + assertThrows(DatabindException.class, + () -> mapper().readValue("{\"entity\":{\"unknown\":1}}", EntityHolder.class)); + // A type without a key has no compound key an object could be. + assertThrows(DatabindException.class, + () -> mapper().readValue("{\"data\":{\"unknown\":1}}", NoPkHolder.class)); } @Test - void deserializeIdFromNodeWithDoubleValueForSimpleNoPkShouldWork() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":{\"@id\":2.71,\"@projection\":{\"value\":\"test\"}}}"; - SimpleNoPkDataProjectionHolder holder = mapper.readValue(json, SimpleNoPkDataProjectionHolder.class); - assertNotNull(holder.data()); - assertEquals(2.71, ((Number) holder.data().id()).doubleValue(), 0.001); + void aProjectionWrapperWithoutAnIdIsRefused() { + assertThrows(DatabindException.class, () -> mapper().readValue( + "{\"projection\":{\"@projection\":{\"id\":5,\"label\":\"Test\"}}}", ProjectionHolder.class)); + // A null id is no id either, when the value is neither entity nor projection. + assertThrows(DatabindException.class, () -> mapper().readValue( + "{\"data\":{\"@id\":null,\"@projection\":{\"id\":5,\"value\":\"test\"}}}", NoPkHolder.class)); } @Test - void deserializeIdFromNodeWithStringValueForSimpleNoPkShouldWork() throws Exception { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"data\":{\"@id\":\"uuid-value\",\"@projection\":{\"value\":\"test\"}}}"; - SimpleNoPkDataProjectionHolder holder = mapper.readValue(json, SimpleNoPkDataProjectionHolder.class); - assertNotNull(holder.data()); - assertEquals("uuid-value", holder.data().id()); + void anEntityWrapperHoldingSomethingElseThanAnEntityIsRefused() { + assertThrows(DatabindException.class, () -> mapper().readValue( + "{\"data\":{\"@entity\":{\"id\":42,\"value\":\"test\"}}}", NoPkHolder.class)); } @Test - void projectionWithoutIdFieldShouldThrow() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"projection\":{\"@projection\":{\"id\":1,\"label\":\"test\"}}}"; - assertThrows(DatabindException.class, () -> - mapper.readValue(json, ProjectionRefHolder.class)); + void aTokenThatIsNotAnIdOrAnObjectIsRefused() { + assertThrows(DatabindException.class, () -> mapper().readValue("{\"entity\":true}", EntityHolder.class)); + assertThrows(DatabindException.class, () -> mapper().readValue("{\"entity\":[1,2,3]}", EntityHolder.class)); + assertThrows(DatabindException.class, () -> mapper().readValue("{\"data\":true}", NoPkHolder.class)); } + @SuppressWarnings("rawtypes") @Test - void refObjectWithoutEntityOrProjectionFieldShouldThrow() { - var mapper = JsonMapper.builder() - .addModule(new StormModule()) - .build(); - String json = "{\"entity\":{\"unknown\":\"value\"}}"; - assertThrows(DatabindException.class, () -> - mapper.readValue(json, EntityRefHolder.class)); + void aRawRefWithoutATargetTypeIsRefused() { + assertThrows(DatabindException.class, () -> mapper().readValue("42", Ref.class)); } } diff --git a/storm-jackson3/src/test/kotlin/st/orm/jackson/JsonORMConverterKotlinInteropTest.kt b/storm-jackson3/src/test/kotlin/st/orm/jackson/JsonORMConverterKotlinInteropTest.kt new file mode 100644 index 000000000..e8a5e440f --- /dev/null +++ b/storm-jackson3/src/test/kotlin/st/orm/jackson/JsonORMConverterKotlinInteropTest.kt @@ -0,0 +1,201 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed 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 st.orm.jackson + +import com.fasterxml.jackson.annotation.JsonTypeInfo +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id.NAME +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import st.orm.DbTable +import st.orm.Entity +import st.orm.Json +import st.orm.PK +import st.orm.core.spi.Providers +import st.orm.core.template.ORMTemplate.of +import st.orm.core.template.impl.BindHint +import st.orm.test.StormTest +import tools.jackson.core.JsonGenerator +import tools.jackson.core.JsonParser +import tools.jackson.databind.DeserializationContext +import tools.jackson.databind.SerializationContext +import tools.jackson.databind.ValueDeserializer +import tools.jackson.databind.ValueSerializer +import tools.jackson.databind.annotation.JsonDeserialize +import tools.jackson.databind.annotation.JsonSerialize +import javax.sql.DataSource + +/** + * Runs the Jackson converter in the configuration every Kotlin application has: storm-kotlin on the class path, + * so its reflection provider builds the field metadata. Covers the language seams the Java suite cannot reach: + * annotations placed on Kotlin constructor properties, Kotlin sealed hierarchies in the sealed-type walk, and + * Java sealed hierarchies enumerated through Kotlin reflection. + */ +@StormTest(scripts = ["/data.sql"]) +open class JsonORMConverterKotlinInteropTest { + + private lateinit var dataSource: DataSource + + @BeforeEach + fun bindDataSource(dataSource: DataSource) { + this.dataSource = dataSource + } + + data class RawOwnerRow(val address: String?, val telephone: String?) + + @Test + fun `kotlin reflection provider is active`() { + // The interop premise: with storm-kotlin on the class path, its provider outranks the default one. + assertEquals("st.orm.template.impl.ORMReflectionImpl", Providers.getORMReflection().javaClass.name) + } + + // Custom serializer/deserializer annotations on a Kotlin constructor property. + + data class KotlinAddress(val address: String, val city: String) + + class PipeAddressSerializer : ValueSerializer() { + override fun serialize(value: KotlinAddress, gen: JsonGenerator, context: SerializationContext) { + gen.writeString("${value.address} | ${value.city}") + } + } + + class PipeAddressDeserializer : ValueDeserializer() { + override fun deserialize(parser: JsonParser, context: DeserializationContext): KotlinAddress { + val parts = parser.text.split(" | ") + return KotlinAddress(parts[0], parts[1]) + } + } + + @DbTable("owner") + data class OwnerWithCustomSerde( + @PK val id: Int = 0, + @Json + @JsonSerialize(using = PipeAddressSerializer::class) + @JsonDeserialize(using = PipeAddressDeserializer::class) + val address: KotlinAddress, + val telephone: String? = null, + ) : Entity + + @Test + fun `custom serde on constructor property should be honored`() { + // The annotations land on the constructor parameter, the default Kotlin use-site target for them. + val orm = of(dataSource) + val repository = orm.entity(OwnerWithCustomSerde::class.java) + val inserted = repository.insertAndFetch(OwnerWithCustomSerde(address = KotlinAddress("1 Way", "Town"))) + assertEquals(KotlinAddress("1 Way", "Town"), inserted.address) + // The raw column value proves the serializer engaged; a bypassed serializer/deserializer pair would + // round trip plain JSON and pass the assertion above. + val row = orm.query("SELECT address, telephone FROM owner WHERE id = ${inserted.id}") + .getSingleResult(RawOwnerRow::class.java) + assertEquals("\"1 Way | Town\"", row.address) + } + + // One serializer class shared by two fields of different Kotlin types. + + data class KotlinPhone(val number: String) + + class TypeNameMarkerSerializer : ValueSerializer() { + override fun serialize(value: Any, gen: JsonGenerator, context: SerializationContext) { + gen.writeString("marker:${value.javaClass.simpleName}") + } + } + + @DbTable("owner") + data class OwnerWithSharedSerializer( + @PK val id: Int = 0, + @Json @JsonSerialize(using = TypeNameMarkerSerializer::class) val address: KotlinAddress, + @Json @JsonSerialize(using = TypeNameMarkerSerializer::class) val telephone: KotlinPhone, + ) : Entity + + @Test + fun `fields of different types sharing serializer class should each use the serializer`() { + val orm = of(dataSource) + val repository = orm.entity(OwnerWithSharedSerializer::class.java) + repository.insert(OwnerWithSharedSerializer(address = KotlinAddress("1 Way", "Town"), telephone = KotlinPhone("555"))) + val row = orm.query("SELECT address, telephone FROM owner WHERE address LIKE '%marker%'") + .getSingleResult(RawOwnerRow::class.java) + assertEquals("\"marker:KotlinAddress\"", row.address) + assertEquals("\"marker:KotlinPhone\"", row.telephone) + } + + // Kotlin sealed hierarchies, top-level and inside containers. + + @JsonTypeInfo(use = NAME) + sealed interface KotlinPerson + + data class KotlinPersonA(val firstName: String, val lastName: String) : KotlinPerson + + data class KotlinPersonB(val firstName: String, val lastName: String) : KotlinPerson + + @DbTable("owner") + data class OwnerWithSealedAddress( + @PK val id: Int = 0, + @Json val address: KotlinPerson, + val telephone: String? = null, + ) : Entity + + @Test + fun `kotlin sealed field should round trip through database`() { + val orm = of(dataSource) + val repository = orm.entity(OwnerWithSealedAddress::class.java) + val inserted = repository.insertAndFetch(OwnerWithSealedAddress(address = KotlinPersonB("Jane", "Doe"))) + assertEquals(KotlinPersonB("Jane", "Doe"), inserted.address) + } + + @Test + fun `kotlin sealed field should resolve subtype via discriminator`() { + val orm = of(dataSource) + val result = orm.query( + """ + SELECT id, + '{"@type":"KotlinPersonA","firstName":"Jane","lastName":"Doe"}' AS address, + telephone + FROM owner WHERE id = 1 + """.trimIndent(), + ).getSingleResult(OwnerWithSealedAddress::class.java) + assertTrue(result.address is KotlinPersonA) + } + + @DbTable("owner") + data class OwnerWithSealedList( + @PK val id: Int = 0, + @Json val address: List, + val telephone: String? = null, + ) : Entity + + @Test + fun `kotlin sealed list field should round trip through database`() { + // The sealed interface appears as the List element type; the walk registers its subtypes from the + // generic type the Kotlin reflection provider reports. + val orm = of(dataSource) + val repository = orm.entity(OwnerWithSealedList::class.java) + val persons = listOf(KotlinPersonA("Jane", "Doe"), KotlinPersonB("John", "Doe")) + val inserted = repository.insertAndFetch(OwnerWithSealedList(address = persons)) + assertEquals(persons, inserted.address) + } + + // Java sealed hierarchies enumerated through Kotlin reflection. + + @Test + fun `java sealed types enumerate through kotlin reflection`() { + // BindHint is a Java sealed interface from storm-core; the Kotlin provider resolves its permitted + // subclasses through KClass.sealedSubclasses, which must match Java reflection. + val permittedSubclasses = Providers.getORMReflection().getPermittedSubclasses(BindHint::class.java) + assertEquals(BindHint::class.java.permittedSubclasses.toSet(), permittedSubclasses.toSet()) + assertTrue(permittedSubclasses.isNotEmpty()) + } +} diff --git a/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/StormSerializersModule.kt b/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/StormSerializersModule.kt index 77c1a199f..afbcd7c98 100644 --- a/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/StormSerializersModule.kt +++ b/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/StormSerializersModule.kt @@ -25,10 +25,12 @@ import kotlinx.serialization.json.* import kotlinx.serialization.json.Json import kotlinx.serialization.modules.SerializersModule import st.orm.* -import st.orm.core.spi.ORMReflection -import st.orm.core.spi.Providers import st.orm.core.spi.RefFactory -import java.util.concurrent.ConcurrentHashMap +import st.orm.core.template.impl.RefJsonSupport +import st.orm.core.template.impl.RefJsonSupport.ENTITY_FIELD +import st.orm.core.template.impl.RefJsonSupport.ID_FIELD +import st.orm.core.template.impl.RefJsonSupport.PROJECTION_FIELD +import java.util.function.Supplier import kotlin.reflect.KClass import kotlin.reflect.full.starProjectedType @@ -170,56 +172,44 @@ public class RefSerializer( } @Suppress("UNCHECKED_CAST") - private fun deserializeObject(json: Json, obj: JsonObject): Ref = when { - obj.containsKey(ENTITY_FIELD) -> { - val payload = obj[ENTITY_FIELD] - ?: throw SerializationException("$ENTITY_FIELD field is null") - val entity = json.decodeFromJsonElement(targetSerializer, payload) - if (entity !is Entity<*>) { - throw SerializationException("$ENTITY_FIELD must decode to an Entity") + private fun deserializeObject(json: Json, obj: JsonObject): Ref? = try { + when { + obj.containsKey(ENTITY_FIELD) -> { + val entity = json.decodeFromJsonElement(targetSerializer, obj.getValue(ENTITY_FIELD)) + RefJsonSupport.loadedRef(entity) as Ref } - Ref.of(entity as Entity) as Ref - } - obj.containsKey(PROJECTION_FIELD) -> { - val idElement = obj[ID_FIELD] - ?: throw SerializationException("$PROJECTION_FIELD requires $ID_FIELD field") - // For @projection, @id must be present and non-null. - val id = decodeId(json, idElement, targetClass) - ?: throw SerializationException("$PROJECTION_FIELD requires non-null $ID_FIELD") - val payload = obj[PROJECTION_FIELD] - ?: throw SerializationException("$PROJECTION_FIELD field is null") - val data = json.decodeFromJsonElement(targetSerializer, payload) - createLoadedRef(data, id) - } - else -> { - val pkType = PkTypeResolver.resolve(targetClass) - if (pkType == null || !PkTypeResolver.isRecord(pkType)) { - throw SerializationException( - "Ref object must contain $ENTITY_FIELD or $PROJECTION_FIELD", - ) + obj.containsKey(PROJECTION_FIELD) -> { + val idElement = obj[ID_FIELD] + ?: throw SerializationException("$PROJECTION_FIELD requires $ID_FIELD field") + // For @projection, @id must be present and non-null. + val id = decodeId(json, idElement, targetClass) + ?: throw SerializationException("$PROJECTION_FIELD requires non-null $ID_FIELD") + val data = json.decodeFromJsonElement(targetSerializer, obj.getValue(PROJECTION_FIELD)) + RefJsonSupport.loadedRef(targetClass, data, id) as Ref + } + else -> { + val pkType = RefJsonSupport.pkType(targetClass) + if (pkType == null || !RefJsonSupport.isCompoundKey(pkType)) { + throw SerializationException( + "Ref object must contain $ENTITY_FIELD or $PROJECTION_FIELD", + ) + } + // A plain object is the inline record of a compound PK; unloaded refs serialize compound PKs this way. + createRef(targetClass as Class, decodeId(json, obj, targetClass)) as Ref? } - // A plain object is the inline record of a compound PK; unloaded refs serialize compound PKs this way. - val id = checkNotNull(decodeId(json, obj, targetClass)) - createRef(targetClass as Class, id) as Ref } - } - - @Suppress("UNCHECKED_CAST") - private fun createLoadedRef(data: T, id: Any): Ref = when (data) { - is Entity<*> -> Ref.of(data as Entity) as Ref - is Projection<*> -> Ref.of(data as Projection, id) as Ref - else -> Ref.of(targetClass as Class, id) as Ref + } catch (e: IllegalArgumentException) { + throw SerializationException(e.message, e) } private fun createRef(targetClass: Class, id: Any?): Ref? { - if (id == null) return null - val factory = refFactoryProvider?.invoke() - return factory?.create(targetClass, id) ?: Ref.of(targetClass, id) + val refFactory = refFactoryProvider?.let { provider -> Supplier { provider() } } + return RefJsonSupport.refOf(targetClass, id, refFactory) } private fun encodeId(json: Json, targetClass: Class<*>?, id: Any?): JsonElement { if (id == null || targetClass == null) return JsonNull - val pkType = PkTypeResolver.resolve(targetClass) + val pkType = RefJsonSupport.pkType(targetClass) if (pkType != null) { val serializer = json.serializerOrNull(pkType) if (serializer != null) { @@ -243,7 +233,7 @@ public class RefSerializer( private fun decodeId(json: Json, element: JsonElement, targetClass: Class<*>): Any? { if (element is JsonNull) return null - val pkType = PkTypeResolver.resolve(targetClass) + val pkType = RefJsonSupport.pkType(targetClass) if (pkType != null) { val serializer = json.serializerOrNull(pkType) ?: throw SerializationException( @@ -266,39 +256,6 @@ public class RefSerializer( else -> throw SerializationException("Cannot decode Ref id from element: $element") } } - - private companion object { - const val ID_FIELD = "@id" - const val ENTITY_FIELD = "@entity" - const val PROJECTION_FIELD = "@projection" - } -} - -/** - * Cache for PK type resolution to avoid repeated reflection. - */ -private object PkTypeResolver { - private val reflection: ORMReflection = Providers.getORMReflection() - - // Java ConcurrentHashMap.computeIfAbsent cannot return null. Use a sentinel. - private val NO_PK: Class<*> = Void::class.java - private val cache = ConcurrentHashMap, Class<*>>() // values never null due to sentinel - - fun resolve(target: Class<*>): Class<*>? { - val cached = cache.computeIfAbsent(target) { - reflection.getRecordType(target).fields() - .firstOrNull { f -> f.isAnnotationPresent(PK::class.java) } - ?.type() - ?: NO_PK - } - return if (cached == NO_PK) null else cached - } - - /** - * Returns whether the given PK type is itself a record type (Java record or Kotlin data class), i.e. a - * compound PK. - */ - fun isRecord(type: Class<*>): Boolean = reflection.findRecordType(type).isPresent } /** diff --git a/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt b/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt index 37bcf5005..249e34ad4 100644 --- a/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt +++ b/storm-kotlinx-serialization/src/main/kotlin/st/orm/serialization/spi/JsonORMConverterImpl.kt @@ -25,12 +25,10 @@ import kotlinx.serialization.serializer import st.orm.Data import st.orm.Json import st.orm.Ref -import st.orm.SqlTemplateException -import st.orm.core.spi.* +import st.orm.core.template.impl.JsonConverterSupport import st.orm.mapping.RecordField import st.orm.serialization.RefSerializer import st.orm.serialization.StormSerializersModule -import st.orm.spi.* import java.util.concurrent.ConcurrentHashMap import kotlin.reflect.KClass import kotlin.reflect.KType @@ -38,20 +36,17 @@ import kotlin.reflect.full.createInstance import kotlinx.serialization.json.Json as JsonMapper internal class JsonORMConverterImpl( - private val field: RecordField, + field: RecordField, kType: KType, json: Json, -) : ORMConverter { +) : JsonConverterSupport(field) { companion object { - private val REFLECTION: ORMReflection = Providers.getORMReflection() - // The mapper depends only on the Json flags, so the cache holds at most one entry per flag combination. private val JSON_CACHE = ConcurrentHashMap() - private val REF_FACTORY = ThreadLocal() private fun buildJson(json: Json): JsonMapper = JsonMapper { - serializersModule = StormSerializersModule { REF_FACTORY.get() } + serializersModule = StormSerializersModule { currentRefFactory() } ignoreUnknownKeys = !json.failOnUnknown coerceInputValues = !json.failOnMissing explicitNulls = true @@ -144,7 +139,7 @@ internal class JsonORMConverterImpl( // Lazy - only called when serializing/deserializing loaded refs. json.serializersModule.serializer(targetType) as KSerializer }, - refFactoryProvider = { REF_FACTORY.get() }, + refFactoryProvider = { currentRefFactory() }, ) as KSerializer } } @@ -165,35 +160,7 @@ internal class JsonORMConverterImpl( } } - override fun getParameterCount(): Int = 1 - override fun getParameterTypes(): List> = listOf(String::class.java) - - override fun getColumns(nameResolver: ORMConverter.NameResolver): List = listOf(nameResolver.getName(field)) - - override fun toDatabase(record: Any?): List = try { - val v = if (record == null) null else REFLECTION.invoke(field, record) - listOf(v?.let { JsonString(this@JsonORMConverterImpl.json.encodeToString(serializer, it)) }) - } catch (t: Throwable) { - throw SqlTemplateException(t) - } + override fun write(value: Any): String = json.encodeToString(serializer, value) - override fun fromDatabase(values: Array, refFactory: RefFactory): Any? { - val raw = values[0] as? String? ?: return null - // A custom serializer may issue a query, nesting another fromDatabase call on this thread. The previous - // factory is therefore restored rather than removed, so the remaining fields of the outer value still - // deserialize with the outer factory and produce attached refs. - val outerRefFactory = REF_FACTORY.get() - REF_FACTORY.set(refFactory) - return try { - this@JsonORMConverterImpl.json.decodeFromString(serializer, raw) - } catch (e: SerializationException) { - throw SqlTemplateException(e) - } finally { - if (outerRefFactory == null) { - REF_FACTORY.remove() - } else { - REF_FACTORY.set(outerRefFactory) - } - } - } + override fun read(json: String): Any? = this.json.decodeFromString(serializer, json) } diff --git a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/RefSerializerTest.kt b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/RefSerializerTest.kt index 0037bcb17..22782d12d 100644 --- a/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/RefSerializerTest.kt +++ b/storm-kotlinx-serialization/src/test/kotlin/st/orm/serialization/RefSerializerTest.kt @@ -209,6 +209,17 @@ internal class RefSerializerTest { ex.message shouldContain "@id" } + @Test + fun `deserialize entity wrapper with null payload throws`() { + // A null payload is no entity for a target that is not nullable. + shouldThrow { + jsonMapper.decodeFromString("""{"ref":{"@entity":null}}""") + } + shouldThrow { + jsonMapper.decodeFromString("""{"ref":{"@id":1,"@projection":null}}""") + } + } + @Test fun `deserialize projection with null id throws`() { val ex = shouldThrow {