From 54997c0755cdef4727bea5e1e8c314946c923a49 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 10 Aug 2026 20:47:02 +0200 Subject: [PATCH 1/3] fix: reject non-Ref foreign key cycles at metamodel generation time Two entities referencing each other with non-Ref @FK fields, or an entity referencing itself, generated metamodels whose constructors build each other's record-typed children eagerly: the first metamodel use died at class initialization with an ExceptionInInitializerError wrapping a StackOverflowError. Both processors now run a memoized depth-first check over non-Ref record fields before generation and report an error naming the cycle and the fix, mirroring the rule the engine states at template level for self-references: a foreign key cycle must cross a Ref boundary to be loadable. A cycle of inline records gets its own diagnostic, since an inline record embeds its columns and a cycle cannot be modeled at all. Each cycle is reported once, not doubled by the nullable-chain variants. Covered by mirrored tests in both processor suites: the mutual cycle and the self-reference are rejected with the cycle named, and a cycle that crosses a Ref boundary still generates as before. --- CHANGELOG.md | 1 + .../st/orm/metamodel/MetamodelProcessor.kt | 62 +++++++++++++ .../orm/metamodel/MetamodelProcessorTest.kt | 86 +++++++++++++++++-- .../st/orm/metamodel/MetamodelProcessor.java | 79 +++++++++++++++++ .../orm/metamodel/MetamodelProcessorTest.java | 56 ++++++++++++ 5 files changed, 277 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1054250e..e0529f09c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ A join widens the query: from the join onward, every clause accepts paths from a - Kotlin's `select { }` block is widened from the start — joined-entity fields use the plain calls with no escalation — and returns the widened builder, so joins made inside the block stay queryable in chained continuations. Record, id and ref matching remain typed to the entity inside the block. - The `st.orm.core.template.impl` and `st.orm.core.repository.impl` packages are exported to Storm's own modules only, stating in the module descriptor that their types were never API. On the class path nothing changes; an application on the module path that reached into them no longer compiles. - `SqlLog` carries the diagnostics API only: summary rendering and hydration-shape analysis live in `SqlLogRenderer`, call-site capture in `CallSiteCapture`, both internal. The display setters moved with them — how summaries render is configured, not programmed: the `storm.sql_log.*` system properties, or the corresponding Spring and Ktor keys. +- Both metamodel processors reject a cycle of non-Ref foreign keys — two entities referencing each other with `@FK` fields, or an entity referencing itself — at compile time, naming the cycle and the fix. Such a cycle used to compile and then fail at first metamodel use with an `ExceptionInInitializerError`: the generated metamodels construct each other eagerly, so a foreign key cycle must cross a `Ref` boundary to be loadable, the same rule the engine states at query time for self-references. - The metamodel processors converge on one contract. The Java annotation processor generates the `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. 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 def76087f..81e116ca8 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 @@ -60,6 +60,17 @@ class MetamodelProcessor( */ private val expandedReferencedTypes = mutableSetOf() + /** + * Track types we’ve already checked for non-Ref record cycles. + */ + private val checkedForRecordCycles = mutableSetOf() + + /** + * Qualified names of the types on the current cycle-check walk, in walk order, so a detected cycle is reported + * by naming its members. + */ + private val recordCyclePath = LinkedHashSet() + /** * Track types we’ve already generated a reference metamodel (`RefMetamodel`) for. */ @@ -1307,8 +1318,59 @@ class MetamodelProcessor( } } + /** + * Rejects cycles in the graph of non-Ref record properties. The generated metamodels construct their + * record-typed children eagerly, so a cycle that does not cross a `Ref` boundary fails at class initialization. + * The engine states the same rule at template level for self-references: a foreign key cycle must be marked as + * `Ref` to be loadable. + */ + private fun checkRecordCycles(classDeclaration: KSClassDeclaration) { + val qualifiedName = classDeclaration.qualifiedName?.asString() ?: return + if (!checkedForRecordCycles.add(qualifiedName)) return + recordCyclePath.add(qualifiedName) + try { + getModelProperties(classDeclaration).forEach { prop -> + val typeRef = prop.type + if (!typeRef.isDataClass() || isRefType(typeRef) || typeRef.isNestedDataClass()) return@forEach + val child = typeRef.resolve().declaration as? KSClassDeclaration ?: return@forEach + val childQualifiedName = child.qualifiedName?.asString() ?: return@forEach + if (childQualifiedName in recordCyclePath) { + val cycle = renderCycle(childQualifiedName) + if (child.implementsInterface(DATA)) { + logger.error( + "Cycle of non-Ref foreign keys: $cycle. " + + "A foreign key cycle must cross a Ref boundary to be loadable. " + + "Mark one of the foreign keys as Ref (for example Ref<${child.simpleName.asString()}>) " + + "to break the cycle.", + prop, + ) + } else { + logger.error( + "Cycle of inline records: $cycle. " + + "An inline record embeds its fields in the enclosing table, so a cycle cannot " + + "be modeled.", + prop, + ) + } + } else { + checkRecordCycles(child) + } + } + } finally { + recordCyclePath.remove(qualifiedName) + } + } + + /** + * Renders the members of the detected cycle: the tail of the current walk from the type the cycle re-enters, + * closed by naming that type again. + */ + private fun renderCycle(cycleStart: String): String = (recordCyclePath.dropWhile { it != cycleStart } + cycleStart) + .joinToString(" -> ") { it.substringAfterLast('.') } + private fun generateMetamodelArtifacts(classDeclaration: KSClassDeclaration, resolver: Resolver) { val qualifiedName = classDeclaration.qualifiedName?.asString() ?: return + checkRecordCycles(classDeclaration) // Always generate classes (both variants) once. if (processedClasses.add(qualifiedName)) { diff --git a/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt b/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt index 36c9afff6..05b7f4905 100644 --- a/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt +++ b/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt @@ -34,19 +34,28 @@ import org.junit.jupiter.api.Test @OptIn(ExperimentalCompilerApi::class) class MetamodelProcessorTest { + private fun compilation(source: String): KotlinCompilation = KotlinCompilation().apply { + sources = listOf(SourceFile.kotlin("CityStats.kt", source)) + useKsp2() + symbolProcessorProviders = mutableListOf(MetamodelProcessorProvider()) + inheritClassPath = true + verbose = false + } + private fun compile(source: String): KotlinCompilation { - val compilation = KotlinCompilation().apply { - sources = listOf(SourceFile.kotlin("CityStats.kt", source)) - useKsp2() - symbolProcessorProviders = mutableListOf(MetamodelProcessorProvider()) - inheritClassPath = true - verbose = false - } + val compilation = compilation(source) val result = compilation.compile() assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode, result.messages) return compilation } + private fun compileExpectingError(source: String): String { + val compilation = compilation(source) + val result = compilation.compile() + assertEquals(KotlinCompilation.ExitCode.COMPILATION_ERROR, result.exitCode, result.messages) + return result.messages + } + private fun KotlinCompilation.generatedFileNames(): Set = kspSourcesDir.walkTopDown().filter { it.isFile }.map { it.name }.toSet() private fun KotlinCompilation.generatedSource(name: String): String = kspSourcesDir.walkTopDown().first { it.isFile && it.name == name }.readText() @@ -193,6 +202,69 @@ class MetamodelProcessorTest { } } + @Test + fun `rejects non-Ref foreign key cycle between entities`() { + val messages = compileExpectingError( + """ + package com.example + + import st.orm.Entity + import st.orm.FK + import st.orm.PK + + data class Owner(@PK val id: Int, @FK val pet: Pet) : Entity + + data class Pet(@PK val id: Int, @FK val owner: Owner) : Entity + """.trimIndent(), + ) + assertTrue( + "Cycle of non-Ref foreign keys: Owner -> Pet -> Owner" in messages || + "Cycle of non-Ref foreign keys: Pet -> Owner -> Pet" in messages, + ) { messages } + assertTrue("Mark one of the foreign keys as Ref" in messages) { messages } + } + + @Test + fun `rejects self-referencing non-Ref foreign key`() { + val messages = compileExpectingError( + """ + package com.example + + import st.orm.Entity + import st.orm.FK + import st.orm.PK + + data class Employee(@PK val id: Int, @FK val manager: Employee) : Entity + """.trimIndent(), + ) + assertTrue("Cycle of non-Ref foreign keys: Employee -> Employee" in messages) { messages } + } + + @Test + fun `accepts foreign key cycle through a Ref boundary`() { + val compilation = compile( + """ + package com.example + + import st.orm.Entity + import st.orm.FK + import st.orm.PK + import st.orm.Ref + + data class Owner(@PK val id: Int, @FK val pet: Pet) : Entity + + data class Pet(@PK val id: Int, @FK val owner: Ref) : Entity + """.trimIndent(), + ) + val generated = compilation.generatedFileNames() + assertTrue("OwnerMetamodel.kt" in generated) { + "a cycle through a Ref boundary is loadable and generates as usual, generated: $generated" + } + assertTrue("PetMetamodel.kt" in generated) { + "a cycle through a Ref boundary is loadable and generates as usual, generated: $generated" + } + } + @Test fun `ignores plain data class without annotation`() { val compilation = compile( 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 691db19a3..74c6f01da 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 @@ -95,6 +95,17 @@ public final class MetamodelProcessor extends AbstractProcessor { */ private final Set expandedReferencedRecords; + /** + * Tracks which record types we already checked for non-Ref record cycles. + */ + private final Set checkedForRecordCycles; + + /** + * Qualified names of the record types on the current cycle-check walk, in walk order, so a detected cycle is + * reported by naming its members. + */ + private final Set recordCyclePath; + /** * Tracks which record types we already generated a reference metamodel class ({@code RefMetamodel}) for. */ @@ -125,6 +136,8 @@ public MetamodelProcessor() { this.generatedMetamodelClasses = new HashSet<>(); this.generatedMetamodelInterfaces = new HashSet<>(); this.expandedReferencedRecords = new HashSet<>(); + this.checkedForRecordCycles = new HashSet<>(); + this.recordCyclePath = new LinkedHashSet<>(); this.generatedReferenceMetamodels = new HashSet<>(); this.generatedNavigableMetamodels = new HashSet<>(); this.navPath = new HashSet<>(); @@ -381,6 +394,71 @@ private void generateReferencedRecordMetamodels(@Nonnull Element recordElement) } } + /** + * Rejects cycles in the graph of non-Ref record fields. The generated metamodels construct their record-typed + * children eagerly, so a cycle that does not cross a {@code Ref} boundary fails at class initialization. The + * engine states the same rule at template level for self-references: a foreign key cycle must be marked as + * {@code Ref} to be loadable. + */ + private void checkRecordCycles(@Nonnull TypeElement recordElement) { + String qualifiedName = recordElement.getQualifiedName().toString(); + if (!checkedForRecordCycles.add(qualifiedName)) return; + recordCyclePath.add(qualifiedName); + try { + for (Element enclosed : recordElement.getEnclosedElements()) { + if (getRecordComponentType(enclosed).isEmpty()) continue; + String fieldName = enclosed.getSimpleName().toString(); + TypeMirror fieldType = getTypeElement(recordElement, fieldName); + if (fieldType == null) continue; + if (!isRecord(fieldType) || isRefType(fieldType) || isNestedRecord(fieldType)) continue; + TypeElement child = asTypeElement(fieldType); + if (child == null) continue; + String childQualifiedName = child.getQualifiedName().toString(); + if (recordCyclePath.contains(childQualifiedName)) { + String cycle = renderCycle(childQualifiedName); + if (implementsData(child)) { + processingEnv.getMessager().printMessage(ERROR, + "Cycle of non-Ref foreign keys: " + cycle + ". " + + "A foreign key cycle must cross a Ref boundary to be loadable. " + + "Mark one of the foreign keys as Ref (for example Ref<" + child.getSimpleName() + + ">) to break the cycle.", + enclosed); + } else { + processingEnv.getMessager().printMessage(ERROR, + "Cycle of inline records: " + cycle + ". " + + "An inline record embeds its fields in the enclosing table, so a cycle cannot " + + "be modeled.", + enclosed); + } + } else { + checkRecordCycles(child); + } + } + } finally { + recordCyclePath.remove(qualifiedName); + } + } + + /** + * Renders the members of the detected cycle: the tail of the current walk from the type the cycle re-enters, + * closed by naming that type again. + */ + private String renderCycle(@Nonnull String cycleStart) { + StringBuilder cycle = new StringBuilder(); + boolean inCycle = false; + for (String qualifiedName : recordCyclePath) { + inCycle = inCycle || qualifiedName.equals(cycleStart); + if (inCycle) { + cycle.append(simpleNameOf(qualifiedName)).append(" -> "); + } + } + return cycle.append(simpleNameOf(cycleStart)).toString(); + } + + private static String simpleNameOf(@Nonnull String qualifiedName) { + return qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1); + } + /** * Generates the metamodel class for all records. * Generates the metamodel interface only if the record implements Data (directly or indirectly). @@ -388,6 +466,7 @@ private void generateReferencedRecordMetamodels(@Nonnull Element recordElement) private void generateMetamodelArtifacts(@Nonnull Element recordElement) { TypeElement typeElement = asTypeElement(recordElement.asType()); if (typeElement == null) return; + checkRecordCycles(typeElement); String qn = typeElement.getQualifiedName().toString(); boolean isData = implementsData(recordElement); diff --git a/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java b/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java index 7c6dd931b..bc703e7cf 100644 --- a/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java +++ b/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java @@ -201,6 +201,62 @@ void registersProcessorsForGradleIncrementalProcessing() throws IOException { assertTrue(content.contains(TypeIndexProcessor.class.getName() + ",aggregating"), content); } + @Test + void rejectsNonRefForeignKeyCycleBetweenEntities() throws Exception { + Compilation compilation = compile("Owner.java", """ + import st.orm.Entity; + import st.orm.FK; + import st.orm.PK; + + public record Owner(@PK Integer id, @FK Pet pet) implements Entity {} + + record Pet(@PK Integer id, @FK Owner owner) implements Entity {} + """); + assertFalse(compilation.success(), + "the generated metamodels would construct each other until the stack overflows, so the cycle " + + "must be rejected at generation time"); + assertTrue(compilation.errors().contains("Cycle of non-Ref foreign keys: Owner -> Pet -> Owner") + || compilation.errors().contains("Cycle of non-Ref foreign keys: Pet -> Owner -> Pet"), + compilation.errors()); + assertTrue(compilation.errors().contains("Mark one of the foreign keys as Ref"), compilation.errors()); + assertEquals(1, compilation.errors().split("Cycle of non-Ref foreign keys", -1).length - 1, + "the cycle must be reported once:\n" + compilation.errors()); + } + + @Test + void rejectsSelfReferencingNonRefForeignKey() throws Exception { + Compilation compilation = compile("Employee.java", """ + import st.orm.Entity; + import st.orm.FK; + import st.orm.PK; + + public record Employee(@PK Integer id, @FK Employee manager) implements Entity {} + """); + assertFalse(compilation.success(), + "a self-referencing non-Ref foreign key must be rejected at generation time"); + assertTrue(compilation.errors().contains("Cycle of non-Ref foreign keys: Employee -> Employee"), + compilation.errors()); + } + + @Test + void acceptsForeignKeyCycleThroughRefBoundary() throws Exception { + Compilation compilation = compile("Owner.java", """ + import st.orm.Entity; + import st.orm.FK; + import st.orm.PK; + import st.orm.Ref; + + public record Owner(@PK Integer id, @FK Pet pet) implements Entity {} + + record Pet(@PK Integer id, @FK Ref owner) implements Entity {} + """); + assertTrue(compilation.success(), compilation.errors()); + assertTrue(compilation.generated("OwnerMetamodel.java"), + "a cycle through a Ref boundary is loadable and generates as usual"); + assertTrue(compilation.generated("PetMetamodel.java"), + "a cycle through a Ref boundary is loadable and generates as usual"); + } + @Test void ignoresPlainRecordWithoutAnnotation() throws Exception { Compilation compilation = compile("CityStats.java", """ From 5b7dffce4740d734351b8de3f2c68d03d30b5f41 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 10 Aug 2026 20:51:00 +0200 Subject: [PATCH 2/3] test: cover deep foreign key chains closed by a Ref boundary A three-entity chain whose cycle closes through a Ref generates as usual in both processors, including the reference metamodel at the boundary and the navigation metamodels beyond it: the cycle check skips Ref edges, so only cycles that cannot be loaded are rejected. --- .../orm/metamodel/MetamodelProcessorTest.kt | 30 +++++++++++++++++++ .../orm/metamodel/MetamodelProcessorTest.java | 23 ++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt b/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt index 05b7f4905..87954460c 100644 --- a/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt +++ b/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt @@ -265,6 +265,36 @@ class MetamodelProcessorTest { } } + @Test + fun `accepts deep foreign key chain closed by a Ref boundary`() { + val compilation = compile( + """ + package com.example + + import st.orm.Entity + import st.orm.FK + import st.orm.PK + import st.orm.Ref + + data class Country(@PK val id: Int, @FK val region: Region) : Entity + + data class Region(@PK val id: Int, @FK val city: City) : Entity + + data class City(@PK val id: Int, @FK val country: Ref) : Entity + """.trimIndent(), + ) + val generated = compilation.generatedFileNames() + assertTrue("CityMetamodel.kt" in generated) { + "the chain closed by a Ref at depth three generates as usual, generated: $generated" + } + assertTrue("CountryRefMetamodel.kt" in generated) { + "the Ref boundary generates a reference metamodel, generated: $generated" + } + assertTrue("NavigableRegionMetamodel.kt" in generated) { + "navigation beyond the Ref boundary reaches the deeper graph, generated: $generated" + } + } + @Test fun `ignores plain data class without annotation`() { val compilation = compile( diff --git a/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java b/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java index bc703e7cf..8cd3857a6 100644 --- a/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java +++ b/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java @@ -257,6 +257,29 @@ record Pet(@PK Integer id, @FK Ref owner) implements Entity {} "a cycle through a Ref boundary is loadable and generates as usual"); } + @Test + void acceptsDeepForeignKeyChainClosedByRefBoundary() throws Exception { + Compilation compilation = compile("Country.java", """ + import st.orm.Entity; + import st.orm.FK; + import st.orm.PK; + import st.orm.Ref; + + public record Country(@PK Integer id, @FK Region region) implements Entity {} + + record Region(@PK Integer id, @FK City city) implements Entity {} + + record City(@PK Integer id, @FK Ref country) implements Entity {} + """); + assertTrue(compilation.success(), compilation.errors()); + assertTrue(compilation.generated("CityMetamodel.java"), + "the chain closed by a Ref at depth three generates as usual"); + assertTrue(compilation.generated("CountryRefMetamodel.java"), + "the Ref boundary generates a reference metamodel"); + assertTrue(compilation.generated("NavigableRegionMetamodel.java"), + "navigation beyond the Ref boundary reaches the deeper graph"); + } + @Test void ignoresPlainRecordWithoutAnnotation() throws Exception { Compilation compilation = compile("CityStats.java", """ From 2d58b3e3235d2aae2c9e340a0cc50917945490aa Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Mon, 10 Aug 2026 21:04:06 +0200 Subject: [PATCH 3/3] fix: report the Ref fix when runtime validation detects a foreign key cycle The record graph validation named the cycle but not the remedy, while the self-reference check in TemplatePreparation and the processor diagnostics both state that a foreign key cycle must cross a Ref boundary. The cycle message now matches the processors: it renders the cycle members from the type the cycle re-enters and names the fix, with the inline-record variant for cycles of non-Data records. --- CHANGELOG.md | 2 +- .../core/template/impl/RecordValidation.java | 22 +++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0529f09c..355684b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,7 +37,7 @@ A join widens the query: from the join onward, every clause accepts paths from a - Kotlin's `select { }` block is widened from the start — joined-entity fields use the plain calls with no escalation — and returns the widened builder, so joins made inside the block stay queryable in chained continuations. Record, id and ref matching remain typed to the entity inside the block. - The `st.orm.core.template.impl` and `st.orm.core.repository.impl` packages are exported to Storm's own modules only, stating in the module descriptor that their types were never API. On the class path nothing changes; an application on the module path that reached into them no longer compiles. - `SqlLog` carries the diagnostics API only: summary rendering and hydration-shape analysis live in `SqlLogRenderer`, call-site capture in `CallSiteCapture`, both internal. The display setters moved with them — how summaries render is configured, not programmed: the `storm.sql_log.*` system properties, or the corresponding Spring and Ktor keys. -- Both metamodel processors reject a cycle of non-Ref foreign keys — two entities referencing each other with `@FK` fields, or an entity referencing itself — at compile time, naming the cycle and the fix. Such a cycle used to compile and then fail at first metamodel use with an `ExceptionInInitializerError`: the generated metamodels construct each other eagerly, so a foreign key cycle must cross a `Ref` boundary to be loadable, the same rule the engine states at query time for self-references. +- Both metamodel processors reject a cycle of non-Ref foreign keys — two entities referencing each other with `@FK` fields, or an entity referencing itself — at compile time, naming the cycle and the fix. Such a cycle used to compile and then fail at first metamodel use with an `ExceptionInInitializerError`: the generated metamodels construct each other eagerly, so a foreign key cycle must cross a `Ref` boundary to be loadable, the same rule the engine states at query time for self-references. The runtime record validation, which catches such a cycle at first model use when the processors are not attached, reports the same message — the cycle's members and the `Ref` fix — instead of a bare "Cyclic dependency detected". - The metamodel processors converge on one contract. The Java annotation processor generates the `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. diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java index bf19b73c6..e73275854 100644 --- a/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java +++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordValidation.java @@ -418,7 +418,16 @@ private static Optional validateRecordGraph(@Nonnull RecordType recordTy @Nonnull Set currentPath) { // Check if the current record type is already in the path (cycle detected). if (currentPath.contains(recordType)) { - return Optional.of("Cyclic dependency detected: %s.".formatted(buildCyclePath(recordType, currentPath))); + String cycle = buildCyclePath(recordType, currentPath); + if (Data.class.isAssignableFrom(recordType.type())) { + return Optional.of(("Cycle of non-Ref foreign keys: %s. " + + "A foreign key cycle must cross a Ref boundary to be loadable. " + + "Mark one of the foreign keys as Ref (for example Ref<%s>) to break the cycle.") + .formatted(cycle, recordType.type().getSimpleName())); + } + return Optional.of(("Cycle of inline records: %s. " + + "An inline record embeds its fields in the enclosing table, so a cycle cannot be modeled.") + .formatted(cycle)); } currentPath.add(recordType); for (RecordField field : recordType.fields()) { @@ -439,17 +448,22 @@ private static Optional validateRecordGraph(@Nonnull RecordType recordTy } /** - * Builds a string representation of the cycle path for error messaging. + * Builds a string representation of the cycle for error messaging: the tail of the traversal path from the type + * the cycle re-enters, closed by naming that type again. * * @param currentType the record type where the cycle was detected. * @param path the current traversal path leading up to the cycle. - * @return a string describing the cycle path. + * @return a string describing the cycle. */ private static String buildCyclePath(@Nonnull RecordType currentType, @Nonnull Set path) { StringBuilder cyclePath = new StringBuilder(); + boolean inCycle = false; for (RecordType type : path) { - cyclePath.append(type.type().getSimpleName()).append(" -> "); + inCycle = inCycle || type.equals(currentType); + if (inCycle) { + cyclePath.append(type.type().getSimpleName()).append(" -> "); + } } cyclePath.append(currentType.type().getSimpleName()); return cyclePath.toString();