From f0151064ee49dcfb39ec7f5cdf2ac94771f2e693 Mon Sep 17 00:00:00 2001 From: xhon-pelushi Date: Tue, 18 Aug 2026 09:03:08 -0400 Subject: [PATCH 1/2] postgres: Drop() also drops custom types Drop() dropped every table in the current schema but left custom types (enums, domains, standalone composite types) behind. A migration that creates one of those types could not be re-run on a database that had just been dropped, since the type it tries to create already exists. Query pg_type for types owned by the current schema, excluding the row type Postgres implicitly creates for every table (relkind != 'c' in the join to pg_class) and the array type Postgres implicitly creates for every type (the typarray back-reference check), then drop what's left the same way tables are dropped: one by one, IF EXISTS, CASCADE. Fixes #626 --- database/postgres/postgres.go | 43 +++++++++++++++++++++ database/postgres/postgres_test.go | 60 ++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/database/postgres/postgres.go b/database/postgres/postgres.go index ed96fe63e..862e24093 100644 --- a/database/postgres/postgres.go +++ b/database/postgres/postgres.go @@ -444,6 +444,49 @@ func (p *Postgres) Drop() (err error) { } } + // select all custom types (enums, domains, and standalone composite types) in the current schema. + // Excludes the row type Postgres automatically creates for every table (already gone via DROP TABLE + // above, or never a real user type to begin with) and the array type Postgres automatically creates + // alongside every type. + query = `SELECT t.typname FROM pg_catalog.pg_type t + LEFT JOIN pg_catalog.pg_class c ON c.oid = t.typrelid + WHERE t.typnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = current_schema()) + AND (t.typrelid = 0 OR c.relkind = 'c') + AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_type el WHERE el.typarray = t.oid)` + types, err := p.conn.QueryContext(context.Background(), query) + if err != nil { + return &database.Error{OrigErr: err, Query: []byte(query)} + } + defer func() { + if errClose := types.Close(); errClose != nil { + err = errors.Join(err, errClose) + } + }() + + typeNames := make([]string, 0) + for types.Next() { + var typeName string + if err := types.Scan(&typeName); err != nil { + return err + } + if len(typeName) > 0 { + typeNames = append(typeNames, typeName) + } + } + if err := types.Err(); err != nil { + return &database.Error{OrigErr: err, Query: []byte(query)} + } + + if len(typeNames) > 0 { + // delete one by one ... + for _, t := range typeNames { + query = `DROP TYPE IF EXISTS ` + pq.QuoteIdentifier(t) + ` CASCADE` + if _, err := p.conn.ExecContext(context.Background(), query); err != nil { + return &database.Error{OrigErr: err, Query: []byte(query)} + } + } + } + return nil } diff --git a/database/postgres/postgres_test.go b/database/postgres/postgres_test.go index 3a49c50ab..b35879e60 100644 --- a/database/postgres/postgres_test.go +++ b/database/postgres/postgres_test.go @@ -99,6 +99,7 @@ func Test(t *testing.T) { t.Run("testPostgresLock", testPostgresLock) t.Run("testWithInstanceConcurrent", testWithInstanceConcurrent) t.Run("testWithConnection", testWithConnection) + t.Run("testDropWithCustomTypes", testDropWithCustomTypes) t.Cleanup(func() { for _, spec := range specs { @@ -132,6 +133,65 @@ func test(t *testing.T) { }) } +// testDropWithCustomTypes is a regression test for Drop() not removing custom +// types (enums, domains, standalone composite types), which left a database +// unable to re-run a migration that (re-)creates one of those types. See +// https://github.com/golang-migrate/migrate/issues/626. +func testDropWithCustomTypes(t *testing.T) { + dktesting.ParallelTest(t, specs, func(t *testing.T, c dktest.ContainerInfo) { + ip, port, err := c.FirstPort() + if err != nil { + t.Fatal(err) + } + + addr := pgConnectionString(ip, port) + p := &Postgres{} + d, err := p.Open(addr) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := d.Close(); err != nil { + t.Error(err) + } + }() + + db, err := sql.Open("postgres", addr) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() + + statements := []string{ + `CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')`, + `CREATE TYPE point2d AS (x int, y int)`, + `CREATE DOMAIN posint AS int CHECK (VALUE > 0)`, + `CREATE TABLE widgets (id serial primary key, m mood, p point2d, n posint)`, + } + for _, s := range statements { + if _, err := db.Exec(s); err != nil { + t.Fatal(err) + } + } + + if err := d.Drop(); err != nil { + t.Fatal(err) + } + + // A truly clean database can re-create every type Drop() should have + // removed. Before the fix this failed with "type ... already exists". + for _, s := range statements[:3] { + if _, err := db.Exec(s); err != nil { + t.Fatalf("re-creating type after Drop() failed (Drop() left it behind): %v", err) + } + } + }) +} + func testMigrate(t *testing.T) { dktesting.ParallelTest(t, specs, func(t *testing.T, c dktest.ContainerInfo) { ip, port, err := c.FirstPort() From ed4f7cdcf0d6a35d3078f98424b5b2108496f773 Mon Sep 17 00:00:00 2001 From: xhon-pelushi Date: Wed, 19 Aug 2026 22:10:51 -0400 Subject: [PATCH 2/2] postgres: exclude extension-owned and internally-dependent types from Drop() A second Claude Code session independently verifying this PR found two real regressions in the type-selection query added in the previous commit: - Any type an extension installs into the current schema (citext, hstore, postgis, ...) was selected and DROP TYPE fails on it -- only dropping the extension can remove it. Drop() would return that error and abort with the tables already gone. - A range type's auto-generated multirange type has an internal dependency on its range type. If the multirange sorts before the range type in the (unordered) query result, its DROP fails immediately for the same reason, again aborting with tables already dropped. Reordering wouldn't fully fix this either -- it would just make the failure order-dependent. Exclude anything with an extension ('e') or internal ('i') pg_depend entry. Verified against a real Postgres 16 instance: citext extension + enum + range type (which also exercises its implicit multirange) + a table, in combination. Before this commit, Drop() failed outright. After, it succeeds and drops everything droppable, leaving only the extension-owned type, which is correct since removing it means dropping the extension. Adds testDropWithExtensionAndRangeTypes alongside the existing testDropWithCustomTypes. --- database/postgres/postgres.go | 14 +++++-- database/postgres/postgres_test.go | 66 ++++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/database/postgres/postgres.go b/database/postgres/postgres.go index 862e24093..7b25bf4dc 100644 --- a/database/postgres/postgres.go +++ b/database/postgres/postgres.go @@ -446,13 +446,21 @@ func (p *Postgres) Drop() (err error) { // select all custom types (enums, domains, and standalone composite types) in the current schema. // Excludes the row type Postgres automatically creates for every table (already gone via DROP TABLE - // above, or never a real user type to begin with) and the array type Postgres automatically creates - // alongside every type. + // above, or never a real user type to begin with), the array type Postgres automatically creates + // alongside every type, and any type an extension or the system installed (an internal or + // extension pg_depend entry) -- those can't be dropped without dropping the extension itself, + // which is outside Drop()'s remit, and a range type's auto-generated multirange has an internal + // dependency on its range type that trips a plain DROP regardless of statement order. query = `SELECT t.typname FROM pg_catalog.pg_type t LEFT JOIN pg_catalog.pg_class c ON c.oid = t.typrelid WHERE t.typnamespace = (SELECT oid FROM pg_catalog.pg_namespace WHERE nspname = current_schema()) AND (t.typrelid = 0 OR c.relkind = 'c') - AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_type el WHERE el.typarray = t.oid)` + AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_type el WHERE el.typarray = t.oid) + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.classid = 'pg_catalog.pg_type'::regclass AND d.objid = t.oid + AND d.deptype IN ('e', 'i') + )` types, err := p.conn.QueryContext(context.Background(), query) if err != nil { return &database.Error{OrigErr: err, Query: []byte(query)} diff --git a/database/postgres/postgres_test.go b/database/postgres/postgres_test.go index b35879e60..569278c46 100644 --- a/database/postgres/postgres_test.go +++ b/database/postgres/postgres_test.go @@ -100,6 +100,7 @@ func Test(t *testing.T) { t.Run("testWithInstanceConcurrent", testWithInstanceConcurrent) t.Run("testWithConnection", testWithConnection) t.Run("testDropWithCustomTypes", testDropWithCustomTypes) + t.Run("testDropWithExtensionAndRangeTypes", testDropWithExtensionAndRangeTypes) t.Cleanup(func() { for _, spec := range specs { @@ -192,6 +193,71 @@ func testDropWithCustomTypes(t *testing.T) { }) } +// testDropWithExtensionAndRangeTypes is a regression test for two ways the +// type-selection query in Drop() can pick up a type it must not try to drop: +// a type an extension installed (dropping it fails -- only dropping the +// extension itself can remove it), and a range type, whose auto-generated +// multirange type has an internal dependency on it that a plain DROP TYPE +// (even with CASCADE) cannot satisfy regardless of statement order. +func testDropWithExtensionAndRangeTypes(t *testing.T) { + dktesting.ParallelTest(t, specs, func(t *testing.T, c dktest.ContainerInfo) { + ip, port, err := c.FirstPort() + if err != nil { + t.Fatal(err) + } + + addr := pgConnectionString(ip, port) + p := &Postgres{} + d, err := p.Open(addr) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := d.Close(); err != nil { + t.Error(err) + } + }() + + db, err := sql.Open("postgres", addr) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := db.Close(); err != nil { + t.Error(err) + } + }() + + statements := []string{ + `CREATE EXTENSION IF NOT EXISTS citext`, + `CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')`, + `CREATE TYPE floatrange AS RANGE (subtype = float8)`, + } + for _, s := range statements { + if _, err := db.Exec(s); err != nil { + t.Fatal(err) + } + } + + if err := d.Drop(); err != nil { + t.Fatalf("Drop() failed on a database with an extension-owned type and a range type: %v", err) + } + + var extant string + err = db.QueryRow(`SELECT typname FROM pg_type WHERE typname = 'citext'`).Scan(&extant) + if err != nil { + t.Fatalf("expected the extension-owned citext type to survive Drop(), got: %v", err) + } + + if _, err := db.Exec(`CREATE TYPE mood AS ENUM ('sad', 'ok', 'happy')`); err != nil { + t.Fatalf("re-creating mood after Drop() failed: %v", err) + } + if _, err := db.Exec(`CREATE TYPE floatrange AS RANGE (subtype = float8)`); err != nil { + t.Fatalf("re-creating floatrange after Drop() failed: %v", err) + } + }) +} + func testMigrate(t *testing.T) { dktesting.ParallelTest(t, specs, func(t *testing.T, c dktest.ContainerInfo) { ip, port, err := c.FirstPort()