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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 `<Type>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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,16 @@ private static Optional<String> validateRecordGraph(@Nonnull RecordType recordTy
@Nonnull Set<RecordType> 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()) {
Expand All @@ -439,17 +448,22 @@ private static Optional<String> 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<RecordType> 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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ class MetamodelProcessor(
*/
private val expandedReferencedTypes = mutableSetOf<String>()

/**
* Track types we’ve already checked for non-Ref record cycles.
*/
private val checkedForRecordCycles = mutableSetOf<String>()

/**
* 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<String>()

/**
* Track types we’ve already generated a reference metamodel (`<Type>RefMetamodel`) for.
*/
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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()
Expand Down Expand Up @@ -193,6 +202,99 @@ 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<Int>

data class Pet(@PK val id: Int, @FK val owner: Owner) : Entity<Int>
""".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<Int>
""".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<Int>

data class Pet(@PK val id: Int, @FK val owner: Ref<Owner>) : Entity<Int>
""".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 `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<Int>

data class Region(@PK val id: Int, @FK val city: City) : Entity<Int>

data class City(@PK val id: Int, @FK val country: Ref<Country>) : Entity<Int>
""".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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,17 @@ public final class MetamodelProcessor extends AbstractProcessor {
*/
private final Set<String> expandedReferencedRecords;

/**
* Tracks which record types we already checked for non-Ref record cycles.
*/
private final Set<String> 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<String> recordCyclePath;

/**
* Tracks which record types we already generated a reference metamodel class ({@code <Type>RefMetamodel}) for.
*/
Expand Down Expand Up @@ -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<>();
Expand Down Expand Up @@ -381,13 +394,79 @@ 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).
*/
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);
Expand Down
Loading
Loading