From 4295bc7c0416050fd6d682cce6db11c0aa12d43f Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Wed, 14 Oct 2026 14:46:48 +0200 Subject: [PATCH 1/4] fix: preserve inherited partition index names Restore constraint-owned index names before creating standalone indexes. Remap explicit regclass literals in DO blocks without changing aliases or record fields. Add typed partition-index dependency errors and replay coverage. --- cmd/do_literal_desired_state_test.go | 114 +++++ cmd/do_regclass_desired_state_test.go | 87 ++++ cmd/dump/partition_index_ignore_test.go | 23 +- ...herited_constraint_index_collision_test.go | 78 ++++ cmd/inherited_constraint_index_ignore_test.go | 63 +++ cmd/inherited_constraint_index_test.go | 216 ++++++++++ cmd/plan/plan.go | 13 + cmd/plan/plan_test.go | 9 + docs/cli/dump.mdx | 7 + docs/cli/plan.mdx | 20 + internal/diff/inherited_constraint_index.go | 125 ++++++ .../diff/inherited_constraint_index_test.go | 79 ++++ internal/diff/partition_index.go | 35 +- internal/diff/partition_index_error_test.go | 43 ++ internal/diff/partition_index_test.go | 8 +- internal/diff/table.go | 6 +- internal/postgres/desired_state.go | 279 ++++-------- internal/postgres/desired_state_test.go | 17 +- internal/postgres/do_remap_test.go | 102 +++++ internal/postgres/embedded.go | 5 +- internal/postgres/external.go | 5 +- internal/postgres/sql_lex.go | 212 ++++++++++ internal/postgres/sql_literal.go | 131 ++++++ internal/postgres/sql_literal_test.go | 86 ++++ ir/index_test.go | 4 + ir/ir.go | 19 + ir/partition_index.go | 18 +- .../issue_409_partitioned_fk/pgschema.sql | 114 +++++ .../pgschema.sql | 57 +++ testdata/dump/sakila/pgschema.sql | 399 ++++++++++++++++++ 30 files changed, 2160 insertions(+), 214 deletions(-) create mode 100644 cmd/do_literal_desired_state_test.go create mode 100644 cmd/do_regclass_desired_state_test.go create mode 100644 cmd/inherited_constraint_index_collision_test.go create mode 100644 cmd/inherited_constraint_index_ignore_test.go create mode 100644 cmd/inherited_constraint_index_test.go create mode 100644 internal/diff/inherited_constraint_index.go create mode 100644 internal/diff/inherited_constraint_index_test.go create mode 100644 internal/diff/partition_index_error_test.go create mode 100644 internal/postgres/do_remap_test.go create mode 100644 internal/postgres/sql_lex.go create mode 100644 internal/postgres/sql_literal.go create mode 100644 internal/postgres/sql_literal_test.go diff --git a/cmd/do_literal_desired_state_test.go b/cmd/do_literal_desired_state_test.go new file mode 100644 index 00000000..7c96cc57 --- /dev/null +++ b/cmd/do_literal_desired_state_test.go @@ -0,0 +1,114 @@ +package cmd + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/pgplex/pgschema/internal/postgres" + "github.com/pgplex/pgschema/ir" + "github.com/pgplex/pgschema/testutil" +) + +// Compare the loader with PostgreSQL's own literal semantics on an owned local +// instance. Invalid SQL must fail before executing the desired-state script. +func TestDesiredStateDOLiteralSemantics(t *testing.T) { + if testing.Short() { + t.Skip("embedded PostgreSQL integration") + } + pg := testutil.SetupPostgres(t) + defer pg.Stop() + db, h, p, d, u, w := testutil.ConnectToPostgres(t, pg) + defer db.Close() + major, err := testutil.GetMajorVersion(db) + if err != nil { + t.Fatal(err) + } + external, err := postgres.NewExternalDatabase(&postgres.ExternalDatabaseConfig{Host: h, Port: p, Database: d, Username: u, Password: w, SSLMode: "disable", TargetMajorVersion: major}) + if err != nil { + t.Fatal(err) + } + defer external.Stop() + embeddedDB, _, _, _, _, _ := testutil.ConnectToPostgres(t, sharedEmbeddedPG) + defer embeddedDB.Close() + for _, tc := range []struct{ name, body string }{ + {"incomplete_u", `DO E'BEGIN RAISE NOTICE ''\u12''; END';`}, + {"incomplete_U", `DO E'BEGIN RAISE NOTICE ''\U0001F60''; END';`}, + {"invalid_hex", `DO E'BEGIN RAISE NOTICE ''\u12GG''; END';`}, + {"out_of_range", `DO E'BEGIN RAISE NOTICE ''\U00110000''; END';`}, + {"maximum_uint32", `DO E'BEGIN RAISE NOTICE ''\UFFFFFFFF''; END';`}, + {"zero", `DO E'BEGIN RAISE NOTICE ''\u0000''; END';`}, + {"unpaired_low_surrogate", `DO E'BEGIN RAISE NOTICE ''\uDE00''; END';`}, + {"invalid_low_surrogate", `DO E'BEGIN RAISE NOTICE ''\uD83D\u0041''; END';`}, + {"unpaired_surrogate", `DO E'BEGIN RAISE NOTICE ''\uD83D''; END';`}, + {"invalid_regclass", `DO $$ BEGIN PERFORM E'public.\u12'::pg_catalog.regclass; END $$;`}, + } { + t.Run(tc.name, func(t *testing.T) { + _, rawErr := db.Exec(tc.body) + var pgErr *pgconn.PgError + if !errors.As(rawErr, &pgErr) { + t.Fatalf("PostgreSQL accepted invalid fixture: %v", rawErr) + } + for name, provider := range map[string]postgres.DesiredStateProvider{"embedded": sharedEmbeddedPG, "external": external} { + t.Run(name, func(t *testing.T) { + err := provider.ApplySchema(context.Background(), "public", `CREATE TABLE public.marker(v text); `+tc.body) + if err == nil || !strings.Contains(err.Error(), "failed to remap desired state SQL") { + t.Fatalf("missing desired-state remapping error: %v", err) + } + conn := db + if name == "embedded" { + conn = embeddedDB + } + var count int + if err := conn.QueryRow(`SELECT count(*) FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relname='marker'`, provider.GetSchemaName()).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("invalid desired SQL executed before remapping failed") + } + }) + } + }) + } + for _, escape := range []string{`\uD83D\uDE00`, `\U0000D83D\uDE00`, `\uD83D\U0000DE00`, `\U0001F600`} { + t.Run(escape, func(t *testing.T) { + const prefix = `CREATE TABLE public.marker(v text); ` + body := `DO E'BEGIN EXECUTE pg_catalog.format(''INSERT INTO %s VALUES (%L)'', ''public.marker''::pg_catalog.regclass, ''` + escape + `''); END';` + if _, err := db.Exec(`DROP TABLE IF EXISTS public.marker; ` + prefix + body); err != nil { + t.Fatal(err) + } + var want string + if err := db.QueryRow(`SELECT v FROM public.marker`).Scan(&want); err != nil { + t.Fatal(err) + } + if want != "πŸ˜€" { + t.Fatalf("unexpected native Unicode value %q", want) + } + for name, provider := range map[string]postgres.DesiredStateProvider{"embedded": sharedEmbeddedPG, "external": external} { + t.Run(name, func(t *testing.T) { + if name == "external" { + if _, err := db.Exec("DROP TABLE IF EXISTS " + ir.QuoteIdentifier(provider.GetSchemaName()) + ".marker"); err != nil { + t.Fatal(err) + } + } + if err := provider.ApplySchema(context.Background(), "public", prefix+body); err != nil { + t.Fatal(err) + } + conn := db + if name == "embedded" { + conn = embeddedDB + } + var got string + if err := conn.QueryRow("SELECT v FROM " + ir.QuoteIdentifier(provider.GetSchemaName()) + ".marker").Scan(&got); err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("loader changed Unicode content: got %q, want %q", got, want) + } + }) + } + }) + } +} diff --git a/cmd/do_regclass_desired_state_test.go b/cmd/do_regclass_desired_state_test.go new file mode 100644 index 00000000..e3238e18 --- /dev/null +++ b/cmd/do_regclass_desired_state_test.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "context" + "database/sql" + "testing" + + "github.com/pgplex/pgschema/internal/postgres" + "github.com/pgplex/pgschema/ir" + "github.com/pgplex/pgschema/testutil" +) + +func TestDesiredStateDOAliasesAndRecords(t *testing.T) { + if testing.Short() { + t.Skip("embedded PostgreSQL integration") + } + pg := testutil.SetupPostgres(t) + defer pg.Stop() + db, h, p, d, u, w := testutil.ConnectToPostgres(t, pg) + defer db.Close() + major, err := testutil.GetMajorVersion(db) + if err != nil { + t.Fatal(err) + } + embeddedDB, _, _, _, _, _ := testutil.ConnectToPostgres(t, sharedEmbeddedPG) + defer embeddedDB.Close() + const prefix = `CREATE TABLE public.t(t int); +INSERT INTO t VALUES (7); +CREATE TABLE public.marker(v text); +` + for _, tc := range []struct{ name, body, want string }{ + {"alias", `DO $$ BEGIN INSERT INTO marker SELECT public.t::text FROM t AS public; END $$;`, "7"}, + {"quoted_alias", `DO $$ BEGIN INSERT INTO marker SELECT "public".t::text FROM t AS "public"; END $$;`, "7"}, + {"record", `DO $$ DECLARE public record; BEGIN SELECT 9 AS t INTO public; INSERT INTO marker VALUES (public.t::text); END $$;`, "9"}, + {"alias_and_regclass", `DO $$ BEGIN INSERT INTO marker SELECT public.t::text FROM t AS public WHERE 'public.t'::pg_catalog.regclass = 't'::regclass; END $$;`, "7"}, + {"record_and_regclass", `DO $$ DECLARE public record; BEGIN SELECT 9 AS t INTO public; IF CAST('public.t' AS pg_catalog.regclass) = 't'::regclass THEN INSERT INTO marker VALUES (public.t::text); END IF; END $$;`, "9"}, + {"nested_record", `DO $$ DECLARE public record; BEGIN +SELECT 11 AS t INTO public; +INSERT INTO marker VALUES (public.t::text); +DECLARE public record; BEGIN +SELECT 13 AS t INTO public; +INSERT INTO marker VALUES (public.t::text); +END; +INSERT INTO marker VALUES (public.t::text); +END $$;`, "11,11,13"}, + } { + t.Run(tc.name, func(t *testing.T) { + fixture := prefix + tc.body + if _, err := db.Exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public; ` + fixture); err != nil { + t.Fatalf("independent PostgreSQL fixture failed: %v", err) + } + readMarker := func(conn *sql.DB, schema string) string { + t.Helper() + var got string + if err := conn.QueryRow("SELECT COALESCE(string_agg(v, ',' ORDER BY v), '') FROM " + ir.QuoteIdentifier(schema) + ".marker").Scan(&got); err != nil { + t.Fatal(err) + } + return got + } + if got := readMarker(db, "public"); got != tc.want { + t.Fatalf("native PostgreSQL result = %q, want %q", got, tc.want) + } + external, err := postgres.NewExternalDatabase(&postgres.ExternalDatabaseConfig{Host: h, Port: p, Database: d, Username: u, Password: w, SSLMode: "disable", TargetMajorVersion: major}) + if err != nil { + t.Fatal(err) + } + defer external.Stop() + for name, provider := range map[string]postgres.DesiredStateProvider{"embedded": sharedEmbeddedPG, "external": external} { + t.Run(name, func(t *testing.T) { + if err := provider.ApplySchema(context.Background(), "public", fixture); err != nil { + t.Fatal(err) + } + conn := db + if name == "embedded" { + conn = embeddedDB + } + if got := readMarker(conn, provider.GetSchemaName()); got != tc.want { + t.Fatalf("loader result = %q, want %q", got, tc.want) + } + }) + } + if got := readMarker(db, "public"); got != tc.want { + t.Fatalf("loader changed the original schema: %q", got) + } + }) + } +} diff --git a/cmd/dump/partition_index_ignore_test.go b/cmd/dump/partition_index_ignore_test.go index c4f1b1ea..7898e090 100644 --- a/cmd/dump/partition_index_ignore_test.go +++ b/cmd/dump/partition_index_ignore_test.go @@ -1,13 +1,16 @@ package dump import ( + "errors" "fmt" "os" "path/filepath" "strings" "testing" + "github.com/pgplex/pgschema/internal/diff" "github.com/pgplex/pgschema/internal/include" + "github.com/pgplex/pgschema/ir" "github.com/pgplex/pgschema/testutil" ) @@ -97,9 +100,13 @@ ALTER INDEX partial_lookup ATTACH PARTITION late_partial_lookup; Schema: "public", SSLMode: "disable", MultiFile: multiFile, File: path, }) if tc.child != "" { - want := "cannot dump index public." + tc.parent + ": attached index public." + tc.child + " is excluded" - if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), ".pgschemaignore") { - t.Fatalf("Expected actionable dependency error %q, got %v", want, err) + var dependencyErr *diff.PartitionIndexDependencyError + if !errors.As(err, &dependencyErr) { + t.Fatalf("Expected partition index dependency error, got %v", err) + } + if dependencyErr.ParentIndex != (ir.IndexRef{Schema: "public", Name: tc.parent}) || + dependencyErr.AttachedIndex != (ir.IndexRef{Schema: "public", Name: tc.child}) || dependencyErr.ExcludedTable != nil { + t.Fatalf("Unexpected dependency context: %+v", dependencyErr) } if output != "" { t.Fatal("Failed dump returned partial SQL") @@ -185,8 +192,14 @@ CREATE TABLE event_links (bucket int, id int, FOREIGN KEY (bucket, id) REFERENCE Host: host, Port: port, DB: dbname, User: user, Password: password, Schema: "public", SSLMode: "disable", MultiFile: multiFile, File: path, }) - if err == nil || !strings.Contains(err.Error(), "event_unique") || !strings.Contains(err.Error(), "mid_unique") || !strings.Contains(err.Error(), "events_leaf") || !strings.Contains(err.Error(), ".pgschemaignore") { - t.Fatalf("Expected actionable nested dependency error, got %v", err) + var dependencyErr *diff.PartitionIndexDependencyError + if !errors.As(err, &dependencyErr) { + t.Fatalf("Expected nested partition index dependency error, got %v", err) + } + if dependencyErr.ParentIndex != (ir.IndexRef{Schema: "public", Name: "event_unique"}) || + dependencyErr.AttachedIndex != (ir.IndexRef{Schema: "public", Name: "mid_unique"}) || dependencyErr.ExcludedTable == nil || + dependencyErr.ExcludedTable.Schema != "public" || dependencyErr.ExcludedTable.Name != "events_leaf" { + t.Fatalf("Unexpected dependency context: %+v", dependencyErr) } if output != "" { t.Fatal("Failed dump returned partial SQL") diff --git a/cmd/inherited_constraint_index_collision_test.go b/cmd/inherited_constraint_index_collision_test.go new file mode 100644 index 00000000..d64d10e3 --- /dev/null +++ b/cmd/inherited_constraint_index_collision_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pgplex/pgschema/cmd/apply" + planCmd "github.com/pgplex/pgschema/cmd/plan" + "github.com/pgplex/pgschema/testutil" +) + +func TestInheritedConstraintIndexTemporaryNameCollisions(t *testing.T) { + if testing.Short() { + t.Skip("embedded PostgreSQL integration") + } + t.Chdir(t.TempDir()) + pg := testutil.SetupPostgres(t) + defer pg.Stop() + db, h, p, d, u, w := testutil.ConnectToPostgres(t, pg) + defer db.Close() + const desired = `CREATE TABLE events (id int PRIMARY KEY) PARTITION BY RANGE(id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10); +ALTER INDEX events_leaf_pkey RENAME TO custom_leaf;` + path := filepath.Join(t.TempDir(), "schema.sql") + if err := os.WriteFile(path, []byte(desired), 0600); err != nil { + t.Fatal(err) + } + config := &planCmd.PlanConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: "public", SSLMode: "disable", File: path} + migration, err := planCmd.GeneratePlan(config, sharedEmbeddedPG) + if err != nil { + t.Fatal(err) + } + injected := false + for g := range migration.Groups { + for s := range migration.Groups[g].Steps { + step := &migration.Groups[g].Steps[s] + if !strings.Contains(step.SQL, "INTO STRICT") { + continue + } + setup := ` EXECUTE pg_catalog.format('CREATE TABLE %I (id int)', 'pgschema_inherited_' || index_ids[1] || '_0'); + EXECUTE pg_catalog.format('ALTER TABLE events_leaf ADD CONSTRAINT %I CHECK (id > -1)', 'pgschema_inherited_' || index_ids[1] || '_1'); + desired_names := pg_catalog.array_append(desired_names, 'pgschema_inherited_' || index_ids[1] || '_2'); +` + step.SQL = strings.Replace(step.SQL, " FOR position IN", setup+" FOR position IN", 1) + cleanup := ` EXECUTE pg_catalog.format('DROP TABLE %I', 'pgschema_inherited_' || index_ids[1] || '_0'); + EXECUTE pg_catalog.format('ALTER TABLE events_leaf DROP CONSTRAINT %I', 'pgschema_inherited_' || index_ids[1] || '_1'); +` + step.SQL = strings.Replace(step.SQL, "END\n", cleanup+"END\n", 1) + // Each candidate is occupied by a relation, a table constraint, or a + // reserved destination. The fourth candidate must be the temporary name. + step.SQL = strings.Replace(step.SQL, " EXECUTE pg_catalog.format('ALTER INDEX", ` IF suffix <> 3 THEN + RAISE EXCEPTION 'expected collision-free suffix 3, got %', suffix; + END IF; + EXECUTE pg_catalog.format('ALTER INDEX`, 1) + injected = true + } + } + if !injected { + t.Fatal("missing restoration") + } + err = apply.ApplyMigration(&apply.ApplyConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: "public", SSLMode: "disable", Plan: migration, AutoApprove: true, NoColor: true, Quiet: true}, nil) + if err != nil { + t.Fatal(err) + } + var name string + if err := db.QueryRow(`SELECT c.conname FROM pg_catalog.pg_constraint c WHERE c.conrelid='events_leaf'::pg_catalog.regclass AND c.contype='p'`).Scan(&name); err != nil { + t.Fatal(err) + } + if name != "custom_leaf" { + t.Fatalf("constraint ownership/name changed: %s", name) + } + followUp, err := planCmd.GeneratePlan(config, sharedEmbeddedPG) + if err != nil || len(followUp.Groups) != 0 { + t.Fatalf("collision follow-up: %+v, %v", followUp, err) + } +} diff --git a/cmd/inherited_constraint_index_ignore_test.go b/cmd/inherited_constraint_index_ignore_test.go new file mode 100644 index 00000000..f6489e0f --- /dev/null +++ b/cmd/inherited_constraint_index_ignore_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pgplex/pgschema/cmd/dump" + planCmd "github.com/pgplex/pgschema/cmd/plan" + "github.com/pgplex/pgschema/testutil" +) + +func TestIgnoredOriginInheritedConstraintIndexes(t *testing.T) { + if testing.Short() { + t.Skip("embedded PostgreSQL integration") + } + t.Chdir(t.TempDir()) + pg := testutil.SetupPostgres(t) + defer pg.Stop() + db, h, p, d, u, w := testutil.ConnectToPostgres(t, pg) + defer db.Close() + const fixture = `CREATE TABLE events (id int PRIMARY KEY) PARTITION BY RANGE(id); +CREATE TABLE events_mid PARTITION OF events FOR VALUES FROM (0) TO (10) PARTITION BY RANGE(id); +CREATE TABLE events_leaf PARTITION OF events_mid FOR VALUES FROM (0) TO (5); +ALTER INDEX events_mid_pkey RENAME TO custom_mid; +ALTER INDEX events_leaf_pkey RENAME TO custom_leaf;` + if _, err := db.Exec(fixture); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(".pgschemaignore", []byte("[constraints]\npatterns = [\"events_pkey\"]\n"), 0600); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "schema.sql") + text, err := dump.ExecuteDump(&dump.DumpConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: "public", SSLMode: "disable", File: path}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(text, "INTO STRICT") || strings.Contains(text, "PRIMARY KEY") { + t.Fatalf("ignored constraint family was restored:\n%s", text) + } + if err := os.WriteFile(path, []byte(text), 0600); err != nil { + t.Fatal(err) + } + config := &planCmd.PlanConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: "public", SSLMode: "disable", File: path} + migration, err := planCmd.GeneratePlan(config, sharedEmbeddedPG) + if err != nil { + t.Fatal(err) + } + if len(migration.Groups) != 0 { + t.Fatalf("ignored-family dump-to-plan is not empty: %+v", migration.Groups) + } + if _, err := db.Exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public;` + text); err != nil { + t.Fatal(err) + } + var count int + if err := db.QueryRow(`SELECT count(*) FROM pg_catalog.pg_constraint c JOIN pg_catalog.pg_namespace n ON n.oid=c.connamespace WHERE n.nspname='public' AND c.contype='p'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("replay created %d ignored primary keys", count) + } +} diff --git a/cmd/inherited_constraint_index_test.go b/cmd/inherited_constraint_index_test.go new file mode 100644 index 00000000..1995f519 --- /dev/null +++ b/cmd/inherited_constraint_index_test.go @@ -0,0 +1,216 @@ +package cmd + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/pgplex/pgschema/cmd/apply" + "github.com/pgplex/pgschema/cmd/dump" + planCmd "github.com/pgplex/pgschema/cmd/plan" + "github.com/pgplex/pgschema/internal/include" + "github.com/pgplex/pgschema/internal/postgres" + "github.com/pgplex/pgschema/ir" + "github.com/pgplex/pgschema/testutil" +) + +func TestInheritedConstraintIndexRoundTrip(t *testing.T) { + if testing.Short() { + t.Skip("embedded PostgreSQL integration") + } + t.Chdir(t.TempDir()) + source := testutil.SetupPostgres(t) + defer source.Stop() + sourceDB, sh, sp, sd, su, sw := testutil.ConnectToPostgres(t, source) + defer sourceDB.Close() + majorVersion, err := testutil.GetMajorVersion(sourceDB) + if err != nil { + t.Fatal(err) + } + target := testutil.SetupPostgres(t) + defer target.Stop() + targetDB, h, p, d, u, w := testutil.ConnectToPostgres(t, target) + defer targetDB.Close() + fixtures := []struct{ name, schema, sql string }{ + {"reported_primary_key", "public", `CREATE TABLE public.events (id int PRIMARY KEY) PARTITION BY RANGE(id); +CREATE INDEX events_leaf_pkey ON public.events(id); +CREATE TABLE public.events_leaf PARTITION OF public.events FOR VALUES FROM (0) TO (10);`}, + {"unique_collision", "public", `CREATE TABLE events (id int UNIQUE) PARTITION BY RANGE(id); +CREATE INDEX events_leaf_id_key ON events(id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10);`}, + {"name_swap", "public", `CREATE TABLE events (id int, a int, b int, UNIQUE(id,a), UNIQUE(id,b)) PARTITION BY RANGE(id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10); +ALTER INDEX events_leaf_id_a_key RENAME TO staging; +ALTER INDEX events_leaf_id_b_key RENAME TO events_leaf_id_a_key; +ALTER INDEX staging RENAME TO events_leaf_id_b_key;`}, + {"nested_local_constraints", "public", `CREATE TABLE events (id int PRIMARY KEY, a int) PARTITION BY RANGE(id); +CREATE TABLE events_mid PARTITION OF events FOR VALUES FROM (0) TO (10) PARTITION BY RANGE(id); +ALTER TABLE events_mid ADD CONSTRAINT mid_local UNIQUE(id,a); +CREATE TABLE events_leaf PARTITION OF events_mid FOR VALUES FROM (0) TO (5); +ALTER INDEX events_mid_pkey RENAME TO mid_custom; +ALTER INDEX events_leaf_pkey RENAME TO leaf_custom; +ALTER INDEX events_leaf_id_a_key RENAME TO leaf_local_custom;`}, + {"quoted_identifiers", `s.'\$$.$pgschema_restore_0$"`, `CREATE TABLE "t.'\$$.$pgschema_restore_0$" (id int PRIMARY KEY, a int, UNIQUE(id,a)) PARTITION BY RANGE(id); +CREATE TABLE "leaf.'\$$.$pgschema_restore_0$" PARTITION OF "t.'\$$.$pgschema_restore_0$" FOR VALUES FROM (0) TO (10); +ALTER INDEX "leaf.'\$$.$pgschema_restore_0$_pkey" RENAME TO "idx.'\$$.$pgschema_restore_0$"; +ALTER INDEX "leaf.'\$$.$pgschema_restore_0$_id_a_key" RENAME TO "a_very_long_destination_index_name_with_apostrophe_'_slash_\_end";`}, + } + for _, fixture := range fixtures { + t.Run(fixture.name, func(t *testing.T) { + reset := func(db *sql.DB) { + t.Helper() + _, err := db.Exec("DROP SCHEMA IF EXISTS " + ir.QuoteIdentifier(fixture.schema) + " CASCADE; CREATE SCHEMA " + ir.QuoteIdentifier(fixture.schema) + "; SET search_path TO " + ir.QuoteIdentifier(fixture.schema) + ";") + if err != nil { + t.Fatal(err) + } + } + reset(sourceDB) + if _, err := sourceDB.Exec(fixture.sql); err != nil { + t.Fatal(err) + } + want := inheritedIndexCatalog(t, sourceDB, fixture.schema) + for _, multi := range []bool{false, true} { + for _, qualified := range []bool{false, true} { + t.Run(fmt.Sprintf("multi=%v/qualified=%v", multi, qualified), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "schema.sql") + output, err := dump.ExecuteDump(&dump.DumpConfig{Host: sh, Port: sp, DB: sd, User: su, Password: sw, Schema: fixture.schema, SSLMode: "disable", File: path, MultiFile: multi, QualifySchema: qualified}) + if err != nil { + t.Fatal(err) + } + if !multi { + if err := os.WriteFile(path, []byte(output), 0600); err != nil { + t.Fatal(err) + } + } + text, err := include.NewProcessor(filepath.Dir(path)).ProcessFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(text, "INTO STRICT") { + t.Fatal("dump omitted restoration") + } + sourcePlan, err := planCmd.GeneratePlan(&planCmd.PlanConfig{Host: sh, Port: sp, DB: sd, User: su, Password: sw, Schema: fixture.schema, SSLMode: "disable", File: path}, sharedEmbeddedPG) + if err != nil { + t.Fatal(err) + } + if len(sourcePlan.Groups) != 0 { + t.Fatalf("dump-to-source plan is not empty: %+v", sourcePlan.Groups) + } + if qualified && !multi { + external, err := postgres.NewExternalDatabase(&postgres.ExternalDatabaseConfig{Host: sh, Port: sp, Database: sd, Username: su, Password: sw, SSLMode: "disable", TargetMajorVersion: majorVersion}) + if err != nil { + t.Fatal(err) + } + defer external.Stop() + externalPlan, err := planCmd.GeneratePlan(&planCmd.PlanConfig{Host: sh, Port: sp, DB: sd, User: su, Password: sw, Schema: fixture.schema, SSLMode: "disable", File: path}, external) + if err != nil || len(externalPlan.Groups) != 0 { + t.Fatalf("external dump-to-source plan: %+v, %v", externalPlan, err) + } + } + for _, executor := range []string{"native", "sql"} { + t.Run(executor, func(t *testing.T) { + reset(targetDB) + config := &planCmd.PlanConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: fixture.schema, SSLMode: "disable", File: path} + migration, err := planCmd.GeneratePlan(config, sharedEmbeddedPG) + if err != nil { + t.Fatal(err) + } + if executor == "native" { + if len(migration.Groups) != 1 { + t.Fatalf("expected single creation group, got %d", len(migration.Groups)) + } + // Inject both strict-lookup and destination-name failures into the real + // creation group. Apply must propagate each failure and roll back tables. + for _, failure := range []string{"lookup", "occupied"} { + injected := false + for i := range migration.Groups[0].Steps { + step := &migration.Groups[0].Steps[i] + if !strings.Contains(step.SQL, "INTO STRICT") { + continue + } + original := step.SQL + if failure == "lookup" { + step.SQL = strings.Replace(original, "WHERE c.conrelid =", "WHERE false AND c.conrelid =", 1) + } else { + // The parent table is a relation in the same namespace as the leaf. + parentName := "events" + if fixture.name == "quoted_identifiers" { + parentName = `t.'\$$.$pgschema_restore_0$` + } + literal := "E'" + strings.ReplaceAll(strings.ReplaceAll(parentName, `\`, `\\`), "'", "''") + "'" + step.SQL = strings.Replace(original, "desired_names[position]);", literal+");", 1) + } + err = apply.ApplyMigration(&apply.ApplyConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: fixture.schema, SSLMode: "disable", Plan: migration, AutoApprove: true, NoColor: true, Quiet: true}, nil) + step.SQL = original + expected := "P0002" + if failure == "occupied" { + expected = "42P07" + } + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != expected { + t.Fatalf("%s failure did not propagate %s: %v", failure, expected, err) + } + var count int + if err := targetDB.QueryRow(`SELECT count(*) FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1`, fixture.schema).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("failed group left %d relations", count) + } + injected = true + break + } + if !injected { + t.Fatal("no restoration step for failure injection") + } + } + err = apply.ApplyMigration(&apply.ApplyConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: fixture.schema, SSLMode: "disable", Plan: migration, AutoApprove: true, NoColor: true, Quiet: true}, nil) + } else { + _, err = targetDB.Exec(text) + } + if err != nil { + t.Fatalf("%s replay failed: %v", executor, err) + } + if got := inheritedIndexCatalog(t, targetDB, fixture.schema); got != want { + t.Fatalf("catalog mismatch\nwant: %s\ngot: %s", want, got) + } + followUp, err := planCmd.GeneratePlan(config, sharedEmbeddedPG) + if err != nil { + t.Fatal(err) + } + if len(followUp.Groups) != 0 { + t.Fatalf("follow-up plan is not empty: %+v", followUp.Groups) + } + }) + } + }) + } + } + }) + } +} + +func inheritedIndexCatalog(t *testing.T, db *sql.DB, schema string) string { + t.Helper() + var catalog string + err := db.QueryRow(`SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY table_name,index_name),'[]'::jsonb)::text FROM ( + SELECT t.relname AS table_name,i.relname AS index_name,pg_catalog.pg_get_indexdef(i.oid) AS definition, + idx.indisvalid,idx.indisready,idx.indislive,c.conname,c.contype, + pc.conname AS parent_constraint,pt.relname AS parent_table, + ARRAY(SELECT p.relname FROM pg_catalog.pg_inherits h JOIN pg_catalog.pg_class p ON p.oid=h.inhparent WHERE h.inhrelid=i.oid ORDER BY h.inhseqno) AS parent_indexes, + ARRAY(SELECT dep.deptype::text FROM pg_catalog.pg_depend dep WHERE dep.classid='pg_catalog.pg_class'::pg_catalog.regclass AND dep.objid=i.oid AND dep.refclassid='pg_catalog.pg_constraint'::pg_catalog.regclass AND dep.refobjid=c.oid ORDER BY dep.deptype) AS ownership + FROM pg_catalog.pg_index idx JOIN pg_catalog.pg_class i ON i.oid=idx.indexrelid + JOIN pg_catalog.pg_class t ON t.oid=idx.indrelid JOIN pg_catalog.pg_namespace n ON n.oid=t.relnamespace + LEFT JOIN pg_catalog.pg_constraint c ON c.conindid=i.oid AND c.conrelid=t.oid AND c.contype IN ('p','u') + LEFT JOIN pg_catalog.pg_constraint pc ON pc.oid=c.conparentid LEFT JOIN pg_catalog.pg_class pt ON pt.oid=pc.conrelid + WHERE n.nspname=$1) x`, schema).Scan(&catalog) + if err != nil { + t.Fatal(err) + } + return catalog +} diff --git a/cmd/plan/plan.go b/cmd/plan/plan.go index 44a1e00a..372f26c5 100644 --- a/cmd/plan/plan.go +++ b/cmd/plan/plan.go @@ -588,6 +588,19 @@ func normalizeSchemaNames(irData *ir.IR, fromSchema, toSchema string) { } } + for j := range table.InheritedConstraintIndexes { + index := &table.InheritedConstraintIndexes[j] + if index.Index.Schema == fromSchema { + index.Index.Schema = toSchema + } + if index.ParentIndex.Schema == fromSchema { + index.ParentIndex.Schema = toSchema + } + if index.ParentConstraint.Schema == fromSchema { + index.ParentConstraint.Schema = toSchema + } + } + // Normalize schema names in indexes for _, index := range table.Indexes { if index.Schema == fromSchema { diff --git a/cmd/plan/plan_test.go b/cmd/plan/plan_test.go index 6569f3be..ca3618f4 100644 --- a/cmd/plan/plan_test.go +++ b/cmd/plan/plan_test.go @@ -303,6 +303,10 @@ func TestNormalizeSchemaNames_IndexAttachment(t *testing.T) { "child": {Schema: temporary, Name: "child", Indexes: map[string]*ir.Index{"local": local, "external": external}, Constraints: map[string]*ir.Constraint{"local": localConstraint, "external": externalConstraint}, AllPartitionChildren: []ir.TableDependency{{Schema: temporary, Name: "leaf"}, {Schema: "other", Name: "outside_leaf"}}, + InheritedConstraintIndexes: []ir.InheritedConstraintIndex{ + {Index: ir.IndexRef{Schema: temporary, Name: "leaf"}, ParentIndex: ir.IndexRef{Schema: temporary, Name: "parent"}, ParentConstraint: ir.ConstraintRef{Schema: temporary, Table: "root", Name: "origin"}}, + {Index: ir.IndexRef{Schema: temporary, Name: "local_leaf"}, ParentIndex: ir.IndexRef{Schema: "other", Name: "external_parent"}, ParentConstraint: ir.ConstraintRef{Schema: "other", Table: "outside_root", Name: "external_origin"}}, + }, }, }}, }} @@ -320,6 +324,11 @@ func TestNormalizeSchemaNames_IndexAttachment(t *testing.T) { t.Fatalf("Incorrect constraint-index normalization: %+v, %+v", localConstraint.BackingIndex, externalConstraint.BackingIndex) } children := data.Schemas["public"].Tables["child"].AllPartitionChildren + inherited := data.Schemas["public"].Tables["child"].InheritedConstraintIndexes + if inherited[0].Index.Schema != "public" || inherited[0].ParentIndex.Schema != "public" || inherited[0].ParentConstraint.Schema != "public" || + inherited[1].Index.Schema != "public" || inherited[1].ParentIndex.Schema != "other" || inherited[1].ParentConstraint.Schema != "other" { + t.Fatalf("Incorrect inherited-index metadata normalization: %+v", inherited) + } if children[0].Schema != "public" || children[1].Schema != "other" { t.Fatalf("Incorrect partition-table metadata normalization: %+v", children) } diff --git a/docs/cli/dump.mdx b/docs/cli/dump.mdx index e214d164..71d49093 100644 --- a/docs/cli/dump.mdx +++ b/docs/cli/dump.mdx @@ -263,6 +263,13 @@ leaves unattached indexes unattached. Multi-file dumps write parent indexes and their attachments under `indexes/`, such as `indexes/0001_events_lookup.sql`, after the table includes. +For retained constraint families, `dump` emits a `DO` block after each partition +definition to restore inherited `PRIMARY KEY` and `UNIQUE` index names. During +replay, PostgreSQL locates the indexes through their parent constraints and +renames mismatches in two phases, preserving ownership and handling name swaps. +Lookup and rename failures stop replay. Multi-file dumps keep each block with +its table definition, before standalone indexes. + Each individual file (e.g., `tables/users.sql`) contains the specific object definition: ```sql -- diff --git a/docs/cli/plan.mdx b/docs/cli/plan.mdx index fdba0061..75bcc2e9 100644 --- a/docs/cli/plan.mdx +++ b/docs/cli/plan.mdx @@ -28,6 +28,26 @@ When part of the hierarchy already exists in the target, or part of it is ignored, `plan` falls back to recursive index creation and does not reconcile attachments on existing tables. +For a complete new hierarchy, `plan` restores inherited `PRIMARY KEY` and `UNIQUE` +index names immediately after creating each partition. It restores only indexes +whose originating constraint remains managed. Native `apply` runs restoration +with the table creation group, so a restoration failure rolls back that group. + +## Executable DO blocks in desired SQL + +When loading desired SQL into its temporary schema, `plan` remaps explicit +relation-valued literals cast to `regclass` inside executable `DO` bodies. +For example, `'public.events'::pg_catalog.regclass` refers to the temporary copy +of `events`. The loader preserves other code, ordinary strings, comments, +destination index names, and references to other schemas. For example, +`FROM public.events` remains unchanged inside a `DO` body. This preserves table +aliases and record fields that have the same name as the managed schema. +With an external plan database, the loader remaps only casts for objects created +by the desired SQL, so extension references remain intact. The loader continues +to remap function and procedure bodies as before. +If the loader encounters an invalid escape literal while remapping a `DO` body, +it returns a contextual error before executing the desired SQL. + ## Basic Usage ```bash diff --git a/internal/diff/inherited_constraint_index.go b/internal/diff/inherited_constraint_index.go new file mode 100644 index 00000000..83a0146e --- /dev/null +++ b/internal/diff/inherited_constraint_index.go @@ -0,0 +1,125 @@ +package diff + +import ( + "fmt" + "sort" + "strings" + + "github.com/pgplex/pgschema/ir" +) + +// Follow immediate constraint parents to a retained originating constraint. +// Inherited copies are inspection metadata, even when their origin is ignored. +func retainedInheritedConstraint(index ir.InheritedConstraintIndex, tables map[string]*ir.Table, seen map[ir.ConstraintRef]bool) bool { + ref := index.ParentConstraint + if seen[ref] { + return false + } + seen[ref] = true + parent := tables[ref.Schema+"."+ref.Table] + if parent == nil || parent.IsExternal { + return false + } + if constraint := parent.Constraints[ref.Name]; constraint != nil { + return constraint.Type == index.ConstraintType + } + for _, inherited := range parent.InheritedConstraintIndexes { + if inherited.ConstraintName == ref.Name && inherited.Index == index.ParentIndex { + return retainedInheritedConstraint(inherited, tables, seen) + } + } + return false +} + +// restoreInheritedIndexesSQL runs immediately after PARTITION OF. Resolve every +// index before renaming anything, then vacate all mismatched names before the +// final renames. ALTER INDEX retains constraint ownership and renames the owning +// constraint too. Catalog identities, never generated names, select the indexes. +func (p *partitionIndexCreationPlan) restoreInheritedIndexesSQL(table *ir.Table, targetSchema string, qualify bool) string { + if p == nil { + return "" + } + indexes := append([]ir.InheritedConstraintIndex(nil), p.inherited[table.Schema+"."+table.Name]...) + if len(indexes) == 0 { + return "" + } + sort.Slice(indexes, func(i, j int) bool { return indexes[i].Index.Name < indexes[j].Index.Name }) + // Explicit escape literals also work with standard_conforming_strings off. + literal := func(s string) string { + return "E'" + strings.ReplaceAll(strings.ReplaceAll(s, `\`, `\\`), "'", "''") + "'" + } + relation := func(schema, name string) string { + return literal(qualifyEntityNameMode(schema, name, targetSchema, qualify)) + "::pg_catalog.regclass" + } + var body strings.Builder + body.WriteString(`DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN +`) + for _, index := range indexes { + kind := "u" + if index.ConstraintType == ir.ConstraintTypePrimaryKey { + kind = "p" + } + fmt.Fprintf(&body, ` SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = %s AND c.contype = %s + AND pc.conrelid = %s AND pc.conname = %s + AND parent_index.relname = %s; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, %s); +`, relation(table.Schema, table.Name), literal(kind), relation(index.ParentConstraint.Schema, index.ParentConstraint.Table), literal(index.ParentConstraint.Name), literal(index.ParentIndex.Name), literal(index.Index.Name)) + } + body.WriteString(` FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +`) + for n := 0; ; n++ { + tag := fmt.Sprintf("$pgschema_restore_%d$", n) + if !strings.Contains(body.String(), tag) { + return "DO " + tag + "\n" + body.String() + tag + ";" + } + } +} diff --git a/internal/diff/inherited_constraint_index_test.go b/internal/diff/inherited_constraint_index_test.go new file mode 100644 index 00000000..f1837d04 --- /dev/null +++ b/internal/diff/inherited_constraint_index_test.go @@ -0,0 +1,79 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/pgplex/pgschema/ir" +) + +func TestInheritedConstraintIndexEligibility(t *testing.T) { + for _, tc := range []struct { + name string + want bool + }{ + {"complete", true}, {"ignored_origin", false}, {"existing_root", false}, {"existing_child", false}, + {"filtered_child", false}, {"unmanaged_ancestor", false}, {"nested_retained_origin", true}, {"nested_ignored_origin", false}, + } { + t.Run(tc.name, func(t *testing.T) { + schema := partitionIndexTestSchema() + tables := schema.Schemas["public"].Tables + root, child := tables["events"], tables["events_early"] + origin := &ir.Constraint{Schema: "public", Table: root.Name, Name: "origin", Type: ir.ConstraintTypePrimaryKey} + root.Constraints = map[string]*ir.Constraint{"origin": origin} + root.AllPartitionChildren = []ir.TableDependency{{Schema: "public", Name: child.Name}} + child.InheritedConstraintIndexes = []ir.InheritedConstraintIndex{{Index: ir.IndexRef{Schema: "public", Name: "renamed_leaf"}, ConstraintName: "renamed_leaf", ConstraintType: ir.ConstraintTypePrimaryKey, ParentIndex: ir.IndexRef{Schema: "public", Name: "origin"}, ParentConstraint: ir.ConstraintRef{Schema: "public", Table: root.Name, Name: "origin"}}} + desired := map[string]*ir.Table{"public.events": root, "public.events_early": child} + added := []*ir.Table{root, child} + switch tc.name { + case "ignored_origin": + delete(root.Constraints, "origin") + case "existing_root": + added = []*ir.Table{child} + case "existing_child": + added = []*ir.Table{root} + case "filtered_child": + root.AllPartitionChildren = append(root.AllPartitionChildren, ir.TableDependency{Schema: "public", Name: "missing"}) + case "unmanaged_ancestor": + root.PartitionOf = "external" + root.PartitionOfSchema = "other" + case "nested_retained_origin", "nested_ignored_origin": + ancestor := &ir.Table{Schema: "public", Name: "ancestor", Constraints: map[string]*ir.Constraint{"ancestor_origin": {Name: "ancestor_origin", Type: ir.ConstraintTypePrimaryKey}}} + ancestor.AllPartitionChildren = []ir.TableDependency{{Schema: "public", Name: root.Name}} + root.Constraints = nil + root.PartitionOf = ancestor.Name + root.PartitionOfSchema = "public" + root.InheritedConstraintIndexes = []ir.InheritedConstraintIndex{{Index: ir.IndexRef{Schema: "public", Name: "origin"}, ConstraintName: "origin", ConstraintType: ir.ConstraintTypePrimaryKey, ParentIndex: ir.IndexRef{Schema: "public", Name: "ancestor_origin"}, ParentConstraint: ir.ConstraintRef{Schema: "public", Table: ancestor.Name, Name: "ancestor_origin"}}} + desired["public.ancestor"] = ancestor + added = append(added, ancestor) + tables[ancestor.Name] = ancestor + if tc.name == "nested_ignored_origin" { + ancestor.Constraints = nil + } + } + plan := planPartitionIndexCreation(added, desired, false) + restoration := plan.restoreInheritedIndexesSQL(child, "public", false) + if (restoration != "") != tc.want { + t.Fatalf("restoration eligibility = %v, want %v", restoration != "", tc.want) + } + if tc.want { + statements := GenerateMigration(ir.NewIR(), schema, "public") + if !strings.Contains(buildSQLFromSteps(statements), "INTO STRICT") { + t.Fatal("migration omitted restoration") + } + found := false + for _, step := range statements { + if step.Type == DiffTypeTable && step.Path == "public.events_early" { + if len(step.Statements) != 2 || !strings.HasPrefix(step.Statements[0].SQL, "CREATE TABLE") || !strings.HasPrefix(step.Statements[1].SQL, "DO ") || !step.Statements[1].CanRunInTransaction { + t.Fatalf("restoration is not part of table creation: %+v", step) + } + found = true + } + } + if !found { + t.Fatal("missing table creation") + } + } + }) + } +} diff --git a/internal/diff/partition_index.go b/internal/diff/partition_index.go index 4a6e349b..ba68c15b 100644 --- a/internal/diff/partition_index.go +++ b/internal/diff/partition_index.go @@ -7,6 +7,25 @@ import ( "github.com/pgplex/pgschema/ir" ) +// PartitionIndexDependencyError identifies an excluded dependency of a retained +// partitioned index. ExcludedTable is set when the attached constraint-backed +// index requires a partition table that is outside the managed schema. +type PartitionIndexDependencyError struct { + ParentIndex ir.IndexRef + AttachedIndex ir.IndexRef + ExcludedTable *ir.TableDependency +} + +func (e *PartitionIndexDependencyError) Error() string { + parent := ir.QuoteIdentifier(e.ParentIndex.Schema) + "." + ir.QuoteIdentifier(e.ParentIndex.Name) + child := ir.QuoteIdentifier(e.AttachedIndex.Schema) + "." + ir.QuoteIdentifier(e.AttachedIndex.Name) + if e.ExcludedTable != nil { + table := ir.QuoteIdentifier(e.ExcludedTable.Schema) + "." + ir.QuoteIdentifier(e.ExcludedTable.Name) + return fmt.Sprintf("cannot dump index %s: attached constraint-backed index %s requires excluded partition table %s; adjust .pgschemaignore to include the partition table", parent, child, table) + } + return fmt.Sprintf("cannot dump index %s: attached index %s is excluded; adjust .pgschemaignore to include the child index, its table, and any owning constraint", parent, child) +} + // availablePartitionIndexes includes indexes created by retained constraints. // Those indexes remain outside standalone index creation and comparison. func availablePartitionIndexes(tables []*ir.Table) map[ir.IndexRef]bool { @@ -58,9 +77,7 @@ func validatePartitionIndexDependencies(schema *ir.IR) error { for _, key := range keys { for _, child := range indexes[key].AttachedPartitions { if !available[child] { - return fmt.Errorf("cannot dump index %s: attached index %s is excluded; adjust .pgschemaignore to include the child index, its table, and any owning constraint", - ir.QuoteIdentifier(key.Schema)+"."+ir.QuoteIdentifier(key.Name), - ir.QuoteIdentifier(child.Schema)+"."+ir.QuoteIdentifier(child.Name)) + return &PartitionIndexDependencyError{ParentIndex: key, AttachedIndex: child} } if table := constraintIndexes[child]; table != nil { if err := validateConstraintIndexPartitions(key, child, table, tablesByName); err != nil { @@ -89,10 +106,7 @@ func validateConstraintIndexPartitions(parent, child ir.IndexRef, table *ir.Tabl for _, partition := range current.AllPartitionChildren { next := tables[partition.Schema+"."+partition.Name] if next == nil || next.IsExternal { - return fmt.Errorf("cannot dump index %s: attached constraint-backed index %s requires excluded partition table %s; adjust .pgschemaignore to include the partition table", - ir.QuoteIdentifier(parent.Schema)+"."+ir.QuoteIdentifier(parent.Name), - ir.QuoteIdentifier(child.Schema)+"."+ir.QuoteIdentifier(child.Name), - ir.QuoteIdentifier(partition.Schema)+"."+ir.QuoteIdentifier(partition.Name)) + return &PartitionIndexDependencyError{ParentIndex: parent, AttachedIndex: child, ExcludedTable: &partition} } pending = append(pending, next) } @@ -109,6 +123,7 @@ type partitionIndexCreationPlan struct { indexes map[ir.IndexRef]*ir.Index children map[ir.IndexRef]map[ir.IndexRef]bool uniqueTables map[string]bool + inherited map[string][]ir.InheritedConstraintIndex } func planPartitionIndexCreation(addedTables []*ir.Table, desiredTables map[string]*ir.Table, dump bool) *partitionIndexCreationPlan { @@ -116,6 +131,7 @@ func planPartitionIndexCreation(addedTables []*ir.Table, desiredTables map[strin indexes: make(map[ir.IndexRef]*ir.Index), children: make(map[ir.IndexRef]map[ir.IndexRef]bool), uniqueTables: make(map[string]bool), + inherited: make(map[string][]ir.InheritedConstraintIndex), } added := make(map[string]*ir.Table) available := availablePartitionIndexes(addedTables) @@ -166,6 +182,11 @@ func planPartitionIndexCreation(addedTables []*ir.Table, desiredTables map[strin if !dump && (roots[key] == "" || blocked[roots[key]]) { continue } + for _, index := range table.InheritedConstraintIndexes { + if retainedInheritedConstraint(index, desiredTables, make(map[ir.ConstraintRef]bool)) { + plan.inherited[key] = append(plan.inherited[key], index) + } + } for _, index := range table.Indexes { if index.IsPartitioned && index.Type != ir.IndexTypePrimary { plan.indexes[ir.IndexRef{Schema: index.Schema, Name: index.Name}] = index diff --git a/internal/diff/partition_index_error_test.go b/internal/diff/partition_index_error_test.go new file mode 100644 index 00000000..7586363c --- /dev/null +++ b/internal/diff/partition_index_error_test.go @@ -0,0 +1,43 @@ +package diff + +import ( + "errors" + "fmt" + "testing" + + "github.com/pgplex/pgschema/ir" +) + +func TestPartitionIndexDependencyError(t *testing.T) { + for _, tc := range []struct { + name string + partition *ir.TableDependency + message string + }{ + { + name: "excluded_index", + message: `cannot dump index "App.Schema"."Parent.Index": attached index "App.Schema"."Child.Index" is excluded; adjust .pgschemaignore to include the child index, its table, and any owning constraint`, + }, + { + name: "excluded_partition", + partition: &ir.TableDependency{Schema: "App.Schema", Name: "Leaf.Table", Type: ir.DependencyTypeTable}, + message: `cannot dump index "App.Schema"."Parent.Index": attached constraint-backed index "App.Schema"."Child.Index" requires excluded partition table "App.Schema"."Leaf.Table"; adjust .pgschemaignore to include the partition table`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + original := &PartitionIndexDependencyError{ + ParentIndex: ir.IndexRef{Schema: "App.Schema", Name: "Parent.Index"}, + AttachedIndex: ir.IndexRef{Schema: "App.Schema", Name: "Child.Index"}, + ExcludedTable: tc.partition, + } + wrapped := fmt.Errorf("generate dump: %w", original) + var restored *PartitionIndexDependencyError + if !errors.As(wrapped, &restored) || restored != original { + t.Fatalf("Lost dependency classification or context: %v", wrapped) + } + if got := restored.Error(); got != tc.message { + t.Fatalf("Dependency guidance changed:\ngot: %s\nwant: %s", got, tc.message) + } + }) + } +} diff --git a/internal/diff/partition_index_test.go b/internal/diff/partition_index_test.go index c8bcc8f9..5997cf53 100644 --- a/internal/diff/partition_index_test.go +++ b/internal/diff/partition_index_test.go @@ -1,6 +1,7 @@ package diff import ( + "errors" "strings" "testing" @@ -102,9 +103,14 @@ func TestGenerateDumpExcludedPartitionIndex(t *testing.T) { }} } steps, err := GenerateDump(schema, "public", false) - if err == nil || !strings.Contains(err.Error(), "index public.parent_lookup: attached index public.child_lookup is excluded") { + var dependencyErr *PartitionIndexDependencyError + if !errors.As(err, &dependencyErr) { t.Fatalf("Expected excluded-child error, got %v", err) } + if dependencyErr.ParentIndex != (ir.IndexRef{Schema: "public", Name: "parent_lookup"}) || + dependencyErr.AttachedIndex != (ir.IndexRef{Schema: "public", Name: "child_lookup"}) || dependencyErr.ExcludedTable != nil { + t.Fatalf("Unexpected dependency context: %+v", dependencyErr) + } if len(steps) != 0 { t.Fatal("Failed dump returned partial SQL") } diff --git a/internal/diff/table.go b/internal/diff/table.go index c9fc2e6d..0ba6625c 100644 --- a/internal/diff/table.go +++ b/internal/diff/table.go @@ -508,7 +508,11 @@ func generateCreateTablesSQL( CanRunInTransaction: true, // CREATE TABLE can run in a transaction } - collector.collect(context, sql) + statements := []SQLStatement{{SQL: sql, CanRunInTransaction: true}} + if restoration := partitionIndexes.restoreInheritedIndexesSQL(table, targetSchema, collector.qualifySchema); restoration != "" { + statements = append(statements, SQLStatement{SQL: restoration, CanRunInTransaction: true}) + } + collector.collectStatements(context, statements) // Add table comment if table.Comment != "" { diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index f9d04a56..0e70cef1 100644 --- a/internal/postgres/desired_state.go +++ b/internal/postgres/desired_state.go @@ -96,7 +96,7 @@ func GenerateTempSchemaName() string { // Only qualifications matching the specified schemaName are stripped. // All other schema qualifications are preserved as intentional cross-schema references. func stripSchemaQualifications(sql string, schemaName string) string { - if schemaName == "" || !strings.Contains(sql, schemaName) { + if schemaName == "" { return sql } @@ -109,112 +109,21 @@ func stripSchemaQualifications(sql string, schemaName string) string { // unstripped body references (Issue #399), callers should disable function // body validation with SET check_function_bodies = off before executing // the resulting SQL. - segments := splitDollarQuotedSegments(sql) - var result strings.Builder - result.Grow(len(sql)) - for _, seg := range segments { - if seg.quoted { - // Preserve dollar-quoted content as-is - result.WriteString(seg.text) - } else { - // Further split on single-quoted string literals to avoid stripping - // schema prefixes from inside string constants (Issue #371). - // e.g., has_scope('s.manage') must NOT become has_scope('manage') - result.WriteString(stripSchemaQualificationsPreservingStrings(seg.text, schemaName)) + tokens := lexSQL(sql) + significant := significantTokens(tokens) + for j := 0; j < len(significant); j++ { + i := significant[j] + if (j == 0 || tokens[significant[j-1]].text != ".") && tokenIdentifier(tokens[i]) == schemaName && j+2 < len(significant) && tokens[significant[j+1]].text == "." && tokens[significant[j+2]].kind == 'i' { + tokens[i].text = "" + tokens[significant[j+1]].text = "" + j += 2 } } - return result.String() -} - -// stripSchemaQualificationsPreservingStrings splits text on single-quoted string -// literals and SQL comments, applies schema stripping only to non-string, -// non-comment parts, and reassembles. -// -// Limitation: E'...' escape-string syntax uses backslash-escaped quotes (E'it\'s') -// rather than doubled quotes ('it”s'). This parser only recognises the ” form. -// With E'content\”, a backslash-escaped quote may cause the parser to mistrack -// string boundaries, which can result in either: -// - false-negative: schema qualifiers after the string are not stripped, or -// - false-positive: schema prefixes inside the E-string are incorrectly stripped. -// -// Both cases change semantics only for E'...' strings, which are extremely rare -// in DDL schema definitions. The false-negative case preserves valid SQL; the -// false-positive case could alter string content but is unlikely in practice. -func stripSchemaQualificationsPreservingStrings(text string, schemaName string) string { var result strings.Builder - result.Grow(len(text)) - - // flushCode writes text[segStart:end] through schema stripping and advances segStart. - i := 0 - segStart := 0 - - flushCode := func(end int) { - if end > segStart { - result.WriteString(stripSchemaQualificationsFromText(text[segStart:end], schemaName)) - } - segStart = end - } - flushLiteral := func(end int) { - result.WriteString(text[segStart:end]) - segStart = end - } - - for i < len(text) { - ch := text[i] - - // Start of single-quoted string literal - if ch == '\'' { - flushCode(i) - i++ // skip opening quote - for i < len(text) { - if text[i] == '\'' { - if i+1 < len(text) && text[i+1] == '\'' { - i += 2 // skip escaped '' - } else { - i++ // skip closing quote - break - } - } else { - i++ - } - } - flushLiteral(i) - continue - } - - // Start of line comment (--) - if ch == '-' && i+1 < len(text) && text[i+1] == '-' { - flushCode(i) - i += 2 - for i < len(text) && text[i] != '\n' { - i++ - } - if i < len(text) { - i++ // skip the newline - } - flushLiteral(i) - continue - } - - // Start of block comment (/* ... */) - if ch == '/' && i+1 < len(text) && text[i+1] == '*' { - flushCode(i) - i += 2 - for i < len(text) { - if text[i] == '*' && i+1 < len(text) && text[i+1] == '/' { - i += 2 - break - } - i++ - } - flushLiteral(i) - continue - } - - i++ + result.Grow(len(sql)) + for _, token := range tokens { + result.WriteString(token.text) } - // Remaining text is code - flushCode(i) return result.String() } @@ -234,43 +143,26 @@ var dollarQuoteRe = regexp.MustCompile(`\$(?:[a-zA-Z_][a-zA-Z0-9_]*)?\$`) func splitDollarQuotedSegments(sql string) []dollarQuotedSegment { var segments []dollarQuotedSegment - - pos := 0 - for pos < len(sql) { - // Find the next dollar-quote opening tag - loc := dollarQuoteRe.FindStringIndex(sql[pos:]) - if loc == nil { - // No more dollar quotes β€” rest is unquoted - segments = append(segments, dollarQuotedSegment{text: sql[pos:], quoted: false}) - break - } - - openStart := pos + loc[0] - openEnd := pos + loc[1] - tag := sql[openStart:openEnd] - - // Add the unquoted segment before this tag - if openStart > pos { - segments = append(segments, dollarQuotedSegment{text: sql[pos:openStart], quoted: false}) + var plain strings.Builder + for _, token := range lexSQL(sql) { + if token.kind != 'd' { + plain.WriteString(token.text) + continue } - - // Find the matching closing tag - closeIdx := strings.Index(sql[openEnd:], tag) - if closeIdx == -1 { - // No closing tag β€” treat rest as quoted (unterminated) - segments = append(segments, dollarQuotedSegment{text: sql[openStart:], quoted: true}) - pos = len(sql) - } else { - closeEnd := openEnd + closeIdx + len(tag) - segments = append(segments, dollarQuotedSegment{text: sql[openStart:closeEnd], quoted: true}) - pos = closeEnd + if plain.Len() > 0 { + segments = append(segments, dollarQuotedSegment{text: plain.String()}) + plain.Reset() } + segments = append(segments, dollarQuotedSegment{text: token.text, quoted: true}) + } + if plain.Len() > 0 { + segments = append(segments, dollarQuotedSegment{text: plain.String()}) } return segments } // schemaRegexes holds compiled regexes for a specific schema name, avoiding -// recompilation on every call to stripSchemaQualificationsFromText. +// recompilation when remapping function and procedure bodies. type schemaRegexes struct { re1 *regexp.Regexp // "schema"."object" re2 *regexp.Regexp // "schema".object @@ -307,51 +199,38 @@ func getSchemaRegexes(schemaName string) *schemaRegexes { return sr } -// stripSchemaQualificationsFromText performs the actual schema qualification stripping on a text segment. -// It handles 4 cases: -// 1. unquoted_schema.unquoted_object -> unquoted_object -// 2. unquoted_schema."quoted_object" -> "quoted_object" -// 3. "quoted_schema".unquoted_object -> unquoted_object -// 4. "quoted_schema"."quoted_object" -> "quoted_object" -func stripSchemaQualificationsFromText(text string, schemaName string) string { - sr := getSchemaRegexes(schemaName) - - result := text - // Apply in order: quoted schema first to avoid double-matching - result = sr.re1.ReplaceAllString(result, "$1") - result = sr.re2.ReplaceAllString(result, "$1") - // For patterns 3 and 4, $1 is the prefix (boundary char or empty at ^), - // $2 is the object name β€” preserve the prefix and keep only the object. - result = sr.re3.ReplaceAllString(result, "${1}${2}") - result = sr.re4.ReplaceAllString(result, "${1}${2}") - - return result -} - -var ( - // functionBodyPrefixRe matches unquoted SQL ending in the AS keyword, i.e. the text - // right before a dollar-quoted function/procedure body (as opposed to a DO block, - // a COMMENT ... IS $$...$$ or a dollar-quoted string constant). Comments may sit - // between AS and the opening dollar quote. - functionBodyPrefixRe = regexp.MustCompile(`(?is)\bAS\s*(?:(?:--[^\n]*\n|/\*.*?\*/)\s*)*$`) - - // createdObjectRe captures the name of each schema-level object a CREATE statement - // defines. It runs on SQL whose target-schema qualifiers were already stripped. - createdObjectRe = regexp.MustCompile(`(?i)\bCREATE\s+(?:OR\s+REPLACE\s+)?(?:UNLOGGED\s+|RECURSIVE\s+)?` + - `(?:TABLE|VIEW|MATERIALIZED\s+VIEW|FUNCTION|PROCEDURE|AGGREGATE|TYPE|DOMAIN|SEQUENCE)\s+` + - `(?:IF\s+NOT\s+EXISTS\s+)?("[^"]+"|[a-zA-Z_][a-zA-Z0-9_$]*)`) -) - // extractCreatedObjectNames returns the names of the objects created by the given SQL, // folded the way PostgreSQL folds them (unquoted names lowercased, quotes removed). func extractCreatedObjectNames(sql string) map[string]bool { names := make(map[string]bool) - for _, seg := range splitDollarQuotedSegments(sql) { - if seg.quoted { + tokens := lexSQL(sql) + indexes := significantTokens(tokens) + for j, i := range indexes { + if tokens[i].text == "" || tokens[i].kind != 'i' || strings.HasPrefix(tokens[i].text, `"`) || tokenIdentifier(tokens[i]) != "create" { + continue + } + k := j + 1 + for k < len(indexes) { + word := tokenIdentifier(tokens[indexes[k]]) + if word != "or" && word != "replace" && word != "unlogged" && word != "recursive" && word != "materialized" { + break + } + k++ + } + if k >= len(indexes) { continue } - for _, m := range createdObjectRe.FindAllStringSubmatch(seg.text, -1) { - names[foldIdentifier(m[1])] = true + switch tokenIdentifier(tokens[indexes[k]]) { + case "table", "view", "function", "procedure", "aggregate", "type", "domain", "sequence": + default: + continue + } + k++ + if k+2 < len(indexes) && tokenIdentifier(tokens[indexes[k]]) == "if" && tokenIdentifier(tokens[indexes[k+1]]) == "not" && tokenIdentifier(tokens[indexes[k+2]]) == "exists" { + k += 3 + } + if k < len(indexes) && tokens[indexes[k]].kind == 'i' && (k+1 == len(indexes) || tokens[indexes[k+1]].text != ".") { + names[tokenIdentifier(tokens[indexes[k]])] = true } } return names @@ -359,13 +238,15 @@ func extractCreatedObjectNames(sql string) map[string]bool { func foldIdentifier(ident string) string { if strings.HasPrefix(ident, `"`) { - return strings.Trim(ident, `"`) + return strings.ReplaceAll(ident[1:len(ident)-1], `""`, `"`) } return strings.ToLower(ident) } // qualifyFunctionBodiesWithTempSchema points target-schema qualifiers inside -// dollar-quoted function/procedure bodies at the temporary schema. +// function/procedure bodies at the temporary schema. In executable DO bodies, +// it remaps only explicit relation-valued literals cast to regclass. Other DO +// code, comments, and ordinary literals retain their existing meaning. // // stripSchemaQualifications leaves bodies untouched (issue #354), and // check_function_bodies = off keeps CREATE FUNCTION from validating them. But a @@ -381,9 +262,9 @@ func foldIdentifier(ident string) string { // installs the target schema's extensions there). Otherwise only qualifiers of the // named objects are rewritten, so references to things that really live in the // target schema of an external plan database (e.g. public.unaccent) are preserved. -func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, objects map[string]bool) string { - if schemaName == "" || tempSchema == "" || !strings.Contains(sql, schemaName) { - return sql +func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, objects map[string]bool) (string, error) { + if schemaName == "" || tempSchema == "" { + return sql, nil } sr := getSchemaRegexes(schemaName) @@ -396,15 +277,37 @@ func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, obj }) } - segments := splitDollarQuotedSegments(sql) - var result strings.Builder - result.Grow(len(sql)) - for i, seg := range segments { - if !seg.quoted || i == 0 || !functionBodyPrefixRe.MatchString(segments[i-1].text) { - result.WriteString(seg.text) + tokens := lexSQL(sql) + significant := significantTokens(tokens) + for j, i := range significant { + token := tokens[i] + if !completeSQLLiteral(token) { + continue + } + doPosition := j - 1 + if j >= 3 && strings.EqualFold(tokens[significant[j-2]].text, "language") { + doPosition = j - 3 + } + isDO := doPosition >= 0 && strings.EqualFold(tokens[significant[doPosition]].text, "do") && + (doPosition == 0 || tokens[significant[doPosition-1]].text == ";") + if isDO { + body, err := literalContent(token) + if err != nil { + return "", fmt.Errorf("decode DO body: %w", err) + } + remapped, err := remapDOBody(body, schemaName, tempSchema, objects) + if err != nil { + return "", err + } + if remapped != body { + tokens[i].text = encodeSQLLiteral(token, remapped) + } + continue + } + if token.kind != 'd' || j == 0 || tokenIdentifier(tokens[significant[j-1]]) != "as" { continue } - body := seg.text + body := token.text // Preserve the qualifier's quoting so the round trip through // normalizeSchemaNames reproduces the original text. quoted := `"` + tempSchema + `".$1` @@ -413,9 +316,13 @@ func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, obj body = rewrite(sr.re2, body, 1, quoted) body = rewrite(sr.re3, body, 2, unquoted) body = rewrite(sr.re4, body, 2, unquoted) - result.WriteString(body) + tokens[i].text = body } - return result.String() + var result strings.Builder + for _, token := range tokens { + result.WriteString(token.text) + } + return result.String(), nil } // replaceSchemaInSearchPath replaces the target schema name in SET search_path clauses diff --git a/internal/postgres/desired_state_test.go b/internal/postgres/desired_state_test.go index eb949111..26f69d74 100644 --- a/internal/postgres/desired_state_test.go +++ b/internal/postgres/desired_state_test.go @@ -274,16 +274,10 @@ func TestStripSchemaQualifications_PreservesStringLiterals(t *testing.T) { expected: "/* drop public.t */ DROP TABLE t;", }, { - // Known limitation: E'...' escape-string syntax with backslash-escaped quotes - // is not handled. The parser treats \' as ordinary char + string-closer, - // mistracking boundaries. Here it strips inside the string (wrong) and - // misses the identifier after (also wrong). Both are safe: the SQL remains - // valid, and the unstripped qualifier just means the object is looked up - // in the original schema. E'...' in DDL is extremely rare. - name: "E-string with backslash-escaped quote (known limitation)", + name: "E-string with backslash-escaped quote", sql: "SELECT E'it\\'s public.test' FROM public.t;", schema: "public", - expected: "SELECT E'it\\'s test' FROM public.t;", + expected: "SELECT E'it\\'s public.test' FROM t;", }, } @@ -529,7 +523,7 @@ func TestQualifyFunctionBodiesWithTempSchema(t *testing.T) { expected: `CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELECT 1 FROM auth.users, notpublic.t $$;`, }, { - name: "dollar-quoted text that is not a function body is preserved", + name: "DO code and comments remain unchanged", sql: `COMMENT ON TABLE t IS $$see public.t$$; DO $$ BEGIN PERFORM 1 FROM public.t; END $$;`, expected: `COMMENT ON TABLE t IS $$see public.t$$; DO $$ BEGIN PERFORM 1 FROM public.t; END $$;`, }, @@ -543,7 +537,10 @@ func TestQualifyFunctionBodiesWithTempSchema(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := qualifyFunctionBodiesWithTempSchema(tt.sql, "public", tmp, tt.objects) + got, err := qualifyFunctionBodiesWithTempSchema(tt.sql, "public", tmp, tt.objects) + if err != nil { + t.Fatal(err) + } if got != tt.expected { t.Errorf("got:\n%s\nexpected:\n%s", got, tt.expected) } diff --git a/internal/postgres/do_remap_test.go b/internal/postgres/do_remap_test.go new file mode 100644 index 00000000..bab40be5 --- /dev/null +++ b/internal/postgres/do_remap_test.go @@ -0,0 +1,102 @@ +package postgres + +import ( + "strings" + "testing" +) + +func TestRemapExecutableDOBodies(t *testing.T) { + for _, tc := range []struct { + name, sql, schema, want string + objects map[string]bool + }{ + {name: "code_comments_and_literals", schema: "public", sql: `DO $b$ BEGIN +PERFORM 1 FROM public.t; +-- public.t $$ +/* public.t /* nested public.t */ public.t */ +PERFORM 'public.t', E'it\'s public.t', $text$public.t$text$; +EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', 'public', 'old', 'public.t'); +PERFORM 1 FROM other.t; +END $b$;`, want: `DO $b$ BEGIN +PERFORM 1 FROM public.t; +-- public.t $$ +/* public.t /* nested public.t */ public.t */ +PERFORM 'public.t', E'it\'s public.t', $text$public.t$text$; +EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', 'public', 'old', 'public.t'); +PERFORM 1 FROM other.t; +END $b$;`}, + {name: "regclass_literals", schema: "public", sql: `DO LANGUAGE plpgsql $$ BEGIN +PERFORM 'public.t'::regclass, E'public."t''\\"'::pg_catalog.regclass, $n$public.t$n$::regclass; +PERFORM CAST('public.t' AS pg_catalog.regclass), 'other.t'::regclass, 'public.t'::text, 'public.t'::other.regclass; +END $$;`, want: `DO LANGUAGE plpgsql $$ BEGIN +PERFORM '"pgschema_tmp_x".t'::regclass, E'"pgschema_tmp_x"."t''\\"'::pg_catalog.regclass, $n$"pgschema_tmp_x".t$n$::regclass; +PERFORM CAST('"pgschema_tmp_x".t' AS pg_catalog.regclass), 'other.t'::regclass, 'public.t'::text, 'public.t'::other.regclass; +END $$;`}, + {name: "external_objects", schema: "public", objects: map[string]bool{"t": true}, sql: `DO /* public.t */ $$ BEGIN PERFORM public.unaccent('public.t') FROM public.t; PERFORM 'public.t'::regclass, 'public.extension_table'::regclass; END $$;`, want: `DO /* public.t */ $$ BEGIN PERFORM public.unaccent('public.t') FROM public.t; PERFORM '"pgschema_tmp_x".t'::regclass, 'public.extension_table'::regclass; END $$;`}, + {name: "single_quoted_do", schema: "public", sql: `DO 'BEGIN PERFORM 1 FROM public.t; PERFORM ''public.t''::regclass; END';`, want: `DO 'BEGIN PERFORM 1 FROM public.t; PERFORM ''"pgschema_tmp_x".t''::regclass; END';`}, + {name: "escaped_identifiers", schema: `s".'\$$`, sql: `DO $body$ BEGIN PERFORM 1 FROM "s"".'\$$"."t.'\$$"; PERFORM '"s"".''\$$"."t.''\$$"'::pg_catalog.regclass; END $body$;`, want: `DO $body$ BEGIN PERFORM 1 FROM "s"".'\$$"."t.'\$$"; PERFORM '"pgschema_tmp_x"."t.''\$$"'::pg_catalog.regclass; END $body$;`}, + {name: "preserve_non_executable_text", schema: "public", sql: `-- DO $$ public.t $$ +SELECT 'DO', 'public.t'; COMMENT ON TABLE t IS $$ public.t $$;`, want: `-- DO $$ public.t $$ +SELECT 'DO', 'public.t'; COMMENT ON TABLE t IS $$ public.t $$;`}, + {name: "repeated_schema_identifiers", schema: "public", sql: `DO $$ BEGIN PERFORM public.public.id FROM public.public; PERFORM other.public.id FROM other.public; END $$;`, want: `DO $$ BEGIN PERFORM public.public.id FROM public.public; PERFORM other.public.id FROM other.public; END $$;`}, + {name: "unterminated_dollar_body", schema: "public", sql: `DO $$ public.t`, want: `DO $$ public.t`}, + {name: "unterminated_string_body", schema: "public", sql: `DO 'public.t`, want: `DO 'public.t`}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := qualifyFunctionBodiesWithTempSchema(tc.sql, tc.schema, "pgschema_tmp_x", tc.objects) + if err != nil { + t.Fatal(err) + } + if got != tc.want { + t.Fatalf("got:\n%s\nwant:\n%s", got, tc.want) + } + }) + } +} + +func TestCreatedObjectsWithLiteralDelimiters(t *testing.T) { + const schema = `s".'\$$` + sql := `-- CREATE TABLE bogus(id int); +CREATE TABLE "s"".'\$$"."t"".'\$$"(id int); +COMMENT ON TABLE "s"".'\$$"."t"".'\$$" IS 'CREATE TABLE bogus2(id int)'; +CREATE TABLE other.external(id int); +DO $$ BEGIN PERFORM 1; END $$;` + stripped := stripSchemaQualifications(sql, schema) + if !strings.Contains(stripped, `CREATE TABLE "t"".'\$$"`) { + t.Fatalf("schema not stripped: %s", stripped) + } + objects := extractCreatedObjectNames(stripped) + if len(objects) != 1 || !objects[`t".'\$$`] { + t.Fatalf("incorrect created objects: %v", objects) + } +} + +func TestStripRepeatedSchemaIdentifier(t *testing.T) { + const sql = `COMMENT ON COLUMN public.public.id IS 'public.public.id'; SELECT other.public.id FROM other.public;` + const want = `COMMENT ON COLUMN public.id IS 'public.public.id'; SELECT other.public.id FROM other.public;` + if got := stripSchemaQualifications(sql, "public"); got != want { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestDORegclassRemappingPreservesAliasesAndRecords(t *testing.T) { + for _, body := range []string{ + `BEGIN PERFORM public.t FROM t AS public; END`, + `BEGIN PERFORM "public".t FROM t AS "public"; END`, + `DECLARE public record; BEGIN SELECT 1 AS t INTO public; PERFORM public.t; END`, + `DECLARE public record; BEGIN SELECT 1 AS t INTO public; DECLARE public record; BEGIN SELECT 2 AS t INTO public; PERFORM public.t; END; PERFORM public.t; END`, + } { + t.Run(body, func(t *testing.T) { + for _, objects := range []map[string]bool{nil, {"t": true}} { + // The second block forces a remap in the same script. Alias and + // record references in the first block must still remain verbatim. + original := "DO $$ " + body + " $$; DO $$ BEGIN PERFORM 'public.t'::regclass; END $$;" + want := "DO $$ " + body + " $$; DO $$ BEGIN PERFORM '\"pgschema_tmp_x\".t'::regclass; END $$;" + got, err := qualifyFunctionBodiesWithTempSchema(original, "public", "pgschema_tmp_x", objects) + if err != nil || got != want { + t.Fatalf("remapping changed an alias or record reference:\ngot: %s\nwant: %s\nerror: %v", got, want, err) + } + } + }) + } +} diff --git a/internal/postgres/embedded.go b/internal/postgres/embedded.go index df5031c3..6d3668c1 100644 --- a/internal/postgres/embedded.go +++ b/internal/postgres/embedded.go @@ -290,7 +290,10 @@ func (ep *EmbeddedPostgres) ApplySchema(ctx context.Context, schema string, sql // Point target-schema qualifiers inside function bodies at the temporary schema so that // SQL functions inlined by later statements resolve (issue #596). Everything of the target // schema lives in the temporary schema here, so every qualifier is rewritten. - schemaAgnosticSQL = qualifyFunctionBodiesWithTempSchema(schemaAgnosticSQL, schema, ep.tempSchema, nil) + schemaAgnosticSQL, err = qualifyFunctionBodiesWithTempSchema(schemaAgnosticSQL, schema, ep.tempSchema, nil) + if err != nil { + return fmt.Errorf("failed to remap desired state SQL to temporary schema %s: %w", ep.tempSchema, err) + } // Execute the SQL directly // Note: Desired state SQL should never contain operations like CREATE INDEX CONCURRENTLY diff --git a/internal/postgres/external.go b/internal/postgres/external.go index 9c558a46..1a910194 100644 --- a/internal/postgres/external.go +++ b/internal/postgres/external.go @@ -253,7 +253,10 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql // Point target-schema qualifiers inside function bodies at the temporary schema so that // SQL functions inlined by later statements resolve (issue #596). Only objects created by // this SQL are rewritten: the plan database's own target schema may hold extensions. - schemaAgnosticSQL = qualifyFunctionBodiesWithTempSchema(schemaAgnosticSQL, schema, ed.tempSchema, extractCreatedObjectNames(schemaAgnosticSQL)) + schemaAgnosticSQL, err = qualifyFunctionBodiesWithTempSchema(schemaAgnosticSQL, schema, ed.tempSchema, extractCreatedObjectNames(schemaAgnosticSQL)) + if err != nil { + return fmt.Errorf("failed to remap desired state SQL to temporary schema %s: %w", ed.tempSchema, err) + } // Stub every role the desired state references so GRANT/POLICY/DEFAULT // PRIVILEGES statements apply in the plan database (issue #450). A diff --git a/internal/postgres/sql_lex.go b/internal/postgres/sql_lex.go new file mode 100644 index 00000000..6f878c72 --- /dev/null +++ b/internal/postgres/sql_lex.go @@ -0,0 +1,212 @@ +package postgres + +import ( + "fmt" + "strings" +) + +// schemaSQLToken retains original spelling so remapping can preserve comments, +// whitespace, ordinary literals, and destination identifiers verbatim. +type schemaSQLToken struct { + text string + kind byte +} + +func sqlIdentifierByte(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' || c >= 128 +} +func lexSQL(text string) []schemaSQLToken { + var tokens []schemaSQLToken + for i := 0; i < len(text); { + start := i + kind := byte('p') + switch { + case strings.ContainsRune(" \t\r\n\f", rune(text[i])): + kind = 'w' + for i < len(text) && strings.ContainsRune(" \t\r\n\f", rune(text[i])) { + i++ + } + case strings.HasPrefix(text[i:], "--"): + kind = 'c' + for i < len(text) && text[i] != '\n' { + i++ + } + case strings.HasPrefix(text[i:], "/*"): + kind = 'c' + i += 2 + depth := 1 + for i < len(text) && depth > 0 { + if strings.HasPrefix(text[i:], "/*") { + depth++ + i += 2 + } else if strings.HasPrefix(text[i:], "*/") { + depth-- + i += 2 + } else { + i++ + } + } + case text[i] == '\'' || (text[i] == 'E' || text[i] == 'e') && i+1 < len(text) && text[i+1] == '\'': + kind = 's' + closed := false + escape := text[i] != '\'' + if escape { + i++ + } + i++ + for i < len(text) { + if escape && text[i] == '\\' { + i++ + if i < len(text) { + i++ + } + } else if text[i] == '\'' { + i++ + if i < len(text) && text[i] == '\'' { + i++ + } else { + closed = true + break + } + } else { + i++ + } + } + if !closed { + kind = '!' + } + case text[i] == '"': + kind = 'i' + _, end, ok := parseQuotedIdent(text, i) + i = end + if !ok { + kind = '!' + } + case text[i] == '$': + loc := dollarQuoteRe.FindStringIndex(text[i:]) + if loc != nil && loc[0] == 0 { + kind = 'd' + tag := text[i : i+loc[1]] + i += len(tag) + if end := strings.Index(text[i:], tag); end >= 0 { + i += end + len(tag) + } else { + i = len(text) + } + } else { + i++ + } + case sqlIdentifierByte(text[i]): + kind = 'i' + i++ + for i < len(text) && (sqlIdentifierByte(text[i]) || text[i] >= '0' && text[i] <= '9' || text[i] == '$') { + i++ + } + default: + i++ + } + tokens = append(tokens, schemaSQLToken{text[start:i], kind}) + } + return tokens +} + +func tokenIdentifier(t schemaSQLToken) string { + if t.kind != 'i' { + return "" + } + if strings.HasPrefix(t.text, `"`) { + name, _, _ := parseQuotedIdent(t.text, 0) + return name + } + return strings.ToLower(t.text) +} + +func completeSQLLiteral(t schemaSQLToken) bool { + if t.kind == 'd' { + tag := dollarQuoteRe.FindString(t.text) + return len(t.text) >= 2*len(tag) && strings.HasSuffix(t.text, tag) + } + if t.kind == 's' { + opening := 1 + if t.text[0] != '\'' { + opening = 2 + } + return len(t.text) > opening && strings.HasSuffix(t.text, "'") + } + return false +} + +func encodeSQLLiteral(t schemaSQLToken, content string) string { + if t.kind == 'd' { + tag := dollarQuoteRe.FindString(t.text) + // The temporary schema name cannot introduce a new delimiter. + return tag + content + tag + } + if t.text[0] != '\'' { + return t.text[:1] + "'" + strings.ReplaceAll(strings.ReplaceAll(content, `\`, `\\`), "'", "''") + "'" + } + return "'" + strings.ReplaceAll(content, "'", "''") + "'" +} + +func significantTokens(tokens []schemaSQLToken) []int { + var indexes []int + for i, t := range tokens { + if t.kind != 'w' && t.kind != 'c' { + indexes = append(indexes, i) + } + } + return indexes +} + +// remapDOBody rewrites only explicit relation-valued regclass literals. +// Other code stays unchanged because an identifier prefix may name a schema, +// table alias, or PL/pgSQL variable. Ordinary strings are never interpreted as SQL. +func remapDOBody(body, schema, temp string, objects map[string]bool) (string, error) { + tokens := lexSQL(body) + significant := significantTokens(tokens) + allowed := func(name string) bool { return objects == nil || objects[name] } + for j := 0; j < len(significant); j++ { + i := significant[j] + t := tokens[i] + if !completeSQLLiteral(t) { + continue + } + next := j + 1 + regclass := false + if next+2 < len(significant) && tokens[significant[next]].text == ":" && tokens[significant[next+1]].text == ":" { + next += 2 + regclass = true + } else if j >= 2 && tokenIdentifier(tokens[significant[j-2]]) == "cast" && tokens[significant[j-1]].text == "(" && next < len(significant) && tokenIdentifier(tokens[significant[next]]) == "as" { + next++ + regclass = true + } + if !regclass || next >= len(significant) { + continue + } + if tokenIdentifier(tokens[significant[next]]) == "pg_catalog" && next+2 < len(significant) && tokens[significant[next+1]].text == "." { + next += 2 + } + if tokenIdentifier(tokens[significant[next]]) != "regclass" { + continue + } + content, err := literalContent(t) + if err != nil { + return "", fmt.Errorf("decode regclass literal in DO body: %w", err) + } + relation := lexSQL(content) + parts := significantTokens(relation) + if len(parts) == 3 && tokenIdentifier(relation[parts[0]]) == schema && relation[parts[1]].text == "." && relation[parts[2]].kind == 'i' && allowed(tokenIdentifier(relation[parts[2]])) { + relation[parts[0]].text = `"` + strings.ReplaceAll(temp, `"`, `""`) + `"` + var b strings.Builder + for _, part := range relation { + b.WriteString(part.text) + } + tokens[i].text = encodeSQLLiteral(t, b.String()) + } + } + var result strings.Builder + for _, t := range tokens { + result.WriteString(t.text) + } + return result.String(), nil +} diff --git a/internal/postgres/sql_literal.go b/internal/postgres/sql_literal.go new file mode 100644 index 00000000..aa84c1d8 --- /dev/null +++ b/internal/postgres/sql_literal.go @@ -0,0 +1,131 @@ +package postgres + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// decodeUnicodeEscape requires the exact PostgreSQL escape width. Surrogate +// code units are returned to the caller, which must validate and combine pairs. +func decodeUnicodeEscape(text string, start int) (rune, int, error) { + width := 4 + if text[start] == 'U' { + width = 8 + } + end := start + 1 + width + if end > len(text) { + return 0, 0, fmt.Errorf("incomplete Unicode escape at byte %d", start) + } + value, err := strconv.ParseUint(text[start+1:end], 16, 32) + if err != nil { + return 0, 0, fmt.Errorf("invalid Unicode escape at byte %d: %w", start, err) + } + if value == 0 || value > utf8.MaxRune { + return 0, 0, fmt.Errorf("invalid Unicode value U+%X at byte %d", value, start) + } + return rune(value), end, nil +} + +// literalContent decodes PostgreSQL dollar, standard, and E-string literals. +// Invalid escape literals fail at the remapping boundary before SQL execution. +func literalContent(t schemaSQLToken) (string, error) { + if t.kind == 'd' { + tag := dollarQuoteRe.FindString(t.text) + return t.text[len(tag) : len(t.text)-len(tag)], nil + } + text := t.text + escape := text[0] != '\'' + if escape { + text = text[1:] + } + text = text[1 : len(text)-1] + var b strings.Builder + for i := 0; i < len(text); i++ { + if text[i] == '\'' && i+1 < len(text) && text[i+1] == '\'' { + b.WriteByte('\'') + i++ + continue + } + if !escape || text[i] != '\\' { + b.WriteByte(text[i]) + continue + } + if i+1 == len(text) { + return "", fmt.Errorf("incomplete escape at byte %d", i) + } + i++ + switch text[i] { + case 'b': + b.WriteByte('\b') + case 'f': + b.WriteByte('\f') + case 'n': + b.WriteByte('\n') + case 'r': + b.WriteByte('\r') + case 't': + b.WriteByte('\t') + case 'u', 'U': + value, end, err := decodeUnicodeEscape(text, i) + if err != nil { + return "", err + } + if value >= 0xD800 && value <= 0xDBFF { + if end+1 >= len(text) || text[end] != '\\' || (text[end+1] != 'u' && text[end+1] != 'U') { + return "", fmt.Errorf("missing low Unicode surrogate at byte %d", end) + } + low, next, err := decodeUnicodeEscape(text, end+1) + if err != nil { + return "", err + } + if low < 0xDC00 || low > 0xDFFF { + return "", fmt.Errorf("invalid low Unicode surrogate U+%X at byte %d", low, end) + } + value = utf16.DecodeRune(value, low) + end = next + } else if value >= 0xDC00 && value <= 0xDFFF { + return "", fmt.Errorf("unexpected low Unicode surrogate at byte %d", i) + } + b.WriteRune(value) + i = end - 1 + case 'x': + start, end := i+1, i+1 + for end < len(text) && end-start < 2 && strings.ContainsRune("0123456789abcdefABCDEF", rune(text[end])) { + end++ + } + if end == start { + b.WriteByte('x') + continue + } + value, err := strconv.ParseUint(text[start:end], 16, 8) + if err != nil { + return "", fmt.Errorf("invalid hexadecimal escape at byte %d: %w", i, err) + } + b.WriteByte(byte(value)) + i = end - 1 + default: + if text[i] >= '0' && text[i] <= '7' { + start, end := i, i+1 + for end < len(text) && end-start < 3 && text[end] >= '0' && text[end] <= '7' { + end++ + } + value, err := strconv.ParseUint(text[start:end], 8, 32) + if err != nil { + return "", fmt.Errorf("invalid octal escape at byte %d: %w", i, err) + } + b.WriteByte(byte(value)) + i = end - 1 + } else { + b.WriteByte(text[i]) + } + } + } + content := b.String() + if strings.ContainsRune(content, 0) { + return "", fmt.Errorf("SQL literals cannot contain a zero byte") + } + return content, nil +} diff --git a/internal/postgres/sql_literal_test.go b/internal/postgres/sql_literal_test.go new file mode 100644 index 00000000..38214efd --- /dev/null +++ b/internal/postgres/sql_literal_test.go @@ -0,0 +1,86 @@ +package postgres + +import ( + "strings" + "testing" +) + +func TestSQLLiteralUnicodeSemantics(t *testing.T) { + for _, tc := range []struct { + name, literal, want string + invalid bool + }{ + {name: "short_u", literal: `E'\u12'`, invalid: true}, + {name: "short_U", literal: `E'\U0001F60'`, invalid: true}, + {name: "invalid_hex", literal: `E'\u12GG'`, invalid: true}, + {name: "invalid_sign", literal: `E'\u+001'`, invalid: true}, + {name: "out_of_range", literal: `E'\U00110000'`, invalid: true}, + {name: "maximum_uint32", literal: `E'\UFFFFFFFF'`, invalid: true}, + {name: "zero", literal: `E'\u0000'`, invalid: true}, + {name: "unpaired_high", literal: `E'\uD83D'`, invalid: true}, + {name: "unpaired_low", literal: `E'\uDE00'`, invalid: true}, + {name: "wrong_second_surrogate", literal: `E'\uD83D\u0041'`, invalid: true}, + {name: "incomplete_second_surrogate", literal: `E'\uD83D\uDE'`, invalid: true}, + {name: "surrogate_pair", literal: `E'\uD83D\uDE00'`, want: "πŸ˜€"}, + {name: "mixed_width_surrogate_pair", literal: `E'\U0000D83D\uDE00'`, want: "πŸ˜€"}, + {name: "wide_low_surrogate", literal: `E'\uD83D\U0000DE00'`, want: "πŸ˜€"}, + {name: "single_code_point", literal: `E'\U0001F600'`, want: "πŸ˜€"}, + {name: "bmp", literal: `E'\u00E9'`, want: "Γ©"}, + {name: "escaped_backslash", literal: `E'\\u12'`, want: `\u12`}, + {name: "escaped_quote", literal: `E'it\'s'`, want: "it's"}, + {name: "standard_literal", literal: `'\u12'`, want: `\u12`}, + {name: "doubled_quote", literal: `E'it''s'`, want: "it's"}, + {name: "octal_wrap", literal: `E'\777'`, want: string([]byte{255})}, + } { + t.Run(tc.name, func(t *testing.T) { + tokens := lexSQL(tc.literal) + if len(tokens) != 1 || !completeSQLLiteral(tokens[0]) { + t.Fatalf("invalid test literal %s", tc.literal) + } + got, err := literalContent(tokens[0]) + if tc.invalid { + if err == nil || got != "" { + t.Fatalf("invalid literal decoded to %q, error %v", got, err) + } + return + } + if err != nil || got != tc.want { + t.Fatalf("got %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +func TestRemapDOBodiesRejectsInvalidEscapeLiterals(t *testing.T) { + for _, sql := range []string{ + `DO E'BEGIN RAISE NOTICE ''\u12''; END';`, + `DO E'BEGIN RAISE NOTICE ''\U00110000''; END';`, + `DO E'BEGIN RAISE NOTICE ''\uD83D''; END';`, + `DO $$ BEGIN PERFORM E'public.\u12'::pg_catalog.regclass; END $$;`, + } { + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "pgschema_tmp_x", nil) + if err == nil || got != "" { + t.Fatalf("invalid DO literal was repaired to %q, error %v", got, err) + } + if !strings.Contains(err.Error(), "DO body") { + t.Fatalf("missing remapping context: %v", err) + } + } +} + +func TestRemapDOSurrogatePairs(t *testing.T) { + const original = `DO E'BEGIN PERFORM ''public.t''::pg_catalog.regclass; RAISE NOTICE ''\uD83D\uDE00''; END';` + got, err := qualifyFunctionBodiesWithTempSchema(original, "public", "pgschema_tmp_x", nil) + if err != nil { + t.Fatal(err) + } + const want = `DO E'BEGIN PERFORM ''"pgschema_tmp_x".t''::pg_catalog.regclass; RAISE NOTICE ''πŸ˜€''; END';` + if got != want { + t.Fatalf("got %s, want %s", got, want) + } + const untouched = `DO E'BEGIN RAISE NOTICE ''\uD83D\uDE00''; END';` + got, err = qualifyFunctionBodiesWithTempSchema(untouched, "public", "pgschema_tmp_x", nil) + if err != nil || got != untouched { + t.Fatalf("unchanged body re-encoded: %s, %v", got, err) + } +} diff --git a/ir/index_test.go b/ir/index_test.go index 2cf1cc01..93f0547e 100644 --- a/ir/index_test.go +++ b/ir/index_test.go @@ -17,6 +17,10 @@ func TestPartitionInspectionMetadataNotSerialized(t *testing.T) { index.AttachedPartitions = []IndexRef{{Schema: "public", Name: "ignored_child"}} constraint.BackingIndex = &IndexRef{Schema: "public", Name: "unique_constraint"} table.AllPartitionChildren = []TableDependency{{Schema: "public", Name: "ignored_partition"}} + table.InheritedConstraintIndexes = []InheritedConstraintIndex{{ + Index: IndexRef{Schema: "public", Name: "renamed_inherited"}, ConstraintName: "renamed_inherited", ConstraintType: ConstraintTypeUnique, + ParentIndex: IndexRef{Schema: "public", Name: "origin"}, ParentConstraint: ConstraintRef{Schema: "public", Table: "parent", Name: "origin"}, + }} after, err := json.Marshal(table) if err != nil { t.Fatal(err) diff --git a/ir/ir.go b/ir/ir.go index ea3c8dc6..881ab232 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -83,6 +83,25 @@ type Table struct { // AllPartitionChildren records catalog children, including ignored or external // tables. Creation planning uses this metadata to identify complete hierarchies. AllPartitionChildren []TableDependency `json:"-"` + // InheritedConstraintIndexes preserves names of automatically created PK and + // UNIQUE indexes without managing inherited constraints as standalone objects. + InheritedConstraintIndexes []InheritedConstraintIndex `json:"-"` +} + +// ConstraintRef identifies the immediate parent of an inherited constraint. +type ConstraintRef struct { + Schema string + Table string + Name string +} + +// InheritedConstraintIndex is table-owned, transient inspection metadata. +type InheritedConstraintIndex struct { + Index IndexRef + ConstraintName string + ConstraintType ConstraintType + ParentIndex IndexRef + ParentConstraint ConstraintRef } // Column represents a table column diff --git a/ir/partition_index.go b/ir/partition_index.go index 70b8ff0c..6f3d2929 100644 --- a/ir/partition_index.go +++ b/ir/partition_index.go @@ -10,7 +10,7 @@ import ( func (i *Inspector) buildIndexAttachments(ctx context.Context, schema *IR, targetSchema string) (map[IndexRef][]IndexRef, error) { rows, err := i.db.QueryContext(ctx, ` SELECT pn.nspname, parent.relname, cn.nspname, child.relname, - t.relname, c.conname + t.relname, c.conname, c.contype::text, pc.conname, pt.relname FROM pg_inherits inh JOIN pg_class parent ON parent.oid = inh.inhparent JOIN pg_namespace pn ON pn.oid = parent.relnamespace @@ -20,6 +20,8 @@ JOIN pg_index idx ON idx.indexrelid = child.oid JOIN pg_class t ON t.oid = idx.indrelid LEFT JOIN pg_constraint c ON c.conindid = child.oid AND c.conrelid = t.oid AND c.contype IN ('p', 'u', 'x') +LEFT JOIN pg_constraint pc ON pc.oid = c.conparentid AND pc.conindid = parent.oid +LEFT JOIN pg_class pt ON pt.oid = pc.conrelid WHERE parent.relkind = 'I' AND pn.nspname = $1 ORDER BY pn.nspname, parent.relname, cn.nspname, child.relname`, targetSchema) if err != nil { @@ -32,12 +34,24 @@ ORDER BY pn.nspname, parent.relname, cn.nspname, child.relname`, targetSchema) var parent, child IndexRef var tableName string var constraintName sql.NullString - if err := rows.Scan(&parent.Schema, &parent.Name, &child.Schema, &child.Name, &tableName, &constraintName); err != nil { + var constraintType, parentConstraintName, parentTable sql.NullString + if err := rows.Scan(&parent.Schema, &parent.Name, &child.Schema, &child.Name, &tableName, &constraintName, &constraintType, &parentConstraintName, &parentTable); err != nil { return nil, err } attachments[parent] = append(attachments[parent], child) if namespace := schema.Schemas[child.Schema]; namespace != nil { if table := namespace.Tables[tableName]; table != nil && constraintName.Valid { + if parentConstraintName.Valid && (constraintType.String == "p" || constraintType.String == "u") { + kind := ConstraintTypeUnique + if constraintType.String == "p" { + kind = ConstraintTypePrimaryKey + } + table.InheritedConstraintIndexes = append(table.InheritedConstraintIndexes, InheritedConstraintIndex{ + Index: child, ConstraintName: constraintName.String, ConstraintType: kind, + ParentIndex: parent, + ParentConstraint: ConstraintRef{Schema: parent.Schema, Table: parentTable.String, Name: parentConstraintName.String}, + }) + } if constraint := table.Constraints[constraintName.String]; constraint != nil { constraint.BackingIndex = &child } diff --git a/testdata/dump/issue_409_partitioned_fk/pgschema.sql b/testdata/dump/issue_409_partitioned_fk/pgschema.sql index 51f3d45d..b0dac328 100644 --- a/testdata/dump/issue_409_partitioned_fk/pgschema.sql +++ b/testdata/dump/issue_409_partitioned_fk/pgschema.sql @@ -31,10 +31,124 @@ CREATE TABLE IF NOT EXISTS event ( -- CREATE TABLE IF NOT EXISTS session_2026_01 PARTITION OF session FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'session_2026_01'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'session'::pg_catalog.regclass AND pc.conname = E'session_pkey' + AND parent_index.relname = E'session_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'session_2026_01_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: session_2026_02; Type: TABLE; Schema: -; Owner: - -- CREATE TABLE IF NOT EXISTS session_2026_02 PARTITION OF session FOR VALUES FROM ('2026-02-01 00:00:00+00') TO ('2026-03-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'session_2026_02'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'session'::pg_catalog.regclass AND pc.conname = E'session_pkey' + AND parent_index.relname = E'session_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'session_2026_02_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; diff --git a/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql b/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql index 29eb8592..3548a228 100644 --- a/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql +++ b/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql @@ -22,6 +22,63 @@ CREATE TABLE IF NOT EXISTS ledger ( -- CREATE TABLE IF NOT EXISTS ledger_2026_06 PARTITION OF ledger FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'ledger_2026_06'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'ledger'::pg_catalog.regclass AND pc.conname = E'ledger_pkey' + AND parent_index.relname = E'ledger_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'ledger_2026_06_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: tg_noop(); Type: FUNCTION; Schema: -; Owner: - diff --git a/testdata/dump/sakila/pgschema.sql b/testdata/dump/sakila/pgschema.sql index 5ed343f6..fadecdae 100644 --- a/testdata/dump/sakila/pgschema.sql +++ b/testdata/dump/sakila/pgschema.sql @@ -433,6 +433,63 @@ CREATE TABLE IF NOT EXISTS payment_p2022_01 PARTITION OF payment ( CONSTRAINT payment_p2022_01_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_01_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-01-01 00:00:00+00') TO ('2022-02-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'payment_p2022_01'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' + AND parent_index.relname = E'payment_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_01_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_01_customer_id; Type: INDEX; Schema: -; Owner: - @@ -461,6 +518,63 @@ CREATE TABLE IF NOT EXISTS payment_p2022_02 PARTITION OF payment ( CONSTRAINT payment_p2022_02_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_02_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-02-01 00:00:00+00') TO ('2022-03-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'payment_p2022_02'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' + AND parent_index.relname = E'payment_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_02_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_02_customer_id; Type: INDEX; Schema: -; Owner: - @@ -489,6 +603,63 @@ CREATE TABLE IF NOT EXISTS payment_p2022_03 PARTITION OF payment ( CONSTRAINT payment_p2022_03_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_03_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-03-01 00:00:00+00') TO ('2022-04-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'payment_p2022_03'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' + AND parent_index.relname = E'payment_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_03_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_03_customer_id; Type: INDEX; Schema: -; Owner: - @@ -517,6 +688,63 @@ CREATE TABLE IF NOT EXISTS payment_p2022_04 PARTITION OF payment ( CONSTRAINT payment_p2022_04_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_04_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-04-01 00:00:00+00') TO ('2022-05-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'payment_p2022_04'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' + AND parent_index.relname = E'payment_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_04_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_04_customer_id; Type: INDEX; Schema: -; Owner: - @@ -545,6 +773,63 @@ CREATE TABLE IF NOT EXISTS payment_p2022_05 PARTITION OF payment ( CONSTRAINT payment_p2022_05_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_05_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-05-01 00:00:00+00') TO ('2022-06-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'payment_p2022_05'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' + AND parent_index.relname = E'payment_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_05_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_05_customer_id; Type: INDEX; Schema: -; Owner: - @@ -573,6 +858,63 @@ CREATE TABLE IF NOT EXISTS payment_p2022_06 PARTITION OF payment ( CONSTRAINT payment_p2022_06_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_06_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-06-01 00:00:00+00') TO ('2022-07-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'payment_p2022_06'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' + AND parent_index.relname = E'payment_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_06_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_06_customer_id; Type: INDEX; Schema: -; Owner: - @@ -601,6 +943,63 @@ CREATE TABLE IF NOT EXISTS payment_p2022_07 PARTITION OF payment ( CONSTRAINT payment_p2022_07_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_07_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-07-01 00:00:00+00') TO ('2022-08-01 00:00:00+00'); +DO $pgschema_restore_0$ +DECLARE + index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; + desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; + found_id pg_catalog.oid; + current_name pg_catalog.text; + namespace_name pg_catalog.text; + namespace_id pg_catalog.oid; + table_id pg_catalog.oid; + temporary_name pg_catalog.text; + position pg_catalog.int4; + suffix pg_catalog.int4; +BEGIN + SELECT c.conindid INTO STRICT found_id + FROM pg_catalog.pg_constraint c + JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid + JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid + JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid + WHERE c.conrelid = E'payment_p2022_07'::pg_catalog.regclass AND c.contype = E'p' + AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' + AND parent_index.relname = E'payment_pkey'; + index_ids := pg_catalog.array_append(index_ids, found_id); + desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_07_pkey'); + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname, n.oid, i.indrelid + INTO STRICT current_name, namespace_name, namespace_id, table_id + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + suffix := 0; + LOOP + temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); + EXIT WHEN NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = namespace_id AND relname = temporary_name + ) AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint + WHERE conrelid = table_id AND conname = temporary_name + ) AND temporary_name <> ALL(desired_names); + suffix := suffix + 1; + END LOOP; + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); + END IF; + END LOOP; + FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP + SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE c.oid = index_ids[position]; + IF current_name <> desired_names[position] THEN + EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); + END IF; + END LOOP; +END +$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_07_customer_id; Type: INDEX; Schema: -; Owner: - From f86bfeb068bd3e5411c693f858f6d15ed4628b48 Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Wed, 14 Oct 2026 16:29:37 +0200 Subject: [PATCH 2/4] fix(postgres): remap created indexes in external DO blocks --- cmd/do_index_desired_state_test.go | 93 +++++++++++++++++++++++++ internal/postgres/desired_state.go | 78 +++++++++++++++++---- internal/postgres/desired_state_test.go | 58 ++++++++++++++- internal/postgres/do_remap_test.go | 6 +- internal/postgres/embedded.go | 2 +- internal/postgres/sql_literal_test.go | 6 +- 6 files changed, 218 insertions(+), 25 deletions(-) create mode 100644 cmd/do_index_desired_state_test.go diff --git a/cmd/do_index_desired_state_test.go b/cmd/do_index_desired_state_test.go new file mode 100644 index 00000000..fdfc75d9 --- /dev/null +++ b/cmd/do_index_desired_state_test.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "testing" + + "github.com/pgplex/pgschema/internal/postgres" + "github.com/pgplex/pgschema/ir" + "github.com/pgplex/pgschema/testutil" +) + +func TestExternalDesiredStateDOIndexReferences(t *testing.T) { + if testing.Short() { + t.Skip("embedded PostgreSQL integration") + } + pg := testutil.SetupPostgres(t) + defer pg.Stop() + db, host, port, database, user, password := testutil.ConnectToPostgres(t, pg) + defer db.Close() + major, err := testutil.GetMajorVersion(db) + if err != nil { + t.Fatal(err) + } + for _, index := range []struct{ name, definition string }{ + {"idx", "CREATE INDEX idx ON public.t(id);"}, + {"idx", "CREATE UNIQUE INDEX IF NOT EXISTS idx ON ONLY public.t(id);"}, + {"idx", "CREATE INDEX idx ON public.t(id); CREATE INDEX IF NOT EXISTS idx ON public.outside_table(id);"}, + {"idx", "CREATE INDEX IF NOT EXISTS idx ON public.outside_table(id); CREATE UNIQUE INDEX idx ON public.t(id);"}, + {`Index."Quoted`, `CREATE INDEX "Index.""Quoted" ON public.t(id);`}, + } { + for _, shadow := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/shadow=%v", index.definition, shadow), func(t *testing.T) { + if _, err := db.Exec(`DROP SCHEMA public CASCADE; CREATE SCHEMA public; +CREATE TABLE public.outside_table(id int); +CREATE INDEX untouched ON public.outside_table(id); +CREATE FUNCTION public.idx() RETURNS int LANGUAGE sql IMMUTABLE AS $$ SELECT 42 $$;`); err != nil { + t.Fatal(err) + } + if shadow { + if _, err := db.Exec("CREATE INDEX " + ir.QuoteIdentifier(index.name) + " ON public.outside_table(id)"); err != nil { + t.Fatal(err) + } + } + provider, err := postgres.NewExternalDatabase(&postgres.ExternalDatabaseConfig{ + Host: host, Port: port, Database: database, Username: user, Password: password, + SSLMode: "disable", TargetMajorVersion: major, + }) + if err != nil { + t.Fatal(err) + } + defer provider.Stop() + indexLiteral := "'public." + strings.ReplaceAll(ir.QuoteIdentifier(index.name), "'", "''") + "'" + fixture := `CREATE TABLE public.t(id int);` + index.definition + ` +CREATE TABLE public.resolved(index_id oid, untouched_id oid); +CREATE FUNCTION public.fetch_value() RETURNS int LANGUAGE sql AS $$ SELECT public.idx() $$; +CREATE VIEW public.result AS SELECT public.fetch_value() AS value; +DO $$ BEGIN INSERT INTO resolved VALUES (` + indexLiteral + `::pg_catalog.regclass, 'public.untouched'::regclass); END $$;` + if err := provider.ApplySchema(context.Background(), "public", fixture); err != nil { + t.Fatal(err) + } + var indexSchema, tableName, untouchedSchema, untouchedTable string + err = db.QueryRow(`SELECT n.nspname, t.relname, un.nspname, ut.relname +FROM `+ir.QuoteIdentifier(provider.GetSchemaName())+`.resolved r +JOIN pg_catalog.pg_class c ON c.oid=r.index_id +JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace +JOIN pg_catalog.pg_index i ON i.indexrelid=c.oid +JOIN pg_catalog.pg_class t ON t.oid=i.indrelid +JOIN pg_catalog.pg_class uc ON uc.oid=r.untouched_id +JOIN pg_catalog.pg_namespace un ON un.oid=uc.relnamespace +JOIN pg_catalog.pg_index ui ON ui.indexrelid=uc.oid +JOIN pg_catalog.pg_class ut ON ut.oid=ui.indrelid`).Scan(&indexSchema, &tableName, &untouchedSchema, &untouchedTable) + if err != nil { + t.Fatal(err) + } + if indexSchema != provider.GetSchemaName() || tableName != "t" { + t.Fatalf("created index resolved to %s.%s", indexSchema, tableName) + } + if untouchedSchema != "public" || untouchedTable != "outside_table" { + t.Fatalf("unmanaged index resolved to %s.%s", untouchedSchema, untouchedTable) + } + var value int + if err := db.QueryRow("SELECT value FROM " + ir.QuoteIdentifier(provider.GetSchemaName()) + ".result").Scan(&value); err != nil { + t.Fatal(err) + } + if value != 42 { + t.Fatalf("external function reference changed: %d", value) + } + }) + } + } +} diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index 0e70cef1..820ab606 100644 --- a/internal/postgres/desired_state.go +++ b/internal/postgres/desired_state.go @@ -199,12 +199,26 @@ func getSchemaRegexes(schemaName string) *schemaRegexes { return sr } -// extractCreatedObjectNames returns the names of the objects created by the given SQL, -// folded the way PostgreSQL folds them (unquoted names lowercased, quotes removed). -func extractCreatedObjectNames(sql string) map[string]bool { - names := make(map[string]bool) +// schemaRemapNames keeps index names out of function/procedure remapping. +// An index and an external function can have the same name. Nil maps allow all +// names when the embedded provider owns the entire desired-state schema. +type schemaRemapNames struct { + body map[string]bool + relations map[string]bool +} + +// extractCreatedObjectNames reads SQL after managed-schema qualifiers have been +// stripped. Indexes belong to their indexed table's schema, so only indexes on +// relations created in the temporary schema qualify for regclass remapping. +func extractCreatedObjectNames(sql string) schemaRemapNames { + names := schemaRemapNames{body: make(map[string]bool), relations: make(map[string]bool)} + indexableRelations := make(map[string]bool) + var indexedTables []struct{ index, table string } tokens := lexSQL(sql) indexes := significantTokens(tokens) + keyword := func(k int, word string) bool { + return k < len(indexes) && strings.EqualFold(tokens[indexes[k]].text, word) + } for j, i := range indexes { if tokens[i].text == "" || tokens[i].kind != 'i' || strings.HasPrefix(tokens[i].text, `"`) || tokenIdentifier(tokens[i]) != "create" { continue @@ -212,7 +226,7 @@ func extractCreatedObjectNames(sql string) map[string]bool { k := j + 1 for k < len(indexes) { word := tokenIdentifier(tokens[indexes[k]]) - if word != "or" && word != "replace" && word != "unlogged" && word != "recursive" && word != "materialized" { + if word != "or" && word != "replace" && word != "unlogged" && word != "recursive" && word != "materialized" && word != "unique" { break } k++ @@ -220,17 +234,51 @@ func extractCreatedObjectNames(sql string) map[string]bool { if k >= len(indexes) { continue } - switch tokenIdentifier(tokens[indexes[k]]) { - case "table", "view", "function", "procedure", "aggregate", "type", "domain", "sequence": + kind := tokenIdentifier(tokens[indexes[k]]) + switch kind { + case "table", "view", "function", "procedure", "aggregate", "type", "domain", "sequence", "index": default: continue } k++ - if k+2 < len(indexes) && tokenIdentifier(tokens[indexes[k]]) == "if" && tokenIdentifier(tokens[indexes[k+1]]) == "not" && tokenIdentifier(tokens[indexes[k+2]]) == "exists" { + if kind == "index" && keyword(k, "concurrently") { + k++ + } + if keyword(k, "if") && keyword(k+1, "not") && keyword(k+2, "exists") { k += 3 } - if k < len(indexes) && tokens[indexes[k]].kind == 'i' && (k+1 == len(indexes) || tokens[indexes[k+1]].text != ".") { - names[tokenIdentifier(tokens[indexes[k]])] = true + if k >= len(indexes) || tokens[indexes[k]].kind != 'i' || keyword(k+1, ".") { + continue + } + name := tokenIdentifier(tokens[indexes[k]]) + if kind == "index" { + // An omitted index name is generated by PostgreSQL, not this reader. + if keyword(k, "on") || !keyword(k+1, "on") { + continue + } + k += 2 + if keyword(k, "only") { + k++ + } + if k < len(indexes) && tokens[indexes[k]].kind == 'i' && !keyword(k+1, ".") { + indexedTables = append(indexedTables, struct{ index, table string }{name, tokenIdentifier(tokens[indexes[k]])}) + } + continue + } + names.body[name] = true + switch kind { + case "table", "view": + indexableRelations[name] = true + names.relations[name] = true + case "sequence": + names.relations[name] = true + } + } + // The same index name may occur on tables in different schemas. Keep every + // declaration so an external-table index cannot hide a temporary one. + for _, index := range indexedTables { + if indexableRelations[index.table] { + names.relations[index.index] = true } } return names @@ -257,12 +305,12 @@ func foldIdentifier(ident string) string { // search_path keep working, and normalizeSchemaNames maps the temporary schema name // back to the target schema after inspection, so the inspected body is unchanged. // -// When objects is nil every qualifier is rewritten; this is right when everything of -// the target schema lives in the temporary schema (embedded postgres, which also +// Nil name maps allow every eligible reference when everything in the target +// schema lives in the temporary schema (embedded postgres, which also // installs the target schema's extensions there). Otherwise only qualifiers of the // named objects are rewritten, so references to things that really live in the // target schema of an external plan database (e.g. public.unaccent) are preserved. -func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, objects map[string]bool) (string, error) { +func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, objects schemaRemapNames) (string, error) { if schemaName == "" || tempSchema == "" { return sql, nil } @@ -270,7 +318,7 @@ func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, obj sr := getSchemaRegexes(schemaName) rewrite := func(re *regexp.Regexp, body string, objectGroup int, replacement string) string { return re.ReplaceAllStringFunc(body, func(match string) string { - if objects != nil && !objects[foldIdentifier(re.FindStringSubmatch(match)[objectGroup])] { + if objects.body != nil && !objects.body[foldIdentifier(re.FindStringSubmatch(match)[objectGroup])] { return match } return re.ReplaceAllString(match, replacement) @@ -295,7 +343,7 @@ func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, obj if err != nil { return "", fmt.Errorf("decode DO body: %w", err) } - remapped, err := remapDOBody(body, schemaName, tempSchema, objects) + remapped, err := remapDOBody(body, schemaName, tempSchema, objects.relations) if err != nil { return "", err } diff --git a/internal/postgres/desired_state_test.go b/internal/postgres/desired_state_test.go index 26f69d74..75fdc215 100644 --- a/internal/postgres/desired_state_test.go +++ b/internal/postgres/desired_state_test.go @@ -537,7 +537,7 @@ func TestQualifyFunctionBodiesWithTempSchema(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := qualifyFunctionBodiesWithTempSchema(tt.sql, "public", tmp, tt.objects) + got, err := qualifyFunctionBodiesWithTempSchema(tt.sql, "public", tmp, schemaRemapNames{body: tt.objects, relations: tt.objects}) if err != nil { t.Fatal(err) } @@ -558,12 +558,64 @@ CREATE INDEX idx ON name (id); ` got := extractCreatedObjectNames(sql) expected := []string{"name", "Rank", "find_rank", "name_mv"} - if len(got) != len(expected) { + if len(got.body) != len(expected) { t.Errorf("got %v, expected %v", got, expected) } for _, name := range expected { - if !got[name] { + if !got.body[name] { t.Errorf("missing %q in %v", name, got) } } } + +func TestCreatedIndexNamesForRegclassRemapping(t *testing.T) { + for _, tc := range []struct { + name, sql string + body, relations []string + }{ + {"regular", `CREATE TABLE t(id int); CREATE INDEX idx ON t(id);`, []string{"t"}, []string{"t", "idx"}}, + {"unique_only", `CREATE TABLE t(id int); CREATE UNIQUE INDEX IF NOT EXISTS idx ON ONLY t(id);`, []string{"t"}, []string{"t", "idx"}}, + {"concurrent", `CREATE TABLE t(id int); CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx ON t(id);`, []string{"t"}, []string{"t", "idx"}}, + {"quoted", `CREATE TABLE "only"(id int); CREATE INDEX "on" ON "only"(id); CREATE INDEX "idx""quoted" ON "only"(id);`, []string{"only"}, []string{"only", "on", `idx"quoted`}}, + {"materialized_view", `CREATE MATERIALIZED VIEW mv AS SELECT 1 AS id; CREATE INDEX idx ON mv(id);`, []string{"mv"}, []string{"mv", "idx"}}, + {"other_schema", `CREATE TABLE t(id int); CREATE INDEX idx ON other.t(id);`, []string{"t"}, []string{"t"}}, + {"existing_table", `CREATE TABLE t(id int); CREATE INDEX idx ON outside_table(id); CREATE INDEX outside_table ON t(id);`, []string{"t"}, []string{"t", "outside_table"}}, + {"repeated_index_existing_table_after", `CREATE TABLE t(id int); CREATE INDEX idx ON t(id); CREATE INDEX IF NOT EXISTS idx ON outside_table(id);`, []string{"t"}, []string{"t", "idx"}}, + {"repeated_index_existing_table_before", `CREATE TABLE t(id int); CREATE INDEX IF NOT EXISTS idx ON outside_table(id); CREATE UNIQUE INDEX idx ON t(id);`, []string{"t"}, []string{"t", "idx"}}, + {"implicit_name", `CREATE TABLE t(id int); CREATE INDEX ON t(id);`, []string{"t"}, []string{"t"}}, + {"non_relations", `CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$; CREATE TYPE typ AS ENUM ('x'); CREATE SEQUENCE seq;`, []string{"f", "typ", "seq"}, []string{"seq"}}, + {"comments_and_strings", `-- CREATE INDEX fake ON t(id); +CREATE TABLE t(id int); COMMENT ON TABLE t IS 'CREATE INDEX fake2 ON t(id)'; DO $$ BEGIN PERFORM 'CREATE INDEX fake3 ON t(id)'; END $$;`, []string{"t"}, []string{"t"}}, + } { + t.Run(tc.name, func(t *testing.T) { + got := extractCreatedObjectNames(tc.sql) + want := schemaRemapNames{body: make(map[string]bool), relations: make(map[string]bool)} + for _, name := range tc.body { + want.body[name] = true + } + for _, name := range tc.relations { + want.relations[name] = true + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v, want %+v", got, want) + } + }) + } +} + +func TestIndexRegclassRemappingPreservesExternalFunctions(t *testing.T) { + const sql = `CREATE TABLE t(id int); +CREATE INDEX idx ON t(id); +CREATE UNIQUE INDEX IF NOT EXISTS "Unique.Index" ON ONLY t(id); +CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELECT public.idx() $$; +DO $$ BEGIN PERFORM 'public.idx'::regclass, 'public."Unique.Index"'::pg_catalog.regclass, 'public.untouched'::regclass; END $$;` + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "pgschema_tmp_x", extractCreatedObjectNames(sql)) + if err != nil { + t.Fatal(err) + } + want := strings.ReplaceAll(sql, "'public.idx'", "'\"pgschema_tmp_x\".idx'") + want = strings.ReplaceAll(want, `'public."Unique.Index"'`, `'"pgschema_tmp_x"."Unique.Index"'`) + if got != want { + t.Fatalf("got:\n%s\nwant:\n%s", got, want) + } +} diff --git a/internal/postgres/do_remap_test.go b/internal/postgres/do_remap_test.go index bab40be5..2f803ecf 100644 --- a/internal/postgres/do_remap_test.go +++ b/internal/postgres/do_remap_test.go @@ -43,7 +43,7 @@ SELECT 'DO', 'public.t'; COMMENT ON TABLE t IS $$ public.t $$;`}, {name: "unterminated_string_body", schema: "public", sql: `DO 'public.t`, want: `DO 'public.t`}, } { t.Run(tc.name, func(t *testing.T) { - got, err := qualifyFunctionBodiesWithTempSchema(tc.sql, tc.schema, "pgschema_tmp_x", tc.objects) + got, err := qualifyFunctionBodiesWithTempSchema(tc.sql, tc.schema, "pgschema_tmp_x", schemaRemapNames{body: tc.objects, relations: tc.objects}) if err != nil { t.Fatal(err) } @@ -66,7 +66,7 @@ DO $$ BEGIN PERFORM 1; END $$;` t.Fatalf("schema not stripped: %s", stripped) } objects := extractCreatedObjectNames(stripped) - if len(objects) != 1 || !objects[`t".'\$$`] { + if len(objects.body) != 1 || !objects.body[`t".'\$$`] || len(objects.relations) != 1 || !objects.relations[`t".'\$$`] { t.Fatalf("incorrect created objects: %v", objects) } } @@ -92,7 +92,7 @@ func TestDORegclassRemappingPreservesAliasesAndRecords(t *testing.T) { // record references in the first block must still remain verbatim. original := "DO $$ " + body + " $$; DO $$ BEGIN PERFORM 'public.t'::regclass; END $$;" want := "DO $$ " + body + " $$; DO $$ BEGIN PERFORM '\"pgschema_tmp_x\".t'::regclass; END $$;" - got, err := qualifyFunctionBodiesWithTempSchema(original, "public", "pgschema_tmp_x", objects) + got, err := qualifyFunctionBodiesWithTempSchema(original, "public", "pgschema_tmp_x", schemaRemapNames{body: objects, relations: objects}) if err != nil || got != want { t.Fatalf("remapping changed an alias or record reference:\ngot: %s\nwant: %s\nerror: %v", got, want, err) } diff --git a/internal/postgres/embedded.go b/internal/postgres/embedded.go index 6d3668c1..eb9364f1 100644 --- a/internal/postgres/embedded.go +++ b/internal/postgres/embedded.go @@ -290,7 +290,7 @@ func (ep *EmbeddedPostgres) ApplySchema(ctx context.Context, schema string, sql // Point target-schema qualifiers inside function bodies at the temporary schema so that // SQL functions inlined by later statements resolve (issue #596). Everything of the target // schema lives in the temporary schema here, so every qualifier is rewritten. - schemaAgnosticSQL, err = qualifyFunctionBodiesWithTempSchema(schemaAgnosticSQL, schema, ep.tempSchema, nil) + schemaAgnosticSQL, err = qualifyFunctionBodiesWithTempSchema(schemaAgnosticSQL, schema, ep.tempSchema, schemaRemapNames{}) if err != nil { return fmt.Errorf("failed to remap desired state SQL to temporary schema %s: %w", ep.tempSchema, err) } diff --git a/internal/postgres/sql_literal_test.go b/internal/postgres/sql_literal_test.go index 38214efd..f1b4c3e2 100644 --- a/internal/postgres/sql_literal_test.go +++ b/internal/postgres/sql_literal_test.go @@ -58,7 +58,7 @@ func TestRemapDOBodiesRejectsInvalidEscapeLiterals(t *testing.T) { `DO E'BEGIN RAISE NOTICE ''\uD83D''; END';`, `DO $$ BEGIN PERFORM E'public.\u12'::pg_catalog.regclass; END $$;`, } { - got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "pgschema_tmp_x", nil) + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "pgschema_tmp_x", schemaRemapNames{}) if err == nil || got != "" { t.Fatalf("invalid DO literal was repaired to %q, error %v", got, err) } @@ -70,7 +70,7 @@ func TestRemapDOBodiesRejectsInvalidEscapeLiterals(t *testing.T) { func TestRemapDOSurrogatePairs(t *testing.T) { const original = `DO E'BEGIN PERFORM ''public.t''::pg_catalog.regclass; RAISE NOTICE ''\uD83D\uDE00''; END';` - got, err := qualifyFunctionBodiesWithTempSchema(original, "public", "pgschema_tmp_x", nil) + got, err := qualifyFunctionBodiesWithTempSchema(original, "public", "pgschema_tmp_x", schemaRemapNames{}) if err != nil { t.Fatal(err) } @@ -79,7 +79,7 @@ func TestRemapDOSurrogatePairs(t *testing.T) { t.Fatalf("got %s, want %s", got, want) } const untouched = `DO E'BEGIN RAISE NOTICE ''\uD83D\uDE00''; END';` - got, err = qualifyFunctionBodiesWithTempSchema(untouched, "public", "pgschema_tmp_x", nil) + got, err = qualifyFunctionBodiesWithTempSchema(untouched, "public", "pgschema_tmp_x", schemaRemapNames{}) if err != nil || got != untouched { t.Fatalf("unchanged body re-encoded: %s, %v", got, err) } From 5855d4d3a24d39d09f51a9749d1556b2e7095d1b Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Wed, 14 Oct 2026 18:48:58 +0200 Subject: [PATCH 3/4] fix(postgres): preserve Unicode SQL during schema remapping --- cmd/unicode_desired_state_test.go | 158 +++++++++++++++ cmd/unicode_edges_desired_state_test.go | 139 +++++++++++++ internal/postgres/desired_state.go | 35 ++-- internal/postgres/desired_state_test.go | 21 +- internal/postgres/do_remap_test.go | 16 +- internal/postgres/embedded.go | 5 +- internal/postgres/external.go | 11 +- internal/postgres/sql_lex.go | 104 ++++++++-- internal/postgres/sql_literal.go | 3 + internal/postgres/sql_unicode.go | 209 ++++++++++++++++++++ internal/postgres/sql_unicode_edges_test.go | 120 +++++++++++ internal/postgres/sql_unicode_test.go | 123 ++++++++++++ 12 files changed, 905 insertions(+), 39 deletions(-) create mode 100644 cmd/unicode_desired_state_test.go create mode 100644 cmd/unicode_edges_desired_state_test.go create mode 100644 internal/postgres/sql_unicode.go create mode 100644 internal/postgres/sql_unicode_edges_test.go create mode 100644 internal/postgres/sql_unicode_test.go diff --git a/cmd/unicode_desired_state_test.go b/cmd/unicode_desired_state_test.go new file mode 100644 index 00000000..df107c34 --- /dev/null +++ b/cmd/unicode_desired_state_test.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "context" + "database/sql" + "errors" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/pgplex/pgschema/internal/postgres" + "github.com/pgplex/pgschema/ir" + "github.com/pgplex/pgschema/testutil" +) + +func TestDesiredStateUnicode(t *testing.T) { + if testing.Short() { + t.Skip("embedded PostgreSQL integration") + } + pg := testutil.SetupPostgres(t) + defer pg.Stop() + db, h, p, d, u, w := testutil.ConnectToPostgres(t, pg) + defer db.Close() + major, err := testutil.GetMajorVersion(db) + if err != nil { + t.Fatal(err) + } + embeddedDB, _, _, _, _, _ := testutil.ConnectToPostgres(t, sharedEmbeddedPG) + defer embeddedDB.Close() + externalProvider := func(t *testing.T) postgres.DesiredStateProvider { + t.Helper() + external, err := postgres.NewExternalDatabase(&postgres.ExternalDatabaseConfig{Host: h, Port: p, Database: d, Username: u, Password: w, SSLMode: "disable", TargetMajorVersion: major}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { external.Stop() }) + return external + } + readMarker := func(t *testing.T, conn *sql.DB, schema string) string { + t.Helper() + var got string + if err := conn.QueryRow("SELECT v FROM " + ir.QuoteIdentifier(schema) + ".marker").Scan(&got); err != nil { + t.Fatal(err) + } + return got + } + for _, tc := range []struct{ name, schema, sql, want string }{ + {"unicode_schema", "public", `CREATE TABLE U&"public".t(id int); +CREATE TABLE public.marker(v text); +DO U&'BEGIN IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=''public.t''::regclass) <> current_schema() THEN RAISE EXCEPTION ''wrong namespace''; END IF; INSERT INTO marker VALUES (''ok''); END';`, "ok"}, + {"escaped_names_and_body", "public", `CREATE TABLE u&"!0070ublic" /* schema comment */ UESCAPE '!' . U&"t\+01F600"(id int); +CREATE UNIQUE INDEX U&"i!D83D!DE00" UESCAPE '!' ON U&"\0070ublic".U&"t\D83D\DE00"(id); +CREATE TABLE public.marker(v text); +DO u&'#0042EGIN +IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=U&''!0070ublic."t!+01F600"'' /* regclass comment */ UESCAPE ''!''::regclass) <> current_schema() THEN RAISE EXCEPTION ''wrong table namespace''; END IF; +IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=CAST(U&''!0070ublic."i!D83D!DE00"'' UESCAPE ''!'' AS pg_catalog.regclass)) <> current_schema() THEN RAISE EXCEPTION ''wrong index namespace''; END IF; +EXECUTE format(''INSERT INTO %s VALUES (%L)'', ''public.marker''::regclass, U&''!0070ublic.t!!'' UESCAPE ''!''); +END' /* body comment */ UESCAPE '#';`, "public.t!"}, + {"encoded_target_schema", "schΓ©ma", `CREATE TABLE U&"sch!00E9ma" UESCAPE '!' . U&"t\00E9"(id int); +CREATE TABLE U&"sch\00E9ma".marker(v text); +DO $$ BEGIN +IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=U&'sch\00E9ma.t\00E9'::regclass) <> current_schema() THEN RAISE EXCEPTION 'wrong namespace'; END IF; +EXECUTE format('INSERT INTO %s VALUES (%L)', U&'sch!00E9ma.marker' UESCAPE '!'::regclass, 'ok'); END $$;`, "ok"}, + {"decoded_dollar_delimiter", "public", `CREATE TABLE public."t$body$"(id int); +CREATE TABLE public.marker(v text); +DO $body$ BEGIN +IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=U&'public."t\0024body\0024"'::regclass) <> current_schema() THEN RAISE EXCEPTION 'wrong namespace'; END IF; +INSERT INTO marker VALUES ('ok'); END $body$;`, "ok"}, + {"unicode_inner_literal", "public", `CREATE TABLE public.marker(v text); +DO U&'\0042EGIN EXECUTE format(''INSERT INTO %s VALUES (%L)'', U&''\\0070ublic.marker''::regclass, ''ok''); END';`, "ok"}, + } { + t.Run(tc.name, func(t *testing.T) { + quoted := ir.QuoteIdentifier(tc.schema) + if _, err := db.Exec("DROP SCHEMA IF EXISTS " + quoted + " CASCADE; CREATE SCHEMA " + quoted + "; SET search_path = " + quoted + ", pg_catalog; " + tc.sql); err != nil { + t.Fatalf("native PostgreSQL fixture: %v", err) + } + if got := readMarker(t, db, tc.schema); got != tc.want { + t.Fatalf("native marker %q, want %q", got, tc.want) + } + for name, provider := range map[string]postgres.DesiredStateProvider{"embedded": sharedEmbeddedPG, "external": externalProvider(t)} { + t.Run(name, func(t *testing.T) { + if err := provider.ApplySchema(context.Background(), tc.schema, tc.sql); err != nil { + t.Fatal(err) + } + conn := db + if name == "embedded" { + conn = embeddedDB + } + if got := readMarker(t, conn, provider.GetSchemaName()); got != tc.want { + t.Fatalf("temporary marker %q, want %q", got, tc.want) + } + }) + } + if got := readMarker(t, db, tc.schema); got != tc.want { + t.Fatalf("original marker changed to %q", got) + } + }) + } + for _, tc := range []struct{ literal, want string }{ + {`U&'\D83D\DE00'`, "πŸ˜€"}, {`U&'\D83D\+00DE00'`, "πŸ˜€"}, {`U&'\+01F600'`, "πŸ˜€"}, + {`u&'it''s \\ text'`, "it's \\ text"}, {`U&'!0061!!' UESCAPE E'!'`, "a!"}, {`U&'!0061' UESCAPE $$!$$`, "a"}, + } { + t.Run(tc.literal, func(t *testing.T) { + var native string + if err := db.QueryRow("SELECT " + tc.literal).Scan(&native); err != nil || native != tc.want { + t.Fatalf("native value %q, %v; want %q", native, err, tc.want) + } + fixture := `CREATE TABLE public.marker(v text); DO $body$ BEGIN EXECUTE format('INSERT INTO %s VALUES (%L)', 'public.marker'::regclass, ` + tc.literal + `); END $body$;` + for name, provider := range map[string]postgres.DesiredStateProvider{"embedded": sharedEmbeddedPG, "external": externalProvider(t)} { + t.Run(name, func(t *testing.T) { + if err := provider.ApplySchema(context.Background(), "public", fixture); err != nil { + t.Fatal(err) + } + conn := db + if name == "embedded" { + conn = embeddedDB + } + if got := readMarker(t, conn, provider.GetSchemaName()); got != native { + t.Fatalf("marker %q, native %q", got, native) + } + }) + } + }) + } + for _, bad := range []string{ + `U&'\12'`, `U&'\12GG'`, `U&'\+110000'`, `U&'\0000'`, `U&'\D83D'`, `U&'\DE00'`, `U&'\D83D\0041'`, `U&'\D83D\DE'`, + `U&'a' UESCAPE ''`, `U&'a' UESCAPE 'ab'`, `U&'a' UESCAPE '+'`, `U&'a' UESCAPE '0'`, `U&'a' UESCAPE 'F'`, `U&'a' UESCAPE ' '`, `U&'a' UESCAPE '"'`, `U&'a' UESCAPE ''''`, `U&'a' UESCAPE 'Β§'`, `U&'a' UESCAPE U&'!'`, `U&'a' UESCAPE`, `U&'a' UESCAPE E'\u12'`, + `U&'unfinished`, `U&"unfinished`, + } { + t.Run("invalid_"+bad, func(t *testing.T) { + _, err := db.Exec("SELECT " + bad) + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) { + t.Fatalf("native PostgreSQL accepted invalid Unicode: %v", err) + } + // Exercise each boundary, including decoding inside an executable DO body. + for _, fixture := range []string{`CREATE TABLE public.marker(v text); SELECT ` + bad, `CREATE TABLE public.marker(v text); DO $$ BEGIN PERFORM ` + bad + `; END $$;`} { + for name, provider := range map[string]postgres.DesiredStateProvider{"embedded": sharedEmbeddedPG, "external": externalProvider(t)} { + t.Run(name, func(t *testing.T) { + if err := provider.ApplySchema(context.Background(), "public", fixture); err == nil { + t.Fatal("provider accepted invalid Unicode") + } + conn := db + if name == "embedded" { + conn = embeddedDB + } + var count int + if err := conn.QueryRow(`SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relname='marker'`, provider.GetSchemaName()).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("provider executed SQL before Unicode decoding failed") + } + }) + } + } + }) + } +} diff --git a/cmd/unicode_edges_desired_state_test.go b/cmd/unicode_edges_desired_state_test.go new file mode 100644 index 00000000..ce54e0bb --- /dev/null +++ b/cmd/unicode_edges_desired_state_test.go @@ -0,0 +1,139 @@ +package cmd + +import ( + "context" + "database/sql" + "errors" + "strings" + "testing" + + "github.com/jackc/pgx/v5/pgconn" + "github.com/pgplex/pgschema/internal/postgres" + "github.com/pgplex/pgschema/ir" + "github.com/pgplex/pgschema/testutil" +) + +func TestDesiredStateUnicodeEdges(t *testing.T) { + if testing.Short() { + t.Skip("embedded PostgreSQL integration") + } + pg := testutil.SetupPostgres(t) + defer pg.Stop() + db, h, p, d, u, w := testutil.ConnectToPostgres(t, pg) + defer db.Close() + major, err := testutil.GetMajorVersion(db) + if err != nil { + t.Fatal(err) + } + embeddedDB, _, _, _, _, _ := testutil.ConnectToPostgres(t, sharedEmbeddedPG) + defer embeddedDB.Close() + const prefix = `CREATE TABLE public.t(id int); CREATE TABLE public.marker(v text); ` + const body = `BEGIN IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid='public.t'::regclass) <> current_schema() THEN RAISE EXCEPTION 'wrong namespace'; END IF; INSERT INTO marker VALUES ('ok'); END` + literal := func(text string) string { return "'" + strings.ReplaceAll(text, "'", "''") + "'" } + for _, tc := range []struct{ name, sql, state string }{ + {"continued_suffix", prefix + "DO U&" + literal(strings.TrimSuffix(body, "END")) + "\n'\\0045ND';", ""}, + {"continued_custom_suffix", prefix + "DO U&" + literal(strings.TrimSuffix(body, "END")) + "\n'!0045ND' UESCAPE '!';", ""}, + {"continued_prefix", prefix + "DO U&'BEGIN'\n" + literal(strings.TrimPrefix(body, "BEGIN")) + ";", ""}, + {"continued_line_comment", prefix + "DO U&'BEGIN'-- keep public.t\n" + literal(strings.TrimPrefix(body, "BEGIN")) + ";", ""}, + {"continued_line_comment_cr", prefix + "DO U&'BEGIN'-- keep public.t\r" + literal(strings.TrimPrefix(body, "BEGIN")) + ";", ""}, + {"continued_cr", prefix + "DO U&'BEGIN'\r" + literal(strings.TrimPrefix(body, "BEGIN")) + ";", ""}, + {"continued_regclass", prefix + `DO $$ BEGIN IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=U&'public.' +'!0074' UESCAPE '!'::regclass) <> current_schema() THEN RAISE EXCEPTION 'wrong namespace'; END IF; INSERT INTO marker VALUES ('ok'); END $$;`, ""}, + {"continued_surrogate", `CREATE TABLE public."tπŸ˜€"(id int); CREATE TABLE public.marker(v text); DO $$ BEGIN IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=U&'public."t\D83D' +'\DE00"'::regclass) <> current_schema() THEN RAISE EXCEPTION 'wrong namespace'; END IF; INSERT INTO marker VALUES ('ok'); END $$;`, ""}, + {"unusual_names", `CREATE TABLE public."tΓ‰"(id int); CREATE TABLE public."t!"(id int); CREATE TABLE public."t/*x*/"(id int); CREATE TABLE public."t\x'"(id int); CREATE TABLE public."t""q"(id int); CREATE TABLE public."u&""t"""(id int); CREATE TABLE public.marker(v text); +DO $$ DECLARE rel regclass; BEGIN FOREACH rel IN ARRAY ARRAY['public.TΓ‰'::regclass, 'public.t!'::regclass, 'public.t/*x*/'::regclass, U&'public."t\\x''"'::regclass, 'public."t""q"'::regclass, 'public.U&"t"'::regclass] LOOP +IF (SELECT n.nspname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE c.oid=rel) <> current_schema() THEN RAISE EXCEPTION 'wrong namespace'; END IF; END LOOP; INSERT INTO marker VALUES ('ok'); END $$;`, ""}, + {"invalid_utf8_comment", prefix + "DO U&'BEGIN PERFORM ''public.t''::regclass; -- \xff\nEND';", "22021"}, + {"invalid_utf8_string", prefix + "DO U&'BEGIN PERFORM ''public.t''::regclass; RAISE NOTICE ''\xff''; END';", "22021"}, + {"invalid_utf8_split", prefix + "DO U&'BEGIN PERFORM ''public.t''::regclass; -- \xc3'\n'\xa9\nEND';", "22021"}, + {"invalid_utf8_uescape", prefix + "DO U&'BEGIN PERFORM ''public.t''::regclass; END' UESCAPE '\xff';", "22021"}, + {"invalid_utf8_escaped_uescape", prefix + `DO U&'BEGIN PERFORM ''public.t''::regclass; END' UESCAPE E'\377';`, "22021"}, + {"regclass_not_sql", prefix + `DO $$ BEGIN PERFORM 'U&"public".t'::regclass; END $$;`, "42P01"}, + {"standard_conforming_off", prefix + `DO $$ BEGIN SET standard_conforming_strings=off; PERFORM U&'public.t'::regclass; END $$;`, "0A000"}, + {"standard_conforming_off_custom", prefix + `DO $$ BEGIN SET standard_conforming_strings=off; PERFORM U&'!0070ublic.t' /*keep*/ UESCAPE '!'::regclass; END $$;`, "0A000"}, + {"space_is_not_continuation", prefix + "DO U&" + literal(body) + " 'END';", "42601"}, + {"block_comment_is_not_continuation", prefix + "DO U&" + literal(body) + " /*keep*/\n'END';", "42601"}, + {"block_comment_after_newline", prefix + "DO U&" + literal(body) + "\n/*keep*/'END';", "42601"}, + {"continued_uescape_multichar", prefix + "DO U&" + literal(body) + " UESCAPE '!'\n'?';", "42601"}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := db.Exec(`SET standard_conforming_strings=on; DROP SCHEMA public CASCADE; CREATE SCHEMA public; SET search_path=public,pg_catalog;`); err != nil { + t.Fatal(err) + } + _, nativeErr := db.Exec(tc.sql) + expectedState := tc.state + // PostgreSQL 14 reports a missing schema for this regclass name. + // Match each provider against the independently observed native code. + if tc.name == "regclass_not_sql" { + var pgErr *pgconn.PgError + if errors.As(nativeErr, &pgErr) && pgErr.Code == "3F000" { + expectedState = pgErr.Code + } + } + assertState := func(err error) { + t.Helper() + var pgErr *pgconn.PgError + if !errors.As(err, &pgErr) || pgErr.Code != expectedState { + t.Fatalf("database error %v, want SQLSTATE %s", err, expectedState) + } + } + if tc.state == "" { + if nativeErr != nil { + t.Fatalf("native fixture: %v", nativeErr) + } + } else { + assertState(nativeErr) + } + external, err := postgres.NewExternalDatabase(&postgres.ExternalDatabaseConfig{Host: h, Port: p, Database: d, Username: u, Password: w, SSLMode: "disable", TargetMajorVersion: major}) + if err != nil { + t.Fatal(err) + } + defer external.Stop() + readMarker := func(conn *sql.DB, schema string) string { + t.Helper() + var got string + if err := conn.QueryRow("SELECT v FROM " + ir.QuoteIdentifier(schema) + ".marker").Scan(&got); err != nil { + t.Fatal(err) + } + return got + } + for name, provider := range map[string]postgres.DesiredStateProvider{"embedded": sharedEmbeddedPG, "external": external} { + t.Run(name, func(t *testing.T) { + err := provider.ApplySchema(context.Background(), "public", tc.sql) + conn := db + if name == "embedded" { + conn = embeddedDB + } + if tc.state == "" { + if err != nil { + t.Fatal(err) + } + if got := readMarker(conn, provider.GetSchemaName()); got != "ok" { + t.Fatalf("temporary marker %q", got) + } + } else { + if err == nil { + t.Fatal("provider accepted native-invalid SQL") + } + // UTF-8 validation fails locally. Other failures retain PostgreSQL's + // SQLSTATE, including Unicode rejection after the DO block changes GUCs. + if tc.state != "22021" && tc.name != "continued_uescape_multichar" { + assertState(err) + } + var count int + if err := conn.QueryRow(`SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname=$1 AND c.relname='marker'`, provider.GetSchemaName()).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatal("native-invalid script left a marker") + } + } + }) + } + if tc.state == "" && readMarker(db, "public") != "ok" { + t.Fatal("original marker changed") + } + }) + } +} diff --git a/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index 820ab606..af1d61d8 100644 --- a/internal/postgres/desired_state.go +++ b/internal/postgres/desired_state.go @@ -95,9 +95,9 @@ func GenerateTempSchemaName() string { // // Only qualifications matching the specified schemaName are stripped. // All other schema qualifications are preserved as intentional cross-schema references. -func stripSchemaQualifications(sql string, schemaName string) string { +func stripSchemaQualifications(sql string, schemaName string) (string, error) { if schemaName == "" { - return sql + return sql, nil } // Split SQL into dollar-quoted and non-dollar-quoted segments. @@ -110,11 +110,14 @@ func stripSchemaQualifications(sql string, schemaName string) string { // body validation with SET check_function_bodies = off before executing // the resulting SQL. tokens := lexSQL(sql) + if err := unicodeTokenError(tokens); err != nil { + return "", err + } significant := significantTokens(tokens) for j := 0; j < len(significant); j++ { i := significant[j] if (j == 0 || tokens[significant[j-1]].text != ".") && tokenIdentifier(tokens[i]) == schemaName && j+2 < len(significant) && tokens[significant[j+1]].text == "." && tokens[significant[j+2]].kind == 'i' { - tokens[i].text = "" + tokens[i].text = tokens[i].trivia tokens[significant[j+1]].text = "" j += 2 } @@ -124,7 +127,7 @@ func stripSchemaQualifications(sql string, schemaName string) string { for _, token := range tokens { result.WriteString(token.text) } - return result.String() + return result.String(), nil } // dollarQuotedSegment represents a segment of SQL text, either inside or outside a dollar-quoted block. @@ -210,22 +213,25 @@ type schemaRemapNames struct { // extractCreatedObjectNames reads SQL after managed-schema qualifiers have been // stripped. Indexes belong to their indexed table's schema, so only indexes on // relations created in the temporary schema qualify for regclass remapping. -func extractCreatedObjectNames(sql string) schemaRemapNames { +func extractCreatedObjectNames(sql string) (schemaRemapNames, error) { names := schemaRemapNames{body: make(map[string]bool), relations: make(map[string]bool)} indexableRelations := make(map[string]bool) var indexedTables []struct{ index, table string } tokens := lexSQL(sql) + if err := unicodeTokenError(tokens); err != nil { + return schemaRemapNames{}, err + } indexes := significantTokens(tokens) keyword := func(k int, word string) bool { - return k < len(indexes) && strings.EqualFold(tokens[indexes[k]].text, word) + return k < len(indexes) && (tokenKeyword(tokens[indexes[k]]) == word || word == "." && tokens[indexes[k]].text == ".") } for j, i := range indexes { - if tokens[i].text == "" || tokens[i].kind != 'i' || strings.HasPrefix(tokens[i].text, `"`) || tokenIdentifier(tokens[i]) != "create" { + if tokenKeyword(tokens[i]) != "create" { continue } k := j + 1 for k < len(indexes) { - word := tokenIdentifier(tokens[indexes[k]]) + word := tokenKeyword(tokens[indexes[k]]) if word != "or" && word != "replace" && word != "unlogged" && word != "recursive" && word != "materialized" && word != "unique" { break } @@ -234,7 +240,7 @@ func extractCreatedObjectNames(sql string) schemaRemapNames { if k >= len(indexes) { continue } - kind := tokenIdentifier(tokens[indexes[k]]) + kind := tokenKeyword(tokens[indexes[k]]) switch kind { case "table", "view", "function", "procedure", "aggregate", "type", "domain", "sequence", "index": default: @@ -281,7 +287,7 @@ func extractCreatedObjectNames(sql string) schemaRemapNames { names.relations[index.index] = true } } - return names + return names, nil } func foldIdentifier(ident string) string { @@ -326,6 +332,9 @@ func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, obj } tokens := lexSQL(sql) + if err := unicodeTokenError(tokens); err != nil { + return "", err + } significant := significantTokens(tokens) for j, i := range significant { token := tokens[i] @@ -333,10 +342,10 @@ func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, obj continue } doPosition := j - 1 - if j >= 3 && strings.EqualFold(tokens[significant[j-2]].text, "language") { + if j >= 3 && tokenKeyword(tokens[significant[j-2]]) == "language" { doPosition = j - 3 } - isDO := doPosition >= 0 && strings.EqualFold(tokens[significant[doPosition]].text, "do") && + isDO := doPosition >= 0 && tokenKeyword(tokens[significant[doPosition]]) == "do" && (doPosition == 0 || tokens[significant[doPosition-1]].text == ";") if isDO { body, err := literalContent(token) @@ -352,7 +361,7 @@ func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, obj } continue } - if token.kind != 'd' || j == 0 || tokenIdentifier(tokens[significant[j-1]]) != "as" { + if token.kind != 'd' || j == 0 || tokenKeyword(tokens[significant[j-1]]) != "as" { continue } body := token.text diff --git a/internal/postgres/desired_state_test.go b/internal/postgres/desired_state_test.go index 75fdc215..172af03d 100644 --- a/internal/postgres/desired_state_test.go +++ b/internal/postgres/desired_state_test.go @@ -283,7 +283,10 @@ func TestStripSchemaQualifications_PreservesStringLiterals(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := stripSchemaQualifications(tt.sql, tt.schema) + result, err := stripSchemaQualifications(tt.sql, tt.schema) + if err != nil { + t.Fatal(err) + } if result != tt.expected { t.Errorf("stripSchemaQualifications(%q, %q)\n got: %q\n want: %q", tt.sql, tt.schema, result, tt.expected) } @@ -556,7 +559,10 @@ CREATE OR REPLACE FUNCTION Find_Rank(id bigint) RETURNS text LANGUAGE sql AS $$ CREATE MATERIALIZED VIEW IF NOT EXISTS name_mv AS SELECT 1; CREATE INDEX idx ON name (id); ` - got := extractCreatedObjectNames(sql) + got, err := extractCreatedObjectNames(sql) + if err != nil { + t.Fatal(err) + } expected := []string{"name", "Rank", "find_rank", "name_mv"} if len(got.body) != len(expected) { t.Errorf("got %v, expected %v", got, expected) @@ -588,7 +594,10 @@ func TestCreatedIndexNamesForRegclassRemapping(t *testing.T) { CREATE TABLE t(id int); COMMENT ON TABLE t IS 'CREATE INDEX fake2 ON t(id)'; DO $$ BEGIN PERFORM 'CREATE INDEX fake3 ON t(id)'; END $$;`, []string{"t"}, []string{"t"}}, } { t.Run(tc.name, func(t *testing.T) { - got := extractCreatedObjectNames(tc.sql) + got, err := extractCreatedObjectNames(tc.sql) + if err != nil { + t.Fatal(err) + } want := schemaRemapNames{body: make(map[string]bool), relations: make(map[string]bool)} for _, name := range tc.body { want.body[name] = true @@ -609,7 +618,11 @@ CREATE INDEX idx ON t(id); CREATE UNIQUE INDEX IF NOT EXISTS "Unique.Index" ON ONLY t(id); CREATE FUNCTION f() RETURNS int LANGUAGE sql AS $$ SELECT public.idx() $$; DO $$ BEGIN PERFORM 'public.idx'::regclass, 'public."Unique.Index"'::pg_catalog.regclass, 'public.untouched'::regclass; END $$;` - got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "pgschema_tmp_x", extractCreatedObjectNames(sql)) + objects, err := extractCreatedObjectNames(sql) + if err != nil { + t.Fatal(err) + } + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "pgschema_tmp_x", objects) if err != nil { t.Fatal(err) } diff --git a/internal/postgres/do_remap_test.go b/internal/postgres/do_remap_test.go index 2f803ecf..d0dd2929 100644 --- a/internal/postgres/do_remap_test.go +++ b/internal/postgres/do_remap_test.go @@ -61,11 +61,17 @@ CREATE TABLE "s"".'\$$"."t"".'\$$"(id int); COMMENT ON TABLE "s"".'\$$"."t"".'\$$" IS 'CREATE TABLE bogus2(id int)'; CREATE TABLE other.external(id int); DO $$ BEGIN PERFORM 1; END $$;` - stripped := stripSchemaQualifications(sql, schema) + stripped, err := stripSchemaQualifications(sql, schema) + if err != nil { + t.Fatal(err) + } if !strings.Contains(stripped, `CREATE TABLE "t"".'\$$"`) { t.Fatalf("schema not stripped: %s", stripped) } - objects := extractCreatedObjectNames(stripped) + objects, err := extractCreatedObjectNames(stripped) + if err != nil { + t.Fatal(err) + } if len(objects.body) != 1 || !objects.body[`t".'\$$`] || len(objects.relations) != 1 || !objects.relations[`t".'\$$`] { t.Fatalf("incorrect created objects: %v", objects) } @@ -74,7 +80,11 @@ DO $$ BEGIN PERFORM 1; END $$;` func TestStripRepeatedSchemaIdentifier(t *testing.T) { const sql = `COMMENT ON COLUMN public.public.id IS 'public.public.id'; SELECT other.public.id FROM other.public;` const want = `COMMENT ON COLUMN public.id IS 'public.public.id'; SELECT other.public.id FROM other.public;` - if got := stripSchemaQualifications(sql, "public"); got != want { + got, err := stripSchemaQualifications(sql, "public") + if err != nil { + t.Fatal(err) + } + if got != want { t.Fatalf("got %s, want %s", got, want) } } diff --git a/internal/postgres/embedded.go b/internal/postgres/embedded.go index eb9364f1..4791812a 100644 --- a/internal/postgres/embedded.go +++ b/internal/postgres/embedded.go @@ -276,7 +276,10 @@ func (ep *EmbeddedPostgres) ApplySchema(ctx context.Context, schema string, sql // Strip schema qualifications from SQL before applying to temporary schema // This ensures that objects are created in the temporary schema via search_path // rather than being explicitly qualified with the original schema name - schemaAgnosticSQL := stripSchemaQualifications(sql, schema) + schemaAgnosticSQL, err := stripSchemaQualifications(sql, schema) + if err != nil { + return fmt.Errorf("failed to strip desired state schema qualifications: %w", err) + } // Replace schema names in ALTER DEFAULT PRIVILEGES statements // These use "IN SCHEMA " syntax which isn't handled by stripSchemaQualifications diff --git a/internal/postgres/external.go b/internal/postgres/external.go index 1a910194..65bd6b83 100644 --- a/internal/postgres/external.go +++ b/internal/postgres/external.go @@ -239,7 +239,10 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql // Strip schema qualifications from SQL before applying to temporary schema // This ensures that objects are created in the temporary schema via search_path // rather than being explicitly qualified with the original schema name - schemaAgnosticSQL := stripSchemaQualifications(sql, schema) + schemaAgnosticSQL, err := stripSchemaQualifications(sql, schema) + if err != nil { + return fmt.Errorf("failed to strip desired state schema qualifications: %w", err) + } // Replace schema names in ALTER DEFAULT PRIVILEGES statements // These use "IN SCHEMA " syntax which isn't handled by stripSchemaQualifications @@ -253,7 +256,11 @@ func (ed *ExternalDatabase) ApplySchema(ctx context.Context, schema string, sql // Point target-schema qualifiers inside function bodies at the temporary schema so that // SQL functions inlined by later statements resolve (issue #596). Only objects created by // this SQL are rewritten: the plan database's own target schema may hold extensions. - schemaAgnosticSQL, err = qualifyFunctionBodiesWithTempSchema(schemaAgnosticSQL, schema, ed.tempSchema, extractCreatedObjectNames(schemaAgnosticSQL)) + objects, err := extractCreatedObjectNames(schemaAgnosticSQL) + if err != nil { + return err + } + schemaAgnosticSQL, err = qualifyFunctionBodiesWithTempSchema(schemaAgnosticSQL, schema, ed.tempSchema, objects) if err != nil { return fmt.Errorf("failed to remap desired state SQL to temporary schema %s: %w", ed.tempSchema, err) } diff --git a/internal/postgres/sql_lex.go b/internal/postgres/sql_lex.go index 6f878c72..dcac6693 100644 --- a/internal/postgres/sql_lex.go +++ b/internal/postgres/sql_lex.go @@ -8,8 +8,12 @@ import ( // schemaSQLToken retains original spelling so remapping can preserve comments, // whitespace, ordinary literals, and destination identifiers verbatim. type schemaSQLToken struct { - text string - kind byte + text string + kind byte + unicode bool + content string + trivia string // Whitespace and comments around a consumed UESCAPE clause. + err error } func sqlIdentifierByte(c byte) bool { @@ -28,7 +32,7 @@ func lexSQL(text string) []schemaSQLToken { } case strings.HasPrefix(text[i:], "--"): kind = 'c' - for i < len(text) && text[i] != '\n' { + for i < len(text) && text[i] != '\n' && text[i] != '\r' { i++ } case strings.HasPrefix(text[i:], "/*"): @@ -46,6 +50,30 @@ func lexSQL(text string) []schemaSQLToken { i++ } } + case (text[i] == 'U' || text[i] == 'u') && i+2 < len(text) && text[i+1] == '&' && (text[i+2] == '\'' || text[i+2] == '"'): + quote := text[i+2] + kind = 's' + if quote == '"' { + kind = 'i' + } + i += 3 + closed := false + for i < len(text) { + if text[i] == quote { + i++ + if i < len(text) && text[i] == quote { + i++ + } else { + closed = true + break + } + } else { + i++ + } + } + if !closed { + kind = '!' + } case text[i] == '\'' || (text[i] == 'E' || text[i] == 'e') && i+1 < len(text) && text[i+1] == '\'': kind = 's' closed := false @@ -105,15 +133,18 @@ func lexSQL(text string) []schemaSQLToken { default: i++ } - tokens = append(tokens, schemaSQLToken{text[start:i], kind}) + tokens = append(tokens, schemaSQLToken{text: text[start:i], kind: kind}) } - return tokens + return decodeUnicodeTokens(tokens) } func tokenIdentifier(t schemaSQLToken) string { if t.kind != 'i' { return "" } + if t.unicode { + return t.content + } if strings.HasPrefix(t.text, `"`) { name, _, _ := parseQuotedIdent(t.text, 0) return name @@ -122,6 +153,9 @@ func tokenIdentifier(t schemaSQLToken) string { } func completeSQLLiteral(t schemaSQLToken) bool { + if t.unicode { + return t.kind == 's' && t.err == nil + } if t.kind == 'd' { tag := dollarQuoteRe.FindString(t.text) return len(t.text) >= 2*len(tag) && strings.HasSuffix(t.text, tag) @@ -137,9 +171,18 @@ func completeSQLLiteral(t schemaSQLToken) bool { } func encodeSQLLiteral(t schemaSQLToken, content string) string { + if t.unicode { + // Keep U& semantics, including PostgreSQL's rejection when + // standard_conforming_strings is off. Use the default escape and + // consume the original UESCAPE clause while retaining its trivia. + return t.text[:2] + "'" + strings.ReplaceAll(strings.ReplaceAll(content, `\`, `\\`), "'", "''") + "'" + t.trivia + } if t.kind == 'd' { tag := dollarQuoteRe.FindString(t.text) - // The temporary schema name cannot introduce a new delimiter. + // Decoding an inner Unicode literal can expose the body's delimiter. + for strings.Contains(content, tag) { + tag = strings.TrimSuffix(tag, "$") + "_$" + } return tag + content + tag } if t.text[0] != '\'' { @@ -163,6 +206,9 @@ func significantTokens(tokens []schemaSQLToken) []int { // table alias, or PL/pgSQL variable. Ordinary strings are never interpreted as SQL. func remapDOBody(body, schema, temp string, objects map[string]bool) (string, error) { tokens := lexSQL(body) + if err := unicodeTokenError(tokens); err != nil { + return "", fmt.Errorf("decode DO body: %w", err) + } significant := significantTokens(tokens) allowed := func(name string) bool { return objects == nil || objects[name] } for j := 0; j < len(significant); j++ { @@ -176,7 +222,7 @@ func remapDOBody(body, schema, temp string, objects map[string]bool) (string, er if next+2 < len(significant) && tokens[significant[next]].text == ":" && tokens[significant[next+1]].text == ":" { next += 2 regclass = true - } else if j >= 2 && tokenIdentifier(tokens[significant[j-2]]) == "cast" && tokens[significant[j-1]].text == "(" && next < len(significant) && tokenIdentifier(tokens[significant[next]]) == "as" { + } else if j >= 2 && tokenKeyword(tokens[significant[j-2]]) == "cast" && tokens[significant[j-1]].text == "(" && next < len(significant) && tokenKeyword(tokens[significant[next]]) == "as" { next++ regclass = true } @@ -193,15 +239,16 @@ func remapDOBody(body, schema, temp string, objects map[string]bool) (string, er if err != nil { return "", fmt.Errorf("decode regclass literal in DO body: %w", err) } - relation := lexSQL(content) - parts := significantTokens(relation) - if len(parts) == 3 && tokenIdentifier(relation[parts[0]]) == schema && relation[parts[1]].text == "." && relation[parts[2]].kind == 'i' && allowed(tokenIdentifier(relation[parts[2]])) { - relation[parts[0]].text = `"` + strings.ReplaceAll(temp, `"`, `""`) + `"` - var b strings.Builder - for _, part := range relation { - b.WriteString(part.text) - } - tokens[i].text = encodeSQLLiteral(t, b.String()) + start := skipSpace(content, 0) + name, end, ok := parseRegclassIdent(content, start) + dot := skipSpace(content, end) + if !ok || name != schema || dot >= len(content) || content[dot] != '.' { + continue + } + object, next, ok := parseRegclassIdent(content, dot+1) + if ok && skipSpace(content, next) == len(content) && allowed(object) { + remapped := content[:start] + quoteIdent(temp) + content[end:] + tokens[i].text = encodeSQLLiteral(t, remapped) } } var result strings.Builder @@ -210,3 +257,28 @@ func remapDOBody(body, schema, temp string, objects map[string]bool) (string, er } return result.String(), nil } + +// Regclass input uses PostgreSQL's qualified-name grammar, not SQL tokens. +// In an unquoted component, characters such as &, / and embedded quotes are +// part of the name. Only a dot or whitespace ends the component. +func parseRegclassIdent(text string, start int) (string, int, bool) { + start = skipSpace(text, start) + if start == len(text) { + return "", start, false + } + if text[start] == '"' { + return parseQuotedIdent(text, start) + } + end := start + for end < len(text) && text[end] != '.' && !strings.ContainsRune(" \t\n\r\f\v", rune(text[end])) { + end++ + } + name := []byte(text[start:end]) + // PostgreSQL folds ASCII letters in UTF-8 unquoted names. + for i, c := range name { + if c >= 'A' && c <= 'Z' { + name[i] = c + ('a' - 'A') + } + } + return string(name), end, end > start +} diff --git a/internal/postgres/sql_literal.go b/internal/postgres/sql_literal.go index aa84c1d8..fef9eb84 100644 --- a/internal/postgres/sql_literal.go +++ b/internal/postgres/sql_literal.go @@ -32,6 +32,9 @@ func decodeUnicodeEscape(text string, start int) (rune, int, error) { // literalContent decodes PostgreSQL dollar, standard, and E-string literals. // Invalid escape literals fail at the remapping boundary before SQL execution. func literalContent(t schemaSQLToken) (string, error) { + if t.unicode { + return t.content, t.err + } if t.kind == 'd' { tag := dollarQuoteRe.FindString(t.text) return t.text[len(tag) : len(t.text)-len(tag)], nil diff --git a/internal/postgres/sql_unicode.go b/internal/postgres/sql_unicode.go new file mode 100644 index 00000000..14a11d6b --- /dev/null +++ b/internal/postgres/sql_unicode.go @@ -0,0 +1,209 @@ +package postgres + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf16" + "unicode/utf8" +) + +// tokenKeyword excludes quoted identifiers even when their names match keywords. +func tokenKeyword(t schemaSQLToken) string { + if t.kind != 'i' || t.unicode || strings.HasPrefix(t.text, `"`) { + return "" + } + return strings.ToLower(t.text) +} + +func unicodeTokenError(tokens []schemaSQLToken) error { + for _, t := range tokens { + if t.err != nil { + return t.err + } + } + return nil +} + +// decodeUnicodeTokens consumes UESCAPE with its token so a rewrite cannot leave +// an orphaned clause. Unchanged tokens retain their complete original spelling. +func decodeUnicodeTokens(tokens []schemaSQLToken) []schemaSQLToken { + var result []schemaSQLToken + for i := 0; i < len(tokens); i++ { + t := tokens[i] + if len(t.text) < 3 || (t.text[0] != 'u' && t.text[0] != 'U') || t.text[1] != '&' || (t.text[2] != '\'' && t.text[2] != '"') { + result = append(result, t) + continue + } + t.unicode = true + if t.kind == '!' { + t.err = fmt.Errorf("unterminated Unicode token") + result = append(result, t) + continue + } + quote := rune(t.text[2]) + payload := t.text[3 : len(t.text)-1] + // PostgreSQL joins the raw fragments before interpreting Unicode escapes. + // This also permits an escape or surrogate pair to span fragments. + if t.kind == 's' { + for { + next := sqlStringContinuation(tokens, i) + if next == i { + break + } + payload += tokens[next].text[1 : len(tokens[next].text)-1] + for n := i + 1; n <= next; n++ { + t.text += tokens[n].text + if n != next { + t.trivia += tokens[n].text + } + } + i = next + } + } + escape := rune('\\') + j := i + 1 + for j < len(tokens) && (tokens[j].kind == 'w' || tokens[j].kind == 'c') { + j++ + } + if j < len(tokens) && tokenKeyword(tokens[j]) == "uescape" { + k := j + 1 + for k < len(tokens) && (tokens[k].kind == 'w' || tokens[k].kind == 'c') { + k++ + } + if k == len(tokens) || !completeSQLLiteral(tokens[k]) || strings.HasPrefix(strings.ToLower(tokens[k].text), "u&") { + t.err = fmt.Errorf("UESCAPE requires a simple string literal") + } else { + value, err := literalContent(tokens[k]) + escaped := tokens[k].kind == 's' && tokens[k].text[0] != '\'' + if tokens[k].kind == 's' { + for { + next := sqlStringContinuation(tokens, k) + if next == k { + break + } + fragment := tokens[next] + if escaped { + fragment.text = "E" + fragment.text + } + part, partErr := literalContent(fragment) + value += part + if err == nil { + err = partErr + } + k = next + } + } + if err != nil { + t.err = err + } else { + r, size := utf8.DecodeRuneInString(value) + if !utf8.ValidString(value) || len(value) != 1 || size != 1 || strings.ContainsRune("0123456789abcdefABCDEF+'\" \t\n\r\f\v", r) { + t.err = fmt.Errorf("invalid UESCAPE character %q", value) + } else { + escape = r + } + } + for n := i + 1; n <= k; n++ { + t.text += tokens[n].text + if tokens[n].kind == 'w' || tokens[n].kind == 'c' { + t.trivia += tokens[n].text + } + } + i = k + } + } + if !utf8.ValidString(t.text) { + t.err = fmt.Errorf("invalid UTF-8 in Unicode token") + } + if t.err == nil { + t.content, t.err = decodeSQLUnicode(payload, quote, escape) + } + result = append(result, t) + } + return result +} + +// sqlStringContinuation follows PostgreSQL quote continuation: a newline is +// required, line comments are allowed, and block comments stop continuation. +// Only an unprefixed single-quoted fragment can continue a string. +func sqlStringContinuation(tokens []schemaSQLToken, start int) int { + newline := false + k := start + 1 + for k < len(tokens) { + t := tokens[k] + if t.kind != 'w' && !(t.kind == 'c' && strings.HasPrefix(t.text, "--")) { + break + } + newline = newline || strings.ContainsAny(t.text, "\n\r") + k++ + } + if newline && k < len(tokens) && tokens[k].kind == 's' && tokens[k].text[0] == '\'' && completeSQLLiteral(tokens[k]) { + return k + } + return start +} + +// decodeSQLUnicode implements PostgreSQL U& escapes, including adjacent UTF-16 +// surrogate pairs. SQL quote doubling is independent of the escape character. +func decodeSQLUnicode(text string, quote, escape rune) (string, error) { + if !utf8.ValidString(text) { + return "", fmt.Errorf("invalid UTF-8 in Unicode token") + } + var b strings.Builder + pending := rune(0) + for i := 0; i < len(text); { + r, size := utf8.DecodeRuneInString(text[i:]) + i += size + escaped := false + if r == quote && strings.HasPrefix(text[i:], string(quote)) { + i += utf8.RuneLen(quote) + } else if r == escape { + if strings.HasPrefix(text[i:], string(escape)) { + i += utf8.RuneLen(escape) + } else { + width := 4 + if i < len(text) && text[i] == '+' { + width = 6 + i++ + } + if i+width > len(text) { + return "", fmt.Errorf("incomplete Unicode escape") + } + digits := text[i : i+width] + for _, c := range digits { + if !strings.ContainsRune("0123456789abcdefABCDEF", c) { + return "", fmt.Errorf("invalid Unicode escape %q", digits) + } + } + value, err := strconv.ParseUint(digits, 16, 32) + if err != nil { + return "", err + } + if value == 0 || value > utf8.MaxRune { + return "", fmt.Errorf("invalid Unicode value U+%X", value) + } + r = rune(value) + i += width + escaped = true + } + } + if pending != 0 { + if !escaped || r < 0xDC00 || r > 0xDFFF { + return "", fmt.Errorf("invalid low Unicode surrogate") + } + r = utf16.DecodeRune(pending, r) + pending = 0 + } else if escaped && r >= 0xD800 && r <= 0xDBFF { + pending = r + continue + } else if escaped && r >= 0xDC00 && r <= 0xDFFF { + return "", fmt.Errorf("unexpected low Unicode surrogate") + } + b.WriteRune(r) + } + if pending != 0 { + return "", fmt.Errorf("missing low Unicode surrogate") + } + return b.String(), nil +} diff --git a/internal/postgres/sql_unicode_edges_test.go b/internal/postgres/sql_unicode_edges_test.go new file mode 100644 index 00000000..7f490edc --- /dev/null +++ b/internal/postgres/sql_unicode_edges_test.go @@ -0,0 +1,120 @@ +package postgres + +import ( + "strings" + "testing" +) + +func TestUnicodeInvalidUTF8(t *testing.T) { + for _, body := range []string{ + "DO U&'BEGIN PERFORM ''public.t''::regclass; -- \xff\nEND';", + "DO U&'BEGIN PERFORM ''public.t''::regclass; RAISE NOTICE ''\xc0\xaf''; END';", + "DO $$ BEGIN PERFORM U&'public.t\xff'::regclass; END $$;", + "DO U&'BEGIN -- \xc3'\n'\xa9\nEND';", + "DO U&'BEGIN END' UESCAPE '\xff';", + `DO U&'BEGIN END' UESCAPE E'\377';`, + } { + got, err := qualifyFunctionBodiesWithTempSchema(body, "public", "tmp", schemaRemapNames{}) + if err == nil || got != "" { + t.Fatalf("invalid UTF-8 repaired: %q, %v", got, err) + } + } + // A real replacement character is valid UTF-8 and must remain valid. + tokens := lexSQL(`U&'οΏ½'`) + if tokens[0].err != nil || tokens[0].content != "οΏ½" { + t.Fatalf("valid replacement character rejected: %+v", tokens) + } +} + +func TestUnicodeContinuedTokens(t *testing.T) { + for _, tc := range []struct{ sql, want string }{ + {"U&'a'\n'\\0062'", "ab"}, + {"U&'a'-- keep\n'\\0062'", "ab"}, + {"U&'a'\n-- keep\n'!0062' UESCAPE '!'", "ab"}, + {"U&'a'\r'\\0062'", "ab"}, + {"U&'\\D83D'\n'\\DE00'", "πŸ˜€"}, + {"U&'\\00'\n'62'", "b"}, + {"U&'a' UESCAPE '!'\n''", "a"}, + } { + t.Run(tc.sql, func(t *testing.T) { + tokens := lexSQL(tc.sql) + if len(tokens) != 1 || tokens[0].err != nil || tokens[0].content != tc.want || tokens[0].text != tc.sql { + t.Fatalf("tokens %+v; want %q", tokens, tc.want) + } + }) + } + for _, gap := range []string{" ", " /*keep*/\n", "\n/*keep*/", "/*\n*/"} { + sql := "U&'a'" + gap + "'b'" + tokens := lexSQL(sql) + if tokens[0].text != `U&'a'` || tokens[0].content != "a" { + t.Fatalf("invalid continuation combined: %+v", tokens) + } + } + for _, suffix := range []string{"'!'\n'?'", "E'!'\n'?'"} { + if _, err := qualifyFunctionBodiesWithTempSchema("DO U&'BEGIN END' UESCAPE "+suffix, "public", "tmp", schemaRemapNames{}); err == nil { + t.Fatal("continued multicharacter UESCAPE accepted") + } + } +} + +func TestUnicodeContinuedDO(t *testing.T) { + for _, sql := range []string{ + "DO U&'BEGIN PERFORM ''public.t''::regclass;'\n'\\0045ND';", + "DO U&'BEGIN PERFORM ''public.t''::regclass;'\n'!0045ND' UESCAPE '!';", + "DO U&'BEGIN'-- keep\n' PERFORM ''public.t''::regclass; END';", + "DO $$ BEGIN PERFORM U&'public.'\n'!0074' UESCAPE '!'::regclass; END $$;", + } { + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "tmp", schemaRemapNames{}) + if err != nil || !strings.Contains(got, `"tmp".t`) || !strings.Contains(got, "END") { + t.Fatalf("continued DO not remapped: %s, %v", got, err) + } + if strings.Contains(sql, "-- keep") && !strings.Contains(got, "-- keep\n") { + t.Fatalf("lost continuation comment: %s", got) + } + } + const unchanged = "DO U&'BEGIN'-- keep\n' RAISE NOTICE ''public.t''; END' UESCAPE '!';" + got, err := qualifyFunctionBodiesWithTempSchema(unchanged, "public", "tmp", schemaRemapNames{}) + if err != nil || got != unchanged { + t.Fatalf("unchanged spelling lost: %s, %v", got, err) + } +} + +func TestRegclassNamesAreNotSQL(t *testing.T) { + for _, name := range []string{`U&"public".t`, `public.U&"t"`, `other.t`, `public."t"x`, `public./*comment*/t`} { + sql := `DO $$ BEGIN PERFORM '` + name + `'::regclass; END $$;` + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "tmp", schemaRemapNames{relations: map[string]bool{"t": true}}) + if err != nil || got != sql { + t.Fatalf("name interpreted as SQL: %s, %v", got, err) + } + } + for _, name := range []string{`public.tΓ©`, `public.t!`, `public.t/*x*/`, `public.t\x`, `public."t""q"`, ` public . "tΓ©" `} { + sql := `DO $$ BEGIN PERFORM '` + name + `'::regclass; END $$;` + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "tmp", schemaRemapNames{}) + want := strings.Replace(sql, "public", `"tmp"`, 1) + if err != nil || got != want { + t.Fatalf("unusual name lost: %s, %v; want %s", got, err, want) + } + } +} + +func TestUnicodeEncodingRetainsSemantics(t *testing.T) { + sql := `DO $$ BEGIN SET standard_conforming_strings=off; PERFORM U&'public.t'::regclass; END $$;` + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "tmp", schemaRemapNames{}) + if err != nil || !strings.Contains(got, `U&'"tmp".t'`) { + t.Fatalf("Unicode semantics lost: %s, %v", got, err) + } + for _, raw := range []string{`u&'!0070ublic."t!!\x''q"' /* keep */ UESCAPE '!'`, `U&'public."t\\x''q"'`} { + token := lexSQL(raw)[0] + if token.err != nil { + t.Fatal(token.err) + } + encoded := encodeSQLLiteral(token, token.content) + decoded := lexSQL(encoded)[0] + if decoded.err != nil || decoded.content != token.content || !decoded.unicode { + t.Fatalf("Unicode round trip %q => %q => %+v", raw, encoded, decoded) + } + if strings.Contains(raw, "/* keep */") && !strings.Contains(encoded, "/* keep */") { + t.Fatal("lost comment") + } + } +} diff --git a/internal/postgres/sql_unicode_test.go b/internal/postgres/sql_unicode_test.go new file mode 100644 index 00000000..32fa71cc --- /dev/null +++ b/internal/postgres/sql_unicode_test.go @@ -0,0 +1,123 @@ +package postgres + +import ( + "reflect" + "strings" + "testing" +) + +func TestSQLUnicodeTokens(t *testing.T) { + for _, tc := range []struct{ sql, want string }{ + {`U&'\0070ublic.\+01F600'`, "public.πŸ˜€"}, + {`u&"\0070ublic"`, "public"}, + {`U&'\D83D\DE00'`, "πŸ˜€"}, + {`U&'\D83D\+00DE00'`, "πŸ˜€"}, + {`U&'it''s \\ text'`, "it's \\ text"}, + {`U&"a""b"`, `a"b`}, + {`u&'!0070ublic!!t' UESCAPE '!'`, "public!t"}, + {`U&"!0070ublic" /* keep */ UESCAPE /* also */ E'!'`, "public"}, + {`U&'!0061' UESCAPE $$!$$`, "a"}, + } { + t.Run(tc.sql, func(t *testing.T) { + tokens := lexSQL(tc.sql) + if len(tokens) != 1 || tokens[0].err != nil { + t.Fatalf("tokens %+v", tokens) + } + token := tokens[0] + got := tokenIdentifier(token) + if token.kind == 's' { + var err error + got, err = literalContent(token) + if err != nil { + t.Fatal(err) + } + } + if got != tc.want || token.text != tc.sql { + t.Fatalf("got %q, raw %q; want %q, raw %q", got, token.text, tc.want, tc.sql) + } + }) + } +} + +func TestSQLUnicodeInvalid(t *testing.T) { + for _, sql := range []string{ + `U&'\12'`, `U&'\12GG'`, `U&'\+110000'`, `U&'\0000'`, + `U&'\D83D'`, `U&'\DE00'`, `U&'\D83D\0041'`, `U&'\D83D\DE'`, + `U&'a' UESCAPE ''`, `U&'a' UESCAPE 'ab'`, `U&'a' UESCAPE '+'`, + `U&'a' UESCAPE '0'`, `U&'a' UESCAPE 'F'`, `U&'a' UESCAPE ' '`, + `U&'a' UESCAPE '"'`, `U&'a' UESCAPE ''''`, `U&'a' UESCAPE 'Β§'`, + `U&'a' UESCAPE U&'!'`, `U&'a' UESCAPE`, `U&'a' UESCAPE E'\u12'`, + `U&'unfinished`, `U&"unfinished`, + } { + t.Run(sql, func(t *testing.T) { + if _, err := stripSchemaQualifications("SELECT "+sql, "public"); err == nil { + t.Fatal("strip accepted invalid Unicode") + } + if _, err := extractCreatedObjectNames("SELECT " + sql); err == nil { + t.Fatal("collection accepted invalid Unicode") + } + if _, err := qualifyFunctionBodiesWithTempSchema("DO "+sql, "public", "tmp", schemaRemapNames{}); err == nil { + t.Fatal("remapping accepted invalid Unicode") + } + }) + } +} + +func TestUnicodeSchemaAndObjectRemapping(t *testing.T) { + const original = `-- public.t +CREATE TABLE u&"!0070ublic" /* keep */ UESCAPE /* also */ '!' . U&"t\+01F600"(id int); +CREATE UNIQUE INDEX U&"i\D83D\DE00" ON U&"public".U&"t\+01F600"(id); +CREATE TABLE other.U&"public"(id int); +SELECT 'public.t', U&'\0070ublic.t';` + stripped, err := stripSchemaQualifications(original, "public") + if err != nil { + t.Fatal(err) + } + want := strings.Replace(original, `u&"!0070ublic" /* keep */ UESCAPE /* also */ '!' .`, ` /* keep */ /* also */ `, 1) + want = strings.Replace(want, `U&"public".`, "", 1) + if stripped != want { + t.Fatalf("got %s; want %s", stripped, want) + } + objects, err := extractCreatedObjectNames(stripped) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(objects, schemaRemapNames{body: map[string]bool{"tπŸ˜€": true}, relations: map[string]bool{"tπŸ˜€": true, "iπŸ˜€": true}}) { + t.Fatalf("objects %+v", objects) + } + const body = `DO u&'BEGIN PERFORM U&''!0070ublic."t!+01F600"'' /* keep */ UESCAPE ''!''::regclass; PERFORM ''public.t'', public.t; END';` + got, err := qualifyFunctionBodiesWithTempSchema(body, "public", "tmp", objects) + if err != nil { + t.Fatal(err) + } + const expected = `DO u&'BEGIN PERFORM U&''"tmp"."tπŸ˜€"'' /* keep */ ::regclass; PERFORM ''public.t'', public.t; END';` + if got != expected { + t.Fatalf("got %s; want %s", got, expected) + } + const unchanged = `DO U&'BEGIN RAISE NOTICE ''\0070ublic.t''; END' UESCAPE '\';` + got, err = qualifyFunctionBodiesWithTempSchema(unchanged, "public", "tmp", objects) + if err != nil || got != unchanged { + t.Fatalf("changed unrelated Unicode text %s, %v", got, err) + } +} + +func TestQuotedKeywordsAreIdentifiers(t *testing.T) { + const sql = `SELECT "do", U&'public.t'; CREATE TABLE U&"on"(id int); CREATE INDEX U&"only" ON U&"on"(id);` + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "tmp", schemaRemapNames{}) + if err != nil || got != sql { + t.Fatalf("quoted keyword interpreted as code %s, %v", got, err) + } + objects, err := extractCreatedObjectNames(sql) + if err != nil || !objects.relations["only"] || !objects.relations["on"] { + t.Fatalf("quoted names lost %+v, %v", objects, err) + } +} + +func TestUnicodeRegclassDoesNotCloseDollarBody(t *testing.T) { + const sql = `DO $body$ BEGIN PERFORM U&'public."t\0024body\0024\0024body_\0024"'::regclass; END $body$;` + got, err := qualifyFunctionBodiesWithTempSchema(sql, "public", "tmp", schemaRemapNames{}) + const want = `DO $body__$ BEGIN PERFORM U&'"tmp"."t$body$$body_$"'::regclass; END $body__$;` + if err != nil || got != want { + t.Fatalf("got %s, %v; want %s", got, err, want) + } +} From 6a620735e9743c7b9ec0a8ad30da56a9d4562ede Mon Sep 17 00:00:00 2001 From: Nikolay Kushin Date: Wed, 14 Oct 2026 23:54:36 +0200 Subject: [PATCH 4/4] fix(diff): create eligible partition constraints explicitly --- ...herited_constraint_index_collision_test.go | 6 +- cmd/inherited_constraint_index_test.go | 67 ++- docs/cli/dump.mdx | 25 +- docs/cli/plan.mdx | 17 +- internal/diff/diff.go | 1 + internal/diff/explicit_constraint_index.go | 193 +++++++ .../diff/explicit_constraint_index_test.go | 74 +++ internal/diff/inherited_constraint_index.go | 2 +- internal/diff/partition_index.go | 10 +- internal/diff/table.go | 13 +- internal/dump/formatter.go | 25 + internal/dump/formatter_test.go | 36 ++ internal/plan/partition_index_test.go | 55 ++ ir/index_test.go | 3 + ir/ir.go | 16 +- ir/partition_index.go | 42 +- .../issue_409_partitioned_fk/pgschema.sql | 159 ++---- .../pgschema.sql | 80 +-- testdata/dump/sakila/pgschema.sql | 494 ++++-------------- 19 files changed, 698 insertions(+), 620 deletions(-) create mode 100644 internal/diff/explicit_constraint_index.go create mode 100644 internal/diff/explicit_constraint_index_test.go diff --git a/cmd/inherited_constraint_index_collision_test.go b/cmd/inherited_constraint_index_collision_test.go index d64d10e3..e1a8d9d1 100644 --- a/cmd/inherited_constraint_index_collision_test.go +++ b/cmd/inherited_constraint_index_collision_test.go @@ -20,8 +20,12 @@ func TestInheritedConstraintIndexTemporaryNameCollisions(t *testing.T) { defer pg.Stop() db, h, p, d, u, w := testutil.ConnectToPostgres(t, pg) defer db.Close() - const desired = `CREATE TABLE events (id int PRIMARY KEY) PARTITION BY RANGE(id); + // The child needs a new function, so this family crosses creation batches + // and deliberately exercises strict restoration. + const desired = `CREATE TABLE events (id int PRIMARY KEY, a int) PARTITION BY RANGE(id); +CREATE FUNCTION event_value() RETURNS int LANGUAGE SQL AS 'SELECT a FROM events GROUP BY id'; CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10); +ALTER TABLE events_leaf ALTER a SET DEFAULT event_value(); ALTER INDEX events_leaf_pkey RENAME TO custom_leaf;` path := filepath.Join(t.TempDir(), "schema.sql") if err := os.WriteFile(path, []byte(desired), 0600); err != nil { diff --git a/cmd/inherited_constraint_index_test.go b/cmd/inherited_constraint_index_test.go index 1995f519..e98ddd6e 100644 --- a/cmd/inherited_constraint_index_test.go +++ b/cmd/inherited_constraint_index_test.go @@ -37,9 +37,35 @@ func TestInheritedConstraintIndexRoundTrip(t *testing.T) { targetDB, h, p, d, u, w := testutil.ConnectToPostgres(t, target) defer targetDB.Close() fixtures := []struct{ name, schema, sql string }{ + {"dependency_sensitive", "public", `CREATE TABLE events (id int PRIMARY KEY, a int) PARTITION BY RANGE(id); +CREATE FUNCTION event_value() RETURNS int LANGUAGE SQL AS 'SELECT a FROM events GROUP BY id'; +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10); +ALTER TABLE events_leaf ALTER a SET DEFAULT event_value(); +ALTER INDEX events_leaf_pkey RENAME TO custom_leaf;`}, + {"renamed_pk_timestamp", "public", `CREATE TABLE events (id int PRIMARY KEY, occurred_at timestamp) PARTITION BY RANGE(id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10); +ALTER INDEX events_leaf_pkey RENAME TO events_leaf_identity; +CREATE INDEX events_leaf_pkey ON events_leaf(occurred_at); +CREATE FUNCTION event_value() RETURNS timestamp LANGUAGE SQL AS 'SELECT occurred_at FROM events GROUP BY id'; +COMMENT ON CONSTRAINT events_pkey ON events IS 'root key'; +COMMENT ON CONSTRAINT events_leaf_identity ON events_leaf IS 'leaf key'; +CREATE TABLE event_refs (id int REFERENCES events(id), CONSTRAINT events_pkey CHECK(id > 0)); +CREATE POLICY grouped ON event_refs USING (id IN (SELECT id FROM events GROUP BY id HAVING occurred_at IS NOT NULL)); +CREATE POLICY grouped_self ON events USING (id IN (SELECT id FROM events GROUP BY id HAVING occurred_at IS NOT NULL));`}, + {"identical_unique", "public", `CREATE TABLE events (id int, a int) PARTITION BY RANGE(id); +ALTER TABLE events ADD CONSTRAINT first_unique UNIQUE(id,a) INCLUDE (id); +ALTER TABLE events ADD CONSTRAINT second_unique UNIQUE(id,a) INCLUDE (id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10);`}, + {"deferrable", "public", `CREATE TABLE events (id int, a int, CONSTRAINT custom_unique UNIQUE(id,a) INCLUDE (id) DEFERRABLE INITIALLY DEFERRED) PARTITION BY RANGE(id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10);`}, {"reported_primary_key", "public", `CREATE TABLE public.events (id int PRIMARY KEY) PARTITION BY RANGE(id); CREATE INDEX events_leaf_pkey ON public.events(id); CREATE TABLE public.events_leaf PARTITION OF public.events FOR VALUES FROM (0) TO (10);`}, + {"nulls_not_distinct", "public", `CREATE TABLE events (id int, a int, UNIQUE NULLS NOT DISTINCT(id,a)) PARTITION BY RANGE(id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10);`}, + {"unicode_long_names", "public", `CREATE TABLE events (id int, a int, CONSTRAINT "ΠΊΠ»ΡŽΡ‡_Π΄Π»ΠΈΠ½Π½ΠΎΠ΅_имя_ограничСния_Ρ‚Π°Π±Π»ΠΈΡ†Ρ‹" UNIQUE(id,a)) PARTITION BY RANGE(id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10); +ALTER INDEX events_leaf_id_a_key RENAME TO "ΠΊΠ»ΡŽΡ‡_Ρ€Π°Π·Π΄Π΅Π»Π°_с_Π΄Π»ΠΈΠ½Π½Ρ‹ΠΌ_ΠΈΠΌΠ΅Π½Π΅ΠΌ_индСкса";`}, {"unique_collision", "public", `CREATE TABLE events (id int UNIQUE) PARTITION BY RANGE(id); CREATE INDEX events_leaf_id_key ON events(id); CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10);`}, @@ -62,6 +88,9 @@ ALTER INDEX "leaf.'\$$.$pgschema_restore_0$_id_a_key" RENAME TO "a_very_long_des } for _, fixture := range fixtures { t.Run(fixture.name, func(t *testing.T) { + if fixture.name == "nulls_not_distinct" && majorVersion < 15 { + t.Skip("NULLS NOT DISTINCT requires PostgreSQL 15") + } reset := func(db *sql.DB) { t.Helper() _, err := db.Exec("DROP SCHEMA IF EXISTS " + ir.QuoteIdentifier(fixture.schema) + " CASCADE; CREATE SCHEMA " + ir.QuoteIdentifier(fixture.schema) + "; SET search_path TO " + ir.QuoteIdentifier(fixture.schema) + ";") @@ -91,8 +120,12 @@ ALTER INDEX "leaf.'\$$.$pgschema_restore_0$_id_a_key" RENAME TO "a_very_long_des if err != nil { t.Fatal(err) } - if !strings.Contains(text, "INTO STRICT") { - t.Fatal("dump omitted restoration") + fallback := fixture.name == "dependency_sensitive" + if strings.Contains(text, "INTO STRICT") != fallback { + t.Fatalf("unexpected restoration, fallback=%v", fallback) + } + if !fallback && !strings.Contains(text, "ALTER TABLE ONLY") { + t.Fatal("dump omitted explicit constraints") } sourcePlan, err := planCmd.GeneratePlan(&planCmd.PlanConfig{Host: sh, Port: sp, DB: sd, User: su, Password: sw, Schema: fixture.schema, SSLMode: "disable", File: path}, sharedEmbeddedPG) if err != nil { @@ -124,17 +157,34 @@ ALTER INDEX "leaf.'\$$.$pgschema_restore_0$_id_a_key" RENAME TO "a_very_long_des if len(migration.Groups) != 1 { t.Fatalf("expected single creation group, got %d", len(migration.Groups)) } - // Inject both strict-lookup and destination-name failures into the real + // Inject missing-object and occupied-name failures into the real // creation group. Apply must propagate each failure and roll back tables. for _, failure := range []string{"lookup", "occupied"} { injected := false for i := range migration.Groups[0].Steps { step := &migration.Groups[0].Steps[i] - if !strings.Contains(step.SQL, "INTO STRICT") { + if !strings.Contains(step.SQL, "INTO STRICT") && !strings.HasPrefix(step.SQL, "ALTER TABLE ONLY") { continue } original := step.SQL - if failure == "lookup" { + expected := "P0002" + if !fallback { + pos := strings.Index(original, " ADD CONSTRAINT ") + if failure == "lookup" { + step.SQL = "ALTER TABLE ONLY pgschema_missing_relation" + original[pos:] + expected = "42P01" + } else { + definition := strings.Index(original, " PRIMARY KEY") + if definition < 0 { + definition = strings.Index(original, " UNIQUE") + } + parentName := "events" + if fixture.name == "quoted_identifiers" { + parentName = `t.'\$$.$pgschema_restore_0$` + } + step.SQL = original[:pos] + " ADD CONSTRAINT " + ir.QuoteIdentifier(parentName) + original[definition:] + } + } else if failure == "lookup" { step.SQL = strings.Replace(original, "WHERE c.conrelid =", "WHERE false AND c.conrelid =", 1) } else { // The parent table is a relation in the same namespace as the leaf. @@ -147,7 +197,6 @@ ALTER INDEX "leaf.'\$$.$pgschema_restore_0$_id_a_key" RENAME TO "a_very_long_des } err = apply.ApplyMigration(&apply.ApplyConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: fixture.schema, SSLMode: "disable", Plan: migration, AutoApprove: true, NoColor: true, Quiet: true}, nil) step.SQL = original - expected := "P0002" if failure == "occupied" { expected = "42P07" } @@ -166,7 +215,7 @@ ALTER INDEX "leaf.'\$$.$pgschema_restore_0$_id_a_key" RENAME TO "a_very_long_des break } if !injected { - t.Fatal("no restoration step for failure injection") + t.Fatal("no constraint creation step for failure injection") } } err = apply.ApplyMigration(&apply.ApplyConfig{Host: h, Port: p, DB: d, User: u, Password: w, Schema: fixture.schema, SSLMode: "disable", Plan: migration, AutoApprove: true, NoColor: true, Quiet: true}, nil) @@ -200,7 +249,9 @@ func inheritedIndexCatalog(t *testing.T, db *sql.DB, schema string) string { var catalog string err := db.QueryRow(`SELECT COALESCE(jsonb_agg(to_jsonb(x) ORDER BY table_name,index_name),'[]'::jsonb)::text FROM ( SELECT t.relname AS table_name,i.relname AS index_name,pg_catalog.pg_get_indexdef(i.oid) AS definition, - idx.indisvalid,idx.indisready,idx.indislive,c.conname,c.contype, + idx.indisvalid,idx.indisready,idx.indislive,c.conname,c.contype,c.conislocal,c.coninhcount, + c.condeferrable,c.condeferred,pg_catalog.pg_get_constraintdef(c.oid) AS constraint_definition, + pg_catalog.obj_description(c.oid,'pg_constraint') AS constraint_comment, pc.conname AS parent_constraint,pt.relname AS parent_table, ARRAY(SELECT p.relname FROM pg_catalog.pg_inherits h JOIN pg_catalog.pg_class p ON p.oid=h.inhparent WHERE h.inhrelid=i.oid ORDER BY h.inhseqno) AS parent_indexes, ARRAY(SELECT dep.deptype::text FROM pg_catalog.pg_depend dep WHERE dep.classid='pg_catalog.pg_class'::pg_catalog.regclass AND dep.objid=i.oid AND dep.refclassid='pg_catalog.pg_constraint'::pg_catalog.regclass AND dep.refobjid=c.oid ORDER BY dep.deptype) AS ownership diff --git a/docs/cli/dump.mdx b/docs/cli/dump.mdx index 71d49093..dea094cb 100644 --- a/docs/cli/dump.mdx +++ b/docs/cli/dump.mdx @@ -263,12 +263,25 @@ leaves unattached indexes unattached. Multi-file dumps write parent indexes and their attachments under `indexes/`, such as `indexes/0001_events_lookup.sql`, after the table includes. -For retained constraint families, `dump` emits a `DO` block after each partition -definition to restore inherited `PRIMARY KEY` and `UNIQUE` index names. During -replay, PostgreSQL locates the indexes through their parent constraints and -renames mismatches in two phases, preserving ownership and handling name swaps. -Lookup and rename failures stop replay. Multi-file dumps keep each block with -its table definition, before standalone indexes. +For complete inherited `PRIMARY KEY` and `UNIQUE` families that fit in one +table-creation batch, the exporter emits named constraints with +`ALTER TABLE ONLY ... ADD CONSTRAINT`. It then emits explicit index attachments. +During replay, PostgreSQL creates the indexes with their source names and retains +constraint ownership. The exporter uses this path for ordinary and custom names +without rename blocks. For example, PostgreSQL creates `events_low_identity` +directly, leaving `events_low_pkey` available for a separate timestamp index. + +If the exporter must split table creation across batches because of function or +domain dependencies, or cannot use the captured constraint definitions, it retains +a `DO` restoration block after each eligible `CREATE TABLE ... PARTITION OF`. +During replay, PostgreSQL locates inherited indexes through their parent +constraints and renames mismatches in two phases, so name swaps work. PostgreSQL +reports lookup and rename failures to the caller. + +Multi-file dumps keep deferred constraint creation under `constraints/` and +index attachments under `indexes/`, in dependency order in `schema.sql`. +For the restoration path, the exporter keeps each block with its table +definition, before standalone indexes. Load files in the generated include order. Each individual file (e.g., `tables/users.sql`) contains the specific object definition: ```sql diff --git a/docs/cli/plan.mdx b/docs/cli/plan.mdx index 75bcc2e9..abfc7305 100644 --- a/docs/cli/plan.mdx +++ b/docs/cli/plan.mdx @@ -28,10 +28,19 @@ When part of the hierarchy already exists in the target, or part of it is ignored, `plan` falls back to recursive index creation and does not reconcile attachments on existing tables. -For a complete new hierarchy, `plan` restores inherited `PRIMARY KEY` and `UNIQUE` -index names immediately after creating each partition. It restores only indexes -whose originating constraint remains managed. Native `apply` runs restoration -with the table creation group, so a restoration failure rolls back that group. +For a complete new hierarchy with catalog constraint definitions, `plan` can +schedule named `PRIMARY KEY` and `UNIQUE` constraints after creating the tables. +It then attaches each child index to its recorded parent index. This preserves +source names without rename blocks. For example, PostgreSQL creates a child key +named `custom_pk` directly, even if its usual generated name is `events_low_pkey`. + +If `plan` must create the hierarchy in separate batches because of function or +domain dependencies, it retains recursive constraint creation and restores +inherited index names after each partition. It also retains this path for +unsupported constraint definitions or missing catalog metadata. It restores only +indexes whose originating constraint remains managed. Native `apply` keeps creation and +attachments or restoration in the table creation transaction, so a failure +rolls back that group. ## Executable DO blocks in desired SQL diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 4b53e1b2..7b56d957 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -2031,6 +2031,7 @@ func (d *ddlDiff) generateCreateSQL(targetSchema string, collector *diffCollecto } // Create tables WITHOUT function/domain dependencies first (functions may reference these) + d.partitionIndexes.planExplicitConstraints(d.allNewTables, tablesWithoutDeps, tablesWithDeps, tablesAfterTableDomains) deferredPolicies1, deferredConstraints1 := generateCreateTablesSQL(tablesWithoutDeps, targetSchema, collector, existingTables, shouldDeferPolicy, d.suppressedInlineFKs, d.allNewTables, d.partitionIndexes) // Build view lookup - needed for detecting functions that depend on views diff --git a/internal/diff/explicit_constraint_index.go b/internal/diff/explicit_constraint_index.go new file mode 100644 index 00000000..513192c8 --- /dev/null +++ b/internal/diff/explicit_constraint_index.go @@ -0,0 +1,193 @@ +package diff + +import ( + "fmt" + "sort" + + "github.com/pgplex/pgschema/ir" +) + +type explicitConstraintIndex struct { + constraint *ir.Constraint + index ir.IndexRef + parent ir.IndexRef + definition string + comment string +} + +// planExplicitConstraints selects whole, new table hierarchies within one +// creation batch. A function/domain boundary retains recursive creation and +// strict restoration, because consumers may need the parent's key earlier. +func (p *partitionIndexCreationPlan) planExplicitConstraints(tables map[string]*ir.Table, batches ...[]*ir.Table) { + if p == nil { + return + } + p.constraints = make(map[ir.IndexRef]*explicitConstraintIndex) + p.constraintTables = make(map[string]bool) + batchOf := make(map[string]int) + for b, batch := range batches { + for _, t := range batch { + batchOf[t.Schema+"."+t.Name] = b + 1 + } + } + families := make(map[string][]*ir.Table) + blocked := make(map[string]bool) + for key, t := range tables { + root := partitionHierarchyRoot(t, tables) + if root == "" { + continue + } + families[root] = append(families[root], t) + if batchOf[key] == 0 || batchOf[key] != batchOf[root] { + blocked[root] = true + } + for _, child := range t.AllPartitionChildren { + if batchOf[child.Schema+"."+child.Name] == 0 { + blocked[root] = true + } + } + } + for root, family := range families { + if blocked[root] || len(family) < 2 { + continue + } + candidates := make(map[ir.IndexRef]*explicitConstraintIndex) + valid := true + for _, t := range family { + key := t.Schema + "." + t.Name + // Ignored origins and incomplete inspection metadata stay on the old path. + if len(p.inherited[key]) != len(t.InheritedConstraintIndexes) { + valid = false + } + for _, c := range t.Constraints { + if c.Type != ir.ConstraintTypePrimaryKey && c.Type != ir.ConstraintTypeUnique { + continue + } + if c.CatalogDefinition == "" || c.BackingIndex == nil || c.BackingIndex.Name != c.Name || c.IsTemporal { + valid = false + continue + } + candidates[*c.BackingIndex] = &explicitConstraintIndex{constraint: c, index: *c.BackingIndex, definition: c.CatalogDefinition, comment: c.CatalogComment} + } + for _, c := range p.inherited[key] { + if c.Definition == "" || c.Index.Name != c.ConstraintName { + valid = false + continue + } + constraint := &ir.Constraint{Schema: t.Schema, Table: t.Name, Name: c.ConstraintName, Type: c.ConstraintType} + candidates[c.Index] = &explicitConstraintIndex{constraint: constraint, index: c.Index, parent: c.ParentIndex, definition: c.Definition, comment: c.Comment} + } + } + for _, c := range candidates { + if c.parent.Name != "" && candidates[c.parent] == nil { + valid = false + } + t := tables[c.constraint.Schema+"."+c.constraint.Table] + children := make(map[ir.TableDependency]bool) + for _, child := range t.AllPartitionChildren { + children[child] = true + } + for _, child := range family { + parentSchema := child.PartitionOfSchema + if parentSchema == "" { + parentSchema = child.Schema + } + if child.PartitionOf == t.Name && parentSchema == t.Schema { + children[ir.TableDependency{Schema: child.Schema, Name: child.Name}] = true + } + } + for child := range children { + found := false + for _, cc := range candidates { + if cc.parent == c.index && cc.constraint.Schema == child.Schema && cc.constraint.Table == child.Name { + found = true + break + } + } + if !found { + valid = false + } + } + } + if !valid || len(candidates) == 0 { + continue + } + for key, c := range candidates { + p.constraints[key] = c + tableKey := c.constraint.Schema + "." + c.constraint.Table + p.constraintTables[tableKey] = true + p.uniqueTables[tableKey] = true + } + } +} + +func (p *partitionIndexCreationPlan) explicitConstraint(c *ir.Constraint) bool { + if p == nil || c == nil { + return false + } + planned := p.constraints[ir.IndexRef{Schema: c.Schema, Name: c.Name}] + return planned != nil && planned.constraint.Table == c.Table && planned.constraint.Type == c.Type +} + +// Finish each batch before functions or policies can consume its constraints. +// Create children first and attach exact catalog edges without index matching. +func (p *partitionIndexCreationPlan) generateExplicitConstraintsSQL(batch []*ir.Table, targetSchema string, collector *diffCollector) { + if p == nil { + return + } + selected := make(map[string]bool) + for _, t := range batch { + selected[t.Schema+"."+t.Name] = true + } + var keys []ir.IndexRef + for key, c := range p.constraints { + if selected[c.constraint.Schema+"."+c.constraint.Table] { + keys = append(keys, key) + } + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].Schema != keys[j].Schema { + return keys[i].Schema < keys[j].Schema + } + return keys[i].Name < keys[j].Name + }) + created := make(map[ir.IndexRef]bool) + var create func(ir.IndexRef) + create = func(key ir.IndexRef) { + if created[key] { + return + } + created[key] = true + c := p.constraints[key] + for _, child := range keys { + if p.constraints[child].parent == key { + create(child) + } + } + constraint := c.constraint + tableName := qualifyEntityNameMode(constraint.Schema, constraint.Table, targetSchema, collector.qualifySchema) + context := &diffContext{Type: DiffTypeTableConstraint, Operation: DiffOperationCreate, Path: constraint.Schema + "." + constraint.Table + "." + constraint.Name, Source: constraint, CanRunInTransaction: true} + collector.collect(context, fmt.Sprintf("ALTER TABLE ONLY %s ADD CONSTRAINT %s %s;", tableName, ir.QuoteIdentifier(constraint.Name), c.definition)) + indexType := ir.IndexTypeUnique + if constraint.Type == ir.ConstraintTypePrimaryKey { + indexType = ir.IndexTypePrimary + } + index := &ir.Index{Schema: key.Schema, Table: constraint.Table, Name: key.Name, Type: indexType, IsPartitioned: true} + for _, child := range keys { + if p.constraints[child].parent != key { + continue + } + attachment := &diffContext{Type: DiffTypeTableIndex, Operation: DiffOperationAttach, Path: index.Schema + "." + index.Table + "." + index.Name, Source: index, CanRunInTransaction: true} + collector.collect(attachment, fmt.Sprintf("ALTER INDEX %s ATTACH PARTITION %s;", qualifyEntityNameMode(key.Schema, key.Name, targetSchema, collector.qualifySchema), qualifyEntityNameMode(child.Schema, child.Name, targetSchema, collector.qualifySchema))) + } + if c.comment != "" { + comment := *context + comment.Type = DiffTypeTableComment + comment.Path = constraint.Schema + "." + constraint.Table + collector.collect(&comment, fmt.Sprintf("COMMENT ON CONSTRAINT %s ON %s IS %s;", ir.QuoteIdentifier(constraint.Name), tableName, quoteString(c.comment))) + } + } + for _, key := range keys { + create(key) + } +} diff --git a/internal/diff/explicit_constraint_index_test.go b/internal/diff/explicit_constraint_index_test.go new file mode 100644 index 00000000..e143596b --- /dev/null +++ b/internal/diff/explicit_constraint_index_test.go @@ -0,0 +1,74 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/pgplex/pgschema/ir" +) + +func TestExplicitConstraintFamilyEligibility(t *testing.T) { + for _, name := range []string{"complete", "split_batch", "existing_child", "filtered_child", "missing_definition", "missing_inherited", "ignored_origin", "incomplete_ancestry"} { + t.Run(name, func(t *testing.T) { + root := &ir.Table{Schema: "public", Name: "events", IsPartitioned: true, PartitionStrategy: "RANGE", PartitionKey: "id", Columns: []*ir.Column{{Name: "id", DataType: "integer"}}, Constraints: map[string]*ir.Constraint{"root_pk": {Schema: "public", Table: "events", Name: "root_pk", Type: ir.ConstraintTypePrimaryKey, Columns: []*ir.ConstraintColumn{{Name: "id", Position: 1}}, BackingIndex: &ir.IndexRef{Schema: "public", Name: "root_pk"}, CatalogDefinition: "PRIMARY KEY (id)"}}} + child := &ir.Table{Schema: "public", Name: "events_leaf", PartitionOf: "events", PartitionBound: "FOR VALUES FROM (0) TO (10)", Columns: root.Columns, InheritedConstraintIndexes: []ir.InheritedConstraintIndex{{Index: ir.IndexRef{Schema: "public", Name: "leaf_pk"}, ConstraintName: "leaf_pk", ConstraintType: ir.ConstraintTypePrimaryKey, ParentIndex: ir.IndexRef{Schema: "public", Name: "root_pk"}, ParentConstraint: ir.ConstraintRef{Schema: "public", Table: "events", Name: "root_pk"}, Definition: "PRIMARY KEY (id)"}}} + root.AllPartitionChildren = []ir.TableDependency{{Schema: "public", Name: child.Name}} + tables := map[string]*ir.Table{"public.events": root, "public.events_leaf": child} + added := []*ir.Table{root, child} + batches := [][]*ir.Table{added} + switch name { + case "split_batch": + batches = [][]*ir.Table{{root}, {child}} + case "existing_child": + added = []*ir.Table{root} + batches = [][]*ir.Table{added} + case "filtered_child": + root.AllPartitionChildren = append(root.AllPartitionChildren, ir.TableDependency{Schema: "public", Name: "missing"}) + case "missing_definition": + root.Constraints["root_pk"].CatalogDefinition = "" + case "missing_inherited": + child.InheritedConstraintIndexes = nil + root.AllPartitionChildren = nil + case "ignored_origin": + root.Constraints = nil + case "incomplete_ancestry": + root.PartitionOf = "missing" + } + plan := planPartitionIndexCreation(added, tables, false) + plan.planExplicitConstraints(tables, batches...) + if got := len(plan.constraints) > 0; got != (name == "complete") { + t.Fatalf("explicit selection=%v", got) + } + if name != "complete" { + return + } + collector := newDiffCollector() + generateCreateTablesSQL(added, "public", collector, nil, nil, nil, tables, plan) + text := buildSQLFromSteps(collector.diffs) + for _, required := range []string{"id integer NOT NULL", "ALTER TABLE ONLY events_leaf ADD CONSTRAINT leaf_pk PRIMARY KEY (id);", "ALTER TABLE ONLY events ADD CONSTRAINT root_pk PRIMARY KEY (id);", "ALTER INDEX root_pk ATTACH PARTITION leaf_pk;"} { + if !strings.Contains(text, required) { + t.Fatalf("missing %q in %s", required, text) + } + } + if strings.Contains(text, "pgschema_restore") { + t.Fatal("explicit family retained restoration") + } + if strings.Index(text, "ALTER TABLE ONLY events_leaf") > strings.Index(text, "ALTER TABLE ONLY events ADD") { + t.Fatal("parent created before child") + } + fk := &ir.Constraint{Type: ir.ConstraintTypeForeignKey, ReferencedTable: "events"} + for _, unrelated := range []*ir.Constraint{ + {Schema: "public", Table: "another_table", Name: "root_pk", Type: ir.ConstraintTypeCheck}, + {Schema: "public", Table: "events", Name: "root_pk", Type: ir.ConstraintTypeCheck}, + {Schema: "public", Table: "another_table", Name: "root_pk", Type: ir.ConstraintTypePrimaryKey}, + } { + if plan.explicitConstraint(unrelated) { + t.Fatalf("unrelated constraint selected: %+v", unrelated) + } + } + if !plan.defersForeignKey(child, fk) { + t.Fatal("PK foreign key was not deferred") + } + }) + } +} diff --git a/internal/diff/inherited_constraint_index.go b/internal/diff/inherited_constraint_index.go index 83a0146e..c0d11bd0 100644 --- a/internal/diff/inherited_constraint_index.go +++ b/internal/diff/inherited_constraint_index.go @@ -36,7 +36,7 @@ func retainedInheritedConstraint(index ir.InheritedConstraintIndex, tables map[s // final renames. ALTER INDEX retains constraint ownership and renames the owning // constraint too. Catalog identities, never generated names, select the indexes. func (p *partitionIndexCreationPlan) restoreInheritedIndexesSQL(table *ir.Table, targetSchema string, qualify bool) string { - if p == nil { + if p == nil || p.constraintTables[table.Schema+"."+table.Name] { return "" } indexes := append([]ir.InheritedConstraintIndex(nil), p.inherited[table.Schema+"."+table.Name]...) diff --git a/internal/diff/partition_index.go b/internal/diff/partition_index.go index ba68c15b..aaaba70b 100644 --- a/internal/diff/partition_index.go +++ b/internal/diff/partition_index.go @@ -120,10 +120,12 @@ func validateConstraintIndexPartitions(parent, child ir.IndexRef, table *ir.Tabl // filtered hierarchies retain recursive creation for compatibility. // Dump selects every managed partitioned index after its strict validation. type partitionIndexCreationPlan struct { - indexes map[ir.IndexRef]*ir.Index - children map[ir.IndexRef]map[ir.IndexRef]bool - uniqueTables map[string]bool - inherited map[string][]ir.InheritedConstraintIndex + indexes map[ir.IndexRef]*ir.Index + children map[ir.IndexRef]map[ir.IndexRef]bool + uniqueTables map[string]bool + inherited map[string][]ir.InheritedConstraintIndex + constraints map[ir.IndexRef]*explicitConstraintIndex + constraintTables map[string]bool } func planPartitionIndexCreation(addedTables []*ir.Table, desiredTables map[string]*ir.Table, dump bool) *partitionIndexCreationPlan { diff --git a/internal/diff/table.go b/internal/diff/table.go index 0ba6625c..7cd4458d 100644 --- a/internal/diff/table.go +++ b/internal/diff/table.go @@ -581,7 +581,7 @@ func generateCreateTablesSQL( policyNames := sortedKeys(table.Policies) for _, name := range policyNames { policy := table.Policies[name] - if shouldDeferPolicy != nil && shouldDeferPolicy(policy) { + if (partitionIndexes != nil && partitionIndexes.constraintTables[table.Schema+"."+table.Name]) || (shouldDeferPolicy != nil && shouldDeferPolicy(policy)) { deferredPolicies = append(deferredPolicies, policy) } else { inlinePolicies = append(inlinePolicies, policy) @@ -596,6 +596,7 @@ func generateCreateTablesSQL( createdTables[fmt.Sprintf("%s.%s", table.Schema, table.Name)] = true } + partitionIndexes.generateExplicitConstraintsSQL(tables, targetSchema, collector) return deferredPolicies, deferredConstraints } @@ -991,7 +992,7 @@ func generateTableSQL(table *ir.Table, targetSchema string, qualifySchema bool, var deferred []*deferredConstraint currentKey := fmt.Sprintf("%s.%s", table.Schema, table.Name) for _, constraint := range inlineConstraints { - if suppressedInlineFKs[constraintPathKey(constraint)] { + if suppressedInlineFKs[constraintPathKey(constraint)] || partitionIndexes.explicitConstraint(constraint) { continue } if shouldDeferConstraint(table, constraint, currentKey, createdTables, existingTables) || @@ -1035,7 +1036,7 @@ func generateTableSQL(table *ir.Table, targetSchema string, qualifySchema bool, for _, column := range table.Columns { // Build column definition with SERIAL detection var builder strings.Builder - writeColumnDefinitionToBuilder(&builder, table, column, targetSchema, qualifySchema) + writeColumnDefinitionToBuilder(&builder, table, column, targetSchema, qualifySchema, partitionIndexes) columnParts = append(columnParts, fmt.Sprintf(" %s", builder.String())) } @@ -1056,7 +1057,7 @@ func generateTableSQL(table *ir.Table, targetSchema string, qualifySchema bool, for _, constraint := range inlineConstraints { // FKs bound to a unique/PK constraint being replaced by this migration // are created after the replacement instead (issue #439) - if suppressedInlineFKs[constraintPathKey(constraint)] { + if suppressedInlineFKs[constraintPathKey(constraint)] || partitionIndexes.explicitConstraint(constraint) { continue } if shouldDeferConstraint(table, constraint, currentKey, createdTables, existingTables) || @@ -1973,7 +1974,7 @@ func ensureCheckClauseParens(s string) string { // writeColumnDefinitionToBuilder builds column definitions with SERIAL detection and proper formatting // This is moved from ir/table.go to consolidate SQL generation in the diff module -func writeColumnDefinitionToBuilder(builder *strings.Builder, table *ir.Table, column *ir.Column, targetSchema string, qualifySchema bool) { +func writeColumnDefinitionToBuilder(builder *strings.Builder, table *ir.Table, column *ir.Column, targetSchema string, qualifySchema bool, partitionIndexes *partitionIndexCreationPlan) { builder.WriteString(ir.QuoteIdentifier(column.Name)) builder.WriteString(" ") @@ -1989,7 +1990,7 @@ func writeColumnDefinitionToBuilder(builder *strings.Builder, table *ir.Table, c // Check if column is part of any primary key constraint for NOT NULL handling isPartOfAnyPK := false for _, constraint := range table.Constraints { - if constraint.Type == ir.ConstraintTypePrimaryKey { + if constraint.Type == ir.ConstraintTypePrimaryKey && !partitionIndexes.explicitConstraint(constraint) { for _, col := range constraint.Columns { if col.Name == column.Name { isPartOfAnyPK = true diff --git a/internal/dump/formatter.go b/internal/dump/formatter.go index 23637a8c..7d864f50 100644 --- a/internal/dump/formatter.go +++ b/internal/dump/formatter.go @@ -92,6 +92,9 @@ func (f *DumpFormatter) FormatMultiFile(diffs []diff.Diff, outputPath string) er stepsByFile := make(map[fileKey][]diff.Diff) var fileOrder []fileKey constraintIndex := 0 + constraintFiles := make(map[ir.ConstraintRef]fileKey) + policyIndex := 0 + namedKeysCreated := false type indexKey struct{ schema, name string } partitionIndexFiles := make(map[indexKey]fileKey) var tables []*ir.Table @@ -107,12 +110,34 @@ func (f *DumpFormatter) FormatMultiFile(diffs []diff.Diff, outputPath string) er } key := fileKey{dir: dir, name: f.getGroupingName(step)} if step.Type == diff.DiffTypeTableConstraint { + if constraint, ok := step.Source.(*ir.Constraint); ok && + (constraint.Type == ir.ConstraintTypePrimaryKey || constraint.Type == ir.ConstraintTypeUnique) { + namedKeysCreated = true + } // Keep postponed constraints at their position in the diff sequence. // Bundling them with CREATE TABLE can put an FK before its target. // A sequence number also avoids collisions between constraint names // on different tables and names that sanitize to the same filename. constraintIndex++ key = fileKey{dir: "constraints", name: fmt.Sprintf("%04d_%s", constraintIndex, key.name)} + if constraint, ok := step.Source.(*ir.Constraint); ok { + constraintFiles[ir.ConstraintRef{Schema: constraint.Schema, Table: constraint.Table, Name: constraint.Name}] = key + } + } + if step.Type == diff.DiffTypeTableComment { + if constraint, ok := step.Source.(*ir.Constraint); ok { + // Constraint comments follow their deferred creation, even when + // attachments have intervened. Never bundle them with the table. + if existing, exists := constraintFiles[ir.ConstraintRef{Schema: constraint.Schema, Table: constraint.Table, Name: constraint.Name}]; exists { + key = existing + } + } + } + if step.Type == diff.DiffTypeTablePolicy && namedKeysCreated { + // A policy can use a PK's GROUP BY dependency. Keep it after the + // explicit keys rather than moving it into an earlier table file. + policyIndex++ + key = fileKey{dir: "policies", name: fmt.Sprintf("%04d_%s", policyIndex, key.name)} } if index, ok := step.Source.(*ir.Index); ok && index.IsPartitioned && diff --git a/internal/dump/formatter_test.go b/internal/dump/formatter_test.go index 3d41ae86..5951c304 100644 --- a/internal/dump/formatter_test.go +++ b/internal/dump/formatter_test.go @@ -166,3 +166,39 @@ func TestFormatMultiFilePostponedConstraints(t *testing.T) { } } } + +func TestFormatMultiFilePoliciesAfterNamedKeys(t *testing.T) { + path := filepath.Join(t.TempDir(), "schema.sql") + diffs := []diff.Diff{ + {Type: diff.DiffTypeTable, Path: "public.events", Source: &ir.Table{Name: "events"}, Statements: []diff.SQLStatement{{SQL: "CREATE TABLE events (id int NOT NULL, a int);"}}}, + {Type: diff.DiffTypeTableConstraint, Path: "public.events.pk", Source: &ir.Constraint{Table: "events", Name: "pk", Type: ir.ConstraintTypePrimaryKey}, Statements: []diff.SQLStatement{{SQL: "ALTER TABLE ONLY events ADD CONSTRAINT pk PRIMARY KEY (id);"}}}, + {Type: diff.DiffTypeTableIndex, Operation: diff.DiffOperationAttach, Path: "public.events.pk", Source: &ir.Index{Table: "events", Name: "pk", IsPartitioned: true}, Statements: []diff.SQLStatement{{SQL: "ALTER INDEX pk ATTACH PARTITION leaf_pk;"}}}, + {Type: diff.DiffTypeTableComment, Path: "public.events", Source: &ir.Constraint{Table: "events", Name: "pk", Type: ir.ConstraintTypePrimaryKey}, Statements: []diff.SQLStatement{{SQL: "COMMENT ON CONSTRAINT pk ON events IS 'key';"}}}, + {Type: diff.DiffTypeTablePolicy, Path: "public.events.grouped", Source: &ir.RLSPolicy{Table: "events", Name: "grouped"}, Statements: []diff.SQLStatement{{SQL: "CREATE POLICY grouped ON events USING (id IN (SELECT id FROM events GROUP BY id HAVING a IS NOT NULL));"}}}, + } + if err := NewDumpFormatter("PostgreSQL 18", "public", false, false).FormatMultiFile(diffs, path); err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + want := "\\i tables/events.sql\n\\i constraints/0001_events.sql\n\\i indexes/0001_pk.sql\n\\i policies/0001_events.sql\n" + if !strings.HasSuffix(string(content), want) { + t.Fatalf("unexpected include order: %s", content) + } + table, err := os.ReadFile(filepath.Join(filepath.Dir(path), "tables", "events.sql")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(table), "CREATE POLICY") { + t.Fatal("policy moved before its key") + } + constraint, err := os.ReadFile(filepath.Join(filepath.Dir(path), "constraints", "0001_events.sql")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(constraint), "ADD CONSTRAINT pk PRIMARY KEY (id);\n\nCOMMENT ON CONSTRAINT pk ON events IS 'key';") { + t.Fatalf("constraint comment lost its creation order: %s", constraint) + } +} diff --git a/internal/plan/partition_index_test.go b/internal/plan/partition_index_test.go index 557c8bcc..287c44dd 100644 --- a/internal/plan/partition_index_test.go +++ b/internal/plan/partition_index_test.go @@ -142,3 +142,58 @@ func checkIndexPlanRoundTrips(t *testing.T, p *Plan) { }) } } + +func TestConstraintOwnedPartitionIndexAttachments(t *testing.T) { + desired := parseSQL(t, ` +CREATE TABLE events (id integer PRIMARY KEY, a integer, UNIQUE(id,a)) PARTITION BY RANGE(id); +CREATE TABLE events_leaf PARTITION OF events FOR VALUES FROM (0) TO (10); +ALTER INDEX events_leaf_pkey RENAME TO leaf_identity; +COMMENT ON CONSTRAINT events_pkey ON events IS 'root key'; +COMMENT ON CONSTRAINT leaf_identity ON events_leaf IS 'child key'; +`) + diffs := diff.GenerateMigration(ir.NewIR(), desired, "public") + p := NewPlan(diffs, 18, nil) + if len(p.Groups) != 1 { + t.Fatalf("expected one transaction, got %d groups", len(p.Groups)) + } + var attachments []string + comments := 0 + for _, d := range diffs { + for _, stmt := range d.Statements { + if d.Operation == diff.DiffOperationAttach { + index, ok := d.Source.(*ir.Index) + if !ok || d.Type != diff.DiffTypeTableIndex || d.Path != index.Schema+"."+index.Table+"."+index.Name || !index.IsPartitioned || !stmt.CanRunInTransaction { + t.Fatalf("invalid constraint-owned attachment metadata: %+v", d) + } + attachments = append(attachments, stmt.SQL) + } + if strings.HasPrefix(stmt.SQL, "COMMENT ON CONSTRAINT") { + comments++ + if d.Type != diff.DiffTypeTableComment || d.Operation != diff.DiffOperationCreate { + t.Fatalf("invalid constraint comment metadata: %+v", d) + } + } + } + } + want := []string{"ALTER INDEX events_id_a_key ATTACH PARTITION events_leaf_id_a_key;", "ALTER INDEX events_pkey ATTACH PARTITION leaf_identity;"} + if !reflect.DeepEqual(attachments, want) { + t.Fatalf("attachments=%v, want %v", attachments, want) + } + if comments != 2 { + t.Fatalf("got %d constraint comments, want 2", comments) + } + summary := p.calculateSummaryFromSteps() + if summary.Total != 2 || summary.Add != 2 || summary.Change != 0 || summary.Destroy != 0 { + t.Fatalf("comments or attachments changed table counts: %+v", summary) + } + human := p.HumanColored(false) + for _, name := range []string{"events_pkey", "events_id_a_key"} { + if !strings.Contains(human, name+" (index attachment)") { + t.Errorf("missing attachment label for %s:\n%s", name, human) + } + } + if strings.Contains(human, "concurrent rebuild") || strings.Contains(p.ToSQL(SQLFormatRaw), "CONCURRENTLY") { + t.Fatal("new attachments classified as rebuild/concurrent creation") + } + checkIndexPlanRoundTrips(t, p) +} diff --git a/ir/index_test.go b/ir/index_test.go index 93f0547e..1012f730 100644 --- a/ir/index_test.go +++ b/ir/index_test.go @@ -16,8 +16,11 @@ func TestPartitionInspectionMetadataNotSerialized(t *testing.T) { } index.AttachedPartitions = []IndexRef{{Schema: "public", Name: "ignored_child"}} constraint.BackingIndex = &IndexRef{Schema: "public", Name: "unique_constraint"} + constraint.CatalogDefinition = "UNIQUE (id) INCLUDE (payload)" + constraint.CatalogComment = "local key" table.AllPartitionChildren = []TableDependency{{Schema: "public", Name: "ignored_partition"}} table.InheritedConstraintIndexes = []InheritedConstraintIndex{{ + Definition: "UNIQUE (id) INCLUDE (payload)", Comment: "inherited key", Index: IndexRef{Schema: "public", Name: "renamed_inherited"}, ConstraintName: "renamed_inherited", ConstraintType: ConstraintTypeUnique, ParentIndex: IndexRef{Schema: "public", Name: "origin"}, ParentConstraint: ConstraintRef{Schema: "public", Table: "parent", Name: "origin"}, }} diff --git a/ir/ir.go b/ir/ir.go index 881ab232..281445d1 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -83,8 +83,8 @@ type Table struct { // AllPartitionChildren records catalog children, including ignored or external // tables. Creation planning uses this metadata to identify complete hierarchies. AllPartitionChildren []TableDependency `json:"-"` - // InheritedConstraintIndexes preserves names of automatically created PK and - // UNIQUE indexes without managing inherited constraints as standalone objects. + // InheritedConstraintIndexes contains catalog identities and definitions of + // inherited PK/UNIQUE constraints, outside the managed schema and fingerprints. InheritedConstraintIndexes []InheritedConstraintIndex `json:"-"` } @@ -97,6 +97,8 @@ type ConstraintRef struct { // InheritedConstraintIndex is table-owned, transient inspection metadata. type InheritedConstraintIndex struct { + Definition string + Comment string Index IndexRef ConstraintName string ConstraintType ConstraintType @@ -265,9 +267,13 @@ type Sequence struct { // Constraint represents a table constraint type Constraint struct { - // BackingIndex identifies the index created by this constraint. Inspection - // records it for attached indexes; it does not change constraint generation. - BackingIndex *IndexRef `json:"-"` + // BackingIndex identifies the catalog index owned by this constraint. + // Creation planning uses this transient identity for partition families. + BackingIndex *IndexRef `json:"-"` + // CatalogDefinition and CatalogComment support explicit family creation + // without changing public IR or fingerprints. + CatalogDefinition string `json:"-"` + CatalogComment string `json:"-"` Schema string `json:"schema"` Table string `json:"table"` Name string `json:"name"` diff --git a/ir/partition_index.go b/ir/partition_index.go index 6f3d2929..a492228a 100644 --- a/ir/partition_index.go +++ b/ir/partition_index.go @@ -10,7 +10,8 @@ import ( func (i *Inspector) buildIndexAttachments(ctx context.Context, schema *IR, targetSchema string) (map[IndexRef][]IndexRef, error) { rows, err := i.db.QueryContext(ctx, ` SELECT pn.nspname, parent.relname, cn.nspname, child.relname, - t.relname, c.conname, c.contype::text, pc.conname, pt.relname + t.relname, c.conname, c.contype::text, pc.conname, pt.relname, + pg_get_constraintdef(c.oid), obj_description(c.oid, 'pg_constraint') FROM pg_inherits inh JOIN pg_class parent ON parent.oid = inh.inhparent JOIN pg_namespace pn ON pn.oid = parent.relnamespace @@ -34,8 +35,8 @@ ORDER BY pn.nspname, parent.relname, cn.nspname, child.relname`, targetSchema) var parent, child IndexRef var tableName string var constraintName sql.NullString - var constraintType, parentConstraintName, parentTable sql.NullString - if err := rows.Scan(&parent.Schema, &parent.Name, &child.Schema, &child.Name, &tableName, &constraintName, &constraintType, &parentConstraintName, &parentTable); err != nil { + var constraintType, parentConstraintName, parentTable, definition, comment sql.NullString + if err := rows.Scan(&parent.Schema, &parent.Name, &child.Schema, &child.Name, &tableName, &constraintName, &constraintType, &parentConstraintName, &parentTable, &definition, &comment); err != nil { return nil, err } attachments[parent] = append(attachments[parent], child) @@ -48,6 +49,7 @@ ORDER BY pn.nspname, parent.relname, cn.nspname, child.relname`, targetSchema) } table.InheritedConstraintIndexes = append(table.InheritedConstraintIndexes, InheritedConstraintIndex{ Index: child, ConstraintName: constraintName.String, ConstraintType: kind, + Definition: definition.String, Comment: comment.String, ParentIndex: parent, ParentConstraint: ConstraintRef{Schema: parent.Schema, Table: parentTable.String, Name: parentConstraintName.String}, }) @@ -61,5 +63,37 @@ ORDER BY pn.nspname, parent.relname, cn.nspname, child.relname`, targetSchema) if err := rows.Err(); err != nil { return nil, err } - return attachments, nil + if err := rows.Close(); err != nil { + return nil, err + } + // Keep complete PK/UNIQUE clauses, including INCLUDE, outside public IR. + locals, err := i.db.QueryContext(ctx, ` +SELECT t.relname, c.conname, idx.relname, pg_get_constraintdef(c.oid), + obj_description(c.oid, 'pg_constraint') +FROM pg_constraint c +JOIN pg_class t ON t.oid = c.conrelid +JOIN pg_namespace n ON n.oid = t.relnamespace +JOIN pg_class idx ON idx.oid = c.conindid +WHERE n.nspname = $1 AND c.contype IN ('p', 'u') AND c.conparentid = 0`, targetSchema) + if err != nil { + return nil, err + } + defer locals.Close() + for locals.Next() { + var tableName, name, indexName, definition string + var comment sql.NullString + if err := locals.Scan(&tableName, &name, &indexName, &definition, &comment); err != nil { + return nil, err + } + if namespace := schema.Schemas[targetSchema]; namespace != nil { + if table := namespace.Tables[tableName]; table != nil { + if constraint := table.Constraints[name]; constraint != nil { + constraint.CatalogDefinition = definition + constraint.BackingIndex = &IndexRef{Schema: targetSchema, Name: indexName} + constraint.CatalogComment = comment.String + } + } + } + } + return attachments, locals.Err() } diff --git a/testdata/dump/issue_409_partitioned_fk/pgschema.sql b/testdata/dump/issue_409_partitioned_fk/pgschema.sql index b0dac328..540b4aee 100644 --- a/testdata/dump/issue_409_partitioned_fk/pgschema.sql +++ b/testdata/dump/issue_409_partitioned_fk/pgschema.sql @@ -11,9 +11,8 @@ -- CREATE TABLE IF NOT EXISTS session ( - id bigint, - started_at timestamptz, - CONSTRAINT session_pkey PRIMARY KEY (id, started_at) + id bigint NOT NULL, + started_at timestamptz NOT NULL ) PARTITION BY RANGE (started_at); -- @@ -22,8 +21,7 @@ CREATE TABLE IF NOT EXISTS session ( CREATE TABLE IF NOT EXISTS event ( session_id bigint NOT NULL, - session_started_at timestamptz NOT NULL, - CONSTRAINT event_session_id_session_started_at_fkey FOREIGN KEY (session_id, session_started_at) REFERENCES session (id, started_at) + session_started_at timestamptz NOT NULL ); -- @@ -31,124 +29,47 @@ CREATE TABLE IF NOT EXISTS event ( -- CREATE TABLE IF NOT EXISTS session_2026_01 PARTITION OF session FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-02-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'session_2026_01'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'session'::pg_catalog.regclass AND pc.conname = E'session_pkey' - AND parent_index.relname = E'session_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'session_2026_01_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; -- -- Name: session_2026_02; Type: TABLE; Schema: -; Owner: - -- CREATE TABLE IF NOT EXISTS session_2026_02 PARTITION OF session FOR VALUES FROM ('2026-02-01 00:00:00+00') TO ('2026-03-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'session_2026_02'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'session'::pg_catalog.regclass AND pc.conname = E'session_pkey' - AND parent_index.relname = E'session_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'session_2026_02_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; + +-- +-- Name: session_2026_01_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY session_2026_01 ADD CONSTRAINT session_2026_01_pkey PRIMARY KEY (id, started_at); + +-- +-- Name: session_2026_02_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY session_2026_02 ADD CONSTRAINT session_2026_02_pkey PRIMARY KEY (id, started_at); + +-- +-- Name: session_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY session ADD CONSTRAINT session_pkey PRIMARY KEY (id, started_at); + +-- +-- Name: session_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX session_pkey ATTACH PARTITION session_2026_01_pkey; + +-- +-- Name: session_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX session_pkey ATTACH PARTITION session_2026_02_pkey; + +-- +-- Name: event_session_id_session_started_at_fkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE event +ADD CONSTRAINT event_session_id_session_started_at_fkey FOREIGN KEY (session_id, session_started_at) REFERENCES session (id, started_at); diff --git a/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql b/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql index 3548a228..b02f46de 100644 --- a/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql +++ b/testdata/dump/issue_472_partition_clone_trigger/pgschema.sql @@ -11,10 +11,9 @@ -- CREATE TABLE IF NOT EXISTS ledger ( - id uuid, + id uuid NOT NULL, amount bigint NOT NULL, - ts timestamptz, - CONSTRAINT ledger_pkey PRIMARY KEY (ts, id) + ts timestamptz NOT NULL ) PARTITION BY RANGE (ts); -- @@ -22,63 +21,24 @@ CREATE TABLE IF NOT EXISTS ledger ( -- CREATE TABLE IF NOT EXISTS ledger_2026_06 PARTITION OF ledger FOR VALUES FROM ('2026-06-01 00:00:00+00') TO ('2026-07-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'ledger_2026_06'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'ledger'::pg_catalog.regclass AND pc.conname = E'ledger_pkey' - AND parent_index.relname = E'ledger_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'ledger_2026_06_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; + +-- +-- Name: ledger_2026_06_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY ledger_2026_06 ADD CONSTRAINT ledger_2026_06_pkey PRIMARY KEY (ts, id); + +-- +-- Name: ledger_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY ledger ADD CONSTRAINT ledger_pkey PRIMARY KEY (ts, id); + +-- +-- Name: ledger_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX ledger_pkey ATTACH PARTITION ledger_2026_06_pkey; -- -- Name: tg_noop(); Type: FUNCTION; Schema: -; Owner: - diff --git a/testdata/dump/sakila/pgschema.sql b/testdata/dump/sakila/pgschema.sql index fadecdae..6af32596 100644 --- a/testdata/dump/sakila/pgschema.sql +++ b/testdata/dump/sakila/pgschema.sql @@ -287,13 +287,12 @@ CREATE TABLE IF NOT EXISTS film_category ( -- CREATE TABLE IF NOT EXISTS payment ( - payment_id integer DEFAULT nextval('payment_payment_id_seq'::regclass), + payment_id integer DEFAULT nextval('payment_payment_id_seq'::regclass) NOT NULL, customer_id integer NOT NULL, staff_id integer NOT NULL, rental_id integer NOT NULL, amount numeric(5,2) NOT NULL, - payment_date timestamptz, - CONSTRAINT payment_pkey PRIMARY KEY (payment_date, payment_id) + payment_date timestamptz NOT NULL ) PARTITION BY RANGE (payment_date); -- @@ -433,63 +432,6 @@ CREATE TABLE IF NOT EXISTS payment_p2022_01 PARTITION OF payment ( CONSTRAINT payment_p2022_01_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_01_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-01-01 00:00:00+00') TO ('2022-02-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'payment_p2022_01'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' - AND parent_index.relname = E'payment_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_01_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_01_customer_id; Type: INDEX; Schema: -; Owner: - @@ -518,63 +460,6 @@ CREATE TABLE IF NOT EXISTS payment_p2022_02 PARTITION OF payment ( CONSTRAINT payment_p2022_02_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_02_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-02-01 00:00:00+00') TO ('2022-03-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'payment_p2022_02'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' - AND parent_index.relname = E'payment_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_02_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_02_customer_id; Type: INDEX; Schema: -; Owner: - @@ -603,63 +488,6 @@ CREATE TABLE IF NOT EXISTS payment_p2022_03 PARTITION OF payment ( CONSTRAINT payment_p2022_03_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_03_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-03-01 00:00:00+00') TO ('2022-04-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'payment_p2022_03'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' - AND parent_index.relname = E'payment_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_03_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_03_customer_id; Type: INDEX; Schema: -; Owner: - @@ -688,63 +516,6 @@ CREATE TABLE IF NOT EXISTS payment_p2022_04 PARTITION OF payment ( CONSTRAINT payment_p2022_04_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_04_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-04-01 00:00:00+00') TO ('2022-05-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'payment_p2022_04'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' - AND parent_index.relname = E'payment_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_04_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_04_customer_id; Type: INDEX; Schema: -; Owner: - @@ -773,63 +544,6 @@ CREATE TABLE IF NOT EXISTS payment_p2022_05 PARTITION OF payment ( CONSTRAINT payment_p2022_05_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_05_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-05-01 00:00:00+00') TO ('2022-06-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'payment_p2022_05'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' - AND parent_index.relname = E'payment_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_05_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_05_customer_id; Type: INDEX; Schema: -; Owner: - @@ -858,63 +572,6 @@ CREATE TABLE IF NOT EXISTS payment_p2022_06 PARTITION OF payment ( CONSTRAINT payment_p2022_06_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_06_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-06-01 00:00:00+00') TO ('2022-07-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'payment_p2022_06'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' - AND parent_index.relname = E'payment_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_06_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_06_customer_id; Type: INDEX; Schema: -; Owner: - @@ -943,63 +600,6 @@ CREATE TABLE IF NOT EXISTS payment_p2022_07 PARTITION OF payment ( CONSTRAINT payment_p2022_07_rental_id_fkey FOREIGN KEY (rental_id) REFERENCES rental (rental_id), CONSTRAINT payment_p2022_07_staff_id_fkey FOREIGN KEY (staff_id) REFERENCES staff (staff_id) ) FOR VALUES FROM ('2022-07-01 00:00:00+00') TO ('2022-08-01 00:00:00+00'); -DO $pgschema_restore_0$ -DECLARE - index_ids pg_catalog.oid[] := ARRAY[]::pg_catalog.oid[]; - desired_names pg_catalog.text[] := ARRAY[]::pg_catalog.text[]; - found_id pg_catalog.oid; - current_name pg_catalog.text; - namespace_name pg_catalog.text; - namespace_id pg_catalog.oid; - table_id pg_catalog.oid; - temporary_name pg_catalog.text; - position pg_catalog.int4; - suffix pg_catalog.int4; -BEGIN - SELECT c.conindid INTO STRICT found_id - FROM pg_catalog.pg_constraint c - JOIN pg_catalog.pg_constraint pc ON pc.oid = c.conparentid - JOIN pg_catalog.pg_class parent_index ON parent_index.oid = pc.conindid - JOIN pg_catalog.pg_inherits inh ON inh.inhrelid = c.conindid AND inh.inhparent = pc.conindid - WHERE c.conrelid = E'payment_p2022_07'::pg_catalog.regclass AND c.contype = E'p' - AND pc.conrelid = E'payment'::pg_catalog.regclass AND pc.conname = E'payment_pkey' - AND parent_index.relname = E'payment_pkey'; - index_ids := pg_catalog.array_append(index_ids, found_id); - desired_names := pg_catalog.array_append(desired_names, E'payment_p2022_07_pkey'); - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname, n.oid, i.indrelid - INTO STRICT current_name, namespace_name, namespace_id, table_id - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - suffix := 0; - LOOP - temporary_name := pg_catalog.format('pgschema_inherited_%s_%s', index_ids[position], suffix); - EXIT WHEN NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_class - WHERE relnamespace = namespace_id AND relname = temporary_name - ) AND NOT EXISTS ( - SELECT 1 FROM pg_catalog.pg_constraint - WHERE conrelid = table_id AND conname = temporary_name - ) AND temporary_name <> ALL(desired_names); - suffix := suffix + 1; - END LOOP; - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, temporary_name); - END IF; - END LOOP; - FOR position IN 1..pg_catalog.array_length(index_ids, 1) LOOP - SELECT c.relname, n.nspname INTO STRICT current_name, namespace_name - FROM pg_catalog.pg_class c - JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace - WHERE c.oid = index_ids[position]; - IF current_name <> desired_names[position] THEN - EXECUTE pg_catalog.format('ALTER INDEX %I.%I RENAME TO %I', namespace_name, current_name, desired_names[position]); - END IF; - END LOOP; -END -$pgschema_restore_0$; -- -- Name: idx_fk_payment_p2022_07_customer_id; Type: INDEX; Schema: -; Owner: - @@ -1019,6 +619,96 @@ CREATE INDEX IF NOT EXISTS idx_fk_payment_p2022_07_staff_id ON payment_p2022_07 CREATE INDEX IF NOT EXISTS payment_p2022_07_customer_id_idx ON payment_p2022_07 (customer_id); +-- +-- Name: payment_p2022_01_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY payment_p2022_01 ADD CONSTRAINT payment_p2022_01_pkey PRIMARY KEY (payment_date, payment_id); + +-- +-- Name: payment_p2022_02_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY payment_p2022_02 ADD CONSTRAINT payment_p2022_02_pkey PRIMARY KEY (payment_date, payment_id); + +-- +-- Name: payment_p2022_03_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY payment_p2022_03 ADD CONSTRAINT payment_p2022_03_pkey PRIMARY KEY (payment_date, payment_id); + +-- +-- Name: payment_p2022_04_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY payment_p2022_04 ADD CONSTRAINT payment_p2022_04_pkey PRIMARY KEY (payment_date, payment_id); + +-- +-- Name: payment_p2022_05_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY payment_p2022_05 ADD CONSTRAINT payment_p2022_05_pkey PRIMARY KEY (payment_date, payment_id); + +-- +-- Name: payment_p2022_06_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY payment_p2022_06 ADD CONSTRAINT payment_p2022_06_pkey PRIMARY KEY (payment_date, payment_id); + +-- +-- Name: payment_p2022_07_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY payment_p2022_07 ADD CONSTRAINT payment_p2022_07_pkey PRIMARY KEY (payment_date, payment_id); + +-- +-- Name: payment_pkey; Type: CONSTRAINT; Schema: -; Owner: - +-- + +ALTER TABLE ONLY payment ADD CONSTRAINT payment_pkey PRIMARY KEY (payment_date, payment_id); + +-- +-- Name: payment_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX payment_pkey ATTACH PARTITION payment_p2022_01_pkey; + +-- +-- Name: payment_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX payment_pkey ATTACH PARTITION payment_p2022_02_pkey; + +-- +-- Name: payment_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX payment_pkey ATTACH PARTITION payment_p2022_03_pkey; + +-- +-- Name: payment_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX payment_pkey ATTACH PARTITION payment_p2022_04_pkey; + +-- +-- Name: payment_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX payment_pkey ATTACH PARTITION payment_p2022_05_pkey; + +-- +-- Name: payment_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX payment_pkey ATTACH PARTITION payment_p2022_06_pkey; + +-- +-- Name: payment_pkey; Type: INDEX; Schema: -; Owner: - +-- + +ALTER INDEX payment_pkey ATTACH PARTITION payment_p2022_07_pkey; + -- -- Name: _group_concat(text, text); Type: FUNCTION; Schema: -; Owner: - --