Skip to content

Commit 9456a40

Browse files
dmealingclaude
andcommitted
feat(codegen-kotlin): FR-017 Unit7 — Kotlin runtime M:N resolver (hetero/self-join/symmetric)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de1458a commit 9456a40

5 files changed

Lines changed: 244 additions & 5 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
package com.metaobjects.integration.kotlin
2+
3+
import com.metaobjects.MetaData
4+
import com.metaobjects.MetaRoot
5+
import com.metaobjects.`object`.MetaObject
6+
import com.metaobjects.relationship.M2MFields
7+
import com.metaobjects.relationship.MetaRelationship
8+
import java.sql.Connection
9+
10+
/**
11+
* Generic, metadata-driven M:N query resolver for the Kotlin/Exposed runtime.
12+
*
13+
* Kotlin port of the TS reference (`runtime-ts/src/n2m-resolver.ts`) and the Java
14+
* OMDB `M2MResolver`. Given a source entity and an M:N relationship
15+
* (`@cardinality: "many"` + `@objectRef: <target>` + `@through: <junction>`),
16+
* resolve the related target rows by traversing the junction. The junction FK
17+
* columns are NOT restated on the relationship — they are DERIVED from the
18+
* junction entity's two `identity.reference` children via the shared JVM
19+
* [M2MFields.derive] helper (the cross-port SSOT for FK direction), exactly as
20+
* the Java OMDB resolver does.
21+
*
22+
* The Exposed substrate here uses raw JDBC against the open transaction
23+
* connection rather than typed `Table` objects: the M:N corpus entities (Post /
24+
* Tag / Person / Follow / Friendship / PostTag) carry no hand-written Exposed
25+
* `Table`, and the physical table + column + PK names are fully described by the
26+
* loaded metadata — so the resolver reads them from the metadata and addresses
27+
* the DB generically (no per-entity Exposed mapping needed).
28+
*
29+
* Three modes (mirroring TS / Java):
30+
* 1. **Hetero** (source != target): query the junction `WHERE sourceFK = sourceId`,
31+
* collect `targetFK`, load the targets by PK.
32+
* 2. **Directed self-join** (`@sourceRefField`): identical traversal;
33+
* [M2MFields.derive] has already picked which junction FK is the source side,
34+
* so direction is honored.
35+
* 3. **Symmetric self-join** (`@symmetric: true`): single-row storage, union on
36+
* read — query `WHERE sourceFK = id OR targetFK = id`; for each junction row the
37+
* related id is whichever FK column is NOT the source id (a self-pair row `(a,a)`
38+
* yields the source id itself).
39+
*/
40+
object M2MResolver {
41+
42+
/**
43+
* Resolve the related target rows for an M:N relationship.
44+
*
45+
* @param conn the open JDBC connection (from the Exposed transaction)
46+
* @param sourceMeta the source entity's metadata
47+
* @param sourceId the source row's primary-key value (from the scenario `by:`)
48+
* @param rel the M:N relationship declared on [sourceMeta]
49+
* @param root the loaded model root (to find junction + target entities)
50+
* @return the related target rows as column-name-keyed maps (empty when none)
51+
*/
52+
fun resolve(
53+
conn: Connection,
54+
sourceMeta: MetaObject,
55+
sourceId: Any?,
56+
rel: MetaRelationship,
57+
root: MetaRoot,
58+
): List<Map<String, Any?>> {
59+
if (sourceId == null) return emptyList()
60+
61+
val fields = M2MFields.derive(rel, sourceMeta, root)
62+
val junction = mustGetEntity(root, rel.through)
63+
val target = mustGetEntity(root, rel.objectRef)
64+
65+
val junctionTable = tableName(junction)
66+
val sourceFk = fields.sourceField
67+
val targetFk = fields.targetField
68+
69+
// 1. Query the junction for matching rows.
70+
// hetero / directed: sourceFK = sourceId
71+
// symmetric: sourceFK = sourceId OR targetFK = sourceId (union-on-read)
72+
val whereSql = if (rel.isSymmetric) {
73+
"\"$sourceFk\" = ? OR \"$targetFk\" = ?"
74+
} else {
75+
"\"$sourceFk\" = ?"
76+
}
77+
val params = if (rel.isSymmetric) listOf(sourceId, sourceId) else listOf(sourceId)
78+
79+
// 2. Collect the distinct related target ids (preserve first-seen order).
80+
val targetIds = LinkedHashSet<Any?>()
81+
conn.prepareStatement(
82+
"SELECT \"$sourceFk\", \"$targetFk\" FROM \"$junctionTable\" WHERE $whereSql"
83+
).use { ps ->
84+
params.forEachIndexed { i, v -> ps.setObject(i + 1, v) }
85+
ps.executeQuery().use { rs ->
86+
while (rs.next()) {
87+
val a = rs.getObject(sourceFk)
88+
val b = rs.getObject(targetFk)
89+
val relatedId = if (rel.isSymmetric) {
90+
// The related id is whichever FK column is NOT the source id.
91+
if (keyEquals(a, sourceId)) b else a
92+
} else {
93+
b
94+
}
95+
if (relatedId != null) targetIds.add(relatedId)
96+
}
97+
}
98+
}
99+
if (targetIds.isEmpty()) return emptyList()
100+
101+
// 3. Load the targets by PK.
102+
val targetTable = tableName(target)
103+
val targetPk = primaryKeyField(target)
104+
val targetCols = columnNames(target)
105+
val placeholders = targetIds.joinToString(",") { "?" }
106+
val rows = ArrayList<Map<String, Any?>>(targetIds.size)
107+
conn.prepareStatement(
108+
"SELECT * FROM \"$targetTable\" WHERE \"$targetPk\" IN ($placeholders)"
109+
).use { ps ->
110+
targetIds.forEachIndexed { i, id -> ps.setObject(i + 1, id) }
111+
ps.executeQuery().use { rs ->
112+
while (rs.next()) {
113+
val row = LinkedHashMap<String, Any?>(targetCols.size)
114+
for (col in targetCols) row[col] = rs.getObject(col)
115+
rows.add(row)
116+
}
117+
}
118+
}
119+
return rows
120+
}
121+
122+
// --- helpers -----------------------------------------------------------
123+
124+
/** The physical table name of an entity (`source.rdb @table`). */
125+
private fun tableName(mc: MetaObject): String =
126+
mc.primaryRdbTableName
127+
?: error("Entity '${mc.shortName}' has no source.rdb @table — required for M:N resolution")
128+
129+
/** The single primary-key field name of an entity. */
130+
private fun primaryKeyField(mc: MetaObject): String {
131+
val pk = mc.primaryIdentity
132+
?: error("Entity '${mc.shortName}' has no identity.primary")
133+
val fields = pk.fields
134+
require(!fields.isNullOrEmpty()) { "identity.primary on '${mc.shortName}' declares no @fields" }
135+
return fields[0]
136+
}
137+
138+
/** Physical column names of an entity (metadata field names; the corpus declares no @column renames). */
139+
private fun columnNames(mc: MetaObject): List<String> = mc.metaFields.map { it.name }
140+
141+
/**
142+
* Compare two key values by string-coerced identity. The source id comes from
143+
* the scenario `by:` (e.g. an Int) while the junction FK value comes straight
144+
* off the driver (which may surface a BIGINT key as a different numeric type);
145+
* comparing by toString() bridges that the same way the TS / Java resolvers do.
146+
*/
147+
private fun keyEquals(a: Any?, b: Any?): Boolean {
148+
if (a == null || b == null) return a === b
149+
return a.toString() == b.toString()
150+
}
151+
152+
private fun mustGetEntity(root: MetaRoot, name: String?): MetaObject {
153+
val bare = stripPackage(name)
154+
for (child in root.getChildren(MetaObject::class.java, false)) {
155+
if (bare == child.shortName) return child
156+
}
157+
error("Entity '$name' not found in model root")
158+
}
159+
160+
private fun stripPackage(name: String?): String? {
161+
if (name == null) return null
162+
val idx = name.lastIndexOf(MetaData.PKG_SEPARATOR)
163+
return if (idx >= 0) name.substring(idx + MetaData.PKG_SEPARATOR.length) else name
164+
}
165+
}

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/Normalization.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,18 @@ object Normalization {
127127
val normalized = rows.map { normalizeRow(it) }
128128
return String(mapper.writeValueAsBytes(normalized), StandardCharsets.UTF_8)
129129
}
130+
131+
/**
132+
* Canonical JSON for an ORDER-INDEPENDENT row set (`op:relate` — M:N navigation).
133+
* Mirrors the Java / TS runners' `canonicalRowSet`: each row is normalized +
134+
* serialized, then the per-row JSON strings are sorted so the comparison is
135+
* port-agnostic regardless of the order the resolver returns the related rows.
136+
* (`op:list` keeps its order — the scenario pins it via `sort:`.)
137+
*/
138+
fun canonicalRowSet(rows: List<Map<String, Any?>>): String {
139+
val each = rows.map { row ->
140+
String(mapper.writeValueAsBytes(normalizeRow(row)), StandardCharsets.UTF_8)
141+
}.sorted()
142+
return "[" + each.joinToString(",") + "]"
143+
}
130144
}

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/QueryScenarioRunner.kt

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package com.metaobjects.integration.kotlin
22

