diff --git a/pg/review/event_trigger_oracle_test.go b/pg/review/event_trigger_oracle_test.go new file mode 100644 index 00000000..7f539272 --- /dev/null +++ b/pg/review/event_trigger_oracle_test.go @@ -0,0 +1,186 @@ +package review + +import ( + "context" + "slices" + "testing" +) + +// TestEventTriggerEventsAgainstPostgres runs statements on PostgreSQL +// under event triggers that log every event they see, and checks that +// the scan expects each one: a statement that raised ddl_command_end is +// DDL, one that raised sql_drop may drop, and one that raised +// table_rewrite may rewrite. Claiming an event that does not come only +// costs findings; missing one would let a trigger run unseen. +func TestEventTriggerEventsAgainstPostgres(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + conn, err := db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + for _, setup := range []string{ + `CREATE TABLE ev_log (event text, tag text)`, + `CREATE FUNCTION ev_note() RETURNS event_trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO ev_log VALUES (lower(tg_event), tg_tag); END $$`, + `CREATE FOREIGN DATA WRAPPER ev_fdw`, + `CREATE SERVER ev_srv FOREIGN DATA WRAPPER ev_fdw`, + `CREATE EVENT TRIGGER ev_end ON ddl_command_end EXECUTE FUNCTION ev_note()`, + `CREATE EVENT TRIGGER ev_drop ON sql_drop EXECUTE FUNCTION ev_note()`, + `CREATE EVENT TRIGGER ev_rewrite ON table_rewrite EXECUTE FUNCTION ev_note()`, + `CREATE EVENT TRIGGER ev_login ON login EXECUTE FUNCTION ev_note()`, + } { + if _, err := conn.ExecContext(ctx, setup); err != nil { + t.Fatalf("%s: %v", setup, err) + } + } + // Each statement runs after the ones before it, and must succeed. + for _, sql := range []string{ + "CREATE TABLE ev_p (id int PRIMARY KEY, code text, n int DEFAULT 1)", + "CREATE UNIQUE INDEX ev_p_code ON ev_p (code)", + "CREATE TABLE ev_t (id int PRIMARY KEY, p_id int, x int)", + "CREATE VIEW ev_v AS SELECT 1 AS a", + "ALTER TABLE ev_t ADD COLUMN a int", + "ALTER TABLE ev_t ADD COLUMN b int DEFAULT random()::int", + "ALTER TABLE ev_t ADD COLUMN c serial", + "ALTER TABLE ev_t ADD CONSTRAINT ev_fk FOREIGN KEY (p_id) REFERENCES ev_p", + "ALTER TABLE ev_t ADD CONSTRAINT ev_chk CHECK (x > 0)", + "CREATE UNIQUE INDEX ev_x ON ev_t (x)", + "ALTER TABLE ev_t ADD CONSTRAINT ev_x_key UNIQUE USING INDEX ev_x", + "ALTER TABLE ev_t ALTER COLUMN x SET NOT NULL", + "ALTER TABLE ev_t ALTER COLUMN x DROP NOT NULL", + "ALTER TABLE ev_t ALTER COLUMN x SET DEFAULT 2", + "ALTER TABLE ev_t ALTER COLUMN x SET DEFAULT 3, ADD COLUMN d int", + "ALTER TABLE ev_t ALTER COLUMN x DROP DEFAULT", + "ALTER TABLE ev_t ALTER COLUMN a TYPE bigint", + "ALTER TABLE ev_t ALTER COLUMN x SET STATISTICS 100", + "ALTER TABLE ev_t ADD COLUMN e int DEFAULT 5", + "CREATE DOMAIN ev_pos AS int CHECK (VALUE > 0)", + "ALTER TABLE ev_t ADD COLUMN f ev_pos", + "ALTER TABLE ev_t ADD COLUMN g int GENERATED ALWAYS AS (x * 2) STORED", + "ALTER TABLE ev_t ADD COLUMN h int GENERATED ALWAYS AS IDENTITY", + "ALTER TABLE ev_t ADD COLUMN i text NOT NULL, ADD COLUMN j varchar(10) COLLATE \"C\", ADD COLUMN k int[], ADD COLUMN l pg_catalog.int8", + "ALTER TABLE ev_t DISABLE TRIGGER ALL, ENABLE TRIGGER USER", + "ALTER TABLE ev_t SET UNLOGGED", + "ALTER TABLE ev_t SET LOGGED", + "ALTER TABLE ev_t OWNER TO CURRENT_USER", + "ALTER TABLE ev_t VALIDATE CONSTRAINT ev_chk", + "ALTER TABLE ev_t ENABLE ROW LEVEL SECURITY", + "ALTER TABLE ev_t DISABLE ROW LEVEL SECURITY", + "ALTER TABLE ev_t RENAME COLUMN a TO aa", + "ALTER TABLE ev_t RENAME TO ev_t2", + "CREATE SCHEMA ev_s", + "ALTER TABLE ev_t2 SET SCHEMA ev_s", + "ALTER SEQUENCE ev_s.ev_t_c_seq OWNED BY NONE", + "CREATE OR REPLACE VIEW ev_v AS SELECT 1 AS a, 2 AS b", + "COMMENT ON TABLE ev_p IS 'x'", + "GRANT SELECT ON ev_p TO PUBLIC", + "REVOKE SELECT ON ev_p FROM PUBLIC", + "CREATE TYPE ev_mood AS ENUM ('a')", + "ALTER TYPE ev_mood ADD VALUE 'b'", + "CREATE DOMAIN ev_d AS int", + "CREATE SEQUENCE ev_q", + "CREATE FUNCTION ev_f() RETURNS int LANGUAGE sql AS 'SELECT 1'", + "CREATE OR REPLACE FUNCTION ev_f() RETURNS int LANGUAGE sql AS 'SELECT 2'", + "CREATE PROCEDURE ev_pr() LANGUAGE sql AS 'SELECT 1'", + "DROP PROCEDURE ev_pr()", + "CREATE MATERIALIZED VIEW ev_mv AS SELECT 1 AS a", + "ALTER VIEW ev_v RENAME TO ev_v2", + "ALTER VIEW ev_v2 RENAME TO ev_v", + "ALTER INDEX ev_p_code RENAME TO ev_p_code2", + "CREATE FOREIGN TABLE ev_ft (a int) SERVER ev_srv", + "CREATE TABLE ev_c AS SELECT 1 AS a", + "TRUNCATE ev_c", + "SELECT 1 AS a INTO ev_c2", + "CREATE INDEX ON ev_p (n)", + "CREATE SCHEMA ev_s2 CREATE TABLE x (id int UNIQUE) CREATE VIEW y AS SELECT 1 AS a", + "INSERT INTO ev_p VALUES (1, 'a', 1)", + "UPDATE ev_p SET n = 2", + "SELECT * FROM ev_p", + "EXPLAIN SELECT * FROM ev_p", + "SET search_path = public", + "ALTER TABLE ev_s.ev_t2 DROP CONSTRAINT ev_chk", + "ALTER TABLE ev_s.ev_t2 DROP COLUMN aa", + "DROP DOMAIN ev_d", + "DROP VIEW ev_v", + "DROP TABLE ev_c, ev_c2", + "DELETE FROM ev_p", + } { + if _, err := conn.ExecContext(ctx, sql); err != nil { + t.Fatalf("%s: %v", sql, err) + } + rows, err := conn.QueryContext(ctx, `DELETE FROM ev_log RETURNING event, tag`) + if err != nil { + t.Fatal(err) + } + raised := make(map[string]bool) + var tags []string + for rows.Next() { + var event, tag string + if err := rows.Scan(&event, &tag); err != nil { + t.Fatal(err) + } + raised[event] = true + if event == "ddl_command_end" { + tags = append(tags, tag) + } + } + rows.Close() + stmts, finding := parse(sql, statementRanges(sql)) + if finding != nil || len(stmts) != 1 { + t.Fatalf("%s: parse: %v", sql, finding) + } + n := stmts[0].node + // A tag the scan names is the server's. + if tag := commandTag(n); tag != "" && len(tags) > 0 && !slices.Contains(tags, tag) { + t.Errorf("%s: the scan names tag %q, the server %q", sql, tag, tags) + } + if raised["ddl_command_end"] && !isDDL(n) { + t.Errorf("%s raised ddl_command_end, which the scan does not expect", sql) + } + if raised["sql_drop"] && !mayDrop(n) { + t.Errorf("%s raised sql_drop, which the scan does not expect", sql) + } + // enabledTriggers sets login triggers aside. + if raised["login"] { + t.Errorf("%s raised login", sql) + } + if raised["table_rewrite"] && !mayRewrite(n) { + t.Errorf("%s raised table_rewrite, which the scan does not expect", sql) + } + } +} + +// TestEventTriggerClassification pins the statements the scan expects +// not to raise sql_drop or table_rewrite, which +// TestEventTriggerEventsAgainstPostgres runs on the server. +func TestEventTriggerClassification(t *testing.T) { + for _, c := range []struct { + sql string + drops, rewrites bool + }{ + {"ALTER TABLE ev_t ADD COLUMN a int", false, false}, + {`ALTER TABLE ev_t ADD COLUMN i text NOT NULL, ADD COLUMN j varchar(10) COLLATE "C", ADD COLUMN k int[], ADD COLUMN l pg_catalog.int8`, false, false}, + {"ALTER TABLE ev_t ADD CONSTRAINT ev_fk FOREIGN KEY (p_id) REFERENCES ev_p", false, false}, + {"ALTER TABLE ev_t ALTER COLUMN x SET DEFAULT 3, ADD COLUMN d int", false, false}, + {"ALTER TABLE ev_t ALTER COLUMN x DROP DEFAULT", true, false}, + {"ALTER TABLE ev_s.ev_t2 DROP COLUMN aa", true, false}, + {"ALTER TABLE ev_t ADD COLUMN e int DEFAULT 5", false, true}, + {"ALTER TABLE ev_t ADD COLUMN f ev_pos", false, true}, + {"ALTER TABLE ev_t ALTER COLUMN a TYPE bigint", true, true}, + {"ALTER TABLE ev_t SET LOGGED", true, true}, + {"ALTER TABLE ev_t RENAME TO ev_t2", false, false}, + {"ALTER TABLE ev_t DISABLE TRIGGER ALL, ENABLE TRIGGER USER", false, false}, + } { + stmts, finding := parse(c.sql, statementRanges(c.sql)) + if finding != nil || len(stmts) != 1 { + t.Fatalf("%s: parse: %v", c.sql, finding) + } + if got := mayDrop(stmts[0].node); got != c.drops { + t.Errorf("mayDrop(%s) = %v, want %v", c.sql, got, c.drops) + } + if got := mayRewrite(stmts[0].node); got != c.rewrites { + t.Errorf("mayRewrite(%s) = %v, want %v", c.sql, got, c.rewrites) + } + } +} diff --git a/pg/review/review.go b/pg/review/review.go index 40cd813d..d8a63c3e 100644 --- a/pg/review/review.go +++ b/pg/review/review.go @@ -2,10 +2,15 @@ // engine side of the contract in github.com/bytebase/omni/review. // // Review parses the SQL once, evaluates the rules that depend on the SQL -// alone once, and reports every finding against every target. A syntax -// error ends the review: the result then holds that one finding and -// nothing else, because no other rule can be trusted on text the engine -// could not parse. +// alone once, and reports every finding against every target. The rules +// that read a target check the target's synced schema directly, without +// rebuilding it, and a finding equal across targets is reported once, +// listing them all. A syntax error ends the review: the result then holds +// that one finding and nothing else, because no other rule can be trusted +// on text the engine could not parse. +// +// A rule reports only what it can establish. Where the SQL and the +// target's schema leave the answer open, the rule reports nothing. package review import ( @@ -26,6 +31,9 @@ var Rules = []review.Rule{ review.DisallowDropObject, review.DisallowTruncate, review.DisallowRename, + review.PriorBackup, + review.DisallowDropConstraint, + review.RequirePrimaryKey, } // Review evaluates opts.Rules over sql for every target. See the contract @@ -55,6 +63,9 @@ func Review(ctx context.Context, sql string, opts review.Options, targets []revi if on[review.OnlineMigration] { checkOnlineMigration(opts.Change, r) } + if on[review.PriorBackup] && opts.Change.PriorBackup { + checkPriorBackupSize(sql, opts.Change, r) + } for i := range stmts { if err := ctx.Err(); err != nil { return nil, err @@ -76,6 +87,14 @@ func Review(ctx context.Context, sql string, opts review.Options, targets []revi checkDisallowRename(s, r) } } + if backup, scans := targetWork(stmts, on, opts.Change); backup || scans { + findings, failures, err := reviewTargets(ctx, stmts, on, backup, scans, targets) + if err != nil { + return nil, err + } + r.findings = append(r.findings, findings...) + result.Failures = failures + } result.Findings = r.sorted() return result, nil } diff --git a/pg/review/review_test.go b/pg/review/review_test.go index 47579dcd..fb13d08d 100644 --- a/pg/review/review_test.go +++ b/pg/review/review_test.go @@ -155,6 +155,7 @@ func TestReview(t *testing.T) { { name: "require is null looks at predicates only", sql: "SELECT a = NULL AS c, CASE WHEN b = NULL THEN 1 END, CASE d WHEN NULL THEN 1 END, count(*) FILTER (WHERE e = NULL), JSON_ARRAYAGG(x) FILTER (WHERE ee = NULL), (SELECT f = NULL FROM u WHERE g = NULL) FROM t JOIN u ON h = NULL WHERE i IN (SELECT j = NULL FROM v WHERE k = NULL) GROUP BY 1 HAVING l = NULL;\nCREATE TABLE n (x int CHECK (x <> NULL)); CREATE INDEX i ON t (a) WHERE m = NULL; INSERT INTO t VALUES (1) ON CONFLICT (a) WHERE o = NULL DO UPDATE SET a = 1 WHERE p = NULL; UPDATE t SET q = NULL WHERE r = NULL;\nCREATE TABLE d (a boolean DEFAULT (NULL = NULL), b boolean GENERATED ALWAYS AS (NULL = NULL) STORED); CREATE TRIGGER tg BEFORE UPDATE ON t FOR EACH ROW WHEN (NEW.s = NULL) EXECUTE FUNCTION f(); CREATE PUBLICATION pub FOR TABLE t WHERE (u = NULL);\nSELECT 1 WHERE (SELECT v = NULL FROM t LIMIT 1) AND NOT (SELECT w = NULL FROM t) OR (SELECT x = NULL FROM t) IS TRUE OR y = (SELECT z = NULL FROM t) OR EXISTS (SELECT aa = NULL FROM t) OR (SELECT bb = NULL FROM t WHERE cc = NULL) OR COALESCE((SELECT dd = NULL FROM t), false) OR (SELECT ee = NULL FROM t)::boolean;", + opts: review.Options{Rules: []review.Rule{review.RequireIsNull}}, targets: 1, want: func(t *testing.T, sql string) []finding { var out []finding diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go new file mode 100644 index 00000000..f4f1d24c --- /dev/null +++ b/pg/review/rule_disallow_drop_constraint.go @@ -0,0 +1,1021 @@ +package review + +import ( + "slices" + "strings" + + "github.com/bytebase/omni/metadata" + "github.com/bytebase/omni/pg/ast" + "github.com/bytebase/omni/review" +) + +// alterTable follows ALTER TABLE. DisallowDropConstraint reports each DROP +// CONSTRAINT that names a primary key, foreign key, unique, or check +// constraint the table has in the synced schema, when nothing earlier in +// the change may have changed which table the name means or what the +// constraint is. The type comes from the schema, since the statement does +// not say it. A constraint the change itself added is not the schema's, so +// dropping it is not reported. RequirePrimaryKey learns here that a table +// lost its primary key, or gained one. +// +// The server runs every DROP subcommand of the statement before the +// others, so the scan does too, and the statement succeeds or fails as a +// whole, so what the drops find is reported only when every subcommand is +// known to run. A statement the server may refuse ends the scan: what +// follows it is not known. +// +// pg: src/backend/commands/tablecmds.c — ATController (AT_PASS_DROP first) +func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { + if v.Cmds == nil || v.Relation == nil { + return + } + // ALTER of a relation the target certainly lacks is refused, and + // under IF EXISTS does nothing; ALTER TABLE of a relation that is no + // table is refused. + if s.index != nil && s.missing(v.Relation) { + if !v.Missing_ok { + s.stop() + } + return + } + if ast.ObjectType(v.ObjType) == ast.OBJECT_TABLE { + if _, kind, ok := s.lookup(v.Relation); ok && (kind == kindView || kind == kindMatView || kind == kindSequence || kind == kindIndex || kind == kindCompositeType) { + s.stop() + return + } + } + // A table the change created is no longer what its CREATE said; a + // relation it created as another kind takes no ALTER TABLE. + if c, ok := s.madeAt(v.Relation); ok { + if ast.ObjectType(v.ObjType) == ast.OBJECT_TABLE && c.kind != kindTable && c.kind != kindForeignTable || c.def != nil && c.def.refusesAlter(v) { + s.stop() + return + } + c.def = nil + } + isTable := ast.ObjectType(v.ObjType) == ast.OBJECT_TABLE + var drops, others []*ast.AlterTableCmd + for _, item := range v.Cmds.Items { + cmd, ok := item.(*ast.AlterTableCmd) + if !ok { + continue + } + // ALTER TYPE ... CASCADE alters the tables of the type. + if ast.ObjectType(v.ObjType) == ast.OBJECT_TYPE && cmd.Behavior == int(ast.DROP_CASCADE) { + s.stop() + return + } + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_DropConstraint, ast.AT_DropColumn: + drops = append(drops, cmd) + default: + others = append(others, cmd) + } + } + name := v.Relation.Relname + schema := s.schemaOf(v.Relation) + before, knownBefore := s.table(v.Relation) + // What the drops find is kept until every subcommand is known to run: + // a statement the server refuses drops nothing. + var out droppedKeys + withhold := false + // droppedColumns names the dropped columns as the statement does, and + // droppedOrigins as the synced table does, "" for one the scan cannot + // follow there. + droppedColumns := make(map[string]bool) + droppedOrigins := make(map[string]bool) + droppedNames := make(map[string]bool) + // Every drop of the statement sees the table as it was before the + // statement; a column drop leaves it unsettled only after them all. + droppedAColumn := false + // reopened are the pending tables the statement drops the added key of. + var reopened []*pendingTable + for _, cmd := range drops { + cascade := cmd.Behavior == int(ast.DROP_CASCADE) + t, known := before, knownBefore && isTable + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_DropConstraint: + // A second drop of the same constraint fails, and so may a drop + // of one an earlier column drop of the statement took with it. + if droppedNames[cmd.Name] && !cmd.Missing_ok { + s.stop() + return + } + if len(droppedOrigins) > 0 && !cmd.Missing_ok && knownBefore && s.mayDependOn(before, cmd.Name, droppedOrigins) { + withhold = true + } + droppedNames[cmd.Name] = true + s.retireReferences(v.Relation, cmd.Name) + // A constraint the change certainly dropped, or renamed, is not + // there to drop. + if known && !cmd.Missing_ok && (s.dropped[[3]string{t.schema, t.table, cmd.Name}] || s.goneConstraints[[3]string{t.schema, t.table, cmd.Name}]) { + s.stop() + return + } + if synced, ok := s.syncedConstraint(t, cmd.Name); known && ok && s.dropConstraint(st, v, cmd, synced, t, cascade, &out) { + s.stop() + return + } + s.constraintChanged(schema, name, cmd.Name) + if cascade { + s.dropKeyReferences(v.Relation, t, known, cmd.Name) + } + for _, p := range s.pending { + // A key a new foreign key references is not dropped without + // CASCADE. + if p.settled && p.key == cmd.Name && s.namesPending(p, v.Relation) && + (cascade || !s.newlyReferenced(tableRef{p.schema, v.Relation.Relname}, nil, true)) { + reopened = append(reopened, p) + } + } + case ast.AT_DropColumn: + if droppedColumns[cmd.Name] && !cmd.Missing_ok { + s.stop() + return + } + // DROP COLUMN IF EXISTS of a column the table lacks does nothing. + if cmd.Missing_ok && known && !s.hasColumn(t, cmd.Name) && !s.columnRenamed(t, cmd.Name) { + continue + } + droppedColumns[cmd.Name] = true + // Something the change made may depend on the column, which + // refuses the statement without CASCADE. + if !cascade && s.columnsUsed[name] { + s.stop() + return + } + // Dropping a column of the key a table the change made declared + // drops the key, unless something the change made depends on + // the column, which refuses the statement without CASCADE. + for _, p := range s.pending { + if !p.settled || p.key == "" || !slices.Contains(p.keyColumns, cmd.Name) || slices.Contains(reopened, p) || !s.namesPending(p, v.Relation) { + continue + } + own := tableRef{p.schema, v.Relation.Relname} + if !cascade && (s.newlyReferencedColumn(own, cmd.Name, p.keyColumns, true) || s.dependsOnNew(s.newReads, own, nil)) { + s.stop() + return + } + if dropsKeyColumns(v, p) { + reopened = append(reopened, p) + } + } + // What the column is in the synced table. + origin, followed := cmd.Name, known + if known { + origin, followed = s.originOf(t, cmd.Name) + if !followed { + droppedOrigins[""] = true + } else if origin != "" { + droppedOrigins[origin] = true + } + } + if known && s.dropColumn(cmd, t, cascade, &out) { + s.stop() + return + } + droppedAColumn = true + if known && followed && origin != "" { + // The table's own foreign keys on the column go with it. + for _, fk := range s.index.foreignKeys { + if fk.owner == t && slices.ContainsFunc(fk.local, func(c string) bool { return columnName(c) == origin }) { + s.constraintChanged(t.schema, t.table, fk.name) + s.dropped[[3]string{t.schema, t.table, fk.name}] = true + } + } + } + if cascade { + sure := known && followed + s.dropReaders(t, sure, v.Relation, origin) + s.dropReferences(v.Relation, t, known, func(fk foreignKeyRef) (bool, bool) { + if !sure { + return true, false + } + certainly := origin != "" && slices.ContainsFunc(fk.columns, func(c string) bool { return columnName(c) == origin }) + return certainly || slices.ContainsFunc(fk.columns, func(c string) bool { return columnName(c) == "" }), certainly + }) + } + } + } + withhold = withhold || out.uncertain + if knownBefore && isTable { + refused, uncertain := s.refuses(before, droppedColumns, droppedOrigins, droppedNames, others) + // A subcommand certain to fail fails the statement, whatever its + // drops do. + if refused { + s.stop() + return + } + withhold = withhold || uncertain + } + // A statement the server may refuse leaves what follows unknown. + if withhold { + s.stop() + return + } + if droppedAColumn { + s.unsettled[[2]string{schema, name}] = true + for column := range droppedColumns { + s.setColumn(schema, name, column, false, false) + } + } + for _, p := range reopened { + p.settled, p.key = false, "" + } + for _, name := range out.freed { + s.free(schema, name) + } + for _, f := range out.findings { + s.r.add(f) + } + if out.lost != nil { + s.lostKey(st, v, out.lost, out.table) + } + addedHere := make(map[string]bool) + for _, cmd := range others { + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_AddConstraint: + if c, ok := cmd.Def.(*ast.Constraint); ok { + if c.Conname != "" { + s.constraintChanged(schema, name, c.Conname) + } + switch { + case !makesIndex(c): + case c.Conname == "": + s.generate(v.Relation) + default: + // A key's index takes the constraint's name, and a + // partition's a generated one. + s.touchName(v.Relation.Schemaname, c.Conname) + if s.mayHavePartitions(v.Relation) { + s.generate(nil) + } + } + if c.Indexname != "" { + // USING INDEX renames the index to the constraint. + s.touchName(v.Relation.Schemaname, c.Indexname) + s.touchName(v.Relation.Schemaname, c.Conname) + s.renamedKeys[c.Indexname] = true + } + if c.Contype == ast.CONSTR_PRIMARY { + // A key on an index the statement names has the index's + // columns, which the scan does not read. + var columns []string + if c.Indexname == "" { + columns = nameParts(c.Keys) + } + s.keyed(v.Relation, s.keyName(v.Relation, c), columns) + } + if c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE { + s.newKey(v.Relation) + } + s.references(v.Relation, c) + } + case ast.AT_AddColumn: + if cd, ok := cmd.Def.(*ast.ColumnDef); ok { + // ADD COLUMN IF NOT EXISTS of a column the table has, or an + // earlier subcommand added, does nothing, its constraints + // included. + if cmd.Missing_ok && (addedHere[cd.Colname] || knownBefore && !droppedColumns[cd.Colname] && s.hasColumn(before, cd.Colname)) { + continue + } + addedHere[cd.Colname] = true + if columnGeneratesNames(cd) { + s.generate(v.Relation) + } + if s.mayHavePartitions(v.Relation) { + s.generate(nil) + } + // A generated column depends on the columns it reads. + if slices.ContainsFunc(constraintsOf(cd.Constraints), func(c *ast.Constraint) bool { return c.Contype == ast.CONSTR_GENERATED }) || cd.Generated != 0 { + s.unsettled[[2]string{schema, name}] = true + } + s.setColumn(schema, name, cd.Colname, true, false) + for _, c := range constraintsOf(cd.Constraints) { + if c.Conname != "" { + s.constraintChanged(schema, name, c.Conname) + } + if makesIndex(c) && c.Conname != "" { + s.touchName(v.Relation.Schemaname, c.Conname) + } + if c.Contype == ast.CONSTR_PRIMARY { + s.keyed(v.Relation, s.keyName(v.Relation, c), []string{cd.Colname}) + } + if c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE { + s.newKey(v.Relation) + } + } + s.references(v.Relation, constraintsOf(cd.Constraints)...) + } + case ast.AT_AddIdentity: + s.generate(v.Relation) + case ast.AT_AddIndexConstraint, ast.AT_AddIndex: + if idx, ok := cmd.Def.(*ast.IndexStmt); ok { + s.renamedKeys[idx.Idxname] = true + if idx.Primary { + s.keyed(v.Relation, "", nil) + } + if idx.Primary || idx.Unique { + s.newKey(v.Relation) + } + } + case ast.AT_AttachPartition: + if pc, ok := cmd.Def.(*ast.PartitionCmd); ok && pc.Name != nil { + // The partition gets the parent's indexes under generated + // names. + s.generate(pc.Name) + s.unsettle(pc.Name) + for _, p := range s.matching(pc.Name) { + p.settle() + } + } + case ast.AT_DetachPartition: + if pc, ok := cmd.Def.(*ast.PartitionCmd); ok && pc.Name != nil { + s.unsettle(pc.Name) + } + case ast.AT_AddInherit, ast.AT_DropInherit, ast.AT_AddOf, ast.AT_DropOf: + s.unsettled[[2]string{schema, name}] = true + } + } +} + +// droppedKeys is what the drops of one statement found: the findings of +// DisallowDropConstraint, and the subcommand that removed the table's +// primary key, if one did. +type droppedKeys struct { + findings []review.Finding + lost *ast.AlterTableCmd + table tableRef + // uncertain marks a drop the server may refuse. + uncertain bool + // freed lists the relations the drops took with them: a key's index, + // and a dropped column's indexes and owned sequences. + freed []string +} + +// dropConstraint checks a DROP CONSTRAINT against the synced table, which +// lists the constraint as synced. It returns true when the server refuses +// the statement: the constraint does not exist and IF EXISTS is absent, +// or it is a key a foreign key references and CASCADE is absent. +func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.AlterTableCmd, synced string, t tableRef, cascade bool, out *droppedKeys) (refused bool) { + table := s.index.schemas[t.schema].tables[t.table] + con, ok := constraint(table, synced) + if !ok { + // The snapshot leaves out NOT NULL constraints, so a constraint it + // does not list may still exist, or may not, and the statement may + // fail: its findings are withheld. + if !cmd.Missing_ok { + out.uncertain = true + } + return false + } + // A check constraint a table that may be its parent has too, under + // the name and with the expression, may be inherited, which the server + // does not drop from the child. + if con.kind == "check constraint" && s.index.mayInheritCheck(t, synced) { + out.uncertain = true + return false + } + if !cascade && (con.kind == "primary key" || con.kind == "unique constraint") { + // When the key's columns cannot be read, any foreign key on the + // table may reference it, and so may one the change added. + if con.columns != nil && s.index.referenced(t, con.columns, s.dropped) || con.columns == nil && s.index.referencedAny(t, s.dropped) || + s.newlyReferenced(t, con.columns, con.kind == "primary key") { + return true + } + } + s.dropped[[3]string{t.schema, t.table, cmd.Name}] = true + // A key's index has the constraint's name. + if s.index.constraintIndexes[tableRef{t.schema, synced}] { + out.freed = append(out.freed, cmd.Name) + } + if s.on[review.DisallowDropConstraint] && con.kind != "" { + out.findings = append(out.findings, review.Finding{ + Rule: review.DisallowDropConstraint, + Statement: st.index, + Range: rangeOf(cmd.Loc), + Message: "drops " + con.kind + " " + ident(cmd.Name) + " of " + relation(v.Relation), + }) + } + if con.kind == "primary key" && out.lost == nil { + out.lost, out.table = cmd, t + } + return false +} + +// dropColumn checks a DROP COLUMN against the synced table and notes a +// column of its primary key, which goes with the column. It returns true +// when the server refuses the statement: the column does not exist and IF +// EXISTS is absent, or, without CASCADE, something outside the table +// depends on it: a foreign key referencing a key with the column, or a +// view reading it. A foreign key or a view the change created may depend +// on any column of the tables it names. +// +// pg: src/backend/commands/tablecmds.c — ATExecDropColumn +func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out *droppedKeys) (refused bool) { + table := s.index.schemas[t.schema].tables[t.table] + // column is what the dropped column is in the synced table, through + // any rename. + column, ok := s.originOf(t, cmd.Name) + switch { + case !ok: + return false + case column == "": + // A column the change added is not the key's; one it dropped is + // not there. + exists, _ := s.columnNow(t, cmd.Name) + return !exists && !cmd.Missing_ok + } + if !slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == column }) { + return !cmd.Missing_ok + } + defer func() { + if refused { + return + } + // The column's indexes and owned sequences go with it. + for _, i := range table.GetIndexes() { + if slices.Contains(keyColumns(i), column) && !s.isTouched(&ast.RangeVar{Schemaname: t.schema, Relname: i.GetName()}) { + out.freed = append(out.freed, i.GetName()) + } + } + for _, q := range s.index.ownedSequences[columnRef{t.schema, t.table, column}] { + if !s.reowned[[2]string{t.schema, q}] && !s.reowned[[2]string{"", q}] && !s.isTouched(&ast.RangeVar{Schemaname: t.schema, Relname: q}) { + out.freed = append(out.freed, q) + } + } + }() + pk := primaryKey(table) + var pkColumns []string + if pk != nil { + pkColumns = keyColumns(pk) + } + if !cascade && (s.index.referencesColumn(t, column, s.dropped) || generatedFrom(table, column) || slices.ContainsFunc(s.index.readsColumn[columnRef{t.schema, t.table, column}], s.viewHolds) || + s.newlyReferencedColumn(t, column, pkColumns, pk != nil) || s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || s.dependsOnNew(s.newReads, t, nil)) { + return true + } + if pk == nil { + return false + } + if !s.holdsConstraint(t, pk.GetName()) || !slices.Contains(keyColumns(pk), column) { + return false + } + if out.lost == nil { + out.lost, out.table = cmd, t + } + return false +} + +// generatedFrom reports whether another column of the synced table may be +// generated from the column: its generation expression refers to it, or, +// when the scan cannot parse the expression, names it. +func generatedFrom(t *metadata.TableMetadata, column string) bool { + for _, c := range t.GetColumns() { + if c.GetName() == column || c.GetGeneration() == nil { + continue + } + expr := c.GetGeneration().GetExpression() + if refs, ok := expressionColumns(expr); ok { + if slices.Contains(refs, column) { + return true + } + } else if mentions(expr, column) { + return true + } + } + return false +} + +// expressionColumns parses an expression and returns the columns it +// refers to without a qualifier. ok is false when it does not parse. +func expressionColumns(expr string) ([]string, bool) { + sql := "SELECT " + expr + stmts, failed := parse(sql, statementRanges(sql)) + if failed != nil || len(stmts) != 1 { + return nil, false + } + sel, ok := stmts[0].node.(*ast.SelectStmt) + if !ok || sel.TargetList == nil || len(sel.TargetList.Items) != 1 { + return nil, false + } + return columnsIn(sel.TargetList.Items[0]), true +} + +// mentions reports whether an expression may name a column: the name as +// a whole word, or quoted. +func mentions(expr, column string) bool { + if strings.Contains(expr, `"`+strings.ReplaceAll(column, `"`, `""`)+`"`) { + return true + } + word := func(b byte) bool { + return b == '_' || b == '$' || b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= 0x80 + } + for i := 0; ; { + j := strings.Index(expr[i:], column) + if j < 0 { + return false + } + j += i + end := j + len(column) + if (j == 0 || !word(expr[j-1])) && (end == len(expr) || !word(expr[end])) { + return true + } + i = j + 1 + } +} + +// dropReaders records the views a cascading DROP COLUMN takes, with the +// views reading them: for a table the scan resolved (known), the views +// reading the column are certainly dropped and their names freed; the +// other views of the table, or every view reading a table of that name +// otherwise, may be, and their names are touched. +func (s *scan) dropReaders(t tableRef, known bool, rv *ast.RangeVar, column string) { + if s.index == nil { + return + } + var certain, maybe []tableRef + if known { + certain = slices.Clone(s.index.readsColumn[columnRef{t.schema, t.table, column}]) + for _, view := range append(slices.Clone(s.index.readers[t]), s.index.mayRead[t]...) { + if !slices.Contains(certain, view) { + maybe = append(maybe, view) + } + } + } else { + for schema := range s.index.schemas { + if rv.Schemaname == "" || rv.Schemaname == schema { + t := tableRef{schema, rv.Relname} + maybe = append(append(maybe, s.index.readers[t]...), s.index.mayRead[t]...) + } + } + } + s.cascadeViews(certain, maybe) +} + +// dropKeyReferences records the foreign keys a cascading DROP CONSTRAINT +// takes: those on the dropped key when the scan knows the table and the +// key's columns, none when the constraint is not a key, and every one +// referencing the table otherwise. +func (s *scan) dropKeyReferences(rv *ast.RangeVar, t tableRef, known bool, name string) { + if !known { + s.dropReferences(rv, t, false, nil) + return + } + con, ok := constraint(s.index.schemas[t.schema].tables[t.table], name) + switch { + case ok && con.kind != "primary key" && con.kind != "unique constraint" && con.kind != "": + // A foreign key or check constraint has no foreign keys on it. + case ok && con.columns != nil: + s.dropReferences(rv, t, true, func(fk foreignKeyRef) (bool, bool) { + same := sameColumns(fk.columns, con.columns) + return same, same + }) + default: + s.dropReferences(rv, t, true, nil) + } +} + +// mayDependOn reports whether a constraint of the synced table may involve +// one of the columns, so that dropping the column took it: a check or an +// exclusion constraint, whose columns the snapshot does not list, or a +// key or foreign key on one of them. +func (s *scan) mayDependOn(t tableRef, name string, columns map[string]bool) bool { + // A dropped column the scan cannot follow may be any. + if columns[""] { + return true + } + if synced, ok := s.syncedConstraint(t, name); ok { + name = synced + } + table := s.index.schemas[t.schema].tables[t.table] + for _, fk := range table.GetForeignKeys() { + if fk.GetName() == name { + return slices.ContainsFunc(fk.GetColumns(), func(c string) bool { return columns[columnName(c)] || columnName(c) == "" }) + } + } + con, ok := constraint(table, name) + if !ok || con.columns == nil { + return true + } + return slices.ContainsFunc(con.columns, func(c string) bool { return columns[c] }) +} + +// refuses reports whether a subcommand of the statement is certain to +// fail against the synced table, after its drops: adding a column it has, +// altering a column it lacks, adding a constraint under a name it uses, a +// second primary key, or a key or foreign key on a column it lacks, or a +// foreign key the referenced table has no key for. uncertain reports a +// foreign key whose referenced key the scan cannot check. Other failures, +// such as a type error or rows a new constraint rejects, are not known +// before the statement runs. +// +// The server adds every column of the statement before any constraint, +// and every key before any foreign key, whatever their order. +// +// pg: src/backend/commands/tablecmds.c — ATController (AT_PASS_ADD_COL, +// AT_PASS_ADD_INDEX, AT_PASS_ADD_OTHERCONSTR) +func (s *scan) refuses(t tableRef, droppedColumns, droppedOrigins, droppedConstraints map[string]bool, others []*ast.AlterTableCmd) (refused, uncertain bool) { + table := s.index.schemas[t.schema].tables[t.table] + // has reports whether the table has the column once the drops ran. + has := func(column string) bool { + return !droppedColumns[column] && s.hasColumn(t, column) + } + // uses reports whether a constraint name certainly names a constraint + // of the table. + uses := func(name string) bool { + synced, ok := s.syncedConstraint(t, name) + if droppedConstraints[name] || !ok || s.dropped[[3]string{t.schema, t.table, name}] { + return false + } + // A column drop of the statement may have taken the constraint. + if len(droppedOrigins) > 0 && s.mayDependOn(t, name, droppedOrigins) { + return false + } + _, ok = constraint(table, synced) + return ok + } + // holds reports whether the table certainly keeps a constraint of the + // synced table, under whatever name it has now. + holds := func(synced string) bool { + name, ok := s.constraintNow(t, synced) + return ok && uses(name) + } + // The columns and keys the statement adds, and the constraints it adds + // with them. + addedColumns := make(map[string]bool) + var addedKeys [][]string + addsPrimary, addsIndex := false, false + var constraints []*ast.Constraint + for _, cmd := range others { + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_AddColumn: + cd, ok := cmd.Def.(*ast.ColumnDef) + if !ok { + continue + } + if has(cd.Colname) || addedColumns[cd.Colname] { + if cmd.Missing_ok { + // The column exists: the subcommand does nothing. + continue + } + return true, false + } + addedColumns[cd.Colname] = true + for _, c := range constraintsOf(cd.Constraints) { + if c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE { + addedKeys = append(addedKeys, []string{cd.Colname}) + } + addsPrimary = addsPrimary || c.Contype == ast.CONSTR_PRIMARY + constraints = append(constraints, c) + } + case ast.AT_AddConstraint: + c, ok := cmd.Def.(*ast.Constraint) + if !ok { + continue + } + if c.Indexname != "" { + addsIndex = true + } else if c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE { + addedKeys = append(addedKeys, nameParts(c.Keys)) + } + addsPrimary = addsPrimary || c.Contype == ast.CONSTR_PRIMARY + constraints = append(constraints, c) + case ast.AT_AddIndexConstraint, ast.AT_AddIndex: + addsIndex = true + } + } + // ownKey reports whether the table, once the statement ran, has its + // primary key (no columns) or a unique key on exactly the columns, for + // a foreign key referencing its own table. known is false when an + // index the statement or an earlier one adds may be the key. + ownKey := func(columns []string) (found, known bool) { + if addsIndex || droppedOrigins[""] || s.newKeys[[2]string{t.schema, t.table}] || s.newKeys[[2]string{"", t.table}] { + return false, false + } + if len(columns) == 0 { + pk := primaryKey(table) + return addsPrimary || pk != nil && holds(pk.GetName()), true + } + for _, key := range addedKeys { + if sameKey(key, columns) { + return true, true + } + } + for _, i := range table.GetIndexes() { + if !s.isKey(t, i) || droppedConstraints[i.GetName()] { + continue + } + if key := keyColumns(i); !slices.ContainsFunc(key, func(c string) bool { return droppedOrigins[c] }) && sameKey(key, columns) { + return true, true + } + } + return false, true + } + addedNames := make(map[string]bool) + addedKey := false + keyed := func() bool { + pk := primaryKey(table) + return addedKey || pk != nil && holds(pk.GetName()) + } + for _, cmd := range others { + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_ColumnDefault, ast.AT_DropNotNull, ast.AT_SetNotNull, ast.AT_AlterColumnType, + ast.AT_SetStatistics, ast.AT_SetStorage, ast.AT_SetCompression, ast.AT_SetOptions, ast.AT_ResetOptions, + ast.AT_AddIdentity, ast.AT_SetIdentity, ast.AT_DropIdentity, ast.AT_SetExpression, ast.AT_DropExpression: + // A column the statement adds may or may not exist when this + // subcommand's pass runs. + if cmd.Name == "" || addedColumns[cmd.Name] { + continue + } + if !has(cmd.Name) { + return true, false + } + // A column of the primary key the table keeps stays NOT NULL. + if ast.AlterTableType(cmd.Subtype) == ast.AT_DropNotNull { + if pk := primaryKey(table); pk != nil && holds(pk.GetName()) { + if origin, ok := s.originOf(t, cmd.Name); ok && origin != "" && slices.Contains(keyColumns(pk), origin) { + return true, false + } + } + } + } + } + for _, c := range constraints { + // A key or foreign key names columns the table must have, and a + // check names nothing but its columns. + for _, list := range []*ast.List{c.Keys, c.FkAttrs} { + for _, column := range nameParts(list) { + if !has(column) && !addedColumns[column] { + return true, false + } + } + } + if c.Contype == ast.CONSTR_CHECK && slices.ContainsFunc(columnsIn(c.RawExpr), func(column string) bool { return !has(column) && !addedColumns[column] }) { + return true, false + } + // A key names a column once. + if (c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE) && hasDuplicate(nameParts(c.Keys)) { + return true, false + } + if c.Conname != "" { + if uses(c.Conname) || addedNames[c.Conname] { + return true, false + } + addedNames[c.Conname] = true + } + // A key's index is the one USING INDEX names, or one under the + // constraint's name in the table's schema. + switch { + case c.Indexname != "": + r, u := s.refusesIndex(t, c.Indexname, droppedOrigins, droppedConstraints) + if r { + return true, false + } + uncertain = uncertain || u + // The index takes the constraint's name. + if c.Conname != "" && c.Conname != c.Indexname { + taken, known := s.indexNameTaken(t, c.Conname, droppedOrigins, droppedConstraints) + if taken { + return true, false + } + uncertain = uncertain || !known + } + case makesIndex(c) && c.Conname != "": + taken, known := s.indexNameTaken(t, c.Conname, droppedOrigins, droppedConstraints) + if taken { + return true, false + } + uncertain = uncertain || !known + } + if c.Contype == ast.CONSTR_PRIMARY { + if keyed() { + return true, false + } + addedKey = true + } + if c.Contype == ast.CONSTR_FOREIGN { + r, u := s.refusesReference(t, c, func(column string) bool { return has(column) || addedColumns[column] }, ownKey) + if r { + return true, false + } + uncertain = uncertain || u + } + } + return false, uncertain +} + +// refusesReference reports whether the server certainly refuses a foreign +// key the statement adds to t for what it references: a relation the +// target lacks or that is not a table, a referenced column the table +// lacks, a count of referenced columns unlike the referencing ones, or no +// primary key, or no unique key on exactly the columns named. uncertain +// reports a referenced table whose keys the scan cannot tell: one the +// change made or altered, or gave a key or a unique index. own and ownKey +// say whether t has a column and a key once the statement ran, for a +// foreign key referencing its own table. +// +// pg: src/backend/commands/tablecmds.c — ATAddForeignKeyConstraint, +// transformFkeyGetPrimaryKey, transformFkeyCheckAttrs +func (s *scan) refusesReference(t tableRef, c *ast.Constraint, own func(string) bool, ownKey func([]string) (bool, bool)) (refused, uncertain bool) { + if c.Pktable == nil { + return false, true + } + columns := nameParts(c.PkAttrs) + // A foreign key of a column names no referencing columns: it is the one. + local := max(len(nameParts(c.FkAttrs)), 1) + if len(columns) > 0 && len(columns) != local { + return true, false + } + // No key has a column twice, and no foreign key names one twice. + if hasDuplicate(columns) || hasDuplicate(nameParts(c.FkAttrs)) { + return true, false + } + if s.missing(c.Pktable) { + return true, false + } + schema, kind, ok := s.lookup(c.Pktable) + if !ok { + // A table the change created and said all of is checked against + // what its CREATE gave it. + if def, made := s.madeDef(c.Pktable); made { + return def.refusedBy(columns, local), false + } + return false, true + } + ref := tableRef{schema, c.Pktable.Relname} + if ref == t { + if slices.ContainsFunc(columns, func(column string) bool { return !own(column) }) { + return true, false + } + found, known := ownKey(columns) + return known && !found, !known + } + switch kind { + case kindTable: + case kindView, kindMatView, kindForeignTable, kindSequence, kindIndex, kindCompositeType: + return true, false + default: + return false, true + } + if s.isUnsettled(ref) { + return false, true + } + if slices.ContainsFunc(columns, func(column string) bool { return !s.hasColumn(ref, column) }) { + return true, false + } + if s.newKeys[[2]string{ref.schema, ref.table}] || s.newKeys[[2]string{"", ref.table}] { + return false, true + } + table := s.index.schemas[ref.schema].tables[ref.table] + if len(columns) == 0 { + pk := primaryKey(table) + if pk == nil || !s.indexHolds(ref, pk) { + return true, false + } + key := keyColumns(pk) + return key != nil && len(key) != local, false + } + for _, i := range table.GetIndexes() { + if s.isKey(ref, i) && sameKey(keyColumns(i), columns) { + return false, false + } + } + return true, false +} + +// refusesIndex reports whether the server certainly refuses ADD CONSTRAINT +// ... USING INDEX of a synced table's index: no index of the table has +// the name, or the index is a constraint's already, or not unique, or +// partial, or on an expression, or sorted other than ascending, or the +// statement drops it with a column. uncertain reports a name an earlier +// statement touched, or that the snapshot lists twice. +// +// pg: src/backend/parser/parse_utilcmd.c — transformIndexConstraint +func (s *scan) refusesIndex(t tableRef, name string, droppedColumns, droppedConstraints map[string]bool) (refused, uncertain bool) { + if droppedColumns[""] || s.isTouched(&ast.RangeVar{Schemaname: t.schema, Relname: name}) { + return false, true + } + switch kind, ok := s.index.schemas[t.schema].relations[name]; { + case ok && kind == kindAmbiguous: + return false, true + case !ok || kind != kindIndex: + return true, false + } + table := s.index.schemas[t.schema].tables[t.table] + for _, i := range table.GetIndexes() { + if i.GetName() != name { + continue + } + key := keyColumns(i) + return droppedConstraints[name] || s.index.constraintIndexes[tableRef{t.schema, name}] || !i.GetUnique() || key == nil || + strings.Contains(strings.ToUpper(i.GetDefinition()), " WHERE ") || slices.Contains(i.GetDescending(), true) || + slices.ContainsFunc(key, func(c string) bool { return droppedColumns[c] }), false + } + return true, false +} + +// indexNameTaken reports whether the index of a key the statement adds +// under a name cannot take it: a relation of the table's schema has it, +// other than an index the statement drops with its constraint or its +// column. known is false when an earlier statement touched the name, or +// the statement may drop the index of that name. +func (s *scan) indexNameTaken(t tableRef, name string, droppedColumns, droppedConstraints map[string]bool) (taken, known bool) { + rv := &ast.RangeVar{Schemaname: t.schema, Relname: name} + switch { + case s.freed[[2]string{t.schema, name}]: + return false, true + case s.isTouched(rv) || droppedColumns[""]: + return false, false + } + if _, ok := s.index.schemas[t.schema].relations[name]; !ok || droppedConstraints[name] { + return false, true + } + for _, i := range s.index.schemas[t.schema].tables[t.table].GetIndexes() { + if i.GetName() != name || len(droppedColumns) == 0 { + continue + } + key := keyColumns(i) + if key == nil { + return false, false + } + if slices.ContainsFunc(key, func(c string) bool { return droppedColumns[c] }) { + return false, true + } + } + return true, true +} + +// columnsIn returns the column names an expression refers to without a +// qualifier. +func columnsIn(expr ast.Node) []string { + var out []string + ast.Inspect(expr, func(n ast.Node) bool { + if ref, ok := n.(*ast.ColumnRef); ok && ref.Fields != nil && len(ref.Fields.Items) == 1 { + if name, ok := ref.Fields.Items[0].(*ast.String); ok { + out = append(out, name.Str) + } + } + return true + }) + return out +} + +// hasDuplicate reports whether a list names something twice. +func hasDuplicate(names []string) bool { + for i, name := range names { + if slices.Contains(names[:i], name) { + return true + } + } + return false +} + +// isKey reports whether a synced table's index is a key a foreign key can +// reference, still there: a primary key or a unique index that is not +// partial. +func (s *scan) isKey(t tableRef, i *metadata.IndexMetadata) bool { + return (i.GetUnique() || i.GetPrimary()) && s.indexHolds(t, i) && !strings.Contains(strings.ToUpper(i.GetDefinition()), " WHERE ") +} + +// sameKey reports whether a key's columns, nil when unknown, are exactly +// the columns, in any order. +func sameKey(key, columns []string) bool { + return key != nil && len(key) == len(columns) && !slices.ContainsFunc(columns, func(column string) bool { return !slices.Contains(key, column) }) +} + +// indexHolds reports whether a synced table's index is still there as +// synced: no statement dropped its constraint, or changed or reused its +// name. +func (s *scan) indexHolds(t tableRef, i *metadata.IndexMetadata) bool { + return !s.dropped[[3]string{t.schema, t.table, i.GetName()}] && s.constraintKnown(t, i.GetName()) && + !s.isTouched(&ast.RangeVar{Schemaname: t.schema, Relname: i.GetName()}) +} + +// hasColumn reports whether the synced table has the column after the +// statements so far: as the last statement that added, dropped, or renamed +// it left it, or as synced. +func (s *scan) hasColumn(t tableRef, column string) bool { + if exists, changed := s.columnNow(t, column); changed { + return exists + } + table := s.index.schemas[t.schema].tables[t.table] + return slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == column }) +} + +// makesIndex reports whether a constraint is backed by an index. +func makesIndex(c *ast.Constraint) bool { + return c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE || c.Contype == ast.CONSTR_EXCLUSION +} + +func constraintsOf(list *ast.List) []*ast.Constraint { + if list == nil { + return nil + } + var out []*ast.Constraint + for _, item := range list.Items { + if c, ok := item.(*ast.Constraint); ok { + out = append(out, c) + } + } + return out +} diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go new file mode 100644 index 00000000..fe50d9fe --- /dev/null +++ b/pg/review/rule_prior_backup.go @@ -0,0 +1,362 @@ +package review + +import ( + "fmt" + "strings" + + "github.com/bytebase/omni/metadata" + "github.com/bytebase/omni/pg/ast" + "github.com/bytebase/omni/review" +) + +// PriorBackup reports what makes Bytebase's prior backup of a PostgreSQL +// change fail, and only that: a failed backup fails the task before any +// of the change runs. The backup works like this (bytebase +// backend/runner/taskrun/database_migrate_executor.go, backupData, and +// backend/plugin/parser/pg/backup.go, TransformDMLToSelect): +// +// - It runs before the change, on the schema Bytebase last synced. +// - It refuses a change longer than the size limit. +// - It copies the rows each top-level UPDATE and DELETE would touch, +// one table at a time, into a table it creates in the bbdataarchive +// schema of the same database, which it does not create. A change +// with no top-level UPDATE or DELETE backs up nothing. +// - It names the table each statement touches as written when the SQL +// qualifies it, and otherwise looks it up in the synced schema along +// the synced search path, or along the last SET search_path before +// the statement; a name it cannot find fails the backup. +// - It refuses a table touched by both UPDATE and DELETE. +// +// DDL in the change is ignored by the backup, so it is not a finding by +// itself. It matters when an UPDATE or DELETE touches a table that only +// the change creates: the backup reads that table before the change has +// created it. +// +// A name the synced schema cannot resolve, and the change does not +// create, is not reported. The schema the review sees may predate an +// earlier spec of the same plan, which may create the table; Bytebase +// syncs again before this spec's backup, which then finds it. A name no +// spec creates fails the UPDATE or DELETE itself, which is WalkThrough's +// to report. + +// backupSchema is where Bytebase writes a PostgreSQL prior backup. +const backupSchema = "bbdataarchive" + +// checkPriorBackupSize reports a change longer than prior backup takes. +// It needs the SQL alone. +func checkPriorBackupSize(sql string, change review.Change, r *reporter) { + if change.MaxBackupSize > 0 && len(sql) > change.MaxBackupSize { + r.report(review.PriorBackup, -1, review.Range{}, fmt.Sprintf("the change is %d bytes, over the %d-byte limit of prior backup", len(sql), change.MaxBackupSize)) + } +} + +// backupTable is a table as the backup keys it: schema and name. +type backupTable struct { + schema string + name string +} + +// checkPriorBackupTarget reports the target-dependent reasons the backup +// fails. Without a schema nothing is known about the target, and nothing +// is reported. +func checkPriorBackupTarget(stmts []statement, schema *metadata.DatabaseSchemaMetadata, r *reporter) { + if schema == nil { + return + } + path := backupSearchPath(schema.GetSearchPath()) + // kinds maps each table to the statement kind the backup saw first, + // and mixed marks the tables already reported. + kinds := make(map[backupTable]string) + mixed := make(map[backupTable]bool) + // created lists the relations the change creates, by name as written: + // an unqualified one under an empty schema. + // The backup runs before the whole change, so a relation the change + // creates anywhere in it is not there yet. + created := make(map[backupTable]bool) + createdNames := make(map[string]bool) + for i := range stmts { + for _, t := range createdRelations(stmts[i].node) { + created[t] = true + createdNames[t.name] = true + } + } + backsUp := false + for i := range stmts { + s := &stmts[i] + var rv *ast.RangeVar + var kind string + switch v := s.node.(type) { + case *ast.UpdateStmt: + rv, kind = v.Relation, "UPDATE" + case *ast.DeleteStmt: + rv, kind = v.Relation, "DELETE" + case *ast.VariableSetStmt: + if p, ok := backupSetSearchPath(v); ok { + path = p + } + continue + default: + continue + } + if rv == nil { + continue + } + backsUp = true + table := backupTable{schema: rv.Schemaname, name: rv.Relname} + if rv.Schemaname == "" { + table.schema = searchBackupRelation(schema, path, rv.Relname) + if table.schema == "" { + // The backup cannot find the table. Report it when the + // change creates it: the table then exists only after the + // backup has run, whatever the target's schema says later. + if createdNames[rv.Relname] { + reportNotYetCreated(s, rv, r) + } + continue + } + } else if (rv.Catalogname == "" || rv.Catalogname == schema.GetName()) && (created[table] || created[backupTable{name: rv.Relname}]) && !hasBackupRelation(findSchema(schema, rv.Schemaname), rv.Relname) { + // An unqualified creation may be in this schema; either way + // the table is not there when the backup runs. + reportNotYetCreated(s, rv, r) + } + first, seen := kinds[table] + if !seen { + kinds[table] = kind + continue + } + if first != kind && !mixed[table] { + mixed[table] = true + r.report(review.PriorBackup, s.index, rangeOf(statementLoc(s.node)), "prior backup cannot back up both UPDATE and DELETE on "+relation(rv)) + } + } + if backsUp && findSchema(schema, backupSchema) == nil { + r.report(review.PriorBackup, -1, review.Range{}, "prior backup needs schema "+backupSchema+", which does not exist") + } +} + +func reportNotYetCreated(s *statement, rv *ast.RangeVar, r *reporter) { + r.report(review.PriorBackup, s.index, rangeOf(statementLoc(s.node)), "prior backup runs before the change, when "+relation(rv)+" does not exist yet") +} + +func statementLoc(n ast.Node) ast.Loc { + switch v := n.(type) { + case *ast.UpdateStmt: + return v.Loc + case *ast.DeleteStmt: + return v.Loc + } + return ast.NoLoc() +} + +// createdRelations returns the relations a statement creates, renames, or +// moves to a new name, as written, the tables and views of CREATE SCHEMA +// in the new schema. +func createdRelations(n ast.Node) []backupTable { + if v, ok := n.(*ast.CreateSchemaStmt); ok { + name := v.Schemaname + if name == "" && v.Authrole != nil { + // CREATE SCHEMA AUTHORIZATION role names the schema after the role. + name = v.Authrole.Rolename + } + if name == "" || v.SchemaElts == nil { + return nil + } + var out []backupTable + for _, elt := range v.SchemaElts.Items { + for _, t := range createdRelations(elt) { + if t.schema == "" { + t.schema = name + } + out = append(out, t) + } + } + return out + } + // EXPLAIN ANALYZE runs the statement it explains. + if v, ok := n.(*ast.ExplainStmt); ok { + if explainAnalyzes(v) { + return createdRelations(v.Query) + } + return nil + } + if t, ok := createdRelation(n); ok { + return []backupTable{t} + } + return nil +} + +// createdRelation returns the relation a statement creates, renames, or +// moves to a new name, as written. A statement that may create nothing, +// IF NOT EXISTS or OR REPLACE, is left out: what it does depends on what +// already exists. +func createdRelation(n ast.Node) (backupTable, bool) { + var rv *ast.RangeVar + switch v := n.(type) { + case *ast.CreateStmt: + if !v.IfNotExists { + rv = v.Relation + } + case *ast.CreateForeignTableStmt: + if !v.Base.IfNotExists { + rv = v.Base.Relation + } + case *ast.CreateTableAsStmt: + if !v.IfNotExists && v.Into != nil { + rv = v.Into.Rel + } + case *ast.SelectStmt: + // INTO may sit on a branch of a set operation. + if into := intoOf(v); into != nil { + rv = into.Rel + } + case *ast.ViewStmt: + if !v.Replace { + rv = v.View + } + case *ast.RenameStmt: + switch v.RenameType { + case ast.OBJECT_TABLE, ast.OBJECT_FOREIGN_TABLE, ast.OBJECT_VIEW, ast.OBJECT_MATVIEW: + if v.Relation != nil { + return backupTable{schema: v.Relation.Schemaname, name: v.Newname}, true + } + } + case *ast.AlterObjectSchemaStmt: + switch v.ObjectType { + case ast.OBJECT_TABLE, ast.OBJECT_FOREIGN_TABLE, ast.OBJECT_VIEW, ast.OBJECT_MATVIEW: + if v.Relation != nil { + return backupTable{schema: v.Newschema, name: v.Relation.Relname}, true + } + } + } + if rv == nil { + return backupTable{}, false + } + return backupTable{schema: rv.Schemaname, name: rv.Relname}, true +} + +// intoOf returns the INTO clause of a SELECT or of a branch of its set +// operation. +func intoOf(s *ast.SelectStmt) *ast.IntoClause { + if s == nil { + return nil + } + if s.IntoClause != nil { + return s.IntoClause + } + if into := intoOf(s.Larg); into != nil { + return into + } + return intoOf(s.Rarg) +} + +// backupSearchPath is the synced search path as the backup reads it: the +// listed schemas without "$user" and the system schemas. An empty path +// is searched as public. +// +// bytebase: backend/store/model/pg_search_path.go — ResolvePGSearchPath +func backupSearchPath(setting string) []string { + var path []string + for _, name := range parseSearchPath(setting) { + if name == "" || name == "$user" || isSystemSchema(name) { + continue + } + path = append(path, name) + } + return path +} + +func isSystemSchema(name string) bool { + for _, s := range []string{"pg_catalog", "information_schema", "pg_toast", "pg_temp_1", "pg_temp_2", "pg_global"} { + if strings.EqualFold(name, s) { + return true + } + } + return false +} + +// backupSetSearchPath reads SET search_path as the backup does: every +// string value, as written, replaces the path. A SET with no string value +// changes nothing. +// +// bytebase: backend/plugin/parser/pg/backup.go — prepareTransformation +func backupSetSearchPath(v *ast.VariableSetStmt) ([]string, bool) { + if !strings.EqualFold(v.Name, "search_path") || v.Args == nil { + return nil, false + } + var path []string + for _, item := range v.Args.Items { + if s, ok := constString(item); ok { + path = append(path, s) + } + } + return path, len(path) > 0 +} + +// searchBackupRelation returns the first schema on the path holding a +// relation of that name, or "". +// +// bytebase: backend/store/model/pg_searcher.go — SearchRelation +func searchBackupRelation(schema *metadata.DatabaseSchemaMetadata, path []string, name string) string { + if len(path) == 0 { + path = []string{"public"} + } + for _, p := range path { + if hasBackupRelation(findSchema(schema, p), name) { + return p + } + } + return "" +} + +func findSchema(schema *metadata.DatabaseSchemaMetadata, name string) *metadata.SchemaMetadata { + for _, s := range schema.GetSchemas() { + if s.GetName() == name { + return s + } + } + return nil +} + +// hasBackupRelation reports whether the schema holds a relation of that +// name the backup's lookup finds: a table or any of its partitions, a +// view, a materialized view, an external table, or a sequence. +func hasBackupRelation(s *metadata.SchemaMetadata, name string) bool { + if s == nil { + return false + } + for _, t := range s.GetTables() { + if t.GetName() == name || hasPartition(t.GetPartitions(), name) { + return true + } + } + for _, v := range s.GetViews() { + if v.GetName() == name { + return true + } + } + for _, v := range s.GetMaterializedViews() { + if v.GetName() == name { + return true + } + } + for _, t := range s.GetExternalTables() { + if t.GetName() == name { + return true + } + } + for _, q := range s.GetSequences() { + if q.GetName() == name { + return true + } + } + return false +} + +func hasPartition(partitions []*metadata.TablePartitionMetadata, name string) bool { + for _, p := range partitions { + if p.GetName() == name || hasPartition(p.GetSubpartitions(), name) { + return true + } + } + return false +} diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go new file mode 100644 index 00000000..cdd4de6e --- /dev/null +++ b/pg/review/rule_require_primary_key.go @@ -0,0 +1,596 @@ +package review + +import ( + "slices" + "strings" + + "github.com/bytebase/omni/pg/ast" + "github.com/bytebase/omni/review" +) + +// RequirePrimaryKey reports two things, each only when the change ends +// that way: a table a CREATE TABLE made without a primary key, and a table +// that had a primary key in the synced schema and lost it. A table that had +// none before the change is not reported, and neither is a table made by +// CREATE TABLE AS or SELECT INTO, a partition, whose keys come from its +// parent, or a temporary table. +// +// Without simulating the change, the rule follows the table by name. It is +// settled, and not reported, by any later statement that may give it a +// key, drop it, or make it a partition, under any name it may have by +// then; every other statement leaves a table's lack of a key as it is. + +// pendingTable is a table without a primary key that the rule reports at +// the end of the change unless a later statement settles it. +type pendingTable struct { + // names are the names the table may have, schema the schema it is in, + // or "" when it may be in any. + names map[string]bool + schema string + // existed marks a table of the synced schema, and parents names the + // tables one made with INHERITS inherits from, in the schema each + // resolved to or was written in, under every name a rename or a move + // may have given it: a cascading drop of a parent may drop either. + existed bool + parents []tableRef + + // made numbers the touch that created a table the change made. + made int + // key names the primary key a statement of the change added, or the + // CREATE gave, that settles the table, when that is all that settles + // it; dropping it leaves the table keyless again. keyColumns are the + // key's columns, while no statement but one that only drops named the + // table since: dropping one drops the key. + key string + keyColumns []string + + statement int + rng review.Range + message string + settled bool +} + +// inheritsFrom names the tables CREATE TABLE ... INHERITS lists. +func (s *scan) inheritsFrom(v *ast.CreateStmt) []tableRef { + if v.InhRelations == nil { + return nil + } + var parents []tableRef + for _, item := range v.InhRelations.Items { + if rv, ok := item.(*ast.RangeVar); ok { + parents = append(parents, tableRef{s.schemaOf(rv), rv.Relname}) + } + } + return parents +} + +// mayBe reports whether two relations, each in a schema or "" when that is +// not known, may be one. +func mayBe(a, b tableRef) bool { + return a.table == b.table && (a.schema == "" || b.schema == "" || a.schema == b.schema) +} + +// followParents records that a parent a statement renamed or moved keeps +// its children under its new schema and name. +func (s *scan) followParents(rv *ast.RangeVar, to tableRef) { + from := tableRef{s.schemaOf(rv), rv.Relname} + for _, p := range s.pending { + if slices.ContainsFunc(p.parents, func(t tableRef) bool { return mayBe(t, from) }) { + p.parents = append(p.parents, to) + } + } +} + +// matching returns the pending tables a name may refer to: any with that +// name, unless both the name and the table are in known, different +// schemas. +func (s *scan) matching(rv *ast.RangeVar) []*pendingTable { + var out []*pendingTable + for _, p := range s.pending { + if !p.names[rv.Relname] || p.schema != "" && rv.Schemaname != "" && rv.Schemaname != p.schema { + continue + } + if rv.Schemaname == "" && p.schema != "" && s.resolvesBefore(rv.Relname, p.schema) { + continue + } + out = append(out, p) + } + return out +} + +// resolvesBefore reports whether an unqualified name certainly means a +// relation of a schema the search path puts before the given one: a synced +// one, or one the change made. +func (s *scan) resolvesBefore(name, schema string) bool { + if s.index == nil || strings.HasPrefix(name, "pg_") { + return false + } + path, ok := s.searchPath() + if !ok { + return false + } + for _, sch := range path { + if sch == schema || sch == "information_schema" { + return false + } + // A relation the change certainly made there, in a schema it did + // not drop, is found first. + if c, ok := s.created[[2]string{sch, name}]; ok && s.lastTouch[name] == c.made && !s.schemaGone[sch] { + return true + } + if s.schemas[sch] || s.isTouched(&ast.RangeVar{Schemaname: sch, Relname: name}) { + return false + } + if ns := s.index.schemas[sch]; ns != nil { + if _, exists := ns.relations[name]; exists { + return true + } + } + } + return false +} + +// settle records that a statement may have given the table a key, or +// dropped it, or made it a partition. +func (p *pendingTable) settle() { + p.settled = true + p.key = "" + p.keyColumns = nil +} + +// keyed settles the pending tables a statement may give a primary key, +// recording the key's name, "" when unknown, and its columns, nil when +// unknown, on the one it certainly names. +func (s *scan) keyed(rv *ast.RangeVar, name string, columns []string) { + for _, p := range s.matching(rv) { + settled := p.settled + p.settle() + if !settled && name != "" && s.namesPending(p, rv) { + p.key, p.keyColumns = name, columns + } + } +} + +// declaredKeyName returns the name of the primary key a CREATE TABLE +// declares: the one written, or the one the server generates when the +// scan knows it free; "" when the key comes from LIKE or is not known. +func (s *scan) declaredKeyName(v *ast.CreateStmt) string { + if v.TableElts == nil { + return "" + } + for _, item := range v.TableElts.Items { + var cs []*ast.Constraint + switch e := item.(type) { + case *ast.ColumnDef: + cs = constraintsOf(e.Constraints) + case *ast.Constraint: + cs = []*ast.Constraint{e} + } + for _, c := range cs { + if c.Contype == ast.CONSTR_PRIMARY { + return s.keyName(v.Relation, c) + } + } + } + return "" +} + +// keyName returns the name of a primary key ADD CONSTRAINT gives a table +// the change made: the one written, or the one the server generates when +// the scan knows it free. +// +// pg: src/backend/commands/indexcmds.c — ChooseIndexName +func (s *scan) keyName(rv *ast.RangeVar, c *ast.Constraint) string { + if c.Conname != "" { + return c.Conname + } + schema := s.pendingSchema(rv) + name := rv.Relname + "_pkey" + if schema == "" || len(name) > 63 || s.nameTaken(schema, name) || s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: name}) { + return "" + } + return name +} + +// namesPending reports whether a name certainly means a pending table the +// change made: under its one name, which no statement used since, in its +// schema, written or the one the search path resolves the name to. +func (s *scan) namesPending(p *pendingTable, rv *ast.RangeVar) bool { + if p.existed || p.schema == "" || len(p.names) != 1 || !p.names[rv.Relname] || s.lastTouch[rv.Relname] != p.made { + return false + } + if rv.Schemaname != "" { + return rv.Schemaname == p.schema + } + path, ok := s.searchPath() + if !ok { + return false + } + for _, name := range path { + if name == p.schema { + return true + } + if strings.HasPrefix(name, "pg_") || s.schemas[name] { + return false + } + if ns := s.index.schemas[name]; ns != nil { + if _, exists := ns.relations[rv.Relname]; exists { + return false + } + } + } + return false +} + +// lostKey records that a statement removed the primary key of a table of +// the synced schema. +func (s *scan) lostKey(st *statement, v *ast.AlterTableStmt, cmd *ast.AlterTableCmd, t tableRef) { + if !s.on[review.RequirePrimaryKey] { + return + } + for _, p := range s.pending { + if p.existed && !p.settled && p.schema == t.schema && p.names[t.table] { + return + } + } + s.pending = append(s.pending, &pendingTable{ + names: map[string]bool{t.table: true}, + schema: t.schema, + existed: true, + statement: st.index, + rng: rangeOf(cmd.Loc), + message: "removes the primary key of " + relation(v.Relation) + ", and the change adds none back", + }) +} + +// createTable records a CREATE TABLE that makes a table without a primary +// key. CREATE TABLE IF NOT EXISTS is left out when a relation of that name +// may already exist, since then it makes nothing. +func (s *scan) createTable(st *statement, v *ast.CreateStmt) { + if !s.on[review.RequirePrimaryKey] || v.Relation == nil || v.Partbound != nil || isTemp(v.Relation) || s.createsTemp(v.Relation) { + return + } + if v.IfNotExists && !s.createsNew(v.Relation) { + return + } + keyed, known := s.createsKey(v) + if keyed && known { + // A table made with a key the scan can name is keyless again when + // a later statement drops it. + if name := s.declaredKeyName(v); name != "" && !s.mayBeGenerated(v.Relation) { + s.pending = append(s.pending, &pendingTable{ + names: map[string]bool{v.Relation.Relname: true}, + schema: s.pendingSchema(v.Relation), + parents: s.inheritsFrom(v), + settled: true, + key: name, + keyColumns: declaredKeyColumns(st, v), + statement: st.index, + rng: rangeOf(v.Loc), + message: "creates table " + relation(v.Relation) + " without a primary key", + }) + } + return + } + if keyed || !known || s.mayBeGenerated(v.Relation) { + return + } + s.pending = append(s.pending, &pendingTable{ + names: map[string]bool{v.Relation.Relname: true}, + schema: s.pendingSchema(v.Relation), + parents: s.inheritsFrom(v), + statement: st.index, + rng: rangeOf(v.Loc), + message: "creates table " + relation(v.Relation) + " without a primary key", + }) +} + +// declaredKeyColumns returns the columns of the primary key a CREATE TABLE +// statement declares, nil when the table may have something else that +// depends on them: it is made by CREATE SCHEMA, which may make triggers +// and views on it too, takes columns from elsewhere, is partitioned, or +// has a generated column. +func declaredKeyColumns(st *statement, v *ast.CreateStmt) []string { + if st.node != ast.Node(v) || v.Partspec != nil || v.TableElts == nil { + return nil + } + if _, _, complete := defOf(v); !complete { + return nil + } + var key []string + for _, item := range v.TableElts.Items { + switch e := item.(type) { + case *ast.ColumnDef: + if e.Generated != 0 { + return nil + } + for _, c := range constraintsOf(e.Constraints) { + switch c.Contype { + case ast.CONSTR_GENERATED: + return nil + case ast.CONSTR_PRIMARY: + key = []string{e.Colname} + } + } + case *ast.Constraint: + if e.Contype == ast.CONSTR_PRIMARY { + key = nameParts(e.Keys) + } + } + } + return key +} + +// forgetKeyColumns stops following the key columns of the tables a +// statement names, but for an ALTER TABLE that only drops: what it makes +// may depend on them, so that dropping one may be refused. +func (s *scan) forgetKeyColumns(n ast.Node) { + if !slices.ContainsFunc(s.pending, func(p *pendingTable) bool { return p.keyColumns != nil }) { + return + } + if v, ok := n.(*ast.AlterTableStmt); ok && dropsOnly(v) { + return + } + ast.Inspect(n, func(m ast.Node) bool { + if rv, ok := m.(*ast.RangeVar); ok { + for _, p := range s.pending { + if p.names[rv.Relname] { + p.keyColumns = nil + } + } + } + return true + }) +} + +// dropsOnly reports whether every subcommand of an ALTER TABLE drops a +// column or a constraint. +func dropsOnly(v *ast.AlterTableStmt) bool { + if v.Cmds == nil { + return false + } + for _, item := range v.Cmds.Items { + cmd, ok := item.(*ast.AlterTableCmd) + if !ok { + return false + } + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_DropColumn, ast.AT_DropConstraint: + default: + return false + } + } + return true +} + +// dropsKeyColumns reports whether an ALTER TABLE certainly runs on a table +// the change made with a declared key, and drops the key with a column of +// it: it only drops, columns of the key or under IF EXISTS, and +// constraints under IF EXISTS, since the key it drops first is gone. +func dropsKeyColumns(v *ast.AlterTableStmt, p *pendingTable) bool { + if !dropsOnly(v) { + return false + } + for _, item := range v.Cmds.Items { + cmd := item.(*ast.AlterTableCmd) + switch { + case cmd.Missing_ok: + case ast.AlterTableType(cmd.Subtype) == ast.AT_DropColumn && slices.Contains(p.keyColumns, cmd.Name): + default: + return false + } + } + return true +} + +// keylessNew reports whether a qualified name certainly means a table the +// change made without a primary key, which no statement since may have +// given one: a pending table of that one name in that schema, whose name +// no statement used since it was made. +func (s *scan) keylessNew(rv *ast.RangeVar) bool { + if rv.Schemaname == "" { + return false + } + for _, p := range s.pending { + if !p.existed && !p.settled && p.schema == rv.Schemaname && len(p.names) == 1 && p.names[rv.Relname] && s.lastTouch[rv.Relname] == p.made { + return true + } + } + return false +} + +// pendingSchema is the schema a CREATE TABLE puts its table in, as written +// or as the search path resolves it, or "" when neither tells. +func (s *scan) pendingSchema(rv *ast.RangeVar) string { + if rv.Schemaname != "" { + return rv.Schemaname + } + schema, _ := s.creationSchema(rv) + return schema +} + +// createsTemp reports whether CREATE puts an unqualified relation in the +// session's temporary schema, or a system one: the known search path +// names one before any schema that exists. +func (s *scan) createsTemp(rv *ast.RangeVar) bool { + if rv == nil || rv.Schemaname != "" || s.index == nil { + return false + } + path, ok := s.searchPath() + if !ok { + return false + } + for _, name := range path { + switch { + case strings.HasPrefix(name, "pg_"): + return true + case s.schemas[name]: + if !s.schemaGone[name] { + return false + } + case s.index.schemas[name] != nil: + return false + } + } + return false +} + +// createsNew reports whether CREATE TABLE IF NOT EXISTS creates a table: +// the schema it creates in is known, the synced one, and holds no relation +// of that name, or one the change created, where no statement made the +// name. An unqualified name is created in the first schema of the search +// path that exists. +func (s *scan) createsNew(rv *ast.RangeVar) bool { + if s.index != nil && rv.Schemaname != "" && s.schemas[rv.Schemaname] && !s.schemaGone[rv.Schemaname] && !isTemp(rv) { + // A schema the change created starts empty. + return !s.isTouched(rv) + } + schema, ok := s.creationSchema(rv) + if !ok { + return false + } + ns := s.index.schemas[schema] + if s.freed[[2]string{schema, rv.Relname}] { + return true + } + if s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: rv.Relname}) { + return false + } + _, exists := ns.relations[rv.Relname] + return !exists +} + +// nameUncertain reports whether an earlier statement may have made a +// relation of that name where CREATE puts it, without the scan knowing it +// gone again: CREATE TABLE may then fail, and creates nothing the rule can +// report. +func (s *scan) nameUncertain(rv *ast.RangeVar) bool { + if schema, ok := s.creationSchema(rv); ok { + return s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: rv.Relname}) && !s.freed[[2]string{schema, rv.Relname}] + } + if rv.Schemaname != "" { + return s.isTouched(rv) && !s.freed[[2]string{rv.Schemaname, rv.Relname}] + } + return s.touchedName[rv.Relname] +} + +// generatedName reports whether a name has the form of one the server +// generates for an index or a sequence: a suffix of _idx, _key, _pkey, +// _excl, or _seq, then digits the server adds to avoid a name in use. A +// table of such a name may collide with a relation an earlier statement +// made, which the scan does not name. +// +// pg: src/backend/commands/indexcmds.c — ChooseIndexName +func generatedName(name string) bool { + name = strings.TrimRight(name, "0123456789") + for _, suffix := range []string{"_idx", "_key", "_pkey", "_excl", "_seq"} { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} + +// existsWhereCreated reports whether the schema a CREATE puts the relation +// in certainly holds a relation of that name already: the schema is the +// synced one, and no statement of the change dropped, renamed, or made the +// name. +func (s *scan) existsWhereCreated(rv *ast.RangeVar) bool { + schema, ok := s.creationSchema(rv) + if !ok || s.freed[[2]string{schema, rv.Relname}] || s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: rv.Relname}) { + return false + } + _, exists := s.index.schemas[schema].relations[rv.Relname] + return exists +} + +// creationSchema returns the synced schema a CREATE puts a relation in: +// the one written, or the first schema of the search path that exists. ok +// is false when that is not a schema the scan knows as synced. +func (s *scan) creationSchema(rv *ast.RangeVar) (string, bool) { + if s.index == nil || isTemp(rv) { + return "", false + } + schema := rv.Schemaname + if schema == "" { + path, ok := s.searchPath() + if !ok { + return "", false + } + for _, name := range path { + // A system schema is not in the snapshot, and a schema the + // change created or dropped may or may not exist. + if s.schemas[name] || strings.HasPrefix(name, "pg_") || name == "information_schema" { + return "", false + } + if s.index.schemas[name] != nil { + schema = name + break + } + } + } + if s.index.schemas[schema] == nil || s.schemas[schema] { + return "", false + } + return schema, true +} + +// likeIndexes is the LIKE option that copies indexes, the primary key +// among them; INCLUDING ALL sets it too. +// +// pg: src/include/nodes/parsenodes.h — CREATE_TABLE_LIKE_INDEXES +const likeIndexes = 1 << 6 + +// createsKey reports whether CREATE TABLE gives the table a primary key: a +// PRIMARY KEY of a column or of the table, or LIKE ... INCLUDING INDEXES of +// a table that has one. known is false when that depends on a table whose +// key the scan cannot tell. +func (s *scan) createsKey(v *ast.CreateStmt) (keyed, known bool) { + if v.TableElts == nil { + return false, true + } + known = true + for _, item := range v.TableElts.Items { + switch e := item.(type) { + case *ast.ColumnDef: + for _, c := range constraintsOf(e.Constraints) { + if c.Contype == ast.CONSTR_PRIMARY { + keyed = true + } + } + case *ast.Constraint: + if e.Contype == ast.CONSTR_PRIMARY { + keyed = true + } + case *ast.TableLikeClause: + if e.Options&likeIndexes == 0 { + continue + } + schema, kind, ok := s.lookup(e.Relation) + switch { + case !ok && s.keylessNew(e.Relation): + // A table the change made without a key has none to copy. + case !ok || kind == kindPartition || kind == kindTable && s.isUnsettled(tableRef{schema, e.Relation.Relname}): + known = false + case kind == kindTable: + t := tableRef{schema, e.Relation.Relname} + pk := primaryKey(s.index.schemas[schema].tables[t.table]) + switch { + case s.newKeys[[2]string{t.schema, t.table}] || s.newKeys[[2]string{"", t.table}]: + // A statement may have given it a key since. + known = false + case pk == nil: + case s.dropped[[3]string{t.schema, t.table, pk.GetName()}]: + // The change dropped its key for certain. + case !s.holdsConstraint(t, pk.GetName()): + known = false + default: + keyed = true + } + } + // A view, materialized view, foreign table, or composite type + // has no primary key to copy. + } + } + return keyed, known +} diff --git a/pg/review/scan.go b/pg/review/scan.go new file mode 100644 index 00000000..49f029f4 --- /dev/null +++ b/pg/review/scan.go @@ -0,0 +1,4762 @@ +package review + +import ( + "context" + "maps" + "slices" + "strings" + + "github.com/bytebase/omni/metadata" + "github.com/bytebase/omni/pg/ast" + "github.com/bytebase/omni/review" +) + +// scan follows the change statement by statement for the rules that ask +// what a statement does to an object of the target's schema: +// DisallowDropConstraint and RequirePrimaryKey. It simulates nothing. It +// checks each statement against the synced schema directly, and keeps +// track of what earlier statements may have changed: a name whose meaning +// a statement may have changed is not looked up again, and a statement +// whose effect the scan cannot bound (procedural code, a rollback, a +// cascading drop of anything but a relation) ends it. Every way the scan +// can be unsure leads to no finding. +// +// A finding is a claim about its own statement, given that the statements +// before it ran: the statement it anchors never fails for a reason the +// SQL and the synced schema show. Whether the change as a whole can run +// is WalkThrough's question, not this scan's. Where the scan notices on +// the way that the server refuses a statement (a missing object, a name of +// the wrong kind, a dependent object without CASCADE) it reports nothing +// for it and ends, since it cannot say what follows; it does not look for +// every way a statement can fail. A finding after such a statement still +// describes the change as written. What user code does when it runs, a +// trigger fired by DML or a function a query calls, is outside what a +// static check can bound, and the scan does not predict it. +type scan struct { + on map[review.Rule]bool + r *reporter + index *schemaIndex // nil without a schema + + // path is the search path as last set, "$user" unexpanded, or nil + // when unknown. user is the current user and session the session + // user, "" when unknown. + path []string + user string + session string + + // touched lists the relation names a statement created, dropped, + // renamed, or moved, by (schema, name), with "" for a schema the + // statement left to the search path: the synced schema no longer says + // what they name. touchedName lists the same names alone. + touched map[[2]string]bool + touchedName map[string]bool + // lastTouch numbers, by name, the last statement that touched it, as + // touches counts them. + lastTouch map[string]int + touches int + // freed lists the (schema, name) a drop the scan resolved removed for + // certain, until a statement uses the name again. + freed map[[2]string]bool + // unsettled lists the (schema, table) whose columns, constraints, or + // inheritance a statement changed in a way the scan does not follow, + // with "" for a schema the scan could not tell. + unsettled map[[2]string]bool + // constraints lists the (schema, table, constraint) names a statement + // added, dropped, or renamed, with "" for a schema the scan could not + // tell, and renamedKeys the names an index rename may have given to or + // taken from a constraint, on whichever table. + constraints map[[3]string]bool + renamedKeys map[string]bool + // renamedConstraints maps a constraint of a synced table, by the + // (schema, table, name) a RENAME CONSTRAINT gave it, to the name the + // synced schema lists it under, and goneConstraints lists the names + // those renames took, until a statement uses either name again. + renamedConstraints map[[3]string]string + goneConstraints map[[3]string]bool + // dropped lists the (schema, table, constraint) a DROP CONSTRAINT of a + // resolved table removed. + dropped map[[3]string]bool + // columns records, by (schema, table, column), with "" for a schema + // the scan could not tell, whether a column a statement added, dropped, + // or renamed exists now, and renamedColumns the names a column got by a + // rename, whose identity the scan does not follow. + columns map[[3]string]bool + renamedColumns map[[3]string]bool + // origins maps a column a rename named, by (schema, table, column), to + // the synced column it is, "" for one the change added. + origins map[[3]string]string + // schemas lists the schemas a statement created or dropped, and + // schemaGone the ones a DROP SCHEMA removed and nothing made again. + schemas map[string]bool + schemaGone map[string]bool + // newReferences lists the keys the foreign keys the change created + // reference, newReads the relations its views read, and newReturns the + // relations whose row type its functions return, none of which the + // synced schema records. + newReferences []newReference + newReads []newDependency + newReturns []newDependency + // pathVersion counts search path changes, so two unqualified names + // are known to resolve alike only under the same path. + pathVersion int + // droppedFunctions lists the synced functions, by (schema, name), a + // DROP FUNCTION certainly removed, and newFunctions counts the functions + // the change created, by name as written. + droppedFunctions map[tableRef]bool + newFunctions map[tableRef]int + // madeRoutines lists the names the change gave a routine by creating, + // renaming, or moving one, movedRoutines the names it took one from by + // renaming or moving it, and replacedFunctions the synced functions, + // by (schema, name), CREATE OR REPLACE redefined. + madeRoutines map[string]bool + movedRoutines map[string]bool + // gainedRoutines lists the names a rename or a move gave a routine, + // and createdAs the names, as written, CREATE FUNCTION gave one, by + // name. + gainedRoutines map[string]bool + createdAs map[string]map[tableRef]bool + // columnsUsed lists the names of tables whose columns something the + // change made may depend on beyond what the scan follows: a generated + // column, a policy, a trigger with a WHEN clause or a column list, a + // rule, a SQL-standard routine body, or a publication. + columnsUsed map[string]bool + // renamedSignatures lists, by (schema, name), the signatures of synced + // routines a rename certainly took from the name. + renamedSignatures map[tableRef][]string + // newSignatures lists the argument signatures of the functions the + // change created and has not dropped, by name as written. + newSignatures map[tableRef][]string + replacedFunctions map[tableRef]bool + // renamedFrom maps a name a rename or a move gave a relation the scan + // resolved to the relation, while no statement used the name since. + renamedFrom map[string]renamedRelation + // created lists the tables and views the change certainly created, by + // (schema, name), while no statement used the name since. + created map[[2]string]*madeRelation + // replacedViews lists the synced views CREATE OR REPLACE VIEW + // redefined, whose synced reads no longer hold, and reowned the + // sequences, by name, whose owner ALTER SEQUENCE changed. + replacedViews map[tableRef]bool + reowned map[[2]string]bool + // newKeys lists the (schema, table), with "" for a schema the scan + // could not tell, a statement may have given a primary key, a unique + // constraint, or a unique index. + newKeys map[[2]string]bool + // generated lists the schemas, "" for one the scan could not tell, a + // statement made relations in under names the server generates (an + // unnamed index, a key's index, a serial or identity column's + // sequence), which the scan does not derive. + generated map[string]bool + + // triggers are the target's enabled event triggers, and + // triggersChanged the trigger names a statement created, dropped, or + // renamed, on whichever table. + triggers []*metadata.EventTriggerMetadata + triggersChanged map[string]bool + // createdTriggers lists the triggers the change created, by relation. + createdTriggers map[string]bool + + // pending are the tables RequirePrimaryKey reports at the end unless a + // later statement settles them. + pending []*pendingTable + stopped bool +} + +// scanTarget scans the change on one target. RequirePrimaryKey is about +// the end of the change, so it reports only when the scan reaches it. +func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, target review.Target, r *reporter) error { + s := &scan{ + on: on, + r: r, + user: target.SessionUser, + session: target.SessionUser, + touched: make(map[[2]string]bool), + touchedName: make(map[string]bool), + lastTouch: make(map[string]int), + unsettled: make(map[[2]string]bool), + freed: make(map[[2]string]bool), + constraints: make(map[[3]string]bool), + renamedKeys: make(map[string]bool), + renamedConstraints: make(map[[3]string]string), + goneConstraints: make(map[[3]string]bool), + dropped: make(map[[3]string]bool), + columns: make(map[[3]string]bool), + renamedColumns: make(map[[3]string]bool), + origins: make(map[[3]string]string), + schemas: make(map[string]bool), + schemaGone: make(map[string]bool), + + droppedFunctions: make(map[tableRef]bool), + newFunctions: make(map[tableRef]int), + madeRoutines: make(map[string]bool), + triggersChanged: make(map[string]bool), + createdTriggers: make(map[string]bool), + movedRoutines: make(map[string]bool), + gainedRoutines: make(map[string]bool), + createdAs: make(map[string]map[tableRef]bool), + columnsUsed: make(map[string]bool), + renamedSignatures: make(map[tableRef][]string), + newSignatures: make(map[tableRef][]string), + renamedFrom: make(map[string]renamedRelation), + created: make(map[[2]string]*madeRelation), + replacedViews: make(map[tableRef]bool), + reowned: make(map[[2]string]bool), + newKeys: make(map[[2]string]bool), + generated: make(map[string]bool), + + replacedFunctions: make(map[tableRef]bool), + } + if target.Schema != nil { + s.index = newSchemaIndex(target.Schema) + // A schema synced without its search path leaves it unknown. + if path := parseSearchPath(target.Schema.GetSearchPath()); len(path) > 0 { + s.path = path + } + } + if target.Schema != nil { + s.triggers = enabledTriggers(target.Schema) + } + for i := range stmts { + if err := ctx.Err(); err != nil { + return err + } + if s.triggerFires(stmts[i].node) { + return nil + } + s.statement(&stmts[i]) + if s.stopped { + return nil + } + } + for _, p := range s.pending { + if !p.settled { + r.report(review.RequirePrimaryKey, p.statement, p.rng, p.message) + } + } + return nil +} + +// enabledTriggers lists the target's enabled event triggers that fire on +// statements: all but login triggers, which fire when a session connects. +func enabledTriggers(schema *metadata.DatabaseSchemaMetadata) []*metadata.EventTriggerMetadata { + var triggers []*metadata.EventTriggerMetadata + for _, t := range schema.GetEventTriggers() { + if t.GetEnabled() && !strings.EqualFold(t.GetEvent(), "LOGIN") { + triggers = append(triggers, t) + } + } + return triggers +} + +// triggerFires reports whether an enabled event trigger of the target may +// fire on the statement: one with no tag filter, or one whose tags name +// the statement's command, or any tag-filtered trigger for a DDL statement +// whose command tag the scan does not name. Its function may run more DDL +// or reject the statement, so from it on, what the change does is no +// longer what it says. Every DDL statement raises ddl_command_start and +// ddl_command_end, one that may drop objects sql_drop, and one that may +// rewrite a table table_rewrite. +func (s *scan) triggerFires(n ast.Node) bool { + if len(s.triggers) == 0 || !isDDL(n) { + return false + } + var tags []string + if tag := commandTag(n); tag != "" { + tags = []string{tag} + } + for _, t := range s.triggers { + switch strings.ToUpper(t.GetEvent()) { + case "SQL_DROP": + if !mayDrop(n) || s.dropsNothing(n) { + continue + } + case "TABLE_REWRITE": + if !mayRewrite(n) { + continue + } + } + if len(t.GetTags()) == 0 || len(tags) == 0 { + return true + } + for _, tag := range t.GetTags() { + if slices.Contains(tags, strings.ToUpper(tag)) { + return true + } + } + } + return false +} + +// dropsNothing reports whether a statement is a DROP ... IF EXISTS of +// relations the target certainly lacks, which drops nothing. +func (s *scan) dropsNothing(n ast.Node) bool { + v, ok := n.(*ast.DropStmt) + if !ok || !v.Missing_ok || s.index == nil || v.Objects == nil || !isRelationKind(ast.ObjectType(v.RemoveType)) { + return false + } + for _, obj := range v.Objects.Items { + parts := nameParts(listOf(obj)) + if len(parts) == 0 || len(parts) > 2 { + return false + } + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) == 2 { + rv.Schemaname = parts[0] + } + if !s.missing(rv) { + return false + } + } + return true +} + +// isDDL reports whether a statement may raise an event trigger's event: +// any statement but a query, DML, and the session, transaction, and +// maintenance commands event triggers do not fire on. +// +// pg: doc/src/sgml/event-trigger.sgml — Event Trigger Firing Matrix +func isDDL(n ast.Node) bool { + switch v := n.(type) { + case *ast.SelectStmt: + return selectsInto(v) + case *ast.ExplainStmt: + // EXPLAIN ANALYZE runs what it explains. + if explainAnalyzes(v) { + return isDDL(v.Query) + } + return false + case *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt, *ast.MergeStmt, + *ast.VariableSetStmt, *ast.VariableShowStmt, *ast.TransactionStmt, + *ast.CopyStmt, *ast.LockStmt, *ast.PrepareStmt, *ast.ExecuteStmt, *ast.DeallocateStmt, + *ast.NotifyStmt, *ast.ListenStmt, *ast.UnlistenStmt, *ast.CheckPointStmt, *ast.VacuumStmt, + *ast.ConstraintsSetStmt, *ast.DiscardStmt, *ast.LoadStmt, *ast.DoStmt, *ast.CallStmt, *ast.TruncateStmt: + return false + } + return true +} + +// mayDrop reports whether a DDL statement may drop an object, which raises +// sql_drop: a DROP, an ALTER TABLE with a subcommand that may, or a +// statement the scan does not know not to. +func mayDrop(n ast.Node) bool { + switch v := n.(type) { + case *ast.CreateStmt, *ast.CreateForeignTableStmt, *ast.CreateTableAsStmt, *ast.SelectStmt, *ast.ViewStmt, + *ast.CreateSeqStmt, *ast.CompositeTypeStmt, *ast.IndexStmt, *ast.CreateSchemaStmt, + *ast.RenameStmt, *ast.AlterObjectSchemaStmt, *ast.AlterSeqStmt, *ast.CommentStmt, *ast.GrantStmt: + return false + case *ast.AlterTableStmt: + if v.Cmds == nil { + return false + } + for _, item := range v.Cmds.Items { + cmd, ok := item.(*ast.AlterTableCmd) + if !ok || !keepsObjects(cmd) { + return true + } + } + return false + } + return true +} + +// keepsObjects reports whether an ALTER TABLE subcommand drops nothing an +// sql_drop trigger sees. Replacing a default or rewriting the table +// deletes catalog rows internally, which the trigger does not see. +func keepsObjects(cmd *ast.AlterTableCmd) bool { + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_AddColumn, ast.AT_AddConstraint, ast.AT_AddIndexConstraint, ast.AT_SetNotNull, + ast.AT_SetStatistics, ast.AT_ChangeOwner, ast.AT_ValidateConstraint, + ast.AT_EnableRowSecurity, ast.AT_DisableRowSecurity: + return true + case ast.AT_ColumnDefault: + // SET DEFAULT replaces; DROP DEFAULT drops. + return cmd.Def != nil + } + return togglesTriggers(cmd) +} + +// mayRewrite reports whether a DDL statement may rewrite a table, which +// raises table_rewrite: an ALTER TABLE with a subcommand that may, ALTER +// TYPE, or a statement the scan does not name. +func mayRewrite(n ast.Node) bool { + v, ok := n.(*ast.AlterTableStmt) + if !ok { + return commandTag(n) == "" + } + if ast.ObjectType(v.ObjType) == ast.OBJECT_TYPE || v.Cmds == nil { + return ast.ObjectType(v.ObjType) == ast.OBJECT_TYPE + } + for _, item := range v.Cmds.Items { + cmd, ok := item.(*ast.AlterTableCmd) + if !ok || !keepsRows(cmd) { + return true + } + } + return false +} + +// keepsRows reports whether an ALTER TABLE subcommand never rewrites the +// table: one that changes only the catalog or scans the rows, or ADD +// COLUMN of a plain column. +// +// pg: src/backend/commands/tablecmds.c — ATExecAddColumn, ATRewriteTables +func keepsRows(cmd *ast.AlterTableCmd) bool { + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_AddColumn: + cd, ok := cmd.Def.(*ast.ColumnDef) + return ok && plainColumn(cd) + case ast.AT_AddConstraint, ast.AT_AddIndexConstraint, ast.AT_SetNotNull, ast.AT_DropNotNull, ast.AT_ColumnDefault, + ast.AT_SetStatistics, ast.AT_ChangeOwner, ast.AT_ValidateConstraint, ast.AT_EnableRowSecurity, ast.AT_DisableRowSecurity, + ast.AT_DropConstraint, ast.AT_DropColumn: + return true + } + return togglesTriggers(cmd) +} + +// togglesTriggers reports whether an ALTER TABLE subcommand enables or +// disables triggers, which changes only the catalog. +func togglesTriggers(cmd *ast.AlterTableCmd) bool { + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_EnableTrig, ast.AT_EnableAlwaysTrig, ast.AT_EnableReplicaTrig, ast.AT_DisableTrig, + ast.AT_EnableTrigAll, ast.AT_DisableTrigAll, ast.AT_EnableTrigUser, ast.AT_DisableTrigUser: + return true + } + return false +} + +// plainColumn reports whether adding a column leaves the rows as they +// are: a built-in type, which no domain constraint checks, and no +// default, identity, or generation expression, which the server may +// compute for every row. +func plainColumn(cd *ast.ColumnDef) bool { + if cd.RawDefault != nil || cd.CookedDefault != nil || cd.Identity != 0 || cd.Generated != 0 || cd.TypeName == nil { + return false + } + for _, c := range constraintsOf(cd.Constraints) { + switch c.Contype { + case ast.CONSTR_DEFAULT, ast.CONSTR_IDENTITY, ast.CONSTR_GENERATED: + return false + } + } + parts := nameParts(cd.TypeName.Names) + if len(parts) == 2 && parts[0] == "pg_catalog" { + parts = parts[1:] + } + return len(parts) == 1 && builtinTypes[parts[0]] +} + +// builtinTypes are the names of common built-in scalar types. +var builtinTypes = map[string]bool{ + "bool": true, "boolean": true, "int2": true, "smallint": true, "int4": true, "int": true, "integer": true, + "int8": true, "bigint": true, "float4": true, "real": true, "float8": true, "numeric": true, "decimal": true, + "text": true, "varchar": true, "bpchar": true, "char": true, "name": true, "bytea": true, "uuid": true, + "date": true, "time": true, "timetz": true, "timestamp": true, "timestamptz": true, "interval": true, + "json": true, "jsonb": true, "xml": true, "inet": true, "cidr": true, "macaddr": true, "money": true, + "bit": true, "varbit": true, "oid": true, "tsvector": true, "tsquery": true, +} + +// commandTag is the command tag of a statement, as an event trigger's tag +// filter names it, or "" for one the scan does not name. +// +// pg: src/include/tcop/cmdtaglist.h +func commandTag(n ast.Node) string { + objectTag := func(t ast.ObjectType) string { + switch t { + case ast.OBJECT_TABLE: + return "TABLE" + case ast.OBJECT_VIEW: + return "VIEW" + case ast.OBJECT_MATVIEW: + return "MATERIALIZED VIEW" + case ast.OBJECT_FOREIGN_TABLE: + return "FOREIGN TABLE" + case ast.OBJECT_SEQUENCE: + return "SEQUENCE" + case ast.OBJECT_INDEX: + return "INDEX" + case ast.OBJECT_TYPE: + return "TYPE" + case ast.OBJECT_SCHEMA: + return "SCHEMA" + case ast.OBJECT_FUNCTION: + return "FUNCTION" + case ast.OBJECT_ROUTINE: + return "ROUTINE" + case ast.OBJECT_PROCEDURE: + return "PROCEDURE" + } + return "" + } + prefixed := func(verb string, t ast.ObjectType) string { + if o := objectTag(t); o != "" { + return verb + " " + o + } + return "" + } + switch v := n.(type) { + case *ast.CreateStmt: + return "CREATE TABLE" + case *ast.CreateForeignTableStmt: + return "CREATE FOREIGN TABLE" + case *ast.CreateTableAsStmt: + if v.Objtype == ast.OBJECT_MATVIEW { + return "CREATE MATERIALIZED VIEW" + } + return "CREATE TABLE AS" + case *ast.SelectStmt: + if selectsInto(v) { + return "SELECT INTO" + } + case *ast.ViewStmt: + return "CREATE VIEW" + case *ast.CreateSeqStmt: + return "CREATE SEQUENCE" + case *ast.CompositeTypeStmt: + return "CREATE TYPE" + case *ast.IndexStmt: + return "CREATE INDEX" + case *ast.CreateFunctionStmt: + if isProcedure(v) { + return "CREATE PROCEDURE" + } + return "CREATE FUNCTION" + case *ast.AlterSeqStmt: + return "ALTER SEQUENCE" + case *ast.CreateSchemaStmt: + return "CREATE SCHEMA" + case *ast.AlterTableStmt: + return prefixed("ALTER", ast.ObjectType(v.ObjType)) + case *ast.RenameStmt: + if v.RenameType == ast.OBJECT_COLUMN || v.RenameType == ast.OBJECT_TABCONSTRAINT { + if tag := prefixed("ALTER", v.RelationType); tag != "" { + return tag + } + return "ALTER TABLE" + } + return prefixed("ALTER", v.RenameType) + case *ast.AlterObjectSchemaStmt: + return prefixed("ALTER", v.ObjectType) + case *ast.DropStmt: + return prefixed("DROP", ast.ObjectType(v.RemoveType)) + case *ast.CommentStmt: + return "COMMENT" + case *ast.GrantStmt: + if v.IsGrant { + return "GRANT" + } + return "REVOKE" + case *ast.CreateEnumStmt: + return "CREATE TYPE" + case *ast.AlterEnumStmt: + return "ALTER TYPE" + case *ast.CreateDomainStmt: + return "CREATE DOMAIN" + case *ast.CreateTrigStmt: + return "CREATE TRIGGER" + case *ast.CreateExtensionStmt: + return "CREATE EXTENSION" + } + return "" +} + +// noteColumnUses records the tables whose columns what a statement makes +// may depend on, in ways the scan does not follow: a DROP COLUMN of them +// without CASCADE may then be refused. +func (s *scan) noteColumnUses(n ast.Node) { + use := func(rv *ast.RangeVar) { + if rv != nil { + s.columnsUsed[rv.Relname] = true + } + } + generates := func(cd *ast.ColumnDef) bool { + return cd.Generated != 0 || slices.ContainsFunc(constraintsOf(cd.Constraints), func(c *ast.Constraint) bool { return c.Contype == ast.CONSTR_GENERATED }) + } + ast.Inspect(n, func(m ast.Node) bool { + switch v := m.(type) { + case *ast.CreateStmt: + if v.TableElts != nil { + for _, item := range v.TableElts.Items { + if cd, ok := item.(*ast.ColumnDef); ok && generates(cd) { + use(v.Relation) + } + } + } + case *ast.AlterTableStmt: + if v.Cmds != nil { + for _, item := range v.Cmds.Items { + if cmd, ok := item.(*ast.AlterTableCmd); ok { + if cd, ok := cmd.Def.(*ast.ColumnDef); ok && generates(cd) { + use(v.Relation) + } + } + } + } + case *ast.CreatePolicyStmt: + use(v.Table) + case *ast.AlterPolicyStmt: + use(v.Table) + case *ast.CreateTrigStmt: + if v.WhenClause != nil || v.Columns != nil { + use(v.Relation) + } + case *ast.RuleStmt: + use(v.Relation) + relationsIn(v, nil, use) + case *ast.CreateFunctionStmt: + if v.SqlBody != nil { + relationsIn(v.SqlBody, nil, use) + } + case *ast.CreatePublicationStmt, *ast.AlterPublicationStmt: + ast.Inspect(v, func(r ast.Node) bool { + if rv, ok := r.(*ast.RangeVar); ok { + use(rv) + } + return true + }) + } + return true + }) +} + +// tempOutsideTemp reports whether a CREATE makes a temporary relation in a +// schema written that is not the temporary one, which the server refuses. +// +// pg: src/backend/catalog/namespace.c — RangeVarAdjustRelationPersistence +func tempOutsideTemp(n ast.Node) bool { + var rv *ast.RangeVar + switch v := n.(type) { + case *ast.CreateStmt: + rv = v.Relation + case *ast.ViewStmt: + rv = v.View + case *ast.CreateSeqStmt: + rv = v.Sequence + case *ast.CreateTableAsStmt: + if v.Into != nil { + rv = v.Into.Rel + } + case *ast.SelectStmt: + if into := intoOf(v); into != nil { + rv = into.Rel + } + } + return rv != nil && rv.Relpersistence == 't' && rv.Schemaname != "" && rv.Schemaname != "pg_temp" && !strings.HasPrefix(rv.Schemaname, "pg_temp_") +} + +// constraintAttrsRefused reports whether CREATE or ALTER TABLE gives a +// constraint an attribute its kind cannot take: NOT VALID or NO INHERIT on +// a key or exclusion constraint, DEFERRABLE on a check, NO INHERIT on a +// foreign key, or NOT VALID on a column's constraint. +// +// pg: src/backend/parser/gram.y — processCASbits, ColConstraint +func constraintAttrsRefused(n ast.Node) bool { + switch n.(type) { + case *ast.CreateStmt, *ast.AlterTableStmt, *ast.CreateSchemaStmt, *ast.CreateForeignTableStmt: + default: + return false + } + refused := func(c *ast.Constraint, column bool) bool { + switch c.Contype { + case ast.CONSTR_PRIMARY, ast.CONSTR_UNIQUE, ast.CONSTR_EXCLUSION: + if c.SkipValidation || c.IsNoInherit { + return true + } + case ast.CONSTR_CHECK: + if c.Deferrable || c.Initdeferred { + return true + } + case ast.CONSTR_FOREIGN: + if c.IsNoInherit { + return true + } + } + return column && c.SkipValidation + } + found := false + ast.Inspect(n, func(m ast.Node) bool { + switch v := m.(type) { + case *ast.ColumnDef: + for _, c := range constraintsOf(v.Constraints) { + found = found || refused(c, true) + } + return false + case *ast.Constraint: + found = found || refused(v, false) + } + return !found + }) + return found +} + +// typeSchemaMissing reports whether a statement other than a DROP names a +// type in a schema the target certainly lacks, which the server refuses +// when it resolves the type. A system schema is not in the snapshot, but +// exists. +func (s *scan) typeSchemaMissing(n ast.Node) bool { + if s.index == nil { + return false + } + if _, ok := n.(*ast.DropStmt); ok { + return false + } + missing := false + ast.Inspect(n, func(m ast.Node) bool { + tn, ok := m.(*ast.TypeName) + if !ok || missing { + return !missing + } + parts := nameParts(tn.Names) + if len(parts) == 2 && !tn.PctType && !strings.HasPrefix(parts[0], "pg_") && parts[0] != "information_schema" && + s.schemaMissing(&ast.RangeVar{Schemaname: parts[0]}) { + missing = true + } + return true + }) + return missing +} + +// namesMissing reports whether a statement the scan does not otherwise +// follow names, as the relation it acts on, one the target certainly lacks +// (a trigger, policy, rule, statistics, LOCK, TRUNCATE, REFRESH, CLUSTER, +// VACUUM, GRANT, or COMMENT ON a relation or its column), or a trigger's +// function it certainly lacks. +func (s *scan) namesMissing(n ast.Node) bool { + if s.index == nil { + return false + } + switch v := n.(type) { + case *ast.CreateTrigStmt: + if fn, ok := routineOf(&ast.ObjectWithArgs{Objname: v.Funcname}); ok && (s.routineMissing(fn, ast.OBJECT_FUNCTION) || s.noTriggerFunction(fn)) { + return true + } + if !v.Replace && v.Relation != nil && (s.triggerTaken(v.Relation, v.Trigname) || s.createdTriggers[triggerKey(s.schemaOf(v.Relation), v.Relation.Relname, v.Trigname)]) { + return true + } + if v.Relation != nil { + if _, kind, ok := s.lookup(v.Relation); ok && (kind == kindMatView || kind == kindSequence || kind == kindIndex || kind == kindCompositeType) { + return true + } + if c, made := s.madeAt(v.Relation); made && c.kind != kindTable && c.kind != kindView && c.kind != kindForeignTable { + return true + } + } + case *ast.CommentStmt: + parts := nameParts(listOf(v.Object)) + column := "" + switch v.Objtype { + case ast.OBJECT_COLUMN: + if len(parts) < 2 { + return false + } + column, parts = parts[len(parts)-1], parts[:len(parts)-1] + case ast.OBJECT_TABLE, ast.OBJECT_VIEW, ast.OBJECT_MATVIEW, ast.OBJECT_FOREIGN_TABLE, ast.OBJECT_SEQUENCE, ast.OBJECT_INDEX: + default: + return false + } + if len(parts) == 0 || len(parts) > 2 { + return false + } + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) == 2 { + rv.Schemaname = parts[0] + } + if s.missing(rv) { + return true + } + if t, ok := s.table(rv); ok && column != "" && !s.hasColumn(t, column) { + return true + } + if schema, kind, ok := s.lookup(rv); ok && kind == kindView && column != "" { + view := s.index.schemas[schema].views[rv.Relname] + return !s.replacedViews[tableRef{schema, rv.Relname}] && len(view.GetColumns()) > 0 && + !slices.ContainsFunc(view.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == column }) + } + return false + case *ast.TruncateStmt: + // TRUNCATE takes tables, partitioned or foreign. + if v.Relations != nil { + for _, item := range v.Relations.Items { + rv, ok := item.(*ast.RangeVar) + if !ok { + continue + } + if _, kind, ok := s.lookup(rv); ok && kind != kindTable && kind != kindPartition && kind != kindForeignTable && kind != kindAmbiguous { + return true + } + if c, made := s.madeAt(rv); made && c.kind != kindTable && c.kind != kindForeignTable { + return true + } + } + } + case *ast.CreatePolicyStmt, *ast.AlterPolicyStmt, *ast.RuleStmt, *ast.CreateStatsStmt, *ast.LockStmt, + *ast.RefreshMatViewStmt, *ast.ClusterStmt, *ast.VacuumStmt, *ast.GrantStmt: + default: + return false + } + missing := false + ast.Inspect(n, func(m ast.Node) bool { + switch r := m.(type) { + case *ast.RangeVar: + missing = missing || s.missing(r) + case *ast.SelectStmt, *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt: + // A rule's actions and a policy's expressions name relations + // of their own, CTEs among them. + return false + } + return true + }) + return missing +} + +// triggerKey names a trigger of a relation. +func triggerKey(schema, table, trigger string) string { + return schema + "\x00" + table + "\x00" + trigger +} + +// noTriggerFunction reports whether a trigger's function name certainly +// has no routine without arguments, which a trigger calls: every routine +// of the name, the change's and the snapshot's, takes some. +func (s *scan) noTriggerFunction(fn tableRef) bool { + if s.index == nil || s.madeRoutines[fn.table] || s.movedRoutines[fn.table] { + return false + } + schema, ok := s.routineSchema(fn) + if !ok { + return false + } + synced := tableRef{schema, fn.table} + n := s.index.functions[synced] + return n > 0 && len(s.index.signatures[synced]) == n && !s.index.noArgs[synced] && !slices.Contains(s.index.signatures[synced], "") +} + +// noteTriggers records the trigger names a statement creates, drops, or +// renames, and the triggers the change created, by relation. +func (s *scan) noteTriggers(n ast.Node) { + switch v := n.(type) { + case *ast.CreateTrigStmt: + s.triggersChanged[v.Trigname] = true + if v.Relation != nil { + s.createdTriggers[triggerKey(s.schemaOf(v.Relation), v.Relation.Relname, v.Trigname)] = true + } + case *ast.RenameStmt: + if v.RenameType == ast.OBJECT_TRIGGER { + s.triggersChanged[v.Subname] = true + s.triggersChanged[v.Newname] = true + } + case *ast.DropStmt: + if ast.ObjectType(v.RemoveType) == ast.OBJECT_TRIGGER && v.Objects != nil { + for _, obj := range v.Objects.Items { + parts := nameParts(listOf(obj)) + if len(parts) == 0 { + continue + } + trigger := parts[len(parts)-1] + s.triggersChanged[trigger] = true + // A trigger the change dropped from its relation may be + // created there again. + if len(parts) >= 2 { + rv := &ast.RangeVar{Relname: parts[len(parts)-2]} + if len(parts) >= 3 { + rv.Schemaname = parts[len(parts)-3] + } + delete(s.createdTriggers, triggerKey(s.schemaOf(rv), rv.Relname, trigger)) + } + } + } + } +} + +// triggerTaken reports whether a synced table or view certainly has a +// trigger of the name: the snapshot lists it, and no statement of the +// change dropped or renamed a trigger of the name or created one on it. +func (s *scan) triggerTaken(rv *ast.RangeVar, name string) bool { + if s.triggersChanged[name] { + return false + } + schema, kind, ok := s.lookup(rv) + if !ok { + return false + } + var triggers []*metadata.TriggerMetadata + switch kind { + case kindTable: + triggers = s.index.schemas[schema].tables[rv.Relname].GetTriggers() + case kindView: + triggers = s.index.schemas[schema].views[rv.Relname].GetTriggers() + } + return slices.ContainsFunc(triggers, func(t *metadata.TriggerMetadata) bool { return t.GetName() == name }) +} + +// ownedByRefused reports whether ALTER SEQUENCE ... OWNED BY certainly +// fails: it names a table the target lacks, or a column a known table +// lacks. +func (s *scan) ownedByRefused(options *ast.List) bool { + if s.index == nil || options == nil { + return false + } + for _, item := range options.Items { + d, ok := item.(*ast.DefElem) + if !ok || d.Defname != "owned_by" { + continue + } + parts := nameParts(listOf(d.Arg)) + if len(parts) < 2 || len(parts) > 3 { + continue + } + rv := &ast.RangeVar{Relname: parts[len(parts)-2]} + if len(parts) == 3 { + rv.Schemaname = parts[0] + } + if s.missing(rv) { + return true + } + if t, ok := s.table(rv); ok && !s.hasColumn(t, parts[len(parts)-1]) { + return true + } + if def, ok := s.madeDef(rv); ok && !def.columns[parts[len(parts)-1]] { + return true + } + } + return false +} + +// createdIn returns the schema a CREATE of a routine, a type, or a table +// by query writes its object's name in, or "". +func createdIn(n ast.Node) string { + var parts []string + switch v := n.(type) { + case *ast.CreateFunctionStmt: + parts = nameParts(v.Funcname) + case *ast.CreateEnumStmt: + parts = nameParts(v.TypeName) + case *ast.CreateDomainStmt: + parts = nameParts(v.Domainname) + case *ast.CreateRangeStmt: + parts = nameParts(v.TypeName) + case *ast.DefineStmt: + if v.Kind == ast.OBJECT_TYPE { + parts = nameParts(v.Defnames) + } + case *ast.CompositeTypeStmt: + if v.Typevar != nil { + return v.Typevar.Schemaname + } + case *ast.CreateForeignTableStmt: + if v.Base.Relation != nil { + return v.Base.Relation.Schemaname + } + case *ast.CreateTableAsStmt: + if v.Into != nil && v.Into.Rel != nil { + return v.Into.Rel.Schemaname + } + case *ast.SelectStmt: + if into := intoOf(v); into != nil && into.Rel != nil { + return into.Rel.Schemaname + } + } + if len(parts) == 2 { + return parts[0] + } + return "" +} + +// createAs follows CREATE TABLE AS, CREATE MATERIALIZED VIEW, and SELECT +// INTO, which make a relation from a query. One whose name is taken, or +// whose query reads a relation the target lacks, is refused, and under IF +// NOT EXISTS of a taken name does nothing. +func (s *scan) createAs(rel *ast.RangeVar, ifNotExists bool, query ast.Node, matview bool) { + if rel == nil { + return + } + taken := s.existsWhereCreated(rel) || s.madeHere(rel) + switch { + case s.schemaMissing(rel) || s.typeExists(rel) || s.readsMissing(query) || taken && !ifNotExists: + s.stop() + return + case taken: + return + } + s.touch(rel) + if !ifNotExists { + kind := kindTable + if matview { + kind = kindMatView + } + s.made(rel, kind) + } + if matview { + s.reads(rel, query) + } +} + +// replacesColumns reports whether CREATE OR REPLACE VIEW certainly changes +// a synced view's columns, which the server refuses: an existing column +// gets another name, or goes. A column whose name the query does not +// tell is not compared, and nothing after a star is. +// +// pg: src/backend/commands/view.c — checkViewColumns +func replacesColumns(old *metadata.ViewMetadata, v *ast.ViewStmt) bool { + outs, star, ok := viewOutputs(v) + if old == nil || !ok { + return false + } + for i, c := range old.GetColumns() { + if i >= len(outs) { + return !star + } + if outs[i].name != "" && outs[i].name != c.GetName() { + return true + } + if want, known := normalizeType(c.GetType()); known && outs[i].typ != "" && outs[i].typ != want { + return true + } + } + return false +} + +// viewOutput is an output column of a view's query: its name and type, +// each "" when the query does not tell. +type viewOutput struct{ name, typ string } + +// viewOutputs reads the output columns a view's query determines, before +// any *: names from the column aliases, an AS name, or a plain column +// reference, and types from a cast or a constant. star marks a list that +// ends at a *, past which nothing is known; ok is false for a query whose +// outputs the scan cannot list. +func viewOutputs(v *ast.ViewStmt) (outs []viewOutput, star, ok bool) { + sel, isSelect := v.Query.(*ast.SelectStmt) + if !isSelect || sel.Op != ast.SETOP_NONE || sel.TargetList == nil { + return nil, false, false + } + for _, item := range sel.TargetList.Items { + rt, isTarget := item.(*ast.ResTarget) + if !isTarget { + return nil, false, false + } + out := viewOutput{name: rt.Name, typ: exprType(rt.Val)} + if ref, isRef := rt.Val.(*ast.ColumnRef); isRef && ref.Fields != nil && len(ref.Fields.Items) > 0 { + switch f := ref.Fields.Items[len(ref.Fields.Items)-1].(type) { + case *ast.String: + if out.name == "" { + out.name = f.Str + } + case *ast.A_Star: + star = true + } + } + if star { + break + } + outs = append(outs, out) + } + for i, alias := range nameParts(v.Aliases) { + if i < len(outs) { + outs[i].name = alias + } + } + return outs, star, true +} + +// duplicateOutputs reports whether a view names an output column twice, +// as its column aliases or its query determine them, or gives more column +// names than its query has outputs, which the server refuses. +func duplicateOutputs(v *ast.ViewStmt) bool { + aliases := nameParts(v.Aliases) + if hasDuplicate(aliases) { + return true + } + outs, star, ok := viewOutputs(v) + if !ok { + return false + } + // More column names than outputs is refused too. + if !star && len(aliases) > len(outs) { + return true + } + var names []string + for _, o := range outs { + if o.name != "" { + names = append(names, o.name) + } + } + return hasDuplicate(names) +} + +// exprType returns the type of an expression whose type it states: a cast +// to a built-in type, or a constant; "" otherwise. +func exprType(n ast.Node) string { + switch e := n.(type) { + case *ast.TypeCast: + if e.TypeName == nil { + return "" + } + parts := nameParts(e.TypeName.Names) + if len(parts) == 2 && parts[0] == "pg_catalog" { + parts = parts[1:] + } + if len(parts) != 1 { + return "" + } + if t, ok := normalizeType(parts[0]); ok { + if e.TypeName.ArrayBounds != nil && len(e.TypeName.ArrayBounds.Items) > 0 { + t += "[]" + } + return t + } + case *ast.A_Const: + switch c := e.Val.(type) { + case *ast.Integer: + if c.Ival >= -1<<31 && c.Ival < 1<<31 { + return "int4" + } + return "int8" + case *ast.Float: + return "numeric" + case *ast.String: + return "text" + case *ast.Boolean: + return "bool" + } + } + return "" +} + +// normalizeType reads a type as the snapshot or a statement writes it into +// the parser's name for a built-in type, without type modifiers. ok is +// false for any other type. +func normalizeType(t string) (string, bool) { + array := strings.HasSuffix(t, "[]") + t = strings.TrimSuffix(t, "[]") + if i := strings.Index(t, "("); i >= 0 { + t = strings.TrimSpace(t[:i]) + } + if mapped, ok := sqlTypes[t]; ok { + t = mapped + } else if !slices.Contains([]string{"int2", "int4", "int8", "float4", "float8", "bool", "varchar", "bpchar", "timestamptz", "timetz", "varbit"}, t) { + return "", false + } + if array { + t += "[]" + } + return t, true +} + +// isProcedure reports whether CREATE FUNCTION is CREATE PROCEDURE, which +// the parser marks with an option. +func isProcedure(v *ast.CreateFunctionStmt) bool { + if v.Options == nil { + return false + } + return slices.ContainsFunc(v.Options.Items, func(n ast.Node) bool { + d, ok := n.(*ast.DefElem) + return ok && d.Defname == "isProcedure" + }) +} + +// stop ends the scan: nothing after this statement is known, and no table +// is known to end the change without a key. +func (s *scan) stop() { + s.stopped = true +} + +func (s *scan) statement(st *statement) { + // A CREATE in a schema that certainly does not exist is refused, and + // so is a statement on a relation the target certainly lacks. + if schema := createdIn(st.node); schema != "" && s.schemaMissing(&ast.RangeVar{Schemaname: schema}) || s.namesMissing(st.node) || + tempOutsideTemp(st.node) || s.typeSchemaMissing(st.node) || constraintAttrsRefused(st.node) { + s.stop() + return + } + s.forgetKeyColumns(st.node) + s.noteColumnUses(st.node) + s.noteTriggers(st.node) + switch v := st.node.(type) { + case *ast.AlterTableStmt: + s.alterTable(st, v) + case *ast.CreateStmt: + s.create(st, v) + case *ast.CreateForeignTableStmt: + rel := v.Base.Relation + if rel == nil { + return + } + taken := s.existsWhereCreated(rel) || s.madeHere(rel) + switch { + case duplicateNames(&v.Base) || checksRefused(&v.Base) || s.sourceRefused(&v.Base): + s.stop() + return + case s.typeExists(rel) || taken && !v.Base.IfNotExists: + s.stop() + return + case taken: + // IF NOT EXISTS of a taken name does nothing. + return + } + s.touch(rel) + if !v.Base.IfNotExists || s.createsNew(rel) { + s.made(rel, kindForeignTable) + } + case *ast.CreateTableAsStmt: + if v.Into != nil { + s.createAs(v.Into.Rel, v.IfNotExists, v.Query, v.Objtype == ast.OBJECT_MATVIEW) + } + case *ast.ViewStmt: + // CREATE VIEW of a name its schema holds, or OR REPLACE of a + // relation that is not a view, is refused. + if s.schemaMissing(v.View) || !v.Replace && (s.existsWhereCreated(v.View) || s.madeHere(v.View)) || s.typeExists(v.View) || s.readsMissing(v.Query) || duplicateOutputs(v) { + s.stop() + return + } + if v.Replace { + schema, kind, ok := s.lookup(v.View) + if ok && kind != kindView && kind != kindAmbiguous { + s.stop() + return + } + if c, made := s.madeAt(v.View); made && c.kind != kindView { + s.stop() + return + } + if ok && kind == kindView { + if replacesColumns(s.index.schemas[schema].views[v.View.Relname], v) { + s.stop() + return + } + s.replacedViews[tableRef{schema, v.View.Relname}] = true + } + s.retire(s.newReads, tableRef{v.View.Schemaname, v.View.Relname}) + } + s.touch(v.View) + if !v.Replace { + s.made(v.View, kindView) + } + s.reads(v.View, v.Query) + case *ast.CreateFunctionStmt: + // A SQL-standard body is resolved when the routine is created, and + // a signature the schema has is taken without OR REPLACE. + if v.SqlBody != nil && s.readsMissing(v.SqlBody) || !v.IsOrReplace && s.signatureTaken(v) || v.IsOrReplace && s.replacesOtherKind(v) { + s.stop() + return + } + s.returns(v) + case *ast.AlterSeqStmt: + // ALTER SEQUENCE of a relation the target lacks is refused, and + // under IF EXISTS does nothing; of another kind, it is refused. + if v.Sequence != nil && s.index != nil { + if s.missing(v.Sequence) { + if !v.MissingOk { + s.stop() + } + return + } + _, kind, ok := s.lookup(v.Sequence) + c, made := s.madeAt(v.Sequence) + if ok && kind != kindSequence && kind != kindAmbiguous || made && c.kind != kindSequence { + s.stop() + return + } + } + if s.ownedByRefused(v.Options) { + s.stop() + return + } + if v.Sequence != nil && v.Options != nil && slices.ContainsFunc(v.Options.Items, func(n ast.Node) bool { + d, ok := n.(*ast.DefElem) + return ok && d.Defname == "owned_by" + }) { + s.reowned[[2]string{s.schemaOf(v.Sequence), v.Sequence.Relname}] = true + } + case *ast.CreateSeqStmt: + exists := s.existsWhereCreated(v.Sequence) || s.madeHere(v.Sequence) + switch { + case s.schemaMissing(v.Sequence) || exists && !v.IfNotExists || s.ownedByRefused(v.Options): + s.stop() + return + case exists: + // IF NOT EXISTS of an existing name does nothing. + return + } + s.touch(v.Sequence) + if !v.IfNotExists || s.createsNew(v.Sequence) { + s.made(v.Sequence, kindSequence) + } + case *ast.CompositeTypeStmt: + if v.Typevar != nil && s.typeTaken(v.Typevar) || duplicateNames(&ast.CreateStmt{TableElts: v.Coldeflist}) { + s.stop() + return + } + s.touch(v.Typevar) + if v.Typevar != nil { + s.made(v.Typevar, kindCompositeType) + } + case *ast.AlterOwnerStmt: + if s.ownerTargetMissing(v) { + s.stop() + return + } + case *ast.CreateEnumStmt: + // An enum names each label once. + if hasDuplicate(nameParts(v.Vals)) { + s.stop() + return + } + // A type's name is taken for a relation's row type too. + s.touchType(v.TypeName, "") + case *ast.CreateDomainStmt: + // A domain's check names nothing but VALUE. + if slices.ContainsFunc(constraintsOf(v.Constraints), func(c *ast.Constraint) bool { + return c.Contype == ast.CONSTR_CHECK && slices.ContainsFunc(columnsIn(c.RawExpr), func(name string) bool { return name != "value" }) + }) { + s.stop() + return + } + s.touchType(v.Domainname, "") + case *ast.AlterEnumStmt: + if s.enumMissing(v.Typname) { + s.stop() + return + } + case *ast.CreateRangeStmt: + // A range type comes with a multirange type, named after it unless + // the statement names it. + s.touchType(v.TypeName, "_multirange") + case *ast.DefineStmt: + if v.Kind == ast.OBJECT_TYPE { + s.touchType(v.Defnames, "") + } + case *ast.IndexStmt: + if s.indexRefused(v) { + s.stop() + return + } + if v.IfNotExists && v.Idxname != "" && v.Relation != nil { + if schema, _, ok := s.lookup(v.Relation); ok && s.nameTaken(schema, v.Idxname) { + // IF NOT EXISTS of a taken name does nothing. + return + } + } + if (v.Unique || v.Primary) && v.Relation != nil { + s.newKey(v.Relation) + } + if v.Idxname == "" { + s.generate(v.Relation) + } + if v.Relation == nil || s.mayHavePartitions(v.Relation) { + // A partition's index takes a generated name, in the + // partition's schema. + s.generate(nil) + } + if v.Idxname != "" && v.Relation != nil { + s.touchName(v.Relation.Schemaname, v.Idxname) + } + case *ast.SelectStmt: + switch { + case v.IntoClause != nil: + q := *v + q.IntoClause = nil + s.createAs(v.IntoClause.Rel, false, &q, false) + case selectsInto(v): + s.stop() + case s.readsMissing(v): + // A query of a relation the target lacks is refused. + s.stop() + default: + s.data(v) + } + case *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt, *ast.MergeStmt: + if s.readsMissing(v) { + s.stop() + return + } + s.data(v) + case *ast.CopyStmt: + if s.copyRefused(v) { + s.stop() + } + case *ast.ReindexStmt: + if s.reindexRefused(v) { + s.stop() + } + case *ast.AlterFunctionStmt: + if fn, ok := routineOf(v.Func); ok && s.routineRefused(fn, v.Func, v.Objtype) { + s.stop() + } + case *ast.ExplainStmt: + // EXPLAIN plans the query, which needs its relations. + switch q := v.Query.(type) { + case *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt, *ast.MergeStmt: + if s.readsMissing(q) { + s.stop() + return + } + case *ast.SelectStmt: + if q.IntoClause == nil && s.readsMissing(q) { + s.stop() + return + } + } + if !explainAnalyzes(v) { + return + } + switch q := v.Query.(type) { + case *ast.CreateTableAsStmt: + if q.Into != nil { + s.createAs(q.Into.Rel, q.IfNotExists, q.Query, q.Objtype == ast.OBJECT_MATVIEW) + } + case *ast.SelectStmt: + if q.IntoClause != nil || selectsInto(q) { + s.stop() + return + } + s.data(q) + default: + s.data(q) + } + case *ast.RenameStmt: + s.rename(v) + case *ast.AlterObjectSchemaStmt: + s.setSchema(v) + case *ast.DropStmt: + s.drop(v) + case *ast.CreateSchemaStmt: + s.createSchema(st, v) + case *ast.DoStmt, *ast.CallStmt: + // Procedural code may run any DDL. + s.stop() + case *ast.CreateExtensionStmt: + // An extension's script may create any object. IF NOT EXISTS of an + // extension the target has does nothing. + if !v.IfNotExists || s.index == nil || !s.index.extensions[v.Extname] { + s.stop() + } + case *ast.DropOwnedStmt, *ast.ImportForeignSchemaStmt, *ast.AlterExtensionStmt, *ast.DiscardStmt, *ast.LoadStmt: + // What these drop, create, or reset is not in the statement. + s.stop() + case *ast.CreateEventTrigStmt, *ast.AlterEventTrigStmt: + // An event trigger runs code on every later DDL statement. + s.stop() + case *ast.TransactionStmt: + switch v.Kind { + case ast.TRANS_STMT_ROLLBACK, ast.TRANS_STMT_ROLLBACK_TO, ast.TRANS_STMT_PREPARE, + ast.TRANS_STMT_COMMIT_PREPARED, ast.TRANS_STMT_ROLLBACK_PREPARED: + s.stop() + } + case *ast.VariableSetStmt: + s.setting(v) + } +} + +// newReference is a foreign key the change created: the key it references, +// by the table as written, "" for a schema left to the search path, and +// the columns, nil for the primary key; and the table that owns it, as +// written, with the search path it was written under, and its name, "" +// when generated. +type newReference struct { + schema, table string + columns []string + + owner tableRef + ownerPath int + name string + retired bool +} + +// references records the keys new foreign keys of the owner reference. +func (s *scan) references(owner *ast.RangeVar, constraints ...*ast.Constraint) { + for _, c := range constraints { + if c.Contype == ast.CONSTR_FOREIGN && c.Pktable != nil { + s.newReferences = append(s.newReferences, newReference{ + schema: s.schemaOf(c.Pktable), table: c.Pktable.Relname, columns: nameParts(c.PkAttrs), + owner: tableRef{owner.Schemaname, owner.Relname}, ownerPath: s.pathVersion, name: c.Conname, + }) + } + } +} + +// retireReferences records that a statement dropped the new foreign keys of +// a table, or the one of that name when name is set. Only a table named +// as the foreign key's owner was, under the same search path when neither +// is qualified, is known to be that owner. +func (s *scan) retireReferences(owner *ast.RangeVar, name string) { + for i := range s.newReferences { + r := &s.newReferences[i] + same := r.owner.table == owner.Relname && r.owner.schema == owner.Schemaname && + (owner.Schemaname != "" || r.ownerPath == s.pathVersion) + if same && (name == "" || r.name == name) { + r.retired = true + } + } +} + +// newlyReferenced reports whether a foreign key the change created may +// reference the table's key with these columns, nil when they cannot be +// read; primary tells whether the key is the primary key. A foreign key +// of a table in except, as written, does not count. +func (s *scan) newlyReferenced(t tableRef, columns []string, primary bool, except ...tableRef) bool { + for _, r := range s.newReferences { + if r.retired || r.table != t.table || r.schema != "" && r.schema != t.schema { + continue + } + // A foreign key of a table the statement drops too goes with it. + if slices.ContainsFunc(except, func(o tableRef) bool { return r.owner == o && (o.schema != "" || r.ownerPath == s.pathVersion) }) { + continue + } + if columns == nil || r.columns == nil && primary || r.columns != nil && sameColumns(r.columns, columns) { + return true + } + } + return false +} + +// newlyReferencedColumn reports whether a foreign key the change created +// may reference a key of the table that has the column; keyColumns are the +// table's primary key columns, nil when it has none or they cannot be read. +func (s *scan) newlyReferencedColumn(t tableRef, column string, keyColumns []string, hasKey bool) bool { + for _, r := range s.newReferences { + if r.retired || r.table != t.table || r.schema != "" && r.schema != t.schema { + continue + } + if r.columns != nil && slices.Contains(r.columns, column) || r.columns == nil && hasKey && (keyColumns == nil || slices.Contains(keyColumns, column)) { + return true + } + } + return false +} + +// newDependency is an object the change created that depends on a +// relation: the relation, in the schema it resolves to, the one written, +// or "" when neither is known, and the object as written, with the search +// path it was written under. +type newDependency struct { + relation tableRef + object tableRef + path int + retired bool + // signature is a new function's argument types, as written. + signature string + // home is the synced schema a new view was made in, "" when unknown, + // and made numbers the touch that made it. + home string + made int +} + +// reads records the relations a new view's query names. An unqualified +// name of a CTE visible where it is used is the CTE, not a relation: a +// CTE is visible to the query of its WITH clause and to the CTEs after it, +// and, under WITH RECURSIVE, to every CTE of the clause. +func (s *scan) reads(view *ast.RangeVar, query ast.Node) { + first := len(s.newReads) + s.dependsIn(&s.newReads, tableRef{view.Schemaname, view.Relname}, query, nil) + home, _ := s.creationSchema(view) + for i := first; i < len(s.newReads); i++ { + s.newReads[i].home = home + s.newReads[i].made = s.lastTouch[view.Relname] + } +} + +// dropNewReaders records the views the change created that a cascading +// drop of a relation certainly takes: those whose query named it, resolved +// to it. A view whose name no statement used since it was made is freed in +// the schema it was made in, and the views reading it go too. +func (s *scan) dropNewReaders(t tableRef) { + s.dropReturners(t) + for i := range s.newReads { + d := s.newReads[i] + if d.retired || d.relation != t { + continue + } + for j := range s.newReads { + if o := &s.newReads[j]; o.object == d.object && o.path == d.path && o.made == d.made { + o.retired = true + } + } + if d.home != "" && s.lastTouch[d.object.table] == d.made { + view := tableRef{d.home, d.object.table} + s.free(view.schema, view.table) + s.dropNewReaders(view) + } + } +} + +// dependsIn records, as dependencies of object, the relations a query or +// SQL-standard routine body names, CTE names aside. +func (s *scan) dependsIn(deps *[]newDependency, object tableRef, n ast.Node, visible map[string]bool) { + relationsIn(n, visible, func(rv *ast.RangeVar) { + *deps = append(*deps, newDependency{ + relation: tableRef{s.schemaOf(rv), rv.Relname}, + object: object, + path: s.pathVersion, + }) + }) +} + +// reindexRefused reports whether the server certainly refuses a REINDEX: +// of a relation the target lacks or of a kind it does not take (TABLE +// takes a table or materialized view, INDEX an index), or of a schema the +// target lacks. +// +// pg: src/backend/commands/indexcmds.c — RangeVarCallbackForReindexIndex, +// ReindexTable +func (s *scan) reindexRefused(v *ast.ReindexStmt) bool { + if s.index == nil { + return false + } + switch v.Kind { + case ast.REINDEX_OBJECT_SCHEMA: + return !strings.HasPrefix(v.Name, "pg_") && v.Name != "information_schema" && s.schemaMissing(&ast.RangeVar{Schemaname: v.Name}) + case ast.REINDEX_OBJECT_TABLE, ast.REINDEX_OBJECT_INDEX: + default: + return false + } + if v.Relation == nil { + return false + } + if s.missing(v.Relation) { + return true + } + kind := relationKind(0) + if _, k, ok := s.lookup(v.Relation); ok { + kind = k + } else if c, made := s.madeAt(v.Relation); made { + kind = c.kind + } + switch { + case kind == 0 || kind == kindAmbiguous: + return false + case v.Kind == ast.REINDEX_OBJECT_INDEX: + return kind != kindIndex + } + return kind != kindTable && kind != kindPartition && kind != kindMatView +} + +// routineRefused reports whether ALTER FUNCTION, PROCEDURE, or ROUTINE +// certainly names no one routine of its kind: a name alone that several +// routines have, a name no routine of the kind has, or a signature none +// has. +func (s *scan) routineRefused(fn tableRef, owa *ast.ObjectWithArgs, kind ast.ObjectType) bool { + return owa.ArgsUnspecified && (s.routinesOf(fn) > 1 || fn.schema == "" && len(s.newSignatures[fn]) > 1) || + s.routineMissing(fn, kind) || s.signatureMissing(fn, owa) +} + +// copyRefused reports whether the server certainly refuses a COPY for the +// relation or query it copies: one names a relation the target lacks or a +// query cannot read, or the relation is of a kind COPY cannot read from +// (a view, materialized view, foreign table, or sequence) or, FROM a +// file, write to (a materialized view or sequence). +// +// pg: src/backend/commands/copyto.c — BeginCopyTo; copyfrom.c — +// CopyFrom +func (s *scan) copyRefused(v *ast.CopyStmt) bool { + if v.Query != nil && s.readsMissing(v.Query) { + return true + } + if v.Relation == nil { + return false + } + if s.readsMissing(v.Relation) { + return true + } + kind := relationKind(0) + if _, k, ok := s.lookup(v.Relation); ok { + kind = k + } else if c, made := s.madeAt(v.Relation); made { + kind = c.kind + } + switch kind { + case kindMatView, kindSequence: + return true + case kindView, kindForeignTable: + return !v.IsFrom + } + return false +} + +// readsMissing reports whether a query names a relation the target +// certainly lacks, or one a query cannot read (an index or a composite +// type), CTE names aside. +func (s *scan) readsMissing(n ast.Node) bool { + if s.index == nil { + return false + } + missing := false + relationsIn(n, nil, func(rv *ast.RangeVar) { + if s.missing(rv) { + missing = true + } else if _, kind, ok := s.lookup(rv); ok && (kind == kindIndex || kind == kindCompositeType) { + missing = true + } + }) + return missing +} + +// relationsIn calls fn for each relation a query or SQL-standard routine +// body names. An unqualified name of a CTE visible where it is used is the +// CTE, not a relation. +func relationsIn(n ast.Node, visible map[string]bool, fn func(*ast.RangeVar)) { + ast.Inspect(n, func(n ast.Node) bool { + if w, rest := splitWith(n); w != nil { + scope := make(map[string]bool, len(visible)) + for name := range visible { + scope[name] = true + } + var ctes []*ast.CommonTableExpr + if w.Ctes != nil { + for _, item := range w.Ctes.Items { + if c, ok := item.(*ast.CommonTableExpr); ok { + ctes = append(ctes, c) + } + } + } + if w.Recursive { + for _, c := range ctes { + scope[c.Ctename] = true + } + } + for _, c := range ctes { + relationsIn(c.Ctequery, scope, fn) + scope[c.Ctename] = true + } + relationsIn(rest, scope, fn) + return false + } + if rv, ok := n.(*ast.RangeVar); ok && !(rv.Schemaname == "" && visible[rv.Relname]) { + fn(rv) + } + return true + }) +} + +// splitWith returns a statement's WITH clause and the statement without +// it, or nil when it has none. +func splitWith(n ast.Node) (*ast.WithClause, ast.Node) { + switch v := n.(type) { + case *ast.SelectStmt: + if v.WithClause != nil { + c := *v + c.WithClause = nil + return v.WithClause, &c + } + case *ast.InsertStmt: + if v.WithClause != nil { + c := *v + c.WithClause = nil + return v.WithClause, &c + } + case *ast.UpdateStmt: + if v.WithClause != nil { + c := *v + c.WithClause = nil + return v.WithClause, &c + } + case *ast.DeleteStmt: + if v.WithClause != nil { + c := *v + c.WithClause = nil + return v.WithClause, &c + } + case *ast.MergeStmt: + if v.WithClause != nil { + c := *v + c.WithClause = nil + return v.WithClause, &c + } + } + return nil, nil +} + +// returns records the relations whose row type a new function returns or +// takes, as far as its result and parameter types name one. +func (s *scan) returns(v *ast.CreateFunctionStmt) { + fn := nameParts(v.Funcname) + if len(fn) == 0 { + return + } + object := tableRef{table: fn[len(fn)-1]} + if len(fn) >= 2 { + object.schema = fn[len(fn)-2] + } + if v.IsOrReplace { + s.replaces(v, object) + } + s.madeRoutines[object.table] = true + if s.createdAs[object.table] == nil { + s.createdAs[object.table] = make(map[tableRef]bool) + } + s.createdAs[object.table][object] = true + // The result type and every parameter's type may be a row type. + types := []*ast.TypeName{v.ReturnType} + var args []ast.Node + if v.Parameters != nil { + for _, item := range v.Parameters.Items { + if p, ok := item.(*ast.FunctionParameter); ok { + types = append(types, p.ArgType) + // The signature DROP FUNCTION names is the input arguments. + if p.Mode != ast.FUNC_PARAM_OUT && p.Mode != ast.FUNC_PARAM_TABLE && p.ArgType != nil { + args = append(args, p.ArgType) + } + } + } + } + signature := argSignature(&ast.List{Items: args}) + // OR REPLACE of a signature the change created redefines that function. + if !slices.Contains(s.newSignatures[object], signature) { + s.newFunctions[object]++ + s.newSignatures[object] = append(s.newSignatures[object], signature) + } + first := len(s.newReturns) + defer func() { + for i := first; i < len(s.newReturns); i++ { + s.newReturns[i].signature = signature + } + }() + // A SQL-standard body is parsed at creation, and the server records + // the relations it names; a string body records nothing. + if v.SqlBody != nil { + s.dependsIn(&s.newReturns, object, v.SqlBody, nil) + } + for _, t := range types { + if t == nil { + continue + } + parts := nameParts(t.Names) + if len(parts) == 0 || len(parts) > 2 { + continue + } + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) == 2 { + rv.Schemaname = parts[0] + } + s.newReturns = append(s.newReturns, newDependency{relation: tableRef{s.schemaOf(rv), rv.Relname}, object: object, path: s.pathVersion}) + } +} + +// routineSignature returns the routine a CREATE FUNCTION names, as +// written, and the argument signature DROP FUNCTION matches it by. +func routineSignature(v *ast.CreateFunctionStmt) (tableRef, string, bool) { + fn := nameParts(v.Funcname) + if len(fn) == 0 { + return tableRef{}, "", false + } + object := tableRef{table: fn[len(fn)-1]} + if len(fn) >= 2 { + object.schema = fn[len(fn)-2] + } + var args []ast.Node + if v.Parameters != nil { + for _, item := range v.Parameters.Items { + if p, ok := item.(*ast.FunctionParameter); ok && p.Mode != ast.FUNC_PARAM_OUT && p.Mode != ast.FUNC_PARAM_TABLE && p.ArgType != nil { + args = append(args, p.ArgType) + } + } + } + return object, argSignature(&ast.List{Items: args}), true +} + +// replacesOtherKind reports whether CREATE OR REPLACE FUNCTION or +// PROCEDURE certainly names a synced routine of the other kind under its +// signature, which the server does not replace: the routine name, where +// the statement creates it, has only routines of the other kind, one of +// them with the signature, and no statement of the change named it. +func (s *scan) replacesOtherKind(v *ast.CreateFunctionStmt) bool { + object, signature, ok := routineSignature(v) + if !ok || s.index == nil || s.madeRoutines[object.table] || s.movedRoutines[object.table] { + return false + } + schema, ok := s.creationSchema(&ast.RangeVar{Schemaname: object.schema, Relname: object.table}) + if !ok { + return false + } + fn := tableRef{schema, object.table} + other := kindProcedure + if isProcedure(v) { + other = kindFunction + } + if s.index.routineKinds[fn] != other || s.droppedFunctions[fn] { + return false + } + return signature == "" && s.index.noArgs[fn] || slices.Contains(s.index.signatures[fn], signature) +} + +// ownerTargetMissing reports whether ALTER ... OWNER TO names a schema or +// a routine the target certainly lacks. +func (s *scan) ownerTargetMissing(v *ast.AlterOwnerStmt) bool { + if s.index == nil { + return false + } + switch v.ObjectType { + case ast.OBJECT_SCHEMA: + parts := nameParts(listOf(v.Object)) + if len(parts) != 1 { + if str, ok := v.Object.(*ast.String); ok { + parts = []string{str.Str} + } + } + return len(parts) == 1 && s.index.schemas[parts[0]] == nil && !s.schemas[parts[0]] || len(parts) == 1 && s.schemaGone[parts[0]] + case ast.OBJECT_FUNCTION, ast.OBJECT_PROCEDURE, ast.OBJECT_ROUTINE: + owa, ok := v.Object.(*ast.ObjectWithArgs) + if !ok { + return false + } + fn, ok := routineOf(owa) + return ok && (s.routineMissing(fn, v.ObjectType) || s.signatureMissing(fn, owa)) + } + return false +} + +// enumMissing reports whether ALTER TYPE ... ADD or RENAME VALUE names a +// type that is certainly no enum: in its schema, written or along the +// known search path, no enum of the name, and nothing the change made +// there may be one. +func (s *scan) enumMissing(name *ast.List) bool { + parts := nameParts(name) + if s.index == nil || len(parts) == 0 || len(parts) > 2 { + return false + } + typ := parts[len(parts)-1] + var path []string + if len(parts) == 2 { + path = []string{parts[0]} + } else { + p, ok := s.searchPath() + if !ok { + return false + } + path = p + } + for _, schema := range path { + if s.schemas[schema] || schema == "information_schema" || strings.HasPrefix(schema, "pg_") || + s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: typ}) { + return false + } + ns := s.index.schemas[schema] + if ns == nil { + continue + } + if slices.Contains(ns.types, typ) { + return false + } + // A relation's row type of the name is found first, and is no + // enum. + if _, ok := ns.relations[typ]; ok { + return true + } + } + return true +} + +// signatureTaken reports whether CREATE FUNCTION names a signature that +// certainly exists: one the change created under the name as written, or, +// without arguments, a synced routine's of that name where the statement +// creates it, which no statement dropped or renamed. +func (s *scan) signatureTaken(v *ast.CreateFunctionStmt) bool { + object, signature, ok := routineSignature(v) + if !ok { + return false + } + if slices.Contains(s.newSignatures[object], signature) { + return true + } + if s.index == nil || s.movedRoutines[object.table] { + return false + } + schema, ok := s.creationSchema(&ast.RangeVar{Schemaname: object.schema, Relname: object.table}) + fn := tableRef{schema, object.table} + if !ok || s.droppedFunctions[fn] { + return false + } + return signature == "" && s.index.noArgs[fn] || slices.Contains(s.index.signatures[fn], signature) +} + +// replaces records a CREATE OR REPLACE FUNCTION that certainly redefines +// a synced function: the only one of its name where the statement creates +// it, which, like the statement, takes no arguments, and no routine of +// the change has had the name. The synced function's dependencies are +// then the statement's. +func (s *scan) replaces(v *ast.CreateFunctionStmt, object tableRef) { + if s.index == nil || s.madeRoutines[object.table] || s.movedRoutines[object.table] { + return + } + if v.Parameters != nil { + for _, item := range v.Parameters.Items { + if p, ok := item.(*ast.FunctionParameter); !ok || p.Mode != ast.FUNC_PARAM_OUT && p.Mode != ast.FUNC_PARAM_TABLE { + return + } + } + } + schema, ok := s.creationSchema(&ast.RangeVar{Schemaname: object.schema, Relname: object.table}) + fn := tableRef{schema, object.table} + if ok && s.index.functions[fn] == 1 && s.index.noArgs[fn] && !s.droppedFunctions[fn] { + s.replacedFunctions[fn] = true + } +} + +// dependsOnNew reports whether an object the change created and has not +// dropped, other than the ones named in except, may depend on the +// relation. +func (s *scan) dependsOnNew(deps []newDependency, t tableRef, except []tableRef) bool { + for _, d := range deps { + if d.retired || d.relation.table != t.table || d.relation.schema != "" && d.relation.schema != t.schema { + continue + } + if slices.ContainsFunc(except, func(o tableRef) bool { return s.sameObject(d, o) }) { + continue + } + return true + } + return false +} + +// sameObject reports whether a name, as written, is the object a +// dependency belongs to: written alike, under the same search path when +// unqualified. +func (s *scan) sameObject(d newDependency, name tableRef) bool { + if d.object == name && (name.schema != "" || d.path == s.pathVersion) { + return true + } + // A view written unqualified is the one its schema's name means. + return d.object.table == name.table && d.object.schema == "" && d.home != "" && name.schema == d.home +} + +// retarget renames the object of the dependencies a name, as written, +// refers to. +func (s *scan) retarget(deps []newDependency, old tableRef, name string) { + for i := range deps { + if s.sameObject(deps[i], old) { + deps[i].object.table = name + } + } +} + +// retireSignature retires the dependencies of a new function written with +// that argument signature. +func (s *scan) retireSignature(fn tableRef, sig string) { + for i := range s.newReturns { + if s.sameObject(s.newReturns[i], fn) && s.newReturns[i].signature == sig { + s.newReturns[i].retired = true + } + } +} + +// argSignature writes the argument types of a routine as written, one per +// argument, for comparing a DROP's argument list with a CREATE's. +func argSignature(args *ast.List) string { + var types []string + if args != nil { + for _, item := range args.Items { + if t, ok := item.(*ast.TypeName); ok { + // A built-in type is pg_catalog's, however it is written. + parts := nameParts(t.Names) + if len(parts) == 2 && parts[0] == "pg_catalog" { + parts = parts[1:] + } + name := strings.Join(parts, ".") + if t.PctType { + name += "%TYPE" + } + if t.ArrayBounds != nil && len(t.ArrayBounds.Items) > 0 { + name += "[]" + } + types = append(types, name) + } else { + types = append(types, "?") + } + } + } + return strings.Join(types, ",") +} + +// retire records that a statement dropped an object the change created, +// named as it was written, under the same search path when unqualified. +func (s *scan) retire(deps []newDependency, object tableRef) { + for i := range deps { + if s.sameObject(deps[i], object) { + deps[i].retired = true + } + } +} + +// create follows CREATE TABLE. A CREATE TABLE of a name its schema +// already holds is refused. +func (s *scan) create(st *statement, v *ast.CreateStmt) { + exists := s.existsWhereCreated(v.Relation) + switch { + case v.Relation != nil && v.Relation.Catalogname != "" && (s.index == nil || v.Relation.Catalogname != s.index.database): + // A table in another database is refused; without a schema the + // database name is not known, and nothing is followed. + if s.index != nil { + s.stop() + } + return + case s.schemaMissing(v.Relation) || exists && !v.IfNotExists || s.typeExists(v.Relation): + // A type of the name is refused even under IF NOT EXISTS. + s.stop() + return + case exists: + // IF NOT EXISTS of an existing table does nothing. + return + } + if duplicateNames(v) || s.sourceRefused(v) || s.keyNameTaken(v) || checksRefused(v) { + s.stop() + return + } + // A name the change created and still has is taken. + if s.madeHere(v.Relation) { + if !v.IfNotExists { + s.stop() + } + return + } + refused, uncertain := s.referencesRefused(v) + if refused { + s.stop() + return + } + pending := len(s.pending) + // A table that may not be created is not reported. + if !uncertain && !s.nameUncertain(v.Relation) { + s.createTable(st, v) + } + s.touch(v.Relation) + for _, p := range s.pending[pending:] { + p.made = s.lastTouch[v.Relation.Relname] + } + if !v.IfNotExists || s.createsNew(v.Relation) { + s.made(v.Relation, kindTable) + if def, _, complete := defOf(v); complete { + // The name the server gives an unnamed primary key, when the + // scan knows it free, is the table's too. + if name := s.declaredKeyName(v); name != "" { + def.constraints[name] = true + } + if c, ok := s.madeAt(v.Relation); ok { + c.def = &def + } + } + } + if generatesNames(v) { + s.generate(v.Relation) + } + if v.TableElts == nil { + return + } + for _, item := range v.TableElts.Items { + var constraints []*ast.Constraint + switch e := item.(type) { + case *ast.ColumnDef: + constraints = constraintsOf(e.Constraints) + case *ast.Constraint: + constraints = []*ast.Constraint{e} + } + s.references(v.Relation, constraints...) + for _, c := range constraints { + // A key's index takes the constraint's name. + if makesIndex(c) && c.Conname != "" { + s.touchName(v.Relation.Schemaname, c.Conname) + } + } + } +} + +// createSchema follows CREATE SCHEMA and the statements it holds, each in +// the new schema unless it names another. A schema named after its owner +// takes the owner's name. +func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { + name := v.Schemaname + if name == "" && v.Authrole != nil { + name = v.Authrole.Rolename + } + if name == "" { + if v.SchemaElts != nil && len(v.SchemaElts.Items) > 0 { + s.stop() + } + return + } + // IF NOT EXISTS cannot carry schema elements: the server refuses it. + if v.IfNotExists && v.SchemaElts != nil && len(v.SchemaElts.Items) > 0 { + s.stop() + return + } + // CREATE SCHEMA of a schema the target has, and the change did not + // drop, or one the change created and did not drop, is refused, and + // with IF NOT EXISTS does nothing. + if s.index != nil && (s.index.schemas[name] != nil && !s.schemas[name] || s.schemas[name] && !s.schemaGone[name]) { + if !v.IfNotExists { + s.stop() + } + return + } + s.schemas[name] = true + delete(s.schemaGone, name) + if v.SchemaElts == nil { + return + } + if elementsCollide(name, v.SchemaElts) { + s.stop() + return + } + in := func(rv *ast.RangeVar) *ast.RangeVar { + if rv == nil || rv.Schemaname != "" { + return rv + } + c := *rv + c.Schemaname = name + return &c + } + for _, elt := range elementOrder(v.SchemaElts) { + switch e := elt.(type) { + case *ast.CreateStmt: + c := *e + c.Relation = in(e.Relation) + s.create(st, &c) + case *ast.ViewStmt: + if s.readsMissing(e.Query) || duplicateOutputs(e) { + s.stop() + return + } + s.touch(in(e.View)) + s.made(in(e.View), kindView) + s.reads(in(e.View), e.Query) + case *ast.CreateSeqStmt: + if s.ownedByRefused(e.Options) { + s.stop() + return + } + s.touch(in(e.Sequence)) + s.made(in(e.Sequence), kindSequence) + case *ast.IndexStmt: + c := *e + c.Relation = in(e.Relation) + if s.indexRefused(&c) { + s.stop() + return + } + if e.Idxname != "" && e.Relation != nil { + s.touchName(in(e.Relation).Schemaname, e.Idxname) + } + case *ast.CreateTrigStmt: + c := *e + c.Relation = in(e.Relation) + if s.namesMissing(&c) { + s.stop() + return + } + s.noteTriggers(&c) + case *ast.GrantStmt: + default: + s.stop() + return + } + } +} + +// elementOrder returns the elements of a CREATE SCHEMA in the order the +// server runs them: the sequences, then the tables, views, indexes, +// triggers, and grants, each kind in the order written. +// +// pg: src/backend/parser/parse_utilcmd.c — transformCreateSchemaStmtElements +func elementOrder(elts *ast.List) []ast.Node { + rank := func(n ast.Node) int { + switch n.(type) { + case *ast.CreateSeqStmt: + return 0 + case *ast.CreateStmt: + return 1 + case *ast.ViewStmt: + return 2 + case *ast.IndexStmt: + return 3 + case *ast.CreateTrigStmt: + return 4 + case *ast.GrantStmt: + return 5 + } + return 6 + } + out := slices.Clone(elts.Items) + slices.SortStableFunc(out, func(a, b ast.Node) int { return rank(a) - rank(b) }) + return out +} + +// elementsCollide reports whether the server may refuse CREATE SCHEMA for +// its elements, which run as one statement: an element in another schema, +// two elements of one name, or an element named like a name the server +// generates (for a table's key, serial column, or unnamed index) while an +// element may generate one. +// +// pg: src/backend/parser/parse_utilcmd.c — transformCreateSchemaStmtElements +func elementsCollide(schema string, elts *ast.List) bool { + names := make(map[string]bool) + generates := false + for _, elt := range elts.Items { + var rv *ast.RangeVar + name := "" + switch e := elt.(type) { + case *ast.CreateStmt: + rv = e.Relation + generates = generates || generatesNames(e) + case *ast.ViewStmt: + rv = e.View + case *ast.CreateSeqStmt: + rv = e.Sequence + case *ast.IndexStmt: + rv, name = e.Relation, e.Idxname + if name == "" { + generates = true + } + } + if rv == nil { + continue + } + if rv.Schemaname != "" && rv.Schemaname != schema { + return true + } + if _, ok := elt.(*ast.IndexStmt); !ok { + name = rv.Relname + } + if name == "" { + continue + } + if names[name] { + return true + } + names[name] = true + } + if generates { + for name := range names { + if generatedName(name) { + return true + } + } + } + return false +} + +// touchType records the name of a type a statement creates, and the +// name with suffix, when there is one, for a type it creates with it. A +// name a relation's row type or another type certainly has is refused. +func (s *scan) touchType(name *ast.List, suffix string) { + parts := nameParts(name) + if len(parts) == 0 || len(parts) > 2 { + return + } + schema := "" + if len(parts) == 2 { + schema = parts[0] + } + rv := &ast.RangeVar{Schemaname: schema, Relname: parts[len(parts)-1]} + if s.typeTaken(rv) { + s.stop() + return + } + s.touchName(schema, parts[len(parts)-1]) + s.made(rv, kindType) + if suffix != "" { + s.touchName(schema, parts[len(parts)-1]+suffix) + } +} + +// typeTaken reports whether the schema a CREATE puts a type in certainly +// holds a type of its name: a relation's row type, an enum, or what the +// change created there. +func (s *scan) typeTaken(rv *ast.RangeVar) bool { + return s.existsWhereCreated(rv) || s.typeExists(rv) || s.madeHere(rv) +} + +// indexRefused reports whether the server certainly refuses CREATE +// INDEX: its relation is one the target lacks, or cannot be indexed, or, +// without IF NOT EXISTS, its name is taken in the relation's schema. +func (s *scan) indexRefused(v *ast.IndexStmt) bool { + if s.index == nil || v.Relation == nil { + return false + } + if s.missing(v.Relation) { + return true + } + schema, kind, ok := s.lookup(v.Relation) + if !ok { + // A relation the change made: its kind, columns, and schema. + c, made := s.madeAt(v.Relation) + home, inHome := s.madeIn(v.Relation) + if !made || !inHome { + return false + } + if c.kind != kindTable && c.kind != kindMatView { + return true + } + if c.def != nil { + for _, list := range []*ast.List{v.IndexParams, v.IndexIncludingParams} { + if list == nil { + continue + } + for _, item := range list.Items { + if e, isElem := item.(*ast.IndexElem); isElem && e.Name != "" && !c.def.columns[e.Name] { + return true + } + } + } + } + return v.Idxname != "" && !v.IfNotExists && (s.nameTaken(home, v.Idxname) || s.madeHere(&ast.RangeVar{Schemaname: home, Relname: v.Idxname})) + } + switch kind { + case kindView, kindSequence, kindIndex, kindCompositeType, kindForeignTable: + return true + } + // A column the index names, plainly or in an expression, must be the + // table's. + if t, known := s.table(v.Relation); known { + for _, list := range []*ast.List{v.IndexParams, v.IndexIncludingParams} { + if list == nil { + continue + } + for _, item := range list.Items { + e, ok := item.(*ast.IndexElem) + if !ok { + continue + } + if e.Name != "" && !s.hasColumn(t, e.Name) || e.Expr != nil && slices.ContainsFunc(columnsIn(e.Expr), func(c string) bool { return !s.hasColumn(t, c) }) { + return true + } + } + } + } + return v.Idxname != "" && !v.IfNotExists && (s.nameTaken(schema, v.Idxname) || s.madeHere(&ast.RangeVar{Schemaname: schema, Relname: v.Idxname})) +} + +// typeExists reports whether the schema a CREATE puts a relation in +// certainly holds an enum type of its name, which the relation's row +// type would collide with. +func (s *scan) typeExists(rv *ast.RangeVar) bool { + schema, ok := s.creationSchema(rv) + if !ok || s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: rv.Relname}) { + return false + } + return slices.Contains(s.index.schemas[schema].types, rv.Relname) +} + +// mayHavePartitions reports whether a relation may be a partitioned table +// with partitions, whose indexes a key or index added to it gives +// generated names: any but a synced table without partitions, or a +// relation that cannot have any. +func (s *scan) mayHavePartitions(rv *ast.RangeVar) bool { + schema, kind, ok := s.lookup(rv) + switch { + case !ok || kind == kindPartition || kind == kindAmbiguous: + return true + case kind != kindTable: + return false + case s.isUnsettled(tableRef{schema, rv.Relname}): + return true + } + return len(s.index.schemas[schema].tables[rv.Relname].GetPartitions()) > 0 +} + +// tableDef is what a CREATE TABLE of the change gives its table, when the +// statement says it all: its columns and its keys, primary or unique. +type tableDef struct { + columns map[string]bool + keys [][]string + primary bool + // constraints names the constraints the statement names. + constraints map[string]bool +} + +// defOf reads a CREATE TABLE's columns and keys, and its foreign keys, +// each with its column when declared on one. complete is false when LIKE, +// INHERITS, OF, or PARTITION OF brings more. +func defOf(v *ast.CreateStmt) (def tableDef, fks []foreignKeyDecl, complete bool) { + def.columns = make(map[string]bool) + complete = (v.InhRelations == nil || len(v.InhRelations.Items) == 0) && v.OfTypename == nil && v.Partbound == nil + if v.TableElts == nil { + return def, nil, complete + } + def.constraints = make(map[string]bool) + add := func(c *ast.Constraint, column string) { + if c.Conname != "" { + def.constraints[c.Conname] = true + } + cols := nameParts(c.Keys) + if column != "" { + cols = []string{column} + } + switch c.Contype { + case ast.CONSTR_PRIMARY: + def.primary = true + def.keys = append(def.keys, cols) + case ast.CONSTR_UNIQUE: + def.keys = append(def.keys, cols) + case ast.CONSTR_FOREIGN: + fks = append(fks, foreignKeyDecl{c, column}) + } + } + for _, item := range v.TableElts.Items { + switch e := item.(type) { + case *ast.ColumnDef: + def.columns[e.Colname] = true + for _, c := range constraintsOf(e.Constraints) { + add(c, e.Colname) + } + case *ast.Constraint: + add(e, "") + case *ast.TableLikeClause: + complete = false + } + } + return def, fks, complete +} + +// foreignKeyDecl is a foreign key CREATE TABLE declares, with its column +// when declared on one. +type foreignKeyDecl struct { + c *ast.Constraint + column string +} + +// refusedBy reports whether a table of that definition has no key a +// foreign key of local referencing columns can reference with these +// columns: no primary key without columns, or none of them, or no key on +// exactly them. +func (d *tableDef) refusedBy(columns []string, local int) bool { + switch { + case len(columns) > 0 && len(columns) != local, + slices.ContainsFunc(columns, func(c string) bool { return !d.columns[c] }), + len(columns) == 0 && !d.primary, + len(columns) > 0 && !slices.ContainsFunc(d.keys, func(k []string) bool { return sameKey(k, columns) }): + return true + } + return false +} + +// refusesAlter reports whether an ALTER TABLE may fail against the table +// its CREATE made: a column it drops or alters without IF EXISTS that the +// table lacks, or one it adds without IF NOT EXISTS that the table has, a +// constraint it drops without IF EXISTS that the CREATE did not name, or +// one it adds under a name the CREATE gave, the statement's own additions +// and drops counted. +func (d *tableDef) refusesAlter(v *ast.AlterTableStmt) bool { + columns := maps.Clone(d.columns) + var cmds []*ast.AlterTableCmd + for _, item := range v.Cmds.Items { + if cmd, ok := item.(*ast.AlterTableCmd); ok { + cmds = append(cmds, cmd) + } + } + // The server drops before it adds, and adds before it alters. + constraints := maps.Clone(d.constraints) + for _, cmd := range cmds { + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_DropColumn: + if !columns[cmd.Name] && !cmd.Missing_ok { + return true + } + delete(columns, cmd.Name) + case ast.AT_DropConstraint: + // A constraint the CREATE did not name may be one the server + // named, or none: the statement may fail. + if !constraints[cmd.Name] && !cmd.Missing_ok { + return true + } + delete(constraints, cmd.Name) + } + } + for _, cmd := range cmds { + if ast.AlterTableType(cmd.Subtype) == ast.AT_AddConstraint { + if c, ok := cmd.Def.(*ast.Constraint); ok && c.Conname != "" { + if constraints[c.Conname] { + return true + } + constraints[c.Conname] = true + } + } + } + added := make(map[string]bool) + for _, cmd := range cmds { + if ast.AlterTableType(cmd.Subtype) != ast.AT_AddColumn { + continue + } + if cd, ok := cmd.Def.(*ast.ColumnDef); ok { + if columns[cd.Colname] && !cmd.Missing_ok { + return true + } + columns[cd.Colname], added[cd.Colname] = true, true + } + } + for _, cmd := range cmds { + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_ColumnDefault, ast.AT_DropNotNull, ast.AT_SetNotNull, ast.AT_AlterColumnType, ast.AT_SetStatistics, ast.AT_SetStorage: + if cmd.Name != "" && !columns[cmd.Name] && !added[cmd.Name] { + return true + } + } + } + return false +} + +// referencesRefused reports whether the server certainly refuses CREATE +// TABLE for a foreign key it declares: on a column the table lacks, or to +// a relation, column, or key as refusesReference finds, or to the table +// itself, or to one the change created and has not altered, without the +// columns or key it names. uncertain reports a foreign key the scan cannot +// check. +func (s *scan) referencesRefused(v *ast.CreateStmt) (refused, uncertain bool) { + if s.index == nil { + return false, false + } + own, fks, complete := defOf(v) + for _, fk := range fks { + if hasDuplicate(nameParts(fk.c.FkAttrs)) { + return true, false + } + if fk.column == "" && complete && slices.ContainsFunc(nameParts(fk.c.FkAttrs), func(c string) bool { return !own.columns[c] }) { + return true, false + } + pk := fk.c.Pktable + columns := nameParts(fk.c.PkAttrs) + local := max(len(nameParts(fk.c.FkAttrs)), 1) + self := pk != nil && pk.Relname == v.Relation.Relname && pk.Schemaname == v.Relation.Schemaname + ambiguous := false + if pk != nil && pk.Relname == v.Relation.Relname && !self { + // Spelled otherwise, it is the table itself when both names + // resolve to the schema CREATE puts the table in, and another + // table when they certainly resolve to different ones. + a, aok := s.homeOf(pk) + b, bok := s.homeOf(v.Relation) + switch { + case aok && bok && a == b: + self = true + case aok && bok: + pk = &ast.RangeVar{Schemaname: a, Relname: pk.Relname} + default: + ambiguous = true + } + } + switch { + case pk == nil || ambiguous: + // It may be the table itself, or another of its name. + uncertain = true + case self: + if !complete { + uncertain = true + } else if own.refusedBy(columns, local) { + return true, false + } + default: + if def, ok := s.madeDef(pk); ok { + if def.refusedBy(columns, local) { + return true, false + } + continue + } + r, u := s.refusesReference(tableRef{}, fk.c, nil, nil) + if r { + return true, false + } + uncertain = uncertain || u + } + } + return false, uncertain +} + +// homeOf returns the schema a name means for a table CREATE TABLE makes: +// the one written, or the one the search path creates in. +func (s *scan) homeOf(rv *ast.RangeVar) (string, bool) { + if rv.Schemaname != "" { + return rv.Schemaname, true + } + return s.creationSchema(rv) +} + +// checksRefused reports whether a constraint CREATE TABLE declares is +// certainly refused: a CHECK or a key naming a column the table does not +// have, when the statement says all its columns, a key naming a column +// twice, or a second primary key. +func checksRefused(v *ast.CreateStmt) bool { + own, _, complete := defOf(v) + if v.TableElts == nil { + return false + } + primaries := 0 + for _, item := range v.TableElts.Items { + var cs []*ast.Constraint + switch e := item.(type) { + case *ast.ColumnDef: + cs = constraintsOf(e.Constraints) + case *ast.Constraint: + cs = []*ast.Constraint{e} + } + for _, c := range cs { + missing := func(column string) bool { return complete && !own.columns[column] } + switch c.Contype { + case ast.CONSTR_CHECK, ast.CONSTR_GENERATED: + if slices.ContainsFunc(columnsIn(c.RawExpr), missing) { + return true + } + case ast.CONSTR_PRIMARY, ast.CONSTR_UNIQUE: + // A key names columns the table has, each once, and a table + // has one primary key. + keys := nameParts(c.Keys) + if hasDuplicate(keys) || slices.ContainsFunc(keys, missing) { + return true + } + if c.Contype == ast.CONSTR_PRIMARY { + primaries++ + } + } + } + } + return primaries > 1 +} + +// madeDef returns the definition of a table a name certainly means that +// the change created, said all of, and has not altered. +func (s *scan) madeDef(rv *ast.RangeVar) (*tableDef, bool) { + c, ok := s.madeAt(rv) + if !ok || c.kind != kindTable || c.def == nil { + return nil, false + } + return c.def, true +} + +// madeHere reports whether a CREATE puts a relation under a name the +// change created a table or view under, and no statement used since. +func (s *scan) madeHere(rv *ast.RangeVar) bool { + _, ok := s.madeAt(rv) + return ok +} + +// madeIn returns the schema of a relation the change created that a name +// certainly means. +func (s *scan) madeIn(rv *ast.RangeVar) (string, bool) { + if _, ok := s.madeAt(rv); !ok { + return "", false + } + if rv.Schemaname != "" { + return rv.Schemaname, true + } + return s.creationSchema(rv) +} + +// madeAt returns what the change created under a name, in the schema +// written or the one the search path creates in, while no statement used +// the name since. +func (s *scan) madeAt(rv *ast.RangeVar) (*madeRelation, bool) { + schema := rv.Schemaname + if schema == "" { + var ok bool + if schema, ok = s.creationSchema(rv); !ok { + return nil, false + } + } + c, ok := s.created[[2]string{schema, rv.Relname}] + if !ok || s.lastTouch[rv.Relname] != c.made { + return nil, false + } + return c, true +} + +// sourceRefused reports whether CREATE TABLE names a relation the server +// refuses to take columns from: an INHERITS parent or a LIKE source the +// target certainly lacks, a parent that is not a table, or a LIKE source +// that is a sequence or an index. +func (s *scan) sourceRefused(v *ast.CreateStmt) bool { + if s.index == nil { + return false + } + var parents, sources []*ast.RangeVar + if v.InhRelations != nil { + for _, item := range v.InhRelations.Items { + if rv, ok := item.(*ast.RangeVar); ok { + parents = append(parents, rv) + } + } + } + if v.TableElts != nil { + for _, item := range v.TableElts.Items { + if like, ok := item.(*ast.TableLikeClause); ok && like.Relation != nil { + sources = append(sources, like.Relation) + } + } + } + for _, rv := range append(parents, sources...) { + if s.missing(rv) { + return true + } + } + // OF names a composite type, which the snapshot lists as a relation. + if v.OfTypename != nil { + parts := nameParts(v.OfTypename.Names) + if len(parts) == 1 || len(parts) == 2 { + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) == 2 { + rv.Schemaname = parts[0] + } + if _, kind, ok := s.lookup(rv); ok && kind != kindCompositeType || !ok && s.missing(rv) { + return true + } + } + } + for _, rv := range parents { + if _, kind, ok := s.lookup(rv); ok && (kind == kindView || kind == kindMatView || kind == kindSequence || kind == kindIndex || kind == kindCompositeType) { + return true + } + } + for _, rv := range sources { + if _, kind, ok := s.lookup(rv); ok && (kind == kindSequence || kind == kindIndex) { + return true + } + } + return false +} + +// duplicateNames reports whether CREATE TABLE names a column, or a +// constraint, twice, which the server refuses. A column LIKE or INHERITS +// brings may merge with one the statement names. +func duplicateNames(v *ast.CreateStmt) bool { + if v.TableElts == nil { + return false + } + columns := make(map[string]bool) + constraints := make(map[string]bool) + named := func(cs []*ast.Constraint) bool { + for _, c := range cs { + if c.Conname == "" { + continue + } + if constraints[c.Conname] { + return true + } + constraints[c.Conname] = true + } + return false + } + for _, item := range v.TableElts.Items { + switch e := item.(type) { + case *ast.ColumnDef: + if columns[e.Colname] || named(constraintsOf(e.Constraints)) { + return true + } + columns[e.Colname] = true + case *ast.Constraint: + if named([]*ast.Constraint{e}) { + return true + } + } + } + return false +} + +// keyNameTaken reports whether a key CREATE TABLE names takes for its +// index a name its schema certainly holds: the table's own, or a +// relation's the synced schema or the change has. +func (s *scan) keyNameTaken(v *ast.CreateStmt) bool { + if s.index == nil || v.TableElts == nil || v.Relation == nil { + return false + } + schema := v.Relation.Schemaname + if schema == "" { + schema, _ = s.creationSchema(v.Relation) + } + for _, item := range v.TableElts.Items { + var cs []*ast.Constraint + switch e := item.(type) { + case *ast.ColumnDef: + cs = constraintsOf(e.Constraints) + case *ast.Constraint: + cs = []*ast.Constraint{e} + } + for _, c := range cs { + if !makesIndex(c) || c.Conname == "" { + continue + } + if c.Conname == v.Relation.Relname || schema != "" && (s.nameTaken(schema, c.Conname) || s.madeHere(&ast.RangeVar{Schemaname: schema, Relname: c.Conname})) { + return true + } + } + } + return false +} + +// generatesNames reports whether CREATE TABLE makes a relation under a +// name the server generates: the index of an unnamed key, the sequence +// of a serial or identity column, what LIKE copies, or the indexes a +// partition takes from its parent. +func generatesNames(v *ast.CreateStmt) bool { + if v.Partbound != nil { + return true + } + if v.TableElts == nil { + return false + } + for _, item := range v.TableElts.Items { + switch e := item.(type) { + case *ast.ColumnDef: + if columnGeneratesNames(e) { + return true + } + case *ast.Constraint: + if makesIndex(e) && e.Conname == "" { + return true + } + case *ast.TableLikeClause: + return true + } + } + return false +} + +// columnGeneratesNames reports whether a column makes a relation under a +// generated name: a serial or identity column's sequence, or the index of +// an unnamed key on it. +func columnGeneratesNames(cd *ast.ColumnDef) bool { + if cd.TypeName != nil { + if parts := nameParts(cd.TypeName.Names); len(parts) == 1 { + switch parts[0] { + case "smallserial", "serial", "bigserial", "serial2", "serial4", "serial8": + return true + } + } + } + for _, c := range constraintsOf(cd.Constraints) { + if makesIndex(c) && c.Conname == "" || c.Contype == ast.CONSTR_IDENTITY { + return true + } + } + return false +} + +// generate records that a statement may have made relations under +// generated names in the schema of a relation: the one the name resolves +// to or names, or the one CREATE puts it in, or any schema when rv is nil +// or the schema is not known. +func (s *scan) generate(rv *ast.RangeVar) { + schema := "" + if rv != nil { + if schema = s.schemaOf(rv); schema == "" { + schema, _ = s.creationSchema(rv) + } + } + s.generated[schema] = true +} + +// mayBeGenerated reports whether a CREATE's name may be one a statement +// generated for a relation of the schema the CREATE puts it in. +func (s *scan) mayBeGenerated(rv *ast.RangeVar) bool { + if !generatedName(rv.Relname) || len(s.generated) == 0 { + return false + } + schema := rv.Schemaname + if schema == "" { + var ok bool + if schema, ok = s.creationSchema(rv); !ok { + return true + } + } + return s.generated[""] || s.generated[schema] +} + +// touch records a relation name a statement creates. +func (s *scan) touch(rv *ast.RangeVar) { + if rv != nil { + s.touchName(rv.Schemaname, rv.Relname) + } +} + +// touchName records a relation name a statement changed, in schema, or +// in whichever schema the search path finds when schema is "". +func (s *scan) touchName(schema, name string) { + s.touched[[2]string{schema, name}] = true + s.touchedName[name] = true + s.touches++ + s.lastTouch[name] = s.touches + for key := range s.freed { + if key[1] == name && (schema == "" || key[0] == schema) { + delete(s.freed, key) + } + } +} + +// free records a name a resolved drop removed from its schema. +func (s *scan) free(schema, name string) { + s.touchName(schema, name) + s.freed[[2]string{schema, name}] = true +} + +// newKey records a table a statement may have given a key or a unique +// index. +func (s *scan) newKey(rv *ast.RangeVar) { + s.newKeys[[2]string{s.schemaOf(rv), rv.Relname}] = true +} + +// unsettle records a table whose columns or constraints changed in a way +// the scan does not follow. +func (s *scan) unsettle(rv *ast.RangeVar) { + s.unsettled[[2]string{s.schemaOf(rv), rv.Relname}] = true +} + +// isUnsettled reports whether a statement may have changed the table's +// columns or constraints. +func (s *scan) isUnsettled(t tableRef) bool { + return s.unsettled[[2]string{t.schema, t.table}] || s.unsettled[[2]string{"", t.table}] +} + +// columnNow reports whether a statement of the change left the table's +// column existing, and known whether one recorded anything for it. +func (s *scan) columnNow(t tableRef, column string) (exists, known bool) { + if v, ok := s.columns[[3]string{t.schema, t.table, column}]; ok { + return v, true + } + v, ok := s.columns[[3]string{"", t.table, column}] + return v, ok +} + +// originOf returns what a known table's column, by its name now, is in the +// synced table: itself when no statement renamed it, the column it was +// renamed from, or "" for a column the change added. ok is false when the +// scan cannot tell. +func (s *scan) originOf(t tableRef, column string) (origin string, ok bool) { + if s.columnRenamed(t, column) { + origin, ok = s.origins[[3]string{t.schema, t.table, column}] + return origin, ok + } + if _, changed := s.columnNow(t, column); changed { + return "", true + } + return column, true +} + +// columnRenamed reports whether the table's column has its name from a +// rename. +func (s *scan) columnRenamed(t tableRef, column string) bool { + return s.renamedColumns[[3]string{t.schema, t.table, column}] || s.renamedColumns[[3]string{"", t.table, column}] +} + +// setColumn records that a column exists or is gone after a statement. +func (s *scan) setColumn(schema, table, column string, exists, renamed bool) { + key := [3]string{schema, table, column} + s.columns[key] = exists + if renamed { + s.renamedColumns[key] = true + } else { + delete(s.renamedColumns, key) + } +} + +// isTouched reports whether a statement may have changed what the name +// means: a qualified name when a statement changed it in that schema or +// in an unknown one, an unqualified name when a statement changed it in +// any schema, which may come first on the search path. +func (s *scan) isTouched(rv *ast.RangeVar) bool { + if rv.Schemaname == "" { + return s.touchedName[rv.Relname] + } + return s.touched[[2]string{rv.Schemaname, rv.Relname}] || s.touched[[2]string{"", rv.Relname}] +} + +// lookup resolves a relation name against the synced schema. ok is false +// when the name may no longer mean what the schema says, or names nothing +// the schema holds. An unqualified name needs a known search path, and a +// name beginning with pg_ may be a system catalog, which pg_catalog finds +// first. +func (s *scan) lookup(rv *ast.RangeVar) (schema string, kind relationKind, ok bool) { + if s.index == nil || rv == nil || s.isTouched(rv) || s.schemas[rv.Schemaname] { + return "", 0, false + } + if rv.Catalogname != "" && rv.Catalogname != s.index.database { + return "", 0, false + } + if rv.Schemaname != "" { + ns := s.index.schemas[rv.Schemaname] + if ns == nil { + return "", 0, false + } + kind, ok := ns.relations[rv.Relname] + return rv.Schemaname, kind, ok + } + if strings.HasPrefix(rv.Relname, "pg_") { + return "", 0, false + } + path, ok := s.searchPath() + if !ok { + return "", 0, false + } + for _, name := range path { + if ns := s.index.schemas[name]; ns != nil { + if kind, ok := ns.relations[rv.Relname]; ok { + return name, kind, true + } + continue + } + // A system schema the snapshot leaves out may hold the name. + if name == "information_schema" { + return "", 0, false + } + } + return "", 0, false +} + +// searchPath expands "$user" in the current search path. Without a known +// current user, "$user" names no schema only when every schema of the +// database is listed by name. +func (s *scan) searchPath() ([]string, bool) { + if s.path == nil { + return nil, false + } + path := make([]string, 0, len(s.path)) + for _, name := range s.path { + switch { + case name != "$user": + path = append(path, name) + case s.user != "": + path = append(path, s.user) + case s.index.unlisted(s.path): + return nil, false + } + } + return path, true +} + +// schemaOf returns the schema a relation name means now: the one the +// synced schema resolves it to, the one the SQL wrote, or "" when neither +// is known. +func (s *scan) schemaOf(rv *ast.RangeVar) string { + if schema, _, ok := s.lookup(rv); ok { + return schema + } + return rv.Schemaname +} + +// table resolves the table an ALTER TABLE acts on, with its constraints +// as the synced schema lists them, when the change has not altered it in +// a way the scan does not follow. A partition is not a table here: its +// keys come from its parent. +func (s *scan) table(rv *ast.RangeVar) (tableRef, bool) { + schema, kind, ok := s.lookup(rv) + t := tableRef{schema, rv.Relname} + if !ok || kind != kindTable || s.isUnsettled(t) { + return tableRef{}, false + } + return t, true +} + +// setting follows SET and RESET. The scan follows SET search_path to a +// list of plain names and SET ROLE. A transaction-local setting reverts at +// commit, and RESET returns to the session's default, which the target +// does not record, so after those the path or the user is unknown. +func (s *scan) setting(v *ast.VariableSetStmt) { + if v.Kind == ast.VAR_RESET_ALL { + s.path = nil + s.pathVersion++ + return + } + switch v.Name { + case "search_path": + s.pathVersion++ + if v.Kind != ast.VAR_SET_VALUE || v.IsLocal || !plainNames(v.Args) { + s.path = nil + return + } + s.path = nil + for _, item := range v.Args.Items { + name, _ := constString(item) + s.path = append(s.path, name) + } + case "role": + s.pathVersion++ + switch { + case v.IsLocal: + s.user = "" + case v.Kind == ast.VAR_SET_DEFAULT || v.Kind == ast.VAR_RESET: + s.user = s.session + case v.Kind == ast.VAR_SET_VALUE && v.Args != nil && len(v.Args.Items) == 1: + name, _ := constString(v.Args.Items[0]) + if name == "none" { + name = s.session + } + s.user = name + default: + s.user = "" + } + case "session_authorization": + s.pathVersion++ + s.user, s.session = "", "" + } +} + +// plainNames reports whether every SET search_path value is a name the +// setting reads back unchanged: lower-case letters, digits, underscores, +// and dollar signs. A value such as 'MySchema' or 'a, b' is read as a list +// of identifiers, which the scan does not redo. +func plainNames(args *ast.List) bool { + if args == nil { + return false + } + for _, item := range args.Items { + name, ok := constString(item) + if !ok || name == "" { + return false + } + for i := 0; i < len(name); i++ { + c := name[i] + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '_' || c == '$') { + return false + } + } + } + return true +} + +// data follows a statement that changes data, not the schema. A call of +// set_config on search_path changes how later names resolve: the form +// pg_dump writes, a lone SELECT of it with constant arguments, sets the +// path, and any other call leaves it unknown. +func (s *scan) data(n ast.Node) { + calls := searchPathCalls(n) + if len(calls) == 0 { + return + } + s.pathVersion++ + s.path = nil + if path, ok := dumpSearchPath(n, calls); ok { + // An empty path is a known path that finds nothing. + s.path = append([]string{}, path...) + } +} + +// searchPathCalls lists the set_config calls that may set search_path: +// every call whose setting name is not a constant naming another setting. +func searchPathCalls(n ast.Node) []*ast.FuncCall { + var calls []*ast.FuncCall + ast.Inspect(n, func(n ast.Node) bool { + fc, ok := n.(*ast.FuncCall) + if !ok { + return true + } + parts := nameParts(fc.Funcname) + builtin := len(parts) == 1 || len(parts) == 2 && parts[0] == "pg_catalog" + if !builtin || parts[len(parts)-1] != "set_config" { + return true + } + if name, ok := constString(firstArg(fc)); ok && !strings.EqualFold(name, "search_path") { + return true + } + calls = append(calls, fc) + return true + }) + return calls +} + +// dumpSearchPath reads SELECT pg_catalog.set_config('search_path', '...', +// false), the only statement in n, and returns the path it sets. +func dumpSearchPath(n ast.Node, calls []*ast.FuncCall) ([]string, bool) { + sel, ok := n.(*ast.SelectStmt) + if !ok || len(calls) != 1 || sel.FromClause != nil || sel.WhereClause != nil || sel.WithClause != nil || + sel.TargetList == nil || len(sel.TargetList.Items) != 1 { + return nil, false + } + target, ok := sel.TargetList.Items[0].(*ast.ResTarget) + if !ok || target.Val != calls[0] || calls[0].Args == nil || len(calls[0].Args.Items) != 3 { + return nil, false + } + value, ok := constString(calls[0].Args.Items[1]) + if !ok { + return nil, false + } + local, ok := calls[0].Args.Items[2].(*ast.A_Const) + if !ok || local.Isnull { + return nil, false + } + if b, ok := local.Val.(*ast.Boolean); !ok || b.Boolval { + return nil, false + } + return parseSearchPath(value), true +} + +func firstArg(fc *ast.FuncCall) ast.Node { + if fc.Args == nil || len(fc.Args.Items) == 0 { + return nil + } + return fc.Args.Items[0] +} + +// constString returns the value of a string constant. +func constString(n ast.Node) (string, bool) { + c, ok := n.(*ast.A_Const) + if !ok || c.Isnull { + return "", false + } + str, ok := c.Val.(*ast.String) + if !ok { + return "", false + } + return str.Str, true +} + +// selectsInto reports whether a branch of a set operation carries INTO. +func selectsInto(s *ast.SelectStmt) bool { + if s == nil { + return false + } + return s.IntoClause != nil || selectsInto(s.Larg) || selectsInto(s.Rarg) +} + +// isTemp reports whether a relation is created in the temporary schema. +func isTemp(rv *ast.RangeVar) bool { + return rv != nil && (rv.Relpersistence == 't' || rv.Schemaname == "pg_temp" || strings.HasPrefix(rv.Schemaname, "pg_temp_")) +} + +// isRelationKind reports whether an object type lives in the relation +// namespace. +func isRelationKind(t ast.ObjectType) bool { + switch t { + case ast.OBJECT_TABLE, ast.OBJECT_VIEW, ast.OBJECT_MATVIEW, ast.OBJECT_FOREIGN_TABLE, ast.OBJECT_SEQUENCE, ast.OBJECT_INDEX: + return true + } + return false +} + +// rename follows RENAME. A relation renamed is touched under both names, +// and a pending table it may be keeps the new name too. An index rename +// renames the constraint the index backs, if any. +func (s *scan) rename(v *ast.RenameStmt) { + // A rename on a relation the target certainly lacks is refused, and + // under IF EXISTS does nothing. + if v.Relation != nil && s.index != nil && s.missing(v.Relation) { + if !v.MissingOk { + s.stop() + } + return + } + switch { + case isRelationKind(v.RenameType) && v.Relation != nil: + if s.alterKindRefused(v.RenameType, v.Relation) { + s.stop() + return + } + schema, _, resolved := s.lookup(v.Relation) + // A name its schema holds is refused. + if resolved && s.nameTaken(schema, v.Newname) { + s.stop() + return + } + if home, ok := s.madeIn(v.Relation); ok && (s.nameTaken(home, v.Newname) || s.madeHere(&ast.RangeVar{Schemaname: home, Relname: v.Newname})) { + s.stop() + return + } + s.follow(v.Relation, "", v.Newname) + s.followParents(v.Relation, tableRef{s.schemaOf(v.Relation), v.Newname}) + if resolved { + // The relation leaves its old name free in its schema. + s.free(schema, v.Relation.Relname) + } else { + s.touchName(v.Relation.Schemaname, v.Relation.Relname) + } + s.touchName(v.Relation.Schemaname, v.Newname) + if resolved { + s.renamedFrom[v.Newname] = renamedRelation{tableRef{schema, v.Relation.Relname}, schema, s.lastTouch[v.Newname]} + } + for _, p := range s.matching(v.Relation) { + // A rename naming the table's own known schema is that table's; + // otherwise it may be another table of that name. + if v.Relation.Schemaname != "" && v.Relation.Schemaname == p.schema { + delete(p.names, v.Relation.Relname) + } + p.names[v.Newname] = true + } + if v.RenameType == ast.OBJECT_INDEX { + s.renamedKeys[v.Relation.Relname] = true + s.renamedKeys[v.Newname] = true + } + // The change's own objects of that name, as written, keep their + // dependencies under the new name. + old := tableRef{v.Relation.Schemaname, v.Relation.Relname} + s.retarget(s.newReads, old, v.Newname) + s.retarget(s.newReturns, old, v.Newname) + for i := range s.newReferences { + r := &s.newReferences[i] + if r.owner == old && (old.schema != "" || r.ownerPath == s.pathVersion) { + r.owner.table = v.Newname + } + } + case v.RenameType == ast.OBJECT_TYPE: + if parts := nameParts(listOf(v.Object)); len(parts) > 0 { + schema := "" + if len(parts) >= 2 { + schema = parts[len(parts)-2] + } + s.touchName(schema, parts[len(parts)-1]) + s.touchName(schema, v.Newname) + } + case v.RenameType == ast.OBJECT_TABCONSTRAINT && v.Relation != nil: + t, known := s.table(v.Relation) + if known && s.renameConstraintRefused(t, v.Subname, v.Newname) { + s.stop() + return + } + // synced is the name the synced schema lists the constraint under, + // when the scan follows it. + synced := "" + if known { + if from, ok := s.syncedConstraint(t, v.Subname); ok { + if _, listed := constraint(s.index.schemas[t.schema].tables[t.table], from); listed { + synced = from + } + } + } + schema := s.schemaOf(v.Relation) + s.constraintChanged(schema, v.Relation.Relname, v.Subname) + s.constraintChanged(schema, v.Relation.Relname, v.Newname) + switch { + case synced != "": + // The table keeps the constraint under the new name, and no + // longer has one under the old. + s.renamedConstraints[[3]string{t.schema, t.table, v.Newname}] = synced + s.goneConstraints[[3]string{t.schema, t.table, v.Subname}] = true + if s.index.constraintIndexes[tableRef{t.schema, synced}] { + // A key's index takes the new name. + s.free(t.schema, v.Subname) + s.touchName(t.schema, v.Newname) + s.renamedFrom[v.Newname] = renamedRelation{tableRef{t.schema, synced}, t.schema, s.lastTouch[v.Newname]} + } + default: + // The constraint may be a key, whose index takes the new name. + s.touchName(v.Relation.Schemaname, v.Newname) + } + // A table the change made, and a key a pending table waits on, + // keep the constraint under its new name. + if c, ok := s.madeAt(v.Relation); ok && c.def != nil && c.def.constraints[v.Subname] { + delete(c.def.constraints, v.Subname) + c.def.constraints[v.Newname] = true + } + for _, p := range s.pending { + if p.key == v.Subname && s.namesPending(p, v.Relation) { + p.key = v.Newname + } + } + case v.RenameType == ast.OBJECT_COLUMN && v.Relation != nil: + // A known table must have the column, and not the new name. + t, known := s.table(v.Relation) + if known && (!s.hasColumn(t, v.Subname) || s.hasColumn(t, v.Newname)) { + s.stop() + return + } + if known { + // The column keeps what it was in the synced table. + if origin, ok := s.originOf(t, v.Subname); ok { + s.origins[[3]string{t.schema, t.table, v.Newname}] = origin + } else { + delete(s.origins, [3]string{t.schema, t.table, v.Newname}) + } + } + schema := s.schemaOf(v.Relation) + s.setColumn(schema, v.Relation.Relname, v.Subname, false, false) + s.setColumn(schema, v.Relation.Relname, v.Newname, true, true) + case v.RenameType == ast.OBJECT_FUNCTION || v.RenameType == ast.OBJECT_PROCEDURE || v.RenameType == ast.OBJECT_ROUTINE: + fn, ok := routineOf(v.Object) + if ok && (s.routineMissing(fn, v.RenameType) || s.signatureMissing(fn, v.Object.(*ast.ObjectWithArgs)) || s.renameTaken(fn, v)) { + s.stop() + return + } + if ok { + s.renameSynced(fn, v.Object.(*ast.ObjectWithArgs)) + } + s.renameRoutine(v) + case v.RenameType == ast.OBJECT_SCHEMA: + s.stop() + } +} + +// renameConstraintRefused reports whether RENAME CONSTRAINT of a known +// table may fail: the constraint is not one the snapshot lists, or the +// table has a constraint of the new name, or the constraint owns an index +// the new name of which its schema holds. +func (s *scan) renameConstraintRefused(t tableRef, from, to string) bool { + table := s.index.schemas[t.schema].tables[t.table] + // A constraint the snapshot does not list, which no statement of the + // change named, is missing, or a NOT NULL constraint the snapshot + // leaves out: the statement may fail either way. So is one a rename + // took the name from. + if _, ok := constraint(table, from); !ok && s.constraintKnown(t, from) || s.goneConstraints[[3]string{t.schema, t.table, from}] { + return true + } + if synced, ok := s.syncedConstraint(t, to); ok && !s.dropped[[3]string{t.schema, t.table, to}] { + if _, listed := constraint(table, synced); listed { + return true + } + } + synced, ok := s.syncedConstraint(t, from) + return ok && s.index.constraintIndexes[tableRef{t.schema, synced}] && s.nameTaken(t.schema, to) +} + +// alterKindRefused reports whether ALTER of an object type certainly names +// a relation of another kind, as the snapshot or the change's own creation +// tells: ALTER VIEW, MATERIALIZED VIEW, SEQUENCE, or FOREIGN TABLE takes +// only its own kind, ALTER TABLE any relation but a composite type, and +// ALTER INDEX ... RENAME any relation at all. +// +// pg: src/backend/commands/tablecmds.c — RangeVarCallbackForAlterRelation +func (s *scan) alterKindRefused(t ast.ObjectType, rv *ast.RangeVar) bool { + kind := relationKind(0) + if _, k, ok := s.lookup(rv); ok { + kind = k + } else if c, made := s.madeAt(rv); made { + kind = c.kind + } + switch { + case kind == 0 || kind == kindAmbiguous || t == ast.OBJECT_INDEX: + return false + case t == ast.OBJECT_TABLE: + return kind == kindCompositeType || kind == kindType + } + return !dropKindMatches(t, kind) +} + +// renamedRelation is a relation of the synced schema a rename or a move +// gave a new name: the relation as synced, the schema it is in now, and +// the touch that gave the name. +type renamedRelation struct { + relation tableRef + schema string + made int +} + +// renamedAway returns the synced relation a name a rename or a move gave +// may mean, while no statement used the name since. +func (s *scan) renamedAway(rv *ast.RangeVar) (tableRef, bool) { + r, ok := s.renamedFrom[rv.Relname] + if !ok || s.lastTouch[rv.Relname] != r.made || rv.Schemaname != "" && rv.Schemaname != r.schema { + return tableRef{}, false + } + return r.relation, true +} + +// movesWith returns what SET SCHEMA moves with a synced relation: a +// table's own indexes and the sequences it owns, a materialized view's +// indexes. A table's partitions stay. +func (s *scan) movesWith(schema, name string) []string { + ns := s.index.schemas[schema] + t := ns.tables[name] + var out []string + for _, d := range ns.dependents[name] { + switch { + case t == nil: + out = append(out, d) + case ns.relations[d] == kindSequence || slices.ContainsFunc(t.GetIndexes(), func(i *metadata.IndexMetadata) bool { return i.GetName() == d }): + out = append(out, d) + } + } + return out +} + +// nameTaken reports whether a synced schema certainly holds a relation or +// an enum type of that name, which a relation cannot take. +func (s *scan) nameTaken(schema, name string) bool { + ns := s.index.schemas[schema] + if ns == nil || s.schemas[schema] || s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: name}) { + return false + } + _, exists := ns.relations[name] + return exists || slices.Contains(ns.types, name) +} + +// follow records that what the change's own objects depend on keeps +// depending on a relation renamed or moved to another schema: each +// dependency on a relation the statement's may be is copied to the new +// schema, when one is given, and name. +func (s *scan) follow(rv *ast.RangeVar, newSchema, name string) { + schema := s.schemaOf(rv) + may := func(t tableRef) bool { + return t.table == rv.Relname && (t.schema == "" || schema == "" || t.schema == schema) + } + moved := func(t tableRef) tableRef { + if newSchema != "" { + t.schema = newSchema + } + t.table = name + return t + } + for _, deps := range []*[]newDependency{&s.newReads, &s.newReturns} { + for i, n := 0, len(*deps); i < n; i++ { + if d := (*deps)[i]; !d.retired && may(d.relation) { + d.relation = moved(d.relation) + *deps = append(*deps, d) + } + } + } + for i, n := 0, len(s.newReferences); i < n; i++ { + if r := s.newReferences[i]; !r.retired && may(tableRef{r.schema, r.table}) { + t := moved(tableRef{r.schema, r.table}) + r.schema, r.table = t.schema, t.table + s.newReferences = append(s.newReferences, r) + } + } +} + +// madeRelation is a table or view the change created, and the touch +// that made its name. +type madeRelation struct { + kind relationKind + made int + // def is a table's definition when its CREATE said it all and no + // ALTER TABLE changed it since. + def *tableDef +} + +// made records a table or view a statement certainly created under a +// name: the schema written, or the one the search path creates it in. +func (s *scan) made(rv *ast.RangeVar, kind relationKind) { + schema := rv.Schemaname + if schema == "" { + var ok bool + if schema, ok = s.creationSchema(rv); !ok { + return + } + } + s.created[[2]string{schema, rv.Relname}] = &madeRelation{kind: kind, made: s.lastTouch[rv.Relname]} +} + +// dropsOwn reports whether a qualified DROP certainly drops a table or +// view the change created, under the name it made, which nothing the +// change created depends on, or CASCADE takes with it. +func (s *scan) dropsOwn(rv *ast.RangeVar, kind ast.ObjectType, cascade bool, written []tableRef) bool { + if rv.Schemaname == "" { + return false + } + t := tableRef{rv.Schemaname, rv.Relname} + c, ok := s.created[[2]string{t.schema, t.table}] + if !ok || s.lastTouch[t.table] != c.made || !dropKindMatches(kind, c.kind) { + return false + } + return cascade || !s.dependsOnNew(s.newReads, t, written) && !s.dependsOnNew(s.newReturns, t, nil) && !s.newlyReferenced(t, nil, true, written...) +} + +// setSchema follows SET SCHEMA. A pending table it may be can then be in +// any schema. +func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { + switch { + case isRelationKind(v.ObjectType) && v.Relation != nil: + if s.index != nil && s.missing(v.Relation) { + if !v.MissingOk { + s.stop() + } + return + } + if s.alterKindRefused(v.ObjectType, v.Relation) { + s.stop() + return + } + // A relation cannot move to a schema holding its name; when it does + // not exist either, the statement is refused, or under IF EXISTS + // does nothing. + if s.nameTaken(v.Newschema, v.Relation.Relname) || s.madeHere(&ast.RangeVar{Schemaname: v.Newschema, Relname: v.Relation.Relname}) { + s.stop() + return + } + schema, _, resolved := s.lookup(v.Relation) + // A schema that lacks the name must exist to take it, and the + // names of what moves with the relation. + if s.index != nil && (s.index.schemas[v.Newschema] == nil && !s.schemas[v.Newschema] || s.schemaGone[v.Newschema]) || + resolved && s.nameTaken(v.Newschema, v.Relation.Relname) { + s.stop() + return + } + if resolved && slices.ContainsFunc(s.movesWith(schema, v.Relation.Relname), func(d string) bool { + return s.nameTaken(v.Newschema, d) || s.madeHere(&ast.RangeVar{Schemaname: v.Newschema, Relname: d}) + }) { + s.stop() + return + } + s.follow(v.Relation, v.Newschema, v.Relation.Relname) + s.followParents(v.Relation, tableRef{v.Newschema, v.Relation.Relname}) + if resolved { + // The relation leaves its schema, with a table's indexes and + // owned sequences. + s.free(schema, v.Relation.Relname) + for _, d := range s.movesWith(schema, v.Relation.Relname) { + s.free(schema, d) + s.touchName(v.Newschema, d) + } + } else { + s.touch(v.Relation) + } + s.touchName(v.Newschema, v.Relation.Relname) + if resolved { + s.renamedFrom[v.Relation.Relname] = renamedRelation{tableRef{schema, v.Relation.Relname}, v.Newschema, s.lastTouch[v.Relation.Relname]} + } + for _, p := range s.matching(v.Relation) { + // The move is known only when the statement names the table's + // own schema; otherwise it may be another table of that name. + if v.Relation.Schemaname != "" && v.Relation.Schemaname == p.schema { + p.schema = v.Newschema + } else { + p.schema = "" + } + } + case v.ObjectType == ast.OBJECT_TYPE || v.ObjectType == ast.OBJECT_DOMAIN: + // The destination schema must exist. + if s.schemaMissing(&ast.RangeVar{Schemaname: v.Newschema}) { + s.stop() + return + } + if parts := nameParts(listOf(v.Object)); len(parts) > 0 { + s.touchName("", parts[len(parts)-1]) + } + case v.ObjectType == ast.OBJECT_FUNCTION || v.ObjectType == ast.OBJECT_PROCEDURE || v.ObjectType == ast.OBJECT_ROUTINE: + if fn, ok := routineOf(v.Object); ok { + owa := v.Object.(*ast.ObjectWithArgs) + if s.routineMissing(fn, v.ObjectType) || s.signatureMissing(fn, owa) { + if !v.MissingOk { + s.stop() + } + return + } + // The destination schema must exist, without the signature. + if s.schemaMissing(&ast.RangeVar{Schemaname: v.Newschema}) || s.index != nil && s.index.schemas[v.Newschema] == nil && !s.schemas[v.Newschema] || + s.moveTaken(fn, owa, v.Newschema, fn.table) { + s.stop() + return + } + s.transferRoutine(fn, owa, tableRef{v.Newschema, fn.table}) + } + if owa, ok := v.Object.(*ast.ObjectWithArgs); ok { + if parts := nameParts(owa.Objname); len(parts) > 0 { + s.madeRoutines[parts[len(parts)-1]] = true + s.gainedRoutines[parts[len(parts)-1]] = true + s.movedRoutines[parts[len(parts)-1]] = true + } + } + case v.ObjectType == ast.OBJECT_EXTENSION: + s.stop() + } +} + +// drop follows DROP. A cascading drop of a relation also takes the +// relations that go with it (its partitions, indexes, owned sequences, +// and the views that read it), the foreign keys that reference it, and +// any inheritance child, which the synced schema does not record; a +// cascading drop of anything else may take columns of any table, which +// ends the scan. DROP EXTENSION drops the extension's objects. +func (s *scan) drop(v *ast.DropStmt) { + kind := ast.ObjectType(v.RemoveType) + cascade := v.Behavior == int(ast.DROP_CASCADE) + // DROP EXTENSION IF EXISTS of extensions the target lacks drops + // nothing; any other drops what the extension made. + if kind == ast.OBJECT_EXTENSION && v.Missing_ok && s.index != nil && v.Objects != nil && !slices.ContainsFunc(v.Objects.Items, func(n ast.Node) bool { + parts := nameParts(listOf(n)) + return len(parts) != 1 || s.index.extensions[parts[0]] + }) { + return + } + if kind == ast.OBJECT_EXTENSION || cascade && !isRelationKind(kind) { + s.stop() + return + } + // DROP EVENT TRIGGER of one the target lacks is refused. + if kind == ast.OBJECT_EVENT_TRIGGER && !v.Missing_ok && s.index != nil && v.Objects != nil && slices.ContainsFunc(v.Objects.Items, func(n ast.Node) bool { + parts := nameParts(listOf(n)) + return len(parts) == 1 && !s.index.eventTriggers[parts[0]] + }) { + s.stop() + return + } + if kind == ast.OBJECT_FUNCTION || kind == ast.OBJECT_PROCEDURE || kind == ast.OBJECT_ROUTINE { + s.dropFunctions(v) + return + } + if kind == ast.OBJECT_SCHEMA && v.Objects != nil { + // Without CASCADE the schema must be empty; its name may then be + // created again. A schema the target lacks and the change did not + // create is not there to drop: IF EXISTS drops nothing, and + // without it the statement is refused. + for _, obj := range v.Objects.Items { + parts := nameParts(listOf(obj)) + if len(parts) != 1 { + continue + } + if s.index != nil && (s.index.schemas[parts[0]] == nil && !s.schemas[parts[0]] || s.schemaGone[parts[0]]) { + if !v.Missing_ok { + s.stop() + return + } + continue + } + if s.schemaHolds(parts[0]) { + s.stop() + return + } + s.schemas[parts[0]] = true + s.schemaGone[parts[0]] = true + } + return + } + // A type a column, a function, or another type may use is not dropped + // without CASCADE; the snapshot does not record every such use. + if kind == ast.OBJECT_TYPE || kind == ast.OBJECT_DOMAIN { + s.stop() + return + } + if !isRelationKind(kind) || v.Objects == nil { + return + } + // A name in another database is refused. + for _, obj := range v.Objects.Items { + if parts := nameParts(listOf(obj)); len(parts) == 3 && (s.index == nil || parts[0] != s.index.database) { + if s.index != nil { + s.stop() + } + return + } + } + if !cascade && s.dropRefused(v) || !v.Missing_ok && s.dropsMissing(v) || s.dropsWrongKind(v) { + s.stop() + return + } + // The names the statement drops, as written: a view among them does + // not hold the drop of another. + var written []tableRef + for _, obj := range v.Objects.Items { + if parts := nameParts(listOf(obj)); len(parts) > 0 { + t := tableRef{table: parts[len(parts)-1]} + if len(parts) >= 2 { + t.schema = parts[len(parts)-2] + } + written = append(written, t) + } + } + var droppedRefs []tableRef + for _, obj := range v.Objects.Items { + parts := nameParts(listOf(obj)) + if len(parts) == 0 { + continue + } + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) >= 2 { + rv.Schemaname = parts[len(parts)-2] + } + schema, relKind, resolved := s.lookup(rv) + ref := tableRef{rv.Schemaname, rv.Relname} + if resolved { + ref.schema = schema + } + // The pending tables the name may mean, as it resolves before the + // drop. + matched := s.matching(rv) + switch { + case resolved: + s.free(schema, rv.Relname) + case v.Missing_ok && s.index != nil && s.missing(rv): + // IF EXISTS of a name the target lacks drops nothing. + continue + case s.dropsOwn(rv, kind, cascade, written): + // The change's own table or view, under the name it made. + s.free(rv.Schemaname, rv.Relname) + schema = "" + default: + s.touch(rv) + schema = "" + } + droppedRefs = append(droppedRefs, ref) + if kind == ast.OBJECT_INDEX { + s.renamedKeys[rv.Relname] = true + } + for _, p := range matched { + p.settle() + } + switch kind { + case ast.OBJECT_TABLE: + s.retireReferences(rv, "") + case ast.OBJECT_VIEW, ast.OBJECT_MATVIEW: + s.retire(s.newReads, tableRef{rv.Schemaname, rv.Relname}) + } + s.dropOwned(rv, schema, resolved && relKind == kindTable) + if cascade { + s.dropDependents(rv, schema) + if resolved { + s.dropNewReaders(tableRef{schema, rv.Relname}) + } + } + } + if cascade && len(droppedRefs) > 0 && (kind == ast.OBJECT_TABLE || kind == ast.OBJECT_FOREIGN_TABLE) { + // An inheritance child of a dropped table goes with it. The synced + // schema records no inheritance, so a table of it may be a child. + for _, p := range s.pending { + if p.existed || slices.ContainsFunc(p.parents, func(parent tableRef) bool { + return slices.ContainsFunc(droppedRefs, func(d tableRef) bool { return mayBe(parent, d) }) + }) { + p.settle() + } + } + } +} + +// dropFunctions follows DROP FUNCTION: a synced function whose name the +// scan resolves to one function, and a function the change created, no +// longer hold the relation whose row type they return. +func (s *scan) dropFunctions(v *ast.DropStmt) { + if v.Objects == nil { + return + } + for _, obj := range v.Objects.Items { + owa, ok := obj.(*ast.ObjectWithArgs) + if !ok { + continue + } + parts := nameParts(owa.Objname) + if len(parts) == 0 || len(parts) > 2 { + continue + } + fn := tableRef{table: parts[len(parts)-1]} + if len(parts) == 2 { + fn.schema = parts[0] + } + // A name alone that several routines have is refused, IF EXISTS + // or not. + if owa.ArgsUnspecified && (s.routinesOf(fn) > 1 || fn.schema == "" && len(s.newSignatures[fn]) > 1) { + s.stop() + return + } + // A routine the target certainly lacks is not there to drop; IF + // EXISTS then drops nothing. + if s.routineMissing(fn, ast.ObjectType(v.RemoveType)) || s.signatureMissing(fn, owa) { + if !v.Missing_ok { + s.stop() + return + } + continue + } + // A function is retired only when the statement leaves no choice + // of which one it drops: an argument list matching the one the + // change wrote, or no argument list on a name only one function + // has. A synced function's argument types are not compared. + if sigs := s.newSignatures[fn]; len(sigs) > 0 && s.syncedFunctions(fn) == 0 { + sig, i := argSignature(owa.Objargs), -1 + if owa.ArgsUnspecified { + if len(sigs) == 1 { + sig, i = sigs[0], 0 + } + } else { + i = slices.Index(sigs, sig) + } + if i >= 0 { + s.retireSignature(fn, sig) + s.newSignatures[fn] = slices.Delete(slices.Clone(sigs), i, i+1) + s.newFunctions[fn]-- + } + } + // A synced function is certainly dropped by its name alone, or by + // the signature the snapshot gives the only function of its name, + // when no routine of the change has the name and none moved away + // from it. + if s.index == nil || s.madeRoutines[fn.table] || s.movedRoutines[fn.table] { + continue + } + if !owa.ArgsUnspecified { + if schema, ok := s.routineSchema(fn); ok { + synced := tableRef{schema, fn.table} + if s.index.functions[synced] == 1 && slices.Contains(s.index.signatures[synced], argSignature(owa.Objargs)) { + s.droppedFunctions[synced] = true + } + } + continue + } + if fn.schema != "" { + if s.index.functions[fn] == 1 { + s.droppedFunctions[fn] = true + } + continue + } + path, ok := s.searchPath() + if !ok { + continue + } + // The server finds the first schema on the path with a function of + // that name; one function there is the one dropped. + for _, name := range path { + if n := s.index.functions[tableRef{name, fn.table}]; n > 0 { + if n == 1 { + s.droppedFunctions[tableRef{name, fn.table}] = true + } + break + } + } + } +} + +// syncedFunctions counts the synced functions a routine name may mean: of +// that name in the schema written, or in any schema. +func (s *scan) syncedFunctions(fn tableRef) int { + if s.index == nil { + return 0 + } + n := 0 + for name, count := range s.index.functions { + if name.table == fn.table && (fn.schema == "" || name.schema == fn.schema) { + n += count + } + } + return n +} + +// routineSchema returns the synced schema a routine name means: the one +// written, or the first schema of the known search path with a routine of +// the name. +func (s *scan) routineSchema(fn tableRef) (string, bool) { + if fn.schema != "" { + return fn.schema, s.index.schemas[fn.schema] != nil && !s.schemas[fn.schema] + } + path, ok := s.searchPath() + if !ok { + return "", false + } + for _, name := range path { + if s.schemas[name] || name == "information_schema" { + return "", false + } + if s.index.routineKinds[tableRef{name, fn.table}] != 0 { + return name, true + } + } + return "", false +} + +// routinesOf counts the routines a qualified name certainly has: the +// synced ones of the name, none dropped or renamed by the change, and the +// signatures the change created under the name as written. It is 0 for a +// name the scan cannot count. +func (s *scan) routinesOf(fn tableRef) int { + if s.index == nil || fn.schema == "" || s.movedRoutines[fn.table] || s.droppedFunctions[fn] || s.schemas[fn.schema] { + return 0 + } + return s.index.functions[fn] + len(s.newSignatures[fn]) +} + +// routineMissing reports whether no routine of that name certainly exists +// where the name resolves: the change created, renamed, or moved none of +// that name, and the synced schema written, or every schema of a known +// search path, has none. A name only the system catalogs have cannot be +// dropped either. +func (s *scan) routineMissing(fn tableRef, kind ast.ObjectType) bool { + if s.index == nil || s.madeRoutines[fn.table] { + return false + } + // DROP FUNCTION refuses a procedure, and DROP PROCEDURE a function. + want := kindFunction | kindProcedure + switch kind { + case ast.OBJECT_FUNCTION: + want = kindFunction + case ast.OBJECT_PROCEDURE: + want = kindProcedure + } + if fn.schema != "" { + return s.index.schemas[fn.schema] != nil && !s.schemas[fn.schema] && s.index.routineKinds[fn]&want == 0 + } + path, ok := s.searchPath() + if !ok { + return false + } + for _, name := range path { + if s.schemas[name] || name == "information_schema" || s.index.routineKinds[tableRef{name, fn.table}]&want != 0 { + return false + } + } + return true +} + +// routineSignatureOf returns the signature a routine ALTER names: the +// argument list written, or, with none, that of the only routine the +// change created or the snapshot lists under the name, in the synced +// schema the name resolves to. +func (s *scan) routineSignatureOf(fn tableRef, owa *ast.ObjectWithArgs) (schema, sig string, ok bool) { + if s.index == nil { + return "", "", false + } + schema, ok = s.creationSchema(&ast.RangeVar{Schemaname: fn.schema, Relname: fn.table}) + if !ok { + return "", "", false + } + synced := tableRef{schema, fn.table} + if !owa.ArgsUnspecified { + return schema, argSignature(owa.Objargs), true + } + switch sigs := s.newSignatures[fn]; { + case len(sigs) == 1 && s.index.functions[synced] == 0: + return schema, sigs[0], true + case len(sigs) == 0 && s.index.functions[synced] == 1 && len(s.index.signatures[synced]) == 1: + return schema, s.index.signatures[synced][0], true + } + return "", "", false +} + +// signatureMissing reports whether a routine ALTER names by an argument +// list certainly has no routine of that signature: neither the change nor +// the snapshot, every signature of whose name the scan can read, has one. +func (s *scan) signatureMissing(fn tableRef, owa *ast.ObjectWithArgs) bool { + if owa.ArgsUnspecified { + return false + } + if s.madeRoutines[fn.table] { + // The change's own routines of the name are known when it created + // each as the statement writes it, and a rename took from them + // only what it certainly named. + sig := argSignature(owa.Objargs) + if !s.createdOnlyAs(fn) || slices.Contains(s.newSignatures[fn], sig) || s.schemas[fn.schema] || s.index.schemas[fn.schema] == nil { + return false + } + synced := tableRef{fn.schema, fn.table} + return s.droppedFunctions[synced] || slices.Contains(s.renamedSignatures[synced], sig) || + len(s.index.signatures[synced]) == s.index.functions[synced] && !slices.Contains(s.index.signatures[synced], sig) && !(sig == "" && s.index.noArgs[synced]) + } + if s.movedRoutines[fn.table] { + return s.signatureRenamed(fn, owa) + } + schema, sig, ok := s.routineSignatureOf(fn, owa) + if !ok { + return false + } + synced := tableRef{schema, fn.table} + return !slices.Contains(s.newSignatures[fn], sig) && len(s.index.signatures[synced]) == s.index.functions[synced] && + !slices.Contains(s.index.signatures[synced], sig) && !(sig == "" && s.index.noArgs[synced]) +} + +// moveTaken reports whether renaming or moving a routine certainly takes a +// signature a routine of the destination name already has in the +// destination schema, among the change's or the snapshot's. +func (s *scan) moveTaken(fn tableRef, owa *ast.ObjectWithArgs, toSchema, toName string) bool { + _, sig, ok := s.routineSignatureOf(fn, owa) + if !ok { + return false + } + to := tableRef{toSchema, toName} + if s.movedRoutines[toName] || s.droppedFunctions[to] { + return false + } + written := tableRef{fn.schema, toName} + if toSchema != fn.schema && fn.schema != "" { + written.schema = toSchema + } + return slices.Contains(s.newSignatures[written], sig) || + sig == "" && s.index.noArgs[to] || slices.Contains(s.index.signatures[to], sig) +} + +// renameTaken reports whether a routine rename certainly takes a signature +// a routine of the new name has where the routine is. +func (s *scan) renameTaken(fn tableRef, v *ast.RenameStmt) bool { + owa, ok := v.Object.(*ast.ObjectWithArgs) + if !ok { + return false + } + schema, _, ok := s.routineSignatureOf(fn, owa) + return ok && s.moveTaken(fn, owa, schema, v.Newname) +} + +// routineOf returns the routine an ALTER names, as written. +func routineOf(n ast.Node) (tableRef, bool) { + owa, ok := n.(*ast.ObjectWithArgs) + if !ok { + return tableRef{}, false + } + parts := nameParts(owa.Objname) + if len(parts) == 0 || len(parts) > 2 { + return tableRef{}, false + } + fn := tableRef{table: parts[len(parts)-1]} + if len(parts) == 2 { + fn.schema = parts[0] + } + return fn, true +} + +// renameRoutine follows ALTER FUNCTION, PROCEDURE, or ROUTINE ... RENAME: +// the change's own function the statement certainly names keeps its +// dependencies under the new name. +func (s *scan) renameRoutine(v *ast.RenameStmt) { + s.madeRoutines[v.Newname] = true + s.gainedRoutines[v.Newname] = true + owa, ok := v.Object.(*ast.ObjectWithArgs) + if !ok { + return + } + parts := nameParts(owa.Objname) + if len(parts) == 0 || len(parts) > 2 { + return + } + s.movedRoutines[parts[len(parts)-1]] = true + fn := tableRef{table: parts[len(parts)-1]} + if len(parts) == 2 { + fn.schema = parts[0] + } + s.transferRoutine(fn, owa, tableRef{fn.schema, v.Newname}) +} + +// renameSynced records the signature a rename certainly takes from a +// synced routine name: the name is one no statement of the change gave a +// routine, and the snapshot lists every signature of it, the one renamed +// among them. +func (s *scan) renameSynced(fn tableRef, owa *ast.ObjectWithArgs) { + if s.index == nil || s.madeRoutines[fn.table] || len(s.newSignatures[fn]) > 0 { + return + } + schema, sig, ok := s.routineSignatureOf(fn, owa) + if !ok { + return + } + synced := tableRef{schema, fn.table} + if len(s.index.signatures[synced]) == s.index.functions[synced] && slices.Contains(s.index.signatures[synced], sig) { + s.renamedSignatures[synced] = append(s.renamedSignatures[synced], sig) + } +} + +// createdOnlyAs reports whether every routine the change gave the name it +// created, as fn writes it, qualified: the signatures the change made +// under the name in that schema are then the ones it recorded. +func (s *scan) createdOnlyAs(fn tableRef) bool { + if fn.schema == "" || s.gainedRoutines[fn.table] || len(s.createdAs[fn.table]) != 1 { + return false + } + return s.createdAs[fn.table][fn] +} + +// signatureRenamed reports whether a routine an ALTER or DROP names by an +// argument list is certainly gone, renamed by the change: no statement +// gave a routine the name, and every synced schema the name may find it +// in either has no routine of the name, lacks the signature, or had it +// renamed away. +func (s *scan) signatureRenamed(fn tableRef, owa *ast.ObjectWithArgs) bool { + if s.index == nil || owa.ArgsUnspecified || s.madeRoutines[fn.table] || len(s.newSignatures[fn]) > 0 { + return false + } + sig := argSignature(owa.Objargs) + renamed := false + // gone reports whether the schema certainly has no routine of the + // name with the signature. + gone := func(schema string) bool { + synced := tableRef{schema, fn.table} + switch { + case s.schemas[schema]: + return false + case slices.Contains(s.renamedSignatures[synced], sig): + renamed = true + return true + case s.index.routineKinds[synced] == 0: + // A schema the target lacks has none either. + return true + } + return len(s.index.signatures[synced]) == s.index.functions[synced] && !slices.Contains(s.index.signatures[synced], sig) && + !(sig == "" && s.index.noArgs[synced]) + } + if fn.schema != "" { + return gone(fn.schema) && renamed + } + path, ok := s.searchPath() + if !ok { + return false + } + for _, name := range path { + if name == "information_schema" || strings.HasPrefix(name, "pg_") || !gone(name) { + return false + } + } + return renamed +} + +// transferRoutine moves the change's own function a rename or a move +// certainly names, by the argument list or by the name alone of the only +// one, to its new name as written, with its dependencies. +func (s *scan) transferRoutine(fn tableRef, owa *ast.ObjectWithArgs, to tableRef) { + sigs := s.newSignatures[fn] + if len(sigs) == 0 || s.syncedFunctions(fn) > 0 || owa.ArgsUnspecified && len(sigs) > 1 { + return + } + sig := argSignature(owa.Objargs) + if owa.ArgsUnspecified { + sig = sigs[0] + } + i := slices.Index(sigs, sig) + if i < 0 { + return + } + for j := range s.newReturns { + d := &s.newReturns[j] + if !d.retired && s.sameObject(*d, fn) && d.signature == sig { + d.object = to + } + } + s.newSignatures[fn] = slices.Delete(slices.Clone(sigs), i, i+1) + s.newFunctions[fn]-- + s.newSignatures[to] = append(s.newSignatures[to], sig) + s.newFunctions[to]++ +} + +// dropReturners records the functions a cascading drop of a relation +// takes with its row type: a synced function that depends on it, when it +// is the only function of its name, and a function the change created +// whose result, arguments, or SQL-standard body named the relation, +// resolved to it. +func (s *scan) dropReturners(t tableRef) { + if s.index != nil { + for _, fn := range s.index.returnedBy[t] { + if s.index.functions[fn] == 1 { + s.droppedFunctions[fn] = true + } + } + } + for i := range s.newReturns { + d := s.newReturns[i] + if d.retired || d.relation != t { + continue + } + for j := range s.newReturns { + if o := &s.newReturns[j]; o.object == d.object && o.path == d.path && o.signature == d.signature { + o.retired = true + } + } + } +} + +// dropsWrongKind reports whether a DROP names a relation of another kind, +// which the server refuses: DROP TABLE of a view, DROP VIEW of a table, or +// DROP INDEX of the index a constraint owns. +func (s *scan) dropsWrongKind(v *ast.DropStmt) bool { + kind := ast.ObjectType(v.RemoveType) + if s.index == nil || v.Objects == nil || !isRelationKind(kind) { + return false + } + for _, obj := range v.Objects.Items { + parts := nameParts(listOf(obj)) + if len(parts) == 0 || len(parts) > 2 { + continue + } + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) == 2 { + rv.Schemaname = parts[0] + } + schema, relKind, ok := s.lookup(rv) + if !ok { + // A relation the change created has the kind it was made as, + // and an index a rename named still belongs to its constraint. + if c, made := s.madeAt(rv); made && !dropKindMatches(kind, c.kind) { + return true + } + if t, renamed := s.renamedAway(rv); renamed && kind == ast.OBJECT_INDEX && s.index.constraintIndexes[t] { + return true + } + continue + } + if relKind == kindAmbiguous { + continue + } + if !dropKindMatches(kind, relKind) { + return true + } + if kind == ast.OBJECT_INDEX && s.index.constraintIndexes[tableRef{schema, rv.Relname}] { + return true + } + } + return false +} + +// freeView records that a cascade dropped a synced view. Its name is freed +// only when the change has not touched it: a view it replaced may no +// longer depend on what was dropped, and one it renamed is not under that +// name. +func (s *scan) freeView(view tableRef) bool { + if s.viewHolds(view) && !s.isTouched(&ast.RangeVar{Schemaname: view.schema, Relname: view.table}) { + s.free(view.schema, view.table) + return true + } + s.touchName(view.schema, view.table) + return false +} + +// cascadeViews records the views a cascade takes: the certain ones are +// freed where the change left them as synced and touched otherwise, the +// others touched. The views reading a freed view go for certain; those +// reading a view the scan could not free go only maybe. +func (s *scan) cascadeViews(certain, maybe []tableRef) { + seen := make(map[tableRef]bool) + for len(certain) > 0 || len(maybe) > 0 { + if len(certain) > 0 { + view := certain[0] + certain = certain[1:] + if seen[view] { + continue + } + seen[view] = true + if s.freeView(view) { + certain = append(certain, s.index.readers[view]...) + s.dropNewReaders(view) + } else { + maybe = append(maybe, s.index.readers[view]...) + } + maybe = append(maybe, s.index.mayRead[view]...) + continue + } + view := maybe[0] + maybe = maybe[1:] + if seen[view] { + continue + } + seen[view] = true + s.touchName(view.schema, view.table) + maybe = append(append(maybe, s.index.readers[view]...), s.index.mayRead[view]...) + } +} + +// viewHolds reports whether a synced view may still read what the snapshot +// says it reads: the change neither dropped it nor redefined it. +func (s *scan) viewHolds(view tableRef) bool { + return !s.freed[[2]string{view.schema, view.table}] && !s.replacedViews[view] +} + +// dropKindMatches reports whether a DROP of an object type can drop a +// relation of that kind. +func dropKindMatches(t ast.ObjectType, kind relationKind) bool { + switch t { + case ast.OBJECT_TABLE: + return kind == kindTable || kind == kindPartition + case ast.OBJECT_VIEW: + return kind == kindView + case ast.OBJECT_MATVIEW: + return kind == kindMatView + case ast.OBJECT_FOREIGN_TABLE: + return kind == kindForeignTable + case ast.OBJECT_SEQUENCE: + return kind == kindSequence + case ast.OBJECT_INDEX: + return kind == kindIndex + } + return true +} + +// schemaHolds reports whether a schema may still hold an object, so that +// DROP SCHEMA without CASCADE may be refused and the scan ends: a name the +// change created, renamed, or moved that may be in it and no resolved drop +// removed, a function the change created that may be in it, or, in a +// synced schema, a relation of it no statement touched, an enum type no +// statement named, or a routine no DROP FUNCTION certainly removed. +func (s *scan) schemaHolds(name string) bool { + if s.index == nil { + return false + } + // What the change created, renamed, or moved may be there: under that + // schema, or under none when the search path may put it there, unless + // a drop the scan resolved removed it. + path, known := s.searchPath() + onPath := !known || slices.Contains(path, name) + for key := range s.touched { + if (key[0] == name || key[0] == "" && onPath) && !s.freed[[2]string{name, key[1]}] { + return true + } + } + for fn, n := range s.newFunctions { + if n > 0 && (fn.schema == name || fn.schema == "" && onPath) { + return true + } + } + ns := s.index.schemas[name] + if ns == nil || s.schemas[name] { + return false + } + for rel := range ns.relations { + if !s.isTouched(&ast.RangeVar{Schemaname: name, Relname: rel}) { + return true + } + } + for _, t := range ns.types { + if !s.isTouched(&ast.RangeVar{Schemaname: name, Relname: t}) { + return true + } + } + dropped := 0 + for fn := range s.droppedFunctions { + if fn.schema == name { + dropped += s.index.functions[fn] + } + } + return ns.routines > dropped +} + +// schemaMissing reports whether a qualified name's schema certainly does +// not exist: the target lacks it and the change did not create it. +func (s *scan) schemaMissing(rv *ast.RangeVar) bool { + return s.index != nil && rv != nil && rv.Schemaname != "" && !isTemp(rv) && + (s.index.schemas[rv.Schemaname] == nil && !s.schemas[rv.Schemaname] || s.schemaGone[rv.Schemaname]) +} + +// dropsMissing reports whether a DROP without IF EXISTS names a relation +// the target certainly lacks: no statement of the change made the name, +// and the schema the SQL wrote, or every schema of a known search path, +// holds no relation of that name. +func (s *scan) dropsMissing(v *ast.DropStmt) bool { + if s.index == nil || v.Objects == nil || !isRelationKind(ast.ObjectType(v.RemoveType)) { + return false + } + for _, obj := range v.Objects.Items { + parts := nameParts(listOf(obj)) + if len(parts) == 0 || len(parts) > 2 { + continue + } + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) == 2 { + rv.Schemaname = parts[0] + } + if s.missing(rv) { + return true + } + } + return false +} + +// missing reports whether the target certainly has no relation of that +// name where the name resolves. +func (s *scan) missing(rv *ast.RangeVar) bool { + // A resolved drop removed the name, and nothing used it since. + if rv.Schemaname != "" && s.freed[[2]string{rv.Schemaname, rv.Relname}] { + return true + } + if s.isTouched(rv) { + return false + } + if rv.Schemaname != "" { + // A schema the change created started empty: what no statement + // named in it, and no generated name may be, is not there. + if s.schemas[rv.Schemaname] && !s.schemaGone[rv.Schemaname] { + return !s.generated[rv.Schemaname] && !s.generated[""] + } + ns := s.index.schemas[rv.Schemaname] + if ns == nil || s.schemas[rv.Schemaname] { + return false + } + _, ok := ns.relations[rv.Relname] + return !ok + } + if strings.HasPrefix(rv.Relname, "pg_") { + return false + } + path, ok := s.searchPath() + if !ok { + return false + } + for _, name := range path { + if s.schemas[name] || name == "information_schema" { + return false + } + if ns := s.index.schemas[name]; ns != nil { + if _, ok := ns.relations[rv.Relname]; ok { + return false + } + } + } + return true +} + +// dropRefused reports whether the server refuses a DROP without CASCADE of +// relations the scan resolves: a view the statement does not drop reads +// one, a function returns its row type, or, for a table, a foreign key of +// a table the statement does not drop references it, as the synced schema +// or the change itself records. +func (s *scan) dropRefused(v *ast.DropStmt) bool { + kind := ast.ObjectType(v.RemoveType) + if s.index == nil || kind != ast.OBJECT_TABLE && kind != ast.OBJECT_VIEW && kind != ast.OBJECT_MATVIEW && kind != ast.OBJECT_FOREIGN_TABLE { + return false + } + var dropped, written []tableRef + for _, obj := range v.Objects.Items { + parts := nameParts(listOf(obj)) + if len(parts) == 0 { + continue + } + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) >= 2 { + rv.Schemaname = parts[len(parts)-2] + } + written = append(written, tableRef{rv.Schemaname, rv.Relname}) + if schema, _, ok := s.lookup(rv); ok { + dropped = append(dropped, tableRef{schema, rv.Relname}) + } else if t, ok := s.renamedAway(rv); ok { + // What depends on a renamed relation still does. + dropped = append(dropped, t) + } else if home, ok := s.madeIn(rv); ok { + // So does what depends on a relation the change created. + dropped = append(dropped, tableRef{home, rv.Relname}) + } + } + for _, t := range dropped { + // A new view the statement drops too does not hold the drop. + if s.dependsOnNew(s.newReads, t, written) || s.dependsOnNew(s.newReturns, t, nil) { + return true + } + for _, fn := range s.index.returnedBy[t] { + if !s.droppedFunctions[fn] && !s.replacedFunctions[fn] { + return true + } + } + // A view whose definition the scan cannot read may read anything. + if s.index.opaqueViews { + return true + } + for _, view := range append(slices.Clone(s.index.readers[t]), s.index.mayRead[t]...) { + if s.viewHolds(view) && !slices.Contains(dropped, view) { + return true + } + } + if kind != ast.OBJECT_TABLE { + continue + } + if s.newlyReferenced(t, nil, true, written...) { + return true + } + for _, fk := range s.index.foreignKeys { + if fk.referenced == t && !fk.in(s.dropped) && !slices.Contains(dropped, fk.owner) { + return true + } + } + } + return false +} + +// dropOwned records what any drop of a relation takes with it, CASCADE or +// not: its partitions, indexes, and owned sequences, freed in its schema +// when the scan resolved it and touched in every schema the name may be in +// otherwise, and, for a table the scan resolved, its own foreign keys, +// which then no longer hold a key of another table. +func (s *scan) dropOwned(rv *ast.RangeVar, schema string, table bool) { + if s.index == nil { + return + } + for name, ns := range s.index.schemas { + switch { + case schema != "" && name == schema: + for _, d := range ns.dependents[rv.Relname] { + if s.reowned[[2]string{name, d}] || s.reowned[[2]string{"", d}] { + s.touchName(name, d) + } else { + s.free(name, d) + } + } + case schema == "" && (rv.Schemaname == "" || rv.Schemaname == name): + for _, d := range ns.dependents[rv.Relname] { + s.touchName(name, d) + } + } + } + if !table { + return + } + for _, fk := range s.index.foreignKeys { + if fk.owner == (tableRef{schema, rv.Relname}) { + s.dropped[[3]string{schema, rv.Relname, fk.name}] = true + } + } +} + +// dropDependents records what a cascading drop of a relation takes with +// it besides what it owns, as far as the synced schema records it: the +// views that read it, freed when the scan resolved the relation to schema +// and touched in every schema the name may be in otherwise, and the +// foreign keys that reference it. +func (s *scan) dropDependents(rv *ast.RangeVar, schema string) { + s.dropReferences(rv, tableRef{schema, rv.Relname}, schema != "", nil) + if s.index == nil { + return + } + if schema != "" { + t := tableRef{schema, rv.Relname} + s.cascadeViews(s.index.readers[t], s.index.mayRead[t]) + return + } + var maybe []tableRef + for name := range s.index.schemas { + if rv.Schemaname == "" || rv.Schemaname == name { + t := tableRef{name, rv.Relname} + maybe = append(append(maybe, s.index.readers[t]...), s.index.mayRead[t]...) + } + } + s.cascadeViews(nil, maybe) +} + +// dropReferences records that foreign keys a cascading drop takes may be +// gone: those referencing t when the scan resolved it (known), and those +// referencing a table of that name, in the schema the SQL wrote if any, +// otherwise. affected, when set, narrows them to the ones that may depend +// on what was dropped, and says which certainly do; those are recorded as +// dropped. +func (s *scan) dropReferences(rv *ast.RangeVar, t tableRef, known bool, affected func(foreignKeyRef) (maybe, certainly bool)) { + if s.index == nil { + return + } + for _, fk := range s.index.foreignKeys { + switch { + case known && fk.referenced != t: + continue + case !known && (fk.referenced.table != rv.Relname || rv.Schemaname != "" && fk.referenced.schema != rv.Schemaname): + continue + } + maybe, certainly := true, false + if affected != nil { + maybe, certainly = affected(fk) + } + if !maybe { + continue + } + s.constraintChanged(fk.owner.schema, fk.owner.table, fk.name) + if known && certainly { + s.dropped[[3]string{fk.owner.schema, fk.owner.table, fk.name}] = true + } + } +} + +// constraintKnown reports whether a table's constraint of that name is +// still the one the synced schema lists. +func (s *scan) constraintKnown(t tableRef, name string) bool { + return !s.constraints[[3]string{t.schema, t.table, name}] && !s.constraints[[3]string{"", t.table, name}] && !s.renamedKeys[name] +} + +// constraintChanged records that a statement added, dropped, or renamed +// a table's constraint of that name, in schema, or in whichever schema +// the search path finds when schema is "": the name no longer means a +// constraint the scan follows. +func (s *scan) constraintChanged(schema, table, name string) { + s.constraints[[3]string{schema, table, name}] = true + for key := range s.renamedConstraints { + if key[1] == table && key[2] == name && (schema == "" || key[0] == schema) { + delete(s.renamedConstraints, key) + } + } + for key := range s.goneConstraints { + if key[1] == table && key[2] == name && (schema == "" || key[0] == schema) { + delete(s.goneConstraints, key) + } + } +} + +// syncedConstraint returns the name the synced schema lists a known +// table's constraint under, by its name now: its own while no statement +// changed it, or the one a RENAME CONSTRAINT took it from. +func (s *scan) syncedConstraint(t tableRef, name string) (string, bool) { + if from, ok := s.renamedConstraints[[3]string{t.schema, t.table, name}]; ok && !s.renamedKeys[name] { + return from, true + } + return name, s.constraintKnown(t, name) +} + +// holdsConstraint reports whether a known table keeps its synced +// constraint, under whatever name it has now, as far as the scan follows +// it. +func (s *scan) holdsConstraint(t tableRef, synced string) bool { + _, ok := s.constraintNow(t, synced) + return ok +} + +// constraintNow returns the name a known table's synced constraint has +// now, as far as the scan follows it: its own while no statement changed +// it, or the one a RENAME CONSTRAINT gave it. +func (s *scan) constraintNow(t tableRef, synced string) (string, bool) { + if s.constraintKnown(t, synced) { + return synced, true + } + for key, from := range s.renamedConstraints { + if from == synced && key[0] == t.schema && key[1] == t.table && !s.renamedKeys[key[2]] { + return key[2], true + } + } + return "", false +} diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go new file mode 100644 index 00000000..7b66ab3b --- /dev/null +++ b/pg/review/scan_oracle_test.go @@ -0,0 +1,852 @@ +package review + +import ( + "context" + "database/sql" + "fmt" + "slices" + "strings" + "testing" + + "github.com/bytebase/omni/metadata" + "github.com/bytebase/omni/pg" + "github.com/bytebase/omni/pg/ast" + "github.com/bytebase/omni/pg/parser" + "github.com/bytebase/omni/review" +) + +// oracleBase is the schema every oracle case starts from: keys of every +// kind, a reference into p, a table without a key, a second schema with a +// table of the same name, and a schema named after the session user. +const oracleBase = ` +CREATE TABLE p (id integer PRIMARY KEY); +CREATE TABLE t (id integer PRIMARY KEY, code text CONSTRAINT t_code_key UNIQUE, p_id integer CONSTRAINT t_p_fk REFERENCES p (id), n integer CONSTRAINT t_n_check CHECK (n > 0)); +CREATE TABLE r (t_id integer CONSTRAINT r_t_fk REFERENCES t (id)); +CREATE TABLE nokey (a integer); +CREATE SCHEMA s; +CREATE TABLE s.t (id integer CONSTRAINT t_pkey UNIQUE); +CREATE SCHEMA postgres; +CREATE TABLE postgres.u (id integer PRIMARY KEY, k integer CONSTRAINT u_k_check CHECK (k > 0)); +CREATE TABLE "Quoted" (id integer CONSTRAINT "Quoted_pkey" PRIMARY KEY); +CREATE TYPE mood AS ENUM ('a', 'b'); +CREATE TABLE e (id integer PRIMARY KEY, m mood CONSTRAINT e_m_check CHECK (m <> 'a')); +CREATE TABLE ek (m mood PRIMARY KEY); +CREATE INDEX t_n_idx ON t (n); +CREATE VIEW tv AS SELECT code FROM t; +CREATE TABLE ip (a integer CONSTRAINT ip_a_check CHECK (a > 0)); +CREATE TABLE ic (b integer) INHERITS (ip); +CREATE TABLE cvt (a integer); +CREATE VIEW cv AS SELECT count(*) AS c FROM cvt; +` + +// TestScanRulesAgainstPostgres runs each change on PostgreSQL, statement +// by statement, and derives from the server's catalogs what +// DisallowDropConstraint and RequirePrimaryKey should report: the +// constraints of the schema before the change that an ALTER TABLE ... +// DROP CONSTRAINT removed, the tables a CREATE TABLE made, and the keyed +// tables an ALTER TABLE left without their key, still without a key at +// the end. The review, given the schema synced from the same server, +// must never report anything else. Where the scan follows the whole +// change (complete), it must report all of it. +// +// Up to the first statement the server rejects: the change stops there +// when it runs. That statement does nothing, so a finding on it is wrong, +// and so is a finding after it, or a RequirePrimaryKey finding about the +// end of a change that never gets there: the review must stop where the +// server does, or before. +func TestScanRulesAgainstPostgres(t *testing.T) { + db := startPostgres(t) + tests := []struct { + name string + change string + complete bool + }{ + {"every constraint kind", "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE, DROP CONSTRAINT t_code_key, DROP CONSTRAINT t_p_fk, DROP CONSTRAINT t_n_check;", true}, + {"names resolve along the search path", "ALTER TABLE s.t DROP CONSTRAINT t_pkey;\nSET search_path = s, public;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_pkey;\nALTER TABLE p DROP CONSTRAINT p_pkey CASCADE;", true}, + {"$user resolves to the session user's schema", "ALTER TABLE u DROP CONSTRAINT u_k_check;\nALTER TABLE u DROP CONSTRAINT u_pkey;", true}, + // A renamed table is not followed; a renamed constraint is. + {"renamed table", "ALTER TABLE t RENAME TO t2;\nALTER TABLE t2 DROP CONSTRAINT t_n_check;\nALTER TABLE IF EXISTS t DROP CONSTRAINT t_code_key;", false}, + {"renamed constraint", "ALTER TABLE t RENAME CONSTRAINT t_n_check TO t_n_check2;\nALTER TABLE t DROP CONSTRAINT t_n_check2;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_n_check;", true}, + {"renamed key, then dropped", "ALTER TABLE e RENAME CONSTRAINT e_pkey TO e_k;\nALTER TABLE e DROP CONSTRAINT e_k;", true}, + {"renamed key's index takes the new name", "ALTER TABLE e RENAME CONSTRAINT e_pkey TO e_k;\nCREATE TABLE e_k (id int);", true}, + {"renamed key's index frees the old name", "ALTER TABLE e RENAME CONSTRAINT e_pkey TO e_k;\nCREATE TABLE e_pkey (id int);", true}, + {"renamed key still keys its table", "ALTER TABLE e RENAME CONSTRAINT e_pkey TO e_k;\nALTER TABLE e DROP CONSTRAINT e_m_check, ADD PRIMARY KEY (m);", true}, + {"renamed key's old name is gone", "ALTER TABLE e RENAME CONSTRAINT e_pkey TO e_k;\nALTER TABLE e DROP CONSTRAINT e_m_check, DROP CONSTRAINT e_pkey;", true}, + {"renamed table frees its name", "ALTER TABLE nokey RENAME TO nk;\nCREATE TABLE nokey (a int);", true}, + // The catalog renames the index alone, so it rejects the DROP. + {"renamed index renames its constraint", "ALTER INDEX t_code_key RENAME TO t_code_uk;\nALTER TABLE t DROP CONSTRAINT t_code_uk;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_code_key;", false}, + {"DROP TYPE CASCADE drops the columns of the type", "DROP TYPE mood CASCADE;\nALTER TABLE e DROP CONSTRAINT IF EXISTS e_m_check;\nALTER TABLE ek ADD COLUMN x int;", true}, + {"constraint the change added", "ALTER TABLE nokey ADD CONSTRAINT nokey_a_check CHECK (a > 0);\nALTER TABLE nokey DROP CONSTRAINT nokey_a_check;", true}, + {"dropped and added again", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT t_n_check CHECK (n > 1);\nALTER TABLE t DROP CONSTRAINT t_n_check;", true}, + {"missing constraint under IF EXISTS", "ALTER TABLE t DROP CONSTRAINT IF EXISTS nope;\nALTER TABLE IF EXISTS nope DROP CONSTRAINT x;", true}, + {"key referenced by a foreign key, without CASCADE", "ALTER TABLE t DROP CONSTRAINT t_pkey;", true}, + {"CASCADE drops the foreign keys on the key", "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nALTER TABLE r DROP CONSTRAINT IF EXISTS r_t_fk;", true}, + {"DROP TABLE CASCADE drops the foreign keys on it", "DROP TABLE t CASCADE;\nALTER TABLE r DROP CONSTRAINT IF EXISTS r_t_fk;", true}, + {"DROP COLUMN CASCADE drops the foreign keys on it", "ALTER TABLE p DROP COLUMN id CASCADE;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_p_fk;", true}, + // A cascading drop of a schema ends the scan. + {"DROP SCHEMA CASCADE", "DROP SCHEMA s CASCADE;\nSET search_path = s, public;\nALTER TABLE t DROP CONSTRAINT t_n_check;", false}, + {"renamed column keeps the check", "ALTER TABLE t RENAME COLUMN n TO m;\nALTER TABLE t DROP CONSTRAINT t_n_check;", true}, + // A moved table is not followed, and neither is a table the change + // creates with a key. + {"moved to another schema", "CREATE SCHEMA s2;\nALTER TABLE t SET SCHEMA s2;\nALTER TABLE s2.t DROP CONSTRAINT t_n_check;\nALTER TABLE s2.t DROP CONSTRAINT t_pkey CASCADE;", false}, + {"copy of a table", "CREATE TABLE c (LIKE t INCLUDING ALL);\nALTER TABLE c DROP CONSTRAINT c_pkey;\nALTER TABLE c DROP CONSTRAINT t_n_check;", false}, + {"shadowed by the search path", "SET search_path = s, public;\nALTER TABLE t DROP CONSTRAINT t_pkey;\nALTER TABLE public.t DROP CONSTRAINT t_code_key;", true}, + {"shadowed by a new table", "CREATE TABLE postgres.t (id int CONSTRAINT t_pkey UNIQUE, n int CONSTRAINT t_n_check CHECK (n > 0));\nALTER TABLE t DROP CONSTRAINT t_pkey;\nALTER TABLE t DROP CONSTRAINT t_n_check;\nALTER TABLE public.t DROP CONSTRAINT t_code_key;", true}, + {"shadowed by a new view", "CREATE VIEW postgres.nokey AS SELECT 1 AS a;\nCREATE TABLE IF NOT EXISTS nokey (a int);\nALTER TABLE public.t DROP CONSTRAINT t_n_check;", true}, + {"dropped with its column", "ALTER TABLE t DROP COLUMN n;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_n_check;", true}, + // After a column's drop the scan no longer follows the table's + // constraints, nor a key column through a rename. + {"dropped with its column in the same statement", "ALTER TABLE t DROP COLUMN n, DROP CONSTRAINT IF EXISTS t_n_check, DROP CONSTRAINT t_p_fk;", false}, + {"column a view reads, without CASCADE", "ALTER TABLE t DROP COLUMN code;", true}, + {"column a view reads, with CASCADE", "ALTER TABLE t DROP COLUMN code CASCADE;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_code_key;\nCREATE VIEW tv AS SELECT 1 AS x;", true}, + {"renamed column, then dropped", "ALTER TABLE t RENAME COLUMN id TO tid;\nALTER TABLE r DROP CONSTRAINT r_t_fk;\nALTER TABLE t DROP COLUMN tid;\nALTER TABLE t DROP COLUMN IF EXISTS id;", false}, + {"a missing column is refused", "ALTER TABLE t DROP CONSTRAINT t_n_check, DROP COLUMN nope;", true}, + {"a missing constraint is refused", "ALTER TABLE t DROP CONSTRAINT t_n_check, DROP CONSTRAINT nope;", true}, + {"dropped key added again in the same statement", "ALTER TABLE t ADD PRIMARY KEY (code), DROP CONSTRAINT t_pkey CASCADE;", true}, + {"IF NOT EXISTS in a new schema on the path", "CREATE SCHEMA x;\nSET search_path = x, public;\nCREATE TABLE IF NOT EXISTS t (id int);\nCREATE TABLE IF NOT EXISTS public.nokey (a int);\nCREATE TABLE IF NOT EXISTS public.fresh (a int);", false}, + {"same names in two schemas", "ALTER TABLE s.t DROP CONSTRAINT t_pkey;\nALTER TABLE public.t DROP CONSTRAINT t_pkey CASCADE;", true}, + {"a dropped table no longer holds its foreign keys", "DROP TABLE r;\nALTER TABLE t DROP CONSTRAINT t_pkey;", true}, + {"a dropped table frees its index names", "DROP TABLE e;\nCREATE TABLE IF NOT EXISTS e_pkey (id int);", true}, + {"a later subcommand fails", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD COLUMN id int;", true}, + {"a later subcommand alters a missing column", "ALTER TABLE t DROP CONSTRAINT t_n_check, ALTER COLUMN nope SET DEFAULT 1;", true}, + {"a later subcommand reuses a name", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT t_code_key CHECK (n > 1);", true}, + {"a later subcommand adds a second key", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD PRIMARY KEY (code);", true}, + {"drops run first", "ALTER TABLE t ADD CONSTRAINT t_n_check CHECK (n > 1), DROP CONSTRAINT t_n_check, DROP COLUMN n, ADD COLUMN n int, ADD COLUMN IF NOT EXISTS id int;\nALTER TABLE s.t DROP CONSTRAINT t_pkey, ADD COLUMN x int, ALTER COLUMN id SET NOT NULL;", true}, + {"dropping a table others depend on", "DROP TABLE p;\nALTER TABLE t DROP CONSTRAINT t_n_check;", true}, + {"dropping a table a view reads", "DROP TABLE r;\nDROP TABLE t;\nALTER TABLE p DROP CONSTRAINT p_pkey CASCADE;", true}, + {"dropping both ends of a foreign key", "DROP VIEW tv;\nDROP TABLE r, t;\nALTER TABLE p DROP CONSTRAINT p_pkey;", true}, + {"an unrelated cascading column drop", "ALTER TABLE p ADD COLUMN note int;\nALTER TABLE p DROP COLUMN note CASCADE;\nALTER TABLE t DROP CONSTRAINT t_p_fk;", true}, + {"the same constraint dropped twice", "ALTER TABLE t DROP CONSTRAINT t_n_check, DROP CONSTRAINT t_n_check;", true}, + {"a constraint gone with its column", "ALTER TABLE t DROP CONSTRAINT t_code_key, DROP COLUMN n, DROP CONSTRAINT t_n_check;\nALTER TABLE s.t DROP CONSTRAINT t_pkey;", true}, + {"a new foreign key on one key", "CREATE TABLE c (t_id int REFERENCES t (id));\nALTER TABLE t DROP CONSTRAINT t_code_key;", true}, + {"a new foreign key on the primary key", "CREATE TABLE c2 (t_id int REFERENCES t);\nALTER TABLE r DROP CONSTRAINT r_t_fk;\nALTER TABLE t DROP CONSTRAINT t_pkey;", true}, + {"a view a new view reads", "CREATE VIEW tv2 AS SELECT code FROM tv;\nDROP VIEW tv;\nCREATE TABLE n (id int);", true}, + {"a qualified move of a new table", "CREATE TABLE public.n (id int);\nCREATE SCHEMA x;\nCREATE TABLE x.n (id int);\nALTER TABLE public.n SET SCHEMA s;\nALTER TABLE x.n ADD PRIMARY KEY (id);", true}, + {"a new foreign key dropped with its table", "ALTER TABLE r DROP CONSTRAINT r_t_fk;\nCREATE TABLE c (t_id int REFERENCES t (id));\nDROP TABLE c;\nALTER TABLE t DROP CONSTRAINT t_pkey;", true}, + {"a new foreign key dropped by name", "ALTER TABLE r DROP CONSTRAINT r_t_fk;\nCREATE TABLE c (t_id int CONSTRAINT c_fk REFERENCES t (id));\nALTER TABLE c DROP CONSTRAINT c_fk;\nALTER TABLE t DROP CONSTRAINT t_pkey;", true}, + {"a new view on a table of the same name elsewhere", "CREATE VIEW v AS SELECT * FROM s.t;\nDROP TABLE r;\nDROP VIEW tv;\nDROP TABLE public.t;\nCREATE TABLE n (id int);", true}, + {"dropping a missing table", "DROP TABLE nope;\nCREATE TABLE n (id int);", true}, + {"a column added twice", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD COLUMN x int, ADD COLUMN x int;", true}, + {"tables of CREATE SCHEMA", "CREATE SCHEMA z CREATE TABLE zt (id int) CREATE TABLE zk (id int PRIMARY KEY);", true}, + {"a CTE named like a table", "CREATE VIEW v AS WITH nokey AS (SELECT 1 AS a) SELECT * FROM nokey;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"a view the change drops", "CREATE VIEW v AS SELECT * FROM nokey;\nDROP VIEW v;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"a function the change creates returns the table", "CREATE FUNCTION g() RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"DROP TABLE of a view", "DROP TABLE tv;\nCREATE TABLE n (id int);", true}, + {"DROP INDEX of a key's index", "DROP INDEX t_pkey;\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE of an existing name", "CREATE TABLE public.nokey (a int);\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE in the session user's schema", "CREATE TABLE nokey (a int);", true}, + {"a CTE body reads the table it shadows", "CREATE VIEW v AS WITH nokey AS (SELECT * FROM nokey) SELECT * FROM nokey;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"IF NOT EXISTS in the session user's schema", "CREATE TABLE IF NOT EXISTS nokey (a text REFERENCES t (code));\nALTER TABLE t DROP CONSTRAINT t_code_key;", true}, + {"ADD COLUMN IF NOT EXISTS of an existing column", "ALTER TABLE r DROP CONSTRAINT r_t_fk;\nALTER TABLE t DROP CONSTRAINT t_pkey;\nALTER TABLE t ADD COLUMN IF NOT EXISTS id int PRIMARY KEY;", true}, + // A name of the form the server generates is not reported after a + // statement that may have generated it, wherever it would go. + {"a table named like a generated index elsewhere", "CREATE INDEX ON nokey (a);\nCREATE TABLE nokey_a_idx (x int);", false}, + {"a table named like a generated index", "CREATE INDEX ON nokey (a);\nCREATE TABLE public.nokey_a_idx (x int);", true}, + {"a table named like a key's index", "CREATE TABLE a (id int CONSTRAINT foo PRIMARY KEY);\nCREATE TABLE foo (x int);", true}, + {"a table in a missing schema", "CREATE TABLE missing.n (id int);", true}, + {"IF EXISTS of a missing table, then created", "DROP TABLE IF EXISTS gone;\nCREATE TABLE gone (id int);", true}, + {"DROP FUNCTION IF EXISTS of another signature", "CREATE FUNCTION g(integer) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION IF EXISTS g(text);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"a new view replaced", "CREATE VIEW v AS SELECT * FROM nokey;\nCREATE OR REPLACE VIEW v AS SELECT 1 AS a;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"a table in another database", "CREATE TABLE other.public.n (id int);", true}, + {"ALTER TABLE of a missing table", "ALTER TABLE nope DROP CONSTRAINT x;\nCREATE TABLE n (id int);", true}, + {"ALTER TABLE IF EXISTS of a missing table", "ALTER TABLE IF EXISTS nope DROP CONSTRAINT x;\nCREATE TABLE n (id int);", true}, + {"CREATE SCHEMA of an existing schema", "CREATE SCHEMA s CREATE TABLE zt (id int);", true}, + {"DROP SCHEMA IF EXISTS of a missing schema", "DROP SCHEMA IF EXISTS missing;\nCREATE TABLE missing.n (id int);", true}, + {"a skipped ADD COLUMN", "ALTER TABLE t ADD COLUMN IF NOT EXISTS id int PRIMARY KEY;\nCREATE TABLE n (id int);", true}, + {"two column drops", "ALTER TABLE r DROP CONSTRAINT r_t_fk;\nALTER TABLE t DROP COLUMN n, DROP COLUMN id;", true}, + {"EXPLAIN ANALYZE of CREATE MATERIALIZED VIEW", "EXPLAIN ANALYZE CREATE MATERIALIZED VIEW mv AS SELECT * FROM nokey;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"CREATE SCHEMA IF NOT EXISTS with elements", "CREATE SCHEMA IF NOT EXISTS s CREATE TABLE zt (id int);", true}, + {"CREATE VIEW of an existing name", "CREATE VIEW public.nokey AS SELECT 1 AS a;\nCREATE TABLE n (id int);", true}, + {"CREATE VIEW in the session user's schema", "CREATE VIEW nokey AS SELECT 1 AS a;\nCREATE TABLE n (id int);", true}, + {"a new function takes the table's row type", "CREATE FUNCTION g(x nokey) RETURNS integer LANGUAGE sql AS 'SELECT 1';\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"a definite rename of a new table", "CREATE TABLE public.a (id int);\nALTER TABLE public.a RENAME TO b;\nCREATE TABLE public.a (id int PRIMARY KEY);\nDROP TABLE public.a;", true}, + {"a column drop frees a constraint name", "ALTER TABLE t DROP COLUMN code CASCADE, ADD CONSTRAINT t_code_key UNIQUE (n);\nCREATE TABLE n2 (id int);", true}, + {"CREATE SCHEMA IF NOT EXISTS of an existing schema", "CREATE SCHEMA IF NOT EXISTS s;\nCREATE TABLE s.t (id int);", true}, + {"CREATE SEQUENCE of an existing name", "CREATE SEQUENCE public.nokey;\nCREATE TABLE n (id int);", true}, + {"DROP SCHEMA of a nonempty schema", "DROP SCHEMA s;\nCREATE TABLE s.n (id int);", true}, + {"a renamed new view", "CREATE VIEW public.v AS SELECT * FROM public.nokey;\nALTER VIEW public.v RENAME TO w;\nDROP VIEW public.w;\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", true}, + {"a new view dropped with what it reads", "CREATE VIEW w AS SELECT * FROM tv;\nDROP VIEW tv, w;\nCREATE TABLE n (id int);", true}, + {"a column renamed twice", "ALTER TABLE r DROP CONSTRAINT r_t_fk;\nALTER TABLE t RENAME COLUMN id TO x;\nALTER TABLE t RENAME COLUMN x TO y;\nALTER TABLE t DROP CONSTRAINT t_pkey;\nALTER TABLE t ADD COLUMN IF NOT EXISTS x int PRIMARY KEY;", true}, + {"a column added twice in one statement", "CREATE TABLE n (id int);\nALTER TABLE n ADD COLUMN x int, ADD COLUMN IF NOT EXISTS x int PRIMARY KEY;", true}, + {"DROP COLUMN IF EXISTS of a missing column", "ALTER TABLE t DROP COLUMN IF EXISTS nope;\nALTER TABLE t DROP CONSTRAINT t_n_check;", true}, + {"a new SQL-standard body reads the table", "CREATE FUNCTION g() RETURNS bigint LANGUAGE SQL RETURN (SELECT count(*) FROM nokey);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"DROP of a name in another database", "DROP TABLE other.public.nokey;\nCREATE TABLE n (id int);", true}, + {"a foreign key a cascade took", "ALTER TABLE p DROP CONSTRAINT p_pkey CASCADE;\nALTER TABLE t DROP CONSTRAINT t_p_fk;\nCREATE TABLE n (id int);", true}, + {"a cascade past a replaced view", "CREATE VIEW tv2 AS SELECT code FROM tv;\nCREATE OR REPLACE VIEW tv AS SELECT 'x'::text AS code;\nDROP TABLE r;\nDROP TABLE t CASCADE;\nCREATE TABLE tv2 (id int);", true}, + {"DROP SCHEMA of a schema with a table, at the end", "CREATE TABLE n (id int);\nDROP SCHEMA s;", true}, + {"an added key on a missing column", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT replacement UNIQUE (nope);", true}, + {"DROP FUNCTION of another signature", "CREATE FUNCTION g(integer) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(text);\nDROP TABLE nokey;", true}, + {"a no-op column drop and a reused name", "ALTER TABLE t DROP CONSTRAINT t_code_key, DROP COLUMN IF EXISTS nope, ADD CONSTRAINT t_n_check CHECK (n > 0);", true}, + {"a dropped column takes its foreign key", "ALTER TABLE t DROP COLUMN p_id;\nALTER TABLE p DROP CONSTRAINT p_pkey;", true}, + {"CREATE SCHEMA elements of one name", "CREATE SCHEMA z CREATE TABLE x (id int) CREATE VIEW x AS SELECT 1;", true}, + {"CREATE SCHEMA element named like a generated name", "CREATE SCHEMA z CREATE TABLE x (id int UNIQUE) CREATE TABLE x_id_key (id int);", true}, + {"CREATE SCHEMA element in another schema", "CREATE SCHEMA z CREATE TABLE x (id int) CREATE TABLE s.y (id int);", true}, + {"added foreign key to a missing column", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id) REFERENCES p (nope);", true}, + {"added foreign key to a table without a key", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (n) REFERENCES nokey;", true}, + {"added foreign key of another column count", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id, n) REFERENCES p (id);", true}, + {"added foreign key to a view", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (code) REFERENCES tv (code);", true}, + {"added foreign key to a unique index the change made", "CREATE UNIQUE INDEX ON nokey (a);\nALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT good FOREIGN KEY (n) REFERENCES nokey (a);", false}, + {"added foreign key to its own table without a key", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (n) REFERENCES t (n);", true}, + {"added foreign key to its own table's new key", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT good FOREIGN KEY (n) REFERENCES t (n), ADD CONSTRAINT t_n_key UNIQUE (n);", true}, + {"a column added after the constraint on it", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT replacement UNIQUE (x), ADD COLUMN x int;", true}, + {"a cascade takes a view the change made", "CREATE VIEW w AS SELECT * FROM t;\nDROP TABLE r;\nDROP TABLE t CASCADE;\nCREATE TABLE w (id int);", true}, + {"a cascade takes views reading a view the change made", "CREATE VIEW w AS SELECT * FROM tv;\nCREATE VIEW w2 AS SELECT * FROM public.w;\nDROP TABLE r;\nDROP TABLE t CASCADE;\nCREATE TABLE w (id int);\nCREATE TABLE w2 (id int);", true}, + {"DROP FUNCTION of a name the target lacks", "DROP FUNCTION public.missing();\nCREATE TABLE n (id int);", true}, + {"DROP PROCEDURE", "CREATE PROCEDURE pr(a nokey) LANGUAGE sql AS 'SELECT 1';\nDROP PROCEDURE pr(nokey);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"a renamed function", "CREATE FUNCTION g(nokey) RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION g(nokey) RENAME TO h;\nDROP FUNCTION h(nokey);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"a renamed function holds the table", "CREATE FUNCTION g(nokey) RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION g(nokey) RENAME TO h;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"a cascade takes a function the change made", "CREATE FUNCTION g(nokey) RETURNS SETOF r LANGUAGE sql AS 'SELECT * FROM r';\nDROP TABLE nokey CASCADE;\nDROP TABLE r;\nCREATE TABLE n (id int);", true}, + {"a plain table generates no names", "CREATE TABLE public.a (id int);\nCREATE TABLE public.n_idx (id int);\nDROP TABLE public.a;", true}, + {"LIKE of a keyless table the change made", "CREATE TABLE public.a (id int);\nCREATE TABLE public.b (LIKE public.a INCLUDING INDEXES);\nDROP TABLE public.a;", true}, + {"CREATE TABLE IF NOT EXISTS in a new schema", "CREATE SCHEMA z;\nCREATE TABLE IF NOT EXISTS z.n (id int);", true}, + {"CREATE TABLE of an enum type's name", "CREATE TABLE IF NOT EXISTS mood (id int);", true}, + {"CREATE TABLE of a domain the change made", "CREATE DOMAIN d AS int;\nCREATE TABLE d (id int);", true}, + {"DROP FUNCTION IF EXISTS of a function the change made", "CREATE FUNCTION public.f(public.nokey) RETURNS integer LANGUAGE sql AS 'SELECT 1';\nDROP FUNCTION IF EXISTS public.f(public.nokey);\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", true}, + {"a view follows a renamed table", "CREATE VIEW public.v AS SELECT * FROM public.nokey;\nALTER TABLE public.nokey RENAME TO u;\nDROP TABLE public.u;\nCREATE TABLE n (id int);", true}, + {"a cascading DROP TABLE IF EXISTS of a missing table", "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nDROP TABLE IF EXISTS public.missing CASCADE;", true}, + {"USING INDEX of a missing index", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT replacement UNIQUE USING INDEX missing_idx;", true}, + {"USING INDEX of an index that is not unique", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT t_n_key UNIQUE USING INDEX t_n_idx;", true}, + {"a key named after a table", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT nokey UNIQUE (n);", true}, + {"a key named after another table's index", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT p_pkey UNIQUE (n);", true}, + {"a key named after the one the statement drops", "ALTER TABLE t DROP CONSTRAINT t_code_key, ADD CONSTRAINT t_code_key UNIQUE (code);", true}, + {"a foreign key to a column twice", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id, n) REFERENCES p (id, id);", true}, + {"CREATE TABLE with a column twice", "CREATE TABLE n (id int, id text);", true}, + {"a rename to a name the schema holds", "ALTER TABLE t RENAME TO nokey;\nCREATE TABLE n (id int);", true}, + {"a view follows a table to another schema", "CREATE VIEW public.v AS SELECT * FROM public.nokey;\nALTER TABLE public.nokey SET SCHEMA s;\nDROP TABLE s.nokey;\nCREATE TABLE n (id int);", true}, + {"dropping a key frees its index's name", "ALTER TABLE t DROP CONSTRAINT t_code_key;\nCREATE TABLE IF NOT EXISTS t_code_key (id int);", true}, + {"a qualified DROP of a table the change made", "CREATE TABLE public.n (id int PRIMARY KEY);\nDROP TABLE public.n;\nCREATE TABLE public.n (id int);", true}, + {"a rename of a relation the target lacks", "ALTER TABLE public.missing RENAME TO x;\nCREATE TABLE n (id int);", true}, + {"a rename under IF EXISTS of a relation the target lacks", "ALTER TABLE IF EXISTS public.missing RENAME TO x;\nCREATE TABLE n (id int);", true}, + {"DROP SCHEMA of a schema the change put a table in", "CREATE SCHEMA z;\nCREATE TABLE z.x (id int PRIMARY KEY);\nDROP SCHEMA z;\nCREATE TABLE n (id int);", true}, + {"DROP SCHEMA of a schema the change emptied again", "CREATE SCHEMA z;\nCREATE TABLE z.x (id int PRIMARY KEY);\nDROP TABLE z.x;\nDROP SCHEMA z;\nCREATE TABLE n (id int);", true}, + {"a foreign key of a table the same DROP drops", "CREATE TABLE public.c (id int PRIMARY KEY, n_a int REFERENCES public.p);\nALTER TABLE t DROP CONSTRAINT t_p_fk;\nDROP TABLE public.c, public.p;\nCREATE TABLE n (id int);", true}, + {"INHERITS of a parent the target lacks", "CREATE TABLE child (id int) INHERITS (public.missing);", true}, + {"INHERITS of a view", "CREATE TABLE child (id int) INHERITS (tv);", true}, + {"a second CREATE TABLE of a name the change made", "CREATE TABLE public.x (id int PRIMARY KEY);\nCREATE TABLE public.x (id int);\nCREATE TABLE n (id int);", true}, + {"ALTER TABLE of an index", "ALTER TABLE public.t_pkey DROP CONSTRAINT x;\nCREATE TABLE n (id int);", true}, + {"SET SCHEMA vacates the schema", "ALTER TABLE s.t SET SCHEMA postgres;\nDROP SCHEMA s;\nCREATE TABLE n (id int);", true}, + {"DROP SCHEMA after dropping the function made there", "CREATE SCHEMA z;\nCREATE FUNCTION z.f() RETURNS int LANGUAGE sql AS 'SELECT 1';\nDROP FUNCTION z.f();\nDROP SCHEMA z;\nCREATE TABLE n (id int);", true}, + {"an unqualified name resolved elsewhere", "CREATE TABLE postgres.t (id int);\nSET search_path = s, postgres;\nALTER TABLE t ADD CONSTRAINT t_pk PRIMARY KEY (id);", true}, + {"CREATE TABLE with a foreign key to a table the target lacks", "CREATE TABLE public.n (id int REFERENCES public.missing);", true}, + {"CREATE TABLE with a foreign key to a synced key", "CREATE TABLE n (id int, p_id int REFERENCES p);", true}, + {"CREATE TABLE with a foreign key to columns without a key", "CREATE TABLE n (id int, t_n int REFERENCES t (n));", true}, + {"CREATE TABLE with a foreign key to a table the change made", "CREATE TABLE a (id int PRIMARY KEY);\nCREATE TABLE b (id int, a_id int REFERENCES a);", true}, + {"CREATE TABLE with a foreign key to itself without the key", "CREATE TABLE n (id int, parent int REFERENCES n (id));", true}, + {"a view of CREATE SCHEMA dropped", "CREATE SCHEMA z CREATE VIEW v AS SELECT 1;\nDROP VIEW z.v;\nCREATE TABLE z.v (id int);", true}, + {"DROP TYPE of a type a column uses", "DROP TYPE mood;\nCREATE TABLE n (id int);", true}, + {"a generated name in another schema", "CREATE TABLE s.x (id serial PRIMARY KEY);\nCREATE TABLE postgres.audit_seq (id int);", true}, + {"DROP COLUMN read by a generated column", "ALTER TABLE nokey ADD COLUMN b int GENERATED ALWAYS AS (a + 1) STORED;\nALTER TABLE nokey DROP COLUMN a;\nCREATE TABLE n (id int);", false}, + {"a sequence the change made and dropped", "CREATE SEQUENCE public.q;\nDROP SEQUENCE public.q;\nCREATE TABLE public.q (id int);", true}, + {"LIKE of a table whose key the change dropped", "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nCREATE TABLE n (LIKE t INCLUDING INDEXES);", true}, + {"LIKE of a table the change gave a key", "ALTER TABLE nokey ADD PRIMARY KEY (a);\nCREATE TABLE n (LIKE nokey INCLUDING INDEXES);", true}, + {"CREATE VIEW of a relation the target lacks", "CREATE VIEW public.v AS SELECT * FROM public.missing;\nCREATE TABLE n (id int);", true}, + {"CREATE VIEW of a CTE named like a missing relation", "CREATE VIEW public.v AS WITH missing AS (SELECT 1 AS a) SELECT * FROM missing;\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE OF a type the target lacks", "CREATE TABLE n OF public.missing;", true}, + {"CREATE TABLE OF a table's row type", "CREATE TABLE n OF public.nokey;", true}, + {"CREATE TABLE OF a composite type the change made", "CREATE TYPE ct AS (a int);\nCREATE TABLE n OF ct;", true}, + {"CREATE TABLE naming a constraint twice", "CREATE TABLE n (a int CONSTRAINT c CHECK (a > 0), b int CONSTRAINT c CHECK (b > 0));", true}, + {"CREATE TABLE with a key named after a relation", "CREATE TABLE n (a int CONSTRAINT nokey UNIQUE);", true}, + {"CREATE TABLE with a key named after the table", "CREATE TABLE n (a int CONSTRAINT n UNIQUE);", true}, + {"CREATE TABLE with a foreign key naming a column twice", "CREATE TABLE n (a int, FOREIGN KEY (a, a) REFERENCES p (id, id));", true}, + {"RENAME COLUMN of a column the table lacks", "ALTER TABLE t RENAME COLUMN missing TO x;\nCREATE TABLE n (id int);", true}, + {"RENAME COLUMN to a column the table has", "ALTER TABLE t RENAME COLUMN n TO code;\nCREATE TABLE n (id int);", true}, + {"RENAME COLUMN of a column the table has", "ALTER TABLE t RENAME COLUMN n TO n2;\nCREATE TABLE n (id int);", true}, + {"CREATE FUNCTION in a schema the target lacks", "CREATE FUNCTION missing.f() RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE AS of a name the schema holds", "CREATE TABLE public.nokey AS SELECT 1;\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE AS IF NOT EXISTS of a name the schema holds", "CREATE TABLE IF NOT EXISTS public.nokey AS SELECT 1;\nCREATE TABLE n (id int);", true}, + {"SELECT INTO a name the schema holds", "SELECT 1 AS a INTO public.nokey;\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE AS of a relation the target lacks", "CREATE TABLE x AS SELECT * FROM public.missing;\nCREATE TABLE n (id int);", true}, + {"RENAME CONSTRAINT to a name the table has", "ALTER TABLE t RENAME CONSTRAINT t_n_check TO t_pkey;\nCREATE TABLE n (id int);", true}, + {"RENAME CONSTRAINT of a key to a relation's name", "ALTER TABLE t RENAME CONSTRAINT t_code_key TO nokey;\nCREATE TABLE n (id int);", true}, + {"ALTER TABLE of a table the change dropped", "DROP TABLE public.nokey;\nALTER TABLE public.nokey ADD COLUMN x int;\nCREATE TABLE n (id int);", true}, + {"DROP TABLE IF EXISTS of a table the change dropped", "DROP TABLE public.nokey;\nDROP TABLE IF EXISTS public.nokey;\nCREATE TABLE n (id int);", true}, + {"CREATE FUNCTION with a body of a relation the target lacks", "CREATE FUNCTION public.f() RETURNS bigint LANGUAGE SQL RETURN (SELECT count(*) FROM public.missing);\nCREATE TABLE n (id int);", true}, + {"an added check on a column the table lacks", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad CHECK (nope > 0);", true}, + {"DROP FUNCTION of a scalar signature with an array overload", "CREATE FUNCTION g(int[]) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(int);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"DROP FUNCTION of an array signature", "CREATE FUNCTION g(int[]) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(int[]);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"DROP FUNCTION of a built-in type written otherwise", "CREATE FUNCTION g(int4) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(integer);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", true}, + {"CREATE FOREIGN TABLE of a name the schema holds", "CREATE FOREIGN TABLE public.nokey (id int) SERVER s;\nCREATE TABLE n (id int);", true}, + {"a cascade of a parent of the same name in another schema", "CREATE TABLE postgres.n (x int) INHERITS (s.t);\nDROP TABLE r;\nDROP TABLE public.t CASCADE;", true}, + {"a cascade of a renamed parent", "CREATE TABLE postgres.n (x int) INHERITS (nokey);\nALTER TABLE nokey RENAME TO nk2;\nDROP TABLE nk2 CASCADE;", true}, + {"dropping a renamed key column", "ALTER TABLE \"Quoted\" RENAME COLUMN id TO x;\nALTER TABLE \"Quoted\" DROP COLUMN x;", true}, + {"a renamed column's drop takes the constraint on it", "ALTER TABLE t RENAME COLUMN code TO c2;\nALTER TABLE t DROP COLUMN c2, DROP CONSTRAINT t_code_key;", true}, + {"a mixed ALTER with a subcommand certain to fail", "ALTER TABLE t DROP CONSTRAINT nope, ADD COLUMN id int;\nCREATE TABLE n (id int);", true}, + {"a drop of a statement that may not run", "ALTER TABLE t DROP CONSTRAINT t_p_fk, DROP CONSTRAINT nope;\nALTER TABLE p DROP CONSTRAINT p_pkey;", true}, + {"CREATE INDEX on a relation the target lacks", "CREATE INDEX i ON public.missing (id);\nCREATE TABLE n (id int);", true}, + {"CREATE INDEX on a view", "CREATE INDEX i ON tv (code);\nCREATE TABLE n (id int);", true}, + {"CREATE INDEX of a name the schema holds", "CREATE INDEX nokey ON t (n);\nCREATE TABLE n (id int);", true}, + {"CREATE INDEX IF NOT EXISTS of a name the schema holds", "CREATE INDEX IF NOT EXISTS nokey ON t (n);\nCREATE TABLE n (id int);", true}, + {"CREATE TYPE of a table's name", "CREATE TYPE public.nokey AS ENUM ('x');\nCREATE TABLE n (id int);", true}, + {"a foreign key to the table itself, qualified", "CREATE TABLE n (id int UNIQUE, parent int REFERENCES public.n (id));", true}, + {"a rename of a table the change made to a taken name", "CREATE TABLE public.x (id int PRIMARY KEY);\nALTER TABLE public.x RENAME TO nokey;\nCREATE TABLE n (id int);", true}, + {"DROP TABLE of a view the change made", "CREATE VIEW public.v AS SELECT 1;\nDROP TABLE public.v;\nCREATE TABLE n (id int);", true}, + {"a second CREATE SCHEMA", "CREATE SCHEMA z;\nCREATE SCHEMA z;\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE with a check on a column it lacks", "CREATE TABLE x (a int CHECK (missing > 0));\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE with a key on a column it lacks", "CREATE TABLE x (a int, PRIMARY KEY (missing));\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE with a key naming a column twice", "CREATE TABLE x (a int, b int, UNIQUE (a, a));\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE with two primary keys", "CREATE TABLE x (a int PRIMARY KEY, b int, PRIMARY KEY (b));\nCREATE TABLE n (id int);", true}, + {"CREATE FUNCTION of a signature the change made", "CREATE FUNCTION g() RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE FUNCTION g() RETURNS int LANGUAGE sql AS 'SELECT 2';\nCREATE TABLE n (id int);", true}, + {"an added key naming a column twice", "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD UNIQUE (id, id);", true}, + {"a qualified DROP of another schema's table", "CREATE TABLE t2 (id int);\nDROP TABLE s.t;\nCREATE TABLE postgres.t2 (id int);", true}, + {"a table the search path puts in pg_temp", "SET search_path = pg_temp, public;\nCREATE TABLE n (id int);", true}, + {"CREATE VIEW reading an index", "CREATE VIEW v AS SELECT * FROM public.t_pkey;\nCREATE TABLE n (id int);", true}, + {"CREATE SCHEMA with an index on a table it lacks", "CREATE SCHEMA z CREATE INDEX i ON missing (id);\nCREATE TABLE n (id int);", true}, + {"CREATE SCHEMA with an index on its table", "CREATE SCHEMA z CREATE TABLE x (id int PRIMARY KEY) CREATE INDEX i ON x (id);\nCREATE TABLE n (id int);", true}, + {"CREATE FOREIGN TABLE with a column twice", "CREATE FOREIGN TABLE x (a int, a int) SERVER s;\nCREATE TABLE n (id int);", true}, + {"ALTER FUNCTION RENAME of a function the target lacks", "ALTER FUNCTION public.missing() RENAME TO f;\nCREATE TABLE n (id int);", true}, + {"ALTER PROCEDURE RENAME of a procedure the target lacks", "ALTER PROCEDURE public.missing() RENAME TO f;\nCREATE TABLE n (id int);", true}, + {"DROP NOT NULL of a primary key column", "ALTER TABLE t ALTER COLUMN id DROP NOT NULL;\nCREATE TABLE n (id int);", true}, + {"DROP NOT NULL of another column", "ALTER TABLE t ALTER COLUMN n DROP NOT NULL;\nCREATE TABLE n (id int);", true}, + {"dropping the key the change added", "CREATE TABLE n (id int);\nALTER TABLE n ADD PRIMARY KEY (id);\nALTER TABLE n DROP CONSTRAINT n_pkey;", true}, + {"dropping a named key the change added", "CREATE TABLE n (id int);\nALTER TABLE n ADD CONSTRAINT n_pk PRIMARY KEY (id);\nALTER TABLE n DROP CONSTRAINT n_pk;", true}, + {"a second CREATE TYPE of a name", "CREATE TYPE public.x AS ENUM ('a');\nCREATE TYPE public.x AS ENUM ('b');\nCREATE TABLE n (id int);", true}, + {"CREATE OR REPLACE VIEW of a table the change made", "CREATE TABLE public.x (id int PRIMARY KEY);\nCREATE OR REPLACE VIEW public.x AS SELECT 1;\nCREATE TABLE n (id int);", true}, + {"ALTER SEQUENCE of a sequence the target lacks", "ALTER SEQUENCE public.missing RESTART;\nCREATE TABLE n (id int);", true}, + {"ALTER SEQUENCE of a table", "ALTER SEQUENCE public.nokey RESTART;\nCREATE TABLE n (id int);", true}, + {"DROP TABLE of a table the change made that a new view reads", "CREATE TABLE public.x (id int PRIMARY KEY);\nCREATE VIEW public.w AS SELECT * FROM public.x;\nDROP TABLE public.x;\nCREATE TABLE n (id int);", true}, + {"a statement that may fail", "ALTER TABLE t DROP CONSTRAINT nope, ADD CONSTRAINT x CHECK (n > 0);\nALTER TABLE t DROP CONSTRAINT x;\nCREATE TABLE fresh (id int);", true}, + {"an added foreign key to a table the change made", "CREATE TABLE a (id int PRIMARY KEY);\nALTER TABLE t ADD CONSTRAINT t_a_fk FOREIGN KEY (n) REFERENCES a;\nCREATE TABLE b (id int);", true}, + {"CREATE FUNCTION of a signature the change made with arguments", "CREATE FUNCTION g(integer) RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE FUNCTION g(int4) RETURNS int LANGUAGE sql AS 'SELECT 2';\nCREATE TABLE n (id int);", true}, + {"ALTER INDEX RENAME of a table", "ALTER INDEX public.t RENAME TO x;\nCREATE TABLE n (id int);", true}, + {"ALTER SEQUENCE RENAME of a table", "ALTER SEQUENCE public.t RENAME TO x;\nCREATE TABLE n (id int);", true}, + {"ALTER TABLE RENAME of a view", "ALTER TABLE tv RENAME TO tv2;\nCREATE TABLE n (id int);", true}, + {"ALTER VIEW SET SCHEMA of a table", "ALTER VIEW public.nokey SET SCHEMA s;\nCREATE TABLE n (id int);", true}, + {"CREATE INDEX on a column the table lacks", "CREATE INDEX i ON t (missing);\nCREATE TABLE n (id int);", true}, + {"DROP FUNCTION by a name several routines have", "CREATE FUNCTION g(integer) RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE FUNCTION g(text) RETURNS int LANGUAGE sql AS 'SELECT 1';\nDROP FUNCTION g;\nCREATE TABLE n (id int);", true}, + {"ALTER TABLE IF EXISTS of a table the target lacks", "ALTER TABLE IF EXISTS public.missing ADD CONSTRAINT fk FOREIGN KEY (x) REFERENCES public.t (id);\nALTER TABLE public.t DROP CONSTRAINT t_code_key;", true}, + {"DROP CONSTRAINT of an inherited check", "ALTER TABLE ic DROP CONSTRAINT ip_a_check;", true}, + {"DROP TABLE a view reads without naming a column", "DROP TABLE cvt;\nCREATE TABLE n (id int);", true}, + {"a cascade takes a view reading a table without naming a column", "DROP TABLE cvt CASCADE;\nCREATE TABLE IF NOT EXISTS cv (id int);", true}, + {"OWNED BY a column the table lacks", "CREATE SEQUENCE public.q;\nALTER SEQUENCE public.q OWNED BY t.nope;\nCREATE TABLE n (id int);", true}, + {"CREATE TYPE AS with an attribute twice", "CREATE TYPE public.ct AS (a int, a text);\nCREATE TABLE n (id int);", true}, + {"RENAME CONSTRAINT of a constraint the table lacks", "ALTER TABLE public.t RENAME CONSTRAINT missing TO replacement;\nCREATE TABLE n (id int);", true}, + {"SET SCHEMA of a table the change made to a schema holding its name", "CREATE TABLE public.x (id int PRIMARY KEY);\nCREATE TABLE s.x (id int PRIMARY KEY);\nALTER TABLE public.x SET SCHEMA s;\nCREATE TABLE n (id int);", true}, + {"ALTER TABLE of a view the change made", "CREATE VIEW public.v AS SELECT 1;\nALTER TABLE public.v ADD COLUMN x int;\nCREATE TABLE n (id int);", true}, + {"CREATE OR REPLACE VIEW renaming a column", "CREATE OR REPLACE VIEW tv AS SELECT 'x'::text AS other;\nCREATE TABLE n (id int);", true}, + {"CREATE OR REPLACE VIEW adding a column", "CREATE OR REPLACE VIEW tv AS SELECT code, 1 AS extra FROM t;\nCREATE TABLE n (id int);", true}, + {"CREATE TRIGGER on a relation the target lacks", "CREATE TRIGGER tr BEFORE INSERT ON public.missing FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", true}, + {"COMMENT ON a column the table lacks", "COMMENT ON COLUMN t.nope IS 'x';\nCREATE TABLE n (id int);", true}, + {"GRANT on a table the target lacks", "GRANT SELECT ON public.missing TO PUBLIC;\nCREATE TABLE n (id int);", true}, + {"COMMENT ON a table the target has", "COMMENT ON TABLE t IS 'x';\nCREATE TABLE n (id int);", true}, + {"ALTER FUNCTION RENAME of a signature the change lacks", "CREATE FUNCTION public.g(integer) RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION public.g(text) RENAME TO h;\nCREATE TABLE n (id int);", true}, + {"CREATE OR REPLACE VIEW changing a column's type", "CREATE OR REPLACE VIEW tv AS SELECT 1 AS code;\nCREATE TABLE n (id int);", true}, + {"CREATE VIEW naming a column twice", "CREATE VIEW v (a, a) AS SELECT 1, 2;\nCREATE TABLE n (id int);", true}, + {"ALTER TABLE DROP COLUMN of a column a table the change made lacks", "CREATE TABLE public.x (id int PRIMARY KEY);\nALTER TABLE public.x DROP COLUMN missing;\nCREATE TABLE n (id int);", true}, + {"OWNED BY a column a table the change made lacks", "CREATE TABLE public.t2 (id int PRIMARY KEY);\nCREATE SEQUENCE public.q;\nALTER SEQUENCE public.q OWNED BY public.t2.missing;\nCREATE TABLE n (id int);", true}, + {"CREATE TRIGGER on a materialized view", "CREATE MATERIALIZED VIEW public.mv AS SELECT 1 AS a;\nCREATE FUNCTION public.tf() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';\nCREATE TRIGGER tr BEFORE INSERT ON public.mv FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", true}, + {"ALTER FUNCTION SET SCHEMA to a schema the target lacks", "CREATE FUNCTION public.g() RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION public.g() SET SCHEMA missing;\nCREATE TABLE n (id int);", true}, + {"CREATE TYPE AS ENUM with a label twice", "CREATE TYPE public.e2 AS ENUM ('a', 'a');\nCREATE TABLE n (id int);", true}, + {"CREATE OR REPLACE VIEW keeping a column's type", "CREATE OR REPLACE VIEW tv AS SELECT 'x'::text AS code;\nCREATE TABLE n (id int);", true}, + {"DROP CONSTRAINT of a name a table the change made did not give", "CREATE TABLE public.x (id int PRIMARY KEY, CONSTRAINT c CHECK (id > 0));\nALTER TABLE public.x DROP CONSTRAINT missing;\nCREATE TABLE n (id int);", true}, + {"DROP CONSTRAINT of a name a table the change made gave", "CREATE TABLE public.x (id int PRIMARY KEY, CONSTRAINT c CHECK (id > 0));\nALTER TABLE public.x DROP CONSTRAINT c;\nCREATE TABLE n (id int);", true}, + {"CREATE SCHEMA with a view naming an output twice", "CREATE SCHEMA z CREATE VIEW v AS SELECT 1 AS a, 2 AS a;\nCREATE TABLE n (id int);", true}, + {"CREATE INDEX on a table the change made under a taken name", "CREATE TABLE public.t2 (id int PRIMARY KEY);\nCREATE INDEX nokey ON public.t2 (id);\nCREATE TABLE n (id int);", true}, + {"a function the change made follows SET SCHEMA", "CREATE FUNCTION public.f() RETURNS public.nokey LANGUAGE sql AS 'SELECT NULL::public.nokey';\nALTER FUNCTION public.f() SET SCHEMA s;\nDROP FUNCTION s.f();\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", true}, + {"CREATE SCHEMA with a trigger on a relation the target lacks", "CREATE FUNCTION public.tf() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';\nCREATE SCHEMA z CREATE TRIGGER tr BEFORE INSERT ON public.missing FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", true}, + {"CREATE VIEW with more column names than outputs", "CREATE VIEW v (a, b) AS SELECT 1;\nCREATE TABLE n (id int);", true}, + {"CREATE SEQUENCE OWNED BY a table the target lacks", "CREATE SEQUENCE public.q OWNED BY public.missing.id;\nCREATE TABLE n (id int);", true}, + {"TRUNCATE of a view", "TRUNCATE tv;\nCREATE TABLE n (id int);", true}, + {"a view the change made unqualified, dropped qualified", "CREATE VIEW v AS SELECT * FROM public.nokey;\nDROP VIEW public.v;\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", true}, + {"CREATE TABLE with a generated column of a column it lacks", "CREATE TABLE public.x (id int PRIMARY KEY, g int GENERATED ALWAYS AS (missing + 1) STORED);\nCREATE TABLE n (id int);", true}, + {"a second CREATE TRIGGER of a name", "CREATE FUNCTION public.tf() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';\nCREATE TRIGGER tr BEFORE INSERT ON nokey FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TRIGGER tr BEFORE INSERT ON nokey FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", false}, + {"CREATE SCHEMA with a sequence owned by a table it lacks", "CREATE SCHEMA z CREATE SEQUENCE q OWNED BY z.missing.id;\nCREATE TABLE n (id int);", true}, + {"a second CREATE TRIGGER of a name on a table", "CREATE FUNCTION public.tf() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';\nCREATE TRIGGER tr BEFORE INSERT ON nokey FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TRIGGER tr BEFORE INSERT ON nokey FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", true}, + {"dropping the key a table the change made was made with", "CREATE TABLE n (id int PRIMARY KEY);\nALTER TABLE n DROP CONSTRAINT n_pkey;", true}, + {"UPDATE of a table the target lacks", "UPDATE public.missing SET id = 1;\nCREATE TABLE n (id int);", true}, + {"SELECT of a table the target lacks", "SELECT * FROM public.missing;\nCREATE TABLE n (id int);", true}, + {"CREATE INDEX on an expression of a column the table lacks", "CREATE INDEX i ON public.t ((missing + 1));\nCREATE TABLE n (id int);", true}, + {"CREATE DOMAIN with a check naming anything but VALUE", "CREATE DOMAIN public.d AS int CHECK (missing > 0);\nCREATE TABLE n (id int);", true}, + {"CREATE DOMAIN with a check of VALUE", "CREATE DOMAIN public.d AS int CHECK (VALUE > 0);\nCREATE TABLE n (id int);", true}, + {"ALTER TYPE ADD VALUE of a type the target lacks", "ALTER TYPE public.missing ADD VALUE 'x';\nCREATE TABLE n (id int);", true}, + {"ALTER TYPE ADD VALUE of an enum", "ALTER TYPE mood ADD VALUE 'x';\nCREATE TABLE n (id int);", true}, + {"DROP INDEX of a renamed constraint index", "ALTER INDEX public.t_pkey RENAME TO x;\nDROP INDEX public.x;\nCREATE TABLE n (id int);", true}, + {"a trigger of CREATE SCHEMA on its own table", "CREATE FUNCTION public.tf() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';\nCREATE SCHEMA z CREATE TABLE t (id int) CREATE TRIGGER tr BEFORE INSERT ON z.t FOR EACH ROW EXECUTE FUNCTION public.tf();", true}, + {"ALTER TYPE SET SCHEMA to a schema the target lacks", "ALTER TYPE mood SET SCHEMA missing;\nCREATE TABLE n (id int);", true}, + {"a renamed key of a table the change made, then dropped", "CREATE TABLE n (id int PRIMARY KEY);\nALTER TABLE n RENAME CONSTRAINT n_pkey TO x;\nALTER TABLE n DROP CONSTRAINT x;", true}, + {"DROP EXTENSION IF EXISTS of an extension the target lacks", "DROP EXTENSION IF EXISTS missing;\nCREATE TABLE n (id int);", true}, + {"DROP EVENT TRIGGER of one the target lacks", "DROP EVENT TRIGGER missing;\nCREATE TABLE n (id int);", true}, + {"COMMENT ON a column a view lacks", "COMMENT ON COLUMN tv.missing IS 'x';\nCREATE TABLE n (id int);", true}, + {"ALTER SCHEMA OWNER of a schema the target lacks", "ALTER SCHEMA missing OWNER TO postgres;\nCREATE TABLE n (id int);", true}, + {"quoted names", "ALTER TABLE \"Quoted\" DROP CONSTRAINT \"Quoted_pkey\";\nCREATE TABLE \"New\" (\"Id\" int);", true}, + {"new tables", "CREATE TABLE n (id int);\nCREATE TABLE k (id int PRIMARY KEY);\nCREATE TABLE k2 (a int, b int, CONSTRAINT k2_pk PRIMARY KEY (a, b));\nCREATE UNLOGGED TABLE ul (a int);\nCREATE TABLE \"Mixed\" (a int);", true}, + {"keys added later", "CREATE TABLE n (id int);\nALTER TABLE n RENAME TO m;\nALTER TABLE m ADD PRIMARY KEY (id);\nCREATE TABLE o (id int NOT NULL);\nCREATE UNIQUE INDEX o_id ON o (id);\nALTER TABLE o ADD CONSTRAINT o_pkey PRIMARY KEY USING INDEX o_id;\nCREATE TABLE q (id int);\nALTER TABLE q ADD COLUMN k serial PRIMARY KEY;", true}, + {"key dropped at the end", "CREATE TABLE n (id int PRIMARY KEY);\nALTER TABLE n DROP CONSTRAINT n_pkey;", false}, + {"table dropped again", "CREATE TABLE n (id int);\nDROP TABLE n;", true}, + {"tables not made by CREATE TABLE", "CREATE TABLE a AS SELECT 1 AS x;\nSELECT 1 AS x INTO b;\nCREATE TABLE IF NOT EXISTS nokey (a int);\nCREATE MATERIALIZED VIEW mv AS SELECT 1 AS x;", true}, + {"partitions", "CREATE TABLE pt (id int) PARTITION BY RANGE (id);\nCREATE TABLE pt1 PARTITION OF pt FOR VALUES FROM (1) TO (10);\nCREATE TABLE pk2 (id int PRIMARY KEY) PARTITION BY LIST (id);\nCREATE TABLE pk2_1 PARTITION OF pk2 FOR VALUES IN (1);\nCREATE TABLE pk2_2 (id int NOT NULL);\nALTER TABLE pk2 ATTACH PARTITION pk2_2 FOR VALUES IN (2);", true}, + {"LIKE", "CREATE TABLE a (LIKE t INCLUDING ALL);\nCREATE TABLE b (LIKE t INCLUDING INDEXES);\nCREATE TABLE c (LIKE t INCLUDING CONSTRAINTS);\nCREATE TABLE d (LIKE t);", true}, + {"inheritance does not pass the key on", "CREATE TABLE child (extra int) INHERITS (t);", true}, + {"typed tables", "CREATE TYPE ty AS (id int);\nCREATE TABLE typed OF ty (PRIMARY KEY (id));\nCREATE TABLE typed2 OF ty;", true}, + {"identity and unique", "CREATE TABLE n (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY, x int UNIQUE);\nCREATE TABLE m (id int GENERATED ALWAYS AS IDENTITY, x int UNIQUE NOT NULL);", true}, + {"table recreated", "DROP TABLE r;\nDROP TABLE t CASCADE;\nCREATE TABLE t (id int);", true}, + {"keyed tables losing their key", "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nALTER TABLE p DROP COLUMN id CASCADE;", true}, + {"key dropped with its column", "ALTER TABLE r DROP CONSTRAINT r_t_fk;\nALTER TABLE t DROP COLUMN id;", true}, + {"key column a foreign key references, without CASCADE", "ALTER TABLE t DROP COLUMN id;", true}, + {"key a new foreign key references, without CASCADE", "CREATE TABLE r2 (t_code text REFERENCES t (code));\nALTER TABLE t DROP CONSTRAINT t_code_key;", true}, + {"column a new view reads, without CASCADE", "CREATE VIEW v2 AS SELECT n FROM t;\nALTER TABLE t DROP COLUMN n, DROP CONSTRAINT t_code_key;", true}, + {"key dropped and added back", "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nALTER TABLE t ADD PRIMARY KEY (id);", true}, + {"column type change keeps the key", "ALTER TABLE r DROP CONSTRAINT r_t_fk;\nALTER TABLE t ALTER COLUMN id TYPE bigint;", true}, + {"no key before the change", "ALTER TABLE nokey ADD PRIMARY KEY (a);\nALTER TABLE nokey DROP CONSTRAINT nokey_pkey;", true}, + {"pg_dump", "SELECT pg_catalog.set_config('search_path', '', false);\nCREATE TABLE public.n (id integer NOT NULL);\nCREATE TABLE public.m (id integer NOT NULL);\nALTER TABLE ONLY public.n ADD CONSTRAINT n_pkey PRIMARY KEY (id);", true}, + {"new schema on the path", "CREATE SCHEMA x;\nCREATE TABLE x.t (id int);\nSET search_path = x, public;\nALTER TABLE t ADD PRIMARY KEY (id);\nCREATE TABLE y (id int);\nALTER TABLE public.t DROP CONSTRAINT t_n_check;", true}, + {"SET ROLE moves $user", "SET ROLE s;\nALTER TABLE t DROP CONSTRAINT t_pkey;\nRESET ROLE;\nALTER TABLE u DROP CONSTRAINT u_k_check;", true}, + {"procedural code", "CREATE TABLE n (id int);\nDO $$ BEGIN ALTER TABLE n ADD PRIMARY KEY (id); END $$;\nALTER TABLE t DROP CONSTRAINT t_n_check;", false}, + {"temporary tables", "CREATE TEMP TABLE t (id int);\nALTER TABLE t ADD CONSTRAINT t_n_check CHECK (id > 0);\nALTER TABLE t DROP CONSTRAINT t_n_check;", false}, + {"rolled back", "BEGIN;\nALTER TABLE t DROP CONSTRAINT t_n_check;\nCREATE TABLE n (id int);\nROLLBACK;", false}, + {"in a transaction", "BEGIN;\nALTER TABLE t DROP CONSTRAINT t_n_check;\nCREATE TABLE n (id int);\nCOMMIT;", true}, + // CREATE SCHEMA runs its sequences, tables, views, indexes, and + // triggers in that order, whatever the order written. + {"CREATE SCHEMA makes tables before indexes", "CREATE SCHEMA z CREATE INDEX i ON x (id) CREATE TABLE x (id int);", true}, + {"CREATE SCHEMA makes tables before views", "CREATE SCHEMA z CREATE VIEW v AS SELECT id FROM z.x CREATE TABLE x (id int);", true}, + {"CREATE SCHEMA makes sequences before tables", "CREATE SCHEMA z CREATE TABLE x (id int DEFAULT nextval('z.q')) CREATE SEQUENCE q;", true}, + {"COPY of a relation the target lacks", "COPY public.missing TO STDOUT;\nCREATE TABLE n (id int);", true}, + {"COPY of a query reading a relation the target lacks", "COPY (SELECT id FROM public.missing) TO STDOUT;\nCREATE TABLE n (id int);", true}, + {"COPY TO of a view", "COPY tv TO STDOUT;\nCREATE TABLE n (id int);", true}, + {"dropping a key column of a new table", "CREATE TABLE n (id int PRIMARY KEY, x int);\nALTER TABLE n DROP COLUMN id;", true}, + {"dropping a column of a new composite key", "CREATE TABLE n (a int, b int, PRIMARY KEY (a, b));\nALTER TABLE n DROP COLUMN b, DROP COLUMN IF EXISTS c;", true}, + {"dropping a key column its own foreign key references", "CREATE TABLE n (id int PRIMARY KEY, parent int REFERENCES n (id));\nALTER TABLE n DROP COLUMN id;\nCREATE TABLE m (id int);", true}, + {"dropping a key column a generated column reads", "CREATE TABLE n (id int PRIMARY KEY, g int GENERATED ALWAYS AS (id * 2) STORED);\nALTER TABLE n DROP COLUMN id;\nCREATE TABLE m (id int);", true}, + {"dropping a key column a policy reads", "CREATE TABLE n (id int PRIMARY KEY, x int);\nCREATE POLICY p ON n USING (id > 0);\nALTER TABLE n DROP COLUMN id;\nCREATE TABLE m (id int);", true}, + {"dropping a synced column a new policy reads", "CREATE POLICY p ON t USING (n > 0);\nALTER TABLE t DROP COLUMN n;\nCREATE TABLE m (id int);", true}, + {"a temporary table in a regular schema", "CREATE TEMP TABLE public.x (id int);\nCREATE TABLE n (id int);", true}, + {"a temporary view in a regular schema", "CREATE TEMP VIEW public.v AS SELECT 1;\nCREATE TABLE n (id int);", true}, + {"a temporary table in the temporary schema", "CREATE TEMP TABLE pg_temp.x (id int);\nCREATE TABLE n (id int);", true}, + {"a column type in a schema the target lacks", "CREATE TABLE public.n (x missing.foo);\nCREATE TABLE m (id int);", true}, + {"a column type in pg_catalog", "CREATE TABLE public.n (x pg_catalog.int4);", true}, + {"a function the change made, renamed, is gone under its old name", "CREATE FUNCTION public.g(integer) RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION public.g(integer) RENAME TO h;\nDROP FUNCTION public.g(integer);\nCREATE TABLE n (id int);", true}, + {"a key added with its column, then dropped", "CREATE TABLE n (x int);\nALTER TABLE n ADD COLUMN id int CONSTRAINT n_pk PRIMARY KEY;\nALTER TABLE n DROP CONSTRAINT n_pk;", true}, + {"a key added with its column, then the column dropped", "CREATE TABLE n (x int);\nALTER TABLE n ADD COLUMN id int PRIMARY KEY;\nALTER TABLE n DROP COLUMN id;", true}, + {"a key added to a new table, then its column dropped", "CREATE TABLE n (id int, x int);\nALTER TABLE n ADD PRIMARY KEY (id);\nALTER TABLE n DROP COLUMN id;", true}, + {"REINDEX of a relation the target lacks", "REINDEX TABLE public.missing;\nCREATE TABLE n (id int);", true}, + {"REINDEX TABLE of a view", "REINDEX TABLE tv;\nCREATE TABLE n (id int);", true}, + {"REINDEX INDEX of a table", "REINDEX INDEX t;\nCREATE TABLE n (id int);", true}, + {"REINDEX of a schema the target lacks", "REINDEX SCHEMA missing;\nCREATE TABLE n (id int);", true}, + {"REINDEX of a table", "REINDEX TABLE t;\nREINDEX INDEX t_pkey;\nCREATE TABLE n (id int);", true}, + {"ALTER FUNCTION of a routine the target lacks", "ALTER FUNCTION public.missing() IMMUTABLE;\nCREATE TABLE n (id int);", true}, + {"ALTER FUNCTION of a signature the change did not make", "CREATE FUNCTION public.g(integer) RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION public.g(text) IMMUTABLE;\nCREATE TABLE n (id int);", true}, + {"ALTER FUNCTION of a routine the change made", "CREATE FUNCTION public.g(integer) RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION public.g(integer) IMMUTABLE;\nCREATE TABLE n (id int);", true}, + {"NOT VALID on a table's primary key", "CREATE TABLE n (id int, PRIMARY KEY (id) NOT VALID);\nCREATE TABLE m (id int);", true}, + {"NOT VALID on a column's foreign key", "CREATE TABLE n (id int REFERENCES t NOT VALID);\nCREATE TABLE m (id int);", true}, + {"NO INHERIT on a foreign key", "CREATE TABLE n (id int, FOREIGN KEY (id) REFERENCES t NO INHERIT);\nCREATE TABLE m (id int);", true}, + {"DEFERRABLE on a check", "ALTER TABLE t ADD CONSTRAINT x CHECK (n > 0) DEFERRABLE, DROP CONSTRAINT t_n_check;", true}, + {"NOT VALID on a table's check", "CREATE TABLE n (id int, CHECK (id > 0) NOT VALID);", true}, + {"a new table shadowing another on the path", "SET search_path = public, s;\nCREATE TABLE s.n (id int);\nCREATE TABLE public.n (id int PRIMARY KEY);\nDROP TABLE n;", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + conn, err := db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + resetOracle(t, conn) + schema := syncSchema(t, conn) + + result, err := Review(ctx, tt.change, review.Options{Rules: []review.Rule{review.DisallowDropConstraint, review.RequirePrimaryKey}}, []review.Target{{Schema: schema, SessionUser: "postgres"}}) + if err != nil { + t.Fatalf("Review: %v", err) + } + want, failedAt := walkTruth(t, conn, tt.change) + var got []string + for _, f := range result.Findings { + if f.Rule == review.Syntax { + t.Fatalf("syntax: %s", f.Message) + } + if failedAt >= 0 && f.Statement == failedAt { + t.Errorf("reported a finding on the statement the server rejects: %s", f.Message) + continue + } + if failedAt >= 0 && (f.Statement > failedAt || f.Rule == review.RequirePrimaryKey) { + t.Errorf("reported a finding the change never gets to, after the statement the server rejects: %s", f.Message) + continue + } + got = append(got, fmt.Sprintf("%d %s %q: %s", f.Statement, f.Rule, tt.change[f.Range.Start:f.Range.End], f.Message)) + } + for _, g := range got { + if !slices.Contains(want, g) { + t.Errorf("reported what the server contradicts: %s\nthe server's: %q", g, want) + } + } + if tt.complete && failedAt < 0 { + for _, w := range want { + if !slices.Contains(got, w) { + t.Errorf("missed: %s\ngot: %q", w, got) + } + } + } + }) + } +} + +// resetOracle empties the database down to oracleBase. +func resetOracle(t *testing.T, conn *sql.Conn) { + t.Helper() + ctx := context.Background() + rows, err := conn.QueryContext(ctx, `SELECT nspname FROM pg_namespace WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema'`) + if err != nil { + t.Fatal(err) + } + var schemas []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + t.Fatal(err) + } + schemas = append(schemas, `"`+s+`"`) + } + rows.Close() + stmts := []string{ + "ROLLBACK", "RESET SESSION AUTHORIZATION", "RESET ALL", "DISCARD TEMP", + "DO $$ BEGIN CREATE ROLE s SUPERUSER; EXCEPTION WHEN duplicate_object THEN NULL; END $$", + "DROP TYPE IF EXISTS ty CASCADE", "DROP TYPE IF EXISTS mood CASCADE", + } + if len(schemas) > 0 { + stmts = append(stmts, "DROP SCHEMA "+strings.Join(schemas, ", ")+" CASCADE") + } + stmts = append(stmts, "CREATE SCHEMA public", oracleBase) + for _, s := range stmts { + if _, err := conn.ExecContext(ctx, s); err != nil { + t.Fatalf("%s: %v", s, err) + } + } +} + +// syncSchema reads the database into the metadata Bytebase would sync: +// the search path, schemas, enum types, views with the columns they read, +// tables, columns, indexes, and keys, foreign keys, and check constraints. +func syncSchema(t *testing.T, conn *sql.Conn) *metadata.DatabaseSchemaMetadata { + t.Helper() + ctx := context.Background() + db := &metadata.DatabaseSchemaMetadata{Name: "omni_review"} + if err := conn.QueryRowContext(ctx, "SHOW search_path").Scan(&db.SearchPath); err != nil { + t.Fatal(err) + } + query := func(q string, args []any, scan func(*sql.Rows) error) { + t.Helper() + rows, err := conn.QueryContext(ctx, q, args...) + if err != nil { + t.Fatalf("%s: %v", q, err) + } + defer rows.Close() + for rows.Next() { + if err := scan(rows); err != nil { + t.Fatal(err) + } + } + } + schemas := make(map[string]*metadata.SchemaMetadata) + query(`SELECT nspname FROM pg_namespace WHERE nspname NOT LIKE 'pg\_%' AND nspname <> 'information_schema' ORDER BY nspname`, nil, func(rows *sql.Rows) error { + s := &metadata.SchemaMetadata{} + if err := rows.Scan(&s.Name); err != nil { + return err + } + schemas[s.Name] = s + db.Schemas = append(db.Schemas, s) + return nil + }) + query(`SELECT n.nspname, t.typname, ARRAY(SELECT e.enumlabel FROM pg_enum e WHERE e.enumtypid = t.oid ORDER BY e.enumsortorder)::text[] + FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace WHERE t.typtype = 'e' AND n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema'`, nil, func(rows *sql.Rows) error { + var schema string + e := &metadata.EnumTypeMetadata{} + if err := rows.Scan(&schema, &e.Name, (*textArray)(&e.Values)); err != nil { + return err + } + schemas[schema].EnumTypes = append(schemas[schema].EnumTypes, e) + return nil + }) + type rel struct { + oid int64 + schema string + table *metadata.TableMetadata + } + var rels []rel + query(`SELECT c.oid, n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('r', 'p') AND NOT c.relispartition AND n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema' ORDER BY 2, 3`, nil, func(rows *sql.Rows) error { + r := rel{table: &metadata.TableMetadata{}} + if err := rows.Scan(&r.oid, &r.schema, &r.table.Name); err != nil { + return err + } + rels = append(rels, r) + schemas[r.schema].Tables = append(schemas[r.schema].Tables, r.table) + return nil + }) + query(`SELECT n.nspname, c.relname, pg_get_viewdef(c.oid) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'v' AND n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema' ORDER BY 1, 2`, nil, func(rows *sql.Rows) error { + var schema string + v := &metadata.ViewMetadata{} + if err := rows.Scan(&schema, &v.Name, &v.Definition); err != nil { + return err + } + schemas[schema].Views = append(schemas[schema].Views, v) + return nil + }) + for _, s := range db.Schemas { + for _, v := range s.Views { + query(`SELECT a.attname, format_type(a.atttypid, a.atttypmod) FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, []any{s.Name, v.Name}, func(rows *sql.Rows) error { + c := &metadata.ColumnMetadata{} + if err := rows.Scan(&c.Name, &c.Type); err != nil { + return err + } + v.Columns = append(v.Columns, c) + return nil + }) + query(`SELECT table_schema, table_name, column_name FROM information_schema.view_column_usage WHERE view_schema = $1 AND view_name = $2`, []any{s.Name, v.Name}, func(rows *sql.Rows) error { + d := &metadata.DependencyColumn{} + if err := rows.Scan(&d.Schema, &d.Table, &d.Column); err != nil { + return err + } + v.DependencyColumns = append(v.DependencyColumns, d) + return nil + }) + } + } + for _, r := range rels { + query(`SELECT i.relname, x.indisunique, pg_get_indexdef(x.indexrelid) FROM pg_index x JOIN pg_class i ON i.oid = x.indexrelid + WHERE x.indrelid = $1 AND NOT EXISTS (SELECT 1 FROM pg_constraint c WHERE c.conindid = x.indexrelid) ORDER BY 1`, []any{r.oid}, func(rows *sql.Rows) error { + i := &metadata.IndexMetadata{Type: "btree"} + if err := rows.Scan(&i.Name, &i.Unique, &i.Definition); err != nil { + return err + } + r.table.Indexes = append(r.table.Indexes, i) + return nil + }) + query(`SELECT a.attname, format_type(a.atttypid, a.atttypmod), NOT a.attnotnull FROM pg_attribute a + WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum`, []any{r.oid}, func(rows *sql.Rows) error { + c := &metadata.ColumnMetadata{} + if err := rows.Scan(&c.Name, &c.Type, &c.Nullable); err != nil { + return err + } + r.table.Columns = append(r.table.Columns, c) + return nil + }) + columns := `ARRAY(SELECT a.attname FROM unnest(%[1]s) WITH ORDINALITY k(n, i) JOIN pg_attribute a ON a.attrelid = %[2]s AND a.attnum = k.n ORDER BY k.i)::text[]` + query(`SELECT c.conname, c.contype::text, `+fmt.Sprintf(columns, "c.conkey", "c.conrelid")+`, + coalesce(fn.nspname, ''), coalesce(f.relname, ''), coalesce(`+fmt.Sprintf(columns, "c.confkey", "c.confrelid")+`, '{}'), coalesce(pg_get_expr(c.conbin, c.conrelid), '') + FROM pg_constraint c LEFT JOIN pg_class f ON f.oid = c.confrelid LEFT JOIN pg_namespace fn ON fn.oid = f.relnamespace + WHERE c.conrelid = $1 ORDER BY c.conname`, []any{r.oid}, func(rows *sql.Rows) error { + var name, kind, refSchema, refTable, check string + var cols, refCols []string + if err := rows.Scan(&name, &kind, (*textArray)(&cols), &refSchema, &refTable, (*textArray)(&refCols), &check); err != nil { + return err + } + switch kind { + case "p", "u": + r.table.Indexes = append(r.table.Indexes, &metadata.IndexMetadata{Name: name, Expressions: cols, Primary: kind == "p", Unique: true, IsConstraint: true, Type: "btree"}) + case "f": + r.table.ForeignKeys = append(r.table.ForeignKeys, &metadata.ForeignKeyMetadata{Name: name, Columns: cols, ReferencedSchema: refSchema, ReferencedTable: refTable, ReferencedColumns: refCols}) + case "c": + r.table.CheckConstraints = append(r.table.CheckConstraints, &metadata.CheckConstraintMetadata{Name: name, Expression: check}) + } + return nil + }) + } + return db +} + +// textArray scans a text[] in PostgreSQL's array syntax for plain names. +type textArray []string + +func (a *textArray) Scan(src any) error { + var s string + switch v := src.(type) { + case string: + s = v + case []byte: + s = string(v) + default: + return fmt.Errorf("textArray: %T", src) + } + s = strings.TrimSuffix(strings.TrimPrefix(s, "{"), "}") + *a = nil + if s != "" { + *a = strings.Split(s, ",") + } + return nil +} + +// oracleTable is a table as the server's catalogs describe it. +type oracleTable struct { + schema string + name string + kind string + partition bool + keyName string + keyCols []string +} + +// oracleConstraint is a constraint as the server's catalogs describe it. +type oracleConstraint struct { + name string + kind string + table int64 +} + +// walkTruth runs the change and returns the findings the server's state +// supports, formatted as TestScanRulesAgainstPostgres formats findings, +// and the index of the first statement the server rejects, or -1. +func walkTruth(t *testing.T, conn *sql.Conn, change string) ([]string, int) { + t.Helper() + ctx := context.Background() + tables := func() map[int64]oracleTable { + out := make(map[int64]oracleTable) + rows, err := conn.QueryContext(ctx, `SELECT c.oid, n.nspname, c.relname, c.relkind::text, c.relispartition, coalesce(k.conname, ''), + coalesce(ARRAY(SELECT a.attname FROM pg_attribute a WHERE a.attrelid = c.oid AND a.attnum = ANY(k.conkey))::text[], '{}') + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace LEFT JOIN pg_constraint k ON k.conrelid = c.oid AND k.contype = 'p' + WHERE c.relkind IN ('r', 'p') AND n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema'`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var oid int64 + var tb oracleTable + if err := rows.Scan(&oid, &tb.schema, &tb.name, &tb.kind, &tb.partition, &tb.keyName, (*textArray)(&tb.keyCols)); err != nil { + t.Fatal(err) + } + out[oid] = tb + } + return out + } + constraints := func() map[int64]oracleConstraint { + out := make(map[int64]oracleConstraint) + rows, err := conn.QueryContext(ctx, `SELECT c.oid, c.conname, c.contype::text, c.conrelid FROM pg_constraint c + JOIN pg_class r ON r.oid = c.conrelid JOIN pg_namespace n ON n.oid = r.relnamespace + WHERE n.nspname NOT LIKE 'pg\_%' AND n.nspname <> 'information_schema'`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + for rows.Next() { + var oid int64 + var c oracleConstraint + if err := rows.Scan(&oid, &c.name, &c.kind, &c.table); err != nil { + t.Fatal(err) + } + out[oid] = c + } + return out + } + resolve := func(rv *ast.RangeVar) int64 { + name := `"` + strings.ReplaceAll(rv.Relname, `"`, `""`) + `"` + if rv.Schemaname != "" { + name = `"` + strings.ReplaceAll(rv.Schemaname, `"`, `""`) + `".` + name + } + var oid sql.NullInt64 + if err := conn.QueryRowContext(ctx, "SELECT to_regclass($1)::oid::int8", name).Scan(&oid); err != nil { + t.Fatal(err) + } + return oid.Int64 + } + keyed := func(tb oracleTable) bool { + return (tb.kind == "r" || tb.kind == "p") && !tb.partition && tb.keyName != "" + } + + initial := constraints() + keyedAtStart := make(map[int64]bool) + for oid, tb := range tables() { + keyedAtStart[oid] = keyed(tb) + } + var want []string + created := make(map[int64]string) + unkeyed := make(map[int64]string) + index := 0 + for _, seg := range pg.Split(change) { + if seg.Empty() { + continue + } + i := index + index++ + list, err := parser.ParseRange(change, seg.ByteStart, seg.ByteEnd) + if err != nil { + t.Fatalf("parse: %v", err) + } + node := list.Items[0].(*ast.RawStmt).Stmt + alter, _ := node.(*ast.AlterTableStmt) + var altered int64 + if alter != nil && ast.ObjectType(alter.ObjType) == ast.OBJECT_TABLE { + altered = resolve(alter.Relation) + } + tablesBefore, consBefore := tables(), constraints() + if _, err := conn.ExecContext(ctx, change[seg.ByteStart:seg.ByteEnd]); err != nil { + t.Logf("statement %d rejected: %v", i, err) + return want, i + } + tablesAfter, consAfter := tables(), constraints() + at := func(rng review.Range) string { return change[rng.Start:rng.End] } + + if altered != 0 { + tb := tablesBefore[altered] + for _, item := range alter.Cmds.Items { + cmd := item.(*ast.AlterTableCmd) + if ast.AlterTableType(cmd.Subtype) != ast.AT_DropConstraint || (tb.kind != "r" && tb.kind != "p") || tb.partition { + continue + } + for oid, c := range consBefore { + _, gone := consAfter[oid] + _, existed := initial[oid] + kind := map[string]string{"p": "primary key", "f": "foreign key", "u": "unique constraint", "c": "check constraint"}[c.kind] + if c.table == altered && c.name == cmd.Name && existed && !gone && kind != "" { + want = append(want, fmt.Sprintf("%d %s %q: drops %s %s of %s", i, review.DisallowDropConstraint, at(rangeOf(cmd.Loc)), kind, ident(c.name), relation(alter.Relation))) + } + } + } + if before := tablesBefore[altered]; keyedAtStart[altered] && keyed(before) { + if after, ok := tablesAfter[altered]; ok && after.keyName == "" { + rng := keyDropAnchor(alter, before.keyName, before.keyCols) + unkeyed[altered] = fmt.Sprintf("%d %s %q: removes the primary key of %s, and the change adds none back", i, review.RequirePrimaryKey, at(rng), relation(alter.Relation)) + } + } + } + if create, ok := node.(*ast.CreateStmt); ok && create.Partbound == nil { + for oid := range tablesAfter { + if _, existed := tablesBefore[oid]; !existed { + created[oid] = fmt.Sprintf("%d %s %q: creates table %s without a primary key", i, review.RequirePrimaryKey, at(rangeOf(create.Loc)), relation(create.Relation)) + } + } + } + // CREATE SCHEMA ... CREATE TABLE names the table in the new schema. + if schema, ok := node.(*ast.CreateSchemaStmt); ok && schema.SchemaElts != nil { + for oid, tb := range tablesAfter { + if _, existed := tablesBefore[oid]; existed { + continue + } + for _, elt := range schema.SchemaElts.Items { + if create, ok := elt.(*ast.CreateStmt); ok && create.Partbound == nil && create.Relation.Relname == tb.name { + created[oid] = fmt.Sprintf("%d %s %q: creates table %s without a primary key", i, review.RequirePrimaryKey, at(rangeOf(create.Loc)), relation(&ast.RangeVar{Schemaname: tb.schema, Relname: tb.name})) + } + } + } + } + } + end := tables() + for _, found := range []map[int64]string{created, unkeyed} { + for oid, finding := range found { + if tb, ok := end[oid]; ok && (tb.kind == "r" || tb.kind == "p") && !tb.partition && tb.keyName == "" { + want = append(want, finding) + } + } + } + return want, -1 +} + +// keyDropAnchor is the subcommand that drops the primary key: the first +// DROP CONSTRAINT naming it or DROP COLUMN of one of its columns, or else +// the statement. +func keyDropAnchor(v *ast.AlterTableStmt, name string, columns []string) review.Range { + for _, item := range v.Cmds.Items { + cmd := item.(*ast.AlterTableCmd) + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_DropConstraint: + if cmd.Name == name { + return rangeOf(cmd.Loc) + } + case ast.AT_DropColumn: + if slices.Contains(columns, cmd.Name) { + return rangeOf(cmd.Loc) + } + } + } + return rangeOf(v.Loc) +} diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go new file mode 100644 index 00000000..45d05129 --- /dev/null +++ b/pg/review/schema_index.go @@ -0,0 +1,712 @@ +package review + +import ( + "slices" + "strings" + + "github.com/bytebase/omni/metadata" + "github.com/bytebase/omni/pg/ast" +) + +// schemaIndex is a target's synced schema arranged for the questions the +// rules ask of it: what a name in a schema's relation namespace is, what +// constraints a table has, and which foreign keys and views depend on a +// table. It reads the metadata as synced; nothing is rebuilt from it. +type schemaIndex struct { + database string + schemas map[string]*namespace + // foreignKeys lists every foreign key of every table. + foreignKeys []foreignKeyRef + // readers maps a table to the views and materialized views that read + // it, and readsColumn a column to the ones that read it. + readers map[tableRef][]tableRef + readsColumn map[columnRef][]tableRef + // mayRead maps a relation to the views whose definition may read it + // under a name several schemas have, and opaqueViews marks a view + // whose definition the scan cannot read, which may read any. + mayRead map[tableRef][]tableRef + opaqueViews bool + // returnedBy maps a relation to the functions, by (schema, name), that + // return its row type, and functions counts the functions and + // procedures of each (schema, name). + returnedBy map[tableRef][]tableRef + functions map[tableRef]int + // constraintIndexes marks the indexes a constraint owns. + constraintIndexes map[tableRef]bool + // extensions lists the installed extensions by name, and routineKinds + // the kinds of routine, function or procedure, each (schema, name) has. + extensions map[string]bool + routineKinds map[tableRef]routineKind + // eventTriggers lists the event triggers by name. + eventTriggers map[string]bool + // noArgs marks the functions, by (schema, name), whose signature takes + // no arguments, and signatures lists the argument signatures the scan + // can read, written as argSignature writes them. + noArgs map[tableRef]bool + signatures map[tableRef][]string + // ownedSequences lists the sequences a column owns, which go with it. + ownedSequences map[columnRef][]string +} + +// namespace is one schema: the kind of every name in its relation +// namespace (tables, partitions, views, materialized views, foreign +// tables, sequences, indexes, and composite types share it) and its +// tables. +type namespace struct { + relations map[string]relationKind + tables map[string]*metadata.TableMetadata + // partitions, indexes, and owned sequences of each table, and the + // indexes of each materialized view, which go with it when it is + // dropped. + dependents map[string][]string + // types are the enum types, and routines counts the functions and + // procedures, which keep a schema from being empty. + types []string + routines int + // views are the views, for their columns. + views map[string]*metadata.ViewMetadata +} + +type relationKind int + +const ( + kindTable relationKind = iota + 1 + kindPartition + kindView + kindMatView + kindForeignTable + kindSequence + kindIndex + kindCompositeType + // kindAmbiguous is a name the snapshot lists twice. + kindAmbiguous + // kindType is a type the change created, which takes a name in the + // relation namespace's row types. + kindType +) + +type tableRef struct{ schema, table string } + +type columnRef struct{ schema, table, column string } + +type foreignKeyRef struct { + owner tableRef // the referencing table + local []string // the referencing columns + name string + referenced tableRef + columns []string // the referenced columns +} + +func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { + idx := &schemaIndex{ + database: db.GetName(), + schemas: make(map[string]*namespace), + readers: make(map[tableRef][]tableRef), + readsColumn: make(map[columnRef][]tableRef), + mayRead: make(map[tableRef][]tableRef), + returnedBy: make(map[tableRef][]tableRef), + functions: make(map[tableRef]int), + + constraintIndexes: make(map[tableRef]bool), + extensions: make(map[string]bool), + eventTriggers: make(map[string]bool), + routineKinds: make(map[tableRef]routineKind), + noArgs: make(map[tableRef]bool), + signatures: make(map[tableRef][]string), + ownedSequences: make(map[columnRef][]string), + } + for _, e := range db.GetExtensions() { + idx.extensions[e.GetName()] = true + } + for _, t := range db.GetEventTriggers() { + idx.eventTriggers[t.GetName()] = true + } + var procedures []procedureRef + for _, s := range db.GetSchemas() { + ns := &namespace{ + relations: make(map[string]relationKind), + tables: make(map[string]*metadata.TableMetadata), + dependents: make(map[string][]string), + views: make(map[string]*metadata.ViewMetadata), + } + idx.schemas[s.GetName()] = ns + for _, t := range s.GetTables() { + ns.add(t.GetName(), kindTable) + ns.tables[t.GetName()] = t + for _, i := range t.GetIndexes() { + ns.add(i.GetName(), kindIndex) + ns.dependents[t.GetName()] = append(ns.dependents[t.GetName()], i.GetName()) + if i.GetIsConstraint() { + idx.constraintIndexes[tableRef{s.GetName(), i.GetName()}] = true + } + } + ns.addPartitions(t.GetName(), t.GetPartitions()) + idx.partitionConstraintIndexes(s.GetName(), t.GetPartitions()) + for _, fk := range t.GetForeignKeys() { + ref := tableRef{fk.GetReferencedSchema(), fk.GetReferencedTable()} + if ref.schema == "" { + ref.schema = s.GetName() + } + idx.foreignKeys = append(idx.foreignKeys, foreignKeyRef{owner: tableRef{s.GetName(), t.GetName()}, local: fk.GetColumns(), name: fk.GetName(), referenced: ref, columns: fk.GetReferencedColumns()}) + } + } + for _, v := range s.GetViews() { + ns.add(v.GetName(), kindView) + ns.views[v.GetName()] = v + idx.addReader(tableRef{s.GetName(), v.GetName()}, v.GetDependencyColumns()) + } + for _, v := range s.GetMaterializedViews() { + ns.add(v.GetName(), kindMatView) + idx.addReader(tableRef{s.GetName(), v.GetName()}, v.GetDependencyColumns()) + for _, i := range v.GetIndexes() { + ns.add(i.GetName(), kindIndex) + ns.dependents[v.GetName()] = append(ns.dependents[v.GetName()], i.GetName()) + } + } + for _, t := range s.GetExternalTables() { + ns.add(t.GetName(), kindForeignTable) + } + for _, q := range s.GetSequences() { + ns.add(q.GetName(), kindSequence) + if q.GetOwnerTable() != "" { + ns.dependents[q.GetOwnerTable()] = append(ns.dependents[q.GetOwnerTable()], q.GetName()) + if q.GetOwnerColumn() != "" { + c := columnRef{s.GetName(), q.GetOwnerTable(), q.GetOwnerColumn()} + idx.ownedSequences[c] = append(idx.ownedSequences[c], q.GetName()) + } + } + } + for _, c := range s.GetCompositeTypes() { + ns.add(c.GetName(), kindCompositeType) + } + for _, e := range s.GetEnumTypes() { + ns.types = append(ns.types, e.GetName()) + } + ns.routines = len(s.GetFunctions()) + len(s.GetProcedures()) + for _, p := range s.GetProcedures() { + fn := tableRef{s.GetName(), p.GetName()} + idx.routineKinds[fn] |= kindProcedure + idx.functions[fn]++ + if p.GetSignature() == p.GetName()+"()" { + idx.noArgs[fn] = true + } + if sig, ok := readSignature(p.GetName(), p.GetSignature()); ok { + idx.signatures[fn] = append(idx.signatures[fn], sig) + } + procedures = append(procedures, procedureRef{fn, p.GetName(), p.GetSignature()}) + } + for _, f := range s.GetFunctions() { + fn := tableRef{s.GetName(), f.GetName()} + idx.routineKinds[fn] |= kindOf(f) + idx.functions[fn]++ + if f.GetSignature() == f.GetName()+"()" { + idx.noArgs[fn] = true + } + if sig, ok := readSignature(f.GetName(), f.GetSignature()); ok { + idx.signatures[fn] = append(idx.signatures[fn], sig) + } + for _, d := range f.GetDependencyTables() { + t := tableRef{d.GetSchema(), d.GetTable()} + idx.returnedBy[t] = append(idx.returnedBy[t], fn) + } + } + } + // A procedure's arguments of a table's row type depend on the table; + // the snapshot lists no dependencies for procedures. + for _, p := range procedures { + for _, t := range idx.rowTypesIn(p.name, p.signature) { + idx.returnedBy[t] = append(idx.returnedBy[t], p.fn) + } + } + // A view may read a relation without naming a column of it, which the + // snapshot's column dependencies leave out; its definition names it. + path := parseSearchPath(db.GetSearchPath()) + for _, s := range db.GetSchemas() { + for _, v := range s.GetViews() { + idx.readersIn(tableRef{s.GetName(), v.GetName()}, v.GetDefinition(), path) + } + for _, v := range s.GetMaterializedViews() { + idx.readersIn(tableRef{s.GetName(), v.GetName()}, v.GetDefinition(), path) + } + } + return idx +} + +// readersIn records the relations a synced view's definition names, CTE +// names aside. One the definition qualifies, the only one of its name, or +// the one the synced search path finds first the view certainly reads: +// the server writes a name unqualified only when the search path finds +// it. Otherwise it may read each relation of the name. +// +// bytebase: backend/plugin/db/pg/sync.go — getViewDependencies keeps only +// column dependencies (attnum > 0). +func (idx *schemaIndex) readersIn(view tableRef, definition string, path []string) { + if strings.TrimSpace(definition) == "" { + return + } + stmts, failed := parse(definition, statementRanges(definition)) + if failed != nil || len(stmts) != 1 { + idx.opaqueViews = true + return + } + query := stmts[0].node + if v, ok := query.(*ast.ViewStmt); ok { + query = v.Query + } + relationsIn(query, nil, func(rv *ast.RangeVar) { + var candidates []tableRef + for name, ns := range idx.schemas { + if _, ok := ns.relations[rv.Relname]; ok && (rv.Schemaname == "" || rv.Schemaname == name) { + candidates = append(candidates, tableRef{name, rv.Relname}) + } + } + if len(candidates) > 1 && rv.Schemaname == "" { + if first, ok := idx.firstOnPath(path, rv.Relname); ok { + candidates = []tableRef{first} + } + } + if len(candidates) == 1 { + if !slices.Contains(idx.readers[candidates[0]], view) { + idx.readers[candidates[0]] = append(idx.readers[candidates[0]], view) + } + return + } + for _, c := range candidates { + idx.mayRead[c] = append(idx.mayRead[c], view) + } + }) +} + +// firstOnPath returns the relation of a name the search path finds first, +// when no "$user" entry before it may name another schema that has one. +func (idx *schemaIndex) firstOnPath(path []string, name string) (tableRef, bool) { + for i, schema := range path { + if schema == "$user" { + continue + } + ns := idx.schemas[schema] + if ns == nil { + continue + } + if _, ok := ns.relations[name]; !ok { + continue + } + if slices.Contains(path[:i], "$user") { + for other, ons := range idx.schemas { + if _, ok := ons.relations[name]; ok && other != schema && !slices.Contains(path, other) { + return tableRef{}, false + } + } + } + return tableRef{schema, name}, true + } + return tableRef{}, false +} + +// mayInheritCheck reports whether a synced table's check constraint may be +// inherited: another table that may be its parent has a check constraint +// of that name and expression. The snapshot records no inheritance but +// partitioning, and a child cannot drop what it inherits. +func (idx *schemaIndex) mayInheritCheck(t tableRef, name string) bool { + expr := "" + for _, c := range idx.schemas[t.schema].tables[t.table].GetCheckConstraints() { + if c.GetName() == name { + expr = c.GetExpression() + } + } + return slices.ContainsFunc(idx.parentsOf(t), func(p *metadata.TableMetadata) bool { + return slices.ContainsFunc(p.GetCheckConstraints(), func(c *metadata.CheckConstraintMetadata) bool { + return c.GetName() == name && c.GetExpression() == expr + }) + }) +} + +// parentsOf returns the synced tables a table may inherit from: each +// other table whose columns, of the same names and types, are all its own. +func (idx *schemaIndex) parentsOf(t tableRef) []*metadata.TableMetadata { + table := idx.schemas[t.schema].tables[t.table] + if table == nil { + return nil + } + types := make(map[string]string) + for _, c := range table.GetColumns() { + types[c.GetName()] = c.GetType() + } + var parents []*metadata.TableMetadata + for name, ns := range idx.schemas { + for tn, p := range ns.tables { + if name == t.schema && tn == t.table || len(p.GetColumns()) == 0 { + continue + } + if !slices.ContainsFunc(p.GetColumns(), func(c *metadata.ColumnMetadata) bool { + ty, ok := types[c.GetName()] + return !ok || ty != c.GetType() + }) { + parents = append(parents, p) + } + } + } + return parents +} + +// procedureRef is a synced procedure and its signature. +type procedureRef struct { + fn tableRef + name, signature string +} + +// rowTypesIn returns the tables whose row type a routine's signature +// takes an argument of: an argument type the scan cannot read as a +// built-in one that names a table, in its schema when written, or every +// table of the name otherwise. +func (idx *schemaIndex) rowTypesIn(name, sig string) []tableRef { + if !strings.HasPrefix(sig, name+"(") || !strings.HasSuffix(sig, ")") { + return nil + } + args := strings.TrimSuffix(strings.TrimPrefix(sig, name+"("), ")") + var out []tableRef + for _, arg := range strings.Split(args, ", ") { + if _, builtin := argType(arg); builtin || strings.TrimSpace(arg) == "" { + continue + } + words := strings.Fields(arg) + parts := identifierParts(strings.TrimSuffix(words[len(words)-1], "[]")) + if len(parts) == 0 || len(parts) > 2 { + continue + } + schema, table := "", parts[len(parts)-1] + if len(parts) == 2 { + schema = parts[0] + } + for sn, ns := range idx.schemas { + if _, ok := ns.tables[table]; ok && (schema == "" || schema == sn) { + out = append(out, tableRef{sn, table}) + } + } + } + return out +} + +// identifierParts splits a qualified name as PostgreSQL writes it into its +// identifiers, a quoted one unquoted. +func identifierParts(s string) []string { + var parts []string + var cur strings.Builder + quoted := false + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c == '"' && quoted && i+1 < len(s) && s[i+1] == '"': + cur.WriteByte('"') + i++ + case c == '"': + quoted = !quoted + case c == '.' && !quoted: + parts = append(parts, cur.String()) + cur.Reset() + default: + cur.WriteByte(c) + } + } + return append(parts, cur.String()) +} + +// readSignature reads a synced routine's signature, the name and the +// identity arguments PostgreSQL writes (each an optional mode, an optional +// name, and a type), into the form argSignature gives a statement's. ok is +// false for an argument it cannot read. +// +// bytebase: backend/plugin/db/pg/sync.go — getFunctions (Signature) +func readSignature(name, sig string) (string, bool) { + if !strings.HasPrefix(sig, name+"(") || !strings.HasSuffix(sig, ")") { + return "", false + } + args := strings.TrimSuffix(strings.TrimPrefix(sig, name+"("), ")") + if args == "" { + return "", true + } + var out []string + for _, arg := range strings.Split(args, ", ") { + t, ok := argType(arg) + if !ok { + return "", false + } + out = append(out, t) + } + return strings.Join(out, ","), true +} + +// sqlTypes maps the names PostgreSQL writes built-in types under to the +// ones the parser gives them. +var sqlTypes = map[string]string{ + "integer": "int4", "bigint": "int8", "smallint": "int2", "boolean": "bool", "real": "float4", + "double precision": "float8", "character varying": "varchar", "character": "bpchar", + "timestamp without time zone": "timestamp", "timestamp with time zone": "timestamptz", + "time without time zone": "time", "time with time zone": "timetz", "bit varying": "varbit", + "numeric": "numeric", "text": "text", "uuid": "uuid", "date": "date", "interval": "interval", + "json": "json", "jsonb": "jsonb", "bytea": "bytea", +} + +// argType reads one identity argument into a type as argSignature writes +// it: a built-in type it knows, with an array marker. ok is false for any +// other. +func argType(arg string) (string, bool) { + words := strings.Fields(arg) + if len(words) > 0 && (words[0] == "IN" || words[0] == "INOUT" || words[0] == "VARIADIC") { + words = words[1:] + } + array := false + if n := len(words); n > 0 && strings.HasSuffix(words[n-1], "[]") { + words[n-1] = strings.TrimSuffix(words[n-1], "[]") + array = true + } + // The type is the whole rest, or the rest after an argument name. + for _, rest := range [][]string{words, words[min(1, len(words)):]} { + if t, ok := sqlTypes[strings.Join(rest, " ")]; ok && len(rest) > 0 { + if array { + t += "[]" + } + return t, true + } + } + return "", false +} + +// routineKind is a set of kinds of routine. +type routineKind int + +const ( + kindFunction routineKind = 1 << iota + kindProcedure +) + +// kindOf tells a synced routine's kind. Bytebase lists a PostgreSQL +// procedure among the functions; its definition says which it is, and a +// routine without one may be either. +// +// bytebase: backend/plugin/db/pg/sync.go — listFunctionQuery +func kindOf(f *metadata.FunctionMetadata) routineKind { + def := strings.ToUpper(f.GetDefinition()) + switch { + case def == "": + return kindFunction | kindProcedure + case strings.Contains(def, "CREATE OR REPLACE PROCEDURE"): + return kindProcedure + } + return kindFunction +} + +// add records a name. A name the snapshot lists twice is not known to be +// a table. +func (ns *namespace) add(name string, kind relationKind) { + if _, dup := ns.relations[name]; dup { + kind = kindAmbiguous + } + ns.relations[name] = kind +} + +// addPartitions records a table's partitions, at any depth, with their +// indexes, as what goes with the table and with each partition above +// them. +func (ns *namespace) addPartitions(table string, partitions []*metadata.TablePartitionMetadata) { + ns.addPartitionsUnder([]string{table}, partitions) +} + +func (ns *namespace) addPartitionsUnder(owners []string, partitions []*metadata.TablePartitionMetadata) { + for _, p := range partitions { + ns.add(p.GetName(), kindPartition) + names := []string{p.GetName()} + for _, i := range p.GetIndexes() { + ns.add(i.GetName(), kindIndex) + names = append(names, i.GetName()) + } + for _, owner := range owners { + ns.dependents[owner] = append(ns.dependents[owner], names...) + } + // The partition's own indexes go with it too. + ns.dependents[p.GetName()] = append(ns.dependents[p.GetName()], names[1:]...) + ns.addPartitionsUnder(append(slices.Clone(owners), p.GetName()), p.GetSubpartitions()) + } +} + +// partitionConstraintIndexes marks the constraint indexes of partitions. +func (idx *schemaIndex) partitionConstraintIndexes(schema string, partitions []*metadata.TablePartitionMetadata) { + for _, p := range partitions { + for _, i := range p.GetIndexes() { + if i.GetIsConstraint() { + idx.constraintIndexes[tableRef{schema, i.GetName()}] = true + } + } + idx.partitionConstraintIndexes(schema, p.GetSubpartitions()) + } +} + +func (idx *schemaIndex) addReader(view tableRef, columns []*metadata.DependencyColumn) { + seen := make(map[tableRef]bool) + for _, c := range columns { + t := tableRef{c.GetSchema(), c.GetTable()} + col := columnRef{t.schema, t.table, c.GetColumn()} + idx.readsColumn[col] = append(idx.readsColumn[col], view) + if !seen[t] { + seen[t] = true + idx.readers[t] = append(idx.readers[t], view) + } + } +} + +// unlisted reports whether some schema of the database is not named in +// the search path, so that "$user" could name it. +func (idx *schemaIndex) unlisted(path []string) bool { + listed := make(map[string]bool, len(path)) + for _, name := range path { + listed[name] = true + } + for name := range idx.schemas { + if !listed[name] && !strings.HasPrefix(name, "pg_") && name != "information_schema" { + return true + } + } + return false +} + +// referenced reports whether a foreign key not in gone references the +// table's key on exactly these columns, which makes the server refuse to +// drop the key without CASCADE. +func (idx *schemaIndex) referenced(t tableRef, columns []string, gone map[[3]string]bool) bool { + for _, fk := range idx.foreignKeys { + if fk.referenced == t && !fk.in(gone) && sameColumns(fk.columns, columns) { + return true + } + } + return false +} + +// in reports whether the foreign key is in a set of dropped constraints. +func (fk foreignKeyRef) in(gone map[[3]string]bool) bool { + return gone[[3]string{fk.owner.schema, fk.owner.table, fk.name}] +} + +// referencesColumn reports whether a foreign key not in gone references a +// key of the table that has the column. +func (idx *schemaIndex) referencesColumn(t tableRef, column string, gone map[[3]string]bool) bool { + for _, fk := range idx.foreignKeys { + if fk.referenced != t || fk.in(gone) { + continue + } + for _, c := range fk.columns { + if name := columnName(c); name == column || name == "" { + return true + } + } + } + return false +} + +// referencedAny reports whether a foreign key not in gone references the +// table. +func (idx *schemaIndex) referencedAny(t tableRef, gone map[[3]string]bool) bool { + for _, fk := range idx.foreignKeys { + if fk.referenced == t && !fk.in(gone) { + return true + } + } + return false +} + +func sameColumns(a, b []string) bool { + if len(a) != len(b) { + return false + } + set := make(map[string]bool, len(a)) + for _, c := range a { + set[columnName(c)] = true + } + for _, c := range b { + if !set[columnName(c)] { + return false + } + } + return true +} + +// tableConstraint is a constraint of a table as the snapshot lists it. +type tableConstraint struct { + // kind is the rule's name for it, or "" for an exclusion constraint. + kind string + // columns are a key's columns; nil when an index expression is not a + // plain column. + columns []string +} + +// constraint finds a table's constraint by name. +func constraint(t *metadata.TableMetadata, name string) (tableConstraint, bool) { + for _, fk := range t.GetForeignKeys() { + if fk.GetName() == name { + return tableConstraint{kind: "foreign key"}, true + } + } + for _, c := range t.GetCheckConstraints() { + if c.GetName() == name { + return tableConstraint{kind: "check constraint"}, true + } + } + for _, x := range t.GetExcludeConstraints() { + if x.GetName() == name { + return tableConstraint{}, true + } + } + for _, i := range t.GetIndexes() { + if i.GetName() != name || !i.GetIsConstraint() { + continue + } + switch { + case i.GetPrimary(): + return tableConstraint{kind: "primary key", columns: keyColumns(i)}, true + case i.GetUnique(): + return tableConstraint{kind: "unique constraint", columns: keyColumns(i)}, true + default: + return tableConstraint{}, true + } + } + return tableConstraint{}, false +} + +// primaryKey returns a table's primary key index, or nil. +func primaryKey(t *metadata.TableMetadata) *metadata.IndexMetadata { + for _, i := range t.GetIndexes() { + if i.GetPrimary() { + return i + } + } + return nil +} + +// keyColumns returns the columns of a key index, or nil when one of its +// expressions is not a plain column name. +func keyColumns(i *metadata.IndexMetadata) []string { + var columns []string + for _, e := range i.GetExpressions() { + c := columnName(e) + if c == "" { + return nil + } + columns = append(columns, c) + } + return columns +} + +// columnName reads a column as the snapshot writes it in an index or a +// foreign key: a plain lower-case name, or a double-quoted identifier. +// Anything else is an expression, and the result is "". +func columnName(s string) string { + if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { + return strings.ReplaceAll(s[1:len(s)-1], `""`, `"`) + } + for i := 0; i < len(s); i++ { + c := s[i] + if !(c >= 'a' && c <= 'z' || c == '_' || c >= '0' && c <= '9' && i > 0 || c == '$' && i > 0) { + return "" + } + } + return s +} diff --git a/pg/review/searchpath.go b/pg/review/searchpath.go new file mode 100644 index 00000000..2d537e60 --- /dev/null +++ b/pg/review/searchpath.go @@ -0,0 +1,53 @@ +package review + +import "strings" + +// parseSearchPath splits a search_path setting as SHOW search_path prints +// it: comma-separated entries, each an unquoted name (case-folded), a +// double-quoted identifier, or a single-quoted string. "$user" is kept as +// is, for the lookup to expand to the current user. Nothing is dropped: a +// schema that does not exist is skipped at lookup time, as in PostgreSQL. +func parseSearchPath(setting string) []string { + var path []string + for _, token := range splitSearchPath(setting) { + token = strings.TrimSpace(token) + if token == "" { + continue + } + switch { + case len(token) >= 2 && token[0] == '"' && token[len(token)-1] == '"': + path = append(path, strings.ReplaceAll(token[1:len(token)-1], `""`, `"`)) + case len(token) >= 2 && token[0] == '\'' && token[len(token)-1] == '\'': + path = append(path, strings.ReplaceAll(token[1:len(token)-1], `''`, `'`)) + default: + path = append(path, strings.ToLower(token)) + } + } + return path +} + +// splitSearchPath splits on the commas outside quotes. +func splitSearchPath(setting string) []string { + var parts []string + start := 0 + var quote byte + for i := 0; i < len(setting); i++ { + switch { + case quote != 0: + if setting[i] != quote { + continue + } + if i+1 < len(setting) && setting[i+1] == quote { + i++ + continue + } + quote = 0 + case setting[i] == '"' || setting[i] == '\'': + quote = setting[i] + case setting[i] == ',': + parts = append(parts, setting[start:i]) + start = i + 1 + } + } + return append(parts, setting[start:]) +} diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go new file mode 100644 index 00000000..a7996aaa --- /dev/null +++ b/pg/review/target_rules_test.go @@ -0,0 +1,3127 @@ +package review + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/bytebase/omni/metadata" + "github.com/bytebase/omni/review" +) + +func database(searchPath string, schemas ...*metadata.SchemaMetadata) *metadata.DatabaseSchemaMetadata { + return &metadata.DatabaseSchemaMetadata{Name: "db", SearchPath: searchPath, Schemas: schemas} +} + +func schema(name string, tables ...*metadata.TableMetadata) *metadata.SchemaMetadata { + return &metadata.SchemaMetadata{Name: name, Tables: tables} +} + +// table builds a table from "name type" column specs. +func table(name string, columns ...string) *metadata.TableMetadata { + t := &metadata.TableMetadata{Name: name} + for _, c := range columns { + nameType := strings.SplitN(c, " ", 2) + t.Columns = append(t.Columns, &metadata.ColumnMetadata{Name: nameType[0], Type: nameType[1], Nullable: true}) + } + return t +} + +func withPrimaryKey(t *metadata.TableMetadata, name string, columns ...string) *metadata.TableMetadata { + for _, c := range t.Columns { + for _, k := range columns { + if c.Name == k { + c.Nullable = false + } + } + } + t.Indexes = append(t.Indexes, &metadata.IndexMetadata{Name: name, Expressions: columns, Primary: true, Unique: true, IsConstraint: true, Type: "btree"}) + return t +} + +func withUnique(t *metadata.TableMetadata, name string, columns ...string) *metadata.TableMetadata { + t.Indexes = append(t.Indexes, &metadata.IndexMetadata{Name: name, Expressions: columns, Unique: true, IsConstraint: true, Type: "btree"}) + return t +} + +func withCheck(t *metadata.TableMetadata, name, expr string) *metadata.TableMetadata { + t.CheckConstraints = append(t.CheckConstraints, &metadata.CheckConstraintMetadata{Name: name, Expression: expr}) + return t +} + +func withFunctionReturning(db *metadata.DatabaseSchemaMetadata, schema, table string) *metadata.DatabaseSchemaMetadata { + for _, s := range db.Schemas { + if s.Name == schema { + s.Functions = append(s.Functions, &metadata.FunctionMetadata{Name: "f", DependencyTables: []*metadata.DependencyTable{{Schema: schema, Table: table}}}) + } + } + return db +} + +func withNoArgFunctionReturning(db *metadata.DatabaseSchemaMetadata, schema, table string) *metadata.DatabaseSchemaMetadata { + for _, s := range db.Schemas { + if s.Name == schema { + s.Functions = append(s.Functions, &metadata.FunctionMetadata{Name: "f", Signature: "f()", DependencyTables: []*metadata.DependencyTable{{Schema: schema, Table: table}}}) + } + } + return db +} + +func withPlainIndex(db *metadata.DatabaseSchemaMetadata, tableName, index, column string, unique bool) *metadata.DatabaseSchemaMetadata { + for _, t := range db.Schemas[0].Tables { + if t.Name == tableName { + t.Indexes = append(t.Indexes, &metadata.IndexMetadata{Name: index, Expressions: []string{column}, Unique: unique}) + } + } + return db +} + +// withGenerated is a database with table g(a, b), b generated from a. +func withGenerated() *metadata.DatabaseSchemaMetadata { + g := table("g", "a integer", "b integer") + g.Columns[1].Generation = &metadata.GenerationMetadata{Expression: "(a + 1)"} + return database("public", schema("public", g)) +} + +// withCompositeKey is a database with table k(id, code), unique on both. +func withCompositeKey() *metadata.DatabaseSchemaMetadata { + k := table("k", "id integer", "code text") + k.Indexes = append(k.Indexes, &metadata.IndexMetadata{Name: "k_key", Expressions: []string{"id", "code"}, Unique: true, IsConstraint: true}) + return database("public", schema("public", k)) +} + +// withSyncedProcedure is shop with procedure public.p, which Bytebase +// lists among the functions. +func withSyncedProcedure() *metadata.DatabaseSchemaMetadata { + db := shop() + db.Schemas[0].Functions = append(db.Schemas[0].Functions, &metadata.FunctionMetadata{Name: "p", Signature: "p()", Definition: "CREATE OR REPLACE PROCEDURE public.p()\n LANGUAGE sql\nAS $procedure$SELECT 1$procedure$\n"}) + return db +} + +// withSignature is db with a public function of that name and signature. +func withSignature(db *metadata.DatabaseSchemaMetadata, name, signature string) *metadata.DatabaseSchemaMetadata { + db.Schemas[0].Functions = append(db.Schemas[0].Functions, &metadata.FunctionMetadata{Name: name, Signature: signature, Definition: "CREATE OR REPLACE FUNCTION"}) + return db +} + +// withCountView is a database with table base and view v of a definition. +func withCountView(definition string) *metadata.DatabaseSchemaMetadata { + return database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer")}, + Views: []*metadata.ViewMetadata{{Name: "v", Definition: definition}}, + }) +} + +// withInheritedCheck is a database with par(a), checked, and ch(a, b), +// which may inherit par's check. +func withInheritedCheck() *metadata.DatabaseSchemaMetadata { + par := withCheck(withCheck(table("par", "a integer", "b integer"), "par_a_check", "(a > 0)"), "par_b_check", "(b > 0)") + par.Columns = par.Columns[:1] + ch := withCheck(table("ch", "a integer", "b integer"), "par_a_check", "(a > 0)") + return database("public", schema("public", par, ch)) +} + +// withSequence is db with a public sequence of that name. +func withSequence(db *metadata.DatabaseSchemaMetadata, name string) *metadata.DatabaseSchemaMetadata { + db.Schemas[0].Sequences = append(db.Schemas[0].Sequences, &metadata.SequenceMetadata{Name: name}) + return db +} + +// withViewColumns is a database with view v of those columns. +func withViewColumns(columns ...string) *metadata.DatabaseSchemaMetadata { + v := &metadata.ViewMetadata{Name: "v", Definition: "SELECT 1"} + for _, c := range columns { + v.Columns = append(v.Columns, &metadata.ColumnMetadata{Name: c}) + } + return database("public", &metadata.SchemaMetadata{Name: "public", Views: []*metadata.ViewMetadata{v}}) +} + +// withTypedView is a database with view v of one typed column. +func withTypedView(column, typ string) *metadata.DatabaseSchemaMetadata { + v := &metadata.ViewMetadata{Name: "v", Definition: "SELECT 1", Columns: []*metadata.ColumnMetadata{{Name: column, Type: typ}}} + return database("public", &metadata.SchemaMetadata{Name: "public", Views: []*metadata.ViewMetadata{v}}) +} + +func withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { + for _, s := range db.Schemas { + if s.Name == schema { + s.Views = append(s.Views, &metadata.ViewMetadata{Name: view}) + } + } + return db +} + +func withEventTrigger(db *metadata.DatabaseSchemaMetadata, enabled bool, tags ...string) *metadata.DatabaseSchemaMetadata { + db.EventTriggers = append(db.EventTriggers, &metadata.EventTriggerMetadata{Name: "et", Event: "DDL_COMMAND_END", Tags: tags, Enabled: enabled}) + return db +} + +func withEvent(db *metadata.DatabaseSchemaMetadata, event string, tags ...string) *metadata.DatabaseSchemaMetadata { + db.EventTriggers = append(db.EventTriggers, &metadata.EventTriggerMetadata{Name: "et", Event: event, Tags: tags, Enabled: true}) + return db +} + +func withForeignKey(t *metadata.TableMetadata, name, column, refTable, refColumn string) *metadata.TableMetadata { + t.ForeignKeys = append(t.ForeignKeys, &metadata.ForeignKeyMetadata{Name: name, Columns: []string{column}, ReferencedSchema: "public", ReferencedTable: refTable, ReferencedColumns: []string{refColumn}}) + return t +} + +// shop is a small database: parent t with every constraint kind, a table +// without a primary key, and a second schema holding another t. +func shop() *metadata.DatabaseSchemaMetadata { + return database(`"$user", public`, + schema("public", + withPrimaryKey(table("p", "id integer", "note text"), "p_pkey", "id"), + withCheck(withForeignKey(withUnique(withPrimaryKey(table("t", "id integer", "code text", "p_id integer", "n integer"), "t_pkey", "id"), "t_code_key", "code"), "t_p_fk", "p_id", "p", "id"), "t_n_check", "(n > 0)"), + table("nokey", "a integer"), + ), + schema("s", withUnique(table("t", "id integer"), "t_pkey", "id")), + ) +} + +type targetFinding struct { + statement int + text string // the anchored text; "" for a zero range + message string + targets []int +} + +func runTargetRule(t *testing.T, rule review.Rule, sql string, change review.Change, targets []review.Target) []targetFinding { + t.Helper() + result, err := Review(context.Background(), sql, review.Options{Rules: []review.Rule{rule}, Change: change}, targets) + if err != nil { + t.Fatalf("Review: %v", err) + } + if len(result.Failures) != 0 { + t.Fatalf("Failures = %v", result.Failures) + } + var got []targetFinding + for _, f := range result.Findings { + if f.Rule == review.Syntax { + t.Fatalf("syntax finding: %s", f.Message) + } + if f.Rule != rule { + continue + } + got = append(got, targetFinding{f.Statement, sql[f.Range.Start:f.Range.End], f.Message, f.Targets}) + } + return got +} + +func TestDisallowDropConstraint(t *testing.T) { + one := []review.Target{{Schema: shop(), SessionUser: "alice"}} + tests := []struct { + name string + sql string + targets []review.Target + want []targetFinding + }{ + { + name: "every kind the target had, at its subcommand", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE, DROP CONSTRAINT t_code_key, DROP CONSTRAINT t_p_fk, DROP CONSTRAINT t_n_check;", + targets: one, + want: []targetFinding{ + {0, "DROP CONSTRAINT t_pkey CASCADE", "drops primary key t_pkey of t", []int{0}}, + {0, "DROP CONSTRAINT t_code_key", "drops unique constraint t_code_key of t", []int{0}}, + {0, "DROP CONSTRAINT t_p_fk", "drops foreign key t_p_fk of t", []int{0}}, + {0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}, + }, + }, + { + name: "the type comes from the table the name resolves to", + sql: "ALTER TABLE s.t DROP CONSTRAINT t_pkey;\nSET search_path = s;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_pkey;", + targets: []review.Target{{Schema: shop(), SessionUser: "alice"}}, + want: []targetFinding{ + {0, "DROP CONSTRAINT t_pkey", "drops unique constraint t_pkey of s.t", []int{0}}, + }, + }, + { + name: "a renamed table is not looked up under either name", + sql: "ALTER TABLE t RENAME TO u;\nALTER TABLE u DROP CONSTRAINT t_n_check;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_n_check;", + targets: one, + }, + { + name: "a renamed constraint is followed under its new name", + sql: "ALTER TABLE t RENAME CONSTRAINT t_n_check TO x;\nALTER TABLE t RENAME CONSTRAINT x TO y;\nALTER TABLE t DROP CONSTRAINT IF EXISTS x, DROP CONSTRAINT y;", + targets: one, + want: []targetFinding{{2, "DROP CONSTRAINT y", "drops check constraint y of t", []int{0}}}, + }, + { + name: "a constraint renamed onto another's name is refused", + sql: "ALTER TABLE t RENAME CONSTRAINT t_n_check TO x;\nALTER TABLE t RENAME CONSTRAINT t_code_key TO x;\nALTER TABLE t DROP CONSTRAINT x;", + targets: one, + }, + { + name: "a column the change added", + sql: "ALTER TABLE t ADD COLUMN tmp int;\nALTER TABLE t DROP COLUMN tmp;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + }, + { + name: "a constraint the change added is not reported", + sql: "ALTER TABLE nokey ADD CONSTRAINT nokey_a_check CHECK (a > 0);\nALTER TABLE nokey DROP CONSTRAINT nokey_a_check;\nALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT t_n_check CHECK (n > 1);\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + want: []targetFinding{{2, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "a missing constraint under IF EXISTS drops nothing", + sql: "ALTER TABLE t DROP CONSTRAINT IF EXISTS nope;", + targets: one, + }, + { + name: "a statement the server refuses is not reported, and the scan ends", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, DROP COLUMN nope;\nALTER TABLE t DROP CONSTRAINT t_code_key;", + targets: one, + }, + { + name: "dropping a constraint the snapshot lacks withholds the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, DROP CONSTRAINT nope;", + targets: one, + }, + { + name: "dropping a referenced key is refused without CASCADE", + sql: "ALTER TABLE p DROP CONSTRAINT p_pkey;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + }, + { + name: "CASCADE drops the foreign keys on the key", + sql: "ALTER TABLE p DROP CONSTRAINT p_pkey CASCADE;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_p_fk;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + want: []targetFinding{ + {0, "DROP CONSTRAINT p_pkey CASCADE", "drops primary key p_pkey of p", []int{0}}, + {2, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}, + }, + }, + { + name: "DROP TABLE CASCADE drops the foreign keys on it", + sql: "DROP TABLE p CASCADE;\nALTER TABLE t DROP CONSTRAINT IF EXISTS t_p_fk;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + want: []targetFinding{{2, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "the same names in two schemas are two constraints", + sql: "ALTER TABLE s.t DROP CONSTRAINT t_pkey;\nALTER TABLE public.t DROP CONSTRAINT t_pkey;", + targets: one, + want: []targetFinding{ + {0, "DROP CONSTRAINT t_pkey", "drops unique constraint t_pkey of s.t", []int{0}}, + {1, "DROP CONSTRAINT t_pkey", "drops primary key t_pkey of public.t", []int{0}}, + }, + }, + { + name: "a dropped table no longer holds its foreign keys", + sql: "DROP TABLE t;\nALTER TABLE p DROP CONSTRAINT p_pkey;", + targets: one, + want: []targetFinding{{1, "DROP CONSTRAINT p_pkey", "drops primary key p_pkey of p", []int{0}}}, + }, + { + name: "dropping a table a foreign key references is refused without CASCADE", + sql: "DROP TABLE p;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + }, + { + name: "dropping both ends of a foreign key together", + sql: "DROP TABLE p, t;\nALTER TABLE nokey ADD CONSTRAINT nokey_a CHECK (a > 0);\nALTER TABLE s.t DROP CONSTRAINT t_pkey;", + targets: one, + want: []targetFinding{{2, "DROP CONSTRAINT t_pkey", "drops unique constraint t_pkey of s.t", []int{0}}}, + }, + { + name: "a subcommand certain to fail refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD COLUMN id int;\nALTER TABLE t DROP CONSTRAINT t_code_key;", + targets: one, + }, + { + name: "altering a missing column refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ALTER COLUMN nope SET DEFAULT 1;", + targets: one, + }, + { + name: "adding a constraint under a name the table uses refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT t_code_key CHECK (n > 1);", + targets: one, + }, + { + name: "a second primary key refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD PRIMARY KEY (code);", + targets: one, + }, + { + name: "the drops run first, so a name or a column they free can be used again", + sql: "ALTER TABLE t ADD CONSTRAINT t_n_check CHECK (n > 1), DROP CONSTRAINT t_n_check, DROP COLUMN code, ADD COLUMN code int, ADD COLUMN IF NOT EXISTS id int;\nALTER TABLE s.t DROP CONSTRAINT t_pkey, ADD COLUMN x int, ALTER COLUMN id SET NOT NULL;", + targets: one, + want: []targetFinding{ + {0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}, + {1, "DROP CONSTRAINT t_pkey", "drops unique constraint t_pkey of s.t", []int{0}}, + }, + }, + { + name: "a cascading drop of an unrelated column keeps the foreign keys on the key", + sql: "ALTER TABLE p DROP COLUMN note CASCADE;\nALTER TABLE t DROP CONSTRAINT t_p_fk;", + targets: one, + want: []targetFinding{{1, "DROP CONSTRAINT t_p_fk", "drops foreign key t_p_fk of t", []int{0}}}, + }, + { + name: "a cascading key drop takes only the foreign keys on that table", + sql: "ALTER TABLE s.p DROP CONSTRAINT p_pkey CASCADE;\nALTER TABLE public.c DROP CONSTRAINT c_fk;", + targets: []review.Target{{Schema: database("public", + schema("public", withPrimaryKey(table("p", "id integer"), "p_pkey", "id"), withForeignKey(table("c", "p_id integer"), "c_fk", "p_id", "p", "id")), + schema("s", withPrimaryKey(table("p", "id integer"), "p_pkey", "id")), + )}}, + want: []targetFinding{ + {0, "DROP CONSTRAINT p_pkey CASCADE", "drops primary key p_pkey of s.p", []int{0}}, + {1, "DROP CONSTRAINT c_fk", "drops foreign key c_fk of public.c", []int{0}}, + }, + }, + { + name: "a second drop of the same constraint refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, DROP CONSTRAINT t_n_check;\nALTER TABLE s.t DROP CONSTRAINT t_pkey;", + targets: one, + }, + { + // The statement may fail, so what follows it is not known. + name: "a drop of a constraint a column drop may have taken ends the scan", + sql: "ALTER TABLE t DROP CONSTRAINT t_code_key, DROP COLUMN n, DROP CONSTRAINT t_n_check;\nALTER TABLE s.t DROP CONSTRAINT t_pkey;", + targets: one, + }, + { + name: "a new foreign key blocks only the key it references", + sql: "CREATE TABLE c (t_id int REFERENCES t (id));\nALTER TABLE t DROP CONSTRAINT t_code_key;\nCREATE TABLE c2 (t_id int REFERENCES t);\nALTER TABLE t DROP CONSTRAINT t_pkey;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + want: []targetFinding{{1, "DROP CONSTRAINT t_code_key", "drops unique constraint t_code_key of t", []int{0}}}, + }, + { + name: "an enabled event trigger on the command leaves its effect unknown", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: []review.Target{ + {Schema: withEventTrigger(shop(), true), SessionUser: "alice"}, + {Schema: withEventTrigger(shop(), true, "ALTER TABLE"), SessionUser: "alice"}, + {Schema: withEventTrigger(shop(), true, "CREATE FUNCTION"), SessionUser: "alice"}, + {Schema: withEventTrigger(shop(), false), SessionUser: "alice"}, + }, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{2, 3}}}, + }, + { + name: "a new foreign key goes with its table or its name", + sql: "CREATE TABLE c (t_id int REFERENCES t (id));\nDROP TABLE c;\nCREATE TABLE c2 (t_id int CONSTRAINT c2_fk REFERENCES t (id));\nALTER TABLE c2 DROP CONSTRAINT c2_fk;\nALTER TABLE t DROP CONSTRAINT t_pkey;", + targets: one, + want: []targetFinding{{4, "DROP CONSTRAINT t_pkey", "drops primary key t_pkey of t", []int{0}}}, + }, + { + name: "a column added twice refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD COLUMN x int, ADD COLUMN x int;", + targets: one, + }, + { + name: "an unqualified new foreign key references the table the path finds", + sql: "CREATE TABLE c (x int REFERENCES p (id));\nALTER TABLE public.p DROP CONSTRAINT p_pkey;", + targets: []review.Target{{Schema: database("s, public", + schema("public", withPrimaryKey(table("p", "id integer"), "p_pkey", "id")), + schema("s", withPrimaryKey(table("p", "id integer"), "p_pkey", "id")), + )}}, + want: []targetFinding{{1, "DROP CONSTRAINT p_pkey", "drops primary key p_pkey of public.p", []int{0}}}, + }, + { + name: "a no-op CREATE TABLE IF NOT EXISTS adds no foreign key", + sql: "CREATE TABLE IF NOT EXISTS public.nokey (a text REFERENCES t (code));\nALTER TABLE t DROP CONSTRAINT t_code_key;", + targets: one, + want: []targetFinding{{1, "DROP CONSTRAINT t_code_key", "drops unique constraint t_code_key of t", []int{0}}}, + }, + { + name: "an event trigger on function DDL", + sql: "CREATE FUNCTION g() RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: []review.Target{{Schema: withEventTrigger(shop(), true, "CREATE FUNCTION"), SessionUser: "alice"}}, + }, + { + name: "DROP COLUMN IF EXISTS of a missing column changes nothing", + sql: "ALTER TABLE t DROP COLUMN IF EXISTS nope;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + want: []targetFinding{{1, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + // The snapshot leaves out NOT NULL constraints, so the drop may + // fail, and what follows it is not known. + name: "a constraint the snapshot lacks ends the scan", + sql: "ALTER TABLE t DROP CONSTRAINT t_nn;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + }, + { + name: "a foreign key a cascade took is not there to drop", + sql: "ALTER TABLE p DROP CONSTRAINT p_pkey CASCADE;\nALTER TABLE t DROP CONSTRAINT t_p_fk;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT p_pkey CASCADE", "drops primary key p_pkey of p", []int{0}}}, + }, + { + name: "an event trigger on SELECT INTO in a set operation", + sql: "SELECT 1 AS a INTO x UNION SELECT 2;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: []review.Target{{Schema: withEventTrigger(shop(), true, "SELECT INTO"), SessionUser: "alice"}}, + }, + { + name: "the statements before an event trigger fires keep their findings", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check;\nCREATE INDEX ON t (n);\nALTER TABLE t DROP CONSTRAINT t_code_key;", + targets: []review.Target{{Schema: withEvent(shop(), "DDL_COMMAND_END", "CREATE INDEX"), SessionUser: "alice"}}, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "an added check on a column the table lacks refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad CHECK (nope > 0);", + targets: one, + }, + { + name: "an added check on a column the table has", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT good CHECK (n > 1);", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "a renamed column's drop takes the constraint on it", + sql: "ALTER TABLE t RENAME COLUMN code TO c2;\nALTER TABLE t DROP COLUMN c2, DROP CONSTRAINT t_code_key;", + targets: one, + }, + { + name: "a drop of a statement that may not run does not free what it drops", + sql: "ALTER TABLE t DROP CONSTRAINT t_p_fk, DROP CONSTRAINT nope;\nALTER TABLE p DROP CONSTRAINT p_pkey;", + targets: one, + }, + { + name: "an added key naming a column twice refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD UNIQUE (id, id);", + targets: one, + }, + { + name: "ALTER TABLE IF EXISTS of a table the target lacks does nothing", + sql: "ALTER TABLE IF EXISTS public.missing ADD CONSTRAINT fk FOREIGN KEY (x) REFERENCES public.t (id);\nALTER TABLE public.t DROP CONSTRAINT t_code_key;", + targets: one, + want: []targetFinding{{1, "DROP CONSTRAINT t_code_key", "drops unique constraint t_code_key of public.t", []int{0}}}, + }, + { + name: "a check constraint a possible parent has too may be inherited", + sql: "ALTER TABLE ch DROP CONSTRAINT par_a_check;", + targets: []review.Target{{Schema: withInheritedCheck()}}, + }, + { + name: "a check constraint no possible parent has", + sql: "ALTER TABLE par DROP CONSTRAINT par_b_check;", + targets: []review.Target{{Schema: withInheritedCheck()}}, + want: []targetFinding{{0, "DROP CONSTRAINT par_b_check", "drops check constraint par_b_check of par", []int{0}}}, + }, + { + name: "ADD CONSTRAINT USING INDEX of a missing index refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT replacement UNIQUE USING INDEX missing_idx;", + targets: one, + }, + { + name: "ADD CONSTRAINT USING INDEX of a constraint's index refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT replacement UNIQUE USING INDEX t_code_key;", + targets: one, + }, + { + name: "ADD CONSTRAINT USING INDEX of a unique index of the table", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT t_n_key UNIQUE USING INDEX t_n_uidx;", + targets: []review.Target{{Schema: withPlainIndex(shop(), "t", "t_n_uidx", "n", true), SessionUser: "alice"}}, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "ADD CONSTRAINT USING INDEX of an index that is not unique refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT t_n_key UNIQUE USING INDEX t_n_uidx;", + targets: []review.Target{{Schema: withPlainIndex(shop(), "t", "t_n_uidx", "n", false), SessionUser: "alice"}}, + }, + { + name: "a key named after a table refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT nokey UNIQUE (n);", + targets: one, + }, + { + name: "a key named after another table's index refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT p_pkey UNIQUE (n);", + targets: one, + }, + { + name: "a key named after the one the statement drops", + sql: "ALTER TABLE t DROP CONSTRAINT t_code_key, ADD CONSTRAINT t_code_key UNIQUE (code);", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_code_key", "drops unique constraint t_code_key of t", []int{0}}}, + }, + { + name: "an added foreign key to a column twice refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id, n) REFERENCES p (id, id);", + targets: one, + }, + { + name: "an added foreign key to a missing column refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id) REFERENCES p (nope);", + targets: one, + }, + { + name: "an added foreign key to a table without a primary key refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (n) REFERENCES nokey;", + targets: one, + }, + { + name: "an added foreign key to columns without a unique index refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (code) REFERENCES p (note);", + targets: one, + }, + { + name: "an added foreign key of another column count refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id, n) REFERENCES p (id);", + targets: one, + }, + { + name: "an added foreign key to a missing table refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id) REFERENCES missing_table;", + targets: one, + }, + { + name: "an added foreign key to a key the table has", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT good FOREIGN KEY (p_id) REFERENCES p (id), ADD CONSTRAINT good2 FOREIGN KEY (n) REFERENCES p;", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + // The scan does not follow the keys the change makes. + name: "an added foreign key to a unique index the change made withholds the statement's findings", + sql: "CREATE UNIQUE INDEX ON p (note);\nALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT good FOREIGN KEY (code) REFERENCES p (note);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "an added foreign key to a table the change made withholds the statement's findings", + sql: "CREATE TABLE k (id int);\nALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id) REFERENCES k (id);", + targets: one, + }, + { + name: "an added foreign key to its own table without a unique key refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (n) REFERENCES t (n);", + targets: one, + }, + { + name: "an added foreign key to its own table's key the statement adds", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT good FOREIGN KEY (n) REFERENCES t (n), ADD CONSTRAINT t_n_key UNIQUE (n);", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "an added foreign key to its own table's key the statement drops refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_code_key, ADD CONSTRAINT bad FOREIGN KEY (code) REFERENCES t (code);", + targets: one, + }, + { + name: "the server adds a column before a constraint written ahead of it", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT replacement UNIQUE (x), ADD COLUMN x int;", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "an added foreign key to a key the change dropped refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_p_fk;\nALTER TABLE p DROP CONSTRAINT p_pkey;\nALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT bad FOREIGN KEY (p_id) REFERENCES p (id);", + targets: one, + want: []targetFinding{ + {0, "DROP CONSTRAINT t_p_fk", "drops foreign key t_p_fk of t", []int{0}}, + {1, "DROP CONSTRAINT p_pkey", "drops primary key p_pkey of p", []int{0}}, + }, + }, + { + name: "an added foreign key to its own table", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT self FOREIGN KEY (p_id) REFERENCES t (id);", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "an added key on a missing column refuses the statement", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT replacement UNIQUE (nope);", + targets: one, + }, + { + name: "a no-op column drop frees no constraint name", + sql: "ALTER TABLE t DROP CONSTRAINT t_code_key, DROP COLUMN IF EXISTS nope, ADD CONSTRAINT t_n_check CHECK (n > 0);", + targets: one, + }, + { + name: "a dropped column takes its table's foreign key on it", + sql: "ALTER TABLE t DROP COLUMN p_id;\nALTER TABLE p DROP CONSTRAINT p_pkey;", + targets: one, + want: []targetFinding{{1, "DROP CONSTRAINT p_pkey", "drops primary key p_pkey of p", []int{0}}}, + }, + { + name: "a cascading drop of anything but a relation ends the scan", + sql: "DROP FUNCTION f() CASCADE;\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + }, + { + name: "procedural code ends the scan", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check;\nDO $$ BEGIN EXECUTE 'ALTER TABLE t RENAME TO old_t'; END $$;\nALTER TABLE t DROP CONSTRAINT t_code_key;", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, + }, + { + name: "after RESET search_path only a qualified name is looked up", + sql: "RESET search_path;\nALTER TABLE t DROP CONSTRAINT t_n_check;\nALTER TABLE public.t DROP CONSTRAINT t_code_key;", + targets: one, + want: []targetFinding{{2, "DROP CONSTRAINT t_code_key", "drops unique constraint t_code_key of public.t", []int{0}}}, + }, + { + name: "a quoted SET search_path value leaves the path unknown", + sql: "SET search_path = 'S';\nALTER TABLE t DROP CONSTRAINT t_n_check;", + targets: one, + }, + { + name: "a name that may be a system catalog", + sql: "ALTER TABLE pg_class DROP CONSTRAINT x;", + targets: []review.Target{{Schema: database("public", schema("public", withCheck(table("pg_class", "a integer"), "x", "(a > 0)")))}}, + }, + { + name: "no schema, no constraint to know", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey;", + targets: []review.Target{{}}, + }, + { + name: "a schema synced without its search path", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey;", + targets: []review.Target{{Schema: database("", shop().Schemas...), SessionUser: "alice"}}, + }, + { + name: "$user with no session user while a schema could be the user's", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey;", + targets: []review.Target{{Schema: shop()}}, + }, + { + name: "$user with no session user and no schema it could name", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey;", + targets: []review.Target{{Schema: database(`"$user", public`, shop().Schemas[0])}}, + want: []targetFinding{{0, "DROP CONSTRAINT t_pkey", "drops primary key t_pkey of t", []int{0}}}, + }, + { + name: "targets with equal findings merge, different ones do not", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey;", + targets: []review.Target{ + {Schema: shop(), SessionUser: "alice"}, + {Schema: database(`public`, schema("public", withUnique(table("t", "id integer"), "t_pkey", "id")))}, + {Schema: shop(), SessionUser: "alice"}, + {Schema: database(`public`, schema("public", table("t", "id integer")))}, + }, + want: []targetFinding{ + {0, "DROP CONSTRAINT t_pkey", "drops primary key t_pkey of t", []int{0, 2}}, + {0, "DROP CONSTRAINT t_pkey", "drops unique constraint t_pkey of t", []int{1}}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := runTargetRule(t, review.DisallowDropConstraint, tt.sql, review.Change{}, tt.targets) + if diff := cmp.Diff(tt.want, got, cmp.AllowUnexported(targetFinding{})); diff != "" { + t.Errorf("findings mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestRequirePrimaryKey(t *testing.T) { + one := []review.Target{{Schema: shop(), SessionUser: "alice"}} + tests := []struct { + name string + sql string + targets []review.Target + want []targetFinding + }{ + { + name: "a new table without a primary key", + sql: "CREATE TABLE n (id int);\nCREATE TABLE k (id int PRIMARY KEY);\nCREATE TABLE k2 (a int, b int, CONSTRAINT k2_pk PRIMARY KEY (a, b));", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a key added later in the change counts, under any name the table has by then", + sql: "CREATE TABLE n (id int);\nALTER TABLE n RENAME TO m;\nALTER TABLE m ADD PRIMARY KEY (id);\nCREATE TABLE o (id int NOT NULL);\nCREATE UNIQUE INDEX o_id ON o (id);\nALTER TABLE o ADD CONSTRAINT o_pkey PRIMARY KEY USING INDEX o_id;", + targets: one, + }, + { + name: "a key added in the same statement that drops one", + sql: "ALTER TABLE t ADD PRIMARY KEY (code), DROP CONSTRAINT t_pkey CASCADE;", + targets: one, + }, + { + name: "a key that may be added under another name", + sql: "CREATE TABLE n (id int);\nSET search_path = s, public;\nALTER TABLE n ADD PRIMARY KEY (id);", + targets: one, + }, + { + name: "a table attached as a partition", + sql: "CREATE TABLE pt (id int PRIMARY KEY) PARTITION BY RANGE (id);\nCREATE TABLE n (id int NOT NULL);\nALTER TABLE pt ATTACH PARTITION n FOR VALUES FROM (1) TO (10);", + targets: one, + }, + { + name: "a dropped table takes its indexes, whose names are then free", + sql: "DROP TABLE t;\nCREATE TABLE IF NOT EXISTS t_code_key (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS t_code_key (id int)", "creates table t_code_key without a primary key", []int{0}}}, + }, + { + name: "a cascading drop takes the views that read the table, in their own schema", + sql: "DROP TABLE public.base CASCADE;\nCREATE TABLE IF NOT EXISTS s.reader (id int);", + targets: []review.Target{{Schema: database("public", schema("public", table("base", "id integer")), &metadata.SchemaMetadata{ + Name: "s", + Views: []*metadata.ViewMetadata{{Name: "reader", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "base", Column: "id"}}}}, + })}}, + want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS s.reader (id int)", "creates table s.reader without a primary key", []int{0}}}, + }, + { + // m is created where the search path puts it, public, so the move + // of public.m is its own too. + name: "a qualified move keeps the table's schema exact", + sql: "CREATE TABLE public.n (id int);\nALTER TABLE public.n SET SCHEMA s;\nALTER TABLE x.n ADD PRIMARY KEY (id);\nCREATE TABLE m (id int);\nALTER TABLE public.m SET SCHEMA s;\nALTER TABLE x.m ADD PRIMARY KEY (id);", + targets: one, + want: []targetFinding{ + {0, "CREATE TABLE public.n (id int)", "creates table public.n without a primary key", []int{0}}, + {3, "CREATE TABLE m (id int)", "creates table m without a primary key", []int{0}}, + }, + }, + { + name: "a dropped materialized view takes its indexes", + sql: "DROP MATERIALIZED VIEW mv;\nCREATE TABLE IF NOT EXISTS mv_idx (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + MaterializedViews: []*metadata.MaterializedViewMetadata{{Name: "mv", Indexes: []*metadata.IndexMetadata{{Name: "mv_idx"}}}}, + })}}, + want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS mv_idx (id int)", "creates table mv_idx without a primary key", []int{0}}}, + }, + { + name: "dropping a view another view reads is refused without CASCADE", + sql: "DROP VIEW v1;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Views: []*metadata.ViewMetadata{ + {Name: "v1"}, + {Name: "v2", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "v1", Column: "a"}}}, + }, + })}}, + }, + { + name: "dropping a view with the view that reads it", + sql: "DROP VIEW v1, v2;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Views: []*metadata.ViewMetadata{ + {Name: "v1"}, + {Name: "v2", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "v1", Column: "a"}}}, + }, + })}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "the tables of CREATE SCHEMA", + sql: "CREATE SCHEMA z CREATE TABLE t (id int) CREATE TABLE k (id int PRIMARY KEY) CREATE TABLE z.q (id int);", + targets: one, + want: []targetFinding{ + {0, "CREATE TABLE t (id int)", "creates table z.t without a primary key", []int{0}}, + {0, "CREATE TABLE z.q (id int)", "creates table z.q without a primary key", []int{0}}, + }, + }, + { + name: "CREATE SCHEMA with an element in another schema is refused", + sql: "CREATE SCHEMA z CREATE TABLE t (id int) CREATE TABLE s.q (id int);", + targets: one, + }, + { + name: "CREATE SCHEMA with two elements of one name is refused", + sql: "CREATE SCHEMA z CREATE TABLE x (id int) CREATE VIEW x AS SELECT 1;", + targets: one, + }, + { + name: "CREATE SCHEMA with an element named like a generated name", + sql: "CREATE SCHEMA z CREATE TABLE x (id int UNIQUE) CREATE TABLE x_id_key (id int);", + targets: one, + }, + { + name: "a cascading drop takes a view the change made", + sql: "CREATE VIEW w AS SELECT * FROM nokey;\nDROP TABLE nokey CASCADE;\nCREATE TABLE w (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE w (id int)", "creates table w without a primary key", []int{0}}}, + }, + { + name: "a cascading drop takes the views reading a view the change made", + sql: "CREATE VIEW w AS SELECT * FROM nokey;\nCREATE VIEW w2 AS SELECT * FROM public.w;\nDROP TABLE nokey CASCADE;\nCREATE TABLE w2 (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE w2 (id int)", "creates table w2 without a primary key", []int{0}}}, + }, + { + name: "a view renamed after it was made keeps its name uncertain", + sql: "CREATE VIEW w AS SELECT * FROM nokey;\nALTER VIEW public.w RENAME TO w2;\nCREATE TABLE w (id int);\nDROP TABLE nokey CASCADE;\nCREATE TABLE w (id int);", + targets: one, + }, + { + name: "CREATE EXTENSION ends the scan", + sql: "CREATE EXTENSION e;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE EXTENSION IF NOT EXISTS of an installed extension does nothing", + sql: "CREATE EXTENSION IF NOT EXISTS pgcrypto;\nCREATE TABLE n (id int);\nCREATE EXTENSION IF NOT EXISTS citext;\nCREATE TABLE m (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := shop() + db.Extensions = []*metadata.ExtensionMetadata{{Name: "pgcrypto", Schema: "public"}} + return db + }(), SessionUser: "alice"}}, + }, + { + name: "a new view of a table in another schema does not block a drop", + sql: "CREATE VIEW v AS SELECT * FROM s.t;\nDROP TABLE public.t;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "dropping a table the target lacks is refused without IF EXISTS", + sql: "DROP TABLE public.nope;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "IF EXISTS drops nothing", + sql: "DROP TABLE IF EXISTS public.nope, nope2;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "dropping a table a function returns is refused without CASCADE", + sql: "DROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withFunctionReturning(shop(), "public", "nokey"), SessionUser: "alice"}}, + }, + { + name: "a CTE the view defines is not a relation it reads", + sql: "CREATE VIEW v AS WITH t AS (SELECT 1 AS id) SELECT * FROM t;\nDROP TABLE public.t;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a view the change drops no longer reads the table", + sql: "CREATE VIEW v AS SELECT * FROM public.t;\nDROP VIEW v;\nDROP TABLE public.t;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a function the change drops no longer returns the table", + sql: "DROP FUNCTION f;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withFunctionReturning(shop(), "public", "nokey"), SessionUser: "alice"}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a function the change creates returning the table blocks its drop", + sql: "CREATE FUNCTION g() RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP TABLE of a view is refused", + sql: "DROP TABLE v;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Views: []*metadata.ViewMetadata{{Name: "v"}}, + })}}, + }, + { + name: "DROP INDEX of a constraint's index is refused", + sql: "DROP INDEX t_pkey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TABLE of a name the schema holds is refused", + sql: "CREATE TABLE nokey (a int);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a CTE body reads the table its name shadows", + sql: "CREATE VIEW v AS WITH t AS (SELECT * FROM t) SELECT * FROM t;\nDROP TABLE public.t;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a cascading drop takes the views that read its views", + sql: "DROP TABLE base CASCADE;\nCREATE TABLE IF NOT EXISTS v2 (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer")}, + Views: []*metadata.ViewMetadata{ + {Name: "v1", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "base", Column: "id"}}}, + {Name: "v2", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "v1", Column: "id"}}}, + }, + })}}, + want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS v2 (id int)", "creates table v2 without a primary key", []int{0}}}, + }, + { + name: "dropping one of two new overloads leaves the other", + sql: "CREATE FUNCTION g(int) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nCREATE FUNCTION g(text) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(int);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP VIEW of a materialized view is refused", + sql: "DROP VIEW mv;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + MaterializedViews: []*metadata.MaterializedViewMetadata{{Name: "mv"}}, + })}}, + }, + { + name: "ADD COLUMN IF NOT EXISTS of an existing column adds no key", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nALTER TABLE t ADD COLUMN IF NOT EXISTS id int PRIMARY KEY;", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_pkey CASCADE", "removes the primary key of t, and the change adds none back", []int{0}}}, + }, + { + name: "a replaced view no longer reads what it read", + sql: "CREATE OR REPLACE VIEW v AS SELECT 1 AS id;\nDROP TABLE base;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer")}, + Views: []*metadata.ViewMetadata{{Name: "v", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "base", Column: "id"}}}}, + })}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a sequence another owner took stays", + sql: "ALTER SEQUENCE q OWNED BY NONE;\nDROP TABLE base;\nCREATE TABLE IF NOT EXISTS q (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer")}, + Sequences: []*metadata.SequenceMetadata{{Name: "q", OwnerTable: "base", OwnerColumn: "id"}}, + })}}, + }, + { + name: "a table named like a relation an earlier statement made", + sql: "CREATE INDEX ON t (n);\nCREATE TABLE t_n_idx1 (x int);\nCREATE TABLE a (id int CONSTRAINT foo PRIMARY KEY);\nCREATE TABLE foo (x int);", + targets: one, + }, + { + name: "a table in a missing schema", + sql: "CREATE TABLE missing.n (id int);\nCREATE TABLE m (id int);", + targets: one, + }, + { + name: "IF EXISTS of a missing table leaves the name free", + sql: "DROP TABLE IF EXISTS gone;\nCREATE TABLE gone (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE gone (id int)", "creates table gone without a primary key", []int{0}}}, + }, + { + name: "DROP FUNCTION IF EXISTS may drop nothing", + sql: "CREATE FUNCTION g(integer) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION IF EXISTS g(text);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a view the change created and replaced no longer reads the table", + sql: "CREATE VIEW v AS SELECT * FROM nokey;\nCREATE OR REPLACE VIEW v AS SELECT 1 AS a;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a table in another database", + sql: "CREATE TABLE other.public.n (id int);\nCREATE TABLE m (id int);", + targets: one, + }, + { + name: "an ALTER refused for a subcommand, with only this rule on", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, ADD COLUMN id int;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER TABLE of a table the target lacks", + sql: "ALTER TABLE nope DROP CONSTRAINT x;\nCREATE TABLE n (id int);\nALTER TABLE IF EXISTS nope DROP CONSTRAINT x;\nCREATE TABLE m (id int);", + targets: one, + }, + { + name: "ALTER TABLE IF EXISTS of a table the target lacks", + sql: "ALTER TABLE IF EXISTS nope DROP CONSTRAINT x;\nCREATE TABLE m (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE m (id int)", "creates table m without a primary key", []int{0}}}, + }, + { + name: "CREATE SCHEMA of an existing schema", + sql: "CREATE SCHEMA s CREATE TABLE t2 (id int);", + targets: one, + }, + { + name: "a dropped partition takes its own indexes", + sql: "DROP TABLE p1;\nCREATE TABLE IF NOT EXISTS p1_idx (id int);", + targets: []review.Target{{Schema: database("public", schema("public", &metadata.TableMetadata{ + Name: "pt", + Columns: []*metadata.ColumnMetadata{{Name: "id", Type: "integer"}}, + Partitions: []*metadata.TablePartitionMetadata{{Name: "p1", Indexes: []*metadata.IndexMetadata{{Name: "p1_idx"}}}}, + }))}}, + want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS p1_idx (id int)", "creates table p1_idx without a primary key", []int{0}}}, + }, + { + name: "DROP SCHEMA IF EXISTS of a missing schema leaves it missing", + sql: "DROP SCHEMA IF EXISTS missing;\nCREATE TABLE missing.n (id int);", + targets: one, + }, + { + name: "DROP SCHEMA of a missing schema is refused", + sql: "DROP SCHEMA missing;\nCREATE TABLE m (id int);", + targets: one, + }, + { + name: "a skipped ADD COLUMN adds no second key", + sql: "ALTER TABLE t ADD COLUMN IF NOT EXISTS id int PRIMARY KEY;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "every column drop of a statement sees the table", + sql: "ALTER TABLE t DROP COLUMN code, DROP COLUMN id;", + targets: one, + want: []targetFinding{{0, "DROP COLUMN id", "removes the primary key of t, and the change adds none back", []int{0}}}, + }, + { + name: "EXPLAIN ANALYZE of CREATE MATERIALIZED VIEW makes the view", + sql: "EXPLAIN ANALYZE CREATE MATERIALIZED VIEW mv AS SELECT * FROM nokey;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE SCHEMA IF NOT EXISTS with elements is refused", + sql: "CREATE SCHEMA IF NOT EXISTS z CREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE VIEW of a name its schema holds is refused", + sql: "CREATE VIEW public.nokey AS SELECT 1 AS a;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE OR REPLACE VIEW of a table is refused", + sql: "CREATE OR REPLACE VIEW public.t AS SELECT 1 AS a;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a new function taking a table's row type blocks its drop", + sql: "CREATE FUNCTION g(x nokey) RETURNS integer LANGUAGE sql AS 'SELECT 1';\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "an event trigger on ALTER SEQUENCE", + sql: "ALTER SEQUENCE q OWNED BY NONE;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEventTrigger(shop(), true, "ALTER SEQUENCE"), SessionUser: "alice"}}, + }, + { + name: "a cascading view drop keeps a table's lost key", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nDROP VIEW v CASCADE;", + targets: []review.Target{{Schema: withView(shop(), "public", "v"), SessionUser: "alice"}}, + want: []targetFinding{{0, "DROP CONSTRAINT t_pkey CASCADE", "removes the primary key of t, and the change adds none back", []int{0}}}, + }, + { + name: "a definite rename leaves the old name to another table", + sql: "CREATE TABLE public.a (id int);\nALTER TABLE public.a RENAME TO b;\nCREATE TABLE public.a (id int PRIMARY KEY);\nDROP TABLE public.a;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE public.a (id int)", "creates table public.a without a primary key", []int{0}}}, + }, + { + name: "a column drop frees the names of its constraints", + sql: "ALTER TABLE t DROP COLUMN code, ADD CONSTRAINT t_code_key UNIQUE (n);\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE SCHEMA IF NOT EXISTS of an existing schema changes nothing", + sql: "CREATE SCHEMA IF NOT EXISTS s;\nCREATE TABLE s.t (id int);", + targets: one, + }, + { + name: "CREATE SEQUENCE of a name its schema holds is refused", + sql: "CREATE SEQUENCE public.nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE SEQUENCE IF NOT EXISTS of an existing name does nothing", + sql: "CREATE SEQUENCE IF NOT EXISTS public.nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a dropped schema is gone", + sql: "DROP SCHEMA s;\nCREATE TABLE s.n (id int);", + targets: []review.Target{{Schema: database("public", schema("public"), schema("s"))}}, + }, + { + name: "a renamed new view keeps its reads under the new name", + sql: "CREATE VIEW public.v AS SELECT * FROM public.nokey;\nALTER VIEW public.v RENAME TO w;\nDROP VIEW public.w;\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{4, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a new view dropped with the view it reads", + sql: "CREATE VIEW w AS SELECT * FROM v;\nDROP VIEW v, w;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withView(shop(), "public", "v"), SessionUser: "alice"}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a column renamed twice is gone under the middle name", + sql: "ALTER TABLE t RENAME COLUMN id TO x;\nALTER TABLE t RENAME COLUMN x TO y;\nALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nALTER TABLE t ADD COLUMN IF NOT EXISTS x int PRIMARY KEY;", + targets: one, + }, + { + name: "a column the statement adds twice gets one definition", + sql: "CREATE TABLE n (id int);\nALTER TABLE n ADD COLUMN x int, ADD COLUMN IF NOT EXISTS x int PRIMARY KEY;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a new SQL-standard function body reading a table blocks its drop", + sql: "CREATE FUNCTION g() RETURNS bigint LANGUAGE SQL RETURN (SELECT count(*) FROM nokey);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a cascading column drop takes the views of the column and their views", + sql: "ALTER TABLE base DROP COLUMN note CASCADE;\nCREATE TABLE w (id int);\nCREATE TABLE IF NOT EXISTS other (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer", "note text")}, + Views: []*metadata.ViewMetadata{ + {Name: "v", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "base", Column: "note"}}}, + {Name: "w", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "v", Column: "note"}}}, + {Name: "other", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "base", Column: "id"}}}, + }, + })}}, + want: []targetFinding{{1, "CREATE TABLE w (id int)", "creates table w without a primary key", []int{0}}}, + }, + { + name: "DROP of a name in another database is refused", + sql: "DROP TABLE other.public.nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP FUNCTION with arguments is not matched against a synced function", + sql: "DROP FUNCTION f(text);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withFunctionReturning(shop(), "public", "nokey"), SessionUser: "alice"}}, + }, + { + name: "DROP FUNCTION of a new function by its arguments", + sql: "CREATE FUNCTION g(integer) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(text);\nDROP TABLE nokey;\nCREATE TABLE n (id int);\nDROP FUNCTION IF EXISTS g(integer);", + targets: one, + }, + { + name: "DROP FUNCTION of a new function, matching arguments", + sql: "CREATE FUNCTION g(integer) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(integer);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP FUNCTION of a name the target lacks is refused", + sql: "DROP FUNCTION public.missing();\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP FUNCTION of an unqualified name the target lacks is refused", + sql: "DROP FUNCTION missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP FUNCTION IF EXISTS of a name the target lacks does nothing", + sql: "DROP FUNCTION IF EXISTS public.missing();\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP FUNCTION of a name the change gave a function", + sql: "CREATE FUNCTION g() RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION g() RENAME TO h;\nDROP FUNCTION h();\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP PROCEDURE retires the procedure's dependencies", + sql: "CREATE PROCEDURE pr(a nokey) LANGUAGE sql AS 'SELECT 1';\nDROP PROCEDURE pr(nokey);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a renamed function keeps its dependencies", + sql: "CREATE FUNCTION g(nokey) RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION g(nokey) RENAME TO h;\nDROP FUNCTION h(nokey);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{4, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a renamed function still holds the table under its new name", + sql: "CREATE FUNCTION g(nokey) RETURNS int LANGUAGE sql AS 'SELECT 1';\nALTER FUNCTION g(nokey) RENAME TO h;\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a cascading drop takes the synced function on the table", + sql: "DROP TABLE s.t CASCADE;\nDROP SCHEMA s;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withFunctionReturning(shop(), "s", "t"), SessionUser: "alice"}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a cascading drop takes a function the change made on the table", + sql: "CREATE FUNCTION g(a) RETURNS SETOF b LANGUAGE sql AS 'SELECT * FROM b';\nDROP TABLE a CASCADE;\nDROP TABLE b;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", schema("public", table("a", "id integer"), table("b", "id integer")))}}, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a plain table generates no names", + sql: "CREATE TABLE public.a (id int);\nCREATE TABLE public.n_idx (id int);\nDROP TABLE public.a;", + targets: one, + want: []targetFinding{{1, "CREATE TABLE public.n_idx (id int)", "creates table public.n_idx without a primary key", []int{0}}}, + }, + { + name: "a serial column generates a name", + sql: "CREATE TABLE a (id serial);\nCREATE TABLE n_idx (id int);", + targets: one, + want: []targetFinding{{0, "CREATE TABLE a (id serial)", "creates table a without a primary key", []int{0}}}, + }, + { + name: "LIKE of a table the change made without a key copies none", + sql: "CREATE TABLE public.a (id int);\nCREATE TABLE public.b (LIKE public.a INCLUDING INDEXES);\nDROP TABLE public.a;", + targets: one, + want: []targetFinding{{1, "CREATE TABLE public.b (LIKE public.a INCLUDING INDEXES)", "creates table public.b without a primary key", []int{0}}}, + }, + { + name: "LIKE of a table the change made with a key", + sql: "CREATE TABLE public.a (id int PRIMARY KEY);\nCREATE TABLE public.b (LIKE public.a INCLUDING INDEXES);", + targets: one, + }, + { + name: "CREATE TABLE IF NOT EXISTS in a schema the change created", + sql: "CREATE SCHEMA z;\nCREATE TABLE IF NOT EXISTS z.n (id int);\nCREATE TABLE z.k (id int PRIMARY KEY);\nCREATE TABLE IF NOT EXISTS z.k (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS z.n (id int)", "creates table z.n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE of a type's name is refused", + sql: "CREATE TABLE IF NOT EXISTS mood (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := shop() + db.Schemas[0].EnumTypes = []*metadata.EnumTypeMetadata{{Name: "mood"}} + return db + }(), SessionUser: "alice"}}, + }, + { + name: "CREATE TABLE of a type the change made", + sql: "CREATE DOMAIN d AS int;\nCREATE TABLE d (id int);", + targets: one, + }, + { + name: "a table_rewrite event trigger does not fire on ADD COLUMN of a plain column", + sql: "ALTER TABLE t ADD COLUMN x int, ADD COLUMN y text[];\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "TABLE_REWRITE"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a table_rewrite event trigger fires on ADD COLUMN with a default", + sql: "ALTER TABLE t ADD COLUMN x int DEFAULT random()::int;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "TABLE_REWRITE"), SessionUser: "alice"}}, + }, + { + name: "a table_rewrite event trigger fires on a column type change", + sql: "ALTER TABLE t ALTER COLUMN n TYPE bigint;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "TABLE_REWRITE"), SessionUser: "alice"}}, + }, + { + name: "DROP FUNCTION IF EXISTS of a function the change made, by its arguments", + sql: "CREATE FUNCTION public.f(public.nokey) RETURNS integer LANGUAGE sql AS 'SELECT 1';\nDROP FUNCTION IF EXISTS public.f(public.nokey);\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a view the change made follows the table it reads through a rename", + sql: "CREATE VIEW public.v AS SELECT * FROM public.nokey;\nALTER TABLE public.nokey RENAME TO u;\nDROP TABLE public.u;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a synced view follows the table it reads through a rename", + sql: "ALTER TABLE base RENAME TO u;\nDROP TABLE u;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer")}, + Views: []*metadata.ViewMetadata{{Name: "v", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "base", Column: "id"}}}}, + })}}, + }, + { + name: "a renamed table without dependents drops", + sql: "ALTER TABLE nokey RENAME TO u;\nDROP TABLE u;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a cascading DROP TABLE IF EXISTS of a missing table settles nothing", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nDROP TABLE IF EXISTS public.missing CASCADE;", + targets: one, + want: []targetFinding{{0, "DROP CONSTRAINT t_pkey CASCADE", "removes the primary key of t, and the change adds none back", []int{0}}}, + }, + { + name: "CREATE OR REPLACE FUNCTION redefines a synced function without arguments", + sql: "CREATE OR REPLACE FUNCTION public.f() RETURNS integer LANGUAGE sql RETURN 1;\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withNoArgFunctionReturning(shop(), "public", "nokey"), SessionUser: "alice"}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE OR REPLACE FUNCTION with arguments may add an overload", + sql: "CREATE OR REPLACE FUNCTION public.f(int) RETURNS integer LANGUAGE sql RETURN 1;\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withNoArgFunctionReturning(shop(), "public", "nokey"), SessionUser: "alice"}}, + }, + { + name: "a CREATE PROCEDURE tag filter does not fire on CREATE FUNCTION", + sql: "CREATE FUNCTION g() RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "DDL_COMMAND_END", "CREATE PROCEDURE"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a CREATE PROCEDURE tag filter fires on CREATE PROCEDURE", + sql: "CREATE PROCEDURE pr() LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "DDL_COMMAND_END", "CREATE PROCEDURE"), SessionUser: "alice"}}, + }, + { + name: "CREATE TABLE with a column twice is refused", + sql: "CREATE TABLE n (id int, id text);", + targets: one, + }, + { + name: "a rename to a name the schema holds is refused", + sql: "ALTER TABLE t RENAME TO nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a view the change made follows the table it reads to another schema", + sql: "CREATE VIEW public.v AS SELECT * FROM public.nokey;\nALTER TABLE public.nokey SET SCHEMA s;\nDROP TABLE s.nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a synced view follows the table it reads to another schema", + sql: "ALTER TABLE base SET SCHEMA s;\nDROP TABLE s.base;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer")}, + Views: []*metadata.ViewMetadata{{Name: "v", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "base", Column: "id"}}}}, + }, &metadata.SchemaMetadata{Name: "s"})}}, + }, + { + name: "SET SCHEMA to a schema the target lacks is refused", + sql: "ALTER TABLE nokey SET SCHEMA nope;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "SET SCHEMA to a schema holding the name is refused", + sql: "ALTER TABLE public.t SET SCHEMA s;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "dropping a key frees its index's name", + sql: "ALTER TABLE t DROP CONSTRAINT t_code_key;\nCREATE TABLE IF NOT EXISTS t_code_key (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS t_code_key (id int)", "creates table t_code_key without a primary key", []int{0}}}, + }, + { + name: "dropping a column frees the sequence it owns", + sql: "ALTER TABLE base DROP COLUMN id;\nCREATE TABLE q (x int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer", "x integer")}, + Sequences: []*metadata.SequenceMetadata{{Name: "q", OwnerTable: "base", OwnerColumn: "id"}}, + })}}, + want: []targetFinding{{1, "CREATE TABLE q (x int)", "creates table q without a primary key", []int{0}}}, + }, + { + name: "a qualified DROP of a table the change made frees its name", + sql: "CREATE TABLE public.n (id int PRIMARY KEY);\nDROP TABLE public.n;\nCREATE TABLE public.n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE public.n (id int)", "creates table public.n without a primary key", []int{0}}}, + }, + { + name: "a DROP of a table the change made that a new view reads", + sql: "CREATE TABLE public.n (id int PRIMARY KEY);\nCREATE VIEW public.w AS SELECT * FROM public.n;\nDROP TABLE public.n;\nCREATE TABLE public.n (id int);", + targets: one, + }, + { + name: "a rename of a relation the target lacks is refused", + sql: "ALTER TABLE public.missing RENAME TO x;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a rename under IF EXISTS of a relation the target lacks does nothing", + sql: "ALTER TABLE IF EXISTS public.missing RENAME TO x;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "SET SCHEMA of a relation the target lacks is refused", + sql: "ALTER TABLE public.missing SET SCHEMA s;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a login event trigger fires on no statement", + sql: "CREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "LOGIN"), SessionUser: "alice"}}, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP SCHEMA of a schema the change put a table in is refused", + sql: "CREATE SCHEMA z;\nCREATE TABLE z.x (id int PRIMARY KEY);\nDROP SCHEMA z;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP SCHEMA of a schema the change emptied again", + sql: "CREATE SCHEMA z;\nCREATE TABLE z.x (id int PRIMARY KEY);\nDROP TABLE z.x;\nDROP SCHEMA z;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{4, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP SCHEMA of a synced schema a table was renamed in is refused", + sql: "ALTER TABLE s.t RENAME TO t2;\nDROP SCHEMA s;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a foreign key of a table the same DROP drops does not hold it", + sql: "CREATE TABLE public.c (id int PRIMARY KEY, n_a int REFERENCES public.k);\nDROP TABLE public.c, public.k;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", schema("public", withPrimaryKey(table("k", "id integer"), "k_pkey", "id")))}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a foreign key of a table the change made still holds the table it references", + sql: "CREATE TABLE public.c (id int PRIMARY KEY, n_a int REFERENCES public.k);\nDROP TABLE public.k;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", schema("public", withPrimaryKey(table("k", "id integer"), "k_pkey", "id")))}}, + }, + { + name: "CREATE TABLE INHERITS of a parent the target lacks is refused", + sql: "CREATE TABLE child (id int) INHERITS (public.missing);", + targets: one, + }, + { + name: "CREATE TABLE INHERITS of an index is refused", + sql: "CREATE TABLE child (id int) INHERITS (public.t_pkey);", + targets: one, + }, + { + name: "CREATE TABLE LIKE of a relation the target lacks is refused", + sql: "CREATE TABLE child (LIKE public.missing);", + targets: one, + }, + { + name: "CREATE TABLE INHERITS of a table", + sql: "CREATE TABLE child (id int) INHERITS (public.nokey);", + targets: one, + want: []targetFinding{{0, "CREATE TABLE child (id int) INHERITS (public.nokey)", "creates table child without a primary key", []int{0}}}, + }, + { + name: "a second CREATE TABLE of a name the change made is refused", + sql: "CREATE TABLE public.x (id int PRIMARY KEY);\nCREATE TABLE public.x (id int);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TABLE IF NOT EXISTS of a name the change made does nothing", + sql: "CREATE TABLE public.x (id int PRIMARY KEY);\nCREATE TABLE IF NOT EXISTS public.x (id int);\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER TABLE of an index is refused", + sql: "ALTER TABLE public.t_pkey DROP CONSTRAINT x;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "TRUNCATE fires no event trigger", + sql: "TRUNCATE t;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "DDL_COMMAND_END", "CREATE INDEX"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "SET SCHEMA vacates the schema the relation leaves", + sql: "ALTER TABLE s.m SET SCHEMA public;\nDROP SCHEMA s;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", schema("public"), schema("s", withUnique(table("m", "id integer"), "m_key", "id")))}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP SCHEMA after the change dropped the function it made there", + sql: "CREATE SCHEMA z;\nCREATE FUNCTION z.f() RETURNS int LANGUAGE sql AS 'SELECT 1';\nDROP FUNCTION z.f();\nDROP SCHEMA z;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{4, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP SCHEMA of a schema holding a function the change made is refused", + sql: "CREATE SCHEMA z;\nCREATE FUNCTION z.f() RETURNS int LANGUAGE sql AS 'SELECT 1';\nDROP SCHEMA z;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "an unqualified name the search path resolves elsewhere does not settle a table", + sql: "CREATE TABLE public.n (id int);\nSET search_path = s, public;\nALTER TABLE n ADD PRIMARY KEY (id);", + targets: []review.Target{{Schema: database(`"$user", public`, schema("public"), schema("s", table("n", "id integer")))}}, + want: []targetFinding{{0, "CREATE TABLE public.n (id int)", "creates table public.n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE with a foreign key to a table the target lacks is refused", + sql: "CREATE TABLE public.n (id int REFERENCES public.missing);", + targets: one, + }, + { + name: "CREATE TABLE with a foreign key to columns without a key is refused", + sql: "CREATE TABLE n (id int, p_note text REFERENCES p (note));", + targets: one, + }, + { + name: "CREATE TABLE with a foreign key on a column it lacks is refused", + sql: "CREATE TABLE n (id int, FOREIGN KEY (nope) REFERENCES p);", + targets: one, + }, + { + name: "CREATE TABLE with a foreign key to a synced key", + sql: "CREATE TABLE n (id int, p_id int REFERENCES p);", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int, p_id int REFERENCES p)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE with a foreign key to a table the change made with a key", + sql: "CREATE TABLE a (id int PRIMARY KEY);\nCREATE TABLE b (id int, a_id int REFERENCES a);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE b (id int, a_id int REFERENCES a)", "creates table b without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE with a foreign key to a table the change made without a key is refused", + sql: "CREATE TABLE a (id int);\nCREATE TABLE b (id int, a_id int REFERENCES a);", + targets: one, + }, + { + name: "CREATE TABLE with a foreign key to a table the change altered is not reported", + sql: "CREATE TABLE a (id int);\nALTER TABLE a ADD PRIMARY KEY (id);\nCREATE TABLE b (id int, a_id int REFERENCES a);", + targets: one, + }, + { + name: "CREATE TABLE with a foreign key to itself without the key is refused", + sql: "CREATE TABLE n (id int, parent int REFERENCES n (id));", + targets: one, + }, + { + name: "CREATE TABLE with a foreign key to its own unique column", + sql: "CREATE TABLE n (id int UNIQUE, parent int REFERENCES n (id));", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int UNIQUE, parent int REFERENCES n (id))", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a view of CREATE SCHEMA is the change's own", + sql: "CREATE SCHEMA z CREATE VIEW v AS SELECT 1;\nDROP VIEW z.v;\nCREATE TABLE z.v (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE z.v (id int)", "creates table z.v without a primary key", []int{0}}}, + }, + { + name: "DROP TYPE without CASCADE ends the scan", + sql: "DROP TYPE mood;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a generated name in one schema leaves another's names alone", + sql: "CREATE TABLE s1.x (id serial PRIMARY KEY);\nCREATE TABLE s2.audit_seq (id int);", + targets: []review.Target{{Schema: database("public", schema("public"), schema("s1"), schema("s2"))}}, + want: []targetFinding{{1, "CREATE TABLE s2.audit_seq (id int)", "creates table s2.audit_seq without a primary key", []int{0}}}, + }, + { + name: "a generated name in the same schema is uncertain", + sql: "CREATE TABLE s1.x (id serial PRIMARY KEY);\nCREATE TABLE s1.audit_seq (id int);", + targets: []review.Target{{Schema: database("public", schema("public"), schema("s1"), schema("s2"))}}, + }, + { + name: "DROP COLUMN of a column a generated column reads is refused", + sql: "ALTER TABLE g DROP COLUMN a;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withGenerated()}}, + }, + { + name: "DROP COLUMN CASCADE of a column a generated column reads", + sql: "ALTER TABLE g DROP COLUMN a CASCADE;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withGenerated()}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "an sql_drop event trigger does not fire on a DROP that drops nothing", + sql: "DROP TABLE IF EXISTS public.missing;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "SQL_DROP"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "an sql_drop event trigger fires on a DROP that drops", + sql: "DROP TABLE IF EXISTS public.nokey;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "SQL_DROP"), SessionUser: "alice"}}, + }, + { + name: "an event trigger ends the scan at the statement it fires on", + sql: "ALTER TABLE t DROP CONSTRAINT t_n_check;\nCREATE INDEX ON t (n);\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "DDL_COMMAND_END", "CREATE INDEX"), SessionUser: "alice"}}, + }, + { + name: "a sequence the change made and dropped frees its name", + sql: "CREATE SEQUENCE public.q;\nDROP SEQUENCE public.q;\nCREATE TABLE public.q (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE public.q (id int)", "creates table public.q without a primary key", []int{0}}}, + }, + { + name: "LIKE of a table whose key the change dropped copies none", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nCREATE TABLE n (LIKE t INCLUDING INDEXES);", + targets: one, + want: []targetFinding{ + {0, "DROP CONSTRAINT t_pkey CASCADE", "removes the primary key of t, and the change adds none back", []int{0}}, + {1, "CREATE TABLE n (LIKE t INCLUDING INDEXES)", "creates table n without a primary key", []int{0}}, + }, + }, + { + name: "LIKE of a table the change gave a key copies it", + sql: "ALTER TABLE nokey ADD PRIMARY KEY (a);\nCREATE TABLE n (LIKE nokey INCLUDING INDEXES);", + targets: one, + }, + { + name: "CREATE VIEW of a relation the target lacks is refused", + sql: "CREATE VIEW public.v AS SELECT * FROM public.missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE VIEW of a CTE named like a missing relation", + sql: "CREATE VIEW public.v AS WITH missing AS (SELECT 1 AS a) SELECT * FROM missing;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE OF a type the target lacks is refused", + sql: "CREATE TABLE n OF public.missing;", + targets: one, + }, + { + name: "CREATE TABLE OF a table's row type is refused", + sql: "CREATE TABLE n OF public.nokey;", + targets: one, + }, + { + name: "CREATE TABLE OF a composite type the change made", + sql: "CREATE TYPE ct AS (a int);\nCREATE TABLE n OF ct;", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n OF ct", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a table_rewrite event trigger does not fire on trigger toggles", + sql: "ALTER TABLE t ENABLE TRIGGER ALL, DISABLE TRIGGER USER;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "TABLE_REWRITE"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE with a foreign key naming a column twice is refused", + sql: "CREATE TABLE n (a int, FOREIGN KEY (a, a) REFERENCES k (id, code));", + targets: []review.Target{{Schema: withCompositeKey()}}, + }, + { + name: "CREATE TABLE with a foreign key to a composite key", + sql: "CREATE TABLE n (a int, b text, FOREIGN KEY (a, b) REFERENCES k (id, code));", + targets: []review.Target{{Schema: withCompositeKey()}}, + want: []targetFinding{{0, "CREATE TABLE n (a int, b text, FOREIGN KEY (a, b) REFERENCES k (id, code))", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE naming a constraint twice is refused", + sql: "CREATE TABLE n (a int CONSTRAINT c CHECK (a > 0), b int CONSTRAINT c CHECK (b > 0));", + targets: one, + }, + { + name: "CREATE TABLE with a key named after a relation is refused", + sql: "CREATE TABLE n (a int CONSTRAINT nokey UNIQUE);", + targets: one, + }, + { + name: "CREATE TABLE with a key named after the table is refused", + sql: "CREATE TABLE n (a int CONSTRAINT n UNIQUE);", + targets: one, + }, + { + name: "RENAME COLUMN of a column the table lacks is refused", + sql: "ALTER TABLE t RENAME COLUMN missing TO x;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "RENAME COLUMN to a column the table has is refused", + sql: "ALTER TABLE t RENAME COLUMN n TO code;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "RENAME COLUMN of a column the table has", + sql: "ALTER TABLE t RENAME COLUMN n TO n2;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE FUNCTION in a schema the target lacks is refused", + sql: "CREATE FUNCTION missing.f() RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TYPE in a schema the target lacks is refused", + sql: "CREATE TYPE missing.e AS ENUM ('a');\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP PROCEDURE of a schema's only routine empties it", + sql: "DROP PROCEDURE z.p;\nDROP SCHEMA z;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", schema("public"), &metadata.SchemaMetadata{ + Name: "z", + Procedures: []*metadata.ProcedureMetadata{{Name: "p"}}, + })}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE AS of a name the schema holds is refused", + sql: "CREATE TABLE public.nokey AS SELECT 1;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TABLE AS IF NOT EXISTS of a name the schema holds does nothing", + sql: "CREATE TABLE IF NOT EXISTS public.nokey AS SELECT 1;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "SELECT INTO a name the schema holds is refused", + sql: "SELECT 1 AS a INTO public.nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TABLE AS of a relation the target lacks is refused", + sql: "CREATE TABLE x AS SELECT * FROM public.missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "RENAME CONSTRAINT to a name the table has is refused", + sql: "ALTER TABLE t RENAME CONSTRAINT t_n_check TO t_pkey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "RENAME CONSTRAINT of a key to a relation's name is refused", + sql: "ALTER TABLE t RENAME CONSTRAINT t_code_key TO nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "RENAME CONSTRAINT to a free name", + sql: "ALTER TABLE t RENAME CONSTRAINT t_n_check TO t_n_check2;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER TABLE of a table the change dropped is refused", + sql: "DROP TABLE public.nokey;\nALTER TABLE public.nokey ADD COLUMN x int;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP TABLE IF EXISTS of a table the change dropped does nothing", + sql: "DROP TABLE public.nokey;\nDROP TABLE IF EXISTS public.nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE FUNCTION with a SQL-standard body of a relation the target lacks is refused", + sql: "CREATE FUNCTION public.f() RETURNS bigint LANGUAGE SQL RETURN (SELECT count(*) FROM public.missing);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP FUNCTION of a procedure is refused", + sql: "DROP FUNCTION public.p();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSyncedProcedure()}}, + }, + { + name: "DROP PROCEDURE of a procedure", + sql: "DROP PROCEDURE public.p();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSyncedProcedure()}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP FUNCTION of a scalar signature leaves an array overload", + sql: "CREATE FUNCTION g(int[]) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(int);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP FUNCTION of an array signature", + sql: "CREATE FUNCTION g(int[]) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(int[]);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP FUNCTION matches a built-in type however it is written", + sql: "CREATE FUNCTION g(int4) RETURNS SETOF nokey LANGUAGE sql AS 'SELECT * FROM nokey';\nDROP FUNCTION g(integer);\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE FOREIGN TABLE of a name the schema holds is refused", + sql: "CREATE FOREIGN TABLE public.nokey (id int) SERVER s;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE FOREIGN TABLE IF NOT EXISTS of a name the schema holds does nothing", + sql: "CREATE FOREIGN TABLE IF NOT EXISTS public.nokey (id int) SERVER s;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a cascade of a parent of the same name in another schema leaves a child", + sql: "CREATE TABLE public.n (x int) INHERITS (s.parent);\nDROP TABLE other.parent CASCADE;", + targets: []review.Target{{Schema: database("public", schema("public"), schema("s", table("parent", "id integer")), schema("other", table("parent", "id integer")))}}, + want: []targetFinding{{0, "CREATE TABLE public.n (x int) INHERITS (s.parent)", "creates table public.n without a primary key", []int{0}}}, + }, + { + name: "a cascade of a renamed parent takes its child", + sql: "CREATE TABLE public.n (x int) INHERITS (s.parent);\nALTER TABLE s.parent RENAME TO p2;\nDROP TABLE s.p2 CASCADE;", + targets: []review.Target{{Schema: database("public", schema("public"), schema("s", table("parent", "id integer")), schema("other", table("parent", "id integer")))}}, + }, + { + name: "dropping a renamed key column removes the key", + sql: "ALTER TABLE k RENAME COLUMN id TO x;\nALTER TABLE k DROP COLUMN x;", + targets: []review.Target{{Schema: database("public", schema("public", withPrimaryKey(table("k", "id integer", "v integer"), "k_pkey", "id")))}}, + want: []targetFinding{{1, "DROP COLUMN x", "removes the primary key of k, and the change adds none back", []int{0}}}, + }, + { + name: "a mixed ALTER with a subcommand certain to fail is refused", + sql: "ALTER TABLE t DROP CONSTRAINT nope, ADD COLUMN id int;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE INDEX on a relation the target lacks is refused", + sql: "CREATE INDEX i ON public.missing (id);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE INDEX of a name the schema holds is refused", + sql: "CREATE INDEX nokey ON t (n);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE INDEX IF NOT EXISTS of a name the schema holds does nothing", + sql: "CREATE INDEX IF NOT EXISTS nokey ON t (n);\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TYPE of a table's name is refused", + sql: "CREATE TYPE public.nokey AS ENUM ('x');\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TYPE AS of a table's name is refused", + sql: "CREATE TYPE public.nokey AS (a int);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a foreign key to the table itself, qualified", + sql: "CREATE TABLE n (id int UNIQUE, parent int REFERENCES public.n (id));", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int UNIQUE, parent int REFERENCES public.n (id))", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a rename of a table the change made to a taken name is refused", + sql: "CREATE TABLE public.x (id int PRIMARY KEY);\nALTER TABLE public.x RENAME TO nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP TABLE of a view the change made is refused", + sql: "CREATE VIEW public.v AS SELECT 1;\nDROP TABLE public.v;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a second CREATE SCHEMA is refused", + sql: "CREATE SCHEMA z;\nCREATE SCHEMA z;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a second CREATE SCHEMA IF NOT EXISTS does nothing", + sql: "CREATE SCHEMA z;\nCREATE SCHEMA IF NOT EXISTS z;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE with a check on a column it lacks is refused", + sql: "CREATE TABLE x (a int CHECK (missing > 0));\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TABLE with a check on its column", + sql: "CREATE TABLE x (a int CHECK (a > 0));", + targets: one, + want: []targetFinding{{0, "CREATE TABLE x (a int CHECK (a > 0))", "creates table x without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE with a key on a column it lacks is refused", + sql: "CREATE TABLE x (a int, PRIMARY KEY (missing));\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TABLE with a key naming a column twice is refused", + sql: "CREATE TABLE x (a int, b int, UNIQUE (a, a));\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TABLE with two primary keys is refused", + sql: "CREATE TABLE x (a int PRIMARY KEY, b int, PRIMARY KEY (b));\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE FUNCTION of a signature the schema has is refused", + sql: "CREATE FUNCTION public.f() RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withNoArgFunctionReturning(shop(), "public", "nokey"), SessionUser: "alice"}}, + }, + { + name: "CREATE FUNCTION of a signature the change made is refused", + sql: "CREATE FUNCTION g() RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE FUNCTION g() RETURNS int LANGUAGE sql AS 'SELECT 2';\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a qualified DROP of another schema's table leaves a table made by the search path", + sql: "CREATE TABLE n (id int);\nDROP TABLE s.n;", + targets: []review.Target{{Schema: database(`"$user", public`, schema("public"), schema("s", table("n", "id integer"))), SessionUser: "alice"}}, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a table the search path puts in pg_temp is temporary", + sql: "SET search_path = pg_temp, public;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE VIEW reading an index is refused", + sql: "CREATE VIEW v AS SELECT * FROM public.t_pkey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE SCHEMA with an index on a table it lacks is refused", + sql: "CREATE SCHEMA z CREATE INDEX i ON missing (id);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE SCHEMA with an index on its table", + sql: "CREATE SCHEMA z CREATE TABLE x (id int PRIMARY KEY) CREATE INDEX i ON x (id);\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE FOREIGN TABLE with a column twice is refused", + sql: "CREATE FOREIGN TABLE x (a int, a int) SERVER s;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER FUNCTION RENAME of a function the target lacks is refused", + sql: "ALTER FUNCTION public.missing() RENAME TO f;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER PROCEDURE RENAME of a procedure the target lacks is refused", + sql: "ALTER PROCEDURE public.missing() RENAME TO f;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER FUNCTION SET SCHEMA of a function the target lacks is refused", + sql: "ALTER FUNCTION public.missing() SET SCHEMA s;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP NOT NULL of a primary key column is refused", + sql: "ALTER TABLE t ALTER COLUMN id DROP NOT NULL;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP NOT NULL of another column", + sql: "ALTER TABLE t ALTER COLUMN n DROP NOT NULL;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "dropping the key the change added leaves the table keyless", + sql: "CREATE TABLE n (id int);\nALTER TABLE n ADD PRIMARY KEY (id);\nALTER TABLE n DROP CONSTRAINT n_pkey;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "dropping a named key the change added leaves the table keyless", + sql: "CREATE TABLE n (id int);\nALTER TABLE n ADD CONSTRAINT n_pk PRIMARY KEY (id);\nALTER TABLE n DROP CONSTRAINT n_pk;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "dropping another constraint leaves the added key", + sql: "CREATE TABLE n (id int);\nALTER TABLE n ADD PRIMARY KEY (id);\nALTER TABLE n DROP CONSTRAINT IF EXISTS other;", + targets: one, + }, + { + name: "a second CREATE TYPE of a name is refused", + sql: "CREATE TYPE public.x AS ENUM ('a');\nCREATE TYPE public.x AS ENUM ('b');\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE OR REPLACE VIEW of a table the change made is refused", + sql: "CREATE TABLE public.x (id int PRIMARY KEY);\nCREATE OR REPLACE VIEW public.x AS SELECT 1;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER SEQUENCE of a sequence the target lacks is refused", + sql: "ALTER SEQUENCE public.missing RESTART;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER SEQUENCE IF EXISTS of a sequence the target lacks does nothing", + sql: "ALTER SEQUENCE IF EXISTS public.missing RESTART;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER SEQUENCE of a table is refused", + sql: "ALTER SEQUENCE public.nokey RESTART;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP TABLE of a table the change made that a new view reads is refused", + sql: "CREATE TABLE public.x (id int PRIMARY KEY);\nCREATE VIEW public.w AS SELECT * FROM public.x;\nDROP TABLE public.x;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a statement that may fail ends the scan", + sql: "ALTER TABLE t DROP CONSTRAINT nope, ADD CONSTRAINT x CHECK (n > 0);\nALTER TABLE t DROP CONSTRAINT x;\nCREATE TABLE fresh (id int);", + targets: one, + }, + { + name: "an added foreign key to a table the change made is checked against its CREATE", + sql: "CREATE TABLE a (id int PRIMARY KEY);\nALTER TABLE t ADD CONSTRAINT t_a_fk FOREIGN KEY (n) REFERENCES a;\nCREATE TABLE b (id int);", + targets: one, + want: []targetFinding{ + {2, "CREATE TABLE b (id int)", "creates table b without a primary key", []int{0}}, + }, + }, + { + name: "CREATE FUNCTION of a synced signature with arguments is refused", + sql: "CREATE FUNCTION public.f(integer) RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + }, + { + name: "CREATE FUNCTION of another signature", + sql: "CREATE FUNCTION public.f(text) RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER SEQUENCE RENAME of a table is refused", + sql: "ALTER SEQUENCE public.t RENAME TO x;\nCREATE TABLE n (id int);", + targets: one, + }, + { + // The server takes ALTER INDEX ... RENAME of any relation. + name: "ALTER INDEX RENAME of a table renames it", + sql: "ALTER INDEX public.nokey RENAME TO x;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER VIEW SET SCHEMA of a table is refused", + sql: "ALTER VIEW public.nokey SET SCHEMA s;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE INDEX on a column the table lacks is refused", + sql: "CREATE INDEX i ON t (missing);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP FUNCTION by a name several routines have is refused", + sql: "DROP FUNCTION public.f;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(withSignature(shop(), "f", "f(a integer)"), "f", "f(text)"), SessionUser: "alice"}}, + }, + { + name: "a view reading a table without naming a column holds its drop", + sql: "DROP TABLE base;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withCountView("SELECT count(*) AS c FROM base")}}, + }, + { + name: "a cascade takes a view reading a table without naming a column", + sql: "DROP TABLE base CASCADE;\nCREATE TABLE IF NOT EXISTS v (id int);", + targets: []review.Target{{Schema: withCountView("SELECT count(*) AS c FROM base")}}, + want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS v (id int)", "creates table v without a primary key", []int{0}}}, + }, + { + name: "a view whose definition the scan cannot read holds every drop", + sql: "DROP TABLE base;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withCountView("not sql at all")}}, + }, + { + name: "OWNED BY a column the table lacks is refused", + sql: "ALTER SEQUENCE q OWNED BY t.nope;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSequence(shop(), "q"), SessionUser: "alice"}}, + }, + { + name: "OWNED BY a table the target lacks is refused", + sql: "ALTER SEQUENCE q OWNED BY public.missing.id;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSequence(shop(), "q"), SessionUser: "alice"}}, + }, + { + name: "OWNED BY a column the table has", + sql: "ALTER SEQUENCE q OWNED BY t.n;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSequence(shop(), "q"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TYPE AS with an attribute twice is refused", + sql: "CREATE TYPE public.ct AS (a int, a text);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "RENAME CONSTRAINT of a constraint the snapshot lacks ends the scan", + sql: "ALTER TABLE public.t RENAME CONSTRAINT missing TO replacement;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "SET SCHEMA of a table the change made to a schema holding its name is refused", + sql: "CREATE TABLE public.x (id int PRIMARY KEY);\nALTER TABLE public.x SET SCHEMA s;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", schema("public"), schema("s", table("x", "id integer")))}}, + }, + { + name: "ALTER FUNCTION RENAME to a signature the schema has is refused", + sql: "ALTER FUNCTION public.f() RENAME TO g;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(withSignature(shop(), "f", "f()"), "g", "g()"), SessionUser: "alice"}}, + }, + { + name: "ALTER TABLE of a view the change made is refused", + sql: "CREATE VIEW public.v AS SELECT 1;\nALTER TABLE public.v ADD COLUMN x int;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP FUNCTION by the synced signature retires it", + sql: "DROP FUNCTION public.f(integer);\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := shop() + db.Schemas[0].Functions = append(db.Schemas[0].Functions, &metadata.FunctionMetadata{Name: "f", Signature: "f(a integer)", Definition: "CREATE OR REPLACE FUNCTION", DependencyTables: []*metadata.DependencyTable{{Schema: "public", Table: "nokey"}}}) + return db + }(), SessionUser: "alice"}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE OR REPLACE VIEW renaming a column is refused", + sql: "CREATE OR REPLACE VIEW public.v AS SELECT 1 AS b;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withViewColumns("a")}}, + }, + { + name: "CREATE OR REPLACE VIEW adding a column", + sql: "CREATE OR REPLACE VIEW public.v AS SELECT 1 AS a, 2 AS b;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withViewColumns("a")}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TRIGGER on a relation the target lacks is refused", + sql: "CREATE TRIGGER tr BEFORE INSERT ON public.missing FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "COMMENT ON a column the table lacks is refused", + sql: "COMMENT ON COLUMN t.nope IS 'x';\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "GRANT on a table the target lacks is refused", + sql: "GRANT SELECT ON public.missing TO PUBLIC;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "COMMENT ON a table the target has", + sql: "COMMENT ON TABLE t IS 'x';\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER FUNCTION RENAME of a signature the target lacks is refused", + sql: "ALTER FUNCTION public.f(text) RENAME TO g;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + }, + { + name: "ALTER FUNCTION RENAME of a signature the target has", + sql: "ALTER FUNCTION public.f(integer) RENAME TO g;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE OR REPLACE VIEW changing a column's type is refused", + sql: "CREATE OR REPLACE VIEW public.v AS SELECT 'x'::text AS a;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withTypedView("a", "integer")}}, + }, + { + name: "CREATE OR REPLACE VIEW keeping a column's type", + sql: "CREATE OR REPLACE VIEW public.v AS SELECT 1 AS a;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withTypedView("a", "integer")}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE VIEW naming a column twice is refused", + sql: "CREATE VIEW v (a, a) AS SELECT 1, 2;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE VIEW with two outputs of a name is refused", + sql: "CREATE VIEW v AS SELECT 1 AS a, 2 AS a;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER TABLE DROP COLUMN of a column a table the change made lacks is refused", + sql: "CREATE TABLE public.x (id int PRIMARY KEY);\nALTER TABLE public.x DROP COLUMN missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER TABLE ADD COLUMN to a table the change made", + sql: "CREATE TABLE public.x (id int PRIMARY KEY);\nALTER TABLE public.x ADD COLUMN y int;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "OWNED BY a column a table the change made lacks is refused", + sql: "CREATE TABLE public.t2 (id int PRIMARY KEY);\nCREATE SEQUENCE public.q;\nALTER SEQUENCE public.q OWNED BY public.t2.missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TRIGGER on a materialized view is refused", + sql: "CREATE MATERIALIZED VIEW public.mv AS SELECT 1 AS a;\nCREATE TRIGGER tr BEFORE INSERT ON public.mv FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "tf", "tf()"), SessionUser: "alice"}}, + }, + { + name: "ALTER FUNCTION SET SCHEMA to a schema the target lacks is refused", + sql: "ALTER FUNCTION public.f() SET SCHEMA missing;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f()"), SessionUser: "alice"}}, + }, + { + name: "CREATE TYPE AS ENUM with a label twice is refused", + sql: "CREATE TYPE public.e AS ENUM ('a', 'a');\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP CONSTRAINT of a name a table the change made did not give is refused", + sql: "CREATE TABLE public.x (id int PRIMARY KEY, CONSTRAINT c CHECK (id > 0));\nALTER TABLE public.x DROP CONSTRAINT missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "DROP CONSTRAINT of a name a table the change made gave", + sql: "CREATE TABLE public.x (id int PRIMARY KEY, CONSTRAINT c CHECK (id > 0));\nALTER TABLE public.x DROP CONSTRAINT c;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE SCHEMA with a view naming an output twice is refused", + sql: "CREATE SCHEMA z CREATE VIEW v AS SELECT 1 AS a, 2 AS a;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER PROCEDURE RENAME of a signature the procedures lack is refused", + sql: "ALTER PROCEDURE public.p(text) RENAME TO q;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := shop() + db.Schemas[0].Procedures = []*metadata.ProcedureMetadata{{Name: "p", Signature: "p(a integer)"}} + return db + }(), SessionUser: "alice"}}, + }, + { + name: "CREATE INDEX on a table the change made under a taken name is refused", + sql: "CREATE TABLE public.t2 (id int PRIMARY KEY);\nCREATE INDEX nokey ON public.t2 (id);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE INDEX on a column a table the change made lacks is refused", + sql: "CREATE TABLE public.t2 (id int PRIMARY KEY);\nCREATE INDEX i ON public.t2 (missing);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a function the change made follows SET SCHEMA", + sql: "CREATE FUNCTION public.f() RETURNS public.nokey LANGUAGE sql AS 'SELECT NULL::public.nokey';\nALTER FUNCTION public.f() SET SCHEMA s;\nDROP FUNCTION s.f();\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{4, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE SCHEMA with a trigger on a relation the target lacks is refused", + sql: "CREATE SCHEMA z CREATE TRIGGER tr BEFORE INSERT ON public.missing FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "tf", "tf()"), SessionUser: "alice"}}, + }, + { + name: "a generation expression's literal is no column", + sql: "ALTER TABLE g DROP COLUMN x;", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + g := withPrimaryKey(table("g", "x integer", "b text"), "g_pkey", "x") + g.Columns[1].Generation = &metadata.GenerationMetadata{Expression: "('x'::text)"} + return database("public", schema("public", g)) + }()}}, + want: []targetFinding{{0, "DROP COLUMN x", "removes the primary key of g, and the change adds none back", []int{0}}}, + }, + { + name: "CREATE VIEW with more column names than outputs is refused", + sql: "CREATE VIEW v (a, b) AS SELECT 1;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE SEQUENCE OWNED BY a table the target lacks is refused", + sql: "CREATE SEQUENCE public.q OWNED BY public.missing.id;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE TRIGGER of a name the table has is refused", + sql: "CREATE TRIGGER tr BEFORE INSERT ON public.t FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := withSignature(shop(), "tf", "tf()") + db.Schemas[0].Tables[1].Triggers = []*metadata.TriggerMetadata{{Name: "tr"}} + return db + }(), SessionUser: "alice"}}, + }, + { + name: "CREATE OR REPLACE TRIGGER of a name the table has", + sql: "CREATE OR REPLACE TRIGGER tr BEFORE INSERT ON public.t FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := withSignature(shop(), "tf", "tf()") + db.Schemas[0].Tables[1].Triggers = []*metadata.TriggerMetadata{{Name: "tr"}} + return db + }(), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP FUNCTION of a signature the target lacks is refused", + sql: "DROP FUNCTION public.f(text);\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + }, + { + name: "a procedure of a table's row type holds its drop", + sql: "DROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := shop() + db.Schemas[0].Procedures = []*metadata.ProcedureMetadata{{Name: "p", Signature: "p(x nokey)"}} + return db + }(), SessionUser: "alice"}}, + }, + { + name: "TRUNCATE of a view is refused", + sql: "TRUNCATE public.v;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withCountView("SELECT 1 AS a")}}, + }, + { + name: "a view the change made unqualified, dropped qualified, no longer holds a table", + sql: "CREATE VIEW v AS SELECT * FROM public.nokey;\nDROP VIEW public.v;\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TABLE with a generated column of a column it lacks is refused", + sql: "CREATE TABLE public.x (id int PRIMARY KEY, g int GENERATED ALWAYS AS (missing + 1) STORED);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE SCHEMA with a sequence owned by a table it lacks is refused", + sql: "CREATE SCHEMA z CREATE SEQUENCE q OWNED BY z.missing.id;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a second CREATE TRIGGER of a name on a table is refused", + sql: "CREATE TRIGGER tr BEFORE INSERT ON public.t FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TRIGGER tr BEFORE INSERT ON public.t FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "tf", "tf()"), SessionUser: "alice"}}, + }, + { + name: "CREATE TRIGGER after the change dropped the trigger", + sql: "CREATE TRIGGER tr BEFORE INSERT ON public.t FOR EACH ROW EXECUTE FUNCTION public.tf();\nDROP TRIGGER tr ON public.t;\nCREATE TRIGGER tr BEFORE INSERT ON public.t FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "tf", "tf()"), SessionUser: "alice"}}, + want: []targetFinding{{3, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE TRIGGER of a function only with arguments is refused", + sql: "CREATE TRIGGER tr BEFORE INSERT ON public.t FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "tf", "tf(a integer)"), SessionUser: "alice"}}, + }, + { + name: "dropping the key a table the change made was made with", + sql: "CREATE TABLE n (id int PRIMARY KEY);\nALTER TABLE n DROP CONSTRAINT n_pkey;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int PRIMARY KEY)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "dropping the named key a table the change made was made with", + sql: "CREATE TABLE n (id int CONSTRAINT n_pk PRIMARY KEY);\nALTER TABLE n DROP CONSTRAINT n_pk;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int CONSTRAINT n_pk PRIMARY KEY)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "UPDATE of a table the target lacks is refused", + sql: "UPDATE public.missing SET id = 1;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "SELECT of a table the target lacks is refused", + sql: "SELECT * FROM public.missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "UPDATE of a table the target has", + sql: "UPDATE t SET n = 1;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE INDEX on an expression of a column the table lacks is refused", + sql: "CREATE INDEX i ON public.t ((missing + 1));\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE INDEX on an expression of a column the table has", + sql: "CREATE INDEX i ON public.t ((n + 1));\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "SET SCHEMA where the table's index name is taken is refused", + sql: "ALTER TABLE public.k SET SCHEMA s;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: database("public", schema("public", withPrimaryKey(table("k", "id integer"), "k_pkey", "id")), schema("s", table("k_pkey", "id integer")))}}, + }, + { + name: "a procedure of a quoted table's row type holds its drop", + sql: "DROP TABLE public.\"NoKey\";\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := database("public", schema("public", table("NoKey", "id integer"))) + db.Schemas[0].Procedures = []*metadata.ProcedureMetadata{{Name: "p", Signature: `p(x public."NoKey")`}} + return db + }()}}, + }, + { + name: "CREATE DOMAIN with a check naming anything but VALUE is refused", + sql: "CREATE DOMAIN public.d AS int CHECK (missing > 0);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "CREATE DOMAIN with a check of VALUE", + sql: "CREATE DOMAIN public.d AS int CHECK (VALUE > 0);\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE OR REPLACE PROCEDURE of a function's signature is refused", + sql: "CREATE OR REPLACE PROCEDURE public.f() LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f()"), SessionUser: "alice"}}, + }, + { + name: "CREATE OR REPLACE FUNCTION of a procedure's signature is refused", + sql: "CREATE OR REPLACE FUNCTION public.p() RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSyncedProcedure(), SessionUser: "alice"}}, + }, + { + name: "CREATE OR REPLACE FUNCTION of a function's signature", + sql: "CREATE OR REPLACE FUNCTION public.f() RETURNS int LANGUAGE sql AS 'SELECT 1';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f()"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER TYPE ADD VALUE of a type the target lacks is refused", + sql: "ALTER TYPE public.missing ADD VALUE 'x';\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER TYPE ADD VALUE of a table's row type is refused", + sql: "ALTER TYPE public.t ADD VALUE 'x';\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "ALTER TYPE ADD VALUE of an enum", + sql: "ALTER TYPE mood ADD VALUE 'x';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := shop() + db.Schemas[0].EnumTypes = []*metadata.EnumTypeMetadata{{Name: "mood"}} + return db + }(), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP INDEX of a renamed constraint index is refused", + sql: "ALTER INDEX public.t_pkey RENAME TO x;\nDROP INDEX public.x;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a trigger of CREATE SCHEMA on its own table", + sql: "CREATE SCHEMA z CREATE TABLE t (id int) CREATE TRIGGER tr BEFORE INSERT ON z.t FOR EACH ROW EXECUTE FUNCTION public.tf();", + targets: []review.Target{{Schema: withSignature(shop(), "tf", "tf()"), SessionUser: "alice"}}, + want: []targetFinding{{0, "CREATE TABLE t (id int)", "creates table z.t without a primary key", []int{0}}}, + }, + { + name: "DROP TRIGGER on one table leaves the trigger of the name on another", + sql: "CREATE TRIGGER tr BEFORE INSERT ON public.t FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TRIGGER tr BEFORE INSERT ON public.p FOR EACH ROW EXECUTE FUNCTION public.tf();\nDROP TRIGGER tr ON public.t;\nCREATE TRIGGER tr BEFORE INSERT ON public.p FOR EACH ROW EXECUTE FUNCTION public.tf();\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "tf", "tf()"), SessionUser: "alice"}}, + }, + { + name: "ALTER TYPE SET SCHEMA to a schema the target lacks is refused", + sql: "ALTER TYPE public.mood SET SCHEMA missing;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := shop() + db.Schemas[0].EnumTypes = []*metadata.EnumTypeMetadata{{Name: "mood"}} + return db + }(), SessionUser: "alice"}}, + }, + { + name: "a renamed key of a table the change made, then dropped", + sql: "CREATE TABLE n (id int PRIMARY KEY);\nALTER TABLE n RENAME CONSTRAINT n_pkey TO x;\nALTER TABLE n DROP CONSTRAINT x;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int PRIMARY KEY)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a renamed synced key, then dropped", + sql: "ALTER TABLE public.t RENAME CONSTRAINT t_pkey TO x;\nALTER TABLE public.t DROP CONSTRAINT x;", + targets: one, + want: []targetFinding{{1, "DROP CONSTRAINT x", "removes the primary key of public.t, and the change adds none back", []int{0}}}, + }, + { + name: "a renamed constraint is gone under its old name", + sql: "ALTER TABLE public.t RENAME CONSTRAINT t_n_check TO x;\nALTER TABLE public.t DROP CONSTRAINT t_n_check;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a renamed key still keys its table", + sql: "ALTER TABLE public.t RENAME CONSTRAINT t_pkey TO x;\nALTER TABLE public.t ADD PRIMARY KEY (code);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a renamed key's index takes the new name", + sql: "ALTER TABLE public.t RENAME CONSTRAINT t_pkey TO x;\nCREATE TABLE public.x (id int);", + targets: one, + }, + { + name: "a renamed key's index frees the old name", + sql: "ALTER TABLE public.t RENAME CONSTRAINT t_pkey TO x;\nCREATE TABLE public.t_pkey (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE public.t_pkey (id int)", "creates table public.t_pkey without a primary key", []int{0}}}, + }, + { + name: "a renamed table frees its name", + sql: "ALTER TABLE public.nokey RENAME TO nk;\nCREATE TABLE public.nokey (a int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE public.nokey (a int)", "creates table public.nokey without a primary key", []int{0}}}, + }, + { + name: "a renamed table is gone under its old name", + sql: "ALTER TABLE public.nokey RENAME TO nk;\nDROP TABLE public.nokey;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a renamed routine is gone under its old name", + sql: "ALTER FUNCTION public.f(integer) RENAME TO g;\nDROP FUNCTION public.f(integer);\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + }, + { + name: "a renamed routine is gone under its old name on the path", + sql: "ALTER FUNCTION f(integer) RENAME TO g;\nDROP FUNCTION f(integer);\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + }, + { + name: "DROP FUNCTION IF EXISTS of a renamed routine does nothing", + sql: "ALTER FUNCTION public.f(integer) RENAME TO g;\nDROP FUNCTION IF EXISTS public.f(integer);\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a rename leaves the routine's other signatures", + sql: "ALTER FUNCTION public.f(integer) RENAME TO g;\nDROP FUNCTION public.f(text);\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(withSignature(shop(), "f", "f(a integer)"), "f", "f(text)"), SessionUser: "alice"}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "COPY of a relation the target lacks is refused", + sql: "COPY public.missing TO STDOUT;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "COPY of a query reading a relation the target lacks is refused", + sql: "COPY (SELECT id FROM public.missing) TO STDOUT;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "COPY TO of a view is refused", + sql: "COPY public.v TO STDOUT;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withView(shop(), "public", "v"), SessionUser: "alice"}}, + }, + { + name: "COPY of a sequence is refused", + sql: "COPY public.sq TO STDOUT;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSequence(shop(), "sq"), SessionUser: "alice"}}, + }, + { + name: "COPY TO of a table goes on", + sql: "COPY public.t TO STDOUT;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "dropping a key column of a new table drops its key", + sql: "CREATE TABLE n (id int PRIMARY KEY, x int);\nALTER TABLE n DROP COLUMN id;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int PRIMARY KEY, x int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a key column of a new table a policy reads", + sql: "CREATE TABLE n (id int PRIMARY KEY, x int);\nCREATE POLICY p ON n USING (id > 0);\nALTER TABLE n DROP COLUMN id;\nCREATE TABLE m (id int);", + targets: one, + }, + { + name: "a key column of a new table, dropped with an added column", + sql: "CREATE TABLE n (id int PRIMARY KEY, x int);\nALTER TABLE n DROP COLUMN id, ADD COLUMN y int;", + targets: one, + }, + { + name: "a temporary table in a regular schema is refused", + sql: "CREATE TEMP TABLE public.x (id int);\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a column type in a schema the target lacks is refused", + sql: "CREATE TABLE public.n (x missing.foo);", + targets: one, + }, + { + name: "a column type in a system schema", + sql: "CREATE TABLE public.n (x pg_catalog.int4);", + targets: one, + want: []targetFinding{{0, "CREATE TABLE public.n (x pg_catalog.int4)", "creates table public.n without a primary key", []int{0}}}, + }, + { + name: "a new table shadows another on the path", + sql: "SET search_path = public, s;\nCREATE TABLE s.n (id int);\nCREATE TABLE public.n (id int PRIMARY KEY);\nDROP TABLE n;", + targets: one, + want: []targetFinding{{1, "CREATE TABLE s.n (id int)", "creates table s.n without a primary key", []int{0}}}, + }, + { + name: "a key added with its column, then dropped", + sql: "CREATE TABLE n (x int);\nALTER TABLE n ADD COLUMN id int CONSTRAINT n_pk PRIMARY KEY;\nALTER TABLE n DROP CONSTRAINT n_pk;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (x int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a key added to a new table, then its column dropped", + sql: "CREATE TABLE n (id int, x int);\nALTER TABLE n ADD PRIMARY KEY (id);\nALTER TABLE n DROP COLUMN id;", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int, x int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "REINDEX of a relation the target lacks is refused", + sql: "REINDEX TABLE public.missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "REINDEX INDEX of a table is refused", + sql: "REINDEX INDEX public.t;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "REINDEX of a table goes on", + sql: "REINDEX TABLE public.t;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER FUNCTION of a signature the target lacks is refused", + sql: "ALTER FUNCTION public.f(text) IMMUTABLE;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + }, + { + name: "ALTER PROCEDURE of a function is refused", + sql: "ALTER PROCEDURE public.f(integer) SECURITY DEFINER;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + }, + { + name: "ALTER FUNCTION of a synced function goes on", + sql: "ALTER FUNCTION public.f(integer) IMMUTABLE;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withSignature(shop(), "f", "f(a integer)"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "NOT VALID on a table's primary key is refused", + sql: "CREATE TABLE n (id int, PRIMARY KEY (id) NOT VALID);\nCREATE TABLE m (id int);", + targets: one, + }, + { + name: "NOT VALID on a table's check goes on", + sql: "CREATE TABLE n (id int, CHECK (id > 0) NOT VALID);", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int, CHECK (id > 0) NOT VALID)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "CREATE SCHEMA makes its tables before their indexes", + sql: "CREATE SCHEMA z CREATE INDEX i ON x (id) CREATE TABLE x (id int);", + targets: one, + want: []targetFinding{{0, "CREATE TABLE x (id int)", "creates table z.x without a primary key", []int{0}}}, + }, + { + name: "DROP EXTENSION IF EXISTS of an extension the target lacks does nothing", + sql: "DROP EXTENSION IF EXISTS missing;\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "DROP EVENT TRIGGER of one the target lacks is refused", + sql: "DROP EVENT TRIGGER missing;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "COMMENT ON a column a view lacks is refused", + sql: "COMMENT ON COLUMN public.v.missing IS 'x';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withViewColumns("a")}}, + }, + { + name: "COMMENT ON a column a view has", + sql: "COMMENT ON COLUMN public.v.a IS 'x';\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withViewColumns("a")}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "ALTER SCHEMA OWNER of a schema the target lacks is refused", + sql: "ALTER SCHEMA missing OWNER TO alice;\nCREATE TABLE n (id int);", + targets: one, + }, + { + name: "a cascade does not go past a view the change replaced", + sql: "CREATE OR REPLACE VIEW v AS SELECT 1 AS id;\nDROP TABLE base CASCADE;\nCREATE TABLE w (id int);", + targets: []review.Target{{Schema: database("public", &metadata.SchemaMetadata{ + Name: "public", + Tables: []*metadata.TableMetadata{table("base", "id integer")}, + Views: []*metadata.ViewMetadata{ + {Name: "v", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "base", Column: "id"}}}, + {Name: "w", DependencyColumns: []*metadata.DependencyColumn{{Schema: "public", Table: "v", Column: "id"}}}, + }, + })}}, + }, + { + name: "DROP SCHEMA of a schema that holds a table is refused", + sql: "CREATE TABLE n (id int);\nDROP SCHEMA s;", + targets: one, + }, + { + name: "reowning a sequence in one schema leaves another's", + sql: "ALTER SEQUENCE s.q OWNED BY NONE;\nDROP TABLE public.base;\nCREATE TABLE public.q (id int);", + targets: []review.Target{{Schema: database("public", + &metadata.SchemaMetadata{Name: "public", Tables: []*metadata.TableMetadata{table("base", "id integer")}, Sequences: []*metadata.SequenceMetadata{{Name: "q", OwnerTable: "base", OwnerColumn: "id"}}}, + &metadata.SchemaMetadata{Name: "s", Sequences: []*metadata.SequenceMetadata{{Name: "q"}}}, + )}}, + want: []targetFinding{{2, "CREATE TABLE public.q (id int)", "creates table public.q without a primary key", []int{0}}}, + }, + { + name: "an sql_drop event trigger does not fire on an ALTER TABLE that drops nothing", + sql: "ALTER TABLE t ADD COLUMN x int, ALTER COLUMN n SET DEFAULT 1;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "SQL_DROP"), SessionUser: "alice"}}, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "an sql_drop event trigger fires on DROP DEFAULT", + sql: "ALTER TABLE t ALTER COLUMN n DROP DEFAULT;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "SQL_DROP"), SessionUser: "alice"}}, + }, + { + name: "an sql_drop event trigger fires on a DROP the scan does not name", + sql: "DROP DOMAIN d;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "SQL_DROP", "DROP TABLE"), SessionUser: "alice"}}, + }, + { + name: "a tag filter fires on DDL the scan has no tag for", + sql: "CREATE POLICY pol ON t USING (true);\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "DDL_COMMAND_END", "CREATE INDEX"), SessionUser: "alice"}}, + }, + { + name: "a tag filter matches the tags the scan names", + sql: "COMMENT ON TABLE t IS 'x';\nGRANT SELECT ON t TO PUBLIC;\nCREATE TABLE n (id int);", + targets: []review.Target{{Schema: withEvent(shop(), "DDL_COMMAND_END", "CREATE INDEX"), SessionUser: "alice"}}, + want: []targetFinding{{2, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "an sql_drop event trigger does not fire on CREATE TABLE", + sql: "CREATE TABLE n (id int);", + targets: []review.Target{{Schema: func() *metadata.DatabaseSchemaMetadata { + db := shop() + db.EventTriggers = []*metadata.EventTriggerMetadata{{Name: "et", Event: "SQL_DROP", Enabled: true}} + return db + }(), SessionUser: "alice"}}, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "a cascading drop of a parent may drop a child", + sql: "CREATE TABLE child (x int) INHERITS (nokey);\nCREATE TABLE other (x int);\nDROP TABLE nokey CASCADE;", + targets: one, + want: []targetFinding{{1, "CREATE TABLE other (x int)", "creates table other without a primary key", []int{0}}}, + }, + { + name: "a table dropped again is not reported", + sql: "CREATE TABLE n (id int);\nDROP TABLE n;", + targets: one, + }, + { + name: "CREATE TABLE AS, SELECT INTO, an existing table under IF NOT EXISTS, and a partition are not reported", + sql: "CREATE TABLE a AS SELECT 1 AS x;\nSELECT 1 AS x INTO b;\nCREATE TABLE IF NOT EXISTS nokey (a int);\nCREATE TABLE pt (id int PRIMARY KEY) PARTITION BY RANGE (id);\nCREATE TABLE pt1 PARTITION OF pt FOR VALUES FROM (1) TO (10);", + targets: one, + }, + { + name: "a partitioned table is a table", + sql: "CREATE TABLE pt (id int) PARTITION BY RANGE (id);", + targets: one, + want: []targetFinding{{0, "CREATE TABLE pt (id int) PARTITION BY RANGE (id)", "creates table pt without a primary key", []int{0}}}, + }, + { + name: "LIKE copies the key only when it includes indexes", + sql: "CREATE TABLE a (LIKE t INCLUDING ALL);\nCREATE TABLE b (LIKE t INCLUDING INDEXES);\nCREATE TABLE c (LIKE t);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE c (LIKE t)", "creates table c without a primary key", []int{0}}}, + }, + { + name: "a table that had a key and lost it", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nALTER TABLE p DROP COLUMN id CASCADE;", + targets: one, + want: []targetFinding{ + {0, "DROP CONSTRAINT t_pkey CASCADE", "removes the primary key of t, and the change adds none back", []int{0}}, + {1, "DROP COLUMN id CASCADE", "removes the primary key of p, and the change adds none back", []int{0}}, + }, + }, + { + name: "a key dropped and added back", + sql: "ALTER TABLE t DROP CONSTRAINT t_pkey CASCADE;\nALTER TABLE t ADD PRIMARY KEY (id);", + targets: one, + }, + { + name: "a table without a key before the change is never reported", + sql: "ALTER TABLE nokey ADD PRIMARY KEY (a);\nALTER TABLE nokey DROP CONSTRAINT nokey_pkey;", + targets: one, + }, + { + name: "pg_dump's search path, keys added at the end", + sql: "SELECT pg_catalog.set_config('search_path', '', false);\nCREATE TABLE public.n (id integer NOT NULL);\nCREATE TABLE public.m (id integer NOT NULL);\nALTER TABLE ONLY public.n ADD CONSTRAINT n_pkey PRIMARY KEY (id);", + targets: one, + want: []targetFinding{{2, "CREATE TABLE public.m (id integer NOT NULL)", "creates table public.m without a primary key", []int{0}}}, + }, + { + name: "an unknown search path does not hide a new table", + sql: "SELECT set_config('search_path', 'public', true);\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "set_config of another setting changes nothing", + sql: "SELECT set_config('lock_timeout', '1s', false);\nCREATE TABLE n (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "procedural code anywhere leaves the end unknown", + sql: "CREATE TABLE n (id int);\nDO $$ BEGIN ALTER TABLE n ADD PRIMARY KEY (id); END $$;", + targets: one, + }, + { + name: "a rollback leaves the end unknown", + sql: "BEGIN;\nCREATE TABLE n (id int);\nROLLBACK;", + targets: one, + }, + { + name: "a temporary table is not reported", + sql: "CREATE TEMP TABLE n (id int);\nCREATE TABLE m (id int);", + targets: one, + want: []targetFinding{{1, "CREATE TABLE m (id int)", "creates table m without a primary key", []int{0}}}, + }, + { + name: "a statement on another table leaves a new one as it is", + sql: "CREATE TABLE n (id int);\nALTER TABLE IF EXISTS nope ADD COLUMN a int;\nALTER TABLE n ADD COLUMN b int, ADD CONSTRAINT n_b UNIQUE (b);", + targets: one, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + { + name: "no schema: a change that stands on its own is still known", + sql: "CREATE TABLE n (id int);\nCREATE TABLE m (id int);\nALTER TABLE m ADD PRIMARY KEY (id);", + targets: []review.Target{{}}, + want: []targetFinding{{0, "CREATE TABLE n (id int)", "creates table n without a primary key", []int{0}}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := runTargetRule(t, review.RequirePrimaryKey, tt.sql, review.Change{}, tt.targets) + if diff := cmp.Diff(tt.want, got, cmp.AllowUnexported(targetFinding{})); diff != "" { + t.Errorf("findings mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestPriorBackup(t *testing.T) { + withArchive := func(db *metadata.DatabaseSchemaMetadata) *metadata.DatabaseSchemaMetadata { + db.Schemas = append(db.Schemas, schema(backupSchema)) + return db + } + on := review.Change{PriorBackup: true, MaxBackupSize: 2 << 20} + one := []review.Target{{Schema: withArchive(shop()), SessionUser: "alice"}} + tests := []struct { + name string + sql string + change review.Change + targets []review.Target + want []targetFinding + }{ + { + name: "backup off", + sql: "UPDATE t SET n = 1 WHERE id = 1; DELETE FROM t WHERE id = 2;", + change: review.Change{MaxBackupSize: 10}, + targets: []review.Target{{Schema: shop()}}, + }, + { + name: "over the size limit, with or without a target", + sql: "UPDATE t SET n = 1 WHERE id = 1;", + change: review.Change{PriorBackup: true, MaxBackupSize: 10}, + targets: []review.Target{{}, {Schema: withArchive(shop())}}, + want: []targetFinding{{-1, "", "the change is 32 bytes, over the 10-byte limit of prior backup", []int{0, 1}}}, + }, + { + name: "no backup schema", + sql: "UPDATE t SET n = 1 WHERE id = 1;", + change: on, + targets: []review.Target{{Schema: shop()}, {Schema: withArchive(shop())}}, + want: []targetFinding{{-1, "", "prior backup needs schema bbdataarchive, which does not exist", []int{0}}}, + }, + { + name: "no backup schema, nothing to back up", + sql: "ALTER TABLE t ADD COLUMN x int; INSERT INTO t (id) VALUES (1); WITH d AS (DELETE FROM t RETURNING *) SELECT 1;", + change: on, + targets: []review.Target{{Schema: shop()}}, + }, + { + name: "UPDATE and DELETE on one table, however it is named", + sql: "UPDATE t SET n = 1 WHERE id = 1;\nUPDATE public.t SET n = 2 WHERE id = 2;\nDELETE FROM public.t WHERE id = 3;\nDELETE FROM t WHERE id = 4;\nDELETE FROM s.t WHERE id = 5;\nUPDATE s.t SET id = 1 WHERE id = 6;", + change: on, + targets: one, + want: []targetFinding{ + {2, "DELETE FROM public.t WHERE id = 3", "prior backup cannot back up both UPDATE and DELETE on public.t", []int{0}}, + {5, "UPDATE s.t SET id = 1 WHERE id = 6", "prior backup cannot back up both UPDATE and DELETE on s.t", []int{0}}, + }, + }, + { + name: "SET search_path moves the lookup", + sql: "UPDATE t SET id = 1 WHERE id = 1;\nSET search_path = s;\nDELETE FROM t WHERE id = 2;", + change: on, + targets: one, + }, + { + name: "the lookup follows the synced path, not the session user", + sql: "UPDATE t SET id = 1 WHERE id = 1;\nDELETE FROM s.t WHERE id = 2;", + change: on, + targets: []review.Target{{Schema: withArchive(database(`"$user", s, public`, shop().Schemas...)), SessionUser: "public"}}, + want: []targetFinding{{1, "DELETE FROM s.t WHERE id = 2", "prior backup cannot back up both UPDATE and DELETE on s.t", []int{0}}}, + }, + { + name: "a table the change creates", + sql: "CREATE TABLE n (id int);\nUPDATE n SET id = 1 WHERE id = 2;\nCREATE TEMP TABLE tmp (id int);\nDELETE FROM tmp WHERE id = 1;\nCREATE TABLE s.m (id int);\nDELETE FROM s.m WHERE id = 1;", + change: on, + targets: one, + want: []targetFinding{ + {1, "UPDATE n SET id = 1 WHERE id = 2", "prior backup runs before the change, when n does not exist yet", []int{0}}, + {3, "DELETE FROM tmp WHERE id = 1", "prior backup runs before the change, when tmp does not exist yet", []int{0}}, + {5, "DELETE FROM s.m WHERE id = 1", "prior backup runs before the change, when s.m does not exist yet", []int{0}}, + }, + }, + { + name: "a table the change moves into the schema", + sql: "ALTER TABLE nokey SET SCHEMA s;\nUPDATE s.nokey SET a = 1 WHERE a = 2;", + change: on, + targets: one, + want: []targetFinding{{1, "UPDATE s.nokey SET a = 1 WHERE a = 2", "prior backup runs before the change, when s.nokey does not exist yet", []int{0}}}, + }, + { + name: "a table CREATE SCHEMA creates", + sql: "CREATE SCHEMA z CREATE TABLE t (id int) CREATE VIEW v AS SELECT 1 AS a;\nUPDATE z.t SET id = 1 WHERE id = 2;\nDELETE FROM z.v WHERE a = 1;", + change: on, + targets: one, + want: []targetFinding{ + {1, "UPDATE z.t SET id = 1 WHERE id = 2", "prior backup runs before the change, when z.t does not exist yet", []int{0}}, + {2, "DELETE FROM z.v WHERE a = 1", "prior backup runs before the change, when z.v does not exist yet", []int{0}}, + }, + }, + { + name: "an unqualified creation named with its schema", + sql: "CREATE TABLE n (id int);\nUPDATE public.n SET id = 1 WHERE id = 2;", + change: on, + targets: one, + want: []targetFinding{{1, "UPDATE public.n SET id = 1 WHERE id = 2", "prior backup runs before the change, when public.n does not exist yet", []int{0}}}, + }, + { + name: "a table the change creates after the statement", + sql: "UPDATE n SET id = 1 WHERE id = 2;\nCREATE TABLE n (id int);", + change: on, + targets: one, + want: []targetFinding{{0, "UPDATE n SET id = 1 WHERE id = 2", "prior backup runs before the change, when n does not exist yet", []int{0}}}, + }, + { + name: "a table of a schema named after its owner", + sql: "CREATE SCHEMA AUTHORIZATION bob CREATE TABLE t (id int);\nUPDATE bob.t SET id = 1 WHERE id = 2;", + change: on, + targets: one, + want: []targetFinding{{1, "UPDATE bob.t SET id = 1 WHERE id = 2", "prior backup runs before the change, when bob.t does not exist yet", []int{0}}}, + }, + { + name: "a name qualified with the current database", + sql: "CREATE TABLE db.public.n (id int);\nUPDATE db.public.n SET id = 1 WHERE id = 2;", + change: on, + targets: one, + want: []targetFinding{{1, "UPDATE db.public.n SET id = 1 WHERE id = 2", "prior backup runs before the change, when db.public.n does not exist yet", []int{0}}}, + }, + { + name: "SELECT INTO on a branch of a set operation", + sql: "SELECT 1 AS id INTO n UNION SELECT 2;\nUPDATE n SET id = 3 WHERE id = 1;", + change: on, + targets: one, + want: []targetFinding{{1, "UPDATE n SET id = 3 WHERE id = 1", "prior backup runs before the change, when n does not exist yet", []int{0}}}, + }, + { + name: "EXPLAIN ANALYZE of CREATE TABLE AS", + sql: "EXPLAIN ANALYZE CREATE TABLE n AS SELECT 1 AS id;\nUPDATE n SET id = 2 WHERE id = 1;", + change: on, + targets: one, + want: []targetFinding{{1, "UPDATE n SET id = 2 WHERE id = 1", "prior backup runs before the change, when n does not exist yet", []int{0}}}, + }, + { + name: "a table the change recreates still exists when the backup runs", + sql: "DROP TABLE t;\nCREATE TABLE t (id int);\nDELETE FROM t WHERE id = 1;", + change: on, + targets: one, + }, + { + name: "a table the target lacks that the change does not create is left to the server", + sql: "UPDATE nope SET a = 1 WHERE a = 2;\nCREATE TABLE IF NOT EXISTS maybe (a int);\nDELETE FROM maybe WHERE a = 1;", + change: on, + targets: one, + }, + { + name: "no schema, nothing known about the target", + sql: "UPDATE t SET n = 1 WHERE id = 1; DELETE FROM t WHERE id = 2;", + change: on, + targets: []review.Target{{}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := runTargetRule(t, review.PriorBackup, tt.sql, tt.change, tt.targets) + if diff := cmp.Diff(tt.want, got, cmp.AllowUnexported(targetFinding{})); diff != "" { + t.Errorf("findings mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestTargetsScanTheSameStatements reviews one change against targets that +// differ only in session user, so each scans the shared statements at the +// same time. Every finding must list every target: a scan that kept state +// in a statement's nodes would make one target see another's. Run with +// -race to catch the write itself. +func TestTargetsScanTheSameStatements(t *testing.T) { + sql := `CREATE TABLE a (id serial, b int GENERATED ALWAYS AS (id * 2) STORED, c text DEFAULT 'x' CHECK (c <> ''), p_id int REFERENCES p (id)); +CREATE TABLE b (LIKE t INCLUDING ALL, extra int); +CREATE TABLE c (LIKE t); +CREATE INDEX a_c ON a (lower(c)) WHERE id > 0; +CREATE VIEW v AS SELECT a.id, t.code FROM a JOIN t ON t.id = a.id WHERE a.c = 'y'; +CREATE FUNCTION f(x int) RETURNS int LANGUAGE sql AS 'SELECT x + 1'; +SELECT id INTO d FROM a WHERE id > 1; +CREATE TABLE e AS SELECT f(id) AS id FROM a; +ALTER TABLE a ADD COLUMN z int DEFAULT 1, ALTER COLUMN c SET NOT NULL, ADD CONSTRAINT a_z CHECK (z > 0); +ALTER TABLE t DROP CONSTRAINT t_n_check, DROP CONSTRAINT t_pkey CASCADE, ADD COLUMN w int; +ALTER TABLE c ADD PRIMARY KEY (id); +COMMENT ON TABLE a IS 'a'; +CREATE TABLE pt (id int, k int) PARTITION BY RANGE (id); +CREATE TABLE pt1 PARTITION OF pt FOR VALUES FROM (1) TO (10);` + var targets []review.Target + var all []int + for i := range 8 { + targets = append(targets, review.Target{Schema: shop(), SessionUser: "user" + string(rune('a'+i))}) + all = append(all, i) + } + result, err := Review(context.Background(), sql, review.Options{}, targets) + if err != nil { + t.Fatal(err) + } + if len(result.Failures) != 0 { + t.Fatalf("Failures = %v", result.Failures) + } + var walked int + for _, f := range result.Findings { + if f.Rule == review.DisallowDropConstraint || f.Rule == review.RequirePrimaryKey { + walked++ + } + if diff := cmp.Diff(all, f.Targets); diff != "" { + t.Errorf("%s %q: targets (-want +got):\n%s", f.Rule, f.Message, diff) + } + } + if walked != 5 { + t.Errorf("got %d findings from the scan, want 5: %+v", walked, result.Findings) + } +} diff --git a/pg/review/targets.go b/pg/review/targets.go new file mode 100644 index 00000000..a87f8e67 --- /dev/null +++ b/pg/review/targets.go @@ -0,0 +1,186 @@ +package review + +import ( + "context" + "fmt" + "runtime" + "slices" + "sync" + + "github.com/bytebase/omni/metadata" + "github.com/bytebase/omni/pg/ast" + "github.com/bytebase/omni/review" +) + +// targetWork reports which of the rules that read a target have +// something to look at in this change: the prior backup check when the +// change asks for a backup and has an UPDATE or DELETE to back up, and +// the scan when a statement can give it a finding. The others depend on +// the SQL alone and run once for every target. +func targetWork(stmts []statement, on map[review.Rule]bool, change review.Change) (backup, scans bool) { + if on[review.PriorBackup] && change.PriorBackup { + for i := range stmts { + switch stmts[i].node.(type) { + case *ast.UpdateStmt, *ast.DeleteStmt: + backup = true + } + } + } + return backup, needsScan(stmts, on) +} + +// needsScan reports whether a statement of the change can give the scan +// something to report: a CREATE TABLE, alone or in CREATE SCHEMA, for +// RequirePrimaryKey, or an ALTER TABLE dropping a constraint or a column +// for either rule. +func needsScan(stmts []statement, on map[review.Rule]bool) bool { + if !on[review.DisallowDropConstraint] && !on[review.RequirePrimaryKey] { + return false + } + for i := range stmts { + switch v := stmts[i].node.(type) { + case *ast.CreateStmt: + if on[review.RequirePrimaryKey] { + return true + } + case *ast.CreateSchemaStmt: + if on[review.RequirePrimaryKey] && v.SchemaElts != nil && slices.ContainsFunc(v.SchemaElts.Items, func(n ast.Node) bool { + _, ok := n.(*ast.CreateStmt) + return ok + }) { + return true + } + case *ast.AlterTableStmt: + if v.Cmds == nil { + continue + } + for _, item := range v.Cmds.Items { + cmd, ok := item.(*ast.AlterTableCmd) + if !ok { + continue + } + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_DropConstraint: + return true + case ast.AT_DropColumn: + if on[review.RequirePrimaryKey] { + return true + } + } + } + } + } + return false +} + +// targetGroup is the targets with the same inputs, reviewed once. +type targetGroup struct { + indices []int + target review.Target +} + +// groupTargets folds targets passing the same schema and session user into +// one group each, in first-seen order. The rules read nothing else of a +// target. Equal schemas passed as different values are reviewed apart: +// comparing them would cost more than reviewing them. +func groupTargets(targets []review.Target) []targetGroup { + type key struct { + schema *metadata.DatabaseSchemaMetadata + user string + } + var groups []targetGroup + byKey := make(map[key]int) + for i, t := range targets { + k := key{t.Schema, t.SessionUser} + g, ok := byKey[k] + if !ok { + g = len(groups) + byKey[k] = g + groups = append(groups, targetGroup{target: t}) + } + groups[g].indices = append(groups[g].indices, i) + } + return groups +} + +// reviewTargets evaluates the rules that read a target: each distinct +// target once, concurrently, then the findings merged by (rule, +// statement, range, message) so equal findings from different targets +// become one finding listing them all. A group that could not be +// evaluated becomes a failure per target. +func reviewTargets(ctx context.Context, stmts []statement, on map[review.Rule]bool, backup, scans bool, targets []review.Target) ([]review.Finding, []review.TargetFailure, error) { + groups := groupTargets(targets) + type outcome struct { + findings []review.Finding + err error + } + outcomes := make([]outcome, len(groups)) + sem := make(chan struct{}, max(1, min(len(groups), runtime.GOMAXPROCS(0)))) + var wg sync.WaitGroup + for i := range groups { + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + findings, err := reviewGroup(ctx, stmts, on, backup, scans, groups[i]) + outcomes[i] = outcome{findings: findings, err: err} + }() + } + wg.Wait() + if err := ctx.Err(); err != nil { + return nil, nil, err + } + + type key struct { + rule review.Rule + statement int + rng review.Range + message string + } + merged := make(map[key]int) + var findings []review.Finding + var failures []review.TargetFailure + for i, o := range outcomes { + if o.err != nil { + for _, t := range groups[i].indices { + failures = append(failures, review.TargetFailure{Target: t, Err: o.err}) + } + continue + } + for _, f := range o.findings { + k := key{f.Rule, f.Statement, f.Range, f.Message} + if j, ok := merged[k]; ok { + findings[j].Targets = append(findings[j].Targets, f.Targets...) + continue + } + merged[k] = len(findings) + findings = append(findings, f) + } + } + for i := range findings { + slices.Sort(findings[i].Targets) + } + slices.SortFunc(failures, func(a, b review.TargetFailure) int { return a.Target - b.Target }) + return findings, failures, nil +} + +// reviewGroup evaluates one distinct target. A panic is the group's +// failure, not the review's. +func reviewGroup(ctx context.Context, stmts []statement, on map[review.Rule]bool, backup, scans bool, g targetGroup) (findings []review.Finding, err error) { + defer func() { + if p := recover(); p != nil { + findings, err = nil, fmt.Errorf("review panicked: %v", p) + } + }() + r := &reporter{targets: g.indices} + if backup { + checkPriorBackupTarget(stmts, g.target.Schema, r) + } + if scans { + if err := scanTarget(ctx, stmts, on, g.target, r); err != nil { + return nil, err + } + } + return r.findings, nil +} diff --git a/pg/review/type_name_oracle_test.go b/pg/review/type_name_oracle_test.go index 5d2231e5..b11e1969 100644 --- a/pg/review/type_name_oracle_test.go +++ b/pg/review/type_name_oracle_test.go @@ -21,7 +21,7 @@ import ( // when the search path finds a type of that name before pg_catalog. func TestTypeNameMatchesFormatType(t *testing.T) { ctx := context.Background() - db := startTypeNameOracle(t) + db := startPostgres(t) conn, err := db.Conn(ctx) if err != nil { t.Fatalf("conn: %v", err) @@ -94,7 +94,8 @@ func assertFormatType(t *testing.T, conn *sql.Conn, spellings []string) { } } -func startTypeNameOracle(t *testing.T) *sql.DB { +// startPostgres starts the PostgreSQL the oracle tests of this package ask. +func startPostgres(t *testing.T) *sql.DB { t.Helper() ctx := context.Background() var container *tcpg.PostgresContainer @@ -119,7 +120,7 @@ func startTypeNameOracle(t *testing.T) *sql.DB { } }() if setupErr != nil { - t.Fatalf("type name oracle unavailable: %v", setupErr) + t.Fatalf("postgres oracle unavailable: %v", setupErr) } t.Cleanup(func() { _ = testcontainers.TerminateContainer(container) })