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;