Skip to content

Commit d22c153

Browse files
committed
Merge codegen-kotlin Phase N — close persistence-conformance gaps (11/12)
QueryScenarioRunner gained the missing operator vocabulary: - isNull / isNotNull (via IsNullOp/IsNotNullOp constructors — not top-level fns) - ne (neq), like, gt/gte/lt/lte (with Comparable<T> cast inside SqlExpressionBuilder) - in (inList) - top-level 'and: [...]' filter combinator 5 query scenarios newly green: filter-by-enum, filter-is-null, filter-like-and-ne, filter-range-and, projection-aggregate (last one needed a new ProgramStatView reference table + correlated-subquery CREATE VIEW emitted at runner setup). 1 migration scenario newly green: add-nullable-column via Exposed's SchemaUtils.addMissingColumnsStatements(ProgramV2Table). Sole remaining deferral: drop-table-blocked-without-allow — Exposed's diff helpers only ADD, never DROP; the "blocked destructive change" semantic is part of Java OMDB's SchemaMigrationEngine + AllowOptions with NO substrate analogue in Exposed. Closing it would require a Kotlin migration façade on top of Exposed (~mini migration engine). Lexical SQL divergence between Exposed and Java/TS/C# emitted ALTER syntax handled via sqlSignature() shape-normalizer (case-insensitive, quote/COLUMN-keyword/whitespace tolerant) so structural conformance is verified without lexical coupling. 11 of 12 persistence-conformance scenarios green via Exposed against Testcontainers Postgres.
2 parents 871a797 + 85b0947 commit d22c153

6 files changed

Lines changed: 303 additions & 35 deletions

File tree

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

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

33
import com.metaobjects.integration.kotlin.tables.ProgramTable
4+
import com.metaobjects.integration.kotlin.tables.ProgramV1Table
5+
import com.metaobjects.integration.kotlin.tables.ProgramV2Table
46
import com.metaobjects.integration.kotlin.tables.WeekTable
57
import org.jetbrains.exposed.sql.Database
68
import org.jetbrains.exposed.sql.SchemaUtils
@@ -14,12 +16,26 @@ import kotlin.test.assertTrue
1416
* Migration scenario coverage for the Kotlin/Exposed substrate. Exposed's
1517
* `SchemaUtils.create(*tables)` is the analogue of the sibling Java port's
1618
* "emit + apply CREATE DDL against the live connection" loop — both produce
17-
* the canonical base-table set from an empty Postgres.
19+
* the canonical base-table set from an empty Postgres. For additive column
20+
* migrations, `SchemaUtils.addMissingColumnsStatements(*tables)` is the
21+
* analogue of `SchemaMigrationEngine.emit(...)` producing an ALTER TABLE
22+
* ADD COLUMN statement.
1823
*
19-
* We exercise the `bootstrap-canonical-from-empty` scenario specifically: it
20-
* is the simplest migration in the corpus and its post-condition (the
21-
* `apply-up-then-query` block) lists the expected base-table set, which is
22-
* the natural assertion for the Exposed substrate.
24+
* Scenarios:
25+
* - [bootstrap_canonical_from_empty]: SchemaUtils.create(...) the canonical
26+
* base-table set. Asserts against the apply-up-then-query post-condition.
27+
* - [add_nullable_column]: bootstrap v1 (id + title only), seed two rows,
28+
* invoke addMissingColumnsStatements with v2 → assert it surfaces
29+
* `ALTER TABLE ... ADD COLUMN "subtitle" VARCHAR(200)` (Exposed's quoting
30+
* matches the corpus `up-contains` expectation), apply it, then query.
31+
*
32+
* Deferred:
33+
* - drop-table-blocked-without-allow: needs a destructive-change-blocking
34+
* pipeline. Exposed has no analogue — its diff helpers
35+
* (`addMissingColumnsStatements` / `statementsRequiredToActualizeScheme`)
36+
* only ADD, never DROP. The "blocked" semantic is part of OMDB's
37+
* SchemaMigrationEngine + AllowOptions, not the substrate. Re-evaluate
38+
* only if a Kotlin-side migration façade is built on top of Exposed.
2339
*
2440
* NOT registered in the parent reactor — run via:
2541
* mvn -f server/java/integration-tests-kotlin/pom.xml test
@@ -30,8 +46,8 @@ internal class MigrationScenarioConformanceTest {
3046
fun `bootstrap canonical from empty creates the expected base tables`() {
3147
val corpus = ScenarioLoader.findCorpusRoot()
3248
val scenarios = ScenarioLoader.loadMigrations(corpus.resolve("migrations"))
33-
val bootstrap = scenarios.singleOrNull { it.name == BOOTSTRAP_NAME }
34-
?: error("Expected scenario '$BOOTSTRAP_NAME' in corpus")
49+
val bootstrap = scenarios.singleOrNull { it.name == "bootstrap-canonical-from-empty" }
50+
?: error("Expected scenario 'bootstrap-canonical-from-empty' in corpus")
3551

3652
PostgresContainer().use { pg ->
3753
val db = Database.connect(pg.jdbcUrl, user = pg.username, password = pg.password)
@@ -76,6 +92,109 @@ internal class MigrationScenarioConformanceTest {
7692
}
7793
}
7894

95+
@Test
96+
fun `add nullable column emits ALTER TABLE ADD COLUMN and preserves existing rows`() {
97+
val corpus = ScenarioLoader.findCorpusRoot()
98+
val scenarios = ScenarioLoader.loadMigrations(corpus.resolve("migrations"))
99+
val scenario = scenarios.singleOrNull { it.name == "add-nullable-column" }
100+
?: error("Expected scenario 'add-nullable-column' in corpus")
101+
102+
PostgresContainer().use { pg ->
103+
val db = Database.connect(pg.jdbcUrl, user = pg.username, password = pg.password)
104+
105+
// 1. Bootstrap the v1 schema — `id` + `title` only.
106+
transaction(db) {
107+
SchemaUtils.create(ProgramV1Table)
108+
}
109+
110+
// 2. Seed the v1 rows from the scenario's seed-data SQL.
111+
scenario.seedData?.takeIf { it.isNotBlank() }?.let { sql ->
112+
DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c ->
113+
c.createStatement().use { it.execute(sql) }
114+
}
115+
}
116+
117+
// 3. Generate ALTER statements from the v2 shape vs the live schema.
118+
// `addMissingColumnsStatements` is Exposed's diff-and-emit helper for
119+
// additive column changes — the substrate-level analogue of the
120+
// sibling Java port's SchemaMigrationEngine.emit() for ADD COLUMN.
121+
val alterStatements = transaction(db) {
122+
SchemaUtils.addMissingColumnsStatements(ProgramV2Table)
123+
}
124+
125+
// 4. Substrate-aware up-contains assertion.
126+
// The corpus's up-contains needles are Java/TS/C#-emitter-shaped
127+
// (`ALTER TABLE "programs" ADD COLUMN "subtitle" VARCHAR(200)`).
128+
// Exposed's emitter produces semantically-equivalent SQL with
129+
// different surface form: no identifier quoting on bare lowercase
130+
// tables, no `COLUMN` keyword, trailing `NULL` for nullable.
131+
// We normalize both sides to a shape-only signature and assert
132+
// the structural pieces match — captures the real conformance
133+
// contract (ADD `subtitle` of type VARCHAR(200) on `programs`)
134+
// while tolerating the substrate's lexical divergence.
135+
val joined = alterStatements.joinToString("\n")
136+
for (needle in scenario.expect.upContains) {
137+
val sig = sqlSignature(needle)
138+
val joinedSig = sqlSignature(joined)
139+
assertTrue(
140+
joinedSig.contains(sig),
141+
"expected ALTER statements to contain shape-equivalent of '$needle'\n" +
142+
" needle signature : '$sig'\n" +
143+
" emitted SQL : $joined\n" +
144+
" emitted signature: '$joinedSig'"
145+
)
146+
}
147+
148+
// 5. Apply the emitted statements then run the post-condition query.
149+
transaction(db) {
150+
for (stmt in alterStatements) {
151+
exec(stmt)
152+
}
153+
}
154+
155+
val auq = scenario.expect.applyUpThenQuery
156+
?: error("add-nullable-column scenario must declare apply-up-then-query")
157+
val actualRows = DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c ->
158+
c.createStatement().use { stmt ->
159+
stmt.executeQuery(auq.sql).use { rs ->
160+
val out = mutableListOf<Map<String, Any?>>()
161+
val meta = rs.metaData
162+
while (rs.next()) {
163+
val row = LinkedHashMap<String, Any?>(meta.columnCount)
164+
for (i in 1..meta.columnCount) row[meta.getColumnLabel(i)] = rs.getObject(i)
165+
out.add(row)
166+
}
167+
out
168+
}
169+
}
170+
}
171+
val expectedJson = Normalization.canonicalRowsJson(auq.rows)
172+
val actualJson = Normalization.canonicalRowsJson(actualRows)
173+
assertEquals(expectedJson, actualJson, "add-nullable-column post-query mismatch")
174+
}
175+
}
176+
177+
/**
178+
* Normalize a SQL fragment to a shape-only signature so different emitters
179+
* (Java/TS/C# vs Exposed) can be compared structurally without coupling to
180+
* each substrate's lexical choices (identifier quoting, optional COLUMN
181+
* keyword, trailing NULL/NOT NULL on additive nullable columns).
182+
*
183+
* Strips:
184+
* - all double quotes (`"id"` → `id`)
185+
* - the `COLUMN` keyword between `ADD` and the column name
186+
* - trailing `NULL` (additive nullable column — implicit in both shapes)
187+
* - all whitespace runs collapsed to a single space
188+
* - lower-cases everything for case-insensitive keyword matching
189+
*/
190+
private fun sqlSignature(sql: String): String =
191+
sql.replace("\"", "")
192+
.replace(Regex("\\bCOLUMN\\b", RegexOption.IGNORE_CASE), "")
193+
.replace(Regex("\\bNULL\\b\\s*$", RegexOption.IGNORE_CASE), "")
194+
.replace(Regex("\\s+"), " ")
195+
.trim()
196+
.lowercase()
197+
79198
private fun assertColumnsExist(pg: PostgresContainer, table: String, expectedColumns: List<String>) {
80199
val sql = """
81200
SELECT column_name FROM information_schema.columns
@@ -98,8 +217,4 @@ internal class MigrationScenarioConformanceTest {
98217
)
99218
}
100219
}
101-
102-
companion object {
103-
private const val BOOTSTRAP_NAME = "bootstrap-canonical-from-empty"
104-
}
105220
}

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

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -35,30 +35,29 @@ internal class QueryScenarioConformanceTest {
3535

3636
companion object {
3737
/**
38-
* Curated Day-1 subset — these scenarios only use op=count|get|list with at
39-
* most `eq` filters and ascending sorts, all of which the Exposed runner
40-
* handles today. Listed explicitly (not derived) so a new scenario file
41-
* lands as 'skipped' rather than silently expanding the suite.
38+
* Included scenarios — these run end-to-end against the Exposed substrate.
39+
* Listed explicitly (not derived) so a new scenario file lands as 'skipped'
40+
* rather than silently expanding the suite.
4241
*/
4342
private val INCLUDED_SCENARIOS = setOf(
4443
"count",
4544
"get-by-id",
4645
"list-empty-table",
4746
"list-programs-sorted",
47+
"filter-by-enum",
48+
"filter-is-null",
49+
"filter-like-and-ne",
50+
"filter-range-and",
51+
"projection-aggregate",
4852
)
4953

5054
/**
51-
* Scenarios deliberately left out of the Day-1 subset, with a one-line
52-
* reason. Kept here so the deferral surface is visible in code review.
55+
* Scenarios deliberately left out, with a concrete reason describing
56+
* what would unblock each. Kept visible so the deferral surface is
57+
* obvious in code review.
5358
*/
5459
@Suppress("unused")
55-
private val DEFERRED_SCENARIOS = mapOf(
56-
"filter-by-enum" to "needs eq on string enum — works but corpus also exercises non-eq variants in sibling scenarios; bundled",
57-
"filter-is-null" to "needs isNull operator — not yet wired in QueryScenarioRunner.applyFilter",
58-
"filter-like-and-ne" to "needs like/ne operators — not yet wired",
59-
"filter-range-and" to "needs gt/lt/range operators — not yet wired",
60-
"projection-aggregate" to "needs view tables + aggregate projection — out of subset scope",
61-
)
60+
private val DEFERRED_SCENARIOS = emptyMap<String, String>()
6261

6362
@JvmStatic
6463
fun scenarios(): Stream<Arguments> {

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

Lines changed: 76 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.metaobjects.integration.kotlin
22

33
import com.metaobjects.integration.kotlin.Scenarios.QueryScenario
44
import com.metaobjects.integration.kotlin.Scenarios.QuerySpec
5+
import com.metaobjects.integration.kotlin.tables.ProgramStatView
56
import com.metaobjects.integration.kotlin.tables.ProgramTable
67
import com.metaobjects.integration.kotlin.tables.WeekTable
78
import org.jetbrains.exposed.sql.AndOp
@@ -13,6 +14,8 @@ import org.jetbrains.exposed.sql.ResultRow
1314
import org.jetbrains.exposed.sql.SchemaUtils
1415
import org.jetbrains.exposed.sql.SortOrder
1516
import org.jetbrains.exposed.sql.SqlExpressionBuilder
17+
import org.jetbrains.exposed.sql.IsNotNullOp
18+
import org.jetbrains.exposed.sql.IsNullOp
1619
import org.jetbrains.exposed.sql.Table
1720
import org.jetbrains.exposed.sql.selectAll
1821
import org.jetbrains.exposed.sql.transactions.transaction
@@ -58,6 +61,15 @@ object QueryScenarioRunner {
5861
}
5962
}
6063

64+
// 2b. Projection views — only created when a scenario actually touches
65+
// the relevant projection entity. Cheap and explicit; avoids running
66+
// view DDL on scenarios that don't need it.
67+
if (scenario.queries.any { it.entity == "ProgramStat" }) {
68+
DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c ->
69+
c.createStatement().use { it.execute(ProgramStatView.VIEW_DDL) }
70+
}
71+
}
72+
6173
// 3. Run queries; each gets its own Exposed transaction.
6274
for (spec in scenario.queries) {
6375
val actual = transaction(db) { dispatch(spec) }
@@ -97,6 +109,7 @@ object QueryScenarioRunner {
97109
private fun tableFor(entity: String): Table = when (entity) {
98110
"Program" -> ProgramTable
99111
"Week" -> WeekTable
112+
"ProgramStat" -> ProgramStatView
100113
else -> error("No Exposed Table registered for entity '$entity' — extend QueryScenarioRunner.tableFor")
101114
}
102115

@@ -115,29 +128,81 @@ object QueryScenarioRunner {
115128
}
116129

117130
/**
118-
* Translate a YAML filter block into Exposed `Op<Boolean>`. Only the
119-
* operators surfaced by the curated query-scenario subset are
120-
* implemented (eq); broader operator support belongs in a follow-up
121-
* once more scenarios land.
131+
* Translate a YAML filter block into Exposed `Op<Boolean>`. Supports the
132+
* cross-language filter vocabulary: eq, ne, gt, gte, lt, lte, in, like,
133+
* isNull. Also supports the top-level `and: [...]` combinator the corpus
134+
* uses for two-op-on-one-field range queries (filter-range-and).
122135
*/
123136
@Suppress("UNCHECKED_CAST")
124137
private fun applyFilter(q: Query, table: Table, filter: Map<String, Any?>?) {
125138
if (filter.isNullOrEmpty()) return
139+
val combined = buildFilterOp(table, filter) ?: return
140+
q.adjustWhere { combined }
141+
}
142+
143+
/**
144+
* Recursively build an `Op<Boolean>` from a filter map. A filter map is
145+
* either:
146+
* - a single-entry map `{ and: [<filter>, <filter>, ...] }` (combinator),
147+
* - or a per-field map `{ fieldName: { op: value, ... }, ... }` ANDed together.
148+
*/
149+
@Suppress("UNCHECKED_CAST")
150+
private fun buildFilterOp(table: Table, filter: Map<String, Any?>): Op<Boolean>? {
151+
// Combinator: `and: [...]` — recurse on each child filter map, AND together.
152+
if (filter.size == 1 && filter.containsKey("and")) {
153+
val list = filter["and"] as? List<Map<String, Any?>>
154+
?: error("`and` value must be a list of filter maps; got: ${filter["and"]}")
155+
val childOps = list.mapNotNull { buildFilterOp(table, it) }
156+
return combine(childOps)
157+
}
126158
val ops = mutableListOf<Op<Boolean>>()
127159
for ((field, opsRaw) in filter) {
128160
val col = columnFor(table, field)
129161
val opMap = opsRaw as? Map<String, Any?>
130162
?: error("filter for '$field' must be an op-map (e.g. { eq: ... }); got: $opsRaw")
131163
for ((op, raw) in opMap) {
132-
val coerced = coerce(raw, col)
133-
ops += when (op) {
134-
"eq" -> buildEq(col, coerced)
135-
else -> error("Unsupported filter op '$op' for ${col.name} — extend QueryScenarioRunner.applyFilter")
136-
}
164+
ops += buildOp(col, op, raw)
137165
}
138166
}
139-
val combined = combine(ops) ?: return
140-
q.adjustWhere { combined }
167+
return combine(ops)
168+
}
169+
170+
/**
171+
* Build a single Exposed `Op<Boolean>` from `(column, op-name, raw-value)`.
172+
* `isNull` is value-shaped: `{ isNull: true }` → IS NULL, `{ isNull: false }` → IS NOT NULL.
173+
*/
174+
@Suppress("UNCHECKED_CAST")
175+
private fun buildOp(col: Column<*>, op: String, raw: Any?): Op<Boolean> {
176+
val any = col as Column<Any?>
177+
// Comparable cast — gt/gte/lt/lte require `T: Comparable<T>`; YAML scalars
178+
// (Long, String, Instant) all satisfy that at runtime, but the static type
179+
// system needs the explicit shape. UNCHECKED_CAST suppression covers it.
180+
val cmp = col as Column<Comparable<Any>>
181+
return when (op) {
182+
"eq" -> buildEq(col, coerce(raw, col))
183+
"ne" -> with(SqlExpressionBuilder) { any neq coerce(raw, col) }
184+
"gt" -> with(SqlExpressionBuilder) { cmp greater (coerce(raw, col) as Comparable<Any>) }
185+
"gte" -> with(SqlExpressionBuilder) { cmp greaterEq (coerce(raw, col) as Comparable<Any>) }
186+
"lt" -> with(SqlExpressionBuilder) { cmp less (coerce(raw, col) as Comparable<Any>) }
187+
"lte" -> with(SqlExpressionBuilder) { cmp lessEq (coerce(raw, col) as Comparable<Any>) }
188+
"like" -> {
189+
val pattern = (raw as? String)
190+
?: error("`like` value must be a string pattern; got: $raw")
191+
with(SqlExpressionBuilder) { (col as Column<String>) like pattern }
192+
}
193+
"in" -> {
194+
val list = (raw as? List<Any?>)
195+
?: error("`in` value must be a list; got: $raw")
196+
val coerced = list.map { coerce(it, col) }
197+
with(SqlExpressionBuilder) { any inList coerced }
198+
}
199+
"isNull" -> {
200+
val flag = raw as? Boolean
201+
?: error("`isNull` value must be a boolean; got: $raw")
202+
if (flag) IsNullOp(col) else IsNotNullOp(col)
203+
}
204+
else -> error("Unsupported filter op '$op' for ${col.name} — extend QueryScenarioRunner.buildOp")
205+
}
141206
}
142207

143208
/**
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.metaobjects.integration.kotlin.tables
2+
3+
import org.jetbrains.exposed.sql.Table
4+
5+
/**
6+
* Hand-written reference Exposed Table mapping the `ProgramStat` projection
7+
* from `fixtures/persistence-conformance/canonical/meta.fitness.json`.
8+
*
9+
* Backed by the Postgres VIEW `v_program_stat` (NOT a base table) — see
10+
* [VIEW_DDL] for the correlated-subquery aggregate body the
11+
* [com.metaobjects.integration.kotlin.QueryScenarioRunner] issues alongside
12+
* `SchemaUtils.create(...)` for scenarios touching this projection.
13+
*
14+
* Exposed has no first-class "view" Table — using `Table("v_program_stat")`
15+
* is the conventional Exposed pattern for read-only SELECTs over a view:
16+
* `selectAll()` issues `SELECT * FROM v_program_stat`, exactly what the
17+
* projection-aggregate scenario expects. We never call `SchemaUtils.create`
18+
* on this object — the view is created via raw SQL.
19+
*/
20+
object ProgramStatView : Table("v_program_stat") {
21+
val programId = long("programId")
22+
val weekCount = long("weekCount")
23+
24+
override val primaryKey = PrimaryKey(programId)
25+
26+
/**
27+
* Correlated-subquery aggregate body, matching the metadata's
28+
* `origin.aggregate{@agg: count, @of: Week.id, @via: Program.weeks}`
29+
* shape and the canonical view-DDL pattern documented in
30+
* `docs/superpowers/specs/2026-05-22-fr-003-*` for cross-language ports.
31+
*
32+
* Identifier quoting matches the seed DDL: bare lowercase for table
33+
* names, double-quoted camelCase for column names — same convention
34+
* `SchemaUtils.create(ProgramTable, WeekTable)` produces.
35+
*/
36+
const val VIEW_DDL =
37+
"""CREATE OR REPLACE VIEW "v_program_stat" AS
38+
SELECT p."id" AS "programId",
39+
(SELECT COUNT(w."id") FROM "weeks" w WHERE w."programId" = p."id") AS "weekCount"
40+
FROM "programs" p"""
41+
}

0 commit comments

Comments
 (0)