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/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..e1a8d9d1 --- /dev/null +++ b/cmd/inherited_constraint_index_collision_test.go @@ -0,0 +1,82 @@ +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() + // 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 { + 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..e98ddd6e --- /dev/null +++ b/cmd/inherited_constraint_index_test.go @@ -0,0 +1,267 @@ +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 }{ + {"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);`}, + {"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) { + 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) + ";") + 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) + } + 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 { + 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 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") && !strings.HasPrefix(step.SQL, "ALTER TABLE ONLY") { + continue + } + original := step.SQL + 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. + 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 + 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 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) + } 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,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 + 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/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/docs/cli/dump.mdx b/docs/cli/dump.mdx index e214d164..dea094cb 100644 --- a/docs/cli/dump.mdx +++ b/docs/cli/dump.mdx @@ -263,6 +263,26 @@ 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 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 fdba0061..abfc7305 100644 --- a/docs/cli/plan.mdx +++ b/docs/cli/plan.mdx @@ -28,6 +28,35 @@ 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 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 + +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/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 new file mode 100644 index 00000000..c0d11bd0 --- /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 || p.constraintTables[table.Schema+"."+table.Name] { + 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..aaaba70b 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) } @@ -106,9 +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 + 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 { @@ -116,6 +133,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 +184,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..7cd4458d 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 != "" { @@ -577,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) @@ -592,6 +596,7 @@ func generateCreateTablesSQL( createdTables[fmt.Sprintf("%s.%s", table.Schema, table.Name)] = true } + partitionIndexes.generateExplicitConstraintsSQL(tables, targetSchema, collector) return deferredPolicies, deferredConstraints } @@ -987,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) || @@ -1031,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())) } @@ -1052,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) || @@ -1969,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(" ") @@ -1985,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/internal/postgres/desired_state.go b/internal/postgres/desired_state.go index f9d04a56..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 { - if schemaName == "" || !strings.Contains(sql, schemaName) { - return sql +func stripSchemaQualifications(sql string, schemaName string) (string, error) { + if schemaName == "" { + return sql, nil } // Split SQL into dollar-quoted and non-dollar-quoted segments. @@ -109,113 +109,25 @@ 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) + if err := unicodeTokenError(tokens); err != nil { + return "", err } - 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)) + 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].trivia + tokens[significant[j+1]].text = "" + j += 2 } - 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++ + var result strings.Builder + result.Grow(len(sql)) + for _, token := range tokens { + result.WriteString(token.text) } - // Remaining text is code - flushCode(i) - return result.String() + return result.String(), nil } // dollarQuotedSegment represents a segment of SQL text, either inside or outside a dollar-quoted block. @@ -234,43 +146,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,65 +202,105 @@ 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 +// 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 } -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 { +// 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, 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) && (tokenKeyword(tokens[indexes[k]]) == word || word == "." && tokens[indexes[k]].text == ".") + } + for j, i := range indexes { + if tokenKeyword(tokens[i]) != "create" { + continue + } + k := j + 1 + for k < len(indexes) { + word := tokenKeyword(tokens[indexes[k]]) + if word != "or" && word != "replace" && word != "unlogged" && word != "recursive" && word != "materialized" && word != "unique" { + break + } + k++ + } + if k >= len(indexes) { continue } - for _, m := range createdObjectRe.FindAllStringSubmatch(seg.text, -1) { - names[foldIdentifier(m[1])] = true + kind := tokenKeyword(tokens[indexes[k]]) + switch kind { + case "table", "view", "function", "procedure", "aggregate", "type", "domain", "sequence", "index": + default: + continue + } + k++ + 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' || 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 + return names, nil } 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 @@ -376,35 +311,60 @@ 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 { - if schemaName == "" || tempSchema == "" || !strings.Contains(sql, schemaName) { - return sql +func qualifyFunctionBodiesWithTempSchema(sql, schemaName, tempSchema string, objects schemaRemapNames) (string, error) { + if schemaName == "" || tempSchema == "" { + return sql, nil } 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) }) } - 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) + if err := unicodeTokenError(tokens); err != nil { + return "", err + } + significant := significantTokens(tokens) + for j, i := range significant { + token := tokens[i] + if !completeSQLLiteral(token) { continue } - body := seg.text + doPosition := j - 1 + if j >= 3 && tokenKeyword(tokens[significant[j-2]]) == "language" { + doPosition = j - 3 + } + isDO := doPosition >= 0 && tokenKeyword(tokens[significant[doPosition]]) == "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.relations) + if err != nil { + return "", err + } + if remapped != body { + tokens[i].text = encodeSQLLiteral(token, remapped) + } + continue + } + if token.kind != 'd' || j == 0 || tokenKeyword(tokens[significant[j-1]]) != "as" { + continue + } + body := token.text // Preserve the qualifier's quoting so the round trip through // normalizeSchemaNames reproduces the original text. quoted := `"` + tempSchema + `".$1` @@ -413,9 +373,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 + } + var result strings.Builder + for _, token := range tokens { + result.WriteString(token.text) } - return result.String() + 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..172af03d 100644 --- a/internal/postgres/desired_state_test.go +++ b/internal/postgres/desired_state_test.go @@ -274,22 +274,19 @@ 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;", }, } 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) } @@ -529,7 +526,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 +540,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, schemaRemapNames{body: tt.objects, relations: tt.objects}) + if err != nil { + t.Fatal(err) + } if got != tt.expected { t.Errorf("got:\n%s\nexpected:\n%s", got, tt.expected) } @@ -559,14 +559,76 @@ 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) != 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, 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 + } + 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 $$;` + 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) + } + 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 new file mode 100644 index 00000000..d0dd2929 --- /dev/null +++ b/internal/postgres/do_remap_test.go @@ -0,0 +1,112 @@ +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", schemaRemapNames{body: tc.objects, relations: 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, 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, 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) + } +} + +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;` + got, err := stripSchemaQualifications(sql, "public") + if err != nil { + t.Fatal(err) + } + if 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", 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 df5031c3..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 @@ -290,7 +293,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, schemaRemapNames{}) + 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..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,14 @@ 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)) + 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) + } // 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..dcac6693 --- /dev/null +++ b/internal/postgres/sql_lex.go @@ -0,0 +1,284 @@ +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 + unicode bool + content string + trivia string // Whitespace and comments around a consumed UESCAPE clause. + err error +} + +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' && text[i] != '\r' { + 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] == '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 + 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: text[start:i], kind: kind}) + } + 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 + } + return strings.ToLower(t.text) +} + +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) + } + 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.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) + // 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] != '\'' { + 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) + 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++ { + 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 && tokenKeyword(tokens[significant[j-2]]) == "cast" && tokens[significant[j-1]].text == "(" && next < len(significant) && tokenKeyword(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) + } + 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 + for _, t := range tokens { + result.WriteString(t.text) + } + 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 new file mode 100644 index 00000000..fef9eb84 --- /dev/null +++ b/internal/postgres/sql_literal.go @@ -0,0 +1,134 @@ +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.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 + } + 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..f1b4c3e2 --- /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", schemaRemapNames{}) + 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", schemaRemapNames{}) + 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", schemaRemapNames{}) + if err != nil || got != untouched { + t.Fatalf("unchanged body re-encoded: %s, %v", got, err) + } +} 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) + } +} diff --git a/ir/index_test.go b/ir/index_test.go index 2cf1cc01..1012f730 100644 --- a/ir/index_test.go +++ b/ir/index_test.go @@ -16,7 +16,14 @@ 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"}, + }} after, err := json.Marshal(table) if err != nil { t.Fatal(err) diff --git a/ir/ir.go b/ir/ir.go index ea3c8dc6..281445d1 100644 --- a/ir/ir.go +++ b/ir/ir.go @@ -83,6 +83,27 @@ 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 contains catalog identities and definitions of + // inherited PK/UNIQUE constraints, outside the managed schema and fingerprints. + 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 { + Definition string + Comment string + Index IndexRef + ConstraintName string + ConstraintType ConstraintType + ParentIndex IndexRef + ParentConstraint ConstraintRef } // Column represents a table column @@ -246,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 70b8ff0c..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 + 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 @@ -20,6 +21,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 +35,25 @@ 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, 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) 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, + Definition: definition.String, Comment: comment.String, + ParentIndex: parent, + ParentConstraint: ConstraintRef{Schema: parent.Schema, Table: parentTable.String, Name: parentConstraintName.String}, + }) + } if constraint := table.Constraints[constraintName.String]; constraint != nil { constraint.BackingIndex = &child } @@ -47,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 51f3d45d..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 ); -- @@ -38,3 +36,40 @@ CREATE TABLE IF NOT EXISTS session_2026_01 PARTITION OF session FOR VALUES FROM 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'); +-- +-- 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 29eb8592..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); -- @@ -23,6 +22,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'); +-- +-- 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 5ed343f6..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); -- @@ -620,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: - --