Skip to content

Commit 99ed71e

Browse files
committed
feat(codegen-kotlin): KotlinRelationsGenerator emits query helpers for cardinality=many relationships
Pairs with KotlinExposedTableGenerator's bidirectional FK emission: where the table generator owns the SCHEMA side (inferred FK column on the many entity's table), KotlinRelationsGenerator owns the QUERY side — an ergonomic extension fn per to-many composition so consumers write `AuthorTable.postsQuery(author.id).toList()` instead of hand-rolling `PostTable.selectAll().where { PostTable.authorId eq author.id }`. Returns Query so consumers can still chain `.where { ... }`, `.orderBy(...)`, `.limit(...)` before materialising. PK param type is resolved from `identity.primary.@fields[0]` via KotlinTypeMapper (defaults to Long when metadata is partially-formed — same lossy-tolerant policy as the table generator).
1 parent ba7f2dd commit 99ed71e

3 files changed

Lines changed: 321 additions & 1 deletion

File tree

server/java/codegen-kotlin/README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Kotlin codegen target for Spring-Boot-Kotlin consumers on Exposed + Flyway. Emit
88
|---|---|---|
99
| `KotlinEntityGenerator` | `<Entity>.kt``@Serializable data class` | every `object.entity` AND `object.value` |
1010
| `KotlinExposedTableGenerator` | `<Entity>Table.kt` — Exposed `Table` object with PK + FK + `@storage` columns | every entity with `source.rdb` |
11+
| `KotlinRelationsGenerator` | `<Entity>Relations.kt` — extension fns for `cardinality=many` query helpers | entities with `cardinality=many` composition relationships |
1112
| `KotlinPayloadGenerator` | `<Template>Payload.kt``@Serializable` payload from `@payloadRef` view-object | every `template.prompt` / `template.output` |
1213
| `KotlinValidatorGenerator` | `MetadataStartupValidator.kt` + `ExposedTableValidator.kt` | once per project |
1314
| `KotlinSpringConfigGenerator` | `MetadataExposedConfig.kt``@Configuration` wiring `Database.connect()` + auto-validator | once per project |
@@ -54,7 +55,17 @@ object PostTable : Table("posts") {
5455
}
5556
```
5657

57-
`@cardinality: many` side is skipped (FK lives on the to-one side). Referential actions map kebab-case metadata → SCREAMING_SNAKE Exposed `ReferenceOption` enum names.
58+
`@cardinality: many` side is skipped on the table emitter (FK lives on the to-one side). Referential actions map kebab-case metadata → SCREAMING_SNAKE Exposed `ReferenceOption` enum names.
59+
60+
Bidirectional emission: when entity X declares `@cardinality: many` to Y with no reciprocal, `KotlinExposedTableGenerator` infers the FK column on `YTable` (`<XShort.lowercased>Id`), and `KotlinRelationsGenerator` emits an ergonomic query helper on the parent side:
61+
62+
```kotlin
63+
// AuthorRelations.kt — emitted alongside AuthorTable.kt
64+
fun AuthorTable.postsQuery(authorId: Long): Query =
65+
PostTable.selectAll().where { PostTable.authorId eq authorId }
66+
```
67+
68+
so consumers can write `AuthorTable.postsQuery(author.id).toList()` (or chain `.orderBy(...)` / `.limit(...)` first). One helper fn per to-many composition; the file is skipped entirely for entities with no to-many relationships.
5869

5970
## FR-004 payload origins
6071

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package com.metaobjects.generator.kotlin
2+
3+
import com.metaobjects.generator.GeneratorIOWriter
4+
import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase
5+
import com.metaobjects.identity.MetaIdentity
6+
import com.metaobjects.loader.MetaDataLoader
7+
import com.metaobjects.`object`.MetaObject
8+
import com.metaobjects.relationship.CompositionRelationship
9+
import com.metaobjects.relationship.MetaRelationship
10+
import com.metaobjects.source.RdbSource
11+
import com.squareup.kotlinpoet.TypeName
12+
import com.squareup.kotlinpoet.LONG
13+
import java.io.OutputStream
14+
import java.io.PrintWriter
15+
import java.nio.file.Files
16+
import java.nio.file.Path
17+
import java.nio.file.Paths
18+
import org.slf4j.LoggerFactory
19+
20+
/**
21+
* Generator: one `<Entity>Relations.kt` per `object.entity` carrying at least
22+
* one `relationship.composition @cardinality: many` child. Emits an ergonomic
23+
* Kotlin extension function per to-many declaration so consumers can write
24+
* `AuthorTable.postsQuery(author.id).toList()` instead of hand-rolling the
25+
* `selectAll().where { ... }` JOIN every time.
26+
*
27+
* <p>Pairs with {@link KotlinExposedTableGenerator}, which emits the SCHEMA
28+
* side (the inferred FK column on the many entity's table). This generator
29+
* owns the QUERY side — the inverse fetch from parent → children — and stays
30+
* separate from the table emitter so the two concerns don't get tangled.
31+
*
32+
* <p>The returned {@link org.jetbrains.exposed.sql.Query} keeps the helper
33+
* compositional: consumers can chain `.where { ... }`, `.orderBy(...)`,
34+
* `.limit(...)` etc. before materialising.
35+
*
36+
* <p>Convention (matches the inferred FK column emitted by
37+
* {@link KotlinExposedTableGenerator#buildInverseFkSpec}):
38+
* <ul>
39+
* <li>FK column on the target table = {@code <ownerShort.lowercased()>Id}
40+
* (e.g. {@code Author} → {@code authorId} on {@code PostTable}).</li>
41+
* <li>Helper fn name = {@code <relShortName>Query} (e.g. {@code postsQuery}).</li>
42+
* <li>Helper fn parameter = the parent's primary-key field type
43+
* (resolved via {@code identity.primary.@fields[0]} + KotlinTypeMapper).</li>
44+
* </ul>
45+
*
46+
* <p>Skips silently when:
47+
* <ul>
48+
* <li>The entity has no {@code source.rdb} child (no persistence layer).</li>
49+
* <li>The entity has no to-many composition relationships.</li>
50+
* <li>The target entity's short name cannot be resolved (defensive).</li>
51+
* </ul>
52+
*
53+
* <p>Args:
54+
* <ul>
55+
* <li>{@code outputDir} (required): output directory root.</li>
56+
* </ul>
57+
*/
58+
class KotlinRelationsGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
59+
60+
override fun getFilterClass(): Class<MetaObject> = MetaObject::class.java
61+
62+
override fun execute(loader: MetaDataLoader) {
63+
parseArgs()
64+
val outRoot = Paths.get(outDir.absolutePath)
65+
66+
for (entity in loader.metaObjects) {
67+
if (entity.subType != MetaObject.SUBTYPE_ENTITY) continue
68+
// No source.rdb → no persistence layer → no FK column to query against.
69+
entity.children.filterIsInstance<RdbSource>().firstOrNull() ?: continue
70+
71+
val manyRels = entity.children
72+
.filterIsInstance<MetaRelationship>()
73+
.filter {
74+
it.subType == CompositionRelationship.SUBTYPE_COMPOSITION &&
75+
it.cardinality == MetaRelationship.CARDINALITY_MANY
76+
}
77+
if (manyRels.isEmpty()) continue
78+
79+
emit(entity, manyRels, outRoot, loader)
80+
}
81+
}
82+
83+
private fun emit(
84+
entity: MetaObject,
85+
manyRels: List<MetaRelationship>,
86+
outRoot: Path,
87+
loader: MetaDataLoader,
88+
) {
89+
val (pkg, ownerShort) = PackageMapping.splitFqn(entity.name)
90+
val ownerTable = ownerShort + "Table"
91+
val pkParamType = primaryKeyKotlinType(entity)
92+
val pkParamSimpleName = (pkParamType as? com.squareup.kotlinpoet.ClassName)?.simpleName
93+
?: pkParamType.toString().substringAfterLast('.')
94+
// FK column matches KotlinExposedTableGenerator.buildInverseFkSpec: <ownerShortLowercased>Id.
95+
val fkColName = ownerShort.replaceFirstChar { it.lowercaseChar() } + "Id"
96+
97+
// Resolve each to-many target → (relShortName, targetShort). Skip relationships
98+
// whose @objectRef cannot be resolved (defensive — the loader's validation phase
99+
// already gates the attr being present).
100+
data class Helper(val relShortName: String, val targetTable: String)
101+
val helpers = manyRels.mapNotNull { rel ->
102+
val ref = rel.objectRef ?: return@mapNotNull null
103+
val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: return@mapNotNull null
104+
val targetShort = PackageMapping.splitFqn(target.name).second
105+
val relShort = rel.shortName ?: rel.name
106+
Helper(relShort, targetShort + "Table")
107+
}
108+
if (helpers.isEmpty()) return
109+
110+
val source = buildString {
111+
if (pkg.isNotEmpty()) {
112+
append("package $pkg\n\n")
113+
}
114+
append("import org.jetbrains.exposed.sql.Query\n")
115+
append("import org.jetbrains.exposed.sql.selectAll\n")
116+
append("\n")
117+
append("/** GENERATED — extension fns for `$ownerShort` to-many relationships. Do not hand-edit. */\n")
118+
for ((idx, h) in helpers.withIndex()) {
119+
if (idx > 0) append("\n")
120+
append("/** Query `$ownerShort.${h.relShortName}` (cardinality=many) on the ${h.targetTable.removeSuffix("Table")} side. */\n")
121+
append("fun $ownerTable.${h.relShortName}Query($fkColName: $pkParamSimpleName): Query =\n")
122+
append(" ${h.targetTable}.selectAll().where { ${h.targetTable}.$fkColName eq $fkColName }\n")
123+
}
124+
}
125+
126+
val outFile = outRoot.resolve(pkg.replace('.', '/')).resolve(ownerShort + "Relations.kt")
127+
outFile.parent?.let { Files.createDirectories(it) }
128+
Files.writeString(outFile, source)
129+
}
130+
131+
/**
132+
* Resolve the Kotlin type of [entity]'s primary key. Falls back to [LONG] when
133+
* the primary identity is missing, multi-field, or its field can't be resolved
134+
* — Long matches the dominant convention (entities almost always have a single
135+
* `field.long` PK; KotlinExposedTableGenerator's FK columns hard-code
136+
* `long("…").references(...)`). Same lossy-tolerant policy as the table
137+
* generator: defensive defaults rather than throwing on partially-formed
138+
* metadata.
139+
*/
140+
private fun primaryKeyKotlinType(entity: MetaObject): TypeName {
141+
val primary = entity.children
142+
.filterIsInstance<MetaIdentity>()
143+
.firstOrNull { it.isPrimary } ?: return LONG
144+
val pkFieldName = primary.fields.firstOrNull() ?: return LONG
145+
val pkField = entity.metaFields.firstOrNull { it.name == pkFieldName } ?: return LONG
146+
return runCatching { KotlinTypeMapper.kotlinTypeName(pkField) }.getOrDefault(LONG)
147+
}
148+
149+
// === MultiFileDirectGeneratorBase abstract-method stubs ====================
150+
override fun writeSingleFile(md: MetaObject, writer: GeneratorIOWriter<*>?) { /* unused */ }
151+
override fun <T : GeneratorIOWriter<*>?> getSingleWriter(
152+
loader: MetaDataLoader?, md: MetaObject?, pw: PrintWriter?
153+
): T? = null
154+
override fun <T : GeneratorIOWriter<*>?> getFinalWriter(
155+
loader: MetaDataLoader?, out: OutputStream?
156+
): T? = null
157+
override fun writeFinalFile(metadata: MutableCollection<MetaObject>?, writer: GeneratorIOWriter<*>?) { /* none */ }
158+
override fun getSingleOutputFilePath(md: MetaObject): String =
159+
PackageMapping.splitFqn(md.name).first.replace('.', '/')
160+
override fun getSingleOutputFilename(md: MetaObject): String =
161+
PackageMapping.splitFqn(md.name).second + "Relations.kt"
162+
163+
private companion object {
164+
@JvmStatic
165+
val LOG = LoggerFactory.getLogger(KotlinRelationsGenerator::class.java)
166+
}
167+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
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.assertEquals
7+
import kotlin.test.assertTrue
8+
9+
class KotlinRelationsGeneratorTest {
10+
11+
/** Author declares `cardinality="many"` to Post — the canonical to-many shape. */
12+
private val manyFixture = """{
13+
"metadata.root": { "package": "acme::blog", "children": [
14+
{ "object.entity": { "name": "Author", "children": [
15+
{ "field.long": { "name": "id" } },
16+
{ "field.string": { "name": "name", "@required": true } },
17+
{ "relationship.composition": {
18+
"name": "posts", "@objectRef": "Post", "@cardinality": "many"
19+
} },
20+
{ "source.rdb": { "@table": "authors" } },
21+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
22+
] } },
23+
{ "object.entity": { "name": "Post", "children": [
24+
{ "field.long": { "name": "id" } },
25+
{ "field.string": { "name": "title", "@required": true } },
26+
{ "source.rdb": { "@table": "posts" } },
27+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
28+
] } }
29+
] }
30+
}""".trimIndent()
31+
32+
@Test fun `cardinality many emits ergonomic query helper`() {
33+
val outDir = Files.createTempDirectory("krel-")
34+
try {
35+
val gen = KotlinRelationsGenerator()
36+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
37+
gen.execute(loadString("rel-many", manyFixture))
38+
39+
val emitted = outDir.resolve("acme/blog/AuthorRelations.kt")
40+
assertTrue(Files.exists(emitted),
41+
"expected $emitted; files=${Files.walk(outDir).toList()}")
42+
43+
val src = Files.readString(emitted)
44+
assertTrue("package acme.blog" in src, src)
45+
assertTrue("import org.jetbrains.exposed.sql.Query" in src, src)
46+
assertTrue("import org.jetbrains.exposed.sql.selectAll" in src, src)
47+
assertTrue("fun AuthorTable.postsQuery(authorId: Long): Query" in src,
48+
"expected ergonomic postsQuery extension fn; saw:\n$src")
49+
assertTrue(
50+
"PostTable.selectAll().where { PostTable.authorId eq authorId }" in src,
51+
"expected JOIN body; saw:\n$src",
52+
)
53+
} finally {
54+
outDir.toFile().deleteRecursively()
55+
}
56+
}
57+
58+
@Test fun `cardinality one or absent emits nothing`() {
59+
val toOne = """{
60+
"metadata.root": { "package": "acme::blog", "children": [
61+
{ "object.entity": { "name": "Author", "children": [
62+
{ "field.long": { "name": "id" } },
63+
{ "source.rdb": { "@table": "authors" } },
64+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
65+
] } },
66+
{ "object.entity": { "name": "Post", "children": [
67+
{ "field.long": { "name": "id" } },
68+
{ "relationship.composition": { "name": "author", "@objectRef": "Author" } },
69+
{ "source.rdb": { "@table": "posts" } },
70+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
71+
] } }
72+
] }
73+
}""".trimIndent()
74+
val outDir = Files.createTempDirectory("krel-")
75+
try {
76+
val gen = KotlinRelationsGenerator()
77+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
78+
gen.execute(loadString("rel-none", toOne))
79+
80+
// No relations file should be emitted for either side.
81+
assertTrue(!Files.exists(outDir.resolve("acme/blog/AuthorRelations.kt")),
82+
"Author has no to-many — should not emit AuthorRelations.kt")
83+
assertTrue(!Files.exists(outDir.resolve("acme/blog/PostRelations.kt")),
84+
"Post has only to-one — should not emit PostRelations.kt")
85+
} finally {
86+
outDir.toFile().deleteRecursively()
87+
}
88+
}
89+
90+
@Test fun `multiple many relationships emit one fn each`() {
91+
val multi = """{
92+
"metadata.root": { "package": "acme::blog", "children": [
93+
{ "object.entity": { "name": "Author", "children": [
94+
{ "field.long": { "name": "id" } },
95+
{ "relationship.composition": {
96+
"name": "posts", "@objectRef": "Post", "@cardinality": "many"
97+
} },
98+
{ "relationship.composition": {
99+
"name": "comments", "@objectRef": "Comment", "@cardinality": "many"
100+
} },
101+
{ "source.rdb": { "@table": "authors" } },
102+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
103+
] } },
104+
{ "object.entity": { "name": "Post", "children": [
105+
{ "field.long": { "name": "id" } },
106+
{ "source.rdb": { "@table": "posts" } },
107+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
108+
] } },
109+
{ "object.entity": { "name": "Comment", "children": [
110+
{ "field.long": { "name": "id" } },
111+
{ "source.rdb": { "@table": "comments" } },
112+
{ "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }
113+
] } }
114+
] }
115+
}""".trimIndent()
116+
val outDir = Files.createTempDirectory("krel-")
117+
try {
118+
val gen = KotlinRelationsGenerator()
119+
gen.setArgs(mapOf("outputDir" to outDir.toString()))
120+
gen.execute(loadString("rel-multi", multi))
121+
122+
val emitted = outDir.resolve("acme/blog/AuthorRelations.kt")
123+
assertTrue(Files.exists(emitted),
124+
"expected $emitted; files=${Files.walk(outDir).toList()}")
125+
val src = Files.readString(emitted)
126+
assertTrue("fun AuthorTable.postsQuery(authorId: Long): Query" in src,
127+
"expected postsQuery; saw:\n$src")
128+
assertTrue("fun AuthorTable.commentsQuery(authorId: Long): Query" in src,
129+
"expected commentsQuery; saw:\n$src")
130+
assertTrue(
131+
"PostTable.selectAll().where { PostTable.authorId eq authorId }" in src, src)
132+
assertTrue(
133+
"CommentTable.selectAll().where { CommentTable.authorId eq authorId }" in src, src)
134+
// Sanity: exactly two helper fns.
135+
val fnCount = src.split("fun AuthorTable.").size - 1
136+
assertEquals(2, fnCount,
137+
"expected exactly 2 query helper fns, saw $fnCount in:\n$src")
138+
} finally {
139+
outDir.toFile().deleteRecursively()
140+
}
141+
}
142+
}

0 commit comments

Comments
 (0)