diff --git a/CHANGELOG.md b/CHANGELOG.md index ac4c07b74..e1054250e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,8 @@ 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. +- 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. ## [1.13.1] - 2026-08-07 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 6eb722cc3..def76087f 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 @@ -17,6 +17,7 @@ package st.orm.metamodel import com.google.devtools.ksp.getAllSuperTypes import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.isAbstract import com.google.devtools.ksp.processing.* import com.google.devtools.ksp.symbol.* import com.google.devtools.ksp.validate @@ -155,6 +156,27 @@ class MetamodelProcessor( "kotlin.UIntArray", "kotlin.ULongArray", ) + + /** + * Kotlin hard keywords. A property so named is declared with backticks and must be emitted with backticks + * wherever it appears as an identifier; path and field string literals keep the raw name. + */ + private val KOTLIN_HARD_KEYWORDS: Set = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", + "true", "try", "typealias", "typeof", "val", "var", "when", "while", + ) + + /** + * Renders a property name as a Kotlin identifier, backticked when the raw name would not parse as one. + */ + private fun escaped(name: String): String { + val identifier = name.isNotEmpty() && + name.first().isJavaIdentifierStart() && + name.all { it.isJavaIdentifierPart() } && + '$' !in name + return if (name in KOTLIN_HARD_KEYWORDS || !identifier) "`$name`" else name + } } override fun process(resolver: Resolver): List { @@ -223,13 +245,16 @@ class MetamodelProcessor( /** * Returns the properties to include in the metamodel for the given class declaration. - * For sealed interfaces, only declared properties are included. - * For data classes, all properties (including inherited) are included. + * For sealed interfaces, only abstract declared properties are included: a defaulted property has no column. + * For data classes, the primary constructor defines the components: a body-declared or inherited property has + * no column, so it carries no metamodel field. */ private fun getModelProperties(classDeclaration: KSClassDeclaration): Sequence = if (isSealedInterface(classDeclaration)) { - classDeclaration.getDeclaredProperties() + classDeclaration.getDeclaredProperties().filter { it.isAbstract() } } else { - classDeclaration.getAllProperties() + val properties = classDeclaration.getAllProperties().associateBy { it.simpleName.asString() } + classDeclaration.primaryConstructor?.parameters.orEmpty().asSequence() + .mapNotNull { parameter -> parameter.name?.asString()?.let { properties[it] } } } private fun KSClassDeclaration.implementsInterface(interfaceName: String): Boolean = try { @@ -641,7 +666,7 @@ class MetamodelProcessor( * Recursively walks inline sub-records. Used to determine if a compound key has nullable constituents. */ private fun hasNullableLeaf(classDeclaration: KSClassDeclaration): Boolean { - classDeclaration.getAllProperties().forEach { prop -> + getModelProperties(classDeclaration).forEach { prop -> val typeRef = prop.type if (typeRef.isDataClass()) { if (typeRef.isNestedDataClass()) return@forEach @@ -778,6 +803,7 @@ class MetamodelProcessor( val modelRef = "${className}Metamodel.instance<$className>()" getModelProperties(classDeclaration).forEach { prop -> val fieldName = prop.simpleName.asString() + val fieldRef = escaped(fieldName) val typeRef = prop.type val propNullable = typeRef.resolve().isMarkedNullable if (typeRef.isDataClass()) { @@ -791,16 +817,16 @@ class MetamodelProcessor( val childMetaType = "$childMetaClass<$className>" builder.append(" /** Represents the $className.$fieldName record. */\n") builder.append( - " val $fieldName: $childMetaType = " + - "$modelRef.$fieldName\n", + " val $fieldRef: $childMetaType = " + + "$modelRef.$fieldRef\n", ) } else if (isRefType(typeRef)) { val simpleTypeName = getSimpleTypeName(typeRef, packageName) val refMetaClassName = "${simpleTypeName}RefMetamodel" builder.append(" /** Represents the $className.$fieldName reference. */\n") builder.append( - " val $fieldName: $refMetaClassName<$className> = " + - "$modelRef.$fieldName\n", + " val $fieldRef: $refMetaClassName<$className> = " + + "$modelRef.$fieldRef\n", ) } else { val override = metamodelTypeOverride(prop) @@ -811,8 +837,8 @@ class MetamodelProcessor( val baseClass = if (unique) "AbstractKeyMetamodel" else "AbstractMetamodel" builder.append(" /** Represents the $className.$fieldName field. */\n") builder.append( - " val $fieldName: $baseClass<$className, $kotlinTypeName, $valueKotlinTypeName> = " + - "$modelRef.$fieldName\n", + " val $fieldRef: $baseClass<$className, $kotlinTypeName, $valueKotlinTypeName> = " + + "$modelRef.$fieldRef\n", ) } } @@ -828,7 +854,7 @@ class MetamodelProcessor( ): String { val builder = StringBuilder() getModelProperties(classDeclaration).forEach { prop -> - val fieldName = prop.simpleName.asString() + val fieldRef = escaped(prop.simpleName.asString()) val typeRef = prop.type val propNullable = typeRef.resolve().isMarkedNullable if (typeRef.isDataClass()) { @@ -836,10 +862,10 @@ class MetamodelProcessor( val simpleTypeName = getSimpleTypeName(typeRef, packageName) val childForceNullable = forceNullableChain || propNullable val childType = if (childForceNullable) "${simpleTypeName}NullableMetamodel" else "${simpleTypeName}Metamodel" - builder.append(" val $fieldName: $childType\n") + builder.append(" val $fieldRef: $childType\n") } else if (isRefType(typeRef)) { val simpleTypeName = getSimpleTypeName(typeRef, packageName) - builder.append(" val $fieldName: ${simpleTypeName}RefMetamodel\n") + builder.append(" val $fieldRef: ${simpleTypeName}RefMetamodel\n") } else { val override = metamodelTypeOverride(prop) val kotlinTypeName = override?.let { kotlinTypeNameOf(it, packageName) } @@ -849,7 +875,7 @@ class MetamodelProcessor( val unique = isEffectivelyUniqueField(prop) val isData = classDeclaration.implementsInterface(DATA) val baseClass = if (!isData || unique) "AbstractKeyMetamodel" else "AbstractMetamodel" - builder.append(" val $fieldName: $baseClass\n") + builder.append(" val $fieldRef: $baseClass\n") } } return builder.toString() @@ -865,6 +891,7 @@ class MetamodelProcessor( getModelProperties(classDeclaration).forEach { prop -> val fieldName = prop.simpleName.asString() + val fieldRef = escaped(fieldName) val typeRef = prop.type val propNullable = typeRef.resolve().isMarkedNullable @@ -873,8 +900,9 @@ class MetamodelProcessor( val simpleTypeName = getSimpleTypeName(typeRef, packageName) val isChildData = isDataType(prop) - // Validate: @PK, @FK, and @UK are not supported on inline record fields. - if (!classDeclaration.implementsInterface(DATA)) { + // Validate: @PK, @FK, and @UK are not supported on inline record fields. Both chain variants walk + // the same properties; only the base pass reports, so a diagnostic prints once. + if (!forceNullableChain && !classDeclaration.implementsInterface(DATA)) { if (hasAnnotationOrMeta(prop, PRIMARY_KEY)) { logger.error( "@PK is not supported on inline record fields. " + @@ -903,14 +931,14 @@ class MetamodelProcessor( if (childForceNullable) "${simpleTypeName}NullableMetamodel" else "${simpleTypeName}Metamodel" val effectiveGetterExpr = if (forceNullableChain) { - "{ t: T -> this@$metaClassName.getValue(t)?.$fieldName }" + "{ t: T -> this@$metaClassName.getValue(t)?.$fieldRef }" } else { - "{ t: T -> this@$metaClassName.getValue(t).$fieldName }" + "{ t: T -> this@$metaClassName.getValue(t).$fieldRef }" } if (!isChildData && isEffectivelyUniqueField(prop)) { val nullsDistinct = getNullsDistinct(prop) val referencedDecl = typeRef.resolve().declaration as? KSClassDeclaration - if (nullsDistinct && referencedDecl != null && hasNullableLeaf(referencedDecl)) { + if (!forceNullableChain && nullsDistinct && referencedDecl != null && hasNullableLeaf(referencedDecl)) { logger.warn( "Unique key field '$fieldName' has nullable constituent fields. " + "Scrolling (scroll/scrollAfter/scrollBefore) will be rejected at runtime. " + @@ -920,21 +948,21 @@ class MetamodelProcessor( ) } builder.append( - " this.$fieldName = $childMetaClassName(" + + " this.$fieldRef = $childMetaClassName(" + "subPath, fieldBase + \"$fieldName\", $inlineFlag, this, " + "$effectiveGetterExpr, $nullsDistinct)\n", ) } else if (!isChildData) { // Inline (non-Data) record: getter must be inside parens (nullable param follows in constructor). builder.append( - " this.$fieldName = $childMetaClassName(" + + " this.$fieldRef = $childMetaClassName(" + "subPath, fieldBase + \"$fieldName\", $inlineFlag, this, " + "$effectiveGetterExpr)\n", ) } else { // Data (FK) record: trailing lambda syntax still works. builder.append( - " this.$fieldName = $childMetaClassName(" + + " this.$fieldRef = $childMetaClassName(" + "subPath, fieldBase + \"$fieldName\", $inlineFlag, this) $effectiveGetterExpr\n", ) } @@ -944,12 +972,12 @@ class MetamodelProcessor( val simpleTypeName = getSimpleTypeName(typeRef, packageName) val refMetaClassName = "${simpleTypeName}RefMetamodel" val refGetterExpr = if (forceNullableChain) { - "{ t: T -> this@$metaClassName.getValue(t)?.$fieldName }" + "{ t: T -> this@$metaClassName.getValue(t)?.$fieldRef }" } else { - "{ t: T -> this@$metaClassName.getValue(t).$fieldName }" + "{ t: T -> this@$metaClassName.getValue(t).$fieldRef }" } builder.append( - " this.$fieldName = $refMetaClassName(" + + " this.$fieldRef = $refMetaClassName(" + "subPath, fieldBase + \"$fieldName\", false, this, $refGetterExpr)\n", ) } else { @@ -965,23 +993,24 @@ class MetamodelProcessor( "val ra = this@$metaClassName.getValue(a)\n" + " val rb = this@$metaClassName.getValue(b)\n" - val leftValue = if (forceNullableChain) "ra?.$fieldName" else "ra.$fieldName" - val rightValue = if (forceNullableChain) "rb?.$fieldName" else "rb.$fieldName" + val leftValue = if (forceNullableChain) "ra?.$fieldRef" else "ra.$fieldRef" + val rightValue = if (forceNullableChain) "rb?.$fieldRef" else "rb.$fieldRef" val isSameExpr = sameExpr(leftValue, rightValue, typeRef, forceNullableChain) val isIdenticalExpr = identicalExpr(leftValue, rightValue, typeRef, forceNullableChain) val getValueBody = if (forceNullableChain) { " override fun getValue(record: T): $v =\n" + - " this@$metaClassName.getValue(record)?.$fieldName\n" + " this@$metaClassName.getValue(record)?.$fieldRef\n" } else { " override fun getValue(record: T): $v =\n" + - " this@$metaClassName.getValue(record).$fieldName\n" + " this@$metaClassName.getValue(record).$fieldRef\n" } val unique = isEffectivelyUniqueField(prop) val isData = classDeclaration.implementsInterface(DATA) - // Validate: @PK, @FK, and @UK are not supported on inline record fields. - if (!isData) { + // Validate: @PK, @FK, and @UK are not supported on inline record fields. Both chain variants walk + // the same properties; only the base pass reports, so a diagnostic prints once. + if (!forceNullableChain && !isData) { if (hasAnnotationOrMeta(prop, PRIMARY_KEY)) { logger.error( "@PK is not supported on inline record fields. " + @@ -1011,7 +1040,7 @@ class MetamodelProcessor( val nullable = isEffectivelyNullable(prop) val nullsDistinct = getNullsDistinct(prop) val result = nullable && nullsDistinct - if (result) { + if (!forceNullableChain && result) { logger.warn( "Unique key field '$fieldName' is nullable. " + "Scrolling (scroll/scrollAfter/scrollBefore) will be rejected at runtime. " + @@ -1031,7 +1060,7 @@ class MetamodelProcessor( "$javaTypeName, subPath, fieldBase + \"$fieldName\", false, this" } builder.append( - " this.$fieldName = object : $baseClass(" + + " this.$fieldRef = object : $baseClass(" + "$constructorArgs" + ") {\n" + getValueBody + @@ -1124,6 +1153,7 @@ class MetamodelProcessor( val className = classDeclaration.simpleName.asString() getModelProperties(classDeclaration).forEach { prop -> val fieldName = prop.simpleName.asString() + val fieldRef = escaped(fieldName) val typeRef = prop.type val record = typeRef.isDataClass() && !isRefType(typeRef) val ref = isRefType(typeRef) @@ -1131,11 +1161,11 @@ class MetamodelProcessor( builder.append(" /** Represents navigation to $className.$fieldName. */\n") if ((record || ref) && !isCyclicNavChild(typeRef)) { val simpleTypeName = getSimpleTypeName(typeRef, packageName) - builder.append(" val $fieldName: ${navClassName(simpleTypeName)}\n") + builder.append(" val $fieldRef: ${navClassName(simpleTypeName)}\n") } else { // Scalar column, or a cyclic navigation edge broken to a leaf. val kotlinTypeName = getKotlinTypeName(typeRef, packageName) - builder.append(" val $fieldName: st.orm.AbstractNavigableMetamodel\n") + builder.append(" val $fieldRef: st.orm.AbstractNavigableMetamodel\n") } } return builder.toString() @@ -1145,6 +1175,7 @@ class MetamodelProcessor( val builder = StringBuilder() getModelProperties(classDeclaration).forEach { prop -> val fieldName = prop.simpleName.asString() + val fieldRef = escaped(fieldName) val typeRef = prop.type val record = typeRef.isDataClass() && !isRefType(typeRef) val ref = isRefType(typeRef) @@ -1153,14 +1184,14 @@ class MetamodelProcessor( val simpleTypeName = getSimpleTypeName(typeRef, packageName) val inlineFlag = if (record && !isDataType(prop)) "true" else "false" builder.append( - " this.$fieldName = ${navClassName(simpleTypeName)}(subPath, fieldBase + \"$fieldName\", $inlineFlag, this)\n", + " this.$fieldRef = ${navClassName(simpleTypeName)}(subPath, fieldBase + \"$fieldName\", $inlineFlag, this)\n", ) } else { // Scalar column, or a cyclic navigation edge broken to a leaf so eager construction terminates. val javaTypeName = getJavaTypeName(typeRef, packageName) val kotlinTypeName = getKotlinTypeName(typeRef, packageName) builder.append( - " this.$fieldName = object : st.orm.AbstractNavigableMetamodel(" + + " this.$fieldRef = object : st.orm.AbstractNavigableMetamodel(" + "$javaTypeName, subPath, fieldBase + \"$fieldName\", false, this) {}\n", ) } @@ -1320,7 +1351,7 @@ class MetamodelProcessor( " 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 components = componentNames.joinToString(",\n") { " instance.${escaped(it)}" } val containingFile = classDeclaration.containingFile val deps = if (containingFile != null) Dependencies(true, containingFile) else Dependencies(false) val file = codeGenerator.createNewFile( @@ -1449,16 +1480,16 @@ class MetamodelProcessor( builder.append(" return listOf(") fieldNames.forEachIndexed { index, name -> if (index > 0) builder.append(", ") - builder.append("this.$name") + builder.append("this.${escaped(name)}") } builder.append(")\n") } else { builder.append(" return buildList {\n") fieldNames.forEachIndexed { index, name -> if (fieldIsInline[index]) { - builder.append(" addAll(this@$metaClassName.$name.flatten())\n") + builder.append(" addAll(this@$metaClassName.${escaped(name)}.flatten())\n") } else { - builder.append(" add(this@$metaClassName.$name)\n") + builder.append(" add(this@$metaClassName.${escaped(name)})\n") } } builder.append(" }\n") @@ -1478,7 +1509,7 @@ class MetamodelProcessor( val abstractVType = recordValueType val pkProp = findPrimaryKeyProperty(classDeclaration) val isSameMethod = if (pkProp != null) { - val pkName = pkProp.simpleName.asString() + val pkName = escaped(pkProp.simpleName.asString()) if (forceNullableChain) { """ | override fun isSame(a: T, b: T): Boolean { 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 0ed5a3ac1..36c9afff6 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 @@ -49,6 +49,8 @@ class MetamodelProcessorTest { 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() + @Test fun `generates metamodel for annotated plain data class`() { val compilation = compile( @@ -80,6 +82,117 @@ class MetamodelProcessorTest { } } + @Test + fun `sources components from the primary constructor only`() { + val compilation = compile( + """ + package com.example + + import st.orm.GenerateMetamodel + + interface Labeled { + val label: String get() = "label" + } + + @GenerateMetamodel + data class CityStats(val name: String, val inhabitants: Int) : Labeled { + val density: Int get() = inhabitants / 2 + } + """.trimIndent(), + ) + val metamodel = compilation.generatedSource("CityStatsMetamodel.kt") + assertTrue("val name" in metamodel) { "expected the constructor component in the metamodel:\n$metamodel" } + assertFalse("density" in metamodel) { "a body-declared property has no column and no metamodel field:\n$metamodel" } + assertFalse("label" in metamodel) { "an inherited property has no column and no metamodel field:\n$metamodel" } + } + + @Test + fun `escapes keyword-named properties at every emission site`() { + val compilation = compile( + """ + package com.example + + import st.orm.GenerateMetamodel + + @GenerateMetamodel + data class CityStats(val name: String, val `object`: String, val `fun`: Int) + """.trimIndent(), + ) + val metamodel = compilation.generatedSource("CityStatsMetamodel.kt") + assertTrue("`object`" in metamodel) { "expected the keyword-named property backticked:\n$metamodel" } + assertTrue("fieldBase + \"object\"" in metamodel) { "the field string literal keeps the raw name:\n$metamodel" } + } + + @Test + fun `selects child metamodel by property nullability`() { + val compilation = compile( + """ + package com.example + + import st.orm.GenerateMetamodel + + @GenerateMetamodel + data class Owner(val name: String, val address: Address, val previousAddress: Address?) + + data class Address(val street: String, val city: String) + """.trimIndent(), + ) + val metamodel = compilation.generatedSource("OwnerMetamodel.kt") + assertTrue("val address: AddressMetamodel" in metamodel) { + "a non-null property selects the base child metamodel:\n$metamodel" + } + assertTrue("val previousAddress: AddressNullableMetamodel" in metamodel) { + "a nullable property selects the nullable-chain child metamodel:\n$metamodel" + } + val nullableMetamodel = compilation.generatedSource("OwnerNullableMetamodel.kt") + assertTrue("val address: AddressNullableMetamodel" in nullableMetamodel) { + "inside a nullable chain every child is the nullable-chain variant:\n$nullableMetamodel" + } + } + + @Test + fun `sealed interfaces contribute abstract declared properties only`() { + val compilation = compile( + """ + package com.example + + import st.orm.Data + + sealed interface Vehicle : Data { + val code: String + val label: String get() = "vehicle" + } + + data class Car(override val code: String, val doors: Int) : Vehicle + """.trimIndent(), + ) + val metamodel = compilation.generatedSource("VehicleMetamodel.kt") + assertTrue("val code" in metamodel) { "expected the abstract property in the sealed metamodel:\n$metamodel" } + assertFalse("label" in metamodel) { "a defaulted property has no column and no metamodel field:\n$metamodel" } + } + + @Test + fun `escapes keyword-named properties in the metamodel interface and primary key`() { + val compilation = compile( + """ + package com.example + + import st.orm.Entity + import st.orm.PK + + data class Registry(@PK val `object`: Int, val `fun`: String) : Entity + """.trimIndent(), + ) + val metamodelInterface = compilation.generatedSource("Registry_.kt") + assertTrue("`object`" in metamodelInterface) { + "expected the keyword-named property backticked in the interface:\n$metamodelInterface" + } + val metamodel = compilation.generatedSource("RegistryMetamodel.kt") + assertTrue("ra.`object` == rb.`object`" in metamodel) { + "expected the primary key backticked in isSame:\n$metamodel" + } + } + @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 b057d0a77..691db19a3 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 @@ -27,6 +27,10 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; +import java.io.IOException; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.io.UncheckedIOException; import java.io.Writer; import java.util.HashSet; import java.util.LinkedHashSet; @@ -277,8 +281,8 @@ private boolean implementsData(@Nonnull Element recordElement) { public boolean process(@Nonnull Set annotations, @Nonnull RoundEnvironment roundEnv) { processingEnv.getMessager().printMessage(NOTE, "Storm Metamodel Processor is running."); - try { - for (Element element : roundEnv.getRootElements()) { + for (Element element : roundEnv.getRootElements()) { + try { if (isRecord(element)) { boolean hasGenerateMetamodel = element.getAnnotationMirrors().stream() .anyMatch(annotationMirror -> GENERATE_METAMODEL @@ -297,9 +301,14 @@ && implementsData(element)) { generateSealedMetamodelArtifacts(typeElement, declaredGetters); } } + } catch (Exception e) { + processingEnv.getMessager().printMessage(ERROR, + "Failed to process metamodel for " + element + ": " + e + "\n" + stackTraceOf(e), + element); + throw (e instanceof RuntimeException runtimeException) + ? runtimeException + : new IllegalStateException(e); } - } catch (Exception e) { - processingEnv.getMessager().printMessage(ERROR, "Failed to process metamodel. Error: " + e); } if (roundEnv.processingOver()) { writeInstantiatorServices(); @@ -307,6 +316,12 @@ && implementsData(element)) { return false; } + private static String stackTraceOf(@Nonnull Throwable throwable) { + StringWriter stringWriter = new StringWriter(); + throwable.printStackTrace(new PrintWriter(stringWriter)); + return stringWriter.toString(); + } + /** * 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. @@ -325,7 +340,8 @@ private void writeInstantiatorServices() { } } } catch (Exception e) { - processingEnv.getMessager().printMessage(ERROR, "Failed to write instantiator services file. Error: " + e + "."); + processingEnv.getMessager().printMessage(ERROR, + "Failed to write instantiator services file: " + e + "\n" + stackTraceOf(e)); } } @@ -376,9 +392,10 @@ private void generateMetamodelArtifacts(@Nonnull Element recordElement) { String qn = typeElement.getQualifiedName().toString(); boolean isData = implementsData(recordElement); - // Always generate the class once. + // Always generate both chain variants once; a nullable field selects the nullable variant of its child. if (generatedMetamodelClasses.add(qn)) { - generateMetamodelClass(recordElement); + generateMetamodelClass(recordElement, false); + generateMetamodelClass(recordElement, true); generateInstantiator(recordElement); } @@ -681,12 +698,19 @@ private static boolean isPrimaryKeyField(@Nonnull Element recordElement, @Nonnul private boolean isNullableUniqueField(@Nonnull Element recordElement, @Nonnull String fieldName) { // PK fields are always non-null. if (isPrimaryKeyField(recordElement, fieldName)) return false; + return isNullableField(recordElement, fieldName); + } + /** + * Returns the derived nullability of a field, matching the runtime contract: primitives are never null, + * explicit annotations win (nullable before non-null), and unannotated fields are non-null unless a + * {@code @NullUnmarked} scope applies. + */ + private boolean isNullableField(@Nonnull Element recordElement, @Nonnull String fieldName) { // Primitive types are never null. TypeMirror fieldType = getTypeElement(recordElement, fieldName); if (fieldType != null && isPrimitiveReturn(fieldType)) return false; - // Explicit annotations win, nullable before non-null, matching the runtime contract. // Check record components first. if (recordElement.getKind() == RECORD && recordElement instanceof TypeElement te) { for (RecordComponentElement rc : te.getRecordComponents()) { @@ -911,12 +935,13 @@ private String buildInterfaceFields(@Nonnull Element recordElement, @Nonnull Str generateMetamodelArtifacts(nestedTypeEl); } boolean inline = !isDataType(recordElement, fieldName); + String childMetamodel = metamodelClassName(fieldTypeName, isNullableField(recordElement, fieldName)); builder.append(" /** Represents the ") .append(inline ? "inline " : "") .append("{@link ").append(recordName).append("#").append(fieldName).append("} ") .append(inline ? "record." : "foreign key.") .append(" */\n"); - builder.append(" ").append(fieldTypeName).append("Metamodel<").append(recordName).append("> ") + builder.append(" ").append(childMetamodel).append("<").append(recordName).append("> ") .append(fieldName).append(" = ").append(modelRef).append(".") .append(fieldName).append(";\n"); } else if (isRefType(fieldType)) { @@ -1028,8 +1053,8 @@ public Object[] deconstruct(%s instance) { )); } generatedInstantiators.add((packageName.isEmpty() ? "" : packageName + ".") + instantiatorName); - } catch (Exception e) { - processingEnv.getMessager().printMessage(ERROR, "Failed to process " + instantiatorName + ". Error: " + e + "."); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write " + instantiatorName, e); } } @@ -1066,14 +1091,15 @@ public interface %s extends Metamodel<%s, %s> { buildInterfaceFields(recordElement, packageName) )); } - } catch (Exception e) { - processingEnv.getMessager().printMessage(ERROR, "Failed to process " + metaInterfaceName + ". Error: " + e + "."); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write " + metaInterfaceName, e); } } private String buildClassFields(@Nonnull Element recordElement, @Nonnull String packageName, - @Nonnull String recordName) { + @Nonnull String recordName, + boolean nullableChain) { StringBuilder builder = new StringBuilder(); for (Element enclosed : recordElement.getEnclosedElements()) { TypeMirror recordComponent = getRecordComponentType(enclosed).orElse(null); @@ -1089,10 +1115,12 @@ private String buildClassFields(@Nonnull Element recordElement, if (isNestedRecord(fieldType)) continue; boolean inline = !isDataType(recordElement, fieldName); + boolean childNullableChain = nullableChain || isNullableField(recordElement, fieldName); builder.append(" /** Represents the ").append(inline ? "inline " : "") .append("{@link ").append(recordName).append("#").append(fieldName).append("} ") .append(inline ? "record." : "foreign key.").append(" */\n"); - builder.append(" public final ").append(fieldTypeName).append("Metamodel ").append(fieldName) + builder.append(" public final ").append(metamodelClassName(fieldTypeName, childNullableChain)) + .append(" ").append(fieldName) .append(";\n"); } else if (isRefType(fieldType)) { // A Ref foreign key: a reference metamodel that selects the foreign key column but also navigates @@ -1121,7 +1149,8 @@ private String buildClassFields(@Nonnull Element recordElement, private String initClassFields(@Nonnull Element recordElement, @Nonnull String packageName, @Nonnull String recordName, - @Nonnull String metaClassName) { + @Nonnull String metaClassName, + boolean nullableChain) { StringBuilder builder = new StringBuilder(); for (Element enclosed : recordElement.getEnclosedElements()) { @@ -1137,8 +1166,11 @@ private String initClassFields(@Nonnull Element recordElement, if (isNestedRecord(fieldType)) continue; boolean inline = !isDataType(recordElement, fieldName); - // Validate: @PK, @FK, and @UK are not supported on inline record fields. - if (!implementsData(recordElement)) { + boolean childNullableChain = nullableChain || isNullableField(recordElement, fieldName); + String childMetamodel = metamodelClassName(fieldTypeName, childNullableChain); + // Validate: @PK, @FK, and @UK are not supported on inline record fields. Both chain variants walk + // the same fields; only the base pass reports, so a diagnostic prints once. + if (!nullableChain && !implementsData(recordElement)) { if (hasAnnotationOrMeta(enclosed, PRIMARY_KEY)) { processingEnv.getMessager().printMessage(ERROR, "@PK is not supported on inline record fields. " @@ -1167,7 +1199,7 @@ private String initClassFields(@Nonnull Element recordElement, " }"; if (inline && isEffectivelyUniqueField(recordElement, fieldName)) { boolean nullsDistinct = getNullsDistinct(recordElement, fieldName); - if (nullsDistinct && hasNullableLeaf(asTypeElement(fieldType))) { + if (!nullableChain && nullsDistinct && hasNullableLeaf(asTypeElement(fieldType))) { processingEnv.getMessager().printMessage( WARNING, "Unique key field '" + fieldName + "' on " + recordName + " has nullable constituent fields. " @@ -1176,15 +1208,15 @@ private String initClassFields(@Nonnull Element recordElement, + "@UK(nullsDistinct = false) if the database constraint prevents duplicate NULLs.", enclosed); } - builder.append(" this.").append(fieldName).append(" = new ").append(fieldTypeName) - .append("Metamodel<>(") + builder.append(" this.").append(fieldName).append(" = new ").append(childMetamodel) + .append("<>(") .append("subPath, fieldBase + \"").append(fieldName).append("\", ") .append(inlineFlag).append(", this, ") .append(nestedGetter).append(", ").append(nullsDistinct) .append(");\n"); } else { - builder.append(" this.").append(fieldName).append(" = new ").append(fieldTypeName) - .append("Metamodel<>(") + builder.append(" this.").append(fieldName).append(" = new ").append(childMetamodel) + .append("<>(") .append("subPath, fieldBase + \"").append(fieldName).append("\", ") .append(inlineFlag).append(", this, ") .append(nestedGetter) @@ -1208,8 +1240,9 @@ private String initClassFields(@Nonnull Element recordElement, String valueTypeName = getValueTypeName(getDeclaredTypeElement(recordElement, fieldName), packageName); boolean unique = isEffectivelyUniqueField(recordElement, fieldName); boolean isData = implementsData(recordElement); - // Validate: @PK, @FK, and @UK are not supported on inline record fields. - if (!isData) { + // Validate: @PK, @FK, and @UK are not supported on inline record fields. Both chain variants walk + // the same fields; only the base pass reports, so a diagnostic prints once. + if (!nullableChain && !isData) { if (hasAnnotationOrMeta(enclosed, PRIMARY_KEY)) { processingEnv.getMessager().printMessage(ERROR, "@PK is not supported on inline record fields. " @@ -1238,7 +1271,7 @@ private String initClassFields(@Nonnull Element recordElement, boolean nullable = isNullableUniqueField(recordElement, fieldName); boolean nullsDistinct = getNullsDistinct(recordElement, fieldName); effectivelyNullable = nullable && nullsDistinct; - if (effectivelyNullable) { + if (!nullableChain && effectivelyNullable) { processingEnv.getMessager().printMessage( WARNING, "Unique key field '" + fieldName + "' on " + recordName + " is nullable. " @@ -1360,6 +1393,15 @@ private static String refClassName(@Nonnull String recordName) { return recordName + "RefMetamodel"; } + /** + * Returns the metamodel class name for a record name. The nullable-chain variant matches the KSP output, so a + * field reads as the same static type from Java and Kotlin. A qualified record name keeps its qualifier; the + * variant lives in the record's package. + */ + private static String metamodelClassName(@Nonnull String recordName, boolean nullableChain) { + return recordName + (nullableChain ? "NullableMetamodel" : "Metamodel"); + } + /** * Returns the navigation metamodel class name for a record name. The record name is qualified when the record is * declared in another package, and the navigation metamodel is generated into that same package, so the prefix @@ -1553,15 +1595,15 @@ private void writeSourceFile(@Nonnull String packageName, @Nonnull String classN try (Writer writer = fileObject.openWriter()) { writer.write(content); } - } catch (Exception e) { - processingEnv.getMessager().printMessage(ERROR, "Failed to generate " + className + ". Error: " + e); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write " + className, e); } } - private void generateMetamodelClass(@Nonnull Element recordElement) { + private void generateMetamodelClass(@Nonnull Element recordElement, boolean nullableChain) { String packageName = elementUtils.getPackageOf(recordElement).getQualifiedName().toString(); String recordName = recordElement.getSimpleName().toString(); - String metaClassName = recordName + "Metamodel"; + String metaClassName = metamodelClassName(recordName, nullableChain); boolean isData = implementsData(recordElement); // Root isSame: compare by PK if present, else compare by value, but guard for null root record. @@ -1571,8 +1613,10 @@ private void generateMetamodelClass(@Nonnull Element recordElement) { String pkName = pkNameOpt.get(); TypeMirror pkType = getTypeElement(recordElement, pkName); if (pkType == null) { - processingEnv.getMessager().printMessage(ERROR, - "Found @PK on '" + pkName + "' but could not resolve its type on " + recordName); + if (!nullableChain) { + processingEnv.getMessager().printMessage(ERROR, + "Found @PK on '" + pkName + "' but could not resolve its type on " + recordName); + } rootIsSameBody = recordName + " ra = getter.apply(a);\n" + " " + recordName + " rb = getter.apply(b);\n" + @@ -1599,8 +1643,8 @@ private void generateMetamodelClass(@Nonnull Element recordElement) { JavaFileObject fileObject = processingEnv.getFiler() .createSourceFile((packageName.isEmpty() ? "" : packageName + ".") + metaClassName, recordElement); - String classFields = buildClassFields(recordElement, packageName, recordName); - String initFields = initClassFields(recordElement, packageName, recordName, metaClassName); + String classFields = buildClassFields(recordElement, packageName, recordName, nullableChain); + String initFields = initClassFields(recordElement, packageName, recordName, metaClassName, nullableChain); String header = (packageName.isEmpty() ? "" : "package " + packageName + ";\n\n") + @@ -1611,7 +1655,10 @@ private void generateMetamodelClass(@Nonnull Element recordElement) { "import javax.annotation.processing.Generated;\n" + "import java.util.Objects;\n\n" + "/**\n" + - " * Metamodel implementation for " + recordName + ".\n" + + (nullableChain + ? " * Nullable-chain metamodel implementation for " + recordName + + ": a parent in the graph can be null, so every value read through it can be.\n" + : " * Metamodel implementation for " + recordName + ".\n") + " *\n" + " * @param the record type of the root table of the entity graph.\n" + " */\n" + @@ -1709,7 +1756,7 @@ private void generateMetamodelClass(@Nonnull Element recordElement) { " }\n"; } String staticInstance = ""; - if (isData) { + if (isData && !nullableChain) { staticInstance = "\n @SuppressWarnings(\"rawtypes\")\n" + " private static final " + metaClassName + " INSTANCE = new " + metaClassName + "();\n\n" + @@ -1727,8 +1774,8 @@ private void generateMetamodelClass(@Nonnull Element recordElement) { writer.write(staticInstance); writer.write(footer); } - } catch (Exception e) { - processingEnv.getMessager().printMessage(ERROR, "Failed to process " + metaClassName + ". Error: " + e); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write " + metaClassName, e); } } @@ -1792,13 +1839,44 @@ private boolean isNullableOnSubclass(@Nonnull TypeElement sealedInterface, @Nonn return firstRecord != null && isNullableUniqueField(firstRecord, fieldName); } + /** + * Returns whether the getter carries any of the given annotations, checking both the method and the return + * type use (JSpecify annotations annotate the type rather than the declaration). + */ + private static boolean hasAnyReturnAnnotation(@Nonnull ExecutableElement getter, @Nonnull Set names) { + for (AnnotationMirror am : getter.getAnnotationMirrors()) { + if (names.contains(am.getAnnotationType().toString())) { + return true; + } + } + for (AnnotationMirror am : getter.getReturnType().getAnnotationMirrors()) { + if (names.contains(am.getAnnotationType().toString())) { + return true; + } + } + return false; + } + + /** + * Returns the derived nullability of a sealed interface getter, matching the runtime contract: primitives are + * never null, explicit annotations win (nullable before non-null), and unannotated getters are non-null unless + * a {@code @NullUnmarked} scope applies. + */ + private static boolean isNullableGetter(@Nonnull ExecutableElement getter) { + if (isPrimitiveReturn(getter.getReturnType())) return false; + if (hasAnyReturnAnnotation(getter, NULLABLE_ANNOTATIONS)) return true; + if (hasAnyReturnAnnotation(getter, NONNULL_ANNOTATIONS)) return false; + return isNullUnmarkedScope(getter); + } + private void generateSealedMetamodelArtifacts(@Nonnull TypeElement sealedInterface, @Nonnull List declaredGetters) { String qn = sealedInterface.getQualifiedName().toString(); boolean isData = implementsData(sealedInterface); if (generatedMetamodelClasses.add(qn)) { - generateSealedMetamodelClass(sealedInterface, declaredGetters); + generateSealedMetamodelClass(sealedInterface, declaredGetters, false); + generateSealedMetamodelClass(sealedInterface, declaredGetters, true); } if (isData && generatedMetamodelInterfaces.add(qn)) { @@ -1827,7 +1905,8 @@ private void generateSealedMetamodelInterface(@Nonnull TypeElement sealedInterfa } fields.append(" /** Represents the {@link ").append(typeName).append("#").append(fieldName) .append("()} record. */\n"); - fields.append(" ").append(fieldTypeName).append("Metamodel<").append(typeName).append("> ") + fields.append(" ").append(metamodelClassName(fieldTypeName, isNullableGetter(getter))) + .append("<").append(typeName).append("> ") .append(fieldName).append(" = ").append(modelRef).append(".") .append(fieldName).append(";\n"); } else { @@ -1872,16 +1951,17 @@ public interface %s extends Metamodel<%s, %s> { fields.toString() )); } - } catch (Exception e) { - processingEnv.getMessager().printMessage(ERROR, "Failed to process " + metaInterfaceName + ". Error: " + e + "."); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write " + metaInterfaceName, e); } } private void generateSealedMetamodelClass(@Nonnull TypeElement sealedInterface, - @Nonnull List declaredGetters) { + @Nonnull List declaredGetters, + boolean nullableChain) { String packageName = elementUtils.getPackageOf(sealedInterface).getQualifiedName().toString(); String typeName = sealedInterface.getSimpleName().toString(); - String metaClassName = typeName + "Metamodel"; + String metaClassName = metamodelClassName(typeName, nullableChain); boolean isData = implementsData(sealedInterface); // Find PK via subclass. @@ -1923,10 +2003,12 @@ private void generateSealedMetamodelClass(@Nonnull TypeElement sealedInterface, if (isRecord(fieldType) && !isRefType(fieldType)) { if (isNestedRecord(fieldType)) continue; boolean inline = !implementsInterface(fieldType, DATA, typeUtils); + boolean childNullableChain = nullableChain || isNullableGetter(getter); classFields.append(" /** Represents the ").append(inline ? "inline " : "") .append("{@link ").append(typeName).append("#").append(fieldName).append("()} ") .append(inline ? "record." : "foreign key.").append(" */\n"); - classFields.append(" public final ").append(fieldTypeName).append("Metamodel ").append(fieldName) + classFields.append(" public final ").append(metamodelClassName(fieldTypeName, childNullableChain)) + .append(" ").append(fieldName) .append(";\n"); } else { String valueTypeName = getValueTypeName(fieldType, packageName); @@ -1949,6 +2031,8 @@ private void generateSealedMetamodelClass(@Nonnull TypeElement sealedInterface, if (isRecord(fieldType) && !isRefType(fieldType)) { if (isNestedRecord(fieldType)) continue; boolean inline = !implementsInterface(fieldType, DATA, typeUtils); + boolean childNullableChain = nullableChain || isNullableGetter(getter); + String childMetamodel = metamodelClassName(fieldTypeName, childNullableChain); String inlineFlag = inline ? "true" : "false"; String nestedGetter = "t -> {\n" + @@ -1957,15 +2041,15 @@ private void generateSealedMetamodelClass(@Nonnull TypeElement sealedInterface, " }"; if (inline && isEffectivelyUniqueFieldOnSubclass(sealedInterface, fieldName)) { boolean nullsDistinct = getNullsDistinctOnSubclass(sealedInterface, fieldName); - initFields.append(" this.").append(fieldName).append(" = new ").append(fieldTypeName) - .append("Metamodel<>(") + initFields.append(" this.").append(fieldName).append(" = new ").append(childMetamodel) + .append("<>(") .append("subPath, fieldBase + \"").append(fieldName).append("\", ") .append(inlineFlag).append(", this, ") .append(nestedGetter).append(", ").append(nullsDistinct) .append(");\n"); } else { - initFields.append(" this.").append(fieldName).append(" = new ").append(fieldTypeName) - .append("Metamodel<>(") + initFields.append(" this.").append(fieldName).append(" = new ").append(childMetamodel) + .append("<>(") .append("subPath, fieldBase + \"").append(fieldName).append("\", ") .append(inlineFlag).append(", this, ") .append(nestedGetter) @@ -2081,7 +2165,10 @@ private void generateSealedMetamodelClass(@Nonnull TypeElement sealedInterface, "import javax.annotation.processing.Generated;\n" + "import java.util.Objects;\n\n" + "/**\n" + - " * Metamodel implementation for " + typeName + ".\n" + + (nullableChain + ? " * Nullable-chain metamodel implementation for " + typeName + + ": a parent in the graph can be null, so every value read through it can be.\n" + : " * Metamodel implementation for " + typeName + ".\n") + " *\n" + " * @param the record type of the root table of the entity graph.\n" + " */\n" + @@ -2165,7 +2252,7 @@ private void generateSealedMetamodelClass(@Nonnull TypeElement sealedInterface, " }\n"; } String staticInstance = ""; - if (isData) { + if (isData && !nullableChain) { staticInstance = "\n @SuppressWarnings(\"rawtypes\")\n" + " private static final " + metaClassName + " INSTANCE = new " + metaClassName + "();\n\n" + @@ -2183,8 +2270,8 @@ private void generateSealedMetamodelClass(@Nonnull TypeElement sealedInterface, writer.write(staticInstance); writer.write(footer); } - } catch (Exception e) { - processingEnv.getMessager().printMessage(ERROR, "Failed to process " + metaClassName + ". Error: " + e); + } catch (IOException e) { + throw new UncheckedIOException("Failed to write " + metaClassName, e); } } } diff --git a/storm-metamodel-processor/src/main/resources/META-INF/gradle/incremental.annotation.processors b/storm-metamodel-processor/src/main/resources/META-INF/gradle/incremental.annotation.processors new file mode 100644 index 000000000..09fe1d370 --- /dev/null +++ b/storm-metamodel-processor/src/main/resources/META-INF/gradle/incremental.annotation.processors @@ -0,0 +1,2 @@ +st.orm.metamodel.MetamodelProcessor,aggregating +st.orm.metamodel.TypeIndexProcessor,aggregating 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 8e2e5b8b3..7c6dd931b 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 @@ -16,7 +16,9 @@ package st.orm.metamodel; import static java.util.stream.Collectors.joining; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -50,11 +52,15 @@ class MetamodelProcessorTest { @TempDir private Path tempDir; - private record Compilation(boolean success, String errors, Path generatedSources, Path classes) { + private record Compilation(boolean success, String errors, List warnings, Path generatedSources, Path classes) { boolean generated(String relativePath) { return Files.exists(generatedSources.resolve(relativePath)); } + + String generatedSource(String relativePath) throws IOException { + return Files.readString(generatedSources.resolve(relativePath)); + } } @Test @@ -79,6 +85,122 @@ public record CityStats(String name, int inhabitants) {} assertTrue(Files.readString(services).contains("CityStatsInstantiator")); } + @Test + void generatesNullableChainVariantForEveryRecord() throws Exception { + Compilation compilation = compile("CityStats.java", """ + import st.orm.GenerateMetamodel; + + @GenerateMetamodel + public record CityStats(String name, int inhabitants) {} + """); + assertTrue(compilation.success(), compilation.errors()); + assertTrue(compilation.generated("CityStatsNullableMetamodel.java"), + "expected a nullable-chain metamodel for the @GenerateMetamodel record"); + assertTrue(Files.exists(compilation.classes().resolve("CityStatsNullableMetamodel.class")), + "expected the generated nullable-chain metamodel to compile"); + } + + @Test + void selectsChildMetamodelByFieldNullability() throws Exception { + Compilation compilation = compile("Owner.java", """ + import jakarta.annotation.Nullable; + import st.orm.GenerateMetamodel; + + @GenerateMetamodel + public record Owner(String name, Address address, @Nullable Address previousAddress) {} + + record Address(String street, String city) {} + """); + assertTrue(compilation.success(), compilation.errors()); + assertTrue(compilation.generated("AddressMetamodel.java"), + "expected a metamodel for the referenced record"); + assertTrue(compilation.generated("AddressNullableMetamodel.java"), + "expected a nullable-chain metamodel for the referenced record"); + String ownerMetamodel = compilation.generatedSource("OwnerMetamodel.java"); + assertTrue(ownerMetamodel.contains("AddressMetamodel address"), + "a non-null field selects the base child metamodel:\n" + ownerMetamodel); + assertTrue(ownerMetamodel.contains("AddressNullableMetamodel previousAddress"), + "a nullable field selects the nullable-chain child metamodel:\n" + ownerMetamodel); + String ownerNullableMetamodel = compilation.generatedSource("OwnerNullableMetamodel.java"); + assertTrue(ownerNullableMetamodel.contains("AddressNullableMetamodel address"), + "inside a nullable chain every child is the nullable-chain variant:\n" + ownerNullableMetamodel); + assertTrue(Files.exists(compilation.classes().resolve("OwnerNullableMetamodel.class")), + "expected the generated metamodels to compile"); + } + + @Test + void interfaceSelectsChildMetamodelByForeignKeyNullability() throws Exception { + Compilation compilation = compile("Owner.java", """ + import jakarta.annotation.Nullable; + import st.orm.Entity; + import st.orm.FK; + import st.orm.PK; + + public record Owner(@PK Integer id, @FK City city, @Nullable @FK City previousCity) + implements Entity {} + + record City(@PK Integer id, String name) implements Entity {} + """); + assertTrue(compilation.success(), compilation.errors()); + String ownerInterface = compilation.generatedSource("Owner_.java"); + assertTrue(ownerInterface.contains("CityMetamodel city"), + "a non-null foreign key reads as the base child metamodel:\n" + ownerInterface); + assertTrue(ownerInterface.contains("CityNullableMetamodel previousCity"), + "a nullable foreign key reads as the nullable-chain child metamodel:\n" + ownerInterface); + assertTrue(Files.exists(compilation.classes().resolve("Owner_.class")), + "expected the generated metamodel interface to compile"); + } + + @Test + void generatesNullableChainVariantForSealedInterfaces() throws Exception { + Compilation compilation = compile("Shipment.java", """ + import st.orm.Data; + + public sealed interface Shipment extends Data permits Parcel { + String code(); + } + + record Parcel(String code) implements Shipment {} + """); + assertTrue(compilation.success(), compilation.errors()); + assertTrue(compilation.generated("ShipmentMetamodel.java"), + "expected a metamodel for the sealed Data interface"); + assertTrue(compilation.generated("ShipmentNullableMetamodel.java"), + "expected a nullable-chain metamodel for the sealed Data interface"); + assertTrue(Files.exists(compilation.classes().resolve("ShipmentNullableMetamodel.class")), + "expected the generated nullable-chain metamodel to compile"); + } + + @Test + void reportsUniqueKeyNullabilityWarningOncePerRecord() throws Exception { + Compilation compilation = compile("Account.java", """ + import jakarta.annotation.Nullable; + import st.orm.Entity; + import st.orm.PK; + import st.orm.UK; + + public record Account(@PK Integer id, @UK @Nullable String email) implements Entity {} + """); + assertTrue(compilation.success(), compilation.errors()); + long emailWarnings = compilation.warnings().stream() + .filter(warning -> warning.contains("Unique key field 'email'")) + .count(); + assertEquals(1, emailWarnings, + "both chain variants walk the field; the warning must print once:\n" + compilation.warnings()); + } + + @Test + void registersProcessorsForGradleIncrementalProcessing() throws IOException { + var descriptor = MetamodelProcessor.class.getResource("/META-INF/gradle/incremental.annotation.processors"); + assertNotNull(descriptor, "expected the Gradle incremental annotation processing descriptor"); + String content; + try (var stream = descriptor.openStream()) { + content = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } + assertTrue(content.contains(MetamodelProcessor.class.getName() + ",aggregating"), content); + assertTrue(content.contains(TypeIndexProcessor.class.getName() + ",aggregating"), content); + } + @Test void ignoresPlainRecordWithoutAnnotation() throws Exception { Compilation compilation = compile("CityStats.java", """ @@ -116,7 +238,12 @@ private Compilation compile(String fileName, String source) throws IOException, .filter(diagnostic -> diagnostic.getKind() == Diagnostic.Kind.ERROR) .map(Object::toString) .collect(joining("\n")); - return new Compilation(success, errors, generatedSources, classes); + List warnings = diagnostics.getDiagnostics().stream() + .filter(diagnostic -> diagnostic.getKind() == Diagnostic.Kind.WARNING + || diagnostic.getKind() == Diagnostic.Kind.MANDATORY_WARNING) + .map(Object::toString) + .toList(); + return new Compilation(success, errors, warnings, generatedSources, classes); } }