From 6258066888742b8fd8be91c69d2f2077b734f949 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 20 Jul 2026 21:45:48 +0200 Subject: [PATCH] feat: generate record deconstructors alongside instantiators The metamodel generators emit an Instantiator per record type so construction runs as generated code, but reading a record's components back out still went through reflective accessors. Both generators now also emit a deconstructor that reads the components directly, and write-set rebuilds (key propagation and generated-key assignment) run the full deconstruct-adjust-instantiate round trip as generated code. Instantiator gains a deconstruct method whose default returns null, signalling that no generated deconstructor is available; callers fall back to reflective component access, so instantiators generated before this change and types without generated support keep working unchanged. Component reads as generated code need no accessor registration for native images and start at full speed in short-lived processes; warmed-JIT throughput is unchanged. Closes #295 --- .../core/repository/impl/WriteSetImpl.java | 26 +++++++++++++++---- .../java/st/orm/mapping/Instantiator.java | 21 +++++++++++++++ .../st/orm/metamodel/MetamodelProcessor.kt | 6 +++++ .../st/orm/metamodel/MetamodelProcessor.java | 16 ++++++++++-- 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java index 0857d4ba4..2904c2f8b 100644 --- a/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java +++ b/storm-core/src/main/java/st/orm/core/repository/impl/WriteSetImpl.java @@ -564,6 +564,26 @@ private record RebuildType(@Nonnull RecordType recordType, @Nullable Instantiato Object newInstance(@Nonnull Object[] args) { return instantiator != null ? instantiator.instantiate(args) : recordType.newInstance(args); } + + /** + * Reads the record's component values in declaration order, through the generated deconstructor when the + * metamodel registered one, so rebuilds run as generated code on both sides of the round trip. + */ + @SuppressWarnings("unchecked") + Object[] deconstruct(@Nonnull Object record) { + if (instantiator != null) { + Object[] args = ((Instantiator) instantiator).deconstruct(record); + if (args != null) { + return args; + } + } + List fields = recordType.fields(); + Object[] args = new Object[fields.size()]; + for (int i = 0; i < fields.size(); i++) { + args[i] = REFLECTION.invoke(fields.get(i), record); + } + return args; + } } private static final ClassValue REBUILD_TYPES = new ClassValue<>() { @@ -584,11 +604,7 @@ protected RebuildType computeValue(Class type) { */ private Object withComponent(@Nonnull Object record, @Nonnull int[] path, int depth, @Nullable Object newValue) { RebuildType rebuildType = REBUILD_TYPES.get(record.getClass()); - List fields = rebuildType.recordType().fields(); - Object[] args = new Object[fields.size()]; - for (int i = 0; i < fields.size(); i++) { - args[i] = REFLECTION.invoke(fields.get(i), record); - } + Object[] args = rebuildType.deconstruct(record); int index = path[depth]; args[index] = depth == path.length - 1 ? newValue diff --git a/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java b/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java index fae151b97..7a83047e1 100644 --- a/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java +++ b/storm-foundation/src/main/java/st/orm/mapping/Instantiator.java @@ -16,6 +16,7 @@ package st.orm.mapping; import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; /** * Constructs record instances without reflection. @@ -51,4 +52,24 @@ public interface Instantiator { * @return the constructed instance. */ T instantiate(@Nonnull Object[] args); + + /** + * Deconstructs the given instance into its canonical constructor arguments, in declaration order. + * + *

Generated instantiators override this to read the components directly, completing the reflection-free + * round trip for record rebuilds: component reads run as generated code, matching {@link #instantiate} on the + * construction side. The returned array is freshly allocated; callers may modify it and pass it to + * {@link #instantiate} to build an adjusted copy of the instance.

+ * + *

The default returns {@code null}, signalling that no generated deconstructor is available; callers fall + * back to reflective component access.

+ * + * @param instance the instance to deconstruct. + * @return the component values in declaration order, or {@code null} when not supported. + * @since 1.13 + */ + @Nullable + default Object[] deconstruct(@Nonnull T instance) { + return null; + } } 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 c0c30f84d..779253db3 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 @@ -991,6 +991,8 @@ class MetamodelProcessor( val arguments = primaryConstructor.parameters.mapIndexed { index, parameter -> " args[$index] as ${getKotlinValueTypeName(parameter.type, packageName)}" }.joinToString(",\n") + val componentNames = primaryConstructor.parameters.map { it.name?.asString() ?: return } + val components = componentNames.joinToString(",\n") { " instance.`$it`" } val containingFile = classDeclaration.containingFile val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) val file = codeGenerator.createNewFile( @@ -1016,6 +1018,10 @@ class MetamodelProcessor( | override fun instantiate(args: Array): $className = $className( |$arguments | ) + | + | override fun deconstruct(instance: $className): Array = arrayOf( + |$components + | ) |} """.trimMargin(), ) 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 f9456e43f..a6e73fed9 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 @@ -906,10 +906,13 @@ private void generateInstantiator(@Nonnull Element recordElement) { String instantiatorName = recordName + "Instantiator"; var parameters = constructor.getParameters(); StringBuilder arguments = new StringBuilder(); + StringBuilder components = 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("]"); + components.append(components.isEmpty() ? "" : ",\n") + .append(" instance.").append(parameters.get(i).getSimpleName()).append("()"); } try { JavaFileObject fileObject = processingEnv.getFiler() @@ -919,7 +922,7 @@ private void generateInstantiator(@Nonnull Element recordElement) { %simport javax.annotation.processing.Generated; /** - * Instantiator for %s; constructs instances without reflection. + * Instantiator for %s; constructs and deconstructs instances without reflection. */ @Generated("%s") public final class %s implements st.orm.mapping.Instantiator<%s> { @@ -936,6 +939,13 @@ public final class %s implements st.orm.mapping.Instantiator<%s> { %s ); } + + @Override + public Object[] deconstruct(%s instance) { + return new Object[] { + %s + }; + } }""", (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n"), recordName, @@ -946,7 +956,9 @@ public final class %s implements st.orm.mapping.Instantiator<%s> { recordName, recordName, recordName, - arguments + arguments, + recordName, + components )); } generatedInstantiators.add((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName);