Skip to content

Commit 9b06300

Browse files
committed
feat(codegen-kotlin): KotlinValidatorGenerator — startup validator + Exposed helper
1 parent 6dd4f94 commit 9b06300

2 files changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.metaobjects.generator.GeneratorException
4+
import com.metaobjects.generator.GeneratorIOWriter
5+
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
6+
import com.metaobjects.loader.MetaDataLoader
7+
import com.metaobjects.`object`.MetaObject
8+
import com.metaobjects.source.RdbSource
9+
import java.io.OutputStream
10+
import java.io.PrintWriter
11+
import java.nio.file.Files
12+
import java.nio.file.Path
13+
import java.nio.file.Paths
14+
15+
/**
16+
* Generator: emits two files per project:
17+
* - MetadataStartupValidator.kt — registry of (FQN, Table) pairs + a validate(loader) entry point
18+
* - ExposedTableValidator.kt — substrate-specific helper that compares one MetaObject to one Table
19+
*
20+
* <p>Output is emitted as hand-rolled Kotlin source (rather than KotlinPoet) to keep
21+
* generated-code template-string escapes readable and to avoid wrestling with
22+
* `ParameterizedTypeName` quoting for `List<Pair<String, Table>>`.
23+
*
24+
* <p>Args:
25+
* <ul>
26+
* <li>{@code outputDir} (required)</li>
27+
* <li>{@code packageName} (required) — the Kotlin package both files live in</li>
28+
* </ul>
29+
*/
30+
class KotlinValidatorGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
31+
32+
override fun getFilterClass(): Class<MetaObject> = MetaObject::class.java
33+
34+
override fun execute(loader: MetaDataLoader) {
35+
parseArgs()
36+
val pkg = getArg("packageName")
37+
?: throw GeneratorException("packageName is required")
38+
val outRoot = Paths.get(outDir.absolutePath)
39+
40+
val entries = loader.metaObjects
41+
.filter { it.subType == MetaObject.SUBTYPE_ENTITY }
42+
.filter { it.children.any { c -> c is RdbSource } }
43+
.map { entity ->
44+
val shortName = PackageMapping.splitFqn(entity.name).second
45+
entity.name to "${shortName}Table"
46+
}
47+
48+
emitValidator(pkg, entries, outRoot)
49+
emitHelper(pkg, outRoot)
50+
}
51+
52+
private fun emitValidator(pkg: String, entries: List<Pair<String, String>>, outRoot: Path) {
53+
val registry = entries.joinToString(",\n ") { (fqn, table) -> "\"$fqn\" to $table" }
54+
55+
val source = buildString {
56+
if (pkg.isNotEmpty()) {
57+
append("package $pkg\n\n")
58+
}
59+
append("import com.metaobjects.loader.MetaDataLoader\n")
60+
append("import com.metaobjects.metadata.ktx.metaObjectOrNull\n")
61+
append("import org.jetbrains.exposed.sql.Table\n\n")
62+
append("/**\n")
63+
append(" * GENERATED — runtime drift gate. Call [validate] from a Spring `@PostConstruct` or\n")
64+
append(" * `ApplicationReadyEvent` listener to fail-fast when generated Tables drift from metadata.\n")
65+
append(" */\n")
66+
append("object MetadataStartupValidator {\n\n")
67+
append(" private val tablesToValidate: List<Pair<String, Table>> = listOf(\n ")
68+
append(registry)
69+
append("\n )\n\n")
70+
append(" fun validate(loader: MetaDataLoader) {\n")
71+
append(" val errors = mutableListOf<String>()\n")
72+
append(" for ((fqn, table) in tablesToValidate) {\n")
73+
append(" val obj = loader.metaObjectOrNull(fqn)\n")
74+
append(" if (obj == null) {\n")
75+
append(" errors.add(\"metadata missing \$fqn (generated table: \${table.tableName})\")\n")
76+
append(" continue\n")
77+
append(" }\n")
78+
append(" ExposedTableValidator.check(obj, table, errors)\n")
79+
append(" }\n")
80+
append(" check(errors.isEmpty()) {\n")
81+
append(" \"MetadataStartupValidator: \${errors.size} drift(s):\\n - \" +\n")
82+
append(" errors.joinToString(\"\\n - \")\n")
83+
append(" }\n")
84+
append(" }\n")
85+
append("}\n")
86+
}
87+
88+
val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("MetadataStartupValidator.kt")
89+
outFile.parent?.let { Files.createDirectories(it) }
90+
Files.writeString(outFile, source)
91+
}
92+
93+
private fun emitHelper(pkg: String, outRoot: Path) {
94+
val source = buildString {
95+
if (pkg.isNotEmpty()) {
96+
append("package $pkg\n\n")
97+
}
98+
append("import com.metaobjects.`object`.MetaObject\n")
99+
append("import com.metaobjects.field.MetaField\n")
100+
append("import org.jetbrains.exposed.sql.Table\n\n")
101+
append("/**\n")
102+
append(" * GENERATED — compares a [MetaObject]'s field set vs an Exposed [Table]'s column set\n")
103+
append(" * and records any discrepancies into the supplied `errors` list.\n")
104+
append(" */\n")
105+
append("object ExposedTableValidator {\n\n")
106+
append(" fun check(obj: MetaObject, table: Table, errors: MutableList<String>) {\n")
107+
append(" val expectedCols = obj.metaFields.map { it.name }.toSet()\n")
108+
append(" val actualCols = table.columns.map { it.name }.toSet()\n")
109+
append(" val missing = expectedCols - actualCols\n")
110+
append(" val extra = actualCols - expectedCols\n")
111+
append(" if (missing.isNotEmpty()) {\n")
112+
append(" errors.add(\"\${obj.name}: metadata declares fields not in generated table: \$missing\")\n")
113+
append(" }\n")
114+
append(" if (extra.isNotEmpty()) {\n")
115+
append(" errors.add(\"\${obj.name}: generated table has columns not in metadata: \$extra\")\n")
116+
append(" }\n")
117+
append(" }\n")
118+
append("}\n")
119+
}
120+
121+
val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve("ExposedTableValidator.kt")
122+
outFile.parent?.let { Files.createDirectories(it) }
123+
Files.writeString(outFile, source)
124+
}
125+
126+
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
127+
override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ }
128+
override fun <T : GeneratorIOWriter<*>?> getSingleWriter(
129+
loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter?
130+
): T? = null
131+
override fun <T : GeneratorIOWriter<*>?> getFinalWriter(
132+
loader: MetaDataLoader?, out: OutputStream?
133+
): T? = null
134+
override fun writeFinalFile(metadata: MutableCollection<MetaObject>?, writer: GeneratorIOWriter<*>?) { /* none */ }
135+
override fun getSingleOutputFilePath(md: MetaObject): String = ""
136+
override fun getSingleOutputFilename(md: MetaObject): String = ""
137+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.metaobjects.metadata.ktx.loadString
4+
import java.nio.file.Files
5+
import kotlin.test.Test
6+
import kotlin.test.assertTrue
7+
8+
class KotlinValidatorGeneratorTest {
9+
10+
private val fixture = """{
11+
"metadata.root": { "package": "acme::demo", "children": [
12+
{ "object.entity": { "name": "Author", "children": [
13+
{ "field.long": { "name": "id" } },
14+
{ "field.string": { "name": "name" } },
15+
{ "source.rdb": { "@table": "authors" } },
16+
{ "identity.primary": { "name": "pk", "@fields": ["id"] } }
17+
] } }
18+
] }
19+
}""".trimIndent()
20+
21+
@Test fun `emits MetadataStartupValidator with one entry per entity with source rdb`() {
22+
val outDir = Files.createTempDirectory("kvld-")
23+
try {
24+
val gen = KotlinValidatorGenerator()
25+
gen.setArgs(mapOf(
26+
"outputDir" to outDir.toString(),
27+
"packageName" to "acme.demo"
28+
))
29+
gen.execute(loadString("test", fixture))
30+
31+
val validator = outDir.resolve("acme/demo/MetadataStartupValidator.kt")
32+
val helper = outDir.resolve("acme/demo/ExposedTableValidator.kt")
33+
assertTrue(Files.exists(validator),
34+
"expected $validator; files=${Files.walk(outDir).toList()}")
35+
assertTrue(Files.exists(helper), "expected $helper")
36+
37+
val vSrc = Files.readString(validator)
38+
assertTrue("object MetadataStartupValidator" in vSrc, vSrc)
39+
assertTrue("fun validate(loader: MetaDataLoader)" in vSrc, vSrc)
40+
assertTrue("\"acme::demo::Author\" to AuthorTable" in vSrc, vSrc)
41+
42+
val hSrc = Files.readString(helper)
43+
assertTrue("object ExposedTableValidator" in hSrc, hSrc)
44+
assertTrue("fun check(obj: MetaObject, table: Table" in hSrc, hSrc)
45+
} finally {
46+
outDir.toFile().deleteRecursively()
47+
}
48+
}
49+
}

0 commit comments

Comments
 (0)