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
1 change: 1 addition & 0 deletions storm-core/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
78 changes: 78 additions & 0 deletions storm-core/src/main/java/st/orm/core/spi/Instantiators.java
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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.</p>
*/
public final class Instantiators {

/** Instantiators per class loader, keyed by the record type they construct. */
private static final ConcurrentMap<ClassLoader, Map<Class<?>, 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 <T> the record type.
* @return the registered instantiator, or {@code null} if the type has no generated instantiator.
*/
@Nullable
@SuppressWarnings("unchecked")
public static <T> Instantiator<T> find(@Nonnull Class<T> type) {
ClassLoader classLoader = ofNullable(currentThread().getContextClassLoader())
.orElseGet(() -> Instantiators.class.getClassLoader());
return (Instantiator<T>) INSTANTIATOR_CACHE
.computeIfAbsent(classLoader, Instantiators::loadInstantiators)
.get(type);
}

private static Map<Class<?>, Instantiator<?>> loadInstantiators(@Nonnull ClassLoader classLoader) {
Map<Class<?>, 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,25 @@
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;
import java.util.concurrent.ConcurrentHashMap;
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.
Expand Down Expand Up @@ -177,6 +181,12 @@ static <T> T construct(@Nonnull Constructor<T> 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.
Expand All @@ -199,11 +209,13 @@ static <T> T construct(@Nonnull Constructor<T> 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<Constructor<?>, ConstructorMeta> CONSTRUCTOR_META = new ConcurrentHashMap<>();
Expand All @@ -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.
*
* <p>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.</p>
*/
@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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
54 changes: 54 additions & 0 deletions storm-foundation/src/main/java/st/orm/mapping/Instantiator.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.</p>
*
* <p>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.</p>
*
* @param <T> the record type this instantiator constructs.
* @since 1.13
*/
public interface Instantiator<T> {

/**
* Returns the record type this instantiator constructs.
*
* @return the constructed record type.
*/
Class<T> type();

/**
* Constructs a new instance from the canonical constructor arguments.
*
* <p>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.</p>
*
* @param args the canonical constructor arguments, in declaration order.
* @return the constructed instance.
*/
T instantiate(@Nonnull Object[] args);
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ class MetamodelProcessor(
*/
private val expandedReferencedTypes = mutableSetOf<String>()

/**
* Fully qualified names of the generated instantiators with their originating files, registered as services
* when processing finishes.
*/
private val generatedInstantiators = mutableListOf<Pair<String, KSFile?>>()

companion object {
private const val GENERATE_METAMODEL = "st.orm.GenerateMetamodel"
private const val DATA = "st.orm.Data"
Expand Down Expand Up @@ -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.
Expand All @@ -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<Any?>): $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()
Expand Down
Loading
Loading