From d25eebacaf4c4249dcf75ff9a704faf73b8ce457 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Tue, 14 Jul 2026 09:20:59 +0200 Subject: [PATCH] feat: construct records through generated instantiators instead of reflection The metamodel generators now emit an Instantiator implementation per record type that invokes the canonical constructor (the primary constructor for Kotlin data classes) directly, registered through META-INF/services/st.orm.mapping.Instantiator. The row mapper resolves the instantiator once per constructor and dispatches to it instead of Constructor.newInstance, falling back to reflective construction for types without one, so models compiled without the generators keep working unchanged. This removes the last reflective call from the row mapping hot path. The motivation is portability rather than performance: native images need no reflection configuration for entity records, and modular applications need no opens clauses for their model packages. The public st.orm.mapping.Instantiator contract lives in storm-foundation so that generated code compiles in model-only modules, mirroring the metamodel types. The ServiceLoader registry caches per class loader; the record mapper verifies the constructor against the record components before attaching an instantiator, so non-canonical constructors always construct reflectively. Only top-level, non-generic records and data classes get instantiators; the annotation processor registers the services file when processing is over, the KSP processor on finish. Closes #253 --- storm-core/src/main/java/module-info.java | 1 + .../java/st/orm/core/spi/Instantiators.java | 78 ++++++++++++ .../template/impl/ObjectMapperFactory.java | 47 +++++++- .../orm/core/InstantiatorIntegrationTest.java | 60 ++++++++++ .../java/st/orm/mapping/Instantiator.java | 54 +++++++++ .../st/orm/metamodel/MetamodelProcessor.kt | 81 +++++++++++++ .../st/orm/metamodel/MetamodelProcessor.java | 111 ++++++++++++++++++ 7 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 storm-core/src/main/java/st/orm/core/spi/Instantiators.java create mode 100644 storm-core/src/test/java/st/orm/core/InstantiatorIntegrationTest.java create mode 100644 storm-foundation/src/main/java/st/orm/mapping/Instantiator.java diff --git a/storm-core/src/main/java/module-info.java b/storm-core/src/main/java/module-info.java index 1a30231f2..90ef0b324 100644 --- a/storm-core/src/main/java/module-info.java +++ b/storm-core/src/main/java/module-info.java @@ -8,6 +8,7 @@ uses st.orm.core.spi.ConnectionProvider; uses st.orm.core.spi.TransactionTemplateProvider; uses st.orm.core.spi.CursorCodecProvider; + uses st.orm.mapping.Instantiator; exports st.orm.core.template; exports st.orm.core.template.impl; exports st.orm.core.spi; diff --git a/storm-core/src/main/java/st/orm/core/spi/Instantiators.java b/storm-core/src/main/java/st/orm/core/spi/Instantiators.java new file mode 100644 index 000000000..117548171 --- /dev/null +++ b/storm-core/src/main/java/st/orm/core/spi/Instantiators.java @@ -0,0 +1,78 @@ +/* + * 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.spi; + +import static java.lang.Thread.currentThread; +import static java.util.Optional.ofNullable; +import static java.util.ServiceLoader.load; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import java.util.HashMap; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import st.orm.mapping.Instantiator; + +/** + * Registry of generated {@link Instantiator} implementations, discovered through the {@link ServiceLoader}. + * + *

The metamodel generators register an instantiator per record type in + * {@code META-INF/services/st.orm.mapping.Instantiator}. The registry is loaded once per class loader and consulted + * by the row mapper to construct records without reflection; types without a registered instantiator fall back to + * reflective construction.

+ */ +public final class Instantiators { + + /** Instantiators per class loader, keyed by the record type they construct. */ + private static final ConcurrentMap, Instantiator>> INSTANTIATOR_CACHE = + new ConcurrentHashMap<>(); + + private Instantiators() { + } + + /** + * Returns the instantiator registered for the given record type, or {@code null} if none is registered. + * + * @param type the record type to find an instantiator for. + * @param the record type. + * @return the registered instantiator, or {@code null} if the type has no generated instantiator. + */ + @Nullable + @SuppressWarnings("unchecked") + public static Instantiator find(@Nonnull Class type) { + ClassLoader classLoader = ofNullable(currentThread().getContextClassLoader()) + .orElseGet(() -> Instantiators.class.getClassLoader()); + return (Instantiator) INSTANTIATOR_CACHE + .computeIfAbsent(classLoader, Instantiators::loadInstantiators) + .get(type); + } + + private static Map, Instantiator> loadInstantiators(@Nonnull ClassLoader classLoader) { + Map, Instantiator> instantiators = new HashMap<>(); + for (Instantiator instantiator : load(Instantiator.class, classLoader)) { + instantiators.put(instantiator.type(), instantiator); + } + if (instantiators.isEmpty() && classLoader != Instantiators.class.getClassLoader()) { + // Revert to the registry's class loader, matching the provider loading strategy. + for (Instantiator instantiator : load(Instantiator.class, Instantiators.class.getClassLoader())) { + instantiators.put(instantiator.type(), instantiator); + } + } + return Map.copyOf(instantiators); + } +} diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java index f8fec1546..3427e7a17 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/ObjectMapperFactory.java @@ -22,10 +22,12 @@ import static st.orm.core.template.impl.RecordValidation.validateDataType; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; import java.lang.annotation.Annotation; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Parameter; +import java.lang.reflect.RecordComponent; import java.util.BitSet; import java.util.Map; import java.util.Optional; @@ -33,10 +35,12 @@ import java.util.function.Supplier; import st.orm.Data; import st.orm.PK; +import st.orm.core.spi.Instantiators; import st.orm.core.spi.ORMReflection; import st.orm.core.spi.Providers; import st.orm.core.spi.RefFactory; import st.orm.core.template.SqlTemplateException; +import st.orm.mapping.Instantiator; /** * Factory for creating instances of a specific type. @@ -177,6 +181,12 @@ static T construct(@Nonnull Constructor constructor, @Nonnull Object[] ar } } } + var instantiator = meta.instantiator(); + if (instantiator != null) { + // Generated instantiator: constructs through the canonical constructor without reflection. + //noinspection unchecked + return (T) instantiator.instantiate(args); + } try { // Use the map's constructor instance: its accessible flag was set before publication, so the // happens-before edge of the concurrent map makes the flag visible to all threads. @@ -199,11 +209,13 @@ static T construct(@Nonnull Constructor constructor, @Nonnull Object[] ar * @param parameterNames the parameter names, for error messages. * @param nonNull whether each parameter is marked as non-null. * @param primitive whether each parameter is a primitive type. + * @param instantiator the generated instantiator for the constructor, or null to construct reflectively. */ private record ConstructorMeta(@Nonnull Constructor constructor, @Nonnull String[] parameterNames, @Nonnull boolean[] nonNull, - @Nonnull boolean[] primitive) {} + @Nonnull boolean[] primitive, + @Nullable Instantiator instantiator) {} /** Cache of precomputed constructor metadata, keyed by constructor. Thread-safe for concurrent access. */ private static final Map, ConstructorMeta> CONSTRUCTOR_META = new ConcurrentHashMap<>(); @@ -220,7 +232,38 @@ private static ConstructorMeta constructorMeta(@Nonnull Constructor construct primitive[i] = parameterTypes[i].isPrimitive(); } constructor.setAccessible(true); - return new ConstructorMeta(constructor, parameterNames, nonNull, primitive); + return new ConstructorMeta(constructor, parameterNames, nonNull, primitive, findInstantiator(constructor)); + } + + /** + * Returns the generated instantiator for the given constructor, or {@code null} if none is registered or the + * constructor is not the one the instantiator was generated for. + * + *

Instantiators are generated for the canonical constructor of records and the primary constructor of + * Kotlin data classes. For records the constructor is verified against the record components; for non-record + * classes the registered instantiator is trusted, as the reflection provider resolves the primary + * constructor.

+ */ + @Nullable + private static Instantiator findInstantiator(@Nonnull Constructor constructor) { + Class declaringClass = constructor.getDeclaringClass(); + Instantiator instantiator = Instantiators.find(declaringClass); + if (instantiator == null) { + return null; + } + if (declaringClass.isRecord()) { + RecordComponent[] components = declaringClass.getRecordComponents(); + Class[] parameterTypes = constructor.getParameterTypes(); + if (components.length != parameterTypes.length) { + return null; + } + for (int i = 0; i < components.length; i++) { + if (components[i].getType() != parameterTypes[i]) { + return null; + } + } + } + return instantiator; } @SuppressWarnings("unchecked") diff --git a/storm-core/src/test/java/st/orm/core/InstantiatorIntegrationTest.java b/storm-core/src/test/java/st/orm/core/InstantiatorIntegrationTest.java new file mode 100644 index 000000000..d9f86def6 --- /dev/null +++ b/storm-core/src/test/java/st/orm/core/InstantiatorIntegrationTest.java @@ -0,0 +1,60 @@ +/* + * 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; + +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.assertSame; + +import org.junit.jupiter.api.Test; +import st.orm.core.model.Address; +import st.orm.core.model.City; +import st.orm.core.model.Owner; +import st.orm.core.model.Pet; +import st.orm.core.spi.Instantiators; + +/** + * Verifies that the metamodel processor generates and registers {@link st.orm.mapping.Instantiator} + * implementations for the test models, and that they construct instances equivalent to the canonical constructor. + * The full test suite exercises these instantiators implicitly, as the row mapper dispatches to them instead of + * reflective construction. + */ +public class InstantiatorIntegrationTest { + + @Test + public void testInstantiatorsAreRegisteredForModelRecords() { + assertNotNull(Instantiators.find(Pet.class)); + assertNotNull(Instantiators.find(Owner.class)); + assertNotNull(Instantiators.find(City.class)); + // Plain nested records (no Data interface) are covered through the referenced-record expansion. + assertNotNull(Instantiators.find(Address.class)); + } + + @Test + public void testInstantiatorConstructsEquivalentInstance() { + var instantiator = Instantiators.find(City.class); + assertNotNull(instantiator); + assertSame(City.class, instantiator.type()); + City city = instantiator.instantiate(new Object[] { 42, "Rotterdam" }); + assertEquals(new City(42, "Rotterdam"), city); + } + + @Test + public void testUnregisteredTypeFallsBackToNull() { + assertNull(Instantiators.find(String.class)); + } +} diff --git a/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java b/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java new file mode 100644 index 000000000..fae151b97 --- /dev/null +++ b/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java @@ -0,0 +1,54 @@ +/* + * 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.mapping; + +import jakarta.annotation.Nonnull; + +/** + * Constructs record instances without reflection. + * + *

The metamodel generators emit an implementation per record type that invokes the record's canonical + * constructor (the primary constructor for Kotlin data classes) directly, registered through + * {@code META-INF/services/st.orm.mapping.Instantiator}. The row mapper dispatches to a registered instantiator + * instead of {@code Constructor.newInstance}, so applications run without reflective construction: no reflection + * configuration for native images, and no {@code opens} clauses for modular applications.

+ * + *

When no instantiator is registered for a type, the row mapper falls back to reflective construction, so + * models compiled without the generators keep working unchanged.

+ * + * @param the record type this instantiator constructs. + * @since 1.13 + */ +public interface Instantiator { + + /** + * Returns the record type this instantiator constructs. + * + * @return the constructed record type. + */ + Class type(); + + /** + * Constructs a new instance from the canonical constructor arguments. + * + *

The arguments are positional and fully adapted: the caller has already performed null checks and type + * conversion, so implementations only cast and invoke the constructor.

+ * + * @param args the canonical constructor arguments, in declaration order. + * @return the constructed instance. + */ + T instantiate(@Nonnull Object[] args); +} diff --git a/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt b/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt index 83ce7f46f..c0c30f84d 100644 --- a/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt +++ b/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt @@ -59,6 +59,12 @@ class MetamodelProcessor( */ private val expandedReferencedTypes = mutableSetOf() + /** + * Fully qualified names of the generated instantiators with their originating files, registered as services + * when processing finishes. + */ + private val generatedInstantiators = mutableListOf>() + companion object { private const val GENERATE_METAMODEL = "st.orm.GenerateMetamodel" private const val DATA = "st.orm.Data" @@ -949,6 +955,7 @@ class MetamodelProcessor( if (processedClasses.add(qualifiedName)) { generateMetamodelClass(classDeclaration, forceNullableChain = false) generateMetamodelClass(classDeclaration, forceNullableChain = true) + generateInstantiator(classDeclaration) } // Only generate interface if this type implements Data. @@ -962,6 +969,80 @@ class MetamodelProcessor( } } + /** + * Generates an `Instantiator` implementation that invokes the data class's primary constructor directly, + * allowing the runtime to construct instances without reflection (no reflection configuration for native + * images, no `opens` clauses for modular applications). + * + * Only top-level, non-generic data classes are supported; other types fall back to reflective construction + * at runtime. + */ + private fun generateInstantiator(classDeclaration: KSClassDeclaration) { + if (!classDeclaration.isDataClass() || + classDeclaration.parentDeclaration != null || + classDeclaration.typeParameters.isNotEmpty() + ) { + return + } + val primaryConstructor = classDeclaration.primaryConstructor ?: return + val packageName = classDeclaration.packageName.asString() + val className = classDeclaration.simpleName.asString() + val instantiatorName = "${className}Instantiator" + val arguments = primaryConstructor.parameters.mapIndexed { index, parameter -> + " args[$index] as ${getKotlinValueTypeName(parameter.type, packageName)}" + }.joinToString(",\n") + val containingFile = classDeclaration.containingFile + val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) + val file = codeGenerator.createNewFile( + dependencies = deps, + packageName = packageName, + fileName = instantiatorName, + ) + OutputStreamWriter(file).use { writer -> + writer.write( + """ + |${if (packageName.isEmpty()) "" else "package $packageName\n"} + |import javax.annotation.processing.Generated + | + |/** + | * Instantiator for $className; constructs instances without reflection. + | */ + |@Generated("st.orm.metamodel.MetamodelProcessor") + |class $instantiatorName : st.orm.mapping.Instantiator<$className> { + | + | override fun type(): Class<$className> = $className::class.java + | + | @Suppress("UNCHECKED_CAST") + | override fun instantiate(args: Array): $className = $className( + |$arguments + | ) + |} + """.trimMargin(), + ) + } + val qualifiedInstantiatorName = if (packageName.isEmpty()) instantiatorName else "$packageName.$instantiatorName" + generatedInstantiators.add(qualifiedInstantiatorName to containingFile) + } + + /** + * Registers the generated instantiators in `META-INF/services/st.orm.mapping.Instantiator`, allowing the + * runtime to discover them through the `ServiceLoader` and construct records without reflection. + */ + override fun finish() { + if (generatedInstantiators.isEmpty()) { + return + } + val originatingFiles = generatedInstantiators.mapNotNull { it.second }.distinct() + val file = codeGenerator.createNewFileByPath( + dependencies = Dependencies(true, *originatingFiles.toTypedArray()), + path = "META-INF/services/st.orm.mapping.Instantiator", + extensionName = "", + ) + OutputStreamWriter(file).use { writer -> + generatedInstantiators.forEach { (name, _) -> writer.write("$name\n") } + } + } + private fun generateMetamodelInterface(classDeclaration: KSClassDeclaration, resolver: Resolver) { val packageName = classDeclaration.packageName.asString() val className = classDeclaration.simpleName.asString() diff --git a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java index 2c871fee2..b99ef4cc9 100644 --- a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java +++ b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java @@ -29,6 +29,7 @@ import jakarta.annotation.Nullable; import java.io.Writer; import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -54,7 +55,9 @@ import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; import javax.lang.model.util.Types; +import javax.tools.FileObject; import javax.tools.JavaFileObject; +import javax.tools.StandardLocation; /** * @since 1.2 @@ -88,6 +91,11 @@ public final class MetamodelProcessor extends AbstractProcessor { */ private final Set expandedReferencedRecords; + /** + * Fully qualified names of the generated instantiators, registered as services when processing is over. + */ + private final Set generatedInstantiators; + private Elements elementUtils; private Types typeUtils; @@ -95,6 +103,7 @@ public MetamodelProcessor() { this.generatedMetamodelClasses = new HashSet<>(); this.generatedMetamodelInterfaces = new HashSet<>(); this.expandedReferencedRecords = new HashSet<>(); + this.generatedInstantiators = new LinkedHashSet<>(); } @Override @@ -271,9 +280,34 @@ && implementsData(element)) { } catch (Exception e) { processingEnv.getMessager().printMessage(ERROR, "Failed to process metamodel. Error: " + e); } + if (roundEnv.processingOver()) { + writeInstantiatorServices(); + } return false; } + /** + * Registers the generated instantiators in {@code META-INF/services/st.orm.mapping.Instantiator}, allowing the + * runtime to discover them through the {@code ServiceLoader} and construct records without reflection. + */ + private void writeInstantiatorServices() { + if (generatedInstantiators.isEmpty()) { + return; + } + try { + FileObject fileObject = processingEnv.getFiler() + .createResource(StandardLocation.CLASS_OUTPUT, "", "META-INF/services/st.orm.mapping.Instantiator"); + try (Writer writer = fileObject.openWriter()) { + for (String instantiator : generatedInstantiators) { + writer.write(instantiator); + writer.write("\n"); + } + } + } catch (Exception e) { + processingEnv.getMessager().printMessage(ERROR, "Failed to write instantiator services file. Error: " + e + "."); + } + } + /** * Walk record-typed fields on this record and ensure metamodels exist for referenced record types. * @@ -318,6 +352,7 @@ private void generateMetamodelArtifacts(@Nonnull Element recordElement) { // Always generate the class once. if (generatedMetamodelClasses.add(qn)) { generateMetamodelClass(recordElement); + generateInstantiator(recordElement); } // Only generate the interface for Data records. @@ -795,6 +830,82 @@ private String buildInterfaceFields(@Nonnull Element recordElement, @Nonnull Str return builder.toString(); } + /** + * Generates an {@code Instantiator} implementation that invokes the record's canonical constructor directly, + * allowing the runtime to construct instances without reflection (no reflection configuration for native + * images, no {@code opens} clauses for modular applications). + * + *

Only top-level, non-generic Java records are supported; other types fall back to reflective + * construction at runtime.

+ */ + private void generateInstantiator(@Nonnull Element recordElement) { + if (recordElement.getKind() != RECORD) { + return; // Kotlin classes are handled by the KSP processor. + } + TypeElement typeElement = asTypeElement(recordElement.asType()); + if (typeElement == null + || typeElement.getEnclosingElement().getKind() != ElementKind.PACKAGE + || !typeElement.getTypeParameters().isEmpty()) { + return; + } + ExecutableElement constructor = findCanonicalConstructor(recordElement); + if (constructor == null) { + return; + } + String packageName = elementUtils.getPackageOf(recordElement).getQualifiedName().toString(); + String recordName = recordElement.getSimpleName().toString(); + String instantiatorName = recordName + "Instantiator"; + var parameters = constructor.getParameters(); + StringBuilder arguments = new StringBuilder(); + for (int i = 0; i < parameters.size(); i++) { + String castType = getBoxedTypeName(parameters.get(i).asType().toString().replaceAll("@\\S+\\s+", "")); + arguments.append(arguments.isEmpty() ? "" : ",\n") + .append(" (").append(castType).append(") args[").append(i).append("]"); + } + try { + JavaFileObject fileObject = processingEnv.getFiler() + .createSourceFile((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName, recordElement); + try (Writer writer = fileObject.openWriter()) { + writer.write(String.format(""" + %simport javax.annotation.processing.Generated; + + /** + * Instantiator for %s; constructs instances without reflection. + */ + @Generated("%s") + public final class %s implements st.orm.mapping.Instantiator<%s> { + + @Override + public Class<%s> type() { + return %s.class; + } + + @Override + @SuppressWarnings("unchecked") + public %s instantiate(Object[] args) { + return new %s( + %s + ); + } + }""", + (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n"), + recordName, + getClass().getName(), + instantiatorName, + recordName, + recordName, + recordName, + recordName, + recordName, + arguments + )); + } + generatedInstantiators.add((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName); + } catch (Exception e) { + processingEnv.getMessager().printMessage(ERROR, "Failed to process " + instantiatorName + ". Error: " + e + "."); + } + } + private void generateMetamodelInterface(@Nonnull Element recordElement) { TypeElement typeElement = asTypeElement(recordElement.asType()); if (typeElement == null) return;