Skip to content

Commit c50b411

Browse files
emsosamsericksosanNovaMageclaude
authored
fix: ensure schema exists before migrate and cleanAndMigrate (#3)
* fix: ensure schema exists before migrate and cleanAndMigrate Migrator.cleanAndMigrate() and Migrator.migrate() failed unrecoverably when the configured schema did not exist: isSchemaEmpty conflated "schema empty" with "schema missing", so cleanAndMigrate skipped the recreate branch and migrate() then issued an unqualified CREATE TYPE that Postgres rejected with "no schema has been selected to create in". Establish the invariant that the configured schema must exist before any entry point runs. A new autoCreateSchema: Boolean = true constructor flag drives CREATE SCHEMA IF NOT EXISTS at the start of migrate() and cleanAndMigrate(); set to false for least-privilege roles lacking the CREATE privilege. A secondary 5-arg constructor preserves binary compatibility with the 0.1.0 signature for Java callers, pre-compiled binaries, and reflective callers using the old arity. Reflection in NomadPlugin now selects the primary constructor by maximum arity so it remains deterministic with both constructors present. The sbt plugin gains nomadSchema (default "public") and nomadAutoCreateSchema (default true) settings, achieving parity with the library constructor instead of hard-coding values. Scripted test coverage extended on H2 and Postgres: Test 7 asserts the exact PG error surfaces when the opt-out is used (pinning the original bug symptom), and a canonical Test 9 replays the verbatim repro from the bug report (DROP SCHEMA public CASCADE then cleanAndMigrate) plus a second invocation proving the fix is idempotent against the self-perpetuating nature of the original failure. * test: share NixOS-aware Postgres bootstrap between both EmbeddedPostgres instances Test 9 was started with a bare EmbeddedPostgres.start(), bypassing the NOMAD_PG_TARBALL resolver applied to the first instance. On NixOS, the fallback to zonky's bundled generic-Linux binaries failed with the dynamic-linker error from stub-ld. Extract the bootstrap into startEmbeddedPostgres() and route both instances through it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: probe schema existence before CREATE SCHEMA in autoCreateSchema path Postgres checks ACL_CREATE on the database before IF NOT EXISTS short- circuits in CREATE SCHEMA, so the previous unconditional CREATE SCHEMA IF NOT EXISTS regressed least-privilege roles holding USAGE on a pre- existing schema but lacking CREATE on the database — for these callers, the 0.1.0 path that simply migrated against an existing schema would fail under 0.1.1 with a permission error even when no creation is actually needed. Probe via information_schema.schemata (readable by PUBLIC, no privilege required) and only issue CREATE SCHEMA on the missing-schema path. The no-op case for an already-existing schema now costs a single SELECT against a system view and demands no extra privilege, restoring the 0.1.0 privilege model for the common case while keeping the self-heal behavior intact when the schema is genuinely absent. Add Test 10 in clean-and-migrate-postgres exercising the regression directly: a freshly created role with USAGE+CREATE on a pre-existing schema and no CREATE on the database successfully runs migrate() with the default autoCreateSchema=true. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: absorb concurrent-bootstrap race in ensureSchemaExists with IF NOT EXISTS The probe-then-create sequence is TOCTOU-racy on cold start of a fresh database from horizontally scaled apps: two processes can both observe the schema as missing via the probe, then race the CREATE, and the loser fails with 'schema already exists'. Restore IF NOT EXISTS on the create branch only. The probe still filters out the existing-schema case, so least-privilege roles with USAGE on a pre-existing schema remain unaffected — the ACL_CREATE check IF NOT EXISTS triggers is only reached when the schema was actually missing at probe time, where forward progress already demands CREATE on the database. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: ericksosan <erickdev03@gmail.com> Co-authored-by: Angel Blanco <novamage@magaran.com> Co-authored-by: Angel Blanco <angel.softworks@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 60d39d6 commit c50b411

5 files changed

Lines changed: 300 additions & 12 deletions

File tree

core/src/main/scala/nomad/Migrator.scala

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,36 @@ import scala.util.Using
1919
* @param migrations the ordered list of migrations to apply
2020
* @param historyTable the name of the table used to track applied migrations
2121
* @param schema the database schema to use for migrations and history tracking
22+
* @param autoCreateSchema if true (default), the configured schema is created
23+
* at the start of `migrate()` and `cleanAndMigrate()`
24+
* when it does not already exist. Existence is probed
25+
* via `information_schema.schemata` (readable by
26+
* `PUBLIC`) and `CREATE SCHEMA` runs only on the
27+
* missing-schema path, so least-privilege roles with
28+
* `USAGE` on a pre-existing schema are unaffected.
29+
* Set to false when running under a role that must
30+
* never attempt schema creation under any condition.
2231
*/
2332
class Migrator(
2433
datasource: DataSource,
2534
db: SupportedDatabase,
2635
migrations: Vector[Migration],
2736
historyTable: String = "nomad_migrations",
28-
schema: String = "public"
37+
schema: String = "public",
38+
autoCreateSchema: Boolean = true
2939
) {
3040

41+
// Preserves the 5-arg constructor signature shipped in 0.1.0 so that
42+
// Java callers, pre-compiled binaries, and reflective callers using
43+
// the old arity keep working after the 0.1.1 bump.
44+
def this(
45+
datasource: DataSource,
46+
db: SupportedDatabase,
47+
migrations: Vector[Migration],
48+
historyTable: String,
49+
schema: String
50+
) = this(datasource, db, migrations, historyTable, schema, autoCreateSchema = true)
51+
3152
final case class FlywayImportAnalysis(
3253
exactMatchCount: Int,
3354
remappableMismatchCount: Int,
@@ -68,6 +89,7 @@ class Migrator(
6889
def migrate(): Unit = {
6990
val conn = datasource.getConnection
7091
try {
92+
if (autoCreateSchema) ensureSchemaExists(conn)
7193
setSchemaIfNeeded(conn)
7294
ensureHistoryTable(conn)
7395
val applied = loadApplied(conn)
@@ -187,6 +209,7 @@ class Migrator(
187209
def cleanAndMigrate(): Unit = {
188210
val conn = datasource.getConnection
189211
try {
212+
if (autoCreateSchema) ensureSchemaExists(conn)
190213
setSchemaIfNeeded(conn)
191214
if (isSchemaEmpty(conn)) {
192215
logger.info("Instructed to clean schema, but schema is empty, nothing to clean.")
@@ -524,6 +547,41 @@ class Migrator(
524547
}
525548
}
526549

550+
private def ensureSchemaExists(conn: Connection): Unit = {
551+
// Probe via information_schema.schemata (readable by PUBLIC, no privilege
552+
// required) and only issue CREATE SCHEMA when absent. Postgres checks
553+
// ACL_CREATE on the database before IF NOT EXISTS short-circuits, so a
554+
// bare CREATE SCHEMA IF NOT EXISTS would regress least-privilege roles
555+
// that hold USAGE on a pre-existing schema but lack CREATE on the database.
556+
//
557+
// The probe-then-create sequence is TOCTOU-racy on concurrent cold start:
558+
// two horizontally scaled processes can both observe the schema as missing
559+
// and then race the CREATE. Keep IF NOT EXISTS on the create path so the
560+
// loser's statement is a no-op instead of a 'schema already exists' error.
561+
// The ACL_CREATE check IF NOT EXISTS still triggers is acceptable here
562+
// because we only reach this branch when the schema was actually missing
563+
// at probe time — i.e., forward progress already requires CREATE on the
564+
// database, so failing without it is the correct outcome.
565+
if (schemaExists(conn)) return
566+
Using.resource(conn.createStatement()) { stmt =>
567+
db match {
568+
case SupportedDatabase.Postgres =>
569+
stmt.execute(s"""CREATE SCHEMA IF NOT EXISTS "$schema"""")
570+
case SupportedDatabase.H2 =>
571+
stmt.execute(s"""CREATE SCHEMA IF NOT EXISTS "$schema"""")
572+
}
573+
}
574+
}
575+
576+
private def schemaExists(conn: Connection): Boolean = {
577+
Using.resource(
578+
conn.prepareStatement("SELECT 1 FROM information_schema.schemata WHERE schema_name = ?")
579+
) { ps =>
580+
ps.setString(1, schema)
581+
Using.resource(ps.executeQuery())(_.next())
582+
}
583+
}
584+
527585
/** Light validation for cleanAndMigrate: only checks that the history table exists by name.
528586
* This confirms the schema is managed by Nomad without requiring specific columns,
529587
* since the entire schema (including the table) is about to be dropped and recreated.

sbt-plugin/src/main/scala/nomad/sbt/NomadPlugin.scala

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,8 @@ object NomadPlugin extends AutoPlugin {
293293
val nomadManifestFile = settingKey[File]("Path to the Scala manifest file defining migration order")
294294
val nomadManifestClass = settingKey[String]("Fully qualified class name of the NomadMigrations implementation")
295295
val nomadHistoryTable = settingKey[String]("Name of the database table used to track applied migrations")
296+
val nomadSchema = settingKey[String]("Database schema used for migrations and history tracking")
297+
val nomadAutoCreateSchema = settingKey[Boolean]("Create the configured schema via CREATE SCHEMA IF NOT EXISTS before migrate / cleanAndMigrate. Set to false for least-privilege roles.")
296298
val nomadGraalSync = settingKey[Boolean]("Automatically sync migration resources to GraalVM native-image resource-config.json")
297299
val nomadGraalResourceConfigFile = settingKey[File]("Path to the GraalVM resource-config.json file")
298300
val nomadImportFlyway = taskKey[Unit]("Import Flyway versioned migrations into the Nomad manifest")
@@ -306,6 +308,8 @@ object NomadPlugin extends AutoPlugin {
306308
nomadManifestFile := (Compile / scalaSource).value / "Nomad.scala",
307309
nomadManifestClass := ManifestNaming.classNameFromFile(nomadManifestFile.value, (Compile / scalaSource).value),
308310
nomadHistoryTable := "nomad_migrations",
311+
nomadSchema := "public",
312+
nomadAutoCreateSchema := true,
309313
nomadGraalSync := false,
310314
nomadGraalResourceConfigFile := (Compile / resourceDirectory).value / "META-INF" / "native-image" / "resource-config.json",
311315
nomadInit / aggregate := false,
@@ -330,6 +334,8 @@ object NomadPlugin extends AutoPlugin {
330334
val cp = (Compile / fullClasspath).value.files
331335
val className = nomadManifestClass.value
332336
val table = nomadHistoryTable.value
337+
val schema = nomadSchema.value
338+
val autoCreateSchema = nomadAutoCreateSchema.value
333339

334340
val loader = new ChildFirstClassLoader(
335341
cp.map(_.toURI.toURL).toArray,
@@ -348,8 +354,8 @@ object NomadPlugin extends AutoPlugin {
348354
val database = databaseMethod.invoke(instance)
349355

350356
val migratorClass = loader.loadClass("nomad.Migrator")
351-
val constructor = migratorClass.getConstructors.head
352-
val migrator = constructor.newInstance(datasource, database, migrations, table, "public")
357+
val constructor = migratorClass.getConstructors.maxBy(_.getParameterCount)
358+
val migrator = constructor.newInstance(datasource, database, migrations, table, schema, java.lang.Boolean.valueOf(autoCreateSchema))
353359
val migrateMethod = migratorClass.getMethod("migrate")
354360
migrateMethod.invoke(migrator)
355361
} finally {
@@ -370,6 +376,8 @@ object NomadPlugin extends AutoPlugin {
370376
val cp = (Compile / fullClasspath).value.files
371377
val className = nomadManifestClass.value
372378
val table = nomadHistoryTable.value
379+
val schema = nomadSchema.value
380+
val autoCreateSchema = nomadAutoCreateSchema.value
373381

374382
val loader = new ChildFirstClassLoader(
375383
cp.map(_.toURI.toURL).toArray,
@@ -388,8 +396,8 @@ object NomadPlugin extends AutoPlugin {
388396
val database = databaseMethod.invoke(instance)
389397

390398
val migratorClass = loader.loadClass("nomad.Migrator")
391-
val constructor = migratorClass.getConstructors.head
392-
val migrator = constructor.newInstance(datasource, database, migrations, table, "public")
399+
val constructor = migratorClass.getConstructors.maxBy(_.getParameterCount)
400+
val migrator = constructor.newInstance(datasource, database, migrations, table, schema, java.lang.Boolean.valueOf(autoCreateSchema))
393401
val printStatusMethod = migratorClass.getMethod("printStatus")
394402
val hasProblems = printStatusMethod.invoke(migrator).asInstanceOf[Boolean]
395403

@@ -450,6 +458,8 @@ object NomadPlugin extends AutoPlugin {
450458
val cp = (Compile / fullClasspath).value.files
451459
val className = nomadManifestClass.value
452460
val table = nomadHistoryTable.value
461+
val schema = nomadSchema.value
462+
val autoCreateSchema = nomadAutoCreateSchema.value
453463
val base = baseDirectory.value
454464

455465
if (!manifest.exists()) {
@@ -539,8 +549,8 @@ object NomadPlugin extends AutoPlugin {
539549
val migrations = migrationsMethod.invoke(instance)
540550

541551
val migratorClass = loader.loadClass("nomad.Migrator")
542-
val constructor = migratorClass.getConstructors.head
543-
val migrator = constructor.newInstance(datasource, database, migrations, table, "public")
552+
val constructor = migratorClass.getConstructors.maxBy(_.getParameterCount)
553+
val migrator = constructor.newInstance(datasource, database, migrations, table, schema, java.lang.Boolean.valueOf(autoCreateSchema))
544554
val analyzeMethod = migratorClass.getMethod("analyzeFlywayHistoryImport", classOf[String], classOf[Array[String]])
545555
val analysis = analyzeMethod.invoke(migrator, flywayTable, allPaths.toArray)
546556

sbt-plugin/src/sbt-test/nomad/clean-and-migrate-postgres/src/main/scala/Main.scala

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import nomad.{Migrator, SQLMigration, SupportedDatabase}
22
import io.zonky.test.db.postgres.embedded.EmbeddedPostgres
33

44
object Main {
5-
def main(args: Array[String]): Unit = {
6-
// On NixOS zonky's bundled generic-Linux binaries fail to load. NOMAD_PG_TARBALL
7-
// (set by the flake devshell) overrides the binary source with a tarball of the
8-
// system postgres.
9-
val pg = sys.env.get("NOMAD_PG_TARBALL").filter(_.nonEmpty) match {
5+
// On NixOS zonky's bundled generic-Linux binaries fail to load. NOMAD_PG_TARBALL
6+
// (set by the flake devshell) overrides the binary source with a tarball of the
7+
// system postgres.
8+
private def startEmbeddedPostgres(): EmbeddedPostgres =
9+
sys.env.get("NOMAD_PG_TARBALL").filter(_.nonEmpty) match {
1010
case Some(path) =>
1111
// nix-store postgres defaults unix_socket_directories to /run/postgresql,
1212
// which doesn't exist in this sandbox — point it at the JVM temp dir instead.
@@ -18,6 +18,9 @@ object Main {
1818
case None => EmbeddedPostgres.start()
1919
}
2020

21+
def main(args: Array[String]): Unit = {
22+
val pg = startEmbeddedPostgres()
23+
2124
try {
2225
val ds = pg.getPostgresDatabase()
2326
val migrations = Vector(SQLMigration("migrations/M001_CreateUsers.sql"))
@@ -151,6 +154,150 @@ object Main {
151154
conn6.close()
152155

153156
println("Test 5 passed: cleanAndMigrate on empty schema skips clean and migrates")
157+
158+
// --- Test 6: cleanAndMigrate self-heals when schema does not exist (autoCreateSchema=true) ---
159+
val setup5 = ds.getConnection
160+
setup5.createStatement().execute("CREATE SCHEMA IF NOT EXISTS will_be_dropped")
161+
setup5.close()
162+
// Drop it to simulate the bug scenario
163+
val drop = ds.getConnection
164+
drop.createStatement().execute("DROP SCHEMA will_be_dropped CASCADE")
165+
drop.close()
166+
167+
val missingSchemaMigrator = new Migrator(
168+
ds, SupportedDatabase.Postgres, migrations, schema = "will_be_dropped"
169+
)
170+
missingSchemaMigrator.cleanAndMigrate()
171+
172+
val conn7 = ds.getConnection
173+
conn7.setSchema("will_be_dropped")
174+
val rs7 = conn7.createStatement().executeQuery("SELECT COUNT(*) FROM nomad_migrations")
175+
rs7.next()
176+
assert(rs7.getLong(1) == 1, "Expected 1 migration recorded after self-heal on missing schema")
177+
rs7.close()
178+
conn7.close()
179+
180+
println("Test 6 passed: cleanAndMigrate self-heals when schema is missing")
181+
182+
// --- Test 7: autoCreateSchema=false reproduces the original bug (no silent creation) ---
183+
// The exact symptom from the bug report: PG rejects CREATE TYPE because the missing
184+
// schema left search_path pointing at a non-existent namespace.
185+
val strictMigrator = new Migrator(
186+
ds, SupportedDatabase.Postgres, migrations,
187+
schema = "never_created", autoCreateSchema = false
188+
)
189+
try {
190+
strictMigrator.cleanAndMigrate()
191+
throw new AssertionError("Expected failure when autoCreateSchema=false and schema is missing")
192+
} catch {
193+
case e: RuntimeException if e.getCause != null
194+
&& e.getCause.getMessage != null
195+
&& e.getCause.getMessage.contains("no schema has been selected") =>
196+
// expected — this is exactly the original bug symptom surfacing when opt-out is chosen
197+
case e: org.postgresql.util.PSQLException if e.getMessage.contains("no schema has been selected") =>
198+
// expected — same symptom surfaced directly
199+
}
200+
201+
println("Test 7 passed: autoCreateSchema=false reproduces original bug symptom")
202+
203+
// --- Test 8: migrate() (without clean) self-heals when schema is missing ---
204+
val migrateSelfHeal = new Migrator(
205+
ds, SupportedDatabase.Postgres, migrations, schema = "migrate_self_heal"
206+
)
207+
migrateSelfHeal.migrate()
208+
209+
val conn8 = ds.getConnection
210+
conn8.setSchema("migrate_self_heal")
211+
val rs8 = conn8.createStatement().executeQuery("SELECT COUNT(*) FROM nomad_migrations")
212+
rs8.next()
213+
assert(rs8.getLong(1) == 1, "Expected 1 migration recorded after migrate self-heal on missing schema")
214+
rs8.close()
215+
conn8.close()
216+
217+
println("Test 8 passed: migrate self-heals when schema is missing")
218+
219+
// --- Test 9: canonical bug repro — DROP SCHEMA public CASCADE then cleanAndMigrate ---
220+
// This mirrors verbatim the "Steps to reproduce" section of the bug report, and
221+
// additionally verifies that the fix is idempotent: a second cleanAndMigrate() call
222+
// after self-heal must also converge (the bug was described as self-perpetuating).
223+
// Use a fresh embedded PG so earlier tests do not interfere with the public schema.
224+
val pg2 = startEmbeddedPostgres()
225+
try {
226+
val ds2 = pg2.getPostgresDatabase()
227+
228+
val dropPublic = ds2.getConnection
229+
dropPublic.createStatement().execute("DROP SCHEMA public CASCADE")
230+
dropPublic.close()
231+
232+
val canonicalMigrator = new Migrator(ds2, SupportedDatabase.Postgres, migrations)
233+
canonicalMigrator.cleanAndMigrate() // must self-heal (default schema = "public")
234+
235+
val verify1 = ds2.getConnection
236+
val rs9a = verify1.createStatement().executeQuery("SELECT COUNT(*) FROM public.nomad_migrations")
237+
rs9a.next()
238+
assert(rs9a.getLong(1) == 1, "Canonical repro: expected 1 migration after first cleanAndMigrate")
239+
rs9a.close()
240+
verify1.close()
241+
242+
// Second invocation — must not re-perpetuate the bug and must remain at 1 migration
243+
canonicalMigrator.cleanAndMigrate()
244+
245+
val verify2 = ds2.getConnection
246+
val rs9b = verify2.createStatement().executeQuery("SELECT COUNT(*) FROM public.nomad_migrations")
247+
rs9b.next()
248+
assert(rs9b.getLong(1) == 1, "Canonical repro: second cleanAndMigrate must be idempotent")
249+
rs9b.close()
250+
verify2.close()
251+
} finally {
252+
pg2.close()
253+
}
254+
255+
println("Test 9 passed: canonical public-schema repro self-heals and is idempotent")
256+
257+
// --- Test 10: autoCreateSchema=true on a pre-existing schema must not require CREATE on the database ---
258+
// Regression: Postgres checks ACL_CREATE on the database before IF NOT EXISTS
259+
// short-circuits, so a naive CREATE SCHEMA IF NOT EXISTS would fail for a
260+
// least-privilege role even when the target schema already exists. The probe-
261+
// then-create implementation must take the no-op path for this role.
262+
val pg3 = startEmbeddedPostgres()
263+
try {
264+
val dsAdmin = pg3.getPostgresDatabase()
265+
val admin = dsAdmin.getConnection
266+
try {
267+
admin.createStatement().execute("CREATE ROLE limited_user LOGIN")
268+
admin.createStatement().execute("CREATE SCHEMA limited_pre_existing")
269+
admin.createStatement().execute(
270+
"GRANT USAGE, CREATE ON SCHEMA limited_pre_existing TO limited_user"
271+
)
272+
// Defense-in-depth: revoke any default CREATE-on-database grant. PG 15+
273+
// already revokes CREATE on database from PUBLIC by default; older PGs do not.
274+
admin.createStatement().execute("REVOKE CREATE ON DATABASE postgres FROM PUBLIC")
275+
} finally {
276+
admin.close()
277+
}
278+
279+
val dsLimited = pg3.getDatabase("limited_user", "postgres")
280+
val limitedMigrator = new Migrator(
281+
dsLimited, SupportedDatabase.Postgres, migrations, schema = "limited_pre_existing"
282+
)
283+
limitedMigrator.migrate()
284+
285+
val verifyLimited = dsLimited.getConnection
286+
try {
287+
verifyLimited.setSchema("limited_pre_existing")
288+
val rs10 = verifyLimited.createStatement().executeQuery("SELECT COUNT(*) FROM nomad_migrations")
289+
rs10.next()
290+
assert(rs10.getLong(1) == 1, "Expected 1 migration recorded by limited role on pre-existing schema")
291+
rs10.close()
292+
} finally {
293+
verifyLimited.close()
294+
}
295+
} finally {
296+
pg3.close()
297+
}
298+
299+
println("Test 10 passed: autoCreateSchema=true on pre-existing schema requires no CREATE on database")
300+
154301
println("All Postgres cleanAndMigrate tests passed!")
155302
} finally {
156303
pg.close()

sbt-plugin/src/sbt-test/nomad/clean-and-migrate/src/main/scala/Main.scala

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,42 @@ object Main {
110110
rs5.close()
111111
conn4.close()
112112

113+
// Test: cleanAndMigrate self-heals when schema does not exist (autoCreateSchema=true default)
114+
val missingSchemaMigrator = new Migrator(
115+
ds, SupportedDatabase.H2, migrations, schema = "NEVER_CREATED"
116+
)
117+
missingSchemaMigrator.cleanAndMigrate()
118+
119+
val conn6 = ds.getConnection
120+
conn6.setSchema("NEVER_CREATED")
121+
val rs6 = conn6.createStatement().executeQuery("SELECT COUNT(*) FROM nomad_migrations")
122+
rs6.next()
123+
assert(rs6.getLong(1) == 1, "Expected 1 migration recorded after self-heal on missing schema")
124+
rs6.close()
125+
conn6.close()
126+
127+
// Test: autoCreateSchema=false must not silently create a missing schema.
128+
// H2 surfaces the missing schema either at setSchema or at the first DDL attempt;
129+
// we assert that a SQL-level failure mentioning the schema name bubbles up.
130+
val strictMigrator = new Migrator(
131+
ds, SupportedDatabase.H2, migrations,
132+
schema = "STRICT_MISSING", autoCreateSchema = false
133+
)
134+
try {
135+
strictMigrator.cleanAndMigrate()
136+
throw new AssertionError("Expected failure when autoCreateSchema=false and schema is missing")
137+
} catch {
138+
case e: java.sql.SQLException =>
139+
assert(
140+
e.getMessage != null && e.getMessage.toUpperCase.contains("STRICT_MISSING"),
141+
s"Expected H2 error referencing missing schema, got: ${e.getMessage}"
142+
)
143+
case e: RuntimeException if e.getCause.isInstanceOf[java.sql.SQLException]
144+
&& e.getCause.getMessage != null
145+
&& e.getCause.getMessage.toUpperCase.contains("STRICT_MISSING") =>
146+
// expected — wrapped in Migrator's RuntimeException
147+
}
148+
113149
println("cleanAndMigrate tests passed!")
114150
}
115151
}

0 commit comments

Comments
 (0)