Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ A join widens the query: from the join onward, every clause accepts paths from a
- The metamodel processors converge on one contract. The Java annotation processor generates the `<Type>NullableMetamodel` chain variant KSP already generates, and a nullable field selects the nullable variant of its child metamodel, so `Owner_.address` reads as the same static type from Java and Kotlin. KSP sources metamodel components from the primary constructor — a body-declared or inherited property has no column, so it gets no metamodel field, and sealed interfaces contribute abstract properties only — and escapes keyword-named properties (`` `object` `` and friends) at every emission site, so a metamodel for such a data class compiles.
- The Java annotation processor registers with Gradle as an aggregating incremental annotation processor, so attaching it no longer switches the whole source set to full recompilation on every change. A failure while generating reports the record it occurred on with the stack trace and stops processing, matching the KSP diagnostics.
- The Kotlin modules keep their implementation to themselves. Every declaration under storm-kotlin's `st.orm.template.impl` and `st.orm.repository.impl` is `internal` — the `Flow.flatMapConcat` and `flattenConcat` operators that collided with their kotlinx.coroutines namesakes, the top-level predicate factories whose generic names polluted completion, and the `*Impl` classes — as are the kotlinx-serialization converter provider and the Kotlin starter's auto-configured repository post processor. All five Kotlin modules compile in explicit API mode, so a declaration missing an explicit visibility fails the build instead of shipping public. The coroutine-aware SQL log recording that the Ktor plugin shares is the one deliberate exception, published as `st.orm.template.recordSqlLog` behind the `@InternalStormApi` opt-in.
- The Jackson JSON converters key their mapper cache on the field's type: a custom `@JsonSerialize`/`@JsonDeserialize` class shared by fields of different types is registered per field type instead of serving only the first field, and a sealed interface reached through a container type — `@Json List<Shape>` — has its permitted subtypes registered, so the discriminator resolves for container elements. The kotlinx converter keys its mapper cache on the `@Json` flags alone, bounding it at one mapper per flag combination.
- A record component's field metadata now includes annotations that Java propagates to the backing field, accessor or constructor parameter — an annotation only reaches the component itself when its targets include `RECORD_COMPONENT`, which third-party annotations rarely declare. `@JsonSerialize`/`@JsonDeserialize` on a `@Json` record component are therefore honored; they were silently ignored before.
- `@Json` fields serialize with the declared field type instead of the value's erased runtime type, so a polymorphic value writes the discriminator that reading the column expects, completing the round trip for fields like `@Json List<Shape>`.

## [1.13.1] - 2026-08-07

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,22 @@
package st.orm.core.repository.impl;

import static java.util.Arrays.asList;
import static java.util.Arrays.stream;
import static java.util.Objects.requireNonNull;
import static java.util.Optional.empty;
import static java.util.stream.IntStream.range;

import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
import java.lang.annotation.Annotation;
import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.lang.reflect.RecordComponent;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
Expand Down Expand Up @@ -111,29 +114,50 @@ protected Optional<RecordType> computeValue(@Nonnull Class<?> type) {
if (!type.isRecord()) {
return empty();
}
var components = requireNonNull(type.getRecordComponents(), "getRecordComponents should not return null");
return CONSTRUCTOR_CACHE.get(type)
.map(constructor -> new RecordType(
type,
constructor,
asList(type.getAnnotations()),
stream(requireNonNull(type.getRecordComponents(), "getRecordComponents should not return null"))
.map(component -> new RecordField(
component.getDeclaringRecord(),
component.getName(),
component.getType(),
component.getGenericType(),
!isNonnull(component),
false,
component.getAccessor(),
asList(component.getAnnotations())
)
)
range(0, components.length)
.mapToObj(index -> {
var component = components[index];
return new RecordField(
component.getDeclaringRecord(),
component.getName(),
component.getType(),
component.getGenericType(),
!isNonnull(component),
false,
component.getAccessor(),
getAnnotations(component, constructor.getParameters()[index])
);
})
.toList()
)
);
}
};

/**
* An annotation on a record component reaches the component itself only when its targets include
* RECORD_COMPONENT; annotations from other libraries typically propagate to the backing field, accessor or
* constructor parameter instead, so all four sites are folded together. An instance propagated to several
* sites compares equal and collapses to one, keeping single-instance lookups unambiguous.
*/
private static List<Annotation> getAnnotations(@Nonnull RecordComponent component, @Nonnull Parameter parameter) {
var annotations = new LinkedHashSet<>(asList(component.getAnnotations()));
try {
annotations.addAll(asList(component.getDeclaringRecord().getDeclaredField(component.getName()).getAnnotations()));
} catch (NoSuchFieldException e) {
// A record component always has a backing field of the same name.
}
annotations.addAll(asList(component.getAccessor().getAnnotations()));
annotations.addAll(asList(parameter.getAnnotations()));
return List.copyOf(annotations);
}

@Override
public Optional<RecordType> findRecordType(@Nonnull Class<?> type) {
return TYPE_CACHE.get(type);
Expand Down
56 changes: 56 additions & 0 deletions storm-jackson2/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,47 @@
</argLine>
</configuration>
</plugin>
<plugin>
<!-- Kotlin test sources only: the interop suite runs the converter with storm-kotlin's reflection
provider active, the configuration every Kotlin application has. The Java test sources are
listed so kotlinc resolves classes shared with the Java suite, such as IntegrationConfig. -->
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-maven-plugin</artifactId>
<version>${kotlin.version}</version>
<configuration>
<jvmTarget>21</jvmTarget>
</configuration>
<executions>
<execution>
<id>test-compile</id>
<phase>process-test-sources</phase>
<goals><goal>test-compile</goal></goals>
<configuration>
<sourceDirs>
<sourceDir>${project.basedir}/src/test/java</sourceDir>
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
</sourceDirs>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<id>add-kotlin-test</id>
<phase>generate-test-sources</phase>
<goals><goal>add-test-source</goal></goals>
<configuration>
<sources>
<source>src/test/kotlin</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
Expand Down Expand Up @@ -85,6 +126,21 @@
<version>2.17.0</version>
<scope>test</scope>
</dependency>
<dependency>
<!-- Activates the Kotlin reflection provider for the interop test suite. -->
<groupId>st.orm</groupId>
<artifactId>storm-kotlin</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<!-- Kotlin applications carry this module; findAndRegisterModules picks it up, giving Jackson the
creators of Kotlin data classes. -->
<groupId>com.fasterxml.jackson.module</groupId>
<artifactId>jackson-module-kotlin</artifactId>
<version>2.17.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,21 @@
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.jsontype.NamedType;
import com.fasterxml.jackson.databind.module.SimpleModule;
import jakarta.annotation.Nonnull;
import jakarta.annotation.Nullable;
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 st.orm.Json;
import st.orm.core.spi.JsonString;
import st.orm.core.spi.Name;
Expand All @@ -61,9 +66,11 @@ public final class JsonORMConverterImpl implements ORMConverter {
private final RecordField field;
private final TypeReference<?> typeReference;
private final ObjectMapper mapper;
private final ObjectWriter writer;

record CacheKey(@Nonnull Json json,
@Nullable Class<?> sealedType,
@Nonnull List<Class<?>> sealedTypes,
@Nullable Class<?> targetType,
@Nullable Class<? extends JsonSerializer<?>> serializer,
@Nullable Class<? extends JsonDeserializer<?>> deserializer) {}

Expand All @@ -73,9 +80,7 @@ public JsonORMConverterImpl(@Nonnull RecordField field,
@Nonnull Json json) {
this.field = requireNonNull(field, "field");
this.typeReference = requireNonNull(typeReference, "typeReference");
var type = getRawType(typeReference.getType())
.filter(Class::isSealed)
.orElse(null);
var sealedTypes = getSealedTypes(typeReference.getType());
// Check for custom serializer/deserializer annotations.
var serializeAnnotation = field.getAnnotation(JsonSerialize.class);
var deserializeAnnotation = field.getAnnotation(JsonDeserialize.class);
Expand All @@ -87,8 +92,14 @@ public JsonORMConverterImpl(@Nonnull RecordField field,
deserializeAnnotation != null && deserializeAnnotation.using() != JsonDeserializer.None.class
? (Class<? extends JsonDeserializer<?>>) 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"), type, serializerClass, deserializerClass),
new CacheKey(requireNonNull(json, "json"), sealedTypes, targetType, serializerClass, deserializerClass),
() -> {
var mapper = new ObjectMapper();
mapper.findAndRegisterModules();
Expand All @@ -98,8 +109,8 @@ public JsonORMConverterImpl(@Nonnull RecordField field,
if (!json.failOnMissing()) {
mapper.disable(FAIL_ON_MISSING_CREATOR_PROPERTIES);
}
if (type != null) {
mapper.registerSubtypes(getPermittedSubtypes(type));
for (var sealedType : sealedTypes) {
mapper.registerSubtypes(getPermittedSubtypes(sealedType));
}
// Register StormModule with supplier for dynamic RefFactory resolution.
mapper.registerModule(new StormModule(REF_FACTORY::get));
Expand All @@ -108,18 +119,16 @@ public JsonORMConverterImpl(@Nonnull RecordField field,
var customModule = new SimpleModule();
if (serializerClass != null) {
try {
Class<?> fieldType = getRawType(typeReference.getType()).orElse(Object.class);
JsonSerializer serializerInstance = serializerClass.getDeclaredConstructor().newInstance();
customModule.addSerializer(fieldType, serializerInstance);
customModule.addSerializer((Class) targetType, serializerInstance);
} catch (Exception e) {
throw new RuntimeException("Failed to instantiate custom serializer: " + serializerClass, e);
}
}
if (deserializerClass != null) {
try {
Class fieldType = getRawType(typeReference.getType()).orElse(Object.class);
JsonDeserializer deserializerInstance = deserializerClass.getDeclaredConstructor().newInstance();
customModule.addDeserializer(fieldType, deserializerInstance);
customModule.addDeserializer((Class) targetType, deserializerInstance);
} catch (Exception e) {
throw new RuntimeException("Failed to instantiate custom deserializer: " + deserializerClass, e);
}
Expand All @@ -128,6 +137,9 @@ public JsonORMConverterImpl(@Nonnull RecordField field,
}
return mapper;
});
// 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<Class<?>> getRawType(@Nonnull Type type) {
Expand All @@ -140,6 +152,35 @@ private static Optional<Class<?>> getRawType(@Nonnull Type type) {
}
}

/**
* 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<Shape>} as well as top-level sealed fields.
*/
private static List<Class<?>> getSealedTypes(@Nonnull Type type) {
var sealedTypes = new LinkedHashSet<Class<?>>();
collectSealedTypes(type, sealedTypes);
return List.copyOf(sealedTypes);
}

private static void collectSealedTypes(@Nonnull Type type, @Nonnull Set<Class<?>> 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(@Nonnull Class<?> sealedClass) {
return REFLECTION.getPermittedSubclasses(sealedClass).stream()
.map(subclass -> {
Expand Down Expand Up @@ -169,7 +210,7 @@ public List<Name> getColumns(@Nonnull NameResolver nameResolver) throws SqlTempl
public List<Object> toDatabase(@Nullable Object record) throws SqlTemplateException {
try {
Object o = record == null ? null : REFLECTION.invoke(field, record);
return singletonList(o == null ? null : new JsonString(mapper.writeValueAsString(o)));
return singletonList(o == null ? null : new JsonString(writer.writeValueAsString(o)));
} catch (Throwable e) {
throw new SqlTemplateException(e);
}
Expand Down
Loading
Loading