33
import com.fasterxml.jackson.databind.ObjectMapper
4+
import com.metaobjects.MetaRoot
5+
import com.metaobjects.loader.MetaDataLoader
6+
import com.metaobjects.`object`.MetaObject
7+
import com.metaobjects.relationship.MetaRelationship
48
import com.metaobjects.integration.kotlin.Scenarios.QueryScenario
59
import com.metaobjects.integration.kotlin.Scenarios.QuerySpec
610
import com.metaobjects.integration.kotlin.tables.AssetTable
@@ -60,18 +64,27 @@ object QueryScenarioRunner {
6064
fun run(scenario: QueryScenario, pg: PostgresContainer) {
6165
val db = Database.connect(pg.jdbcUrl, user = pg.username, password = pg.password)
6266

67+
val corpus = ScenarioLoader.findCorpusRoot()
68+
6369
// 1. Provision the schema from the committed canonical DDL (base tables +
6470
// projection views). Executed verbatim on a direct JDBC connection —
6571
// schema authority is the TS-produced artifact, not Exposed.
66-
val schemaDdl = ScenarioLoader.readCanonicalSchema(ScenarioLoader.findCorpusRoot())
72+
val schemaDdl = ScenarioLoader.readCanonicalSchema(corpus)
6773
execSql(pg, schemaDdl)
6874

6975
// 2. Seed via the YAML's raw SQL.
7076
scenario.seedData?.takeIf { it.isNotBlank() }?.let { sql -> execSql(pg, sql) }
7177

72-
// 3. Run queries; each gets its own Exposed transaction.
78+
// 3. Load the canonical metadata root once per scenario — op:relate derives
79+
// the M:N junction FK fields + physical table/column names from it (the
80+
// cross-port SSOT via M2MFields.derive). Tagged uniquely so registry
81+
// state can't leak across scenarios. Non-relate ops never touch it.
82+
val loaderTag = "ktx-query-" + java.util.UUID.randomUUID().toString().substring(0, 8)
83+
val root: MetaRoot = MetaDataLoader.fromDirectory(loaderTag, corpus.resolve("canonical")).root
84+
85+
// 4. Run queries; each gets its own Exposed transaction.
7386
for (spec in scenario.queries) {
74-
val actual = transaction(db) { dispatch(spec) }
87+
val actual = transaction(db) { dispatch(spec, root) }
7588
assertResult(scenario.sourcePath, spec, actual)
7689
}
7790
}
@@ -92,7 +105,11 @@ object QueryScenarioRunner {
92105
// Dispatch
93106
// -----------------------------------------------------------------------
94107

95-
private fun dispatch(spec: QuerySpec): Any? {
108+
private fun dispatch(spec: QuerySpec, root: MetaRoot): Any? {
109+
// op:relate is metadata-driven (M:N junction traversal) — it does NOT go
110+
// through the Exposed Table map; resolve it before tableFor() is consulted.
111+
if (spec.op == "relate") return dispatchRelate(spec, root)
112+
96113
val table = tableFor(spec.entity)
97114
return when (spec.op) {
98115
"count" -> {
@@ -117,6 +134,38 @@ object QueryScenarioRunner {
117134
}
118135
}
119136

137+
/**
138+
* op:relate — traverse an M:N relationship from a single source entity to its
139+
* related target rows. The source id comes straight from the scenario `by:`
140+
* block (e.g. `{ id: 1 }`); the named `relation` is located on the source
141+
* entity's metadata; the junction traversal + target load is delegated to the
142+
* generic metadata-driven [M2MResolver] (which derives the junction FK fields
143+
* via the shared `M2MFields.derive` SSOT). The `relate` verb is order-
144+
* independent (the runner sorts both sides before comparing).
145+
*/
146+
private fun dispatchRelate(spec: QuerySpec, root: MetaRoot): List<Map<String, Any?>> {
147+
val sourceMeta = mustGetEntity(root, spec.entity)
148+
val sourceId = (spec.by ?: emptyMap())["id"]
149+
?: error("op:relate / ${spec.name}: a `by: { id: ... }` source key is required")
150+
val relationName = spec.relation
151+
?: error("op:relate / ${spec.name}: a `relation` (M:N relationship name) is required")
152+
val rel: MetaRelationship = sourceMeta.relationships.firstOrNull { it.shortName == relationName }
153+
?: error(
154+
"op:relate / ${spec.name}: no relationship named '$relationName' on entity " +
155+
"'${sourceMeta.shortName}'"
156+
)
157+
158+
// The underlying java.sql.Connection of the current Exposed transaction.
159+
val jdbc = org.jetbrains.exposed.sql.transactions.TransactionManager
160+
.current().connection.connection as java.sql.Connection
161+
162+
return M2MResolver.resolve(jdbc, sourceMeta, sourceId, rel, root)
163+
}
164+
165+
private fun mustGetEntity(root: MetaRoot, name: String): MetaObject =
166+
root.getChildren(MetaObject::class.java, false).firstOrNull { it.shortName == name }
167+
?: error("Entity '$name' not found in canonical metadata root")
168+
120169
private fun tableFor(entity: String): Table = when (entity) {
121170
"Program" -> ProgramTable
122171
"Week" -> WeekTable
@@ -332,6 +381,11 @@ object QueryScenarioRunner {
332381
val n = (expect as? Number)?.toLong() ?: expect.toString().toLong()
333382
return n.toString()
334383
}
384+
// op:relate is an ORDER-INDEPENDENT set (M:N navigation) — sort both sides.
385+
if (op == "relate") {
386+
if (expect == null) return "[]"
387+
return Normalization.canonicalRowSet(expect as List<Map<String, Any?>>)
388+
}
335389
if (op == "get") {
336390
if (expect == null) return "null"
337391
// Strip the surrounding [] off canonicalRowsJson — get returns the bare object.
@@ -348,6 +402,10 @@ object QueryScenarioRunner {
348402
val n = (actual as? Number)?.toLong() ?: 0L
349403
return n.toString()
350404
}
405+
if (op == "relate") {
406+
if (actual == null) return "[]"
407+
return Normalization.canonicalRowSet(actual as List<Map<String, Any?>>)
408+
}
351409
if (actual == null) return "null"
352410
if (op == "get") {
353411
return Normalization.canonicalRowsJson(listOf(actual as Map<String, Any?>))

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/ScenarioLoader.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ object ScenarioLoader {
9494
sort = sorts,
9595
limit = (q["limit"] as? Number)?.toInt(),
9696
offset = (q["offset"] as? Number)?.toInt(),
97+
relation = q["relation"] as? String,
9798
expect = q["expect"],
9899
)
99100
}

server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/Scenarios.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,14 @@ object Scenarios {
1111

1212
data class QuerySpec(
1313
val name: String,
14-
val op: String, // list | get | count
14+
val op: String, // list | get | count | relate
1515
val entity: String,
1616
val by: Map<String, Any?>? = null,
1717
val filter: Map<String, Any?>? = null,
1818
val sort: List<SortSpec>? = null,
1919
val limit: Int? = null,
2020
val offset: Int? = null,
21+
val relation: String? = null, // op:relate — the M:N relationship name to traverse
2122
val expect: Any? = null,
2223
)
2324

0 commit comments

Comments
 (0)