|
| 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 | +} |
0 commit comments