Skip to content

Commit ba7f2dd

Browse files
dmealingclaude
andcommitted
feat(integration-tests-kotlin): close drop-blocked scenario via ExposedMigrationEngine — 12/12 conformance
Small Kotlin migration facade on top of JetBrains Exposed that adds the one substrate-level capability Exposed itself does not provide: a status pass that classifies planned changes as additive vs destructive and blocks the destructive ones unless the caller opts in via AllowOptions. Mirrors the sibling Java port's SchemaMigrationEngine + AllowOptions + BlockedChangesError contract. Scope intentionally narrow — table-level CreateTable / DropTable, which is exactly what the corpus exercises on Kotlin. Column-level additive diffs stay delegated to Exposed's addMissingColumnsStatements (no gate needed — never destructive). New scenario `drop-table-blocked-without-allow`: bootstraps a programs table from v1 metadata, targets empty metadata, drives diff() with allowDropTable=false. Asserts blocked.isNotEmpty(), apply() throws BlockedChangesError naming the `allow.dropTable` flag, programs survives the blocked apply, and the opposite leg (allowDropTable=true) actually drops it proving the gate is policy not substrate. Kotlin persistence-conformance corpus coverage: 12/12 (9 query + 3 migration). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 85b0947 commit ba7f2dd

2 files changed

Lines changed: 284 additions & 8 deletions

File tree

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package com.metaobjects.integration.kotlin
2+
3+
import org.jetbrains.exposed.sql.SchemaUtils
4+
import org.jetbrains.exposed.sql.Table
5+
import org.jetbrains.exposed.sql.transactions.transaction
6+
import java.sql.Connection
7+
8+
/**
9+
* Small Kotlin migration façade on top of JetBrains Exposed that adds the
10+
* single substrate-level capability Exposed itself does not provide: a
11+
* status pass that **classifies** planned changes as additive vs destructive
12+
* and **blocks** the destructive ones unless the caller opts in via
13+
* [AllowOptions].
14+
*
15+
* This closes the last persistence-conformance gap for the Kotlin port —
16+
* the `drop-table-blocked-without-allow` scenario, where dropping an entity
17+
* from metadata produces a drop-table change that the migration engine must
18+
* refuse to apply without an explicit `allow.dropTable=true`.
19+
*
20+
* Mirrors the contract surfaced by the sibling Java port's
21+
* `SchemaMigrationEngine` + `AllowOptions` + `BlockedChangesError`
22+
* (see `server/java/omdb/src/main/java/com/metaobjects/manager/db/migrate/`).
23+
* Scope is intentionally narrow: table-level adds + drops only — that is all
24+
* the corpus scenarios actually exercise on Kotlin today. Column-level diffs
25+
* stay delegated to Exposed's `addMissingColumnsStatements` (additive-only
26+
* by design — no policy gate needed).
27+
*/
28+
29+
/** Opt-in flags for destructive changes. Defaults to no-op (everything blocked). */
30+
data class AllowOptions(
31+
val allowDropTable: Boolean = false,
32+
val allowDropColumn: Boolean = false,
33+
val allowTypeChange: Boolean = false,
34+
)
35+
36+
/** A single planned change, classified by destructiveness. */
37+
sealed interface Change {
38+
/** Whether this change destroys schema or data. */
39+
val isDestructive: Boolean
40+
41+
/** Short symbolic kind name, matches the corpus YAML `blocked[].kind` value. */
42+
val kind: String
43+
44+
data class CreateTable(val table: Table) : Change {
45+
override val isDestructive = false
46+
override val kind = "CreateTable"
47+
}
48+
49+
data class DropTable(val tableName: String) : Change {
50+
override val isDestructive = true
51+
override val kind = "DropTable"
52+
}
53+
}
54+
55+
/** Result of diffing expected (Kotlin Tables) vs actual (live DB schema). */
56+
data class DiffResult(
57+
/** Changes that are either additive, or destructive-but-allowed by the [AllowOptions]. */
58+
val changes: List<Change>,
59+
/** Destructive changes whose `allow.*` flag is not set. */
60+
val blocked: List<Change>,
61+
)
62+
63+
/**
64+
* Thrown when [ExposedMigrationEngine.apply] sees destructive changes that
65+
* were not allowed by the [AllowOptions]. Message names the flag that would
66+
* unblock each one — matches the corpus YAML `reason-contains: 'allow.dropTable'`
67+
* assertion shape.
68+
*/
69+
class BlockedChangesError(val blocked: List<Change>) :
70+
RuntimeException(buildMessage(blocked)) {
71+
72+
companion object {
73+
/** Map a change's kind to the `AllowOptions` flag name that unblocks it. */
74+
private val ENABLE_FLAG: Map<String, String> = mapOf(
75+
"DropTable" to "allow.dropTable",
76+
"DropColumn" to "allow.dropColumn",
77+
"ChangeColumnType" to "allow.typeChange",
78+
)
79+
80+
private fun buildMessage(blocked: List<Change>): String {
81+
val sb = StringBuilder(blocked.size.toString())
82+
sb.append(" blocked change(s):")
83+
for (c in blocked) {
84+
val locator = when (c) {
85+
is Change.DropTable -> c.tableName
86+
is Change.CreateTable -> c.table.tableName
87+
}
88+
val flag = ENABLE_FLAG[c.kind] ?: "(no flag enables this)"
89+
sb.append("\n - ").append(c.kind).append(" on ").append(locator)
90+
.append(": pass ").append(flag)
91+
}
92+
return sb.toString()
93+
}
94+
}
95+
}
96+
97+
object ExposedMigrationEngine {
98+
99+
/**
100+
* Compute the diff between the expected Exposed `Table` set and the live
101+
* DB schema reachable through [connection]. Classifies each change as
102+
* additive vs destructive and partitions into `(allowed, blocked)` based
103+
* on [allow].
104+
*
105+
* Scope: table presence only (`CreateTable` / `DropTable`). Column-level
106+
* additive changes are computed by Exposed's `addMissingColumnsStatements`
107+
* directly at the call site — they are never destructive so they need no
108+
* gate.
109+
*/
110+
fun diff(connection: Connection, expectedTables: List<Table>, allow: AllowOptions): DiffResult {
111+
val actualTableNames = listLiveTableNames(connection)
112+
val expectedTableNames = expectedTables.map { it.tableName }.toSet()
113+
114+
val planned = mutableListOf<Change>()
115+
for (t in expectedTables) {
116+
if (t.tableName !in actualTableNames) planned.add(Change.CreateTable(t))
117+
}
118+
for (a in actualTableNames) {
119+
if (a !in expectedTableNames) planned.add(Change.DropTable(a))
120+
}
121+
122+
val (allowed, blocked) = planned.partition { allowsChange(it, allow) }
123+
return DiffResult(allowed, blocked)
124+
}
125+
126+
/**
127+
* Apply allowed changes via Exposed. Throws [BlockedChangesError] if any
128+
* blocked changes are present — nothing is executed in that case
129+
* (matches the corpus contract: `No 'apply-up-then-query' — the up SQL
130+
* is non-empty but blocked; runner should NOT execute it`).
131+
*/
132+
fun apply(
133+
connection: Connection,
134+
database: org.jetbrains.exposed.sql.Database,
135+
expectedTables: List<Table>,
136+
allow: AllowOptions,
137+
) {
138+
val diff = diff(connection, expectedTables, allow)
139+
if (diff.blocked.isNotEmpty()) throw BlockedChangesError(diff.blocked)
140+
141+
// Partition by kind — Exposed's CREATE path takes a vararg of Tables,
142+
// drops take a vararg of Tables too (we synthesize a stub Table for
143+
// bare tableName strings — but the corpus scenarios that produce drops
144+
// start from an actual Exposed Table at seed time, so this path is
145+
// exercised differently per scenario).
146+
val drops = diff.changes.filterIsInstance<Change.DropTable>()
147+
val creates = diff.changes.filterIsInstance<Change.CreateTable>().map { it.table }
148+
149+
transaction(database) {
150+
if (creates.isNotEmpty()) SchemaUtils.create(*creates.toTypedArray())
151+
// For drops we use raw SQL — Exposed's `SchemaUtils.drop` requires
152+
// a `Table` instance, not a bare name. Quote the identifier to
153+
// match the live-schema casing returned by JDBC metadata.
154+
for (drop in drops) {
155+
exec("""DROP TABLE IF EXISTS "${drop.tableName}"""")
156+
}
157+
}
158+
}
159+
160+
private fun allowsChange(change: Change, allow: AllowOptions): Boolean = when (change) {
161+
is Change.DropTable -> allow.allowDropTable
162+
is Change.CreateTable -> true
163+
}
164+
165+
/**
166+
* List base tables in the `public` schema via JDBC metadata. Mirrors the
167+
* corpus's `apply-up-then-query` baseline (`information_schema.tables`
168+
* filtered to `table_schema = 'public' AND table_type = 'BASE TABLE'`).
169+
*/
170+
private fun listLiveTableNames(connection: Connection): Set<String> {
171+
val out = mutableSetOf<String>()
172+
connection.metaData.getTables(null, "public", "%", arrayOf("TABLE")).use { rs ->
173+
while (rs.next()) out.add(rs.getString("TABLE_NAME"))
174+
}
175+
return out
176+
}
177+
}

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

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import org.jetbrains.exposed.sql.Database
88
import org.jetbrains.exposed.sql.SchemaUtils
99
import org.jetbrains.exposed.sql.transactions.transaction
1010
import org.junit.jupiter.api.Test
11+
import org.junit.jupiter.api.assertThrows
1112
import java.sql.DriverManager
1213
import kotlin.test.assertEquals
14+
import kotlin.test.assertFalse
1315
import kotlin.test.assertTrue
1416

1517
/**
@@ -28,14 +30,13 @@ import kotlin.test.assertTrue
2830
* invoke addMissingColumnsStatements with v2 → assert it surfaces
2931
* `ALTER TABLE ... ADD COLUMN "subtitle" VARCHAR(200)` (Exposed's quoting
3032
* 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.
33+
* - [drop_table_blocked_without_allow]: bootstrap v1 (`programs`), declare
34+
* an empty expected table set (the target metadata has no entities), and
35+
* drive the diff through [ExposedMigrationEngine]. Asserts that
36+
* [ExposedMigrationEngine.diff] surfaces a blocked DropTable, that
37+
* [ExposedMigrationEngine.apply] throws [BlockedChangesError], and that
38+
* the `programs` table is NOT dropped from the live schema — closes the
39+
* last persistence-conformance gap for the Kotlin port.
3940
*
4041
* NOT registered in the parent reactor — run via:
4142
* mvn -f server/java/integration-tests-kotlin/pom.xml test
@@ -174,6 +175,104 @@ internal class MigrationScenarioConformanceTest {
174175
}
175176
}
176177

178+
@Test
179+
fun `drop table blocked without allow refuses to drop and surfaces the block`() {
180+
val corpus = ScenarioLoader.findCorpusRoot()
181+
val scenarios = ScenarioLoader.loadMigrations(corpus.resolve("migrations"))
182+
val scenario = scenarios.singleOrNull { it.name == "drop-table-blocked-without-allow" }
183+
?: error("Expected scenario 'drop-table-blocked-without-allow' in corpus")
184+
185+
PostgresContainer().use { pg ->
186+
val db = Database.connect(pg.jdbcUrl, user = pg.username, password = pg.password)
187+
188+
// 1. Bootstrap the seed-metadata state — `programs` table with id + title.
189+
// Mirrors the v1 metadata in fixtures/persistence-conformance/migrations/states/program-v1/.
190+
transaction(db) {
191+
SchemaUtils.create(ProgramV1Table)
192+
}
193+
194+
// 2. Build the "expected" Table set for the target metadata. The
195+
// target is empty (`target-metadata-inline: { ... children: [] }`),
196+
// so the expected set is empty too — the diff should surface a
197+
// drop-table for `programs`.
198+
val expectedTables = emptyList<org.jetbrains.exposed.sql.Table>()
199+
200+
// 3. Drive the diff with allowDropTable = false (the scenario's
201+
// "without-allow" leg). Confirm the destructive change is
202+
// classified as blocked, not allowed.
203+
val diff = DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c ->
204+
ExposedMigrationEngine.diff(c, expectedTables, AllowOptions(allowDropTable = false))
205+
}
206+
207+
assertTrue(
208+
diff.blocked.isNotEmpty(),
209+
"expected drop-table to be blocked without allowDropTable; diff=$diff"
210+
)
211+
assertTrue(
212+
diff.blocked.any { it is Change.DropTable && it.tableName == "programs" },
213+
"expected blocked DropTable for 'programs'; blocked=${diff.blocked}"
214+
)
215+
216+
// 4. Cross-check against the corpus expectation shape:
217+
// blocked[].kind == 'DropTable' and reason-contains 'allow.dropTable'.
218+
for (expectedBlock in scenario.expect.blocked) {
219+
val match = diff.blocked.any { it.kind == expectedBlock.kind }
220+
assertTrue(match, "expected blocked kind '${expectedBlock.kind}' not found in $diff")
221+
}
222+
223+
// 5. Calling apply() must throw BlockedChangesError, and the message
224+
// must name the `allow.dropTable` flag (the corpus's
225+
// `reason-contains` value).
226+
val err = assertThrows<BlockedChangesError> {
227+
DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c ->
228+
ExposedMigrationEngine.apply(c, db, expectedTables, AllowOptions(allowDropTable = false))
229+
}
230+
}
231+
for (expectedBlock in scenario.expect.blocked) {
232+
val needle = expectedBlock.reasonContains
233+
if (needle.isNotBlank()) {
234+
assertTrue(
235+
err.message!!.contains(needle),
236+
"expected BlockedChangesError message to contain '$needle'; got: ${err.message}"
237+
)
238+
}
239+
}
240+
241+
// 6. Critically: the `programs` table must still exist in the live
242+
// schema. The corpus contract is "runner should NOT execute the
243+
// up SQL when blocked" — apply() must be transactionally inert.
244+
val liveTables = DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c ->
245+
val out = mutableSetOf<String>()
246+
c.metaData.getTables(null, "public", "%", arrayOf("TABLE")).use { rs ->
247+
while (rs.next()) out.add(rs.getString("TABLE_NAME"))
248+
}
249+
out
250+
}
251+
assertTrue(
252+
"programs" in liveTables,
253+
"expected 'programs' to still exist after blocked apply; liveTables=$liveTables"
254+
)
255+
256+
// 7. Sanity: passing allowDropTable=true allows the drop through —
257+
// proves the gate is the policy, not the substrate. (Same
258+
// engine, opposite leg of the same scenario.)
259+
DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c ->
260+
ExposedMigrationEngine.apply(c, db, expectedTables, AllowOptions(allowDropTable = true))
261+
}
262+
val liveTablesAfterAllow = DriverManager.getConnection(pg.jdbcUrl, pg.username, pg.password).use { c ->
263+
val out = mutableSetOf<String>()
264+
c.metaData.getTables(null, "public", "%", arrayOf("TABLE")).use { rs ->
265+
while (rs.next()) out.add(rs.getString("TABLE_NAME"))
266+
}
267+
out
268+
}
269+
assertFalse(
270+
"programs" in liveTablesAfterAllow,
271+
"expected 'programs' to be dropped once allowDropTable=true; liveTables=$liveTablesAfterAllow"
272+
)
273+
}
274+
}
275+
177276
/**
178277
* Normalize a SQL fragment to a shape-only signature so different emitters
179278
* (Java/TS/C# vs Exposed) can be compared structurally without coupling to

0 commit comments

Comments
 (0)