From 7d6a6737028cbb1c83efa7632f7ffc6a09853264 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 10:10:37 +0000 Subject: [PATCH 01/40] feat(pg/review): add DISALLOW_DROP_CONSTRAINT, REQUIRE_PRIMARY_KEY, and PRIOR_BACKUP The three rules that read a target's synced schema. Each reports only what it can establish from the SQL and the schema; where they leave the answer open, it reports nothing. DISALLOW_DROP_CONSTRAINT and REQUIRE_PRIMARY_KEY check the synced metadata directly instead of rebuilding it in a catalog. A scan follows the change statement by statement and records what each statement may have changed (names created, dropped, renamed, or moved; constraints and columns altered; the search path and role). A name a statement may have changed is not looked up again, a statement whose effect cannot be bounded (DO, CALL, ROLLBACK, a cascading drop of anything but a relation) ends the scan, and a statement the server would refuse (a missing column or constraint, a key or column something depends on without CASCADE) reports nothing. PRIOR_BACKUP mirrors what Bytebase's PostgreSQL backup refuses: a change over the size limit, a missing bbdataarchive schema, UPDATE and DELETE on one table, and an UPDATE or DELETE of a table the change creates. It resolves names as the backup does. TestScanRulesAgainstPostgres runs every change on PostgreSQL 17 and derives the expected findings from the server's catalogs; the rules must never report anything else. Co-Authored-By: Claude Opus 5.5 --- pg/review/review.go | 27 +- pg/review/review_test.go | 1 + pg/review/rule_disallow_drop_constraint.go | 249 ++++++++ pg/review/rule_prior_backup.go | 288 +++++++++ pg/review/rule_require_primary_key.go | 187 ++++++ pg/review/scan.go | 662 +++++++++++++++++++++ pg/review/scan_oracle_test.go | 518 ++++++++++++++++ pg/review/schema_index.go | 302 ++++++++++ pg/review/searchpath.go | 53 ++ pg/review/target_rules_test.go | 543 +++++++++++++++++ pg/review/targets.go | 178 ++++++ pg/review/type_name_oracle_test.go | 7 +- 12 files changed, 3008 insertions(+), 7 deletions(-) create mode 100644 pg/review/rule_disallow_drop_constraint.go create mode 100644 pg/review/rule_prior_backup.go create mode 100644 pg/review/rule_require_primary_key.go create mode 100644 pg/review/scan.go create mode 100644 pg/review/scan_oracle_test.go create mode 100644 pg/review/schema_index.go create mode 100644 pg/review/searchpath.go create mode 100644 pg/review/target_rules_test.go create mode 100644 pg/review/targets.go 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..660a27fd --- /dev/null +++ b/pg/review/rule_disallow_drop_constraint.go @@ -0,0 +1,249 @@ +package review + +import ( + "slices" + + "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. +// +// 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 + } + 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 + // What the drops find is kept until every drop is known to run: a + // statement the server refuses drops nothing. + var out droppedKeys + for _, cmd := range drops { + cascade := cmd.Behavior == int(ast.DROP_CASCADE) + t, known := s.table(v.Relation) + known = known && isTable + switch ast.AlterTableType(cmd.Subtype) { + case ast.AT_DropConstraint: + if known && s.constraintKnown(name, cmd.Name) && s.dropConstraint(st, v, cmd, t, cascade, &out) { + s.stop() + return + } + s.constraints[[2]string{name, cmd.Name}] = true + case ast.AT_DropColumn: + if known && s.dropColumn(cmd, t, cascade, &out) { + s.stop() + return + } + s.unsettled[name] = true + if cascade { + s.dropReaders(t, v.Relation) + } + } + if cascade { + s.dropReferences(name) + } + } + for _, f := range out.findings { + s.r.add(f) + } + if out.lost != nil { + s.lostKey(st, v, out.lost, out.table) + } + 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.constraints[[2]string{name, c.Conname}] = true + } + 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 { + s.keyed(v.Relation) + } + s.references(c) + } + case ast.AT_AddColumn: + if cd, ok := cmd.Def.(*ast.ColumnDef); ok { + s.columns[[2]string{name, cd.Colname}] = true + for _, c := range constraintsOf(cd.Constraints) { + if c.Conname != "" { + s.constraints[[2]string{name, c.Conname}] = true + } + if c.Contype == ast.CONSTR_PRIMARY { + s.keyed(v.Relation) + } + } + s.references(constraintsOf(cd.Constraints)...) + } + 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) + } + } + case ast.AT_AttachPartition: + if pc, ok := cmd.Def.(*ast.PartitionCmd); ok && pc.Name != nil { + s.unsettled[pc.Name.Relname] = true + for _, p := range s.matching(pc.Name) { + p.settled = true + } + } + case ast.AT_DetachPartition: + if pc, ok := cmd.Def.(*ast.PartitionCmd); ok && pc.Name != nil { + s.unsettled[pc.Name.Relname] = true + } + case ast.AT_AddInherit, ast.AT_DropInherit, ast.AT_AddOf, ast.AT_DropOf: + s.unsettled[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 +} + +// dropConstraint checks a DROP CONSTRAINT against the synced table. 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, t tableRef, cascade bool, out *droppedKeys) (refused bool) { + table := s.index.schemas[t.schema].tables[t.table] + con, ok := constraint(table, cmd.Name) + if !ok { + // The snapshot leaves out NOT NULL constraints, so a constraint it + // does not list may still exist; either way nothing is reported. + return !cmd.Missing_ok + } + 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.referencedByChange[t.table] { + return true + } + } + s.dropped[[3]string{t.schema, t.table, cmd.Name}] = true + 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 := [2]string{t.table, cmd.Name} + switch { + case s.renamedTo[column]: + // A column renamed to this name: what it was is not followed. + return false + case s.renamedFrom[column] && !s.columns[column]: + return !cmd.Missing_ok + } + if !slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == cmd.Name }) { + // A column the change added is not the key's. + return !cmd.Missing_ok && !s.columns[[2]string{t.table, cmd.Name}] + } + if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}] || + s.referencedByChange[t.table] || s.readByChange[t.table]) { + return true + } + pk := primaryKey(table) + if pk == nil || !s.constraintKnown(t.table, pk.GetName()) { + return false + } + if !slices.Contains(keyColumns(pk), cmd.Name) { + return false + } + if out.lost == nil { + out.lost, out.table = cmd, t + } + return false +} + +// dropReaders touches the views that read the table, which a cascading +// DROP COLUMN may drop: in the table's schema when the scan resolved it, +// and in every schema the name may be in otherwise. +func (s *scan) dropReaders(t tableRef, rv *ast.RangeVar) { + if s.index == nil { + return + } + for schema := range s.index.schemas { + if t.schema != "" && t.schema != schema || t.schema == "" && rv.Schemaname != "" && rv.Schemaname != schema { + continue + } + for _, view := range s.index.readers[tableRef{schema, rv.Relname}] { + s.touchName(schema, view) + } + } +} + +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..276865c8 --- /dev/null +++ b/pg/review/rule_prior_backup.go @@ -0,0 +1,288 @@ +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. + +// 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. + created := make(map[backupTable]bool) + createdNames := make(map[string]bool) + 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: + if t, ok := createdRelation(s.node); ok { + created[t] = true + createdNames[t.name] = true + } + 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 == "" && created[table] && !hasBackupRelation(findSchema(schema, rv.Schemaname), rv.Relname) { + 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() +} + +// createdRelation returns the relation a statement creates under 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: + if v.IntoClause != nil { + rv = v.IntoClause.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 + } + } + } + if rv == nil { + return backupTable{}, false + } + return backupTable{schema: rv.Schemaname, name: rv.Relname}, true +} + +// 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..0f030df8 --- /dev/null +++ b/pg/review/rule_require_primary_key.go @@ -0,0 +1,187 @@ +package review + +import ( + "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 inherits one made + // with INHERITS: a cascading drop of a parent may drop either. + existed bool + inherits bool + + statement int + rng review.Range + message string + settled bool +} + +// 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) { + out = append(out, p) + } + } + return out +} + +// keyed settles the pending tables a statement may give a primary key. +func (s *scan) keyed(rv *ast.RangeVar) { + for _, p := range s.matching(rv) { + p.settled = true + } +} + +// 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) { + return + } + if v.IfNotExists && !s.createsNew(v.Relation) { + return + } + keyed, known := s.createsKey(v) + if keyed || !known { + return + } + s.pending = append(s.pending, &pendingTable{ + names: map[string]bool{v.Relation.Relname: true}, + schema: v.Relation.Schemaname, + inherits: v.InhRelations != nil && len(v.InhRelations.Items) > 0, + statement: st.index, + rng: rangeOf(v.Loc), + message: "creates table " + relation(v.Relation) + " without a primary key", + }) +} + +// 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. 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 { + 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 + } + } + } + ns := s.index.schemas[schema] + if ns == nil || s.schemas[schema] || s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: rv.Relname}) { + return false + } + _, exists := ns.relations[rv.Relname] + return !exists +} + +// 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 || kind == kindPartition || kind == kindTable && s.unsettled[e.Relation.Relname]: + known = false + case kind == kindTable: + pk := primaryKey(s.index.schemas[schema].tables[e.Relation.Relname]) + if pk != nil && !s.constraintKnown(e.Relation.Relname, pk.GetName()) { + known = false + } else if pk != nil { + 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..0e4285a5 --- /dev/null +++ b/pg/review/scan.go @@ -0,0 +1,662 @@ +package review + +import ( + "context" + "strings" + + "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. +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 + // unsettled lists the tables, by name, whose columns, constraints, or + // inheritance a statement changed in a way the scan does not follow. + unsettled map[string]bool + // constraints lists the (table, constraint) names a statement added, + // dropped, or renamed, and renamedKeys the names an index rename may + // have given to or taken from a constraint, on whichever table. + constraints map[[2]string]bool + renamedKeys map[string]bool + // dropped lists the (schema, table, constraint) a DROP CONSTRAINT of a + // resolved table removed. + dropped map[[3]string]bool + // columns lists the (table, column) names a statement added, and + // renamedFrom and renamedTo the old and new names of renamed columns. + columns map[[2]string]bool + renamedFrom map[[2]string]bool + renamedTo map[[2]string]bool + // schemas lists the schemas a statement created or dropped. + schemas map[string]bool + // referencedByChange and readByChange list the relations, by name, a + // foreign key or a view the change created depends on, which the synced + // schema does not record. + referencedByChange map[string]bool + readByChange 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), + unsettled: make(map[string]bool), + constraints: make(map[[2]string]bool), + renamedKeys: make(map[string]bool), + dropped: make(map[[3]string]bool), + columns: make(map[[2]string]bool), + renamedFrom: make(map[[2]string]bool), + renamedTo: make(map[[2]string]bool), + schemas: make(map[string]bool), + + referencedByChange: make(map[string]bool), + readByChange: make(map[string]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 + } + } + for i := range stmts { + if err := ctx.Err(); err != nil { + return err + } + 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 +} + +// 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) { + switch v := st.node.(type) { + case *ast.AlterTableStmt: + s.alterTable(st, v) + case *ast.CreateStmt: + s.createTable(st, v) + s.touch(v.Relation) + if v.TableElts != nil { + for _, item := range v.TableElts.Items { + switch e := item.(type) { + case *ast.ColumnDef: + s.references(constraintsOf(e.Constraints)...) + case *ast.Constraint: + s.references(e) + } + } + } + case *ast.CreateForeignTableStmt: + s.touch(v.Base.Relation) + case *ast.CreateTableAsStmt: + if v.Into != nil { + s.touch(v.Into.Rel) + } + if v.Objtype == ast.OBJECT_MATVIEW { + s.reads(v.Query) + } + case *ast.ViewStmt: + s.touch(v.View) + s.reads(v.Query) + case *ast.CreateSeqStmt: + s.touch(v.Sequence) + case *ast.CompositeTypeStmt: + s.touch(v.Typevar) + case *ast.IndexStmt: + // An unnamed index takes a generated name the server keeps clear of + // every relation already in its schema. + if v.Idxname != "" && v.Relation != nil { + s.touchName(v.Relation.Schemaname, v.Idxname) + } + case *ast.SelectStmt: + switch { + case v.IntoClause != nil: + s.touch(v.IntoClause.Rel) + case selectsInto(v): + s.stop() + default: + s.data(v) + } + case *ast.InsertStmt, *ast.UpdateStmt, *ast.DeleteStmt, *ast.MergeStmt: + s.data(v) + case *ast.ExplainStmt: + if !explainAnalyzes(v) { + return + } + switch q := v.Query.(type) { + case *ast.CreateTableAsStmt: + if q.Into != nil { + s.touch(q.Into.Rel) + } + 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: + if v.SchemaElts != nil && len(v.SchemaElts.Items) > 0 { + s.stop() + return + } + // A schema named after its owner leaves no name to record; it holds + // only what the change creates in it. + if v.Schemaname != "" { + s.schemas[v.Schemaname] = true + } + case *ast.DoStmt, *ast.CallStmt: + // Procedural code may run any DDL. + 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) + } +} + +// references records the tables new foreign keys reference. +func (s *scan) references(constraints ...*ast.Constraint) { + for _, c := range constraints { + if c.Contype == ast.CONSTR_FOREIGN && c.Pktable != nil { + s.referencedByChange[c.Pktable.Relname] = true + } + } +} + +// reads records the relations a new view's query names. +func (s *scan) reads(query ast.Node) { + ast.Inspect(query, func(n ast.Node) bool { + if rv, ok := n.(*ast.RangeVar); ok { + s.readByChange[rv.Relname] = true + } + return true + }) +} + +// 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 +} + +// 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 +} + +// 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) + if !ok || kind != kindTable || s.unsettled[rv.Relname] { + return tableRef{}, false + } + return tableRef{schema, rv.Relname}, 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 + return + } + switch v.Name { + case "search_path": + 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": + 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.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.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) { + switch { + case isRelationKind(v.RenameType) && v.Relation != nil: + s.touchName(v.Relation.Schemaname, v.Relation.Relname) + s.touchName(v.Relation.Schemaname, v.Newname) + for _, p := range s.matching(v.Relation) { + p.names[v.Newname] = true + } + if v.RenameType == ast.OBJECT_INDEX { + s.renamedKeys[v.Relation.Relname] = true + s.renamedKeys[v.Newname] = true + } + 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: + s.constraints[[2]string{v.Relation.Relname, v.Subname}] = true + s.constraints[[2]string{v.Relation.Relname, v.Newname}] = true + case v.RenameType == ast.OBJECT_COLUMN && v.Relation != nil: + s.renamedFrom[[2]string{v.Relation.Relname, v.Subname}] = true + s.renamedTo[[2]string{v.Relation.Relname, v.Newname}] = true + case v.RenameType == ast.OBJECT_SCHEMA: + s.stop() + } +} + +// 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: + s.touch(v.Relation) + s.touchName(v.Newschema, v.Relation.Relname) + for _, p := range s.matching(v.Relation) { + p.schema = "" + } + case v.ObjectType == ast.OBJECT_TYPE: + if parts := nameParts(listOf(v.Object)); len(parts) > 0 { + s.touchName("", parts[len(parts)-1]) + } + 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) + if kind == ast.OBJECT_EXTENSION || cascade && !isRelationKind(kind) { + s.stop() + return + } + if kind == ast.OBJECT_SCHEMA && v.Objects != nil { + // Without CASCADE the schema must be empty; its name may then be + // created again. + for _, obj := range v.Objects.Items { + if parts := nameParts(listOf(obj)); len(parts) == 1 { + s.schemas[parts[0]] = true + } + } + return + } + if !isRelationKind(kind) && kind != ast.OBJECT_TYPE || v.Objects == nil { + return + } + 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] + } + s.touch(rv) + if kind == ast.OBJECT_INDEX { + s.renamedKeys[rv.Relname] = true + } + for _, p := range s.matching(rv) { + p.settled = true + } + if cascade { + s.dropDependents(rv) + } + } + if cascade { + // An inheritance child of a dropped table goes with it. + for _, p := range s.pending { + if p.inherits || p.existed { + p.settled = true + } + } + } +} + +// dropDependents touches what a cascading drop of a relation takes with +// it, as far as the synced schema records it, in every schema the name +// may be in. +func (s *scan) dropDependents(rv *ast.RangeVar) { + s.dropReferences(rv.Relname) + if s.index == nil { + return + } + for name, ns := range s.index.schemas { + if rv.Schemaname != "" && rv.Schemaname != name { + continue + } + for _, d := range ns.dependents[rv.Relname] { + s.touchName(name, d) + } + for _, view := range s.index.readers[tableRef{name, rv.Relname}] { + s.touchName(name, view) + } + } +} + +// dropReferences records that the foreign keys referencing a table, by +// name in any schema, may be gone. +func (s *scan) dropReferences(table string) { + if s.index == nil { + return + } + for _, fk := range s.index.foreignKeys { + if fk.referenced.table == table { + s.constraints[[2]string{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(table, name string) bool { + return !s.constraints[[2]string{table, name}] && !s.renamedKeys[name] +} diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go new file mode 100644 index 00000000..cad34945 --- /dev/null +++ b/pg/review/scan_oracle_test.go @@ -0,0 +1,518 @@ +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; +` + +// 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 drops nothing, so a DisallowDropConstraint +// finding on it is wrong; a finding after it, or a RequirePrimaryKey +// finding about the end of a change that never gets there, is not +// checked. +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 or constraint is not followed. + {"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;", false}, + // 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}, + {"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}, + } + 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 && f.Rule == review.DisallowDropConstraint { + t.Errorf("reported a drop on the statement the server rejects: %s", f.Message) + continue + } + if failedAt >= 0 && (f.Statement > failedAt || f.Rule == review.RequirePrimaryKey) { + t.Logf("not checked, 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 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); 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 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 { + 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, 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.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)) + } + } + } + } + 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..9bc7f8a8 --- /dev/null +++ b/pg/review/schema_index.go @@ -0,0 +1,302 @@ +package review + +import ( + "strings" + + "github.com/bytebase/omni/metadata" +) + +// 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 marks the columns they read. + readers map[tableRef][]string + readsColumn map[columnRef]bool +} + +// 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, which go + // with it when it is dropped. + dependents map[string][]string +} + +type relationKind int + +const ( + kindTable relationKind = iota + 1 + kindPartition + // kindOther is a view, materialized view, foreign table, sequence, + // index, or composite type, or a name the snapshot lists twice. + kindOther +) + +type tableRef struct{ schema, table string } + +type columnRef struct{ schema, table, column string } + +type foreignKeyRef struct { + owner tableRef // the referencing table + 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][]string), + readsColumn: make(map[columnRef]bool), + } + for _, s := range db.GetSchemas() { + ns := &namespace{ + relations: make(map[string]relationKind), + tables: make(map[string]*metadata.TableMetadata), + dependents: make(map[string][]string), + } + 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(), kindOther) + ns.dependents[t.GetName()] = append(ns.dependents[t.GetName()], i.GetName()) + } + ns.addPartitions(t.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()}, name: fk.GetName(), referenced: ref, columns: fk.GetReferencedColumns()}) + } + } + for _, v := range s.GetViews() { + ns.add(v.GetName(), kindOther) + idx.addReader(v.GetName(), v.GetDependencyColumns()) + } + for _, v := range s.GetMaterializedViews() { + ns.add(v.GetName(), kindOther) + idx.addReader(v.GetName(), v.GetDependencyColumns()) + for _, i := range v.GetIndexes() { + ns.add(i.GetName(), kindOther) + } + } + for _, t := range s.GetExternalTables() { + ns.add(t.GetName(), kindOther) + } + for _, q := range s.GetSequences() { + ns.add(q.GetName(), kindOther) + if q.GetOwnerTable() != "" { + ns.dependents[q.GetOwnerTable()] = append(ns.dependents[q.GetOwnerTable()], q.GetName()) + } + } + for _, c := range s.GetCompositeTypes() { + ns.add(c.GetName(), kindOther) + } + } + return idx +} + +// 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 = kindOther + } + ns.relations[name] = kind +} + +func (ns *namespace) addPartitions(table string, partitions []*metadata.TablePartitionMetadata) { + for _, p := range partitions { + ns.add(p.GetName(), kindPartition) + ns.dependents[table] = append(ns.dependents[table], p.GetName()) + for _, i := range p.GetIndexes() { + ns.add(i.GetName(), kindOther) + ns.dependents[table] = append(ns.dependents[table], i.GetName()) + } + ns.addPartitions(table, p.GetSubpartitions()) + } +} + +func (idx *schemaIndex) addReader(view string, columns []*metadata.DependencyColumn) { + seen := make(map[tableRef]bool) + for _, c := range columns { + t := tableRef{c.GetSchema(), c.GetTable()} + idx.readsColumn[columnRef{t.schema, t.table, c.GetColumn()}] = true + 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..bec94a50 --- /dev/null +++ b/pg/review/target_rules_test.go @@ -0,0 +1,543 @@ +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 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"), "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 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 missing constraint is refused", + 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: "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 the table was created with is not followed", + sql: "CREATE TABLE n (id int PRIMARY KEY);\nALTER TABLE n DROP CONSTRAINT n_pkey;", + 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 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 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 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..425484ba --- /dev/null +++ b/pg/review/targets.go @@ -0,0 +1,178 @@ +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 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.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) }) From 5e2c1515e386b6b3a29839db79a65cac05cde648 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 10:26:37 +0000 Subject: [PATCH 02/40] fix(pg/review): scope the scan's state by schema and follow what drops free From the Codex review of #445: - Record changed constraints, columns, and tables by schema, so a change to s.t no longer hides public.t. - A drop the scan resolves frees the relation's name, its indexes, partitions, and owned sequences, and with CASCADE the views reading it, so CREATE TABLE IF NOT EXISTS of a freed name counts as creating. Dropped views are recorded in their own schema. - A dropped table's own foreign keys no longer block dropping the key they referenced. - An ALTER TABLE whose other subcommand is certain to fail (adding a column the table has, altering one it lacks, reusing a constraint name, a second primary key) reports none of its drops. - DROP TABLE without CASCADE of a table a view or another table's foreign key depends on is refused, and ends the scan. - PRIOR_BACKUP: a table the change moves with SET SCHEMA does not exist under its new name when the backup runs. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 122 ++++++++++-- pg/review/rule_prior_backup.go | 14 +- pg/review/rule_require_primary_key.go | 12 +- pg/review/scan.go | 211 +++++++++++++++++---- pg/review/scan_oracle_test.go | 11 ++ pg/review/schema_index.go | 10 +- pg/review/target_rules_test.go | 77 ++++++++ 7 files changed, 391 insertions(+), 66 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 660a27fd..096ac101 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -18,7 +18,9 @@ import ( // lost its primary key, or gained one. // // The server runs every DROP subcommand of the statement before the -// others, so the scan does too. +// others, so the scan does too, and the statement succeeds or fails as a +// whole, so what the drops find is reported only when no subcommand is +// known to fail. // // pg: src/backend/commands/tablecmds.c — ATController (AT_PASS_DROP first) func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { @@ -45,8 +47,10 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } name := v.Relation.Relname - // What the drops find is kept until every drop is known to run: a - // statement the server refuses drops nothing. + 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 for _, cmd := range drops { cascade := cmd.Behavior == int(ast.DROP_CASCADE) @@ -54,17 +58,17 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { known = known && isTable switch ast.AlterTableType(cmd.Subtype) { case ast.AT_DropConstraint: - if known && s.constraintKnown(name, cmd.Name) && s.dropConstraint(st, v, cmd, t, cascade, &out) { + if known && s.constraintKnown(t, cmd.Name) && s.dropConstraint(st, v, cmd, t, cascade, &out) { s.stop() return } - s.constraints[[2]string{name, cmd.Name}] = true + s.constraints[[3]string{schema, name, cmd.Name}] = true case ast.AT_DropColumn: if known && s.dropColumn(cmd, t, cascade, &out) { s.stop() return } - s.unsettled[name] = true + s.unsettled[[2]string{schema, name}] = true if cascade { s.dropReaders(t, v.Relation) } @@ -73,6 +77,10 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.dropReferences(name) } } + if (len(out.findings) > 0 || out.lost != nil) && knownBefore && s.refuses(before, drops, others) { + s.stop() + return + } for _, f := range out.findings { s.r.add(f) } @@ -84,7 +92,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { case ast.AT_AddConstraint: if c, ok := cmd.Def.(*ast.Constraint); ok { if c.Conname != "" { - s.constraints[[2]string{name, c.Conname}] = true + s.constraints[[3]string{schema, name, c.Conname}] = true } if c.Indexname != "" { // USING INDEX renames the index to the constraint. @@ -99,10 +107,10 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } case ast.AT_AddColumn: if cd, ok := cmd.Def.(*ast.ColumnDef); ok { - s.columns[[2]string{name, cd.Colname}] = true + s.columns[[3]string{schema, name, cd.Colname}] = true for _, c := range constraintsOf(cd.Constraints) { if c.Conname != "" { - s.constraints[[2]string{name, c.Conname}] = true + s.constraints[[3]string{schema, name, c.Conname}] = true } if c.Contype == ast.CONSTR_PRIMARY { s.keyed(v.Relation) @@ -119,17 +127,17 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } case ast.AT_AttachPartition: if pc, ok := cmd.Def.(*ast.PartitionCmd); ok && pc.Name != nil { - s.unsettled[pc.Name.Relname] = true + s.unsettle(pc.Name) for _, p := range s.matching(pc.Name) { p.settled = true } } case ast.AT_DetachPartition: if pc, ok := cmd.Def.(*ast.PartitionCmd); ok && pc.Name != nil { - s.unsettled[pc.Name.Relname] = true + s.unsettle(pc.Name) } case ast.AT_AddInherit, ast.AT_DropInherit, ast.AT_AddOf, ast.AT_DropOf: - s.unsettled[name] = true + s.unsettled[[2]string{schema, name}] = true } } } @@ -189,24 +197,24 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt // 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 := [2]string{t.table, cmd.Name} + added := hasColumnIn(s.columns, t, cmd.Name) switch { - case s.renamedTo[column]: + case hasColumnIn(s.renamedTo, t, cmd.Name): // A column renamed to this name: what it was is not followed. return false - case s.renamedFrom[column] && !s.columns[column]: + case hasColumnIn(s.renamedFrom, t, cmd.Name) && !added: return !cmd.Missing_ok } if !slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == cmd.Name }) { // A column the change added is not the key's. - return !cmd.Missing_ok && !s.columns[[2]string{t.table, cmd.Name}] + return !cmd.Missing_ok && !added } if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}] || s.referencedByChange[t.table] || s.readByChange[t.table]) { return true } pk := primaryKey(table) - if pk == nil || !s.constraintKnown(t.table, pk.GetName()) { + if pk == nil || !s.constraintKnown(t, pk.GetName()) { return false } if !slices.Contains(keyColumns(pk), cmd.Name) { @@ -230,9 +238,87 @@ func (s *scan) dropReaders(t tableRef, rv *ast.RangeVar) { continue } for _, view := range s.index.readers[tableRef{schema, rv.Relname}] { - s.touchName(schema, view) + s.touchName(view.schema, view.table) + } + } +} + +// 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, or adding a constraint under a name it +// uses, or a second primary key. Other failures, such as a type error, +// are not known before the statement runs. +func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { + table := s.index.schemas[t.schema].tables[t.table] + droppedColumns := make(map[string]bool) + droppedConstraints := make(map[string]bool) + for _, cmd := range drops { + if ast.AlterTableType(cmd.Subtype) == ast.AT_DropColumn { + droppedColumns[cmd.Name] = true + } else { + droppedConstraints[cmd.Name] = true } } + // has reports whether the table has the column once the drops ran. + has := func(column string) bool { + switch { + case droppedColumns[column]: + return false + case hasColumnIn(s.columns, t, column) || hasColumnIn(s.renamedTo, t, column): + return true + case hasColumnIn(s.renamedFrom, t, column): + return false + } + return slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == column }) + } + // uses reports whether a constraint name certainly names a constraint + // of the table. + uses := func(name string) bool { + if droppedConstraints[name] || !s.constraintKnown(t, name) || s.dropped[[3]string{t.schema, t.table, name}] { + return false + } + _, ok := constraint(table, name) + return ok + } + keyed := func() bool { + pk := primaryKey(table) + return pk != nil && uses(pk.GetName()) + } + 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) && !cmd.Missing_ok { + return true + } + for _, c := range constraintsOf(cd.Constraints) { + if c.Contype == ast.CONSTR_PRIMARY && keyed() { + return true + } + } + 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: + if cmd.Name == "" { + continue + } + if !has(cmd.Name) { + return true + } + case ast.AT_AddConstraint: + c, ok := cmd.Def.(*ast.Constraint) + if !ok { + continue + } + if c.Conname != "" && uses(c.Conname) || c.Contype == ast.CONSTR_PRIMARY && keyed() { + return true + } + } + } + return false } func constraintsOf(list *ast.List) []*ast.Constraint { diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go index 276865c8..344f425a 100644 --- a/pg/review/rule_prior_backup.go +++ b/pg/review/rule_prior_backup.go @@ -135,9 +135,10 @@ func statementLoc(n ast.Node) ast.Loc { return ast.NoLoc() } -// createdRelation returns the relation a statement creates under 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. +// 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) { @@ -168,6 +169,13 @@ func createdRelation(n ast.Node) (backupTable, bool) { 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 diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 0f030df8..643d270e 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -129,7 +129,13 @@ func (s *scan) createsNew(rv *ast.RangeVar) bool { } } ns := s.index.schemas[schema] - if ns == nil || s.schemas[schema] || s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: rv.Relname}) { + if ns == nil || s.schemas[schema] { + return false + } + 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] @@ -169,11 +175,11 @@ func (s *scan) createsKey(v *ast.CreateStmt) (keyed, known bool) { } schema, kind, ok := s.lookup(e.Relation) switch { - case !ok || kind == kindPartition || kind == kindTable && s.unsettled[e.Relation.Relname]: + case !ok || kind == kindPartition || kind == kindTable && s.isUnsettled(tableRef{schema, e.Relation.Relname}): known = false case kind == kindTable: pk := primaryKey(s.index.schemas[schema].tables[e.Relation.Relname]) - if pk != nil && !s.constraintKnown(e.Relation.Relname, pk.GetName()) { + if pk != nil && !s.constraintKnown(tableRef{schema, e.Relation.Relname}, pk.GetName()) { known = false } else if pk != nil { keyed = true diff --git a/pg/review/scan.go b/pg/review/scan.go index 0e4285a5..1c3c6d84 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -2,6 +2,7 @@ package review import ( "context" + "slices" "strings" "github.com/bytebase/omni/pg/ast" @@ -35,22 +36,28 @@ type scan struct { // what they name. touchedName lists the same names alone. touched map[[2]string]bool touchedName map[string]bool - // unsettled lists the tables, by name, whose columns, constraints, or - // inheritance a statement changed in a way the scan does not follow. - unsettled map[string]bool - // constraints lists the (table, constraint) names a statement added, - // dropped, or renamed, and renamedKeys the names an index rename may - // have given to or taken from a constraint, on whichever table. - constraints map[[2]string]bool + // 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 // dropped lists the (schema, table, constraint) a DROP CONSTRAINT of a // resolved table removed. dropped map[[3]string]bool - // columns lists the (table, column) names a statement added, and - // renamedFrom and renamedTo the old and new names of renamed columns. - columns map[[2]string]bool - renamedFrom map[[2]string]bool - renamedTo map[[2]string]bool + // columns lists the (schema, table, column) names a statement added, + // and renamedFrom and renamedTo the old and new names of renamed + // columns, with "" for a schema the scan could not tell. + columns map[[3]string]bool + renamedFrom map[[3]string]bool + renamedTo map[[3]string]bool // schemas lists the schemas a statement created or dropped. schemas map[string]bool // referencedByChange and readByChange list the relations, by name, a @@ -75,13 +82,14 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, session: target.SessionUser, touched: make(map[[2]string]bool), touchedName: make(map[string]bool), - unsettled: make(map[string]bool), - constraints: make(map[[2]string]bool), + unsettled: make(map[[2]string]bool), + freed: make(map[[2]string]bool), + constraints: make(map[[3]string]bool), renamedKeys: make(map[string]bool), dropped: make(map[[3]string]bool), - columns: make(map[[2]string]bool), - renamedFrom: make(map[[2]string]bool), - renamedTo: make(map[[2]string]bool), + columns: make(map[[3]string]bool), + renamedFrom: make(map[[3]string]bool), + renamedTo: make(map[[3]string]bool), schemas: make(map[string]bool), referencedByChange: make(map[string]bool), @@ -252,6 +260,35 @@ func (s *scan) touch(rv *ast.RangeVar) { func (s *scan) touchName(schema, name string) { s.touched[[2]string{schema, name}] = true s.touchedName[name] = true + 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 +} + +// 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}] +} + +// hasColumnIn reports whether a column set holds the table's column, in +// the table's schema or in an unknown one. +func hasColumnIn(set map[[3]string]bool, t tableRef, column string) bool { + return set[[3]string{t.schema, t.table, column}] || set[[3]string{"", t.table, column}] } // isTouched reports whether a statement may have changed what the name @@ -328,16 +365,27 @@ func (s *scan) searchPath() ([]string, bool) { 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) - if !ok || kind != kindTable || s.unsettled[rv.Relname] { + t := tableRef{schema, rv.Relname} + if !ok || kind != kindTable || s.isUnsettled(t) { return tableRef{}, false } - return tableRef{schema, rv.Relname}, true + return t, true } // setting follows SET and RESET. The scan follows SET search_path to a @@ -536,11 +584,13 @@ func (s *scan) rename(v *ast.RenameStmt) { s.touchName(schema, v.Newname) } case v.RenameType == ast.OBJECT_TABCONSTRAINT && v.Relation != nil: - s.constraints[[2]string{v.Relation.Relname, v.Subname}] = true - s.constraints[[2]string{v.Relation.Relname, v.Newname}] = true + schema := s.schemaOf(v.Relation) + s.constraints[[3]string{schema, v.Relation.Relname, v.Subname}] = true + s.constraints[[3]string{schema, v.Relation.Relname, v.Newname}] = true case v.RenameType == ast.OBJECT_COLUMN && v.Relation != nil: - s.renamedFrom[[2]string{v.Relation.Relname, v.Subname}] = true - s.renamedTo[[2]string{v.Relation.Relname, v.Newname}] = true + schema := s.schemaOf(v.Relation) + s.renamedFrom[[3]string{schema, v.Relation.Relname, v.Subname}] = true + s.renamedTo[[3]string{schema, v.Relation.Relname, v.Newname}] = true case v.RenameType == ast.OBJECT_SCHEMA: s.stop() } @@ -591,6 +641,10 @@ func (s *scan) drop(v *ast.DropStmt) { if !isRelationKind(kind) && kind != ast.OBJECT_TYPE || v.Objects == nil { return } + if !cascade && s.dropRefused(v) { + s.stop() + return + } for _, obj := range v.Objects.Items { parts := nameParts(listOf(obj)) if len(parts) == 0 { @@ -600,15 +654,22 @@ func (s *scan) drop(v *ast.DropStmt) { if len(parts) >= 2 { rv.Schemaname = parts[len(parts)-2] } - s.touch(rv) + schema, relKind, resolved := s.lookup(rv) + if resolved { + s.free(schema, rv.Relname) + } else { + s.touch(rv) + schema = "" + } if kind == ast.OBJECT_INDEX { s.renamedKeys[rv.Relname] = true } for _, p := range s.matching(rv) { p.settled = true } + s.dropOwned(rv, schema, resolved && relKind == kindTable) if cascade { - s.dropDependents(rv) + s.dropDependents(rv, schema) } } if cascade { @@ -621,23 +682,99 @@ func (s *scan) drop(v *ast.DropStmt) { } } -// dropDependents touches what a cascading drop of a relation takes with -// it, as far as the synced schema records it, in every schema the name -// may be in. -func (s *scan) dropDependents(rv *ast.RangeVar) { - s.dropReferences(rv.Relname) +// dropRefused reports whether the server refuses a DROP without CASCADE of +// tables the scan resolves: a view reads one, or a foreign key of a table +// the statement does not drop references one, as the synced schema or the +// change itself records. +func (s *scan) dropRefused(v *ast.DropStmt) bool { + if s.index == nil || ast.ObjectType(v.RemoveType) != ast.OBJECT_TABLE { + return false + } + var tables []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] + } + if schema, kind, ok := s.lookup(rv); ok && kind == kindTable { + tables = append(tables, tableRef{schema, rv.Relname}) + } + } + for _, t := range tables { + if s.referencedByChange[t.table] || s.readByChange[t.table] { + return true + } + for _, view := range s.index.readers[t] { + if !s.freed[[2]string{view.schema, view.table}] { + return true + } + } + for _, fk := range s.index.foreignKeys { + if fk.referenced == t && !fk.in(s.dropped) && !slices.Contains(tables, 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] { + 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.Relname) + if s.index == nil { + return + } + if schema != "" { + for _, view := range s.index.readers[tableRef{schema, rv.Relname}] { + s.free(view.schema, view.table) + } + return + } + for name := range s.index.schemas { if rv.Schemaname != "" && rv.Schemaname != name { continue } - for _, d := range ns.dependents[rv.Relname] { - s.touchName(name, d) - } for _, view := range s.index.readers[tableRef{name, rv.Relname}] { - s.touchName(name, view) + s.touchName(view.schema, view.table) } } } @@ -650,13 +787,13 @@ func (s *scan) dropReferences(table string) { } for _, fk := range s.index.foreignKeys { if fk.referenced.table == table { - s.constraints[[2]string{fk.owner.table, fk.name}] = true + s.constraints[[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(table, name string) bool { - return !s.constraints[[2]string{table, name}] && !s.renamedKeys[name] +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] } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index cad34945..e1907871 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -94,6 +94,17 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 9bc7f8a8..5bfd57fd 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -17,7 +17,7 @@ type schemaIndex struct { foreignKeys []foreignKeyRef // readers maps a table to the views and materialized views that read // it, and readsColumn marks the columns they read. - readers map[tableRef][]string + readers map[tableRef][]tableRef readsColumn map[columnRef]bool } @@ -58,7 +58,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { idx := &schemaIndex{ database: db.GetName(), schemas: make(map[string]*namespace), - readers: make(map[tableRef][]string), + readers: make(map[tableRef][]tableRef), readsColumn: make(map[columnRef]bool), } for _, s := range db.GetSchemas() { @@ -86,11 +86,11 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { } for _, v := range s.GetViews() { ns.add(v.GetName(), kindOther) - idx.addReader(v.GetName(), v.GetDependencyColumns()) + idx.addReader(tableRef{s.GetName(), v.GetName()}, v.GetDependencyColumns()) } for _, v := range s.GetMaterializedViews() { ns.add(v.GetName(), kindOther) - idx.addReader(v.GetName(), v.GetDependencyColumns()) + idx.addReader(tableRef{s.GetName(), v.GetName()}, v.GetDependencyColumns()) for _, i := range v.GetIndexes() { ns.add(i.GetName(), kindOther) } @@ -132,7 +132,7 @@ func (ns *namespace) addPartitions(table string, partitions []*metadata.TablePar } } -func (idx *schemaIndex) addReader(view string, columns []*metadata.DependencyColumn) { +func (idx *schemaIndex) addReader(view tableRef, columns []*metadata.DependencyColumn) { seen := make(map[tableRef]bool) for _, c := range columns { t := tableRef{c.GetSchema(), c.GetTable()} diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index bec94a50..688201f3 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -176,6 +176,61 @@ func TestDisallowDropConstraint(t *testing.T) { 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 anything but a relation ends the scan", sql: "DROP FUNCTION f() CASCADE;\nALTER TABLE t DROP CONSTRAINT t_n_check;", @@ -288,6 +343,21 @@ func TestRequirePrimaryKey(t *testing.T) { 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}}}, + }, { 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;", @@ -466,6 +536,13 @@ func TestPriorBackup(t *testing.T) { {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 the change recreates still exists when the backup runs", sql: "DROP TABLE t;\nCREATE TABLE t (id int);\nDELETE FROM t WHERE id = 1;", From 80f73170f365cb838d626747345f62a66181a64e Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 10:42:33 +0000 Subject: [PATCH 03/40] fix(pg/review): narrow cascades, match new foreign keys to their key, and follow more of the change From the second Codex review of #445: - A cascading DROP COLUMN or DROP CONSTRAINT invalidates only the foreign keys on the dropped key or column, of the resolved table. - A foreign key the change creates blocks only the key it references, recorded with its schema and columns. - A qualified SET SCHEMA keeps a pending table's new schema exact. - A materialized view's indexes go with it when it is dropped. - DROP VIEW and DROP MATERIALIZED VIEW without CASCADE are refused when a view outside the statement reads them. - A repeated DROP in one ALTER TABLE refuses it; a DROP CONSTRAINT after a DROP COLUMN that may have taken the constraint withholds the statement's findings. - An enabled event trigger of the target that fires on a command the change runs leaves the scan's findings unknown, so none are reported. - PRIOR_BACKUP: tables and views of CREATE SCHEMA, and an unqualified creation matched by a qualified UPDATE or DELETE. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 87 +++++++- pg/review/rule_prior_backup.go | 31 ++- pg/review/scan.go | 228 ++++++++++++++++++--- pg/review/scan_oracle_test.go | 7 + pg/review/schema_index.go | 6 +- pg/review/target_rules_test.go | 108 +++++++++- 6 files changed, 425 insertions(+), 42 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 096ac101..a229f360 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -50,20 +50,42 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { 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. + // a statement the server refuses drops nothing. A DROP the server may + // refuse withholds the statement's findings without ending the scan. var out droppedKeys + withhold := false + droppedColumns := make(map[string]bool) + droppedNames := make(map[string]bool) for _, cmd := range drops { cascade := cmd.Behavior == int(ast.DROP_CASCADE) t, known := s.table(v.Relation) known = known && 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(droppedColumns) > 0 && !cmd.Missing_ok && knownBefore && s.mayDependOn(before, cmd.Name, droppedColumns) { + withhold = true + } + droppedNames[cmd.Name] = true if known && s.constraintKnown(t, cmd.Name) && s.dropConstraint(st, v, cmd, t, cascade, &out) { s.stop() return } s.constraints[[3]string{schema, name, cmd.Name}] = true + if cascade { + s.dropKeyReferences(v.Relation, t, known, cmd.Name) + } case ast.AT_DropColumn: + if droppedColumns[cmd.Name] && !cmd.Missing_ok { + s.stop() + return + } + droppedColumns[cmd.Name] = true if known && s.dropColumn(cmd, t, cascade, &out) { s.stop() return @@ -71,15 +93,18 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.unsettled[[2]string{schema, name}] = true if cascade { s.dropReaders(t, v.Relation) + s.dropReferences(v.Relation, t, known, func(fk foreignKeyRef) bool { + return !known || slices.ContainsFunc(fk.columns, func(c string) bool { n := columnName(c); return n == cmd.Name || n == "" }) + }) } } - if cascade { - s.dropReferences(name) - } } - if (len(out.findings) > 0 || out.lost != nil) && knownBefore && s.refuses(before, drops, others) { - s.stop() - return + if withhold || (len(out.findings) > 0 || out.lost != nil) && knownBefore && s.refuses(before, drops, others) { + if !withhold { + s.stop() + return + } + out = droppedKeys{} } for _, f := range out.findings { s.r.add(f) @@ -167,7 +192,7 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt // 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.referencedByChange[t.table] { + s.newlyReferenced(t, con.columns, con.kind == "primary key") { return true } } @@ -209,11 +234,15 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out // A column the change added is not the key's. return !cmd.Missing_ok && !added } + pk := primaryKey(table) + var pkColumns []string + if pk != nil { + pkColumns = keyColumns(pk) + } if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}] || - s.referencedByChange[t.table] || s.readByChange[t.table]) { + s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || s.readByChange[t.table]) { return true } - pk := primaryKey(table) if pk == nil || !s.constraintKnown(t, pk.GetName()) { return false } @@ -243,6 +272,44 @@ func (s *scan) dropReaders(t tableRef, rv *ast.RangeVar) { } } +// 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 { return sameColumns(fk.columns, con.columns) }) + 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 { + 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, or adding a constraint under a name it diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go index 344f425a..3092e1b8 100644 --- a/pg/review/rule_prior_backup.go +++ b/pg/review/rule_prior_backup.go @@ -81,7 +81,7 @@ func checkPriorBackupTarget(stmts []statement, schema *metadata.DatabaseSchemaMe } continue default: - if t, ok := createdRelation(s.node); ok { + for _, t := range createdRelations(s.node) { created[t] = true createdNames[t.name] = true } @@ -103,7 +103,9 @@ func checkPriorBackupTarget(stmts []statement, schema *metadata.DatabaseSchemaMe } continue } - } else if rv.Catalogname == "" && created[table] && !hasBackupRelation(findSchema(schema, rv.Schemaname), rv.Relname) { + } else if rv.Catalogname == "" && (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] @@ -135,6 +137,31 @@ func statementLoc(n ast.Node) ast.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 { + if v.Schemaname == "" || v.SchemaElts == nil { + return nil + } + var out []backupTable + for _, elt := range v.SchemaElts.Items { + for _, t := range createdRelations(elt) { + if t.schema == "" { + t.schema = v.Schemaname + } + out = append(out, t) + } + } + return out + } + 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 diff --git a/pg/review/scan.go b/pg/review/scan.go index 1c3c6d84..daf86884 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -5,6 +5,7 @@ import ( "slices" "strings" + "github.com/bytebase/omni/metadata" "github.com/bytebase/omni/pg/ast" "github.com/bytebase/omni/review" ) @@ -60,11 +61,11 @@ type scan struct { renamedTo map[[3]string]bool // schemas lists the schemas a statement created or dropped. schemas map[string]bool - // referencedByChange and readByChange list the relations, by name, a - // foreign key or a view the change created depends on, which the synced - // schema does not record. - referencedByChange map[string]bool - readByChange map[string]bool + // newReferences lists the keys the foreign keys the change created + // reference, and readByChange the relations, by name, its views read, + // neither of which the synced schema records. + newReferences []newReference + readByChange map[string]bool // pending are the tables RequirePrimaryKey reports at the end unless a // later statement settles them. @@ -92,8 +93,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, renamedTo: make(map[[3]string]bool), schemas: make(map[string]bool), - referencedByChange: make(map[string]bool), - readByChange: make(map[string]bool), + readByChange: make(map[string]bool), } if target.Schema != nil { s.index = newSchemaIndex(target.Schema) @@ -102,6 +102,9 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, s.path = path } } + if target.Schema != nil && eventTriggerFires(target.Schema, stmts) { + return nil + } for i := range stmts { if err := ctx.Err(); err != nil { return err @@ -119,6 +122,117 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, return nil } +// eventTriggerFires reports whether an enabled event trigger of the target +// fires on a statement of the change the scan follows: one with no tag +// filter, or one whose tags name the statement's command. Its function may +// run more DDL or reject the statement, so what the statement does is no +// longer what it says. +func eventTriggerFires(schema *metadata.DatabaseSchemaMetadata, stmts []statement) bool { + var triggers []*metadata.EventTriggerMetadata + for _, t := range schema.GetEventTriggers() { + if t.GetEnabled() { + triggers = append(triggers, t) + } + } + if len(triggers) == 0 { + return false + } + tags := make(map[string]bool) + for i := range stmts { + if tag := commandTag(stmts[i].node); tag != "" { + tags[tag] = true + } + } + if len(tags) == 0 { + return false + } + for _, t := range triggers { + if len(t.GetTags()) == 0 { + return true + } + for _, tag := range t.GetTags() { + if tags[strings.ToUpper(tag)] { + return true + } + } + } + return false +} + +// commandTag is the command tag of a statement the scan follows, as an +// event trigger's tag filter names it, or "" for one it does not follow. +// +// 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" + } + 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 v.IntoClause != nil { + 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.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)) + } + return "" +} + // 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() { @@ -229,13 +343,51 @@ func (s *scan) statement(st *statement) { } } -// references records the tables new foreign keys reference. +// newReference is the key a foreign key the change created references: +// the table as written, "" for a schema left to the search path, and the +// columns, nil for the primary key. +type newReference struct { + schema, table string + columns []string +} + +// references records the keys new foreign keys reference. func (s *scan) references(constraints ...*ast.Constraint) { for _, c := range constraints { if c.Contype == ast.CONSTR_FOREIGN && c.Pktable != nil { - s.referencedByChange[c.Pktable.Relname] = true + s.newReferences = append(s.newReferences, newReference{c.Pktable.Schemaname, c.Pktable.Relname, nameParts(c.PkAttrs)}) + } + } +} + +// 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. +func (s *scan) newlyReferenced(t tableRef, columns []string, primary bool) bool { + for _, r := range s.newReferences { + if r.table != t.table || r.schema != "" && r.schema != t.schema { + 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.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 } // reads records the relations a new view's query names. @@ -604,7 +756,13 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { s.touch(v.Relation) s.touchName(v.Newschema, v.Relation.Relname) for _, p := range s.matching(v.Relation) { - p.schema = "" + // 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: if parts := nameParts(listOf(v.Object)); len(parts) > 0 { @@ -683,14 +841,15 @@ func (s *scan) drop(v *ast.DropStmt) { } // dropRefused reports whether the server refuses a DROP without CASCADE of -// tables the scan resolves: a view reads one, or a foreign key of a table -// the statement does not drop references one, as the synced schema or the -// change itself records. +// relations the scan resolves: a view the statement does not drop reads +// one, 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 { - if s.index == nil || ast.ObjectType(v.RemoveType) != ast.OBJECT_TABLE { + 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 tables []tableRef + var dropped []tableRef for _, obj := range v.Objects.Items { parts := nameParts(listOf(obj)) if len(parts) == 0 { @@ -700,21 +859,27 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { if len(parts) >= 2 { rv.Schemaname = parts[len(parts)-2] } - if schema, kind, ok := s.lookup(rv); ok && kind == kindTable { - tables = append(tables, tableRef{schema, rv.Relname}) + if schema, _, ok := s.lookup(rv); ok { + dropped = append(dropped, tableRef{schema, rv.Relname}) } } - for _, t := range tables { - if s.referencedByChange[t.table] || s.readByChange[t.table] { + for _, t := range dropped { + if s.readByChange[t.table] { return true } for _, view := range s.index.readers[t] { - if !s.freed[[2]string{view.schema, view.table}] { + if !s.freed[[2]string{view.schema, view.table}] && !slices.Contains(dropped, view) { return true } } + if kind != ast.OBJECT_TABLE { + continue + } + if s.newlyReferenced(t, nil, true) { + return true + } for _, fk := range s.index.foreignKeys { - if fk.referenced == t && !fk.in(s.dropped) && !slices.Contains(tables, fk.owner) { + if fk.referenced == t && !fk.in(s.dropped) && !slices.Contains(dropped, fk.owner) { return true } } @@ -759,7 +924,7 @@ func (s *scan) dropOwned(rv *ast.RangeVar, schema string, table bool) { // 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.Relname) + s.dropReferences(rv, tableRef{schema, rv.Relname}, schema != "", nil) if s.index == nil { return } @@ -779,16 +944,25 @@ func (s *scan) dropDependents(rv *ast.RangeVar, schema string) { } } -// dropReferences records that the foreign keys referencing a table, by -// name in any schema, may be gone. -func (s *scan) dropReferences(table string) { +// 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 depend on +// what was dropped. +func (s *scan) dropReferences(rv *ast.RangeVar, t tableRef, known bool, affected func(foreignKeyRef) bool) { if s.index == nil { return } for _, fk := range s.index.foreignKeys { - if fk.referenced.table == table { - s.constraints[[3]string{fk.owner.schema, fk.owner.table, fk.name}] = true + switch { + case known && fk.referenced != t: + continue + case !known && (fk.referenced.table != rv.Relname || rv.Schemaname != "" && fk.referenced.schema != rv.Schemaname): + continue + case affected != nil && !affected(fk): + continue } + s.constraints[[3]string{fk.owner.schema, fk.owner.table, fk.name}] = true } } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index e1907871..d7d3eb7d 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -105,6 +105,13 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 5bfd57fd..9c5c28b8 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -28,8 +28,9 @@ type schemaIndex struct { type namespace struct { relations map[string]relationKind tables map[string]*metadata.TableMetadata - // partitions, indexes, and owned sequences of each table, which go - // with it when it is dropped. + // 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 } @@ -93,6 +94,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { idx.addReader(tableRef{s.GetName(), v.GetName()}, v.GetDependencyColumns()) for _, i := range v.GetIndexes() { ns.add(i.GetName(), kindOther) + ns.dependents[v.GetName()] = append(ns.dependents[v.GetName()], i.GetName()) } } for _, t := range s.GetExternalTables() { diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 688201f3..d88c8e63 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -51,6 +51,11 @@ func withCheck(t *metadata.TableMetadata, name, expr string) *metadata.TableMeta return t } +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 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 @@ -61,7 +66,7 @@ func withForeignKey(t *metadata.TableMetadata, name, column, refTable, refColumn func shop() *metadata.DatabaseSchemaMetadata { return database(`"$user", public`, schema("public", - withPrimaryKey(table("p", "id integer"), "p_pkey", "id"), + 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"), ), @@ -231,6 +236,52 @@ func TestDisallowDropConstraint(t *testing.T) { {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, + }, + { + name: "a drop of a constraint a column drop may have taken withholds the statement", + 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, + want: []targetFinding{{1, "DROP CONSTRAINT t_pkey", "drops unique constraint t_pkey of s.t", []int{0}}}, + }, + { + 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 cascading drop of anything but a relation ends the scan", sql: "DROP FUNCTION f() CASCADE;\nALTER TABLE t DROP CONSTRAINT t_n_check;", @@ -358,6 +409,44 @@ func TestRequirePrimaryKey(t *testing.T) { })}}, want: []targetFinding{{1, "CREATE TABLE IF NOT EXISTS s.reader (id int)", "creates table s.reader without a primary key", []int{0}}}, }, + { + 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}}}, + }, + { + 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: "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;", @@ -543,6 +632,23 @@ func TestPriorBackup(t *testing.T) { 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 recreates still exists when the backup runs", sql: "DROP TABLE t;\nCREATE TABLE t (id int);\nDELETE FROM t WHERE id = 1;", From 23490dd41ed4ec4c5f1085b068d2bbd29d327cbb Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 10:56:25 +0000 Subject: [PATCH 04/40] fix(pg/review): retire new foreign keys with their table, follow CREATE SCHEMA, refuse missing drops From the third Codex review of #445: - A foreign key the change creates is recorded with its owner and name, and no longer blocks a key drop once its table or the constraint is dropped. - The relations a new view reads are recorded in the schema they resolve to. - DROP without IF EXISTS of a relation the target certainly lacks is refused and ends the scan, as is DROP TABLE of a table whose row type a function returns. - ALTER TABLE validation counts the statement's own additions, so a column or constraint added twice, or a second primary key, refuses it. - REQUIRE_PRIMARY_KEY follows the tables of CREATE SCHEMA, in the new schema. - PRIOR_BACKUP counts a relation the change creates anywhere in it, since the backup runs before every statement. - The scan's doc states its boundary: it does not predict what triggers or functions do at run time. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 39 +++- pg/review/rule_prior_backup.go | 12 +- pg/review/scan.go | 225 +++++++++++++++++---- pg/review/scan_oracle_test.go | 25 ++- pg/review/schema_index.go | 8 + pg/review/target_rules_test.go | 58 ++++++ pg/review/targets.go | 12 +- 7 files changed, 326 insertions(+), 53 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index a229f360..ffdcc62a 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -72,6 +72,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { withhold = true } droppedNames[cmd.Name] = true + s.retireReferences(v.Relation, cmd.Name) if known && s.constraintKnown(t, cmd.Name) && s.dropConstraint(st, v, cmd, t, cascade, &out) { s.stop() return @@ -128,7 +129,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { if c.Contype == ast.CONSTR_PRIMARY { s.keyed(v.Relation) } - s.references(c) + s.references(v.Relation, c) } case ast.AT_AddColumn: if cd, ok := cmd.Def.(*ast.ColumnDef); ok { @@ -141,7 +142,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.keyed(v.Relation) } } - s.references(constraintsOf(cd.Constraints)...) + s.references(v.Relation, constraintsOf(cd.Constraints)...) } case ast.AT_AddIndexConstraint, ast.AT_AddIndex: if idx, ok := cmd.Def.(*ast.IndexStmt); ok { @@ -240,7 +241,7 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out pkColumns = keyColumns(pk) } if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}] || - s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || s.readByChange[t.table]) { + s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || s.readByChange[[2]string{t.schema, t.table}] || s.readByChange[[2]string{"", t.table}]) { return true } if pk == nil || !s.constraintKnown(t, pk.GetName()) { @@ -347,9 +348,28 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { _, ok := constraint(table, name) return ok } + // What the statement's own additions add counts too. + addedColumns := make(map[string]bool) + addedNames := make(map[string]bool) + addedKey := false keyed := func() bool { pk := primaryKey(table) - return pk != nil && uses(pk.GetName()) + return addedKey || pk != nil && uses(pk.GetName()) + } + addKey := func(c *ast.Constraint) bool { + if c.Conname != "" { + if uses(c.Conname) || addedNames[c.Conname] { + return true + } + addedNames[c.Conname] = true + } + if c.Contype == ast.CONSTR_PRIMARY { + if keyed() { + return true + } + addedKey = true + } + return false } for _, cmd := range others { switch ast.AlterTableType(cmd.Subtype) { @@ -358,18 +378,21 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { if !ok { continue } - if has(cd.Colname) && !cmd.Missing_ok { + if (has(cd.Colname) || addedColumns[cd.Colname]) && !cmd.Missing_ok { return true } + addedColumns[cd.Colname] = true for _, c := range constraintsOf(cd.Constraints) { - if c.Contype == ast.CONSTR_PRIMARY && keyed() { + if addKey(c) { return true } } 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: - if cmd.Name == "" { + // 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) { @@ -380,7 +403,7 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { if !ok { continue } - if c.Conname != "" && uses(c.Conname) || c.Contype == ast.CONSTR_PRIMARY && keyed() { + if addKey(c) { return true } } diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go index 3092e1b8..8fd13be6 100644 --- a/pg/review/rule_prior_backup.go +++ b/pg/review/rule_prior_backup.go @@ -63,8 +63,16 @@ func checkPriorBackupTarget(stmts []statement, schema *metadata.DatabaseSchemaMe 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] @@ -81,10 +89,6 @@ func checkPriorBackupTarget(stmts []statement, schema *metadata.DatabaseSchemaMe } continue default: - for _, t := range createdRelations(s.node) { - created[t] = true - createdNames[t.name] = true - } continue } if rv == nil { diff --git a/pg/review/scan.go b/pg/review/scan.go index daf86884..b55da91b 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -19,6 +19,14 @@ import ( // 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 DDL statement the server refuses for a reason the SQL and the synced +// schema show (a missing object, a dependent object without CASCADE) +// reports nothing and ends the scan. 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: findings +// describe the change as written, and a change that fails at run time +// fails before they matter. type scan struct { on map[review.Rule]bool r *reporter @@ -65,7 +73,10 @@ type scan struct { // reference, and readByChange the relations, by name, its views read, // neither of which the synced schema records. newReferences []newReference - readByChange map[string]bool + readByChange map[[2]string]bool + // pathVersion counts search path changes, so two unqualified names + // are known to resolve alike only under the same path. + pathVersion int // pending are the tables RequirePrimaryKey reports at the end unless a // later statement settles them. @@ -93,7 +104,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, renamedTo: make(map[[3]string]bool), schemas: make(map[string]bool), - readByChange: make(map[string]bool), + readByChange: make(map[[2]string]bool), } if target.Schema != nil { s.index = newSchemaIndex(target.Schema) @@ -244,18 +255,7 @@ func (s *scan) statement(st *statement) { case *ast.AlterTableStmt: s.alterTable(st, v) case *ast.CreateStmt: - s.createTable(st, v) - s.touch(v.Relation) - if v.TableElts != nil { - for _, item := range v.TableElts.Items { - switch e := item.(type) { - case *ast.ColumnDef: - s.references(constraintsOf(e.Constraints)...) - case *ast.Constraint: - s.references(e) - } - } - } + s.create(st, v) case *ast.CreateForeignTableStmt: s.touch(v.Base.Relation) case *ast.CreateTableAsStmt: @@ -314,15 +314,7 @@ func (s *scan) statement(st *statement) { case *ast.DropStmt: s.drop(v) case *ast.CreateSchemaStmt: - if v.SchemaElts != nil && len(v.SchemaElts.Items) > 0 { - s.stop() - return - } - // A schema named after its owner leaves no name to record; it holds - // only what the change creates in it. - if v.Schemaname != "" { - s.schemas[v.Schemaname] = true - } + s.createSchema(st, v) case *ast.DoStmt, *ast.CallStmt: // Procedural code may run any DDL. s.stop() @@ -343,19 +335,44 @@ func (s *scan) statement(st *statement) { } } -// newReference is the key a foreign key the change created references: -// the table as written, "" for a schema left to the search path, and the -// columns, nil for the primary key. +// 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 reference. -func (s *scan) references(constraints ...*ast.Constraint) { +// 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{c.Pktable.Schemaname, c.Pktable.Relname, nameParts(c.PkAttrs)}) + s.newReferences = append(s.newReferences, newReference{ + schema: c.Pktable.Schemaname, 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 } } } @@ -365,7 +382,7 @@ func (s *scan) references(constraints ...*ast.Constraint) { // read; primary tells whether the key is the primary key. func (s *scan) newlyReferenced(t tableRef, columns []string, primary bool) bool { for _, r := range s.newReferences { - if r.table != t.table || r.schema != "" && r.schema != t.schema { + if r.retired || r.table != t.table || r.schema != "" && r.schema != t.schema { continue } if columns == nil || r.columns == nil && primary || r.columns != nil && sameColumns(r.columns, columns) { @@ -380,7 +397,7 @@ func (s *scan) newlyReferenced(t tableRef, columns []string, primary bool) bool // 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.table != t.table || r.schema != "" && r.schema != t.schema { + 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)) { @@ -390,16 +407,83 @@ func (s *scan) newlyReferencedColumn(t tableRef, column string, keyColumns []str return false } -// reads records the relations a new view's query names. +// reads records the relations a new view's query names, in the schema +// the name resolves to, the one written, or "" when neither is known. func (s *scan) reads(query ast.Node) { ast.Inspect(query, func(n ast.Node) bool { if rv, ok := n.(*ast.RangeVar); ok { - s.readByChange[rv.Relname] = true + s.readByChange[[2]string{s.schemaOf(rv), rv.Relname}] = true } return true }) } +// create follows CREATE TABLE. +func (s *scan) create(st *statement, v *ast.CreateStmt) { + s.createTable(st, v) + s.touch(v.Relation) + if v.TableElts == nil { + return + } + for _, item := range v.TableElts.Items { + switch e := item.(type) { + case *ast.ColumnDef: + s.references(v.Relation, constraintsOf(e.Constraints)...) + case *ast.Constraint: + s.references(v.Relation, e) + } + } +} + +// 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 + } + s.schemas[name] = true + if v.SchemaElts == nil { + 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 v.SchemaElts.Items { + switch e := elt.(type) { + case *ast.CreateStmt: + c := *e + c.Relation = in(e.Relation) + s.create(st, &c) + case *ast.ViewStmt: + s.touch(in(e.View)) + s.reads(e.Query) + case *ast.CreateSeqStmt: + s.touch(in(e.Sequence)) + case *ast.IndexStmt: + if e.Idxname != "" && e.Relation != nil { + s.touchName(in(e.Relation).Schemaname, e.Idxname) + } + case *ast.CreateTrigStmt, *ast.GrantStmt: + default: + s.stop() + return + } + } +} + // touch records a relation name a statement creates. func (s *scan) touch(rv *ast.RangeVar) { if rv != nil { @@ -547,10 +631,12 @@ func (s *scan) table(rv *ast.RangeVar) (tableRef, bool) { 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 @@ -561,6 +647,7 @@ func (s *scan) setting(v *ast.VariableSetStmt) { s.path = append(s.path, name) } case "role": + s.pathVersion++ switch { case v.IsLocal: s.user = "" @@ -576,6 +663,7 @@ func (s *scan) setting(v *ast.VariableSetStmt) { s.user = "" } case "session_authorization": + s.pathVersion++ s.user, s.session = "", "" } } @@ -612,6 +700,7 @@ func (s *scan) data(n ast.Node) { 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. @@ -799,7 +888,7 @@ func (s *scan) drop(v *ast.DropStmt) { if !isRelationKind(kind) && kind != ast.OBJECT_TYPE || v.Objects == nil { return } - if !cascade && s.dropRefused(v) { + if !cascade && s.dropRefused(v) || !v.Missing_ok && s.dropsMissing(v) { s.stop() return } @@ -825,6 +914,9 @@ func (s *scan) drop(v *ast.DropStmt) { for _, p := range s.matching(rv) { p.settled = true } + if kind == ast.OBJECT_TABLE { + s.retireReferences(rv, "") + } s.dropOwned(rv, schema, resolved && relKind == kindTable) if cascade { s.dropDependents(rv, schema) @@ -840,10 +932,69 @@ func (s *scan) drop(v *ast.DropStmt) { } } +// 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 { + if s.isTouched(rv) { + return false + } + if rv.Schemaname != "" { + 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, 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. +// 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 { @@ -864,7 +1015,7 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { } } for _, t := range dropped { - if s.readByChange[t.table] { + if s.readByChange[[2]string{t.schema, t.table}] || s.readByChange[[2]string{"", t.table}] || s.index.returnedBy[t] { return true } for _, view := range s.index.readers[t] { diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index d7d3eb7d..85b2e0e5 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -112,6 +112,12 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, @@ -367,6 +373,8 @@ func (a *textArray) Scan(src any) error { // oracleTable is a table as the server's catalogs describe it. type oracleTable struct { + schema string + name string kind string partition bool keyName string @@ -388,7 +396,7 @@ func walkTruth(t *testing.T, conn *sql.Conn, change string) ([]string, int) { ctx := context.Background() tables := func() map[int64]oracleTable { out := make(map[int64]oracleTable) - rows, err := conn.QueryContext(ctx, `SELECT c.oid, c.relkind::text, c.relispartition, coalesce(k.conname, ''), + 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'`) @@ -399,7 +407,7 @@ func walkTruth(t *testing.T, conn *sql.Conn, change string) ([]string, int) { for rows.Next() { var oid int64 var tb oracleTable - if err := rows.Scan(&oid, &tb.kind, &tb.partition, &tb.keyName, (*textArray)(&tb.keyCols)); err != nil { + 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 @@ -503,6 +511,19 @@ func walkTruth(t *testing.T, conn *sql.Conn, change string) ([]string, int) { } } } + // 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} { diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 9c5c28b8..2344b0b5 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -19,6 +19,8 @@ type schemaIndex struct { // it, and readsColumn marks the columns they read. readers map[tableRef][]tableRef readsColumn map[columnRef]bool + // returnedBy marks the relations whose row type a function returns. + returnedBy map[tableRef]bool } // namespace is one schema: the kind of every name in its relation @@ -61,6 +63,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { schemas: make(map[string]*namespace), readers: make(map[tableRef][]tableRef), readsColumn: make(map[columnRef]bool), + returnedBy: make(map[tableRef]bool), } for _, s := range db.GetSchemas() { ns := &namespace{ @@ -109,6 +112,11 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { for _, c := range s.GetCompositeTypes() { ns.add(c.GetName(), kindOther) } + for _, f := range s.GetFunctions() { + for _, d := range f.GetDependencyTables() { + idx.returnedBy[tableRef{d.GetSchema(), d.GetTable()}] = true + } + } } return idx } diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index d88c8e63..bf4a8e59 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -51,6 +51,15 @@ func withCheck(t *metadata.TableMetadata, name, expr string) *metadata.TableMeta 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 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 @@ -282,6 +291,17 @@ func TestDisallowDropConstraint(t *testing.T) { }, 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: "a cascading drop of anything but a relation ends the scan", sql: "DROP FUNCTION f() CASCADE;\nALTER TABLE t DROP CONSTRAINT t_n_check;", @@ -447,6 +467,37 @@ func TestRequirePrimaryKey(t *testing.T) { })}}, 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 s.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 s.q (id int)", "creates table s.q without a primary key", []int{0}}, + }, + }, + { + 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 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;", @@ -649,6 +700,13 @@ func TestPriorBackup(t *testing.T) { 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 the change recreates still exists when the backup runs", sql: "DROP TABLE t;\nCREATE TABLE t (id int);\nDELETE FROM t WHERE id = 1;", diff --git a/pg/review/targets.go b/pg/review/targets.go index 425484ba..a87f8e67 100644 --- a/pg/review/targets.go +++ b/pg/review/targets.go @@ -30,8 +30,9 @@ func targetWork(stmts []statement, on map[review.Rule]bool, change review.Change } // needsScan reports whether a statement of the change can give the scan -// something to report: a CREATE TABLE for RequirePrimaryKey, or an ALTER -// TABLE dropping a constraint or a column for either rule. +// 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 @@ -42,6 +43,13 @@ func needsScan(stmts []statement, on map[review.Rule]bool) bool { 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 From 4fc02547f975aa5f4708088ce15b6199c074c832 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 11:09:14 +0000 Subject: [PATCH 05/40] fix(pg/review): retire dropped views' and functions' dependencies, refuse drops of the wrong kind From the fourth Codex review of #445: - A view's CTE names are not relations it reads. - Dependencies of a view or function the change created are retired when the change drops it; a synced function whose name resolves to one function is retired by DROP FUNCTION. - A function the change creates that returns a table's row type blocks dropping the table. - DROP TABLE of a non-table, DROP VIEW of a table, and DROP INDEX of a constraint's index are refused and end the scan. - CREATE TABLE of a name its schema already holds is refused instead of reported. - PRIOR_BACKUP: CREATE SCHEMA AUTHORIZATION names the schema after the role. - The scan's doc states what a finding claims: its own statement, given that the statements before it ran. Whether the whole change can run is WALK_THROUGH's question. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 2 +- pg/review/rule_prior_backup.go | 9 +- pg/review/rule_require_primary_key.go | 52 +++-- pg/review/scan.go | 225 ++++++++++++++++++--- pg/review/scan_oracle_test.go | 7 + pg/review/schema_index.go | 35 +++- pg/review/target_rules_test.go | 48 +++++ 7 files changed, 333 insertions(+), 45 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index ffdcc62a..5771ed8d 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -241,7 +241,7 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out pkColumns = keyColumns(pk) } if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}] || - s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || s.readByChange[[2]string{t.schema, t.table}] || s.readByChange[[2]string{"", t.table}]) { + s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || dependsOnNew(s.newReads, t)) { return true } if pk == nil || !s.constraintKnown(t, pk.GetName()) { diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go index 8fd13be6..6e1cfa30 100644 --- a/pg/review/rule_prior_backup.go +++ b/pg/review/rule_prior_backup.go @@ -146,14 +146,19 @@ func statementLoc(n ast.Node) ast.Loc { // in the new schema. func createdRelations(n ast.Node) []backupTable { if v, ok := n.(*ast.CreateSchemaStmt); ok { - if v.Schemaname == "" || v.SchemaElts == nil { + 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 = v.Schemaname + t.schema = name } out = append(out, t) } diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 643d270e..42c7dfc3 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -107,20 +107,52 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { // of that 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 { + 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 +} + +// 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 + 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 + return "", false } if s.index.schemas[name] != nil { schema = name @@ -128,18 +160,10 @@ func (s *scan) createsNew(rv *ast.RangeVar) bool { } } } - ns := s.index.schemas[schema] - if ns == nil || s.schemas[schema] { - return false + if s.index.schemas[schema] == nil || s.schemas[schema] { + return "", false } - 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 + return schema, true } // likeIndexes is the LIKE option that copies indexes, the primary key diff --git a/pg/review/scan.go b/pg/review/scan.go index b55da91b..45497e2f 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -20,13 +20,17 @@ import ( // cascading drop of anything but a relation) ends it. Every way the scan // can be unsure leads to no finding. // -// A DDL statement the server refuses for a reason the SQL and the synced -// schema show (a missing object, a dependent object without CASCADE) -// reports nothing and ends the scan. 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: findings -// describe the change as written, and a change that fails at run time -// fails before they matter. +// 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 @@ -70,13 +74,18 @@ type scan struct { // schemas lists the schemas a statement created or dropped. schemas map[string]bool // newReferences lists the keys the foreign keys the change created - // reference, and readByChange the relations, by name, its views read, - // neither of which the synced schema records. + // 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 - readByChange map[[2]string]bool + 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. + droppedFunctions map[tableRef]bool // pending are the tables RequirePrimaryKey reports at the end unless a // later statement settles them. @@ -104,7 +113,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, renamedTo: make(map[[3]string]bool), schemas: make(map[string]bool), - readByChange: make(map[[2]string]bool), + droppedFunctions: make(map[tableRef]bool), } if target.Schema != nil { s.index = newSchemaIndex(target.Schema) @@ -262,12 +271,14 @@ func (s *scan) statement(st *statement) { if v.Into != nil { s.touch(v.Into.Rel) } - if v.Objtype == ast.OBJECT_MATVIEW { - s.reads(v.Query) + if v.Objtype == ast.OBJECT_MATVIEW && v.Into != nil { + s.reads(v.Into.Rel, v.Query) } case *ast.ViewStmt: s.touch(v.View) - s.reads(v.Query) + s.reads(v.View, v.Query) + case *ast.CreateFunctionStmt: + s.returns(v) case *ast.CreateSeqStmt: s.touch(v.Sequence) case *ast.CompositeTypeStmt: @@ -407,19 +418,94 @@ func (s *scan) newlyReferencedColumn(t tableRef, column string, keyColumns []str return false } -// reads records the relations a new view's query names, in the schema -// the name resolves to, the one written, or "" when neither is known. -func (s *scan) reads(query ast.Node) { +// 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 +} + +// reads records the relations a new view's query names. A name a WITH +// clause of the query defines is a CTE, not a relation. +func (s *scan) reads(view *ast.RangeVar, query ast.Node) { + ctes := make(map[string]bool) ast.Inspect(query, func(n ast.Node) bool { - if rv, ok := n.(*ast.RangeVar); ok { - s.readByChange[[2]string{s.schemaOf(rv), rv.Relname}] = true + if w, ok := n.(*ast.WithClause); ok && w.Ctes != nil { + for _, item := range w.Ctes.Items { + if c, ok := item.(*ast.CommonTableExpr); ok { + ctes[c.Ctename] = true + } + } } return true }) + ast.Inspect(query, func(n ast.Node) bool { + if rv, ok := n.(*ast.RangeVar); ok && !(rv.Schemaname == "" && ctes[rv.Relname]) { + s.newReads = append(s.newReads, newDependency{ + relation: tableRef{s.schemaOf(rv), rv.Relname}, + object: tableRef{view.Schemaname, view.Relname}, + path: s.pathVersion, + }) + } + return true + }) +} + +// returns records the relation whose row type a new function returns, +// when its result type names one. +func (s *scan) returns(v *ast.CreateFunctionStmt) { + if v.ReturnType == nil { + return + } + parts := nameParts(v.ReturnType.Names) + fn := nameParts(v.Funcname) + if len(parts) == 0 || len(parts) > 2 || len(fn) == 0 { + return + } + rv := &ast.RangeVar{Relname: parts[len(parts)-1]} + if len(parts) == 2 { + rv.Schemaname = parts[0] + } + object := tableRef{table: fn[len(fn)-1]} + if len(fn) >= 2 { + object.schema = fn[len(fn)-2] + } + s.newReturns = append(s.newReturns, newDependency{relation: tableRef{s.schemaOf(rv), rv.Relname}, object: object, path: s.pathVersion}) +} + +// dependsOnNew reports whether an object the change created and has not +// dropped may depend on the relation. +func dependsOnNew(deps []newDependency, t tableRef) bool { + for _, d := range deps { + if !d.retired && d.relation.table == t.table && (d.relation.schema == "" || d.relation.schema == t.schema) { + return true + } + } + return false +} + +// 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 { + d := &deps[i] + if d.object == object && (object.schema != "" || d.path == s.pathVersion) { + d.retired = true + } + } } -// create follows CREATE TABLE. +// 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) { + if !v.IfNotExists && s.existsWhereCreated(v.Relation) { + s.stop() + return + } s.createTable(st, v) s.touch(v.Relation) if v.TableElts == nil { @@ -469,7 +555,7 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { s.create(st, &c) case *ast.ViewStmt: s.touch(in(e.View)) - s.reads(e.Query) + s.reads(in(e.View), e.Query) case *ast.CreateSeqStmt: s.touch(in(e.Sequence)) case *ast.IndexStmt: @@ -875,6 +961,10 @@ func (s *scan) drop(v *ast.DropStmt) { s.stop() return } + if kind == ast.OBJECT_FUNCTION || 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. @@ -888,7 +978,7 @@ func (s *scan) drop(v *ast.DropStmt) { if !isRelationKind(kind) && kind != ast.OBJECT_TYPE || v.Objects == nil { return } - if !cascade && s.dropRefused(v) || !v.Missing_ok && s.dropsMissing(v) { + if !cascade && s.dropRefused(v) || !v.Missing_ok && s.dropsMissing(v) || s.dropsWrongKind(v) { s.stop() return } @@ -914,8 +1004,11 @@ func (s *scan) drop(v *ast.DropStmt) { for _, p := range s.matching(rv) { p.settled = true } - if kind == ast.OBJECT_TABLE { + 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 { @@ -932,6 +1025,85 @@ func (s *scan) drop(v *ast.DropStmt) { } } +// 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] + } + s.retire(s.newReturns, fn) + if s.index == nil { + 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 + } + } + } +} + +// 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 { + continue + } + table := relKind == kindTable || relKind == kindPartition + switch { + case kind == ast.OBJECT_TABLE && !table, kind != ast.OBJECT_TABLE && table: + return true + case kind == ast.OBJECT_INDEX && s.index.constraintIndexes[tableRef{schema, rv.Relname}]: + return true + } + } + return false +} + // 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, @@ -1015,9 +1187,14 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { } } for _, t := range dropped { - if s.readByChange[[2]string{t.schema, t.table}] || s.readByChange[[2]string{"", t.table}] || s.index.returnedBy[t] { + if dependsOnNew(s.newReads, t) || dependsOnNew(s.newReturns, t) { return true } + for _, fn := range s.index.returnedBy[t] { + if !s.droppedFunctions[fn] { + return true + } + } for _, view := range s.index.readers[t] { if !s.freed[[2]string{view.schema, view.table}] && !slices.Contains(dropped, view) { return true diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 85b2e0e5..3a33f6dd 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -118,6 +118,13 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 2344b0b5..0466c71f 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -19,8 +19,13 @@ type schemaIndex struct { // it, and readsColumn marks the columns they read. readers map[tableRef][]tableRef readsColumn map[columnRef]bool - // returnedBy marks the relations whose row type a function returns. - returnedBy map[tableRef]bool + // returnedBy maps a relation to the functions, by (schema, name), that + // return its row type, and functions counts the functions of each + // (schema, name). + returnedBy map[tableRef][]tableRef + functions map[tableRef]int + // constraintIndexes marks the indexes a constraint owns. + constraintIndexes map[tableRef]bool } // namespace is one schema: the kind of every name in its relation @@ -63,7 +68,10 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { schemas: make(map[string]*namespace), readers: make(map[tableRef][]tableRef), readsColumn: make(map[columnRef]bool), - returnedBy: make(map[tableRef]bool), + returnedBy: make(map[tableRef][]tableRef), + functions: make(map[tableRef]int), + + constraintIndexes: make(map[tableRef]bool), } for _, s := range db.GetSchemas() { ns := &namespace{ @@ -78,8 +86,12 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { for _, i := range t.GetIndexes() { ns.add(i.GetName(), kindOther) 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 == "" { @@ -113,8 +125,11 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { ns.add(c.GetName(), kindOther) } for _, f := range s.GetFunctions() { + fn := tableRef{s.GetName(), f.GetName()} + idx.functions[fn]++ for _, d := range f.GetDependencyTables() { - idx.returnedBy[tableRef{d.GetSchema(), d.GetTable()}] = true + t := tableRef{d.GetSchema(), d.GetTable()} + idx.returnedBy[t] = append(idx.returnedBy[t], fn) } } } @@ -142,6 +157,18 @@ func (ns *namespace) addPartitions(table string, partitions []*metadata.TablePar } } +// 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 { diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index bf4a8e59..f1776685 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -498,6 +498,47 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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;", @@ -707,6 +748,13 @@ func TestPriorBackup(t *testing.T) { 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 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;", From 77454b255b4d637f215f83b905a3690db290f34c Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 11:30:02 +0000 Subject: [PATCH 06/40] fix(pg/review): follow generated names, sequence ownership, and replaced views From the fifth Codex review of #445: - CREATE TABLE of a name an earlier statement may have made reports nothing: a name the change touched and did not certainly free, a key constraint's index name, or a name of the form the server generates for an index or sequence after a statement that may have generated it. CREATE TABLE in a schema the target lacks is refused, and a no-op CREATE TABLE IF NOT EXISTS has no effect at all. - ALTER SEQUENCE ... OWNED BY keeps the sequence from going with its old table; CREATE OR REPLACE VIEW retires the replaced view's synced reads. - Relation kinds are told apart, so DROP VIEW of a materialized view is refused. - A view's CTE names hide relations only where visible; a cascading drop follows views of views; dropping one of two new overloads retires neither; event-trigger matching knows function DDL. - An unqualified new foreign key is matched to the table the path finds; ADD COLUMN IF NOT EXISTS of an existing column adds no key; DROP ... IF EXISTS of a name the target lacks leaves it free. - The oracle now rejects any finding on a statement the server refuses. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 46 +++- pg/review/rule_require_primary_key.go | 33 ++- pg/review/scan.go | 268 +++++++++++++++++---- pg/review/scan_oracle_test.go | 21 +- pg/review/schema_index.go | 38 +-- pg/review/target_rules_test.go | 92 +++++++ 6 files changed, 423 insertions(+), 75 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 5771ed8d..3d48a93d 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -120,6 +120,14 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { if c.Conname != "" { s.constraints[[3]string{schema, name, c.Conname}] = true } + switch { + case !makesIndex(c): + case c.Conname == "": + s.generated = true + default: + // A key's index takes the constraint's name. + s.touchName(v.Relation.Schemaname, c.Conname) + } if c.Indexname != "" { // USING INDEX renames the index to the constraint. s.touchName(v.Relation.Schemaname, c.Indexname) @@ -133,6 +141,12 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } case ast.AT_AddColumn: if cd, ok := cmd.Def.(*ast.ColumnDef); ok { + // ADD COLUMN IF NOT EXISTS of a column the table has does + // nothing, its constraints included. + if cmd.Missing_ok && knownBefore && !droppedColumns[cd.Colname] && s.hasColumn(before, cd.Colname) { + continue + } + s.generated = true s.columns[[3]string{schema, name, cd.Colname}] = true for _, c := range constraintsOf(cd.Constraints) { if c.Conname != "" { @@ -144,6 +158,8 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } s.references(v.Relation, constraintsOf(cd.Constraints)...) } + case ast.AT_AddIdentity: + s.generated = true case ast.AT_AddIndexConstraint, ast.AT_AddIndex: if idx, ok := cmd.Def.(*ast.IndexStmt); ok { s.renamedKeys[idx.Idxname] = true @@ -240,7 +256,7 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out if pk != nil { pkColumns = keyColumns(pk) } - if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}] || + if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || slices.ContainsFunc(s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}], s.viewHolds) || s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || dependsOnNew(s.newReads, t)) { return true } @@ -329,15 +345,7 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { } // has reports whether the table has the column once the drops ran. has := func(column string) bool { - switch { - case droppedColumns[column]: - return false - case hasColumnIn(s.columns, t, column) || hasColumnIn(s.renamedTo, t, column): - return true - case hasColumnIn(s.renamedFrom, t, column): - return false - } - return slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == column }) + return !droppedColumns[column] && s.hasColumn(t, column) } // uses reports whether a constraint name certainly names a constraint // of the table. @@ -411,6 +419,24 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { return false } +// hasColumn reports whether the synced table has the column after the +// statements so far: added, renamed to it, or synced and not renamed away. +func (s *scan) hasColumn(t tableRef, column string) bool { + switch { + case hasColumnIn(s.columns, t, column) || hasColumnIn(s.renamedTo, t, column): + return true + case hasColumnIn(s.renamedFrom, t, column): + return false + } + 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 diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 42c7dfc3..286c760d 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -89,7 +89,7 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { return } keyed, known := s.createsKey(v) - if keyed || !known { + if keyed || !known || s.generated && generatedName(v.Relation.Relname) { return } s.pending = append(s.pending, &pendingTable{ @@ -122,6 +122,37 @@ func (s *scan) createsNew(rv *ast.RangeVar) bool { 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) + } + 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 diff --git a/pg/review/scan.go b/pg/review/scan.go index 45497e2f..47f1df94 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -84,8 +84,19 @@ type scan struct { // 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. + // DROP FUNCTION certainly removed, and newFunctions counts the functions + // the change created, by name as written. droppedFunctions map[tableRef]bool + newFunctions map[tableRef]int + // 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[string]bool + // generated marks that a statement made relations 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 bool // pending are the tables RequirePrimaryKey reports at the end unless a // later statement settles them. @@ -114,6 +125,9 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, schemas: make(map[string]bool), droppedFunctions: make(map[tableRef]bool), + newFunctions: make(map[tableRef]int), + replacedViews: make(map[tableRef]bool), + reowned: make(map[string]bool), } if target.Schema != nil { s.index = newSchemaIndex(target.Schema) @@ -161,6 +175,10 @@ func eventTriggerFires(schema *metadata.DatabaseSchemaMetadata, stmts []statemen for i := range stmts { if tag := commandTag(stmts[i].node); tag != "" { tags[tag] = true + // CREATE FUNCTION and CREATE PROCEDURE share a node. + if tag == "CREATE FUNCTION" { + tags["CREATE PROCEDURE"] = true + } } } if len(tags) == 0 { @@ -202,6 +220,10 @@ func commandTag(n ast.Node) string { return "TYPE" case ast.OBJECT_SCHEMA: return "SCHEMA" + case ast.OBJECT_FUNCTION: + return "FUNCTION" + case ast.OBJECT_ROUTINE: + return "ROUTINE" } return "" } @@ -233,6 +255,8 @@ func commandTag(n ast.Node) string { return "CREATE TYPE" case *ast.IndexStmt: return "CREATE INDEX" + case *ast.CreateFunctionStmt: + return "CREATE FUNCTION" case *ast.CreateSchemaStmt: return "CREATE SCHEMA" case *ast.AlterTableStmt: @@ -275,18 +299,30 @@ func (s *scan) statement(st *statement) { s.reads(v.Into.Rel, v.Query) } case *ast.ViewStmt: + if v.Replace { + if schema, kind, ok := s.lookup(v.View); ok && kind == kindView { + s.replacedViews[tableRef{schema, v.View.Relname}] = true + } + } s.touch(v.View) s.reads(v.View, v.Query) case *ast.CreateFunctionStmt: s.returns(v) + case *ast.AlterSeqStmt: + 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[v.Sequence.Relname] = true + } case *ast.CreateSeqStmt: s.touch(v.Sequence) case *ast.CompositeTypeStmt: s.touch(v.Typevar) case *ast.IndexStmt: - // An unnamed index takes a generated name the server keeps clear of - // every relation already in its schema. - if v.Idxname != "" && v.Relation != nil { + if v.Idxname == "" { + s.generated = true + } else if v.Relation != nil { s.touchName(v.Relation.Schemaname, v.Idxname) } case *ast.SelectStmt: @@ -366,7 +402,7 @@ 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: c.Pktable.Schemaname, table: c.Pktable.Relname, columns: nameParts(c.PkAttrs), + 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, }) } @@ -429,22 +465,42 @@ type newDependency struct { retired bool } -// reads records the relations a new view's query names. A name a WITH -// clause of the query defines is a CTE, not a relation. +// 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) { - ctes := make(map[string]bool) - ast.Inspect(query, func(n ast.Node) bool { - if w, ok := n.(*ast.WithClause); ok && w.Ctes != nil { - for _, item := range w.Ctes.Items { - if c, ok := item.(*ast.CommonTableExpr); ok { - ctes[c.Ctename] = true + s.readsIn(view, query, nil) +} + +func (s *scan) readsIn(view *ast.RangeVar, n ast.Node, visible map[string]bool) { + 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 { + s.readsIn(view, c.Ctequery, scope) + scope[c.Ctename] = true + } + s.readsIn(view, rest, scope) + return false } - return true - }) - ast.Inspect(query, func(n ast.Node) bool { - if rv, ok := n.(*ast.RangeVar); ok && !(rv.Schemaname == "" && ctes[rv.Relname]) { + if rv, ok := n.(*ast.RangeVar); ok && !(rv.Schemaname == "" && visible[rv.Relname]) { s.newReads = append(s.newReads, newDependency{ relation: tableRef{s.schemaOf(rv), rv.Relname}, object: tableRef{view.Schemaname, view.Relname}, @@ -455,14 +511,59 @@ func (s *scan) reads(view *ast.RangeVar, query ast.Node) { }) } +// 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 relation whose row type a new function returns, // when its result type names one. func (s *scan) returns(v *ast.CreateFunctionStmt) { + fn := nameParts(v.Funcname) if v.ReturnType == nil { + if len(fn) > 0 { + object := tableRef{table: fn[len(fn)-1]} + if len(fn) >= 2 { + object.schema = fn[len(fn)-2] + } + s.newFunctions[object]++ + } return } parts := nameParts(v.ReturnType.Names) - fn := nameParts(v.Funcname) if len(parts) == 0 || len(parts) > 2 || len(fn) == 0 { return } @@ -474,6 +575,7 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { if len(fn) >= 2 { object.schema = fn[len(fn)-2] } + s.newFunctions[object]++ s.newReturns = append(s.newReturns, newDependency{relation: tableRef{s.schemaOf(rv), rv.Relname}, object: object, path: s.pathVersion}) } @@ -502,21 +604,37 @@ func (s *scan) retire(deps []newDependency, object tableRef) { // 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) { - if !v.IfNotExists && s.existsWhereCreated(v.Relation) { + exists := s.existsWhereCreated(v.Relation) + switch { + case s.schemaMissing(v.Relation) || exists && !v.IfNotExists: s.stop() return + case exists: + // IF NOT EXISTS of an existing table does nothing. + return + } + if !s.nameUncertain(v.Relation) { + s.createTable(st, v) } - s.createTable(st, v) s.touch(v.Relation) + s.generated = true if v.TableElts == nil { return } for _, item := range v.TableElts.Items { + var constraints []*ast.Constraint switch e := item.(type) { case *ast.ColumnDef: - s.references(v.Relation, constraintsOf(e.Constraints)...) + constraints = constraintsOf(e.Constraints) case *ast.Constraint: - s.references(v.Relation, e) + 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) + } } } } @@ -992,9 +1110,13 @@ func (s *scan) drop(v *ast.DropStmt) { rv.Schemaname = parts[len(parts)-2] } schema, relKind, resolved := s.lookup(rv) - if resolved { + switch { + case resolved: s.free(schema, rv.Relname) - } else { + case v.Missing_ok && s.index != nil && s.missing(rv): + // IF EXISTS of a name the target lacks drops nothing. + continue + default: s.touch(rv) schema = "" } @@ -1045,8 +1167,20 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { if len(parts) == 2 { fn.schema = parts[0] } - s.retire(s.newReturns, fn) - if s.index == nil { + // An overload the change created is retired only when the name + // leaves no choice of which function was dropped. + synced := 0 + if s.index != nil { + for name, n := range s.index.functions { + if name.table == fn.table && (fn.schema == "" || name.schema == fn.schema) { + synced += n + } + } + } + if s.newFunctions[fn] == 1 && synced == 0 { + s.retire(s.newReturns, fn) + } + if s.index == nil || s.newFunctions[fn] > 0 { continue } if fn.schema != "" { @@ -1090,20 +1224,52 @@ func (s *scan) dropsWrongKind(v *ast.DropStmt) bool { rv.Schemaname = parts[0] } schema, relKind, ok := s.lookup(rv) - if !ok { + if !ok || relKind == kindAmbiguous { continue } - table := relKind == kindTable || relKind == kindPartition - switch { - case kind == ast.OBJECT_TABLE && !table, kind != ast.OBJECT_TABLE && table: + if !dropKindMatches(kind, relKind) { return true - case kind == ast.OBJECT_INDEX && s.index.constraintIndexes[tableRef{schema, rv.Relname}]: + } + if kind == ast.OBJECT_INDEX && s.index.constraintIndexes[tableRef{schema, rv.Relname}] { return true } } return false } +// 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 +} + +// 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] +} + // 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, @@ -1196,7 +1362,7 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { } } for _, view := range s.index.readers[t] { - if !s.freed[[2]string{view.schema, view.table}] && !slices.Contains(dropped, view) { + if s.viewHolds(view) && !slices.Contains(dropped, view) { return true } } @@ -1228,7 +1394,11 @@ func (s *scan) dropOwned(rv *ast.RangeVar, schema string, table bool) { switch { case schema != "" && name == schema: for _, d := range ns.dependents[rv.Relname] { - s.free(name, d) + if s.reowned[d] { + s.touchName(name, d) + } else { + s.free(name, d) + } } case schema == "" && (rv.Schemaname == "" || rv.Schemaname == name): for _, d := range ns.dependents[rv.Relname] { @@ -1256,18 +1426,32 @@ func (s *scan) dropDependents(rv *ast.RangeVar, schema string) { if s.index == nil { return } + var start []tableRef if schema != "" { - for _, view := range s.index.readers[tableRef{schema, rv.Relname}] { - s.free(view.schema, view.table) + start = []tableRef{{schema, rv.Relname}} + } else { + for name := range s.index.schemas { + if rv.Schemaname == "" || rv.Schemaname == name { + start = append(start, tableRef{name, rv.Relname}) + } } - return } - for name := range s.index.schemas { - if rv.Schemaname != "" && rv.Schemaname != name { - continue - } - for _, view := range s.index.readers[tableRef{name, rv.Relname}] { - s.touchName(view.schema, view.table) + // The views reading a dropped view go too. + seen := make(map[tableRef]bool) + for len(start) > 0 { + t := start[0] + start = start[1:] + for _, view := range s.index.readers[t] { + if seen[view] { + continue + } + seen[view] = true + if schema != "" { + s.free(view.schema, view.table) + } else { + s.touchName(view.schema, view.table) + } + start = append(start, view) } } } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 3a33f6dd..93525ce2 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -46,10 +46,9 @@ CREATE VIEW tv AS SELECT code FROM t; // 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 drops nothing, so a DisallowDropConstraint -// finding on it is wrong; a finding after it, or a RequirePrimaryKey -// finding about the end of a change that never gets there, is not -// checked. +// when it runs. That statement does nothing, so a finding on it is wrong; +// a finding after it, or a RequirePrimaryKey finding about the end of a +// change that never gets there, is not checked. func TestScanRulesAgainstPostgres(t *testing.T) { db := startPostgres(t) tests := []struct { @@ -125,6 +124,16 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, @@ -174,8 +183,8 @@ func TestScanRulesAgainstPostgres(t *testing.T) { if f.Rule == review.Syntax { t.Fatalf("syntax: %s", f.Message) } - if failedAt >= 0 && f.Statement == failedAt && f.Rule == review.DisallowDropConstraint { - t.Errorf("reported a drop on the statement the server rejects: %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) { diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 0466c71f..63bb0cf5 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -16,9 +16,9 @@ type schemaIndex struct { // 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 marks the columns they read. + // it, and readsColumn a column to the ones that read it. readers map[tableRef][]tableRef - readsColumn map[columnRef]bool + readsColumn map[columnRef][]tableRef // returnedBy maps a relation to the functions, by (schema, name), that // return its row type, and functions counts the functions of each // (schema, name). @@ -46,9 +46,14 @@ type relationKind int const ( kindTable relationKind = iota + 1 kindPartition - // kindOther is a view, materialized view, foreign table, sequence, - // index, or composite type, or a name the snapshot lists twice. - kindOther + kindView + kindMatView + kindForeignTable + kindSequence + kindIndex + kindCompositeType + // kindAmbiguous is a name the snapshot lists twice. + kindAmbiguous ) type tableRef struct{ schema, table string } @@ -67,7 +72,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { database: db.GetName(), schemas: make(map[string]*namespace), readers: make(map[tableRef][]tableRef), - readsColumn: make(map[columnRef]bool), + readsColumn: make(map[columnRef][]tableRef), returnedBy: make(map[tableRef][]tableRef), functions: make(map[tableRef]int), @@ -84,7 +89,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { ns.add(t.GetName(), kindTable) ns.tables[t.GetName()] = t for _, i := range t.GetIndexes() { - ns.add(i.GetName(), kindOther) + 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 @@ -101,28 +106,28 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { } } for _, v := range s.GetViews() { - ns.add(v.GetName(), kindOther) + ns.add(v.GetName(), kindView) idx.addReader(tableRef{s.GetName(), v.GetName()}, v.GetDependencyColumns()) } for _, v := range s.GetMaterializedViews() { - ns.add(v.GetName(), kindOther) + ns.add(v.GetName(), kindMatView) idx.addReader(tableRef{s.GetName(), v.GetName()}, v.GetDependencyColumns()) for _, i := range v.GetIndexes() { - ns.add(i.GetName(), kindOther) + 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(), kindOther) + ns.add(t.GetName(), kindForeignTable) } for _, q := range s.GetSequences() { - ns.add(q.GetName(), kindOther) + ns.add(q.GetName(), kindSequence) if q.GetOwnerTable() != "" { ns.dependents[q.GetOwnerTable()] = append(ns.dependents[q.GetOwnerTable()], q.GetName()) } } for _, c := range s.GetCompositeTypes() { - ns.add(c.GetName(), kindOther) + ns.add(c.GetName(), kindCompositeType) } for _, f := range s.GetFunctions() { fn := tableRef{s.GetName(), f.GetName()} @@ -140,7 +145,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { // a table. func (ns *namespace) add(name string, kind relationKind) { if _, dup := ns.relations[name]; dup { - kind = kindOther + kind = kindAmbiguous } ns.relations[name] = kind } @@ -150,7 +155,7 @@ func (ns *namespace) addPartitions(table string, partitions []*metadata.TablePar ns.add(p.GetName(), kindPartition) ns.dependents[table] = append(ns.dependents[table], p.GetName()) for _, i := range p.GetIndexes() { - ns.add(i.GetName(), kindOther) + ns.add(i.GetName(), kindIndex) ns.dependents[table] = append(ns.dependents[table], i.GetName()) } ns.addPartitions(table, p.GetSubpartitions()) @@ -173,7 +178,8 @@ func (idx *schemaIndex) addReader(view tableRef, columns []*metadata.DependencyC seen := make(map[tableRef]bool) for _, c := range columns { t := tableRef{c.GetSchema(), c.GetTable()} - idx.readsColumn[columnRef{t.schema, t.table, c.GetColumn()}] = true + 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) diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index f1776685..6d7dc634 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -302,6 +302,26 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "a cascading drop of anything but a relation ends the scan", sql: "DROP FUNCTION f() CASCADE;\nALTER TABLE t DROP CONSTRAINT t_n_check;", @@ -539,6 +559,78 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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;", From 506354955bcb8f34354243d56a227a0578fb8b60 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 11:42:06 +0000 Subject: [PATCH 07/40] fix(pg/review): refuse CREATE in another database or an existing schema, and ALTER of a missing table From the sixth Codex review of #445: - CREATE TABLE in another database and CREATE SCHEMA of a schema the target has are refused, so neither reports a table it never makes. - ALTER TABLE without IF EXISTS of a table the target certainly lacks is refused; ALTER TABLE validation runs whichever rules are on. - DROP FUNCTION IF EXISTS retires no dependency, since it may name a signature no function has. - CREATE OR REPLACE of a view the change created retires its reads. - A partition's own indexes go with it when it is dropped. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 7 +++- pg/review/scan.go | 19 ++++++++- pg/review/scan_oracle_test.go | 6 +++ pg/review/schema_index.go | 19 +++++++-- pg/review/target_rules_test.go | 49 +++++++++++++++++++++- 5 files changed, 94 insertions(+), 6 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 3d48a93d..e4a83549 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -27,6 +27,11 @@ 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. + if !v.Missing_ok && s.index != nil && s.missing(v.Relation) { + s.stop() + return + } isTable := ast.ObjectType(v.ObjType) == ast.OBJECT_TABLE var drops, others []*ast.AlterTableCmd for _, item := range v.Cmds.Items { @@ -100,7 +105,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } } - if withhold || (len(out.findings) > 0 || out.lost != nil) && knownBefore && s.refuses(before, drops, others) { + if withhold || knownBefore && isTable && s.refuses(before, drops, others) { if !withhold { s.stop() return diff --git a/pg/review/scan.go b/pg/review/scan.go index 47f1df94..149b51b5 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -303,6 +303,7 @@ func (s *scan) statement(st *statement) { if schema, kind, ok := s.lookup(v.View); ok && kind == kindView { s.replacedViews[tableRef{schema, v.View.Relname}] = true } + s.retire(s.newReads, tableRef{v.View.Schemaname, v.View.Relname}) } s.touch(v.View) s.reads(v.View, v.Query) @@ -606,6 +607,13 @@ func (s *scan) retire(deps []newDependency, object tableRef) { 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.stop() return @@ -653,6 +661,12 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { } return } + // CREATE SCHEMA of a schema the target has, and the change did not + // drop, is refused. + if !v.IfNotExists && s.index != nil && s.index.schemas[name] != nil && !s.schemas[name] { + s.stop() + return + } s.schemas[name] = true if v.SchemaElts == nil { return @@ -1151,7 +1165,10 @@ func (s *scan) drop(v *ast.DropStmt) { // 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 { + // IF EXISTS may name a signature no function has, and then drops + // nothing; without it, a signature that does not match fails the + // statement. + if v.Objects == nil || v.Missing_ok { return } for _, obj := range v.Objects.Items { diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 93525ce2..fb12e798 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -134,6 +134,12 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 63bb0cf5..64b1c5ea 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -1,6 +1,7 @@ package review import ( + "slices" "strings" "github.com/bytebase/omni/metadata" @@ -150,15 +151,27 @@ func (ns *namespace) add(name string, kind relationKind) { 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) - ns.dependents[table] = append(ns.dependents[table], p.GetName()) + names := []string{p.GetName()} for _, i := range p.GetIndexes() { ns.add(i.GetName(), kindIndex) - ns.dependents[table] = append(ns.dependents[table], i.GetName()) + names = append(names, i.GetName()) + } + for _, owner := range owners { + ns.dependents[owner] = append(ns.dependents[owner], names...) } - ns.addPartitions(table, p.GetSubpartitions()) + // 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()) } } diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 6d7dc634..90fad90b 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -631,6 +631,53 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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;", @@ -714,7 +761,7 @@ func TestRequirePrimaryKey(t *testing.T) { }, { name: "a statement on another table leaves a new one as it is", - sql: "CREATE TABLE n (id int);\nALTER TABLE nope ADD COLUMN a int;\nALTER TABLE n ADD COLUMN b int, ADD CONSTRAINT n_b UNIQUE (b);", + 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}}}, }, From 866ae633ec5cab99f15b29b6b0d05da569e5efc1 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 11:56:57 +0000 Subject: [PATCH 08/40] fix(pg/review): keep missing schemas missing, and see the table across a statement's column drops From the seventh Codex review of #445: - DROP SCHEMA of a schema the target lacks: IF EXISTS leaves it missing, and without IF EXISTS the statement is refused. - ALTER TABLE validation skips an ADD COLUMN IF NOT EXISTS of an existing column, constraints included. - Every drop of one ALTER TABLE sees the table as it was before the statement, so a second DROP COLUMN of a key column is followed. - EXPLAIN ANALYZE CREATE MATERIALIZED VIEW records the view's reads. - PRIOR_BACKUP accepts a name qualified with the current database. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 15 ++++++++-- pg/review/rule_prior_backup.go | 2 +- pg/review/scan.go | 20 +++++++++++-- pg/review/scan_oracle_test.go | 4 +++ pg/review/target_rules_test.go | 34 ++++++++++++++++++++++ 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index e4a83549..59c75e99 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -61,10 +61,12 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { withhold := false droppedColumns := 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 for _, cmd := range drops { cascade := cmd.Behavior == int(ast.DROP_CASCADE) - t, known := s.table(v.Relation) - known = known && isTable + 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 @@ -96,7 +98,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.stop() return } - s.unsettled[[2]string{schema, name}] = true + droppedAColumn = true if cascade { s.dropReaders(t, v.Relation) s.dropReferences(v.Relation, t, known, func(fk foreignKeyRef) bool { @@ -105,6 +107,9 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } } + if droppedAColumn { + s.unsettled[[2]string{schema, name}] = true + } if withhold || knownBefore && isTable && s.refuses(before, drops, others) { if !withhold { s.stop() @@ -391,6 +396,10 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { if !ok { continue } + if cmd.Missing_ok && has(cd.Colname) { + // The column exists: the subcommand does nothing. + continue + } if (has(cd.Colname) || addedColumns[cd.Colname]) && !cmd.Missing_ok { return true } diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go index 6e1cfa30..a952c190 100644 --- a/pg/review/rule_prior_backup.go +++ b/pg/review/rule_prior_backup.go @@ -107,7 +107,7 @@ func checkPriorBackupTarget(stmts []statement, schema *metadata.DatabaseSchemaMe } continue } - } else if rv.Catalogname == "" && (created[table] || created[backupTable{name: rv.Relname}]) && !hasBackupRelation(findSchema(schema, rv.Schemaname), rv.Relname) { + } 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) diff --git a/pg/review/scan.go b/pg/review/scan.go index 149b51b5..8fa9900f 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -345,6 +345,9 @@ func (s *scan) statement(st *statement) { case *ast.CreateTableAsStmt: if q.Into != nil { s.touch(q.Into.Rel) + if q.Objtype == ast.OBJECT_MATVIEW { + s.reads(q.Into.Rel, q.Query) + } } case *ast.SelectStmt: if q.IntoClause != nil || selectsInto(q) { @@ -1099,11 +1102,22 @@ func (s *scan) drop(v *ast.DropStmt) { } if kind == ast.OBJECT_SCHEMA && v.Objects != nil { // Without CASCADE the schema must be empty; its name may then be - // created again. + // 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 { - if parts := nameParts(listOf(obj)); len(parts) == 1 { - s.schemas[parts[0]] = true + parts := nameParts(listOf(obj)) + if len(parts) != 1 { + continue + } + if s.index != nil && s.index.schemas[parts[0]] == nil && !s.schemas[parts[0]] { + if !v.Missing_ok { + s.stop() + return + } + continue } + s.schemas[parts[0]] = true } return } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index fb12e798..4c02a8cf 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -140,6 +140,10 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 90fad90b..85dca9c7 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -678,6 +678,33 @@ func TestRequirePrimaryKey(t *testing.T) { }))}}, 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: "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;", @@ -894,6 +921,13 @@ func TestPriorBackup(t *testing.T) { 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: "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;", From baec6b3e617fff9af093d4821eb62e34ccff2a5f Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 12:12:35 +0000 Subject: [PATCH 09/40] fix(pg/review): refuse CREATE VIEW collisions, follow function parameter types, and scope cascades From the eighth Codex review of #445: - CREATE SCHEMA IF NOT EXISTS with schema elements is refused, as PostgreSQL's grammar refuses it. - CREATE VIEW of a name its schema holds, CREATE OR REPLACE VIEW of a relation that is not a view, and CREATE VIEW in a missing schema are refused. - A function the change creates blocks dropping a table whose row type any of its parameters takes, not only its result. - Event-trigger matching knows ALTER SEQUENCE. - A cascading drop settles only pending tables it may drop: a synced table or one inheriting from a dropped table, and only for table drops. - A rename naming a pending table's own schema replaces its old name. - ALTER TABLE validation treats a constraint a column drop of the statement may have taken as freeing its name. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 4 ++ pg/review/rule_require_primary_key.go | 25 +++++-- pg/review/scan.go | 83 ++++++++++++++++------ pg/review/scan_oracle_test.go | 6 ++ pg/review/target_rules_test.go | 52 ++++++++++++++ 5 files changed, 142 insertions(+), 28 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 59c75e99..7861bc23 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -363,6 +363,10 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { if droppedConstraints[name] || !s.constraintKnown(t, name) || s.dropped[[3]string{t.schema, t.table, name}] { return false } + // A column drop of the statement may have taken the constraint. + if len(droppedColumns) > 0 && s.mayDependOn(t, name, droppedColumns) { + return false + } _, ok := constraint(table, name) return ok } diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 286c760d..70a389f1 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -26,10 +26,11 @@ type pendingTable struct { // or "" when it may be in any. names map[string]bool schema string - // existed marks a table of the synced schema, and inherits one made - // with INHERITS: a cascading drop of a parent may drop either. - existed bool - inherits bool + // existed marks a table of the synced schema, and parents names the + // tables one made with INHERITS inherits from: a cascading drop of a + // parent may drop either. + existed bool + parents []string statement int rng review.Range @@ -37,6 +38,20 @@ type pendingTable struct { settled bool } +// inheritsFrom names the tables CREATE TABLE ... INHERITS lists. +func inheritsFrom(v *ast.CreateStmt) []string { + if v.InhRelations == nil { + return nil + } + var names []string + for _, item := range v.InhRelations.Items { + if rv, ok := item.(*ast.RangeVar); ok { + names = append(names, rv.Relname) + } + } + return names +} + // 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. @@ -95,7 +110,7 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { s.pending = append(s.pending, &pendingTable{ names: map[string]bool{v.Relation.Relname: true}, schema: v.Relation.Schemaname, - inherits: v.InhRelations != nil && len(v.InhRelations.Items) > 0, + parents: inheritsFrom(v), statement: st.index, rng: rangeOf(v.Loc), message: "creates table " + relation(v.Relation) + " without a primary key", diff --git a/pg/review/scan.go b/pg/review/scan.go index 8fa9900f..b80ae956 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -257,6 +257,8 @@ func commandTag(n ast.Node) string { return "CREATE INDEX" case *ast.CreateFunctionStmt: return "CREATE FUNCTION" + case *ast.AlterSeqStmt: + return "ALTER SEQUENCE" case *ast.CreateSchemaStmt: return "CREATE SCHEMA" case *ast.AlterTableStmt: @@ -299,8 +301,19 @@ func (s *scan) statement(st *statement) { s.reads(v.Into.Rel, v.Query) } 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.stop() + return + } if v.Replace { - if schema, kind, ok := s.lookup(v.View); ok && kind == kindView { + schema, kind, ok := s.lookup(v.View) + if ok && kind != kindView && kind != kindAmbiguous { + s.stop() + return + } + if ok && kind == kindView { s.replacedViews[tableRef{schema, v.View.Relname}] = true } s.retire(s.newReads, tableRef{v.View.Schemaname, v.View.Relname}) @@ -553,34 +566,41 @@ func splitWith(n ast.Node) (*ast.WithClause, ast.Node) { return nil, nil } -// returns records the relation whose row type a new function returns, -// when its result type names one. +// 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 v.ReturnType == nil { - if len(fn) > 0 { - object := tableRef{table: fn[len(fn)-1]} - if len(fn) >= 2 { - object.schema = fn[len(fn)-2] - } - s.newFunctions[object]++ - } + if len(fn) == 0 { return } - parts := nameParts(v.ReturnType.Names) - if len(parts) == 0 || len(parts) > 2 || len(fn) == 0 { - return - } - rv := &ast.RangeVar{Relname: parts[len(parts)-1]} - if len(parts) == 2 { - rv.Schemaname = parts[0] - } object := tableRef{table: fn[len(fn)-1]} if len(fn) >= 2 { object.schema = fn[len(fn)-2] } s.newFunctions[object]++ - s.newReturns = append(s.newReturns, newDependency{relation: tableRef{s.schemaOf(rv), rv.Relname}, object: object, path: s.pathVersion}) + // The result type and every parameter's type may be a row type. + types := []*ast.TypeName{v.ReturnType} + if v.Parameters != nil { + for _, item := range v.Parameters.Items { + if p, ok := item.(*ast.FunctionParameter); ok { + types = append(types, p.ArgType) + } + } + } + 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}) + } } // dependsOnNew reports whether an object the change created and has not @@ -664,6 +684,11 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { } 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, is refused. if !v.IfNotExists && s.index != nil && s.index.schemas[name] != nil && !s.schemas[name] { @@ -1030,6 +1055,11 @@ func (s *scan) rename(v *ast.RenameStmt) { s.touchName(v.Relation.Schemaname, v.Relation.Relname) s.touchName(v.Relation.Schemaname, 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 { @@ -1165,10 +1195,17 @@ func (s *scan) drop(v *ast.DropStmt) { s.dropDependents(rv, schema) } } - if cascade { - // An inheritance child of a dropped table goes with it. + if cascade && (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. + dropped := make(map[string]bool) + for _, obj := range v.Objects.Items { + if parts := nameParts(listOf(obj)); len(parts) > 0 { + dropped[parts[len(parts)-1]] = true + } + } for _, p := range s.pending { - if p.inherits || p.existed { + if p.existed || slices.ContainsFunc(p.parents, func(name string) bool { return dropped[name] }) { p.settled = true } } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 4c02a8cf..122c6d68 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -144,6 +144,12 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 85dca9c7..d77c3689 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -60,6 +60,15 @@ func withFunctionReturning(db *metadata.DatabaseSchemaMetadata, schema, table st return db } +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 @@ -705,6 +714,49 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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;", From d6c7fe5969f1c08d3074ac924298dfc244276c0b Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 12:33:21 +0000 Subject: [PATCH 10/40] fix(pg/review): follow column renames as they chain, and schemas a change drops From the ninth Codex review of #445: - A column's state is the last statement's: renamed away, added, or dropped, so a chain of renames leaves the middle name gone. - A schema DROP SCHEMA removed is gone for later statements; CREATE SCHEMA IF NOT EXISTS of an existing schema changes nothing. - CREATE SEQUENCE of a name its schema holds is refused, and with IF NOT EXISTS does nothing. - Renaming a relation the change created keeps its new dependencies and foreign keys under the new name. - A new view dropped in the same DROP as the view it reads does not hold that drop. - ADD COLUMN IF NOT EXISTS of a column an earlier subcommand added does nothing. - PRIOR_BACKUP: SELECT INTO on a branch of a set operation. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 38 ++--- pg/review/rule_prior_backup.go | 20 ++- pg/review/scan.go | 156 +++++++++++++++------ pg/review/scan_oracle_test.go | 7 + pg/review/target_rules_test.go | 51 +++++++ 5 files changed, 211 insertions(+), 61 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 7861bc23..6598ae6f 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -109,6 +109,9 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } if droppedAColumn { s.unsettled[[2]string{schema, name}] = true + for column := range droppedColumns { + s.setColumn(schema, name, column, false, false) + } } if withhold || knownBefore && isTable && s.refuses(before, drops, others) { if !withhold { @@ -123,6 +126,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { 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: @@ -151,13 +155,15 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } case ast.AT_AddColumn: if cd, ok := cmd.Def.(*ast.ColumnDef); ok { - // ADD COLUMN IF NOT EXISTS of a column the table has does - // nothing, its constraints included. - if cmd.Missing_ok && knownBefore && !droppedColumns[cd.Colname] && s.hasColumn(before, cd.Colname) { + // 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 s.generated = true - s.columns[[3]string{schema, name, cd.Colname}] = true + s.setColumn(schema, name, cd.Colname, true, false) for _, c := range constraintsOf(cd.Constraints) { if c.Conname != "" { s.constraints[[3]string{schema, name, c.Conname}] = true @@ -249,17 +255,19 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt // 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] - added := hasColumnIn(s.columns, t, cmd.Name) + exists, changed := s.columnNow(t, cmd.Name) switch { - case hasColumnIn(s.renamedTo, t, cmd.Name): + case s.columnRenamed(t, cmd.Name): // A column renamed to this name: what it was is not followed. return false - case hasColumnIn(s.renamedFrom, t, cmd.Name) && !added: + case changed && !exists: return !cmd.Missing_ok + case changed: + // A column the change added is not the key's. + return false } if !slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == cmd.Name }) { - // A column the change added is not the key's. - return !cmd.Missing_ok && !added + return !cmd.Missing_ok } pk := primaryKey(table) var pkColumns []string @@ -267,7 +275,7 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out pkColumns = keyColumns(pk) } if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || slices.ContainsFunc(s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}], s.viewHolds) || - s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || dependsOnNew(s.newReads, t)) { + s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || s.dependsOnNew(s.newReads, t, nil)) { return true } if pk == nil || !s.constraintKnown(t, pk.GetName()) { @@ -438,13 +446,11 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { } // hasColumn reports whether the synced table has the column after the -// statements so far: added, renamed to it, or synced and not renamed away. +// 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 { - switch { - case hasColumnIn(s.columns, t, column) || hasColumnIn(s.renamedTo, t, column): - return true - case hasColumnIn(s.renamedFrom, t, column): - return false + 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 }) diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go index a952c190..0cf6470d 100644 --- a/pg/review/rule_prior_backup.go +++ b/pg/review/rule_prior_backup.go @@ -191,8 +191,9 @@ func createdRelation(n ast.Node) (backupTable, bool) { rv = v.Into.Rel } case *ast.SelectStmt: - if v.IntoClause != nil { - rv = v.IntoClause.Rel + // 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 { @@ -219,6 +220,21 @@ func createdRelation(n ast.Node) (backupTable, bool) { 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. diff --git a/pg/review/scan.go b/pg/review/scan.go index b80ae956..cfc339b7 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -65,14 +65,16 @@ type scan struct { // dropped lists the (schema, table, constraint) a DROP CONSTRAINT of a // resolved table removed. dropped map[[3]string]bool - // columns lists the (schema, table, column) names a statement added, - // and renamedFrom and renamedTo the old and new names of renamed - // columns, with "" for a schema the scan could not tell. - columns map[[3]string]bool - renamedFrom map[[3]string]bool - renamedTo map[[3]string]bool - // schemas lists the schemas a statement created or dropped. - schemas map[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 + // 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 @@ -108,21 +110,21 @@ type scan struct { // 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), - unsettled: make(map[[2]string]bool), - freed: make(map[[2]string]bool), - constraints: make(map[[3]string]bool), - renamedKeys: make(map[string]bool), - dropped: make(map[[3]string]bool), - columns: make(map[[3]string]bool), - renamedFrom: make(map[[3]string]bool), - renamedTo: make(map[[3]string]bool), - schemas: make(map[string]bool), + on: on, + r: r, + user: target.SessionUser, + session: target.SessionUser, + touched: make(map[[2]string]bool), + touchedName: make(map[string]bool), + unsettled: make(map[[2]string]bool), + freed: make(map[[2]string]bool), + constraints: make(map[[3]string]bool), + renamedKeys: make(map[string]bool), + dropped: make(map[[3]string]bool), + columns: make(map[[3]string]bool), + renamedColumns: make(map[[3]string]bool), + schemas: make(map[string]bool), + schemaGone: make(map[string]bool), droppedFunctions: make(map[tableRef]bool), newFunctions: make(map[tableRef]int), @@ -330,6 +332,15 @@ func (s *scan) statement(st *statement) { s.reowned[v.Sequence.Relname] = true } case *ast.CreateSeqStmt: + exists := s.existsWhereCreated(v.Sequence) + switch { + case s.schemaMissing(v.Sequence) || exists && !v.IfNotExists: + s.stop() + return + case exists: + // IF NOT EXISTS of an existing name does nothing. + return + } s.touch(v.Sequence) case *ast.CompositeTypeStmt: s.touch(v.Typevar) @@ -604,23 +615,44 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { } // dependsOnNew reports whether an object the change created and has not -// dropped may depend on the relation. -func dependsOnNew(deps []newDependency, t tableRef) bool { +// 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) { - return true + 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 { + return d.object == name && (name.schema != "" || d.path == s.pathVersion) +} + +// 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 + } + } +} + // 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 { - d := &deps[i] - if d.object == object && (object.schema != "" || d.path == s.pathVersion) { - d.retired = true + if s.sameObject(deps[i], object) { + deps[i].retired = true } } } @@ -690,12 +722,15 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { return } // CREATE SCHEMA of a schema the target has, and the change did not - // drop, is refused. - if !v.IfNotExists && s.index != nil && s.index.schemas[name] != nil && !s.schemas[name] { - s.stop() + // drop, is refused, and with IF NOT EXISTS does nothing. + if s.index != nil && s.index.schemas[name] != nil && !s.schemas[name] { + if !v.IfNotExists { + s.stop() + } return } s.schemas[name] = true + delete(s.schemaGone, name) if v.SchemaElts == nil { return } @@ -767,10 +802,31 @@ func (s *scan) isUnsettled(t tableRef) bool { return s.unsettled[[2]string{t.schema, t.table}] || s.unsettled[[2]string{"", t.table}] } -// hasColumnIn reports whether a column set holds the table's column, in -// the table's schema or in an unknown one. -func hasColumnIn(set map[[3]string]bool, t tableRef, column string) bool { - return set[[3]string{t.schema, t.table, column}] || set[[3]string{"", t.table, column}] +// 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 +} + +// 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 @@ -1066,6 +1122,17 @@ func (s *scan) rename(v *ast.RenameStmt) { 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 := "" @@ -1081,8 +1148,8 @@ func (s *scan) rename(v *ast.RenameStmt) { s.constraints[[3]string{schema, v.Relation.Relname, v.Newname}] = true case v.RenameType == ast.OBJECT_COLUMN && v.Relation != nil: schema := s.schemaOf(v.Relation) - s.renamedFrom[[3]string{schema, v.Relation.Relname, v.Subname}] = true - s.renamedTo[[3]string{schema, v.Relation.Relname, v.Newname}] = true + 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_SCHEMA: s.stop() } @@ -1140,7 +1207,7 @@ func (s *scan) drop(v *ast.DropStmt) { if len(parts) != 1 { continue } - if s.index != nil && s.index.schemas[parts[0]] == nil && !s.schemas[parts[0]] { + 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 @@ -1148,6 +1215,7 @@ func (s *scan) drop(v *ast.DropStmt) { continue } s.schemas[parts[0]] = true + s.schemaGone[parts[0]] = true } return } @@ -1335,7 +1403,7 @@ func dropKindMatches(t ast.ObjectType, kind relationKind) bool { // 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.index.schemas[rv.Schemaname] == nil && !s.schemas[rv.Schemaname] || s.schemaGone[rv.Schemaname]) } // dropsMissing reports whether a DROP without IF EXISTS names a relation @@ -1406,7 +1474,7 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { 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 []tableRef + var dropped, written []tableRef for _, obj := range v.Objects.Items { parts := nameParts(listOf(obj)) if len(parts) == 0 { @@ -1416,12 +1484,14 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { 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}) } } for _, t := range dropped { - if dependsOnNew(s.newReads, t) || dependsOnNew(s.newReturns, t) { + // 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] { diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 122c6d68..06701f49 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -150,6 +150,13 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index d77c3689..2f00e265 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -757,6 +757,50 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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;", @@ -980,6 +1024,13 @@ func TestPriorBackup(t *testing.T) { 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: "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;", From 54fa42b930b778037afdffdbd1cd725b70f44008 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 12:48:43 +0000 Subject: [PATCH 11/40] fix(pg/review): follow cascades the scan can prove, SQL-standard bodies, and no-op column drops From the tenth Codex review of #445: - A cascading key or column drop of a resolved table records the foreign keys it certainly takes as dropped, so a later DROP CONSTRAINT of one is refused; one whose columns cannot be read is only unknown. - A cascading DROP COLUMN frees the views reading the column and the views of those views, where the change has not touched them. - A function's SQL-standard body records the relations it names. - DROP CONSTRAINT of a constraint the snapshot does not list withholds the statement's findings instead of ending the scan; DROP COLUMN IF EXISTS of a missing column changes nothing. - DROP of a three-part name in another database is refused. - Event-trigger matching sees SELECT INTO on a set-operation branch; PRIOR_BACKUP sees what EXPLAIN ANALYZE creates. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 78 +++++++++++++++++----- pg/review/rule_prior_backup.go | 7 ++ pg/review/scan.go | 61 +++++++++++++---- pg/review/scan_oracle_test.go | 4 ++ pg/review/target_rules_test.go | 56 +++++++++++++++- 5 files changed, 178 insertions(+), 28 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 6598ae6f..1c61b8e4 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -80,6 +80,11 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } droppedNames[cmd.Name] = true s.retireReferences(v.Relation, cmd.Name) + // A constraint the change certainly dropped is not there to drop. + if known && !cmd.Missing_ok && s.dropped[[3]string{t.schema, t.table, cmd.Name}] { + s.stop() + return + } if known && s.constraintKnown(t, cmd.Name) && s.dropConstraint(st, v, cmd, t, cascade, &out) { s.stop() return @@ -93,6 +98,10 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { 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 if known && s.dropColumn(cmd, t, cascade, &out) { s.stop() @@ -100,9 +109,13 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } droppedAColumn = true if cascade { - s.dropReaders(t, v.Relation) - s.dropReferences(v.Relation, t, known, func(fk foreignKeyRef) bool { - return !known || slices.ContainsFunc(fk.columns, func(c string) bool { n := columnName(c); return n == cmd.Name || n == "" }) + s.dropReaders(t, known, v.Relation, cmd.Name) + s.dropReferences(v.Relation, t, known, func(fk foreignKeyRef) (bool, bool) { + if !known { + return true, false + } + certainly := slices.ContainsFunc(fk.columns, func(c string) bool { return columnName(c) == cmd.Name }) + return certainly || slices.ContainsFunc(fk.columns, func(c string) bool { return columnName(c) == "" }), certainly }) } } @@ -113,6 +126,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.setColumn(schema, name, column, false, false) } } + withhold = withhold || out.uncertain if withhold || knownBefore && isTable && s.refuses(before, drops, others) { if !withhold { s.stop() @@ -207,6 +221,8 @@ type droppedKeys struct { findings []review.Finding lost *ast.AlterTableCmd table tableRef + // uncertain marks a drop the server may refuse. + uncertain bool } // dropConstraint checks a DROP CONSTRAINT against the synced table. It @@ -218,8 +234,12 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt con, ok := constraint(table, cmd.Name) if !ok { // The snapshot leaves out NOT NULL constraints, so a constraint it - // does not list may still exist; either way nothing is reported. - return !cmd.Missing_ok + // 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 } if !cascade && (con.kind == "primary key" || con.kind == "unique constraint") { // When the key's columns cannot be read, any foreign key on the @@ -290,21 +310,46 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out return false } -// dropReaders touches the views that read the table, which a cascading -// DROP COLUMN may drop: in the table's schema when the scan resolved it, -// and in every schema the name may be in otherwise. -func (s *scan) dropReaders(t tableRef, rv *ast.RangeVar) { +// 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 } - for schema := range s.index.schemas { - if t.schema != "" && t.schema != schema || t.schema == "" && rv.Schemaname != "" && rv.Schemaname != schema { - continue + var certain, maybe []tableRef + if known { + certain = slices.Clone(s.index.readsColumn[columnRef{t.schema, t.table, column}]) + for _, view := range s.index.readers[t] { + if !slices.Contains(certain, view) { + maybe = append(maybe, view) + } + } + } else { + for schema := range s.index.schemas { + if rv.Schemaname == "" || rv.Schemaname == schema { + maybe = append(maybe, s.index.readers[tableRef{schema, rv.Relname}]...) + } } - for _, view := range s.index.readers[tableRef{schema, rv.Relname}] { - s.touchName(view.schema, view.table) + } + // The views reading a dropped view go too. + seen := make(map[tableRef]bool) + follow := func(start []tableRef, mark func(tableRef)) { + for len(start) > 0 { + view := start[0] + start = start[1:] + if seen[view] { + continue + } + seen[view] = true + mark(view) + start = append(start, s.index.readers[view]...) } } + follow(certain, s.freeView) + follow(maybe, func(v tableRef) { s.touchName(v.schema, v.table) }) } // dropKeyReferences records the foreign keys a cascading DROP CONSTRAINT @@ -321,7 +366,10 @@ func (s *scan) dropKeyReferences(rv *ast.RangeVar, t tableRef, known bool, name 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 { return sameColumns(fk.columns, con.columns) }) + 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) } diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go index 0cf6470d..cdc489b6 100644 --- a/pg/review/rule_prior_backup.go +++ b/pg/review/rule_prior_backup.go @@ -165,6 +165,13 @@ func createdRelations(n ast.Node) []backupTable { } 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} } diff --git a/pg/review/scan.go b/pg/review/scan.go index cfc339b7..e22bf8ac 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -246,7 +246,7 @@ func commandTag(n ast.Node) string { } return "CREATE TABLE AS" case *ast.SelectStmt: - if v.IntoClause != nil { + if selectsInto(v) { return "SELECT INTO" } case *ast.ViewStmt: @@ -498,10 +498,12 @@ type newDependency struct { // 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) { - s.readsIn(view, query, nil) + s.dependsIn(&s.newReads, tableRef{view.Schemaname, view.Relname}, query, nil) } -func (s *scan) readsIn(view *ast.RangeVar, n ast.Node, visible map[string]bool) { +// 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) { ast.Inspect(n, func(n ast.Node) bool { if w, rest := splitWith(n); w != nil { scope := make(map[string]bool, len(visible)) @@ -522,16 +524,16 @@ func (s *scan) readsIn(view *ast.RangeVar, n ast.Node, visible map[string]bool) } } for _, c := range ctes { - s.readsIn(view, c.Ctequery, scope) + s.dependsIn(deps, object, c.Ctequery, scope) scope[c.Ctename] = true } - s.readsIn(view, rest, scope) + s.dependsIn(deps, object, rest, scope) return false } if rv, ok := n.(*ast.RangeVar); ok && !(rv.Schemaname == "" && visible[rv.Relname]) { - s.newReads = append(s.newReads, newDependency{ + *deps = append(*deps, newDependency{ relation: tableRef{s.schemaOf(rv), rv.Relname}, - object: tableRef{view.Schemaname, view.Relname}, + object: object, path: s.pathVersion, }) } @@ -598,6 +600,11 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { } } } + // 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 @@ -1222,6 +1229,15 @@ func (s *scan) drop(v *ast.DropStmt) { if !isRelationKind(kind) && kind != ast.OBJECT_TYPE || 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 @@ -1373,6 +1389,18 @@ func (s *scan) dropsWrongKind(v *ast.DropStmt) bool { 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) { + if s.viewHolds(view) && !s.isTouched(&ast.RangeVar{Schemaname: view.schema, Relname: view.table}) { + s.free(view.schema, view.table) + return + } + s.touchName(view.schema, view.table) +} + // 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 { @@ -1585,7 +1613,7 @@ func (s *scan) dropDependents(rv *ast.RangeVar, schema string) { } seen[view] = true if schema != "" { - s.free(view.schema, view.table) + s.freeView(view) } else { s.touchName(view.schema, view.table) } @@ -1597,9 +1625,10 @@ func (s *scan) dropDependents(rv *ast.RangeVar, schema string) { // 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 depend on -// what was dropped. -func (s *scan) dropReferences(rv *ast.RangeVar, t tableRef, known bool, affected func(foreignKeyRef) bool) { +// 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 } @@ -1609,10 +1638,18 @@ func (s *scan) dropReferences(rv *ast.RangeVar, t tableRef, known bool, affected continue case !known && (fk.referenced.table != rv.Relname || rv.Schemaname != "" && fk.referenced.schema != rv.Schemaname): continue - case affected != nil && !affected(fk): + } + maybe, certainly := true, false + if affected != nil { + maybe, certainly = affected(fk) + } + if !maybe { continue } s.constraints[[3]string{fk.owner.schema, fk.owner.table, fk.name}] = true + if known && certainly { + s.dropped[[3]string{fk.owner.schema, fk.owner.table, fk.name}] = true + } } } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 06701f49..61c118d2 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -157,6 +157,10 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 2f00e265..5145c8bc 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -175,7 +175,7 @@ func TestDisallowDropConstraint(t *testing.T) { targets: one, }, { - name: "dropping a missing constraint is refused", + name: "dropping a constraint the snapshot lacks withholds the statement", sql: "ALTER TABLE t DROP CONSTRAINT t_n_check, DROP CONSTRAINT nope;", targets: one, }, @@ -331,6 +331,29 @@ func TestDisallowDropConstraint(t *testing.T) { 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}}}, + }, + { + name: "a constraint the snapshot lacks does not end the scan", + sql: "ALTER TABLE t DROP CONSTRAINT t_nn;\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}}}, + }, + { + 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: "a cascading drop of anything but a relation ends the scan", sql: "DROP FUNCTION f() CASCADE;\nALTER TABLE t DROP CONSTRAINT t_n_check;", @@ -801,6 +824,30 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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;", @@ -1031,6 +1078,13 @@ func TestPriorBackup(t *testing.T) { 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;", From 906d8b9843a026aa7652a067f8f7dd00dc6061ff Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 13:05:18 +0000 Subject: [PATCH 12/40] fix(pg/review): stop cascades at replaced views, match DROP FUNCTION signatures, and validate added keys From the eleventh Codex review of the scan: - A cascade no longer walks through a view the change replaced, at any depth, and DROP SCHEMA without CASCADE of a schema that holds relations ends the scan, since the server refuses it. - ALTER TABLE refuses an added key or foreign key on a missing column, and a no-op DROP COLUMN IF EXISTS frees no constraint name. - DROP FUNCTION with an argument list retires only the new function of that signature; a synced function goes only when no arguments are written. - Reowning a sequence is tracked per schema. - Event triggers count only for the events they fire on: sql_drop for DROP and ALTER, table_rewrite for ALTER TABLE and ALTER TYPE. - Dropping a column takes the table's own foreign key on it, so a later drop of the key it referenced is reported. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 47 ++--- pg/review/scan.go | 207 ++++++++++++++++----- pg/review/scan_oracle_test.go | 6 + pg/review/schema_index.go | 11 +- pg/review/target_rules_test.go | 70 ++++++- 5 files changed, 269 insertions(+), 72 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 1c61b8e4..2be1982d 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -108,6 +108,15 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { return } droppedAColumn = true + if known { + // 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) == cmd.Name }) { + s.constraints[[3]string{t.schema, t.table, fk.name}] = true + s.dropped[[3]string{t.schema, t.table, fk.name}] = true + } + } + } if cascade { s.dropReaders(t, known, v.Relation, cmd.Name) s.dropReferences(v.Relation, t, known, func(fk foreignKeyRef) (bool, bool) { @@ -127,7 +136,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } withhold = withhold || out.uncertain - if withhold || knownBefore && isTable && s.refuses(before, drops, others) { + if withhold || knownBefore && isTable && s.refuses(before, droppedColumns, droppedNames, others) { if !withhold { s.stop() return @@ -334,22 +343,7 @@ func (s *scan) dropReaders(t tableRef, known bool, rv *ast.RangeVar, column stri } } } - // The views reading a dropped view go too. - seen := make(map[tableRef]bool) - follow := func(start []tableRef, mark func(tableRef)) { - for len(start) > 0 { - view := start[0] - start = start[1:] - if seen[view] { - continue - } - seen[view] = true - mark(view) - start = append(start, s.index.readers[view]...) - } - } - follow(certain, s.freeView) - follow(maybe, func(v tableRef) { s.touchName(v.schema, v.table) }) + s.cascadeViews(certain, maybe) } // dropKeyReferences records the foreign keys a cascading DROP CONSTRAINT @@ -398,17 +392,8 @@ func (s *scan) mayDependOn(t tableRef, name string, columns map[string]bool) boo // altering a column it lacks, or adding a constraint under a name it // uses, or a second primary key. Other failures, such as a type error, // are not known before the statement runs. -func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { +func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string]bool, others []*ast.AlterTableCmd) bool { table := s.index.schemas[t.schema].tables[t.table] - droppedColumns := make(map[string]bool) - droppedConstraints := make(map[string]bool) - for _, cmd := range drops { - if ast.AlterTableType(cmd.Subtype) == ast.AT_DropColumn { - droppedColumns[cmd.Name] = true - } else { - droppedConstraints[cmd.Name] = true - } - } // has reports whether the table has the column once the drops ran. has := func(column string) bool { return !droppedColumns[column] && s.hasColumn(t, column) @@ -435,6 +420,14 @@ func (s *scan) refuses(t tableRef, drops, others []*ast.AlterTableCmd) bool { return addedKey || pk != nil && uses(pk.GetName()) } addKey := func(c *ast.Constraint) bool { + // A key or foreign key names columns the table must have. + for _, list := range []*ast.List{c.Keys, c.FkAttrs} { + for _, column := range nameParts(list) { + if !has(column) && !addedColumns[column] { + return true + } + } + } if c.Conname != "" { if uses(c.Conname) || addedNames[c.Conname] { return true diff --git a/pg/review/scan.go b/pg/review/scan.go index e22bf8ac..656334c9 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -94,7 +94,7 @@ type scan struct { // redefined, whose synced reads no longer hold, and reowned the // sequences, by name, whose owner ALTER SEQUENCE changed. replacedViews map[tableRef]bool - reowned map[string]bool + reowned map[[2]string]bool // generated marks that a statement made relations 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. @@ -129,7 +129,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, droppedFunctions: make(map[tableRef]bool), newFunctions: make(map[tableRef]int), replacedViews: make(map[tableRef]bool), - reowned: make(map[string]bool), + reowned: make(map[[2]string]bool), } if target.Schema != nil { s.index = newSchemaIndex(target.Schema) @@ -173,20 +173,44 @@ func eventTriggerFires(schema *metadata.DatabaseSchemaMetadata, stmts []statemen if len(triggers) == 0 { return false } - tags := make(map[string]bool) + // The tags of the statements that may raise each event: every DDL + // command raises ddl_command_start and ddl_command_end, a command + // that may drop objects sql_drop, and ALTER TABLE or ALTER TYPE + // table_rewrite. + all := make(map[string]bool) + drops := make(map[string]bool) + rewrites := make(map[string]bool) for i := range stmts { - if tag := commandTag(stmts[i].node); tag != "" { - tags[tag] = true - // CREATE FUNCTION and CREATE PROCEDURE share a node. - if tag == "CREATE FUNCTION" { - tags["CREATE PROCEDURE"] = true + tag := commandTag(stmts[i].node) + if tag == "" { + continue + } + tags := []string{tag} + // CREATE FUNCTION and CREATE PROCEDURE share a node. + if tag == "CREATE FUNCTION" { + tags = append(tags, "CREATE PROCEDURE") + } + for _, t := range tags { + all[t] = true + if strings.HasPrefix(t, "DROP ") || strings.HasPrefix(t, "ALTER ") { + drops[t] = true + } + if t == "ALTER TABLE" || t == "ALTER TYPE" { + rewrites[t] = true } } } - if len(tags) == 0 { - return false - } for _, t := range triggers { + tags := all + switch strings.ToUpper(t.GetEvent()) { + case "SQL_DROP": + tags = drops + case "TABLE_REWRITE": + tags = rewrites + } + if len(tags) == 0 { + continue + } if len(t.GetTags()) == 0 { return true } @@ -329,7 +353,7 @@ func (s *scan) statement(st *statement) { d, ok := n.(*ast.DefElem) return ok && d.Defname == "owned_by" }) { - s.reowned[v.Sequence.Relname] = true + s.reowned[[2]string{s.schemaOf(v.Sequence), v.Sequence.Relname}] = true } case *ast.CreateSeqStmt: exists := s.existsWhereCreated(v.Sequence) @@ -491,6 +515,8 @@ type newDependency struct { object tableRef path int retired bool + // signature is a new function's argument types, as written. + signature string } // reads records the relations a new view's query names. An unqualified @@ -593,13 +619,25 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { s.newFunctions[object]++ // 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}) + 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 { @@ -654,6 +692,32 @@ func (s *scan) retarget(deps []newDependency, old tableRef, name string) { } } +// 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 { + types = append(types, strings.Join(nameParts(t.Names), ".")) + } 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) { @@ -1221,6 +1285,10 @@ func (s *scan) drop(v *ast.DropStmt) { } continue } + if s.schemaHolds(parts[0]) { + s.stop() + return + } s.schemas[parts[0]] = true s.schemaGone[parts[0]] = true } @@ -1319,8 +1387,10 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { if len(parts) == 2 { fn.schema = parts[0] } - // An overload the change created is retired only when the name - // leaves no choice of which function was dropped. + // 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. synced := 0 if s.index != nil { for name, n := range s.index.functions { @@ -1330,9 +1400,13 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { } } if s.newFunctions[fn] == 1 && synced == 0 { - s.retire(s.newReturns, fn) + if owa.ArgsUnspecified { + s.retire(s.newReturns, fn) + } else { + s.retireSignature(fn, argSignature(owa.Objargs)) + } } - if s.index == nil || s.newFunctions[fn] > 0 { + if s.index == nil || s.newFunctions[fn] > 0 || !owa.ArgsUnspecified { continue } if fn.schema != "" { @@ -1393,12 +1467,45 @@ func (s *scan) dropsWrongKind(v *ast.DropStmt) bool { // 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) { +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 + 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]...) + } else { + maybe = append(maybe, s.index.readers[view]...) + } + continue + } + view := maybe[0] + maybe = maybe[1:] + if seen[view] { + continue + } + seen[view] = true + s.touchName(view.schema, view.table) + maybe = append(maybe, s.index.readers[view]...) + } } // viewHolds reports whether a synced view may still read what the snapshot @@ -1427,6 +1534,37 @@ func dropKindMatches(t ast.ObjectType, kind relationKind) bool { return true } +// schemaHolds reports whether a synced schema certainly still holds an +// object, so that DROP SCHEMA without CASCADE is refused: 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 || s.schemas[name] { + return false + } + ns := s.index.schemas[name] + if ns == nil { + 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 { @@ -1560,7 +1698,7 @@ func (s *scan) dropOwned(rv *ast.RangeVar, schema string, table bool) { switch { case schema != "" && name == schema: for _, d := range ns.dependents[rv.Relname] { - if s.reowned[d] { + if s.reowned[[2]string{name, d}] || s.reowned[[2]string{"", d}] { s.touchName(name, d) } else { s.free(name, d) @@ -1592,34 +1730,17 @@ func (s *scan) dropDependents(rv *ast.RangeVar, schema string) { if s.index == nil { return } - var start []tableRef if schema != "" { - start = []tableRef{{schema, rv.Relname}} - } else { - for name := range s.index.schemas { - if rv.Schemaname == "" || rv.Schemaname == name { - start = append(start, tableRef{name, rv.Relname}) - } - } + s.cascadeViews(s.index.readers[tableRef{schema, rv.Relname}], nil) + return } - // The views reading a dropped view go too. - seen := make(map[tableRef]bool) - for len(start) > 0 { - t := start[0] - start = start[1:] - for _, view := range s.index.readers[t] { - if seen[view] { - continue - } - seen[view] = true - if schema != "" { - s.freeView(view) - } else { - s.touchName(view.schema, view.table) - } - start = append(start, view) + var maybe []tableRef + for name := range s.index.schemas { + if rv.Schemaname == "" || rv.Schemaname == name { + maybe = append(maybe, s.index.readers[tableRef{name, rv.Relname}]...) } } + s.cascadeViews(nil, maybe) } // dropReferences records that foreign keys a cascading drop takes may be diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 61c118d2..857cc918 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -161,6 +161,12 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 64b1c5ea..6f0b3ecf 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -40,6 +40,10 @@ type namespace struct { // 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 } type relationKind int @@ -63,6 +67,7 @@ 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 @@ -103,7 +108,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { if ref.schema == "" { ref.schema = s.GetName() } - idx.foreignKeys = append(idx.foreignKeys, foreignKeyRef{owner: tableRef{s.GetName(), t.GetName()}, name: fk.GetName(), referenced: ref, columns: fk.GetReferencedColumns()}) + 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() { @@ -130,6 +135,10 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { 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 _, f := range s.GetFunctions() { fn := tableRef{s.GetName(), f.GetName()} idx.functions[fn]++ diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 5145c8bc..36edfd7b 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -354,6 +354,22 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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;", @@ -564,7 +580,7 @@ func TestRequirePrimaryKey(t *testing.T) { }, { name: "a function the change drops no longer returns the table", - sql: "DROP FUNCTION f();\nDROP TABLE nokey;\nCREATE TABLE n (id int);", + 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}}}, }, @@ -848,6 +864,58 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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 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;", From a725735b9129086fa5698e0969a47e567d06dc09 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 13:27:58 +0000 Subject: [PATCH 13/40] fix(pg/review): validate referenced keys, refuse colliding CREATE SCHEMA elements, and match event triggers to the events they see From the twelfth Codex review of the scan: - An added foreign key whose referenced side the server certainly refuses (a missing relation or column, a relation that is not a table, a different column count, no primary key or no unique index on exactly those columns, on a table no statement may have given one) refuses the ALTER TABLE. - CREATE SCHEMA whose elements share a name, sit in another schema, or may collide with a generated name is refused as a whole. - A cascading drop also takes the views the change created that read what it drops, and frees their names when nothing used them since. - CREATE EXTENSION ends the scan, unless IF NOT EXISTS names an installed extension. - Event triggers: sql_drop counts only for statements that may drop (an ALTER TABLE only for subcommands that may), table_rewrite for any ALTER TABLE, and DDL without a named command tag fires every trigger. TestEventTriggerEventsAgainstPostgres pins this against the server. Co-Authored-By: Claude Opus 5.5 --- pg/review/event_trigger_oracle_test.go | 119 +++++++++ pg/review/rule_disallow_drop_constraint.go | 91 +++++++ pg/review/scan.go | 290 ++++++++++++++++++--- pg/review/scan_oracle_test.go | 10 + pg/review/schema_index.go | 6 + pg/review/target_rules_test.go | 134 +++++++++- 6 files changed, 609 insertions(+), 41 deletions(-) create mode 100644 pg/review/event_trigger_oracle_test.go diff --git a/pg/review/event_trigger_oracle_test.go b/pg/review/event_trigger_oracle_test.go new file mode 100644 index 00000000..2e7e474e --- /dev/null +++ b/pg/review/event_trigger_oracle_test.go @@ -0,0 +1,119 @@ +package review + +import ( + "context" + "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)`, + `CREATE FUNCTION ev_note() RETURNS event_trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO ev_log VALUES (lower(tg_event)); END $$`, + `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()`, + } { + 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 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 TABLE ev_c AS SELECT 1 AS a", + "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`) + if err != nil { + t.Fatal(err) + } + raised := make(map[string]bool) + for rows.Next() { + var event string + if err := rows.Scan(&event); err != nil { + t.Fatal(err) + } + raised[event] = true + } + 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 + 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) + } + if raised["table_rewrite"] && !mayRewrite(n) { + t.Errorf("%s raised table_rewrite, which the scan does not expect", sql) + } + } +} diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 2be1982d..639e90b3 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -2,6 +2,7 @@ package review import ( "slices" + "strings" "github.com/bytebase/omni/metadata" "github.com/bytebase/omni/pg/ast" @@ -174,6 +175,9 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { if c.Contype == ast.CONSTR_PRIMARY { s.keyed(v.Relation) } + if c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE { + s.newKey(v.Relation) + } s.references(v.Relation, c) } case ast.AT_AddColumn: @@ -194,6 +198,9 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { if c.Contype == ast.CONSTR_PRIMARY { s.keyed(v.Relation) } + if c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE { + s.newKey(v.Relation) + } } s.references(v.Relation, constraintsOf(cd.Constraints)...) } @@ -205,6 +212,9 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { if idx.Primary { s.keyed(v.Relation) } + if idx.Primary || idx.Unique { + s.newKey(v.Relation) + } } case ast.AT_AttachPartition: if pc, ok := cmd.Def.(*ast.PartitionCmd); ok && pc.Name != nil { @@ -428,6 +438,9 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string } } } + if c.Contype == ast.CONSTR_FOREIGN && s.refusesReference(t, c, func(column string) bool { return has(column) || addedColumns[column] }) { + return true + } if c.Conname != "" { if uses(c.Conname) || addedNames[c.Conname] { return true @@ -486,6 +499,84 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string return false } +// 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, +// on a table no statement of the change may have given a key, no primary +// key, or no unique index on exactly the columns named. own says whether +// t has a column once the statement's own drops and additions 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) bool { + if c.Pktable == nil { + return false + } + 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 + } + if s.missing(c.Pktable) { + return true + } + schema, kind, ok := s.lookup(c.Pktable) + if !ok { + return false + } + ref := tableRef{schema, c.Pktable.Relname} + if ref == t { + // Its key may be one the statement adds. + return slices.ContainsFunc(columns, func(column string) bool { return !own(column) }) + } + switch kind { + case kindTable: + case kindView, kindMatView, kindForeignTable, kindSequence, kindIndex, kindCompositeType: + return true + default: + return false + } + if s.isUnsettled(ref) { + return false + } + if slices.ContainsFunc(columns, func(column string) bool { return !s.hasColumn(ref, column) }) { + return true + } + if s.newKeys[[2]string{ref.schema, ref.table}] || s.newKeys[[2]string{"", ref.table}] { + return false + } + 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 + } + key := keyColumns(pk) + return key != nil && len(key) != local + } + for _, i := range table.GetIndexes() { + // A partial index is no key a foreign key can reference. + if !i.GetUnique() && !i.GetPrimary() || !s.indexHolds(ref, i) || strings.Contains(strings.ToUpper(i.GetDefinition()), " WHERE ") { + continue + } + if key := keyColumns(i); key != nil && len(key) == len(columns) && !slices.ContainsFunc(columns, func(column string) bool { return !slices.Contains(key, column) }) { + return false + } + } + return true +} + +// 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. diff --git a/pg/review/scan.go b/pg/review/scan.go index 656334c9..d5c76155 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -49,6 +49,10 @@ type scan struct { // 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 @@ -95,6 +99,10 @@ type scan struct { // 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 marks that a statement made relations 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. @@ -116,6 +124,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, 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), @@ -130,6 +139,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, newFunctions: make(map[tableRef]int), replacedViews: make(map[tableRef]bool), reowned: make(map[[2]string]bool), + newKeys: make(map[[2]string]bool), } if target.Schema != nil { s.index = newSchemaIndex(target.Schema) @@ -159,10 +169,11 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, } // eventTriggerFires reports whether an enabled event trigger of the target -// fires on a statement of the change the scan follows: one with no tag -// filter, or one whose tags name the statement's command. Its function may -// run more DDL or reject the statement, so what the statement does is no -// longer what it says. +// may fire on a statement of the change: 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 what the statement does is +// no longer what it says. func eventTriggerFires(schema *metadata.DatabaseSchemaMetadata, stmts []statement) bool { var triggers []*metadata.EventTriggerMetadata for _, t := range schema.GetEventTriggers() { @@ -173,58 +184,131 @@ func eventTriggerFires(schema *metadata.DatabaseSchemaMetadata, stmts []statemen if len(triggers) == 0 { return false } - // The tags of the statements that may raise each event: every DDL - // command raises ddl_command_start and ddl_command_end, a command - // that may drop objects sql_drop, and ALTER TABLE or ALTER TYPE - // table_rewrite. - all := make(map[string]bool) - drops := make(map[string]bool) - rewrites := make(map[string]bool) + // The events each DDL statement may raise: every one raises + // ddl_command_start and ddl_command_end, one that may drop objects + // sql_drop, and one that may rewrite a table table_rewrite. + type raised struct { + tags []string + drops, rewrites bool + } + var events []raised for i := range stmts { - tag := commandTag(stmts[i].node) - if tag == "" { + n := stmts[i].node + if !isDDL(n) { continue } - tags := []string{tag} - // CREATE FUNCTION and CREATE PROCEDURE share a node. - if tag == "CREATE FUNCTION" { - tags = append(tags, "CREATE PROCEDURE") + e := raised{drops: mayDrop(n), rewrites: mayRewrite(n)} + if tag := commandTag(n); tag != "" { + e.tags = []string{tag} + // CREATE FUNCTION and CREATE PROCEDURE share a node. + if tag == "CREATE FUNCTION" { + e.tags = append(e.tags, "CREATE PROCEDURE") + } } - for _, t := range tags { - all[t] = true - if strings.HasPrefix(t, "DROP ") || strings.HasPrefix(t, "ALTER ") { - drops[t] = true + events = append(events, e) + } + for _, t := range triggers { + for _, e := range events { + switch strings.ToUpper(t.GetEvent()) { + case "SQL_DROP": + if !e.drops { + continue + } + case "TABLE_REWRITE": + if !e.rewrites { + continue + } } - if t == "ALTER TABLE" || t == "ALTER TYPE" { - rewrites[t] = true + if len(t.GetTags()) == 0 || len(e.tags) == 0 { + return true + } + for _, tag := range t.GetTags() { + if slices.Contains(e.tags, strings.ToUpper(tag)) { + return true + } } } } - for _, t := range triggers { - tags := all - switch strings.ToUpper(t.GetEvent()) { - case "SQL_DROP": - tags = drops - case "TABLE_REWRITE": - tags = rewrites - } - if len(tags) == 0 { - continue + return false +} + +// 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) } - if len(t.GetTags()) == 0 { - return true + 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: + 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 _, tag := range t.GetTags() { - if tags[strings.ToUpper(tag)] { + 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 false } -// commandTag is the command tag of a statement the scan follows, as an -// event trigger's tag filter names it, or "" for one it does not follow. +// mayRewrite reports whether a DDL statement may rewrite a table, which +// raises table_rewrite: an ALTER TABLE of any kind of relation or ALTER +// TYPE, or a statement the scan does not name. +func mayRewrite(n ast.Node) bool { + if _, ok := n.(*ast.AlterTableStmt); ok { + return true + } + return commandTag(n) == "" +} + +// 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 { @@ -301,6 +385,23 @@ func commandTag(n ast.Node) string { 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 "" } @@ -369,6 +470,9 @@ func (s *scan) statement(st *statement) { case *ast.CompositeTypeStmt: s.touch(v.Typevar) case *ast.IndexStmt: + if (v.Unique || v.Primary) && v.Relation != nil { + s.newKey(v.Relation) + } if v.Idxname == "" { s.generated = true } else if v.Relation != nil { @@ -417,6 +521,12 @@ func (s *scan) statement(st *statement) { 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() @@ -517,6 +627,10 @@ type newDependency struct { 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 @@ -524,7 +638,36 @@ type newDependency struct { // 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) { + 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 @@ -805,6 +948,10 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { 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 @@ -836,6 +983,59 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { } } +// 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, generates = e.Relation, true + 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 +} + // touch records a relation name a statement creates. func (s *scan) touch(rv *ast.RangeVar) { if rv != nil { @@ -848,6 +1048,8 @@ func (s *scan) touch(rv *ast.RangeVar) { 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) @@ -861,6 +1063,12 @@ func (s *scan) free(schema, name string) { 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) { @@ -1345,6 +1553,9 @@ func (s *scan) drop(v *ast.DropStmt) { s.dropOwned(rv, schema, resolved && relKind == kindTable) if cascade { s.dropDependents(rv, schema) + if resolved { + s.dropNewReaders(tableRef{schema, rv.Relname}) + } } } if cascade && (kind == ast.OBJECT_TABLE || kind == ast.OBJECT_FOREIGN_TABLE) { @@ -1492,6 +1703,7 @@ func (s *scan) cascadeViews(certain, maybe []tableRef) { 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]...) } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 857cc918..c5962d5c 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -167,6 +167,16 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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);", 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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 6f0b3ecf..897e366c 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -27,6 +27,8 @@ type schemaIndex struct { functions map[tableRef]int // constraintIndexes marks the indexes a constraint owns. constraintIndexes map[tableRef]bool + // extensions lists the installed extensions by name. + extensions map[string]bool } // namespace is one schema: the kind of every name in its relation @@ -83,6 +85,10 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { functions: make(map[tableRef]int), constraintIndexes: make(map[tableRef]bool), + extensions: make(map[string]bool), + } + for _, e := range db.GetExtensions() { + idx.extensions[e.GetName()] = true } for _, s := range db.GetSchemas() { ns := &namespace{ diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 36edfd7b..7ed105e1 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -74,6 +74,11 @@ func withEventTrigger(db *metadata.DatabaseSchemaMetadata, enabled bool, tags .. 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 @@ -354,6 +359,58 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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}}}, + }, + { + name: "an added foreign key to a unique index the change made", + sql: "CREATE UNIQUE INDEX ON p (note);\nALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT good FOREIGN KEY (code) REFERENCES p (note);", + targets: one, + want: []targetFinding{{1, "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);", @@ -537,13 +594,59 @@ func TestRequirePrimaryKey(t *testing.T) { }, { name: "the tables of CREATE SCHEMA", - sql: "CREATE SCHEMA z CREATE TABLE t (id int) CREATE TABLE k (id int PRIMARY KEY) CREATE TABLE s.q (id int);", + 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 s.q (id int)", "creates table s.q 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);", @@ -906,6 +1009,33 @@ func TestRequirePrimaryKey(t *testing.T) { )}}, 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);", From 6da2600cc327229cf34de9a128fe516fbe348e0f Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 13:48:36 +0000 Subject: [PATCH 14/40] fix(pg/review): follow ALTER TABLE passes, routine renames and cascades, and names that generate nothing From the thirteenth Codex review of the scan: - refuses runs the server's passes: every added column before any constraint, every key before a foreign key. A foreign key to its own table needs a key the table keeps or the statement adds, and one to a table whose keys the scan cannot tell withholds the statement's findings. - DROP FUNCTION, PROCEDURE, or ROUTINE of a name the target certainly lacks ends the scan; DROP PROCEDURE retires the procedure's dependencies; a function renamed by ALTER keeps its own. - A cascading drop takes the functions on the relation's row type: a synced one when it is the only function of its name, and the change's own. - Only a CREATE TABLE that can generate a name (an unnamed key, a serial or identity column, LIKE, PARTITION OF) marks names as generated; an index or key on a table that may have partitions does too. - LIKE of a keyless table the change made copies no key, and CREATE TABLE IF NOT EXISTS in a schema the change created creates the table. - CREATE TABLE or VIEW of a name an enum type has, or a type the change created, is refused, IF NOT EXISTS or not. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 247 ++++++++++++------- pg/review/rule_require_primary_key.go | 30 ++- pg/review/scan.go | 261 +++++++++++++++++++-- pg/review/scan_oracle_test.go | 15 +- pg/review/schema_index.go | 11 +- pg/review/target_rules_test.go | 127 +++++++++- 6 files changed, 585 insertions(+), 106 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 639e90b3..5270cb90 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -137,11 +137,15 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } withhold = withhold || out.uncertain - if withhold || knownBefore && isTable && s.refuses(before, droppedColumns, droppedNames, others) { - if !withhold { + if knownBefore && isTable { + refused, uncertain := s.refuses(before, droppedColumns, droppedNames, others) + if refused && !withhold { s.stop() return } + withhold = withhold || refused || uncertain + } + if withhold { out = droppedKeys{} } for _, f := range out.findings { @@ -163,8 +167,12 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { case c.Conname == "": s.generated = true default: - // A key's index takes the constraint's name. + // 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.generated = true + } } if c.Indexname != "" { // USING INDEX renames the index to the constraint. @@ -189,12 +197,17 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { continue } addedHere[cd.Colname] = true - s.generated = true + if columnGeneratesNames(cd) || s.mayHavePartitions(v.Relation) { + s.generated = true + } s.setColumn(schema, name, cd.Colname, true, false) for _, c := range constraintsOf(cd.Constraints) { if c.Conname != "" { s.constraints[[3]string{schema, name, c.Conname}] = true } + if makesIndex(c) && c.Conname != "" { + s.touchName(v.Relation.Schemaname, c.Conname) + } if c.Contype == ast.CONSTR_PRIMARY { s.keyed(v.Relation) } @@ -217,6 +230,8 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } case ast.AT_AttachPartition: + // The partition gets the parent's indexes under generated names. + s.generated = true if pc, ok := cmd.Def.(*ast.PartitionCmd); ok && pc.Name != nil { s.unsettle(pc.Name) for _, p := range s.matching(pc.Name) { @@ -399,10 +414,19 @@ func (s *scan) mayDependOn(t tableRef, name string, columns map[string]bool) boo // 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, or adding a constraint under a name it -// uses, or a second primary key. Other failures, such as a type error, -// are not known before the statement runs. -func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string]bool, others []*ast.AlterTableCmd) bool { +// 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, 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 { @@ -421,40 +445,12 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string _, ok := constraint(table, name) return ok } - // What the statement's own additions add counts too. + // The columns and keys the statement adds, and the constraints it adds + // with them. addedColumns := make(map[string]bool) - addedNames := make(map[string]bool) - addedKey := false - keyed := func() bool { - pk := primaryKey(table) - return addedKey || pk != nil && uses(pk.GetName()) - } - addKey := func(c *ast.Constraint) bool { - // A key or foreign key names columns the table must have. - for _, list := range []*ast.List{c.Keys, c.FkAttrs} { - for _, column := range nameParts(list) { - if !has(column) && !addedColumns[column] { - return true - } - } - } - if c.Contype == ast.CONSTR_FOREIGN && s.refusesReference(t, c, func(column string) bool { return has(column) || addedColumns[column] }) { - return true - } - if c.Conname != "" { - if uses(c.Conname) || addedNames[c.Conname] { - return true - } - addedNames[c.Conname] = true - } - if c.Contype == ast.CONSTR_PRIMARY { - if keyed() { - return true - } - addedKey = true - } - return false - } + var addedKeys [][]string + addsPrimary, addsIndex := false, false + var constraints []*ast.Constraint for _, cmd := range others { switch ast.AlterTableType(cmd.Subtype) { case ast.AT_AddColumn: @@ -462,19 +458,72 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string if !ok { continue } - if cmd.Missing_ok && has(cd.Colname) { - // The column exists: the subcommand does nothing. - continue - } - if (has(cd.Colname) || addedColumns[cd.Colname]) && !cmd.Missing_ok { - return true + 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 addKey(c) { - return true + 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 || 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 && uses(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 droppedColumns[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 && uses(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: @@ -484,89 +533,123 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string continue } if !has(cmd.Name) { - return true + return true, false } - case ast.AT_AddConstraint: - c, ok := cmd.Def.(*ast.Constraint) - if !ok { - continue + } + } + for _, c := range constraints { + // A key or foreign key names columns the table must have. + for _, list := range []*ast.List{c.Keys, c.FkAttrs} { + for _, column := range nameParts(list) { + if !has(column) && !addedColumns[column] { + return true, false + } } - if addKey(c) { - return true + } + if c.Conname != "" { + if uses(c.Conname) || addedNames[c.Conname] { + return true, false } + addedNames[c.Conname] = true + } + 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 + 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, -// on a table no statement of the change may have given a key, no primary -// key, or no unique index on exactly the columns named. own says whether -// t has a column once the statement's own drops and additions ran, for a +// 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) bool { +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 + 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 + return true, false } if s.missing(c.Pktable) { - return true + return true, false } schema, kind, ok := s.lookup(c.Pktable) if !ok { - return false + return false, true } ref := tableRef{schema, c.Pktable.Relname} if ref == t { - // Its key may be one the statement adds. - return slices.ContainsFunc(columns, func(column string) bool { return !own(column) }) + 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 + return true, false default: - return false + return false, true } if s.isUnsettled(ref) { - return false + return false, true } if slices.ContainsFunc(columns, func(column string) bool { return !s.hasColumn(ref, column) }) { - return true + return true, false } if s.newKeys[[2]string{ref.schema, ref.table}] || s.newKeys[[2]string{"", ref.table}] { - return false + 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 + return true, false } key := keyColumns(pk) - return key != nil && len(key) != local + return key != nil && len(key) != local, false } for _, i := range table.GetIndexes() { - // A partial index is no key a foreign key can reference. - if !i.GetUnique() && !i.GetPrimary() || !s.indexHolds(ref, i) || strings.Contains(strings.ToUpper(i.GetDefinition()), " WHERE ") { - continue - } - if key := keyColumns(i); key != nil && len(key) == len(columns) && !slices.ContainsFunc(columns, func(column string) bool { return !slices.Contains(key, column) }) { - return false + if s.isKey(ref, i) && sameKey(keyColumns(i), columns) { + return false, false } } - return true + return true, 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 diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 70a389f1..5d868faf 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -32,6 +32,9 @@ type pendingTable struct { existed bool parents []string + // made numbers the touch that created a table the change made. + made int + statement int rng review.Range message string @@ -117,11 +120,32 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { }) } +// 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 +} + // 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. An unqualified name is created in the first schema of the -// search path that exists. +// 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 @@ -245,6 +269,8 @@ func (s *scan) createsKey(v *ast.CreateStmt) (keyed, known bool) { } 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: diff --git a/pg/review/scan.go b/pg/review/scan.go index d5c76155..26b46ca9 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -94,6 +94,9 @@ type scan struct { // 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. + madeRoutines map[string]bool // 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. @@ -137,6 +140,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, droppedFunctions: make(map[tableRef]bool), newFunctions: make(map[tableRef]int), + madeRoutines: make(map[string]bool), replacedViews: make(map[tableRef]bool), reowned: make(map[[2]string]bool), newKeys: make(map[[2]string]bool), @@ -334,6 +338,8 @@ func commandTag(n ast.Node) string { return "FUNCTION" case ast.OBJECT_ROUTINE: return "ROUTINE" + case ast.OBJECT_PROCEDURE: + return "PROCEDURE" } return "" } @@ -430,7 +436,7 @@ func (s *scan) statement(st *statement) { 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) { + if s.schemaMissing(v.View) || !v.Replace && s.existsWhereCreated(v.View) || s.typeExists(v.View) { s.stop() return } @@ -469,13 +475,28 @@ func (s *scan) statement(st *statement) { s.touch(v.Sequence) case *ast.CompositeTypeStmt: s.touch(v.Typevar) + case *ast.CreateEnumStmt: + // A type's name is taken for a relation's row type too. + s.touchType(v.TypeName, "") + case *ast.CreateDomainStmt: + s.touchType(v.Domainname, "") + 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 (v.Unique || v.Primary) && v.Relation != nil { s.newKey(v.Relation) } - if v.Idxname == "" { + if v.Idxname == "" || v.Relation == nil || s.mayHavePartitions(v.Relation) { + // A partition's index takes a generated name. s.generated = true - } else if v.Relation != nil { + } + if v.Idxname != "" && v.Relation != nil { s.touchName(v.Relation.Schemaname, v.Idxname) } case *ast.SelectStmt: @@ -652,6 +673,7 @@ func (s *scan) reads(view *ast.RangeVar, query ast.Node) { // 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 { @@ -760,6 +782,7 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { object.schema = fn[len(fn)-2] } s.newFunctions[object]++ + s.madeRoutines[object.table] = true // The result type and every parameter's type may be a row type. types := []*ast.TypeName{v.ReturnType} var args []ast.Node @@ -883,18 +906,25 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { s.stop() } return - case s.schemaMissing(v.Relation) || exists && !v.IfNotExists: + 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 } + pending := len(s.pending) if !s.nameUncertain(v.Relation) { s.createTable(st, v) } s.touch(v.Relation) - s.generated = true + for _, p := range s.pending[pending:] { + p.made = s.lastTouch[v.Relation.Relname] + } + if generatesNames(v) { + s.generated = true + } if v.TableElts == nil { return } @@ -998,7 +1028,8 @@ func elementsCollide(schema string, elts *ast.List) bool { name := "" switch e := elt.(type) { case *ast.CreateStmt: - rv, generates = e.Relation, true + rv = e.Relation + generates = generates || generatesNames(e) case *ast.ViewStmt: rv = e.View case *ast.CreateSeqStmt: @@ -1036,6 +1067,99 @@ func elementsCollide(schema string, elts *ast.List) bool { 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. +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] + } + s.touchName(schema, parts[len(parts)-1]) + if suffix != "" { + s.touchName(schema, parts[len(parts)-1]+suffix) + } +} + +// 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 +} + +// 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 +} + // touch records a relation name a statement creates. func (s *scan) touch(rv *ast.RangeVar) { if rv != nil { @@ -1429,6 +1553,8 @@ func (s *scan) rename(v *ast.RenameStmt) { 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: + s.renameRoutine(v) case v.RenameType == ast.OBJECT_SCHEMA: s.stop() } @@ -1454,6 +1580,12 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { 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 owa, ok := v.Object.(*ast.ObjectWithArgs); ok { + if parts := nameParts(owa.Objname); len(parts) > 0 { + s.madeRoutines[parts[len(parts)-1]] = true + } + } case v.ObjectType == ast.OBJECT_EXTENSION: s.stop() } @@ -1472,7 +1604,7 @@ func (s *scan) drop(v *ast.DropStmt) { s.stop() return } - if kind == ast.OBJECT_FUNCTION || kind == ast.OBJECT_ROUTINE { + if kind == ast.OBJECT_FUNCTION || kind == ast.OBJECT_PROCEDURE || kind == ast.OBJECT_ROUTINE { s.dropFunctions(v) return } @@ -1598,19 +1730,16 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { if len(parts) == 2 { fn.schema = parts[0] } + // A routine the target certainly lacks is not there to drop. + if s.routineMissing(fn) { + s.stop() + return + } // 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. - synced := 0 - if s.index != nil { - for name, n := range s.index.functions { - if name.table == fn.table && (fn.schema == "" || name.schema == fn.schema) { - synced += n - } - } - } - if s.newFunctions[fn] == 1 && synced == 0 { + if s.newFunctions[fn] == 1 && s.syncedFunctions(fn) == 0 { if owa.ArgsUnspecified { s.retire(s.newReturns, fn) } else { @@ -1643,6 +1772,106 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { } } +// 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 +} + +// 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) bool { + if s.index == nil || s.madeRoutines[fn.table] { + return false + } + if fn.schema != "" { + return s.index.schemas[fn.schema] != nil && !s.schemas[fn.schema] && !s.index.routineNames[fn] + } + path, ok := s.searchPath() + if !ok { + return false + } + for _, name := range path { + if s.schemas[name] || name == "information_schema" || s.index.routineNames[tableRef{name, fn.table}] { + return false + } + } + return 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 + owa, ok := v.Object.(*ast.ObjectWithArgs) + if !ok { + return + } + parts := nameParts(owa.Objname) + if len(parts) == 0 || len(parts) > 2 { + return + } + fn := tableRef{table: parts[len(parts)-1]} + if len(parts) == 2 { + fn.schema = parts[0] + } + if s.newFunctions[fn] == 0 || s.syncedFunctions(fn) > 0 || owa.ArgsUnspecified && s.newFunctions[fn] > 1 { + return + } + sig := argSignature(owa.Objargs) + moved := false + for i := range s.newReturns { + d := &s.newReturns[i] + if !d.retired && s.sameObject(*d, fn) && (owa.ArgsUnspecified || d.signature == sig) { + d.object.table = v.Newname + moved = true + } + } + if moved || owa.ArgsUnspecified { + s.newFunctions[fn]-- + s.newFunctions[tableRef{fn.schema, v.Newname}]++ + } +} + +// 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. diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index c5962d5c..b82fb9d6 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -174,9 +174,22 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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);", 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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 897e366c..e193903d 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -27,8 +27,10 @@ type schemaIndex struct { functions map[tableRef]int // constraintIndexes marks the indexes a constraint owns. constraintIndexes map[tableRef]bool - // extensions lists the installed extensions by name. - extensions map[string]bool + // extensions lists the installed extensions by name, and routineNames + // the (schema, name) of every function and procedure. + extensions map[string]bool + routineNames map[tableRef]bool } // namespace is one schema: the kind of every name in its relation @@ -86,6 +88,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { constraintIndexes: make(map[tableRef]bool), extensions: make(map[string]bool), + routineNames: make(map[tableRef]bool), } for _, e := range db.GetExtensions() { idx.extensions[e.GetName()] = true @@ -145,8 +148,12 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { ns.types = append(ns.types, e.GetName()) } ns.routines = len(s.GetFunctions()) + len(s.GetProcedures()) + for _, p := range s.GetProcedures() { + idx.routineNames[tableRef{s.GetName(), p.GetName()}] = true + } for _, f := range s.GetFunctions() { fn := tableRef{s.GetName(), f.GetName()} + idx.routineNames[fn] = true idx.functions[fn]++ for _, d := range f.GetDependencyTables() { t := tableRef{d.GetSchema(), d.GetTable()} diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 7ed105e1..036531a9 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -391,10 +391,37 @@ func TestDisallowDropConstraint(t *testing.T) { 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 unique index the change made", - sql: "CREATE UNIQUE INDEX ON p (note);\nALTER TABLE t DROP CONSTRAINT t_n_check, ADD CONSTRAINT good FOREIGN KEY (code) REFERENCES p (note);", + // 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, - want: []targetFinding{{1, "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 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", @@ -983,6 +1010,100 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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);", From be528bd1cfcde026dd97587e766fd756a6ea9684 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 14:06:49 +0000 Subject: [PATCH 15/40] fix(pg/review): follow dependencies through relation renames, and DROP FUNCTION IF EXISTS of the change's own functions From the fourteenth Codex review of the scan: - A rename keeps what depends on the relation depending on it: the change's own views, functions, and foreign keys follow it to the new name, and a DROP of a synced relation under the name a rename gave it is refused for the synced dependents. - DROP FUNCTION IF EXISTS retires a function the change made, by its argument signature; a synced function is retired only when no routine of the change was given or moved away from its name. - CREATE OR REPLACE FUNCTION of a synced function without arguments, the only one of its name, replaces its dependencies. - table_rewrite counts only for ALTER TABLE subcommands that may rewrite (a plain ADD COLUMN of a built-in type does not), pinned both ways against PostgreSQL. - A cascading DROP TABLE IF EXISTS that drops nothing settles no table. Co-Authored-By: Claude Opus 5.5 --- pg/review/event_trigger_oracle_test.go | 41 ++++++ pg/review/scan.go | 183 +++++++++++++++++++++++-- pg/review/scan_oracle_test.go | 3 + pg/review/schema_index.go | 7 + pg/review/target_rules_test.go | 68 +++++++++ 5 files changed, 287 insertions(+), 15 deletions(-) diff --git a/pg/review/event_trigger_oracle_test.go b/pg/review/event_trigger_oracle_test.go index 2e7e474e..d7493e3a 100644 --- a/pg/review/event_trigger_oracle_test.go +++ b/pg/review/event_trigger_oracle_test.go @@ -50,6 +50,14 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { "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 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", @@ -117,3 +125,36 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { } } } + +// 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}, + } { + 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/scan.go b/pg/review/scan.go index 26b46ca9..bdb54d83 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -95,8 +95,15 @@ type scan struct { droppedFunctions map[tableRef]bool newFunctions map[tableRef]int // madeRoutines lists the names the change gave a routine by creating, - // renaming, or moving one. - madeRoutines map[string]bool + // 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 + replacedFunctions map[tableRef]bool + // renamedFrom maps a name a rename gave a relation the scan resolved + // to the relation, while no statement used the name since. + renamedFrom map[string]renamedRelation // 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. @@ -141,9 +148,13 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, droppedFunctions: make(map[tableRef]bool), newFunctions: make(map[tableRef]int), madeRoutines: make(map[string]bool), + movedRoutines: make(map[string]bool), + renamedFrom: make(map[string]renamedRelation), replacedViews: make(map[tableRef]bool), reowned: make(map[[2]string]bool), newKeys: make(map[[2]string]bool), + + replacedFunctions: make(map[tableRef]bool), } if target.Schema != nil { s.index = newSchemaIndex(target.Schema) @@ -302,13 +313,72 @@ func keepsObjects(cmd *ast.AlterTableCmd) bool { } // mayRewrite reports whether a DDL statement may rewrite a table, which -// raises table_rewrite: an ALTER TABLE of any kind of relation or ALTER +// 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 { - if _, ok := n.(*ast.AlterTableStmt); ok { + 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 commandTag(n) == "" + 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 @@ -782,6 +852,9 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { object.schema = fn[len(fn)-2] } s.newFunctions[object]++ + if v.IsOrReplace { + s.replaces(v, object) + } s.madeRoutines[object.table] = true // The result type and every parameter's type may be a row type. types := []*ast.TypeName{v.ReturnType} @@ -825,6 +898,29 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { } } +// 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. @@ -1511,8 +1607,13 @@ func isRelationKind(t ast.ObjectType) bool { func (s *scan) rename(v *ast.RenameStmt) { switch { case isRelationKind(v.RenameType) && v.Relation != nil: + schema, _, resolved := s.lookup(v.Relation) + s.followRename(v.Relation, v.Newname) 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}, 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. @@ -1560,6 +1661,47 @@ func (s *scan) rename(v *ast.RenameStmt) { } } +// renamedRelation is a relation of the synced schema a rename gave a new +// name, and made numbers the touch that gave it. +type renamedRelation struct { + relation tableRef + made int +} + +// renamedAway returns the synced relation a name a rename 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.relation.schema { + return tableRef{}, false + } + return r.relation, true +} + +// followRename records that what the change's own objects depend on keeps +// depending on a relation under its new name: each dependency on a +// relation the renamed one may be is copied to the new name. +func (s *scan) followRename(rv *ast.RangeVar, name string) { + schema := s.schemaOf(rv) + may := func(t tableRef) bool { + return t.table == rv.Relname && (t.schema == "" || schema == "" || t.schema == schema) + } + 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.table = name + *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}) { + r.table = name + s.newReferences = append(s.newReferences, r) + } + } +} + // setSchema follows SET SCHEMA. A pending table it may be can then be in // any schema. func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { @@ -1584,6 +1726,7 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { if owa, ok := v.Object.(*ast.ObjectWithArgs); ok { if parts := nameParts(owa.Objname); len(parts) > 0 { s.madeRoutines[parts[len(parts)-1]] = true + s.movedRoutines[parts[len(parts)-1]] = true } } case v.ObjectType == ast.OBJECT_EXTENSION: @@ -1650,6 +1793,7 @@ func (s *scan) drop(v *ast.DropStmt) { s.stop() return } + dropsAny := false for _, obj := range v.Objects.Items { parts := nameParts(listOf(obj)) if len(parts) == 0 { @@ -1670,6 +1814,7 @@ func (s *scan) drop(v *ast.DropStmt) { s.touch(rv) schema = "" } + dropsAny = true if kind == ast.OBJECT_INDEX { s.renamedKeys[rv.Relname] = true } @@ -1690,7 +1835,7 @@ func (s *scan) drop(v *ast.DropStmt) { } } } - if cascade && (kind == ast.OBJECT_TABLE || kind == ast.OBJECT_FOREIGN_TABLE) { + if cascade && dropsAny && (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. dropped := make(map[string]bool) @@ -1711,10 +1856,7 @@ func (s *scan) drop(v *ast.DropStmt) { // 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 EXISTS may name a signature no function has, and then drops - // nothing; without it, a signature that does not match fails the - // statement. - if v.Objects == nil || v.Missing_ok { + if v.Objects == nil { return } for _, obj := range v.Objects.Items { @@ -1730,10 +1872,14 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { if len(parts) == 2 { fn.schema = parts[0] } - // A routine the target certainly lacks is not there to drop. + // A routine the target certainly lacks is not there to drop; IF + // EXISTS then drops nothing. if s.routineMissing(fn) { - s.stop() - return + 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 @@ -1746,7 +1892,10 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { s.retireSignature(fn, argSignature(owa.Objargs)) } } - if s.index == nil || s.newFunctions[fn] > 0 || !owa.ArgsUnspecified { + // A synced function is certainly dropped only by its name alone, + // 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] || !owa.ArgsUnspecified { continue } if fn.schema != "" { @@ -1824,6 +1973,7 @@ func (s *scan) renameRoutine(v *ast.RenameStmt) { 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] @@ -2094,6 +2244,9 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { 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) } } for _, t := range dropped { @@ -2102,7 +2255,7 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { return true } for _, fn := range s.index.returnedBy[t] { - if !s.droppedFunctions[fn] { + if !s.droppedFunctions[fn] && !s.replacedFunctions[fn] { return true } } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index b82fb9d6..cb3821f9 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -190,6 +190,9 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index e193903d..fb1e93ad 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -31,6 +31,9 @@ type schemaIndex struct { // the (schema, name) of every function and procedure. extensions map[string]bool routineNames map[tableRef]bool + // noArgs marks the functions, by (schema, name), whose signature takes + // no arguments. + noArgs map[tableRef]bool } // namespace is one schema: the kind of every name in its relation @@ -89,6 +92,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { constraintIndexes: make(map[tableRef]bool), extensions: make(map[string]bool), routineNames: make(map[tableRef]bool), + noArgs: make(map[tableRef]bool), } for _, e := range db.GetExtensions() { idx.extensions[e.GetName()] = true @@ -155,6 +159,9 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { fn := tableRef{s.GetName(), f.GetName()} idx.routineNames[fn] = true idx.functions[fn]++ + if f.GetSignature() == f.GetName()+"()" { + idx.noArgs[fn] = true + } for _, d := range f.GetDependencyTables() { t := tableRef{d.GetSchema(), d.GetTable()} idx.returnedBy[t] = append(idx.returnedBy[t], fn) diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 036531a9..b154e348 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -60,6 +60,15 @@ func withFunctionReturning(db *metadata.DatabaseSchemaMetadata, schema, table st 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 withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { for _, s := range db.Schemas { if s.Name == schema { @@ -1104,6 +1113,65 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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);", From e381924a86d413c9e5d04772a635a2d5e1be1254 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 14:23:19 +0000 Subject: [PATCH 16/40] fix(pg/review): check key index names, follow relations across SET SCHEMA, and free what drops take From the fifteenth Codex review of the scan: - ALTER TABLE refuses ADD CONSTRAINT ... USING INDEX of an index the table lacks or cannot use, a key whose index name a relation of the schema has, and a foreign key naming a referenced column twice. - CREATE TABLE naming a column twice, a rename to a name its schema holds, and SET SCHEMA to a schema that lacks room for the name or does not exist, are refused. - SET SCHEMA keeps what depends on the relation depending on it, as a rename does. - Dropping a key frees its index's name, dropping a column frees its indexes and owned sequences, and a qualified DROP of a table or view the change made frees its name. - CREATE PROCEDURE carries its own command tag; the event trigger oracle now checks every tag the scan names against the server's. Co-Authored-By: Claude Opus 5.5 --- pg/review/event_trigger_oracle_test.go | 28 +++- pg/review/rule_disallow_drop_constraint.go | 122 +++++++++++++++ pg/review/scan.go | 171 ++++++++++++++++++--- pg/review/scan_oracle_test.go | 11 ++ pg/review/schema_index.go | 7 + pg/review/target_rules_test.go | 123 +++++++++++++++ 6 files changed, 438 insertions(+), 24 deletions(-) diff --git a/pg/review/event_trigger_oracle_test.go b/pg/review/event_trigger_oracle_test.go index d7493e3a..4cd8890c 100644 --- a/pg/review/event_trigger_oracle_test.go +++ b/pg/review/event_trigger_oracle_test.go @@ -2,6 +2,7 @@ package review import ( "context" + "slices" "testing" ) @@ -20,8 +21,10 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { } defer conn.Close() for _, setup := range []string{ - `CREATE TABLE ev_log (event text)`, - `CREATE FUNCTION ev_note() RETURNS event_trigger LANGUAGE plpgsql AS $$ BEGIN INSERT INTO ev_log VALUES (lower(tg_event)); END $$`, + `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()`, @@ -77,6 +80,13 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { "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", "SELECT 1 AS a INTO ev_c2", "CREATE INDEX ON ev_p (n)", @@ -96,17 +106,21 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { 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`) + 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 string - if err := rows.Scan(&event); err != nil { + 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)) @@ -114,6 +128,10 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { 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) } diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 5270cb90..c287b4c8 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -145,6 +145,14 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } withhold = withhold || refused || uncertain } + for _, name := range out.freed { + // What a statement that may not run drops may still be there. + if withhold { + s.touchName(schema, name) + } else { + s.free(schema, name) + } + } if withhold { out = droppedKeys{} } @@ -257,6 +265,9 @@ type droppedKeys struct { 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. It @@ -284,6 +295,9 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt } } s.dropped[[3]string{t.schema, t.table, cmd.Name}] = true + if s.index.constraintIndexes[tableRef{t.schema, cmd.Name}] { + out.freed = append(out.freed, cmd.Name) + } if s.on[review.DisallowDropConstraint] && con.kind != "" { out.findings = append(out.findings, review.Finding{ Rule: review.DisallowDropConstraint, @@ -323,6 +337,22 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out if !slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == cmd.Name }) { 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), cmd.Name) && !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, cmd.Name}] { + 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 { @@ -552,6 +582,30 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string } 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, droppedColumns, 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, droppedColumns, droppedConstraints) + if taken { + return true, false + } + uncertain = uncertain || !known + } + case makesIndex(c) && c.Conname != "": + taken, known := s.indexNameTaken(t, c.Conname, droppedColumns, droppedConstraints) + if taken { + return true, false + } + uncertain = uncertain || !known + } if c.Contype == ast.CONSTR_PRIMARY { if keyed() { return true, false @@ -591,6 +645,12 @@ func (s *scan) refusesReference(t tableRef, c *ast.Constraint, own func(string) if len(columns) > 0 && len(columns) != local { return true, false } + // No key has a column twice. + for i, column := range columns { + if slices.Contains(columns[:i], column) { + return true, false + } + } if s.missing(c.Pktable) { return true, false } @@ -639,6 +699,68 @@ func (s *scan) refusesReference(t tableRef, c *ast.Constraint, own func(string) 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 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): + 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 +} + // 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. diff --git a/pg/review/scan.go b/pg/review/scan.go index bdb54d83..98ab99da 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -101,9 +101,12 @@ type scan struct { madeRoutines map[string]bool movedRoutines map[string]bool replacedFunctions map[tableRef]bool - // renamedFrom maps a name a rename gave a relation the scan resolved - // to the relation, while no statement used the name since. + // 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. @@ -150,6 +153,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, madeRoutines: make(map[string]bool), movedRoutines: make(map[string]bool), 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), @@ -215,10 +219,6 @@ func eventTriggerFires(schema *metadata.DatabaseSchemaMetadata, stmts []statemen e := raised{drops: mayDrop(n), rewrites: mayRewrite(n)} if tag := commandTag(n); tag != "" { e.tags = []string{tag} - // CREATE FUNCTION and CREATE PROCEDURE share a node. - if tag == "CREATE FUNCTION" { - e.tags = append(e.tags, "CREATE PROCEDURE") - } } events = append(events, e) } @@ -442,6 +442,9 @@ func commandTag(n ast.Node) string { case *ast.IndexStmt: return "CREATE INDEX" case *ast.CreateFunctionStmt: + if isProcedure(v) { + return "CREATE PROCEDURE" + } return "CREATE FUNCTION" case *ast.AlterSeqStmt: return "ALTER SEQUENCE" @@ -482,6 +485,18 @@ func commandTag(n ast.Node) string { return "" } +// 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() { @@ -522,6 +537,9 @@ func (s *scan) statement(st *statement) { 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: s.returns(v) @@ -1010,6 +1028,10 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { // IF NOT EXISTS of an existing table does nothing. return } + if duplicateColumns(v) { + s.stop() + return + } pending := len(s.pending) if !s.nameUncertain(v.Relation) { s.createTable(st, v) @@ -1018,6 +1040,9 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { 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 generatesNames(v) { s.generated = true } @@ -1208,6 +1233,25 @@ func (s *scan) mayHavePartitions(rv *ast.RangeVar) bool { return len(s.index.schemas[schema].tables[rv.Relname].GetPartitions()) > 0 } +// duplicateColumns reports whether CREATE TABLE names a column twice, +// which the server refuses. A column LIKE or INHERITS brings may merge +// with one the statement names. +func duplicateColumns(v *ast.CreateStmt) bool { + if v.TableElts == nil { + return false + } + seen := make(map[string]bool) + for _, item := range v.TableElts.Items { + if cd, ok := item.(*ast.ColumnDef); ok { + if seen[cd.Colname] { + return true + } + seen[cd.Colname] = 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 @@ -1608,11 +1652,16 @@ func (s *scan) rename(v *ast.RenameStmt) { switch { case isRelationKind(v.RenameType) && v.Relation != nil: schema, _, resolved := s.lookup(v.Relation) - s.followRename(v.Relation, v.Newname) + // A name its schema holds is refused. + if resolved && s.nameTaken(schema, v.Newname) { + s.stop() + return + } + s.follow(v.Relation, "", v.Newname) 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}, s.lastTouch[v.Newname]} + 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; @@ -1661,54 +1710,122 @@ func (s *scan) rename(v *ast.RenameStmt) { } } -// renamedRelation is a relation of the synced schema a rename gave a new -// name, and made numbers the touch that gave it. +// 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 gave may mean, -// while no statement used the name since. +// 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.relation.schema { + if !ok || s.lastTouch[rv.Relname] != r.made || rv.Schemaname != "" && rv.Schemaname != r.schema { return tableRef{}, false } return r.relation, true } -// followRename records that what the change's own objects depend on keeps -// depending on a relation under its new name: each dependency on a -// relation the renamed one may be is copied to the new name. -func (s *scan) followRename(rv *ast.RangeVar, name string) { +// 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.table = name + 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}) { - r.table = name + 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 +} + +// 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, 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) +} + // 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: + schema, _, resolved := s.lookup(v.Relation) + // A schema that lacks the name must exist to take it. + 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 + } + s.follow(v.Relation, v.Newschema, v.Relation.Relname) 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. @@ -1793,6 +1910,18 @@ func (s *scan) drop(v *ast.DropStmt) { 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) + } + } dropsAny := false for _, obj := range v.Objects.Items { parts := nameParts(listOf(obj)) @@ -1810,6 +1939,10 @@ func (s *scan) drop(v *ast.DropStmt) { 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 = "" diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index cb3821f9..7b94a53d 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -193,6 +193,17 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index fb1e93ad..f1bf891b 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -34,6 +34,8 @@ type schemaIndex struct { // noArgs marks the functions, by (schema, name), whose signature takes // no arguments. noArgs map[tableRef]bool + // 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 @@ -93,6 +95,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { extensions: make(map[string]bool), routineNames: make(map[tableRef]bool), noArgs: make(map[tableRef]bool), + ownedSequences: make(map[columnRef][]string), } for _, e := range db.GetExtensions() { idx.extensions[e.GetName()] = true @@ -143,6 +146,10 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { 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() { diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index b154e348..51e05748 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -69,6 +69,15 @@ func withNoArgFunctionReturning(db *metadata.DatabaseSchemaMetadata, schema, tab 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 +} + func withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { for _, s := range db.Schemas { if s.Name == schema { @@ -368,6 +377,48 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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);", @@ -1172,6 +1223,78 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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);", From 6bc9e8637539e3f3d840f8457ca222d142df6c21 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 14:33:10 +0000 Subject: [PATCH 17/40] fix(pg/review): refuse renames of missing relations, and DROP SCHEMA of what the change may have filled From the sixteenth Codex review of the scan: - RENAME and SET SCHEMA of a relation the target certainly lacks end the scan, and do nothing under IF EXISTS. - A non-cascading DROP SCHEMA ends the scan when the change may have created, renamed, or moved anything into the schema that no resolved drop removed. - A foreign key the change created on a table the same DROP drops no longer holds the table it references. - Login event triggers fire when a session connects, not on a statement; the event trigger oracle now runs one. Co-Authored-By: Claude Opus 5.5 --- pg/review/event_trigger_oracle_test.go | 5 +++ pg/review/scan.go | 60 +++++++++++++++++++++----- pg/review/scan_oracle_test.go | 5 +++ pg/review/target_rules_test.go | 49 +++++++++++++++++++++ 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/pg/review/event_trigger_oracle_test.go b/pg/review/event_trigger_oracle_test.go index 4cd8890c..d0fd3545 100644 --- a/pg/review/event_trigger_oracle_test.go +++ b/pg/review/event_trigger_oracle_test.go @@ -28,6 +28,7 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { `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) @@ -138,6 +139,10 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { if raised["sql_drop"] && !mayDrop(n) { t.Errorf("%s raised sql_drop, which the scan does not expect", sql) } + // eventTriggerFires 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) } diff --git a/pg/review/scan.go b/pg/review/scan.go index 98ab99da..18c56c14 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -196,7 +196,9 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, func eventTriggerFires(schema *metadata.DatabaseSchemaMetadata, stmts []statement) bool { var triggers []*metadata.EventTriggerMetadata for _, t := range schema.GetEventTriggers() { - if t.GetEnabled() { + // A login trigger fires when a session connects, not on a + // statement. + if t.GetEnabled() && !strings.EqualFold(t.GetEvent(), "LOGIN") { triggers = append(triggers, t) } } @@ -697,12 +699,17 @@ func (s *scan) retireReferences(owner *ast.RangeVar, name string) { // 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. -func (s *scan) newlyReferenced(t tableRef, columns []string, primary bool) bool { +// 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 } @@ -1649,6 +1656,14 @@ func isRelationKind(t ast.ObjectType) bool { // 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: schema, _, resolved := s.lookup(v.Relation) @@ -1805,7 +1820,7 @@ func (s *scan) dropsOwn(rv *ast.RangeVar, kind ast.ObjectType, cascade bool, wri 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) + 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 @@ -1813,6 +1828,12 @@ func (s *scan) dropsOwn(rv *ast.RangeVar, kind ast.ObjectType, cascade bool, wri 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 + } schema, _, resolved := s.lookup(v.Relation) // A schema that lacks the name must exist to take it. if s.index != nil && (s.index.schemas[v.Newschema] == nil && !s.schemas[v.Newschema] || s.schemaGone[v.Newschema]) || @@ -2258,16 +2279,33 @@ func dropKindMatches(t ast.ObjectType, kind relationKind) bool { return true } -// schemaHolds reports whether a synced schema certainly still holds an -// object, so that DROP SCHEMA without CASCADE is refused: a relation of -// it no statement touched, an enum type no statement named, or a routine -// no DROP FUNCTION certainly removed. +// 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 || s.schemas[name] { + 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 { + if ns == nil || s.schemas[name] { return false } for rel := range ns.relations { @@ -2400,7 +2438,7 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { if kind != ast.OBJECT_TABLE { continue } - if s.newlyReferenced(t, nil, true) { + if s.newlyReferenced(t, nil, true, written...) { return true } for _, fk := range s.index.foreignKeys { diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 7b94a53d..0e8fff7c 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -204,6 +204,11 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 51e05748..0fd9397d 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -1295,6 +1295,55 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 455da3ca4fb552755e9cae55801fb019434a9576 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 14:44:18 +0000 Subject: [PATCH 18/40] fix(pg/review): refuse a second CREATE of the change's own names and ALTER TABLE of non-tables, and resolve unqualified settlements From the seventeenth Codex review of the scan: - CREATE TABLE is refused for an INHERITS parent or LIKE source the target lacks or that cannot give columns, and CREATE TABLE, VIEW, or SEQUENCE of a name the change created and still has is refused, or a no-op under IF NOT EXISTS. - ALTER TABLE of a view, materialized view, sequence, index, or composite type ends the scan. - SET SCHEMA vacates the old schema, with a table's indexes and owned sequences, and a definite DROP FUNCTION of the change's own function no longer counts it toward its schema. - An unqualified name the known search path resolves to a synced relation of an earlier schema no longer settles a pending table of another schema. - TRUNCATE fires no event trigger. Co-Authored-By: Claude Opus 5.5 --- pg/review/event_trigger_oracle_test.go | 1 + pg/review/rule_disallow_drop_constraint.go | 9 +- pg/review/rule_require_primary_key.go | 31 ++++- pg/review/scan.go | 151 ++++++++++++++++++--- pg/review/scan_oracle_test.go | 7 + pg/review/target_rules_test.go | 66 +++++++++ 6 files changed, 243 insertions(+), 22 deletions(-) diff --git a/pg/review/event_trigger_oracle_test.go b/pg/review/event_trigger_oracle_test.go index d0fd3545..10bab38d 100644 --- a/pg/review/event_trigger_oracle_test.go +++ b/pg/review/event_trigger_oracle_test.go @@ -89,6 +89,7 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { "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", diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index c287b4c8..5826fa7a 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -28,11 +28,18 @@ 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. + // ALTER of a relation the target certainly lacks is refused, and so + // is ALTER TABLE of a relation that is no table. if !v.Missing_ok && s.index != nil && s.missing(v.Relation) { 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 + } + } isTable := ast.ObjectType(v.ObjType) == ast.OBJECT_TABLE var drops, others []*ast.AlterTableCmd for _, item := range v.Cmds.Items { diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 5d868faf..6603714b 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -61,13 +61,40 @@ func inheritsFrom(v *ast.CreateStmt) []string { 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) { - out = append(out, p) + 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 +// synced relation of a schema the search path puts before the given one. +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" || 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 +} + // keyed settles the pending tables a statement may give a primary key. func (s *scan) keyed(rv *ast.RangeVar) { for _, p := range s.matching(rv) { diff --git a/pg/review/scan.go b/pg/review/scan.go index 18c56c14..ee537173 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -100,6 +100,9 @@ type scan struct { // by (schema, name), CREATE OR REPLACE redefined. madeRoutines map[string]bool movedRoutines map[string]bool + // 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. @@ -152,6 +155,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, newFunctions: make(map[tableRef]int), madeRoutines: make(map[string]bool), movedRoutines: make(map[string]bool), + newSignatures: make(map[tableRef][]string), renamedFrom: make(map[string]renamedRelation), created: make(map[[2]string]madeRelation), replacedViews: make(map[tableRef]bool), @@ -268,7 +272,7 @@ func isDDL(n ast.Node) bool { *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.ConstraintsSetStmt, *ast.DiscardStmt, *ast.LoadStmt, *ast.DoStmt, *ast.CallStmt, *ast.TruncateStmt: return false } return true @@ -523,7 +527,7 @@ func (s *scan) statement(st *statement) { 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.typeExists(v.View) { + if s.schemaMissing(v.View) || !v.Replace && (s.existsWhereCreated(v.View) || s.madeHere(v.View)) || s.typeExists(v.View) { s.stop() return } @@ -553,7 +557,7 @@ func (s *scan) statement(st *statement) { s.reowned[[2]string{s.schemaOf(v.Sequence), v.Sequence.Relname}] = true } case *ast.CreateSeqStmt: - exists := s.existsWhereCreated(v.Sequence) + exists := s.existsWhereCreated(v.Sequence) || s.madeHere(v.Sequence) switch { case s.schemaMissing(v.Sequence) || exists && !v.IfNotExists: s.stop() @@ -876,7 +880,6 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { if len(fn) >= 2 { object.schema = fn[len(fn)-2] } - s.newFunctions[object]++ if v.IsOrReplace { s.replaces(v, object) } @@ -896,6 +899,11 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { } } 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++ { @@ -1035,10 +1043,17 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { // IF NOT EXISTS of an existing table does nothing. return } - if duplicateColumns(v) { + if duplicateColumns(v) || s.sourceRefused(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 + } pending := len(s.pending) if !s.nameUncertain(v.Relation) { s.createTable(st, v) @@ -1240,6 +1255,61 @@ func (s *scan) mayHavePartitions(rv *ast.RangeVar) bool { return len(s.index.schemas[schema].tables[rv.Relname].GetPartitions()) > 0 } +// 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 { + schema := rv.Schemaname + if schema == "" { + var ok bool + if schema, ok = s.creationSchema(rv); !ok { + return false + } + } + c, ok := s.created[[2]string{schema, rv.Relname}] + return ok && s.lastTouch[rv.Relname] == c.made +} + +// 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 + } + } + 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 +} + // duplicateColumns reports whether CREATE TABLE names a column twice, // which the server refuses. A column LIKE or INHERITS brings may merge // with one the statement names. @@ -1744,6 +1814,24 @@ func (s *scan) renamedAway(rv *ast.RangeVar) (tableRef, bool) { 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 { @@ -1842,7 +1930,17 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { return } s.follow(v.Relation, v.Newschema, v.Relation.Relname) - s.touch(v.Relation) + 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]} @@ -2039,11 +2137,19 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { // 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 s.newFunctions[fn] == 1 && s.syncedFunctions(fn) == 0 { + if sigs := s.newSignatures[fn]; len(sigs) > 0 && s.syncedFunctions(fn) == 0 { + sig, i := argSignature(owa.Objargs), -1 if owa.ArgsUnspecified { - s.retire(s.newReturns, fn) + if len(sigs) == 1 { + sig, i = sigs[0], 0 + } } else { - s.retireSignature(fn, argSignature(owa.Objargs)) + 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 only by its name alone, @@ -2132,22 +2238,29 @@ func (s *scan) renameRoutine(v *ast.RenameStmt) { if len(parts) == 2 { fn.schema = parts[0] } - if s.newFunctions[fn] == 0 || s.syncedFunctions(fn) > 0 || owa.ArgsUnspecified && s.newFunctions[fn] > 1 { + sigs := s.newSignatures[fn] + if len(sigs) == 0 || s.syncedFunctions(fn) > 0 || owa.ArgsUnspecified && len(sigs) > 1 { return } sig := argSignature(owa.Objargs) - moved := false - for i := range s.newReturns { - d := &s.newReturns[i] - if !d.retired && s.sameObject(*d, fn) && (owa.ArgsUnspecified || d.signature == sig) { + 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.table = v.Newname - moved = true } } - if moved || owa.ArgsUnspecified { - s.newFunctions[fn]-- - s.newFunctions[tableRef{fn.schema, v.Newname}]++ - } + to := tableRef{fn.schema, v.Newname} + 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 diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 0e8fff7c..c5d40a59 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -209,6 +209,13 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 0fd9397d..4d0bb6b1 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -1344,6 +1344,72 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 1cc76be0419a08e36fabacf245bfc1cef800d793 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 14:44:40 +0000 Subject: [PATCH 19/40] chore(pg/review): gofmt scan.go Co-Authored-By: Claude Opus 5.5 --- pg/review/scan.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pg/review/scan.go b/pg/review/scan.go index ee537173..39d04832 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -98,11 +98,11 @@ type scan struct { // 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 + madeRoutines map[string]bool + movedRoutines map[string]bool // newSignatures lists the argument signatures of the functions the // change created and has not dropped, by name as written. - newSignatures map[tableRef][]string + 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. From 08eee8b3d6c4614938068c52a4f29457d1939cb0 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 14:59:14 +0000 Subject: [PATCH 20/40] fix(pg/review): validate CREATE TABLE foreign keys, refuse DROP TYPE and drops a generated column reads, and scope generated names to their schema From the eighteenth Codex review of the scan: - CREATE TABLE is refused for a foreign key on a column it lacks, to a relation, column, or key the target lacks, or to itself or a table the change created and has not altered without the key it names. The scan keeps the definition of each table the change creates for this; a foreign key it cannot check leaves the table unreported. - DROP TYPE and DROP DOMAIN without CASCADE end the scan, since the snapshot does not record every use of a type, and DROP COLUMN without CASCADE of a column a synced generated column reads is refused. - Names the server generates are uncertain only in the schema they were generated in. - The views and sequences of CREATE SCHEMA are the change's own, and a qualified name a resolved drop freed is no longer uncertain. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 62 +++++- pg/review/rule_require_primary_key.go | 4 +- pg/review/scan.go | 226 +++++++++++++++++++-- pg/review/scan_oracle_test.go | 9 + pg/review/target_rules_test.go | 88 ++++++++ 5 files changed, 364 insertions(+), 25 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 5826fa7a..bc6bba4b 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -40,6 +40,10 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { return } } + // A table the change created is no longer what its CREATE said. + if c, ok := s.madeAt(v.Relation); ok { + c.def = nil + } isTable := ast.ObjectType(v.ObjType) == ast.OBJECT_TABLE var drops, others []*ast.AlterTableCmd for _, item := range v.Cmds.Items { @@ -180,13 +184,13 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { switch { case !makesIndex(c): case c.Conname == "": - s.generated = true + 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.generated = true + s.generate(nil) } } if c.Indexname != "" { @@ -212,8 +216,15 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { continue } addedHere[cd.Colname] = true - if columnGeneratesNames(cd) || s.mayHavePartitions(v.Relation) { - s.generated = 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) { @@ -233,7 +244,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.references(v.Relation, constraintsOf(cd.Constraints)...) } case ast.AT_AddIdentity: - s.generated = true + s.generate(v.Relation) case ast.AT_AddIndexConstraint, ast.AT_AddIndex: if idx, ok := cmd.Def.(*ast.IndexStmt); ok { s.renamedKeys[idx.Idxname] = true @@ -245,9 +256,10 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } case ast.AT_AttachPartition: - // The partition gets the parent's indexes under generated names. - s.generated = true 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.settled = true @@ -365,7 +377,7 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out if pk != nil { pkColumns = keyColumns(pk) } - if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || slices.ContainsFunc(s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}], s.viewHolds) || + if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || generatedFrom(table, cmd.Name) || slices.ContainsFunc(s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}], s.viewHolds) || s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || s.dependsOnNew(s.newReads, t, nil)) { return true } @@ -381,6 +393,40 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out return false } +// generatedFrom reports whether another column of the synced table may be +// generated from the column: its generation expression names it. +func generatedFrom(t *metadata.TableMetadata, column string) bool { + for _, c := range t.GetColumns() { + if c.GetName() != column && c.GetGeneration() != nil && mentions(c.GetGeneration().GetExpression(), column) { + return true + } + } + return false +} + +// 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 diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 6603714b..b8b1bed9 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -134,7 +134,7 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { return } keyed, known := s.createsKey(v) - if keyed || !known || s.generated && generatedName(v.Relation.Relname) { + if keyed || !known || s.mayBeGenerated(v.Relation) { return } s.pending = append(s.pending, &pendingTable{ @@ -197,7 +197,7 @@ func (s *scan) nameUncertain(rv *ast.RangeVar) bool { return s.isTouched(&ast.RangeVar{Schemaname: schema, Relname: rv.Relname}) && !s.freed[[2]string{schema, rv.Relname}] } if rv.Schemaname != "" { - return s.isTouched(rv) + return s.isTouched(rv) && !s.freed[[2]string{rv.Schemaname, rv.Relname}] } return s.touchedName[rv.Relname] } diff --git a/pg/review/scan.go b/pg/review/scan.go index 39d04832..c9fdc6d8 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -109,7 +109,7 @@ type scan struct { 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 + 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. @@ -119,10 +119,11 @@ type scan struct { // could not tell, a statement may have given a primary key, a unique // constraint, or a unique index. newKeys map[[2]string]bool - // generated marks that a statement made relations 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 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 // pending are the tables RequirePrimaryKey reports at the end unless a // later statement settles them. @@ -157,10 +158,11 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, movedRoutines: make(map[string]bool), newSignatures: make(map[tableRef][]string), renamedFrom: make(map[string]renamedRelation), - created: make(map[[2]string]madeRelation), + 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), } @@ -586,9 +588,13 @@ func (s *scan) statement(st *statement) { if (v.Unique || v.Primary) && v.Relation != nil { s.newKey(v.Relation) } - if v.Idxname == "" || v.Relation == nil || s.mayHavePartitions(v.Relation) { - // A partition's index takes a generated name. - s.generated = true + 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) @@ -1054,8 +1060,14 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { } return } + refused, uncertain := s.referencesRefused(v) + if refused { + s.stop() + return + } pending := len(s.pending) - if !s.nameUncertain(v.Relation) { + // A table that may not be created is not reported. + if !uncertain && !s.nameUncertain(v.Relation) { s.createTable(st, v) } s.touch(v.Relation) @@ -1064,9 +1076,14 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { } if !v.IfNotExists || s.createsNew(v.Relation) { s.made(v.Relation, kindTable) + if def, _, complete := defOf(v); complete { + if c, ok := s.madeAt(v.Relation); ok { + c.def = &def + } + } } if generatesNames(v) { - s.generated = true + s.generate(v.Relation) } if v.TableElts == nil { return @@ -1141,9 +1158,11 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { s.create(st, &c) case *ast.ViewStmt: s.touch(in(e.View)) + s.made(in(e.View), kindView) s.reads(in(e.View), e.Query) case *ast.CreateSeqStmt: s.touch(in(e.Sequence)) + s.made(in(e.Sequence), kindSequence) case *ast.IndexStmt: if e.Idxname != "" && e.Relation != nil { s.touchName(in(e.Relation).Schemaname, e.Idxname) @@ -1255,18 +1274,156 @@ func (s *scan) mayHavePartitions(rv *ast.RangeVar) bool { 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 +} + +// 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 + } + add := func(c *ast.Constraint, column string) { + 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 +} + +// 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 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) + switch { + case pk == nil: + uncertain = true + case pk.Relname == v.Relation.Relname && pk.Schemaname == v.Relation.Schemaname: + if !complete { + uncertain = true + } else if own.refusedBy(columns, local) { + return true, false + } + case pk.Relname == v.Relation.Relname: + // It may be the table itself, or another of its name. + uncertain = true + 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 +} + +// 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 +} + +// 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 false + return nil, false } } c, ok := s.created[[2]string{schema, rv.Relname}] - return ok && s.lastTouch[rv.Relname] == c.made + if !ok || s.lastTouch[rv.Relname] != c.made { + return nil, false + } + return c, true } // sourceRefused reports whether CREATE TABLE names a relation the server @@ -1377,6 +1534,36 @@ func columnGeneratesNames(cd *ast.ColumnDef) bool { 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 { @@ -1881,6 +2068,9 @@ func (s *scan) follow(rv *ast.RangeVar, newSchema, name string) { 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 @@ -1893,7 +2083,7 @@ func (s *scan) made(rv *ast.RangeVar, kind relationKind) { return } } - s.created[[2]string{schema, rv.Relname}] = madeRelation{kind, s.lastTouch[rv.Relname]} + 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 @@ -2013,7 +2203,13 @@ func (s *scan) drop(v *ast.DropStmt) { } return } - if !isRelationKind(kind) && kind != ast.OBJECT_TYPE || v.Objects == nil { + // 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. diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index c5d40a59..4db398c3 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -216,6 +216,15 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 4d0bb6b1..9e8518ad 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -78,6 +78,13 @@ func withPlainIndex(db *metadata.DatabaseSchemaMetadata, tableName, index, colum 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)) +} + func withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { for _, s := range db.Schemas { if s.Name == schema { @@ -1410,6 +1417,87 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 237b15f45f6b7be90841ddccd9e4770b3986cca4 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 15:13:22 +0000 Subject: [PATCH 21/40] fix(pg/review): stop at the statement an event trigger fires on, and refuse views of missing relations From the nineteenth Codex review of the scan: - Event triggers are checked statement by statement: the scan ends at the first statement one may fire on, keeping what came before, and an sql_drop trigger does not fire on DROP ... IF EXISTS of relations the target certainly lacks. - CREATE VIEW, top-level or in CREATE SCHEMA, of a relation the target certainly lacks, CTE names aside, is refused. - CREATE SEQUENCE is the change's own, so dropping it frees its name. - LIKE ... INCLUDING INDEXES of a table whose key the change dropped for certain copies no key; of a table a statement may have given a key, it is not known. Co-Authored-By: Claude Opus 5.5 --- pg/review/event_trigger_oracle_test.go | 2 +- pg/review/rule_require_primary_key.go | 14 ++- pg/review/scan.go | 155 ++++++++++++++++--------- pg/review/scan_oracle_test.go | 5 + pg/review/target_rules_test.go | 53 +++++++++ 5 files changed, 171 insertions(+), 58 deletions(-) diff --git a/pg/review/event_trigger_oracle_test.go b/pg/review/event_trigger_oracle_test.go index 10bab38d..96370fb0 100644 --- a/pg/review/event_trigger_oracle_test.go +++ b/pg/review/event_trigger_oracle_test.go @@ -140,7 +140,7 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { if raised["sql_drop"] && !mayDrop(n) { t.Errorf("%s raised sql_drop, which the scan does not expect", sql) } - // eventTriggerFires sets login triggers aside. + // enabledTriggers sets login triggers aside. if raised["login"] { t.Errorf("%s raised login", sql) } diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index b8b1bed9..ef670910 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -301,10 +301,18 @@ func (s *scan) createsKey(v *ast.CreateStmt) (keyed, known bool) { case !ok || kind == kindPartition || kind == kindTable && s.isUnsettled(tableRef{schema, e.Relation.Relname}): known = false case kind == kindTable: - pk := primaryKey(s.index.schemas[schema].tables[e.Relation.Relname]) - if pk != nil && !s.constraintKnown(tableRef{schema, e.Relation.Relname}, pk.GetName()) { + 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 - } else if pk != nil { + case pk == nil: + case s.dropped[[3]string{t.schema, t.table, pk.GetName()}]: + // The change dropped its key for certain. + case !s.constraintKnown(t, pk.GetName()): + known = false + default: keyed = true } } diff --git a/pg/review/scan.go b/pg/review/scan.go index c9fdc6d8..60c2513d 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -125,6 +125,9 @@ type scan struct { // sequence), which the scan does not derive. generated map[string]bool + // triggers are the target's enabled event triggers. + triggers []*metadata.EventTriggerMetadata + // pending are the tables RequirePrimaryKey reports at the end unless a // later statement settles them. pending []*pendingTable @@ -173,13 +176,16 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, s.path = path } } - if target.Schema != nil && eventTriggerFires(target.Schema, stmts) { - return nil + 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 @@ -193,68 +199,80 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, return nil } -// eventTriggerFires reports whether an enabled event trigger of the target -// may fire on a statement of the change: 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 what the statement does is -// no longer what it says. -func eventTriggerFires(schema *metadata.DatabaseSchemaMetadata, stmts []statement) bool { +// 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() { - // A login trigger fires when a session connects, not on a - // statement. if t.GetEnabled() && !strings.EqualFold(t.GetEvent(), "LOGIN") { triggers = append(triggers, t) } } - if len(triggers) == 0 { + 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 } - // The events each DDL statement may raise: every one raises - // ddl_command_start and ddl_command_end, one that may drop objects - // sql_drop, and one that may rewrite a table table_rewrite. - type raised struct { - tags []string - drops, rewrites bool + var tags []string + if tag := commandTag(n); tag != "" { + tags = []string{tag} } - var events []raised - for i := range stmts { - n := stmts[i].node - if !isDDL(n) { - continue + 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 + } } - e := raised{drops: mayDrop(n), rewrites: mayRewrite(n)} - if tag := commandTag(n); tag != "" { - e.tags = []string{tag} + if len(t.GetTags()) == 0 || len(tags) == 0 { + return true } - events = append(events, e) - } - for _, t := range triggers { - for _, e := range events { - switch strings.ToUpper(t.GetEvent()) { - case "SQL_DROP": - if !e.drops { - continue - } - case "TABLE_REWRITE": - if !e.rewrites { - continue - } - } - if len(t.GetTags()) == 0 || len(e.tags) == 0 { + for _, tag := range t.GetTags() { + if slices.Contains(tags, strings.ToUpper(tag)) { return true } - for _, tag := range t.GetTags() { - if slices.Contains(e.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. @@ -529,7 +547,7 @@ func (s *scan) statement(st *statement) { 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) { + if s.schemaMissing(v.View) || !v.Replace && (s.existsWhereCreated(v.View) || s.madeHere(v.View)) || s.typeExists(v.View) || s.readsMissing(v.Query) { s.stop() return } @@ -569,6 +587,9 @@ func (s *scan) statement(st *statement) { return } s.touch(v.Sequence) + if !v.IfNotExists || s.createsNew(v.Sequence) { + s.made(v.Sequence, kindSequence) + } case *ast.CompositeTypeStmt: s.touch(v.Typevar) case *ast.CreateEnumStmt: @@ -800,6 +821,32 @@ func (s *scan) dropNewReaders(t tableRef) { // 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, + }) + }) +} + +// readsMissing reports whether a query names a relation the target +// certainly lacks, 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) { + missing = missing || s.missing(rv) + }) + 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)) @@ -820,18 +867,14 @@ func (s *scan) dependsIn(deps *[]newDependency, object tableRef, n ast.Node, vis } } for _, c := range ctes { - s.dependsIn(deps, object, c.Ctequery, scope) + relationsIn(c.Ctequery, scope, fn) scope[c.Ctename] = true } - s.dependsIn(deps, object, rest, scope) + relationsIn(rest, scope, fn) return false } if rv, ok := n.(*ast.RangeVar); ok && !(rv.Schemaname == "" && visible[rv.Relname]) { - *deps = append(*deps, newDependency{ - relation: tableRef{s.schemaOf(rv), rv.Relname}, - object: object, - path: s.pathVersion, - }) + fn(rv) } return true }) @@ -1157,6 +1200,10 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { c.Relation = in(e.Relation) s.create(st, &c) case *ast.ViewStmt: + if s.readsMissing(e.Query) { + s.stop() + return + } s.touch(in(e.View)) s.made(in(e.View), kindView) s.reads(in(e.View), e.Query) diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 4db398c3..36fa14ee 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -225,6 +225,11 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 9e8518ad..81490f6a 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -384,6 +384,12 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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;", @@ -1498,6 +1504,53 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 6734d44fdf43ad9213f907bb16cb9d309009b35a Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 15:25:13 +0000 Subject: [PATCH 22/40] fix(pg/review): refuse CREATE TABLE OF, repeated names, column renames, and routines the target cannot take From the twentieth Codex review of the scan: - CREATE TABLE is refused for OF a type that is not a composite type the target or the change has, a column or constraint named twice, a key whose index name its schema or the table holds, and a foreign key naming a referencing column twice. - RENAME COLUMN of a known table is refused when the column is missing or the new name is taken. - CREATE FUNCTION, PROCEDURE, TYPE, DOMAIN, FOREIGN TABLE, TABLE AS, and SELECT INTO in a schema that certainly does not exist end the scan. - Synced procedures count toward a name's routines, so dropping a schema's only one empties it. - Enabling or disabling triggers neither drops nor rewrites. Co-Authored-By: Claude Opus 5.5 --- pg/review/event_trigger_oracle_test.go | 2 + pg/review/rule_disallow_drop_constraint.go | 18 ++- pg/review/scan.go | 146 +++++++++++++++++++-- pg/review/scan_oracle_test.go | 11 ++ pg/review/schema_index.go | 8 +- pg/review/target_rules_test.go | 90 +++++++++++++ 6 files changed, 257 insertions(+), 18 deletions(-) diff --git a/pg/review/event_trigger_oracle_test.go b/pg/review/event_trigger_oracle_test.go index 96370fb0..7f539272 100644 --- a/pg/review/event_trigger_oracle_test.go +++ b/pg/review/event_trigger_oracle_test.go @@ -60,6 +60,7 @@ func TestEventTriggerEventsAgainstPostgres(t *testing.T) { "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", @@ -169,6 +170,7 @@ func TestEventTriggerClassification(t *testing.T) { {"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 { diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index bc6bba4b..20d0524a 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -698,11 +698,9 @@ func (s *scan) refusesReference(t tableRef, c *ast.Constraint, own func(string) if len(columns) > 0 && len(columns) != local { return true, false } - // No key has a column twice. - for i, column := range columns { - if slices.Contains(columns[:i], column) { - 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 @@ -814,6 +812,16 @@ func (s *scan) indexNameTaken(t tableRef, name string, droppedColumns, droppedCo return true, true } +// 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. diff --git a/pg/review/scan.go b/pg/review/scan.go index 60c2513d..1e66fc86 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -335,7 +335,7 @@ func keepsObjects(cmd *ast.AlterTableCmd) bool { // SET DEFAULT replaces; DROP DEFAULT drops. return cmd.Def != nil } - return false + return togglesTriggers(cmd) } // mayRewrite reports whether a DDL statement may rewrite a table, which @@ -373,6 +373,17 @@ func keepsRows(cmd *ast.AlterTableCmd) bool { 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 } @@ -511,6 +522,46 @@ func commandTag(n ast.Node) string { return "" } +// 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 "" +} + // isProcedure reports whether CREATE FUNCTION is CREATE PROCEDURE, which // the parser marks with an option. func isProcedure(v *ast.CreateFunctionStmt) bool { @@ -530,6 +581,11 @@ func (s *scan) stop() { } func (s *scan) statement(st *statement) { + // A CREATE in a schema that certainly does not exist is refused. + if schema := createdIn(st.node); schema != "" && s.schemaMissing(&ast.RangeVar{Schemaname: schema}) { + s.stop() + return + } switch v := st.node.(type) { case *ast.AlterTableStmt: s.alterTable(st, v) @@ -1092,7 +1148,7 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { // IF NOT EXISTS of an existing table does nothing. return } - if duplicateColumns(v) || s.sourceRefused(v) { + if duplicateNames(v) || s.sourceRefused(v) || s.keyNameTaken(v) { s.stop() return } @@ -1403,6 +1459,9 @@ func (s *scan) referencesRefused(v *ast.CreateStmt) (refused, uncertain bool) { } 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 } @@ -1501,6 +1560,19 @@ func (s *scan) sourceRefused(v *ast.CreateStmt) bool { 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 @@ -1514,20 +1586,69 @@ func (s *scan) sourceRefused(v *ast.CreateStmt) bool { return false } -// duplicateColumns reports whether CREATE TABLE names a column twice, -// which the server refuses. A column LIKE or INHERITS brings may merge -// with one the statement names. -func duplicateColumns(v *ast.CreateStmt) bool { +// 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 } - seen := make(map[string]bool) + 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 { - if cd, ok := item.(*ast.ColumnDef); ok { - if seen[cd.Colname] { + 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 } - seen[cd.Colname] = true } } return false @@ -2019,6 +2140,11 @@ func (s *scan) rename(v *ast.RenameStmt) { s.constraints[[3]string{schema, v.Relation.Relname, v.Subname}] = true s.constraints[[3]string{schema, v.Relation.Relname, v.Newname}] = true case v.RenameType == ast.OBJECT_COLUMN && v.Relation != nil: + // A known table must have the column, and not the new name. + if t, ok := s.table(v.Relation); ok && (!s.hasColumn(t, v.Subname) || s.hasColumn(t, v.Newname)) { + s.stop() + return + } 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) diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 36fa14ee..683db9c7 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -230,6 +230,17 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index f1bf891b..f5ce3e93 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -21,8 +21,8 @@ type schemaIndex struct { readers map[tableRef][]tableRef readsColumn map[columnRef][]tableRef // returnedBy maps a relation to the functions, by (schema, name), that - // return its row type, and functions counts the functions of each - // (schema, name). + // 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. @@ -160,7 +160,9 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { } ns.routines = len(s.GetFunctions()) + len(s.GetProcedures()) for _, p := range s.GetProcedures() { - idx.routineNames[tableRef{s.GetName(), p.GetName()}] = true + fn := tableRef{s.GetName(), p.GetName()} + idx.routineNames[fn] = true + idx.functions[fn]++ } for _, f := range s.GetFunctions() { fn := tableRef{s.GetName(), f.GetName()} diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 81490f6a..08bd6c3c 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -85,6 +85,13 @@ func withGenerated() *metadata.DatabaseSchemaMetadata { 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)) +} + func withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { for _, s := range db.Schemas { if s.Name == schema { @@ -1551,6 +1558,89 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 7742df65edf4c47709d9abfa159fe49d57f5f01f Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 15:37:57 +0000 Subject: [PATCH 23/40] fix(pg/review): refuse CREATE TABLE AS collisions, constraint renames, checks, and routine drops the server rejects From the twenty-first Codex review of the scan: - CREATE TABLE AS, CREATE MATERIALIZED VIEW, and SELECT INTO are refused for a taken name or a query reading a relation the target lacks, and are no-ops under IF NOT EXISTS of a taken name. - RENAME CONSTRAINT of a known table is refused when the table has the new name, or the constraint's index cannot take it. - A qualified name a resolved drop freed is missing, so ALTER of it is refused and DROP ... IF EXISTS of it does nothing. - CREATE FUNCTION with a SQL-standard body reading a relation the target lacks, and an added CHECK naming a column the table lacks, are refused. - DROP FUNCTION refuses a procedure and DROP PROCEDURE a function; the index reads a synced routine's kind from its definition, since Bytebase lists procedures among the functions. - Routine signatures tell an array argument from a scalar one. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 21 ++++- pg/review/scan.go | 85 +++++++++++++++---- pg/review/scan_oracle_test.go | 12 +++ pg/review/schema_index.go | 36 +++++++-- pg/review/target_rules_test.go | 94 ++++++++++++++++++++++ 5 files changed, 227 insertions(+), 21 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 20d0524a..5a7e30e1 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -621,7 +621,8 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string } } for _, c := range constraints { - // A key or foreign key names columns the table must have. + // 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] { @@ -629,6 +630,9 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string } } } + if c.Contype == ast.CONSTR_CHECK && slices.ContainsFunc(columnsIn(c.RawExpr), func(column string) bool { return !has(column) && !addedColumns[column] }) { + return true, false + } if c.Conname != "" { if uses(c.Conname) || addedNames[c.Conname] { return true, false @@ -812,6 +816,21 @@ func (s *scan) indexNameTaken(t tableRef, name string, droppedColumns, droppedCo 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 { diff --git a/pg/review/scan.go b/pg/review/scan.go index 1e66fc86..ef0dea38 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -562,6 +562,28 @@ func createdIn(n ast.Node) string { 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 matview { + s.reads(rel, query) + } +} + // isProcedure reports whether CREATE FUNCTION is CREATE PROCEDURE, which // the parser marks with an option. func isProcedure(v *ast.CreateFunctionStmt) bool { @@ -595,10 +617,7 @@ func (s *scan) statement(st *statement) { s.touch(v.Base.Relation) case *ast.CreateTableAsStmt: if v.Into != nil { - s.touch(v.Into.Rel) - } - if v.Objtype == ast.OBJECT_MATVIEW && v.Into != nil { - s.reads(v.Into.Rel, v.Query) + 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 @@ -624,6 +643,11 @@ func (s *scan) statement(st *statement) { } s.reads(v.View, v.Query) case *ast.CreateFunctionStmt: + // A SQL-standard body is resolved when the routine is created. + if v.SqlBody != nil && s.readsMissing(v.SqlBody) { + s.stop() + return + } s.returns(v) case *ast.AlterSeqStmt: if v.Sequence != nil && v.Options != nil && slices.ContainsFunc(v.Options.Items, func(n ast.Node) bool { @@ -679,7 +703,9 @@ func (s *scan) statement(st *statement) { case *ast.SelectStmt: switch { case v.IntoClause != nil: - s.touch(v.IntoClause.Rel) + q := *v + q.IntoClause = nil + s.createAs(v.IntoClause.Rel, false, &q, false) case selectsInto(v): s.stop() default: @@ -694,10 +720,7 @@ func (s *scan) statement(st *statement) { switch q := v.Query.(type) { case *ast.CreateTableAsStmt: if q.Into != nil { - s.touch(q.Into.Rel) - if q.Objtype == ast.OBJECT_MATVIEW { - s.reads(q.Into.Rel, q.Query) - } + s.createAs(q.Into.Rel, q.IfNotExists, q.Query, q.Objtype == ast.OBJECT_MATVIEW) } case *ast.SelectStmt: if q.IntoClause != nil || selectsInto(q) { @@ -1109,7 +1132,14 @@ func argSignature(args *ast.List) string { if args != nil { for _, item := range args.Items { if t, ok := item.(*ast.TypeName); ok { - types = append(types, strings.Join(nameParts(t.Names), ".")) + name := strings.Join(nameParts(t.Names), ".") + if t.PctType { + name += "%TYPE" + } + if t.ArrayBounds != nil && len(t.ArrayBounds.Items) > 0 { + name += "[]" + } + types = append(types, name) } else { types = append(types, "?") } @@ -2136,6 +2166,10 @@ func (s *scan) rename(v *ast.RenameStmt) { s.touchName(schema, v.Newname) } case v.RenameType == ast.OBJECT_TABCONSTRAINT && v.Relation != nil: + if t, ok := s.table(v.Relation); ok && s.renameConstraintRefused(t, v.Subname, v.Newname) { + s.stop() + return + } schema := s.schemaOf(v.Relation) s.constraints[[3]string{schema, v.Relation.Relname, v.Subname}] = true s.constraints[[3]string{schema, v.Relation.Relname, v.Newname}] = true @@ -2155,6 +2189,17 @@ func (s *scan) rename(v *ast.RenameStmt) { } } +// renameConstraintRefused reports whether RENAME CONSTRAINT of a known +// table certainly fails: 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] + if _, ok := constraint(table, to); ok && s.constraintKnown(t, to) && !s.dropped[[3]string{t.schema, t.table, to}] { + return true + } + return s.index.constraintIndexes[tableRef{t.schema, from}] && s.constraintKnown(t, from) && s.nameTaken(t.schema, to) +} + // 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. @@ -2495,7 +2540,7 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { } // A routine the target certainly lacks is not there to drop; IF // EXISTS then drops nothing. - if s.routineMissing(fn) { + if s.routineMissing(fn, ast.ObjectType(v.RemoveType)) { if !v.Missing_ok { s.stop() return @@ -2570,19 +2615,27 @@ func (s *scan) syncedFunctions(fn tableRef) int { // 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) bool { +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.routineNames[fn] + 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.routineNames[tableRef{name, fn.table}] { + if s.schemas[name] || name == "information_schema" || s.index.routineKinds[tableRef{name, fn.table}]&want != 0 { return false } } @@ -2843,6 +2896,10 @@ func (s *scan) dropsMissing(v *ast.DropStmt) bool { // 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 } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 683db9c7..bd3ff557 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -241,6 +241,18 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index f5ce3e93..e0924f75 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -27,10 +27,10 @@ type schemaIndex struct { functions map[tableRef]int // constraintIndexes marks the indexes a constraint owns. constraintIndexes map[tableRef]bool - // extensions lists the installed extensions by name, and routineNames - // the (schema, name) of every function and procedure. + // extensions lists the installed extensions by name, and routineKinds + // the kinds of routine, function or procedure, each (schema, name) has. extensions map[string]bool - routineNames map[tableRef]bool + routineKinds map[tableRef]routineKind // noArgs marks the functions, by (schema, name), whose signature takes // no arguments. noArgs map[tableRef]bool @@ -93,7 +93,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { constraintIndexes: make(map[tableRef]bool), extensions: make(map[string]bool), - routineNames: make(map[tableRef]bool), + routineKinds: make(map[tableRef]routineKind), noArgs: make(map[tableRef]bool), ownedSequences: make(map[columnRef][]string), } @@ -161,12 +161,12 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { ns.routines = len(s.GetFunctions()) + len(s.GetProcedures()) for _, p := range s.GetProcedures() { fn := tableRef{s.GetName(), p.GetName()} - idx.routineNames[fn] = true + idx.routineKinds[fn] |= kindProcedure idx.functions[fn]++ } for _, f := range s.GetFunctions() { fn := tableRef{s.GetName(), f.GetName()} - idx.routineNames[fn] = true + idx.routineKinds[fn] |= kindOf(f) idx.functions[fn]++ if f.GetSignature() == f.GetName()+"()" { idx.noArgs[fn] = true @@ -180,6 +180,30 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { return idx } +// 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) { diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 08bd6c3c..ad063022 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -92,6 +92,14 @@ func withCompositeKey() *metadata.DatabaseSchemaMetadata { 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 +} + func withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { for _, s := range db.Schemas { if s.Name == schema { @@ -397,6 +405,17 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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;", @@ -1641,6 +1660,81 @@ func TestRequirePrimaryKey(t *testing.T) { })}}, 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: "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);", From 9be20ff4bf509ca3f95302a7c66ad06764ea0cc8 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 15:52:46 +0000 Subject: [PATCH 24/40] fix(pg/review): follow renamed columns to the synced ones, inheritance parents by schema, and built-in type spellings From the twenty-second Codex review of the scan: - A column a rename named is followed to the synced column it is, so dropping it removes that column's key, constraints, indexes, owned sequences, and foreign keys. This also fixes a drop of a renamed column alongside a DROP CONSTRAINT the column drop already took, which was reported though the server refuses it. - A pending table's INHERITS parents keep their schema, follow renames and moves, and a cascading drop settles only children of a parent it may drop. - Routine signatures compare a built-in type however it is written. - CREATE FOREIGN TABLE of a taken name is refused, or does nothing under IF NOT EXISTS. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 80 ++++++++++++++-------- pg/review/rule_require_primary_key.go | 35 +++++++--- pg/review/scan.go | 77 +++++++++++++++++---- pg/review/scan_oracle_test.go | 6 ++ pg/review/target_rules_test.go | 39 +++++++++++ 5 files changed, 186 insertions(+), 51 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 5a7e30e1..dee3d5a8 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -71,7 +71,11 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { // refuse withholds the statement's findings without ending the scan. 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. @@ -87,7 +91,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.stop() return } - if len(droppedColumns) > 0 && !cmd.Missing_ok && knownBefore && s.mayDependOn(before, cmd.Name, droppedColumns) { + if len(droppedOrigins) > 0 && !cmd.Missing_ok && knownBefore && s.mayDependOn(before, cmd.Name, droppedOrigins) { withhold = true } droppedNames[cmd.Name] = true @@ -115,27 +119,38 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { continue } droppedColumns[cmd.Name] = true + // 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 { + 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) == cmd.Name }) { + if fk.owner == t && slices.ContainsFunc(fk.local, func(c string) bool { return columnName(c) == origin }) { s.constraints[[3]string{t.schema, t.table, fk.name}] = true s.dropped[[3]string{t.schema, t.table, fk.name}] = true } } } if cascade { - s.dropReaders(t, known, v.Relation, cmd.Name) + sure := known && followed + s.dropReaders(t, sure, v.Relation, origin) s.dropReferences(v.Relation, t, known, func(fk foreignKeyRef) (bool, bool) { - if !known { + if !sure { return true, false } - certainly := slices.ContainsFunc(fk.columns, func(c string) bool { return columnName(c) == cmd.Name }) + 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 }) } @@ -149,7 +164,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } withhold = withhold || out.uncertain if knownBefore && isTable { - refused, uncertain := s.refuses(before, droppedColumns, droppedNames, others) + refused, uncertain := s.refuses(before, droppedColumns, droppedOrigins, droppedNames, others) if refused && !withhold { s.stop() return @@ -342,18 +357,19 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt // 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] - exists, changed := s.columnNow(t, cmd.Name) + // column is what the dropped column is in the synced table, through + // any rename. + column, ok := s.originOf(t, cmd.Name) switch { - case s.columnRenamed(t, cmd.Name): - // A column renamed to this name: what it was is not followed. - return false - case changed && !exists: - return !cmd.Missing_ok - case changed: - // A column the change added is not the key's. + 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() == cmd.Name }) { + if !slices.ContainsFunc(table.GetColumns(), func(c *metadata.ColumnMetadata) bool { return c.GetName() == column }) { return !cmd.Missing_ok } defer func() { @@ -362,11 +378,11 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out } // The column's indexes and owned sequences go with it. for _, i := range table.GetIndexes() { - if slices.Contains(keyColumns(i), cmd.Name) && !s.isTouched(&ast.RangeVar{Schemaname: t.schema, Relname: i.GetName()}) { + 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, cmd.Name}] { + 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) } @@ -377,14 +393,14 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out if pk != nil { pkColumns = keyColumns(pk) } - if !cascade && (s.index.referencesColumn(t, cmd.Name, s.dropped) || generatedFrom(table, cmd.Name) || slices.ContainsFunc(s.index.readsColumn[columnRef{t.schema, t.table, cmd.Name}], s.viewHolds) || - s.newlyReferencedColumn(t, cmd.Name, pkColumns, pk != nil) || s.dependsOnNew(s.newReads, t, nil)) { + 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 || !s.constraintKnown(t, pk.GetName()) { return false } - if !slices.Contains(keyColumns(pk), cmd.Name) { + if !slices.Contains(keyColumns(pk), column) { return false } if out.lost == nil { @@ -482,6 +498,10 @@ func (s *scan) dropKeyReferences(rv *ast.RangeVar, t tableRef, known bool, name // 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 + } table := s.index.schemas[t.schema].tables[t.table] for _, fk := range table.GetForeignKeys() { if fk.GetName() == name { @@ -509,7 +529,7 @@ func (s *scan) mayDependOn(t tableRef, name string, columns map[string]bool) boo // // 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, droppedConstraints map[string]bool, others []*ast.AlterTableCmd) (refused, uncertain bool) { +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 { @@ -522,7 +542,7 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string return false } // A column drop of the statement may have taken the constraint. - if len(droppedColumns) > 0 && s.mayDependOn(t, name, droppedColumns) { + if len(droppedOrigins) > 0 && s.mayDependOn(t, name, droppedOrigins) { return false } _, ok := constraint(table, name) @@ -577,7 +597,7 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string // 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 || s.newKeys[[2]string{t.schema, t.table}] || s.newKeys[[2]string{"", t.table}] { + if addsIndex || droppedOrigins[""] || s.newKeys[[2]string{t.schema, t.table}] || s.newKeys[[2]string{"", t.table}] { return false, false } if len(columns) == 0 { @@ -593,7 +613,7 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string if !s.isKey(t, i) || droppedConstraints[i.GetName()] { continue } - if key := keyColumns(i); !slices.ContainsFunc(key, func(c string) bool { return droppedColumns[c] }) && sameKey(key, columns) { + if key := keyColumns(i); !slices.ContainsFunc(key, func(c string) bool { return droppedOrigins[c] }) && sameKey(key, columns) { return true, true } } @@ -643,21 +663,21 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedConstraints map[string // constraint's name in the table's schema. switch { case c.Indexname != "": - r, u := s.refusesIndex(t, c.Indexname, droppedColumns, droppedConstraints) + 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, droppedColumns, droppedConstraints) + 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, droppedColumns, droppedConstraints) + taken, known := s.indexNameTaken(t, c.Conname, droppedOrigins, droppedConstraints) if taken { return true, false } @@ -763,7 +783,7 @@ func (s *scan) refusesReference(t tableRef, c *ast.Constraint, own func(string) // // 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 s.isTouched(&ast.RangeVar{Schemaname: t.schema, Relname: name}) { + if droppedColumns[""] || s.isTouched(&ast.RangeVar{Schemaname: t.schema, Relname: name}) { return false, true } switch kind, ok := s.index.schemas[t.schema].relations[name]; { @@ -795,7 +815,7 @@ func (s *scan) indexNameTaken(t tableRef, name string, droppedColumns, droppedCo switch { case s.freed[[2]string{t.schema, name}]: return false, true - case s.isTouched(rv): + case s.isTouched(rv) || droppedColumns[""]: return false, false } if _, ok := s.index.schemas[t.schema].relations[name]; !ok || droppedConstraints[name] { diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index ef670910..7cc286df 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -1,6 +1,7 @@ package review import ( + "slices" "strings" "github.com/bytebase/omni/pg/ast" @@ -27,10 +28,11 @@ type pendingTable struct { 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: a cascading drop of a - // parent may drop either. + // 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 []string + parents []tableRef // made numbers the touch that created a table the change made. made int @@ -42,17 +44,34 @@ type pendingTable struct { } // inheritsFrom names the tables CREATE TABLE ... INHERITS lists. -func inheritsFrom(v *ast.CreateStmt) []string { +func (s *scan) inheritsFrom(v *ast.CreateStmt) []tableRef { if v.InhRelations == nil { return nil } - var names []string + var parents []tableRef for _, item := range v.InhRelations.Items { if rv, ok := item.(*ast.RangeVar); ok { - names = append(names, rv.Relname) + 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) } } - return names } // matching returns the pending tables a name may refer to: any with that @@ -140,7 +159,7 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { s.pending = append(s.pending, &pendingTable{ names: map[string]bool{v.Relation.Relname: true}, schema: v.Relation.Schemaname, - parents: inheritsFrom(v), + parents: s.inheritsFrom(v), statement: st.index, rng: rangeOf(v.Loc), message: "creates table " + relation(v.Relation) + " without a primary key", diff --git a/pg/review/scan.go b/pg/review/scan.go index ef0dea38..d74522a4 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -75,6 +75,9 @@ type scan struct { // 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 @@ -152,6 +155,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]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), @@ -614,7 +618,23 @@ func (s *scan) statement(st *statement) { case *ast.CreateStmt: s.create(st, v) case *ast.CreateForeignTableStmt: - s.touch(v.Base.Relation) + rel := v.Base.Relation + if rel == nil { + return + } + taken := s.existsWhereCreated(rel) || s.madeHere(rel) + switch { + 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) @@ -1132,7 +1152,12 @@ func argSignature(args *ast.List) string { if args != nil { for _, item := range args.Items { if t, ok := item.(*ast.TypeName); ok { - name := strings.Join(nameParts(t.Names), ".") + // 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" } @@ -1817,6 +1842,21 @@ func (s *scan) columnNow(t tableRef, column string) (exists, known bool) { 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 { @@ -2128,6 +2168,7 @@ func (s *scan) rename(v *ast.RenameStmt) { return } s.follow(v.Relation, "", v.Newname) + s.followParents(v.Relation, tableRef{s.schemaOf(v.Relation), v.Newname}) s.touchName(v.Relation.Schemaname, v.Relation.Relname) s.touchName(v.Relation.Schemaname, v.Newname) if resolved { @@ -2175,10 +2216,19 @@ func (s *scan) rename(v *ast.RenameStmt) { s.constraints[[3]string{schema, v.Relation.Relname, v.Newname}] = true case v.RenameType == ast.OBJECT_COLUMN && v.Relation != nil: // A known table must have the column, and not the new name. - if t, ok := s.table(v.Relation); ok && (!s.hasColumn(t, v.Subname) || s.hasColumn(t, v.Newname)) { + 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) @@ -2338,6 +2388,7 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { 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. @@ -2455,7 +2506,7 @@ func (s *scan) drop(v *ast.DropStmt) { written = append(written, t) } } - dropsAny := false + var droppedRefs []tableRef for _, obj := range v.Objects.Items { parts := nameParts(listOf(obj)) if len(parts) == 0 { @@ -2466,6 +2517,10 @@ func (s *scan) drop(v *ast.DropStmt) { rv.Schemaname = parts[len(parts)-2] } schema, relKind, resolved := s.lookup(rv) + ref := tableRef{rv.Schemaname, rv.Relname} + if resolved { + ref.schema = schema + } switch { case resolved: s.free(schema, rv.Relname) @@ -2480,7 +2535,7 @@ func (s *scan) drop(v *ast.DropStmt) { s.touch(rv) schema = "" } - dropsAny = true + droppedRefs = append(droppedRefs, ref) if kind == ast.OBJECT_INDEX { s.renamedKeys[rv.Relname] = true } @@ -2501,17 +2556,13 @@ func (s *scan) drop(v *ast.DropStmt) { } } } - if cascade && dropsAny && (kind == ast.OBJECT_TABLE || kind == ast.OBJECT_FOREIGN_TABLE) { + 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. - dropped := make(map[string]bool) - for _, obj := range v.Objects.Items { - if parts := nameParts(listOf(obj)); len(parts) > 0 { - dropped[parts[len(parts)-1]] = true - } - } for _, p := range s.pending { - if p.existed || slices.ContainsFunc(p.parents, func(name string) bool { return dropped[name] }) { + if p.existed || slices.ContainsFunc(p.parents, func(parent tableRef) bool { + return slices.ContainsFunc(droppedRefs, func(d tableRef) bool { return mayBe(parent, d) }) + }) { p.settled = true } } diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index bd3ff557..c22c3b08 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -253,6 +253,12 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index ad063022..a5ee3c5a 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -416,6 +416,11 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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;", @@ -1735,6 +1740,40 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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);", From a9198859d1a9ab38792a9e3368449dc6988569f4 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 16:04:14 +0000 Subject: [PATCH 25/40] fix(pg/review): stage a withheld ALTER's drops, and refuse index, type, schema, rename, and drop collisions From the twenty-third Codex review of the scan: - An ALTER TABLE with a subcommand certain to fail ends the scan even when a drop of it is uncertain, and a withheld statement's constraint and column drops stay tentative: they are not recorded as done. - CREATE INDEX is refused for a relation the target lacks or cannot index, or a taken name, and does nothing under IF NOT EXISTS of one. - CREATE TYPE, enum, domain, range, or composite, is refused for a name a relation's row type or another type has. - A second CREATE SCHEMA of a schema the change created is refused; a rename of a relation the change created to a taken name is refused; DROP of a relation the change created as another kind is refused. - CREATE TABLE is refused for a CHECK naming a column it lacks, and a foreign key spelled with the schema CREATE puts the table in is a reference to the table itself. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 33 +++-- pg/review/scan.go | 140 +++++++++++++++++++-- pg/review/scan_oracle_test.go | 12 ++ pg/review/target_rules_test.go | 74 +++++++++++ 4 files changed, 241 insertions(+), 18 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index dee3d5a8..7ec9b03a 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -1,6 +1,7 @@ package review import ( + "maps" "slices" "strings" @@ -71,6 +72,9 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { // refuse withholds the statement's findings without ending the scan. var out droppedKeys withhold := false + // What the statement records as dropped stays tentative until it is + // known to run. + prior := maps.Clone(s.dropped) // droppedColumns names the dropped columns as the statement does, and // droppedOrigins as the synced table does, "" for one the scan cannot // follow there. @@ -156,20 +160,33 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } } - if droppedAColumn { - s.unsettled[[2]string{schema, name}] = true - for column := range droppedColumns { - s.setColumn(schema, name, column, false, false) - } - } withhold = withhold || out.uncertain if knownBefore && isTable { refused, uncertain := s.refuses(before, droppedColumns, droppedOrigins, droppedNames, others) - if refused && !withhold { + // A subcommand certain to fail fails the statement, whatever its + // drops do. + if refused { s.stop() return } - withhold = withhold || refused || uncertain + withhold = withhold || uncertain + } + if withhold { + // The constraints the statement drops may still be there. + for key := range s.dropped { + if !prior[key] { + delete(s.dropped, key) + } + } + } + if droppedAColumn { + s.unsettled[[2]string{schema, name}] = true + // Columns a statement that may not run drops may still be there. + if !withhold { + for column := range droppedColumns { + s.setColumn(schema, name, column, false, false) + } + } } for _, name := range out.freed { // What a statement that may not run drops may still be there. diff --git a/pg/review/scan.go b/pg/review/scan.go index d74522a4..2ae85132 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -691,6 +691,10 @@ func (s *scan) statement(st *statement) { s.made(v.Sequence, kindSequence) } case *ast.CompositeTypeStmt: + if v.Typevar != nil && s.typeTaken(v.Typevar) { + s.stop() + return + } s.touch(v.Typevar) case *ast.CreateEnumStmt: // A type's name is taken for a relation's row type too. @@ -706,6 +710,16 @@ func (s *scan) statement(st *statement) { 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) } @@ -1203,7 +1217,7 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { // IF NOT EXISTS of an existing table does nothing. return } - if duplicateNames(v) || s.sourceRefused(v) || s.keyNameTaken(v) { + if duplicateNames(v) || s.sourceRefused(v) || s.keyNameTaken(v) || checksRefused(v) { s.stop() return } @@ -1280,8 +1294,9 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { return } // CREATE SCHEMA of a schema the target has, and the change did not - // drop, is refused, and with IF NOT EXISTS does nothing. - if s.index != nil && s.index.schemas[name] != nil && !s.schemas[name] { + // 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() } @@ -1388,7 +1403,8 @@ func elementsCollide(schema string, elts *ast.List) bool { } // 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. +// 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 { @@ -1398,12 +1414,44 @@ func (s *scan) touchType(name *ast.List, suffix string) { if len(parts) == 2 { schema = parts[0] } + if s.typeTaken(&ast.RangeVar{Schemaname: schema, Relname: parts[len(parts)-1]}) { + s.stop() + return + } s.touchName(schema, parts[len(parts)-1]) 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 { + return false + } + switch kind { + case kindView, kindSequence, kindIndex, kindCompositeType, kindForeignTable: + 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. @@ -1523,18 +1571,33 @@ func (s *scan) referencesRefused(v *ast.CreateStmt) (refused, uncertain bool) { 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: + case pk == nil || ambiguous: + // It may be the table itself, or another of its name. uncertain = true - case pk.Relname == v.Relation.Relname && pk.Schemaname == v.Relation.Schemaname: + case self: if !complete { uncertain = true } else if own.refusedBy(columns, local) { return true, false } - case pk.Relname == v.Relation.Relname: - // It may be the table itself, or another of its name. - uncertain = true default: if def, ok := s.madeDef(pk); ok { if def.refusedBy(columns, local) { @@ -1552,6 +1615,40 @@ func (s *scan) referencesRefused(v *ast.CreateStmt) (refused, uncertain bool) { 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 CHECK constraint CREATE TABLE declares +// names a column the table does not have, when the statement says all its +// columns. +func checksRefused(v *ast.CreateStmt) bool { + own, _, complete := defOf(v) + if !complete || v.TableElts == nil { + return false + } + 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_CHECK && slices.ContainsFunc(columnsIn(c.RawExpr), func(column string) bool { return !own.columns[column] }) { + return true + } + } + } + return false +} + // 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) { @@ -1569,6 +1666,18 @@ func (s *scan) madeHere(rv *ast.RangeVar) bool { 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. @@ -2167,6 +2276,10 @@ func (s *scan) rename(v *ast.RenameStmt) { 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}) s.touchName(v.Relation.Schemaname, v.Relation.Relname) @@ -2780,7 +2893,14 @@ func (s *scan) dropsWrongKind(v *ast.DropStmt) bool { rv.Schemaname = parts[0] } schema, relKind, ok := s.lookup(rv) - if !ok || relKind == kindAmbiguous { + if !ok { + // A relation the change created has the kind it was made as. + if c, made := s.madeAt(rv); made && !dropKindMatches(kind, c.kind) { + return true + } + continue + } + if relKind == kindAmbiguous { continue } if !dropKindMatches(kind, relKind) { diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index c22c3b08..87a22f11 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -259,6 +259,18 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index a5ee3c5a..9d85b96f 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -421,6 +421,11 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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;", @@ -1774,6 +1779,75 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 7d02240200aa1ccae66c5e667cc5c5855fffed0d Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 16:13:23 +0000 Subject: [PATCH 26/40] fix(pg/review): refuse invalid keys and taken routine signatures, and resolve where unqualified tables go From the twenty-fourth Codex review of the scan: - CREATE TABLE is refused for a key naming a column it lacks or a column twice, or a second primary key, and ALTER TABLE for an added key naming a column twice. - CREATE FUNCTION without OR REPLACE is refused for a signature the change created, or, without arguments, one a synced routine has where it creates. - A pending table records the schema the search path creates it in, so a qualified statement on another schema's table of that name does not settle it; a table the search path puts in pg_temp, or a system schema, is not reported. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 4 ++ pg/review/rule_require_primary_key.go | 40 ++++++++++- pg/review/scan.go | 77 +++++++++++++++++++--- pg/review/scan_oracle_test.go | 7 ++ pg/review/target_rules_test.go | 48 +++++++++++++- 5 files changed, 164 insertions(+), 12 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 7ec9b03a..e029ef5d 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -670,6 +670,10 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedOrigins, droppedConstr 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 diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 7cc286df..2813c135 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -146,7 +146,7 @@ func (s *scan) lostKey(st *statement, v *ast.AlterTableStmt, cmd *ast.AlterTable // 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) { + 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) { @@ -158,7 +158,7 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { } s.pending = append(s.pending, &pendingTable{ names: map[string]bool{v.Relation.Relname: true}, - schema: v.Relation.Schemaname, + schema: s.pendingSchema(v.Relation), parents: s.inheritsFrom(v), statement: st.index, rng: rangeOf(v.Loc), @@ -182,6 +182,42 @@ func (s *scan) keylessNew(rv *ast.RangeVar) bool { 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 diff --git a/pg/review/scan.go b/pg/review/scan.go index 2ae85132..ef627187 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -663,8 +663,9 @@ func (s *scan) statement(st *statement) { } s.reads(v.View, v.Query) case *ast.CreateFunctionStmt: - // A SQL-standard body is resolved when the routine is created. - if v.SqlBody != nil && s.readsMissing(v.SqlBody) { + // 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) { s.stop() return } @@ -1093,6 +1094,48 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { } } +// 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 +} + +// 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 signature != "" || 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} + return ok && s.index.noArgs[fn] && !s.droppedFunctions[fn] +} + // 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 @@ -1624,14 +1667,16 @@ func (s *scan) homeOf(rv *ast.RangeVar) (string, bool) { return s.creationSchema(rv) } -// checksRefused reports whether a CHECK constraint CREATE TABLE declares -// names a column the table does not have, when the statement says all its -// columns. +// 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 !complete || v.TableElts == nil { + if v.TableElts == nil { return false } + primaries := 0 for _, item := range v.TableElts.Items { var cs []*ast.Constraint switch e := item.(type) { @@ -1641,12 +1686,26 @@ func checksRefused(v *ast.CreateStmt) bool { cs = []*ast.Constraint{e} } for _, c := range cs { - if c.Contype == ast.CONSTR_CHECK && slices.ContainsFunc(columnsIn(c.RawExpr), func(column string) bool { return !own.columns[column] }) { - return true + missing := func(column string) bool { return complete && !own.columns[column] } + switch c.Contype { + case ast.CONSTR_CHECK: + 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 false + return primaries > 1 } // madeDef returns the definition of a table a name certainly means that diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 87a22f11..0c8150b3 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -271,6 +271,13 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 9d85b96f..006c20d1 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -426,6 +426,11 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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;", @@ -691,10 +696,15 @@ func TestRequirePrimaryKey(t *testing.T) { 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}}}, + 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", @@ -1848,6 +1858,42 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 1f518c5aff48b5238eb7dd804ffccd5e65e9b028 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 16:26:31 +0000 Subject: [PATCH 27/40] fix(pg/review): refuse reads of indexes, inline index and foreign table errors, missing routine renames, and NOT NULL drops of key columns From the twenty-fifth Codex review of the scan: - A view or query reading an index or a composite type is refused like one reading a missing relation. - An index element of CREATE SCHEMA is checked like CREATE INDEX, and a schema the change created holds only what the change named in it. - CREATE FOREIGN TABLE gets the CREATE TABLE definition checks. - ALTER FUNCTION, PROCEDURE, or ROUTINE ... RENAME or SET SCHEMA of a routine the target certainly lacks ends the scan. - DROP NOT NULL of a column of the primary key the table keeps refuses the statement. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 8 ++++ pg/review/scan.go | 50 +++++++++++++++++++++- pg/review/scan_oracle_test.go | 8 ++++ pg/review/target_rules_test.go | 47 ++++++++++++++++++++ 4 files changed, 111 insertions(+), 2 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index e029ef5d..9dd95274 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -655,6 +655,14 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedOrigins, droppedConstr 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 && uses(pk.GetName()) { + if origin, ok := s.originOf(t, cmd.Name); ok && origin != "" && slices.Contains(keyColumns(pk), origin) { + return true, false + } + } + } } } for _, c := range constraints { diff --git a/pg/review/scan.go b/pg/review/scan.go index ef627187..e322e81f 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -624,6 +624,9 @@ func (s *scan) statement(st *statement) { } 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 @@ -945,14 +948,19 @@ func (s *scan) dependsIn(deps *[]newDependency, object tableRef, n ast.Node, vis } // readsMissing reports whether a query names a relation the target -// certainly lacks, CTE names aside. +// 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) { - missing = missing || s.missing(rv) + if s.missing(rv) { + missing = true + } else if _, kind, ok := s.lookup(rv); ok && (kind == kindIndex || kind == kindCompositeType) { + missing = true + } }) return missing } @@ -1380,6 +1388,12 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { 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) } @@ -2405,6 +2419,10 @@ func (s *scan) rename(v *ast.RenameStmt) { 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: + if fn, ok := routineOf(v.Object); ok && s.routineMissing(fn, v.RenameType) { + s.stop() + return + } s.renameRoutine(v) case v.RenameType == ast.OBJECT_SCHEMA: s.stop() @@ -2590,6 +2608,12 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { 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 && s.routineMissing(fn, v.ObjectType) { + if !v.MissingOk { + s.stop() + } + return + } if owa, ok := v.Object.(*ast.ObjectWithArgs); ok { if parts := nameParts(owa.Objname); len(parts) > 0 { s.madeRoutines[parts[len(parts)-1]] = true @@ -2865,6 +2889,23 @@ func (s *scan) routineMissing(fn tableRef, kind ast.ObjectType) bool { return true } +// 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. @@ -3134,6 +3175,11 @@ func (s *scan) missing(rv *ast.RangeVar) bool { 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 diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 0c8150b3..38c61897 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -278,6 +278,14 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 006c20d1..c7ad237c 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -1894,6 +1894,53 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 7d5bb592026c5bdbf892cea018d7d57c9158ffa8 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 16:38:59 +0000 Subject: [PATCH 28/40] fix(pg/review): reopen tables whose added key is dropped, and refuse repeated types, view replacements, sequence alters, and drops the change's objects hold From the twenty-sixth Codex review of the scan: - A pending table a statement settled only by adding a primary key the scan can name is keyless again when a later statement certainly drops that key. - Types the change creates are its own: a second CREATE TYPE of the name is refused, and so is a table of it. - CREATE OR REPLACE VIEW of a relation the change made as another kind, ALTER SEQUENCE of a relation the target lacks or that is no sequence, and DROP of a relation the change made that its own objects depend on, are refused. The PRIOR_BACKUP comment on IF NOT EXISTS creations is declined again: the synced schema can predate an earlier spec of the same plan. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 23 +++++-- pg/review/rule_require_primary_key.go | 70 +++++++++++++++++++++- pg/review/scan.go | 34 ++++++++++- pg/review/scan_oracle_test.go | 7 +++ pg/review/schema_index.go | 3 + pg/review/target_rules_test.go | 48 +++++++++++++++ 6 files changed, 175 insertions(+), 10 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 9dd95274..e10ae775 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -84,6 +84,8 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { // 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 @@ -113,6 +115,14 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { 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() @@ -188,6 +198,11 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } } } + if !withhold { + for _, p := range reopened { + p.settled, p.key = false, "" + } + } for _, name := range out.freed { // What a statement that may not run drops may still be there. if withhold { @@ -232,7 +247,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.renamedKeys[c.Indexname] = true } if c.Contype == ast.CONSTR_PRIMARY { - s.keyed(v.Relation) + s.keyed(v.Relation, s.keyName(v.Relation, c)) } if c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE { s.newKey(v.Relation) @@ -267,7 +282,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.touchName(v.Relation.Schemaname, c.Conname) } if c.Contype == ast.CONSTR_PRIMARY { - s.keyed(v.Relation) + s.keyed(v.Relation, "") } if c.Contype == ast.CONSTR_PRIMARY || c.Contype == ast.CONSTR_UNIQUE { s.newKey(v.Relation) @@ -281,7 +296,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { if idx, ok := cmd.Def.(*ast.IndexStmt); ok { s.renamedKeys[idx.Idxname] = true if idx.Primary { - s.keyed(v.Relation) + s.keyed(v.Relation, "") } if idx.Primary || idx.Unique { s.newKey(v.Relation) @@ -294,7 +309,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.generate(pc.Name) s.unsettle(pc.Name) for _, p := range s.matching(pc.Name) { - p.settled = true + p.settle() } } case ast.AT_DetachPartition: diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 2813c135..cedd8fa5 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -36,6 +36,10 @@ type pendingTable struct { // made numbers the touch that created a table the change made. made int + // key names the primary key a statement of the change added that + // settles the table, when that is all that settles it; dropping it + // leaves the table keyless again. + key string statement int rng review.Range @@ -114,11 +118,71 @@ func (s *scan) resolvesBefore(name, schema string) bool { return false } -// keyed settles the pending tables a statement may give a primary key. -func (s *scan) keyed(rv *ast.RangeVar) { +// 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 = "" +} + +// keyed settles the pending tables a statement may give a primary key, +// recording the key's name, "" when unknown, on the one it certainly +// names. +func (s *scan) keyed(rv *ast.RangeVar, name string) { for _, p := range s.matching(rv) { - p.settled = true + settled := p.settled + p.settle() + if !settled && name != "" && s.namesPending(p, rv) { + p.key = name + } + } +} + +// 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 diff --git a/pg/review/scan.go b/pg/review/scan.go index e322e81f..e15b933b 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -655,6 +655,10 @@ func (s *scan) statement(st *statement) { s.stop() return } + if c, made := s.madeAt(v.View); made && c.kind != kindView { + s.stop() + return + } if ok && kind == kindView { s.replacedViews[tableRef{schema, v.View.Relname}] = true } @@ -674,6 +678,22 @@ func (s *scan) statement(st *statement) { } 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 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" @@ -700,6 +720,9 @@ func (s *scan) statement(st *statement) { return } s.touch(v.Typevar) + if v.Typevar != nil { + s.made(v.Typevar, kindCompositeType) + } case *ast.CreateEnumStmt: // A type's name is taken for a relation's row type too. s.touchType(v.TypeName, "") @@ -1471,11 +1494,13 @@ func (s *scan) touchType(name *ast.List, suffix string) { if len(parts) == 2 { schema = parts[0] } - if s.typeTaken(&ast.RangeVar{Schemaname: schema, Relname: parts[len(parts)-1]}) { + 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) } @@ -2736,7 +2761,7 @@ func (s *scan) drop(v *ast.DropStmt) { s.renamedKeys[rv.Relname] = true } for _, p := range s.matching(rv) { - p.settled = true + p.settle() } switch kind { case ast.OBJECT_TABLE: @@ -2759,7 +2784,7 @@ func (s *scan) drop(v *ast.DropStmt) { if p.existed || slices.ContainsFunc(p.parents, func(parent tableRef) bool { return slices.ContainsFunc(droppedRefs, func(d tableRef) bool { return mayBe(parent, d) }) }) { - p.settled = true + p.settle() } } } @@ -3233,6 +3258,9 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { } 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 { diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 38c61897..cf8d3fbb 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -286,6 +286,13 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index e0924f75..54432acf 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -68,6 +68,9 @@ const ( 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 } diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index c7ad237c..57148996 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -1941,6 +1941,54 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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);", From 3c2bcd44df59992a52682f2ded108ae79f367bde Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 16:52:40 +0000 Subject: [PATCH 29/40] fix(pg/review): end the scan at a statement that may fail, and refuse duplicate signatures, wrong-kind alters, index columns, and ambiguous drops From the twenty-seventh Codex review of the scan: - An ALTER TABLE the server may refuse now ends the scan instead of only withholding its own findings: what follows a statement that may not run is not known. An added foreign key to a table the change created and has not altered is checked against its CREATE, so the common create-then-reference change still runs through. - ALTER TABLE IF EXISTS of a relation the target lacks does nothing. - CREATE FUNCTION is refused for a synced signature with arguments, read from the snapshot's identity arguments for built-in types. - RENAME and SET SCHEMA with ALTER VIEW, MATERIALIZED VIEW, SEQUENCE, or FOREIGN TABLE of another kind, or ALTER TABLE of a composite type, are refused; ALTER INDEX ... RENAME takes any relation, as the server shows. - CREATE INDEX naming a column the known table lacks, and DROP FUNCTION by a name several routines have, are refused. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 60 ++++++++---------- pg/review/scan.go | 68 +++++++++++++++++++- pg/review/scan_oracle_test.go | 10 +++ pg/review/schema_index.go | 72 +++++++++++++++++++++- pg/review/target_rules_test.go | 72 ++++++++++++++++++++-- 5 files changed, 238 insertions(+), 44 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index e10ae775..5b5248b6 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -1,7 +1,6 @@ package review import ( - "maps" "slices" "strings" @@ -21,18 +20,22 @@ import ( // // 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 no subcommand is -// known to fail. +// 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 so - // is ALTER TABLE of a relation that is no table. - if !v.Missing_ok && s.index != nil && s.missing(v.Relation) { - s.stop() + // 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 { @@ -68,13 +71,9 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { 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. A DROP the server may - // refuse withholds the statement's findings without ending the scan. + // a statement the server refuses drops nothing. var out droppedKeys withhold := false - // What the statement records as dropped stays tentative until it is - // known to run. - prior := maps.Clone(s.dropped) // droppedColumns names the dropped columns as the statement does, and // droppedOrigins as the synced table does, "" for one the scan cannot // follow there. @@ -181,38 +180,22 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } withhold = withhold || uncertain } + // A statement the server may refuse leaves what follows unknown. if withhold { - // The constraints the statement drops may still be there. - for key := range s.dropped { - if !prior[key] { - delete(s.dropped, key) - } - } + s.stop() + return } if droppedAColumn { s.unsettled[[2]string{schema, name}] = true - // Columns a statement that may not run drops may still be there. - if !withhold { - for column := range droppedColumns { - s.setColumn(schema, name, column, false, false) - } + for column := range droppedColumns { + s.setColumn(schema, name, column, false, false) } } - if !withhold { - for _, p := range reopened { - p.settled, p.key = false, "" - } + for _, p := range reopened { + p.settled, p.key = false, "" } for _, name := range out.freed { - // What a statement that may not run drops may still be there. - if withhold { - s.touchName(schema, name) - } else { - s.free(schema, name) - } - } - if withhold { - out = droppedKeys{} + s.free(schema, name) } for _, f := range out.findings { s.r.add(f) @@ -775,6 +758,11 @@ func (s *scan) refusesReference(t tableRef, c *ast.Constraint, own func(string) } 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} diff --git a/pg/review/scan.go b/pg/review/scan.go index e15b933b..dd73ce05 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -1159,12 +1159,15 @@ func (s *scan) signatureTaken(v *ast.CreateFunctionStmt) bool { if slices.Contains(s.newSignatures[object], signature) { return true } - if signature != "" || s.index == nil || s.movedRoutines[object.table] { + 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} - return ok && s.index.noArgs[fn] && !s.droppedFunctions[fn] + 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 @@ -1531,6 +1534,19 @@ func (s *scan) indexRefused(v *ast.IndexStmt) bool { case kindView, kindSequence, kindIndex, kindCompositeType, kindForeignTable: return true } + // A plain column the index names 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 { + if e, ok := item.(*ast.IndexElem); ok && e.Name != "" && !s.hasColumn(t, e.Name) { + return true + } + } + } + } return v.Idxname != "" && !v.IfNotExists && (s.nameTaken(schema, v.Idxname) || s.madeHere(&ast.RangeVar{Schemaname: schema, Relname: v.Idxname})) } @@ -2368,6 +2384,10 @@ func (s *scan) rename(v *ast.RenameStmt) { } 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) { @@ -2465,6 +2485,29 @@ func (s *scan) renameConstraintRefused(t tableRef, from, to string) bool { return s.index.constraintIndexes[tableRef{t.schema, from}] && s.constraintKnown(t, from) && 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. @@ -2595,6 +2638,10 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { } return } + if s.alterKindRefused(v.ObjectType, v.Relation) { + s.stop() + return + } schema, _, resolved := s.lookup(v.Relation) // A schema that lacks the name must exist to take it. if s.index != nil && (s.index.schemas[v.Newschema] == nil && !s.schemas[v.Newschema] || s.schemaGone[v.Newschema]) || @@ -2810,6 +2857,12 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { 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 { + 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)) { @@ -2882,6 +2935,17 @@ func (s *scan) syncedFunctions(fn tableRef) int { return n } +// 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 diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index cf8d3fbb..19338544 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -293,6 +293,16 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 54432acf..d24b1502 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -32,8 +32,10 @@ type schemaIndex struct { extensions map[string]bool routineKinds map[tableRef]routineKind // noArgs marks the functions, by (schema, name), whose signature takes - // no arguments. - noArgs map[tableRef]bool + // 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 } @@ -98,6 +100,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { extensions: 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() { @@ -174,6 +177,9 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { if f.GetSignature() == f.GetName()+"()" { idx.noArgs[fn] = true } + if sig, ok := syncedSignature(f); 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) @@ -183,6 +189,68 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { return idx } +// syncedSignature 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 syncedSignature(f *metadata.FunctionMetadata) (string, bool) { + sig := f.GetSignature() + if !strings.HasPrefix(sig, f.GetName()+"(") || !strings.HasSuffix(sig, ")") { + return "", false + } + args := strings.TrimSuffix(strings.TrimPrefix(sig, f.GetName()+"("), ")") + 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 diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 57148996..4534d004 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -100,6 +100,12 @@ func withSyncedProcedure() *metadata.DatabaseSchemaMetadata { 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 +} + func withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { for _, s := range db.Schemas { if s.Name == schema { @@ -323,10 +329,10 @@ func TestDisallowDropConstraint(t *testing.T) { targets: one, }, { - name: "a drop of a constraint a column drop may have taken withholds the statement", + // 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, - want: []targetFinding{{1, "DROP CONSTRAINT t_pkey", "drops unique constraint t_pkey of s.t", []int{0}}}, }, { name: "a new foreign key blocks only the key it references", @@ -383,10 +389,11 @@ func TestDisallowDropConstraint(t *testing.T) { want: []targetFinding{{1, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, }, { - name: "a constraint the snapshot lacks does not end the scan", + // 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, - want: []targetFinding{{1, "DROP CONSTRAINT t_n_check", "drops check constraint t_n_check of t", []int{0}}}, }, { name: "a foreign key a cascade took is not there to drop", @@ -431,6 +438,12 @@ func TestDisallowDropConstraint(t *testing.T) { 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: "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;", @@ -1989,6 +2002,57 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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);", From 9caca8b54614031e3667846cd43687ecf246cde9 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 17:08:34 +0000 Subject: [PATCH 30/40] fix(pg/review): read view definitions for relation dependencies, and leave possibly inherited checks undropped From the twenty-eighth Codex review of the scan: - Bytebase records a view's column dependencies only, so a view reading a relation without naming a column of it (count(*), SELECT 1 FROM t) was missing. The index now reads each synced view's definition: a relation it names certainly, qualified, the only one of its name, or the one the synced search path finds, is a reader, and one of several maybe; a definition the scan cannot read holds every drop. The oracle syncs view definitions too. - A DROP CONSTRAINT of a check that a table which may be the table's inheritance parent has too, by name and expression, may be refused, and ends the scan. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 12 +- pg/review/scan.go | 15 ++- pg/review/scan_oracle_test.go | 11 +- pg/review/schema_index.go | 135 +++++++++++++++++++++ pg/review/target_rules_test.go | 45 +++++++ 5 files changed, 210 insertions(+), 8 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 5b5248b6..5ed11556 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -335,6 +335,13 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt } 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, cmd.Name) { + 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. @@ -470,7 +477,7 @@ func (s *scan) dropReaders(t tableRef, known bool, rv *ast.RangeVar, column stri var certain, maybe []tableRef if known { certain = slices.Clone(s.index.readsColumn[columnRef{t.schema, t.table, column}]) - for _, view := range s.index.readers[t] { + for _, view := range append(slices.Clone(s.index.readers[t]), s.index.mayRead[t]...) { if !slices.Contains(certain, view) { maybe = append(maybe, view) } @@ -478,7 +485,8 @@ func (s *scan) dropReaders(t tableRef, known bool, rv *ast.RangeVar, column stri } else { for schema := range s.index.schemas { if rv.Schemaname == "" || rv.Schemaname == schema { - maybe = append(maybe, s.index.readers[tableRef{schema, rv.Relname}]...) + t := tableRef{schema, rv.Relname} + maybe = append(append(maybe, s.index.readers[t]...), s.index.mayRead[t]...) } } } diff --git a/pg/review/scan.go b/pg/review/scan.go index dd73ce05..a7c5e227 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -3135,6 +3135,7 @@ func (s *scan) cascadeViews(certain, maybe []tableRef) { } else { maybe = append(maybe, s.index.readers[view]...) } + maybe = append(maybe, s.index.mayRead[view]...) continue } view := maybe[0] @@ -3144,7 +3145,7 @@ func (s *scan) cascadeViews(certain, maybe []tableRef) { } seen[view] = true s.touchName(view.schema, view.table) - maybe = append(maybe, s.index.readers[view]...) + maybe = append(append(maybe, s.index.readers[view]...), s.index.mayRead[view]...) } } @@ -3337,7 +3338,11 @@ func (s *scan) dropRefused(v *ast.DropStmt) bool { return true } } - for _, view := range s.index.readers[t] { + // 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 } @@ -3403,13 +3408,15 @@ func (s *scan) dropDependents(rv *ast.RangeVar, schema string) { return } if schema != "" { - s.cascadeViews(s.index.readers[tableRef{schema, rv.Relname}], nil) + 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 { - maybe = append(maybe, s.index.readers[tableRef{name, rv.Relname}]...) + t := tableRef{name, rv.Relname} + maybe = append(append(maybe, s.index.readers[t]...), s.index.mayRead[t]...) } } s.cascadeViews(nil, maybe) diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 19338544..ebd38022 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -33,6 +33,10 @@ CREATE TABLE e (id integer PRIMARY KEY, m mood CONSTRAINT e_m_check CHECK (m <> 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 @@ -303,6 +307,9 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, @@ -470,11 +477,11 @@ func syncSchema(t *testing.T, conn *sql.Conn) *metadata.DatabaseSchemaMetadata { schemas[r.schema].Tables = append(schemas[r.schema].Tables, r.table) return nil }) - query(`SELECT n.nspname, c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + 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); err != nil { + if err := rows.Scan(&schema, &v.Name, &v.Definition); err != nil { return err } schemas[schema].Views = append(schemas[schema].Views, v) diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index d24b1502..a5731257 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/bytebase/omni/metadata" + "github.com/bytebase/omni/pg/ast" ) // schemaIndex is a target's synced schema arranged for the questions the @@ -20,6 +21,11 @@ type schemaIndex struct { // 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). @@ -93,6 +99,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { 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), @@ -186,9 +193,137 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { } } } + // 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 +} + // syncedSignature 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 diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 4534d004..e181d5f6 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -106,6 +106,24 @@ func withSignature(db *metadata.DatabaseSchemaMetadata, name, signature string) 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)) +} + func withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { for _, s := range db.Schemas { if s.Name == schema { @@ -444,6 +462,17 @@ func TestDisallowDropConstraint(t *testing.T) { 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;", @@ -2053,6 +2082,22 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From a7d9a81f4538a775a241bdc3386f8dcf3663a692 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 17:21:21 +0000 Subject: [PATCH 31/40] fix(pg/review): refuse statements on missing relations generally, and validate sequence owners, renames, view replacements, and signatures From the twenty-ninth Codex review of the scan: - A trigger, policy, rule, statistics, LOCK, TRUNCATE, REFRESH, CLUSTER, VACUUM, GRANT, or COMMENT ON a relation (or its column) the target certainly lacks ends the scan, and so does a trigger whose function it lacks. - ALTER SEQUENCE ... OWNED BY a table the target lacks, or a column a known table lacks, CREATE TYPE AS with an attribute twice, RENAME CONSTRAINT of a constraint the snapshot does not list, SET SCHEMA of a relation the change made to a schema holding its name, a routine rename to a signature the destination name has, and ALTER TABLE of a view the change made, end the scan. - CREATE OR REPLACE VIEW renaming or dropping a synced view's column, as far as the query names its output, is refused. - DROP FUNCTION by the snapshot's signature of the only function of its name retires its dependencies. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 7 +- pg/review/scan.go | 254 ++++++++++++++++++++- pg/review/scan_oracle_test.go | 20 ++ pg/review/schema_index.go | 4 + pg/review/target_rules_test.go | 98 ++++++++ 5 files changed, 374 insertions(+), 9 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 5ed11556..01100ea8 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -44,8 +44,13 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { return } } - // A table the change created is no longer what its CREATE said. + // 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 { + s.stop() + return + } c.def = nil } isTable := ast.ObjectType(v.ObjType) == ast.OBJECT_TABLE diff --git a/pg/review/scan.go b/pg/review/scan.go index a7c5e227..7e5c0b9c 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -526,6 +526,102 @@ func commandTag(n ast.Node) string { return "" } +// 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) { + return true + } + if v.Relation != nil { + if _, kind, ok := s.lookup(v.Relation); ok && (kind == kindSequence || kind == kindIndex || kind == kindCompositeType) { + 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 + } + return false + case *ast.CreatePolicyStmt, *ast.AlterPolicyStmt, *ast.RuleStmt, *ast.CreateStatsStmt, *ast.LockStmt, + *ast.TruncateStmt, *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 +} + +// 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(v *ast.AlterSeqStmt) bool { + if s.index == nil || v.Options == nil { + return false + } + for _, item := range v.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 + } + } + 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 { @@ -588,6 +684,62 @@ func (s *scan) createAs(rel *ast.RangeVar, ifNotExists bool, query ast.Node, mat } } +// 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 { + sel, ok := v.Query.(*ast.SelectStmt) + if old == nil || len(old.GetColumns()) == 0 || !ok || sel.Op != ast.SETOP_NONE || sel.TargetList == nil { + return false + } + var names []string + for _, item := range sel.TargetList.Items { + rt, ok := item.(*ast.ResTarget) + if !ok { + return false + } + name := rt.Name + if ref, ok := rt.Val.(*ast.ColumnRef); ok && name == "" && ref.Fields != nil && len(ref.Fields.Items) > 0 { + switch f := ref.Fields.Items[len(ref.Fields.Items)-1].(type) { + case *ast.String: + name = f.Str + case *ast.A_Star: + // A star brings columns the scan does not count. + names = append(names, "") + return compareColumns(old, v, names, true) + } + } + names = append(names, name) + } + return compareColumns(old, v, names, false) +} + +// compareColumns compares a replacement's output names, after its column +// aliases, with a view's columns; star marks names that end at a star. +func compareColumns(old *metadata.ViewMetadata, v *ast.ViewStmt, names []string, star bool) bool { + aliases := nameParts(v.Aliases) + for i := range names { + if i < len(aliases) { + names[i] = aliases[i] + } + } + for i, c := range old.GetColumns() { + if i >= len(names) { + return !star + } + if star && i == len(names)-1 { + return false + } + if names[i] != "" && names[i] != c.GetName() { + return true + } + } + return false +} + // isProcedure reports whether CREATE FUNCTION is CREATE PROCEDURE, which // the parser marks with an option. func isProcedure(v *ast.CreateFunctionStmt) bool { @@ -607,8 +759,9 @@ func (s *scan) stop() { } func (s *scan) statement(st *statement) { - // A CREATE in a schema that certainly does not exist is refused. - if schema := createdIn(st.node); schema != "" && s.schemaMissing(&ast.RangeVar{Schemaname: schema}) { + // 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) { s.stop() return } @@ -660,6 +813,10 @@ func (s *scan) statement(st *statement) { 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}) @@ -694,6 +851,10 @@ func (s *scan) statement(st *statement) { return } } + if s.ownedByRefused(v) { + 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" @@ -715,7 +876,7 @@ func (s *scan) statement(st *statement) { s.made(v.Sequence, kindSequence) } case *ast.CompositeTypeStmt: - if v.Typevar != nil && s.typeTaken(v.Typevar) { + if v.Typevar != nil && s.typeTaken(v.Typevar) || duplicateNames(&ast.CreateStmt{TableElts: v.Coldeflist}) { s.stop() return } @@ -2464,7 +2625,7 @@ func (s *scan) rename(v *ast.RenameStmt) { 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: - if fn, ok := routineOf(v.Object); ok && s.routineMissing(fn, v.RenameType) { + if fn, ok := routineOf(v.Object); ok && (s.routineMissing(fn, v.RenameType) || s.renameTaken(fn, v)) { s.stop() return } @@ -2475,10 +2636,17 @@ func (s *scan) rename(v *ast.RenameStmt) { } // renameConstraintRefused reports whether RENAME CONSTRAINT of a known -// table certainly fails: the table has a constraint of the new name, or -// the constraint owns an index the new name of which its schema holds. +// 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. + if _, ok := constraint(table, from); !ok && s.constraintKnown(t, from) { + return true + } if _, ok := constraint(table, to); ok && s.constraintKnown(t, to) && !s.dropped[[3]string{t.schema, t.table, to}] { return true } @@ -2642,6 +2810,12 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { s.stop() return } + // A relation the change created cannot move to a schema holding + // its name. + if _, ok := s.madeIn(v.Relation); ok && (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. if s.index != nil && (s.index.schemas[v.Newschema] == nil && !s.schemas[v.Newschema] || s.schemaGone[v.Newschema]) || @@ -2891,10 +3065,20 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { s.newFunctions[fn]-- } } - // A synced function is certainly dropped only by its name alone, + // 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] || !owa.ArgsUnspecified { + 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 != "" { @@ -2935,6 +3119,28 @@ func (s *scan) syncedFunctions(fn tableRef) int { 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 @@ -2978,6 +3184,38 @@ func (s *scan) routineMissing(fn tableRef, kind ast.ObjectType) bool { return true } +// renameTaken reports whether a routine rename certainly takes a signature +// a routine of the new name has where the routine is: the one written, or +// the only one the change created or the snapshot lists under the name. +func (s *scan) renameTaken(fn tableRef, v *ast.RenameStmt) bool { + owa, ok := v.Object.(*ast.ObjectWithArgs) + if !ok || 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} + sig := argSignature(owa.Objargs) + if owa.ArgsUnspecified { + switch sigs := s.newSignatures[fn]; { + case len(sigs) == 1 && s.index.functions[synced] == 0: + sig = sigs[0] + case len(sigs) == 0 && s.index.functions[synced] == 1 && len(s.index.signatures[synced]) == 1: + sig = s.index.signatures[synced][0] + default: + return false + } + } + to := tableRef{schema, v.Newname} + if s.movedRoutines[v.Newname] || s.droppedFunctions[to] { + return false + } + return slices.Contains(s.newSignatures[tableRef{fn.schema, v.Newname}], sig) || + sig == "" && s.index.noArgs[to] || slices.Contains(s.index.signatures[to], sig) +} + // routineOf returns the routine an ALTER names, as written. func routineOf(n ast.Node) (tableRef, bool) { owa, ok := n.(*ast.ObjectWithArgs) diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index ebd38022..1a353411 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -310,6 +310,17 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, @@ -489,6 +500,15 @@ func syncSchema(t *testing.T, conn *sql.Conn) *metadata.DatabaseSchemaMetadata { }) for _, s := range db.Schemas { for _, v := range s.Views { + query(`SELECT a.attname 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); 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 { diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index a5731257..a9ded050 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -61,6 +61,8 @@ type namespace struct { // 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 @@ -118,6 +120,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { 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() { @@ -142,6 +145,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { } 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() { diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index e181d5f6..3be9eed8 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -124,6 +124,21 @@ func withInheritedCheck() *metadata.DatabaseSchemaMetadata { 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}}) +} + func withView(db *metadata.DatabaseSchemaMetadata, schema, view string) *metadata.DatabaseSchemaMetadata { for _, s := range db.Schemas { if s.Name == schema { @@ -2098,6 +2113,89 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 991a3e15028b2b0e8cc611c16d4c5e22b50f5112 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 17:33:28 +0000 Subject: [PATCH 32/40] fix(pg/review): refuse missing routine signatures and destinations, view column changes, and invalid alters of the change's own tables From the thirtieth Codex review of the scan: - ALTER FUNCTION ... RENAME or SET SCHEMA by a signature no routine of the name has, as far as the change and the snapshot's readable signatures tell, and SET SCHEMA to a schema the target lacks or to one where the signature is taken, end the scan. - CREATE OR REPLACE VIEW changing a synced column's type, as far as a cast or a constant states it, and CREATE VIEW naming an output column twice, are refused. - An ALTER TABLE dropping or altering a column a table the change made lacks, or adding one it has, and OWNED BY a column such a table lacks, are refused. - CREATE TRIGGER on a materialized view, and CREATE TYPE AS ENUM with a label twice, are refused; CREATE TABLE AS and materialized views are recorded as the change's own. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 2 +- pg/review/scan.go | 313 +++++++++++++++++---- pg/review/scan_oracle_test.go | 13 +- pg/review/target_rules_test.go | 69 +++++ 4 files changed, 339 insertions(+), 58 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 01100ea8..46bbd8a8 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -47,7 +47,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { // 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 { + if ast.ObjectType(v.ObjType) == ast.OBJECT_TABLE && c.kind != kindTable && c.kind != kindForeignTable || c.def != nil && c.def.refusesAlter(v) { s.stop() return } diff --git a/pg/review/scan.go b/pg/review/scan.go index 7e5c0b9c..b0df4cae 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -2,6 +2,7 @@ package review import ( "context" + "maps" "slices" "strings" @@ -541,7 +542,10 @@ func (s *scan) namesMissing(n ast.Node) bool { return true } if v.Relation != nil { - if _, kind, ok := s.lookup(v.Relation); ok && (kind == kindSequence || kind == kindIndex || kind == kindCompositeType) { + 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 } } @@ -618,6 +622,9 @@ func (s *scan) ownedByRefused(v *ast.AlterSeqStmt) bool { 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 } @@ -679,6 +686,13 @@ func (s *scan) createAs(rel *ast.RangeVar, ifNotExists bool, query ast.Node, mat return } s.touch(rel) + if !ifNotExists { + kind := kindTable + if matview { + kind = kindMatView + } + s.made(rel, kind) + } if matview { s.reads(rel, query) } @@ -691,53 +705,144 @@ func (s *scan) createAs(rel *ast.RangeVar, ifNotExists bool, query ast.Node, mat // // pg: src/backend/commands/view.c — checkViewColumns func replacesColumns(old *metadata.ViewMetadata, v *ast.ViewStmt) bool { - sel, ok := v.Query.(*ast.SelectStmt) - if old == nil || len(old.GetColumns()) == 0 || !ok || sel.Op != ast.SETOP_NONE || sel.TargetList == nil { + outs, star, ok := viewOutputs(v) + if old == nil || !ok { return false } - var names []string + 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, ok := item.(*ast.ResTarget) - if !ok { - return false + rt, isTarget := item.(*ast.ResTarget) + if !isTarget { + return nil, false, false } - name := rt.Name - if ref, ok := rt.Val.(*ast.ColumnRef); ok && name == "" && ref.Fields != nil && len(ref.Fields.Items) > 0 { + 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: - name = f.Str + if out.name == "" { + out.name = f.Str + } case *ast.A_Star: - // A star brings columns the scan does not count. - names = append(names, "") - return compareColumns(old, v, names, true) + star = true } } - names = append(names, name) + if star { + break + } + outs = append(outs, out) } - return compareColumns(old, v, names, false) + for i, alias := range nameParts(v.Aliases) { + if i < len(outs) { + outs[i].name = alias + } + } + return outs, star, true } -// compareColumns compares a replacement's output names, after its column -// aliases, with a view's columns; star marks names that end at a star. -func compareColumns(old *metadata.ViewMetadata, v *ast.ViewStmt, names []string, star bool) bool { - aliases := nameParts(v.Aliases) - for i := range names { - if i < len(aliases) { - names[i] = aliases[i] +// duplicateOutputs reports whether a view names an output column twice, +// as its column aliases or its query determine them, which the server +// refuses. +func duplicateOutputs(v *ast.ViewStmt) bool { + if hasDuplicate(nameParts(v.Aliases)) { + return true + } + outs, _, ok := viewOutputs(v) + if !ok { + return false + } + var names []string + for _, o := range outs { + if o.name != "" { + names = append(names, o.name) } } - for i, c := range old.GetColumns() { - if i >= len(names) { - return !star + 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 "" } - if star && i == len(names)-1 { - return false + parts := nameParts(e.TypeName.Names) + if len(parts) == 2 && parts[0] == "pg_catalog" { + parts = parts[1:] } - if names[i] != "" && names[i] != c.GetName() { - return true + 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 false + 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 @@ -798,7 +903,7 @@ func (s *scan) statement(st *statement) { 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) { + 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 } @@ -885,6 +990,11 @@ func (s *scan) statement(st *statement) { s.made(v.Typevar, kindCompositeType) } 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: @@ -1809,6 +1919,50 @@ func (d *tableDef) refusedBy(columns []string, local int) bool { return false } +// refusesAlter reports whether an ALTER TABLE certainly fails 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, 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. + for _, cmd := range cmds { + if ast.AlterTableType(cmd.Subtype) == ast.AT_DropColumn { + if !columns[cmd.Name] && !cmd.Missing_ok { + return true + } + delete(columns, cmd.Name) + } + } + 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 @@ -2625,7 +2779,7 @@ func (s *scan) rename(v *ast.RenameStmt) { 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: - if fn, ok := routineOf(v.Object); ok && (s.routineMissing(fn, v.RenameType) || s.renameTaken(fn, v)) { + if fn, ok := routineOf(v.Object); ok && (s.routineMissing(fn, v.RenameType) || s.signatureMissing(fn, v.Object.(*ast.ObjectWithArgs)) || s.renameTaken(fn, v)) { s.stop() return } @@ -2854,11 +3008,20 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { 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 && s.routineMissing(fn, v.ObjectType) { - if !v.MissingOk { + 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 } - return } if owa, ok := v.Object.(*ast.ObjectWithArgs); ok { if parts := nameParts(owa.Objname); len(parts) > 0 { @@ -3184,38 +3347,78 @@ func (s *scan) routineMissing(fn tableRef, kind ast.ObjectType) bool { return true } -// renameTaken reports whether a routine rename certainly takes a signature -// a routine of the new name has where the routine is: the one written, or -// the only one the change created or the snapshot lists under the name. -func (s *scan) renameTaken(fn tableRef, v *ast.RenameStmt) bool { - owa, ok := v.Object.(*ast.ObjectWithArgs) - if !ok || s.index == nil { +// 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 || s.madeRoutines[fn.table] || s.movedRoutines[fn.table] { return false } - schema, ok := s.creationSchema(&ast.RangeVar{Schemaname: fn.schema, Relname: fn.table}) + schema, sig, ok := s.routineSignatureOf(fn, owa) if !ok { return false } synced := tableRef{schema, fn.table} - sig := argSignature(owa.Objargs) - if owa.ArgsUnspecified { - switch sigs := s.newSignatures[fn]; { - case len(sigs) == 1 && s.index.functions[synced] == 0: - sig = sigs[0] - case len(sigs) == 0 && s.index.functions[synced] == 1 && len(s.index.signatures[synced]) == 1: - sig = s.index.signatures[synced][0] - default: - return false - } + 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{schema, v.Newname} - if s.movedRoutines[v.Newname] || s.droppedFunctions[to] { + to := tableRef{toSchema, toName} + if s.movedRoutines[toName] || s.droppedFunctions[to] { return false } - return slices.Contains(s.newSignatures[tableRef{fn.schema, v.Newname}], sig) || + 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) diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 1a353411..3e35a313 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -321,6 +321,15 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, @@ -500,10 +509,10 @@ func syncSchema(t *testing.T, conn *sql.Conn) *metadata.DatabaseSchemaMetadata { }) for _, s := range db.Schemas { for _, v := range s.Views { - query(`SELECT a.attname FROM pg_attribute a JOIN pg_class c ON c.oid = a.attrelid JOIN pg_namespace n ON n.oid = c.relnamespace + 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); err != nil { + if err := rows.Scan(&c.Name, &c.Type); err != nil { return err } v.Columns = append(v.Columns, c) diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 3be9eed8..f7837f75 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -139,6 +139,12 @@ func withViewColumns(columns ...string) *metadata.DatabaseSchemaMetadata { 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 { @@ -2196,6 +2202,69 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From e8c33b21216ecb84f39b06f8364ef77a7a9fe79d Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 17:46:03 +0000 Subject: [PATCH 33/40] fix(pg/review): check constraints, inline elements, and index names of the change's own objects, and parse generation expressions From the thirty-first Codex review of the scan: - A table the change made keeps its constraint names: DROP CONSTRAINT without IF EXISTS of a name its CREATE did not give, or ADD CONSTRAINT of one it did, ends the scan. - View and trigger elements of CREATE SCHEMA get the checks top-level ones do. - CREATE INDEX on a relation the change made is checked against its kind, columns, and schema's names. - Procedure signatures from ProcedureMetadata are indexed too, and a function the change made follows SET SCHEMA with its dependencies. - A generation expression is parsed for the columns it refers to, so a literal or function name is no dependency. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 28 +++++++- pg/review/scan.go | 82 +++++++++++++++++++--- pg/review/scan_oracle_test.go | 6 ++ pg/review/schema_index.go | 17 +++-- pg/review/target_rules_test.go | 56 +++++++++++++++ 5 files changed, 171 insertions(+), 18 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 46bbd8a8..62d77709 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -437,16 +437,40 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out } // generatedFrom reports whether another column of the synced table may be -// generated from the column: its generation expression names it. +// 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 && mentions(c.GetGeneration().GetExpression(), column) { + 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 { diff --git a/pg/review/scan.go b/pg/review/scan.go index b0df4cae..203ed75d 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -1674,7 +1674,7 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { c.Relation = in(e.Relation) s.create(st, &c) case *ast.ViewStmt: - if s.readsMissing(e.Query) { + if s.readsMissing(e.Query) || duplicateOutputs(e) { s.stop() return } @@ -1694,7 +1694,14 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { if e.Idxname != "" && e.Relation != nil { s.touchName(in(e.Relation).Schemaname, e.Idxname) } - case *ast.CreateTrigStmt, *ast.GrantStmt: + case *ast.CreateTrigStmt: + c := *e + c.Relation = in(e.Relation) + if s.namesMissing(&c) { + s.stop() + return + } + case *ast.GrantStmt: default: s.stop() return @@ -1799,7 +1806,28 @@ func (s *scan) indexRefused(v *ast.IndexStmt) bool { } schema, kind, ok := s.lookup(v.Relation) if !ok { - return false + // 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: @@ -1855,6 +1883,8 @@ 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, @@ -1866,7 +1896,11 @@ func defOf(v *ast.CreateStmt) (def tableDef, fks []foreignKeyDecl, complete bool 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} @@ -1919,10 +1953,12 @@ func (d *tableDef) refusedBy(columns []string, local int) bool { return false } -// refusesAlter reports whether an ALTER TABLE certainly fails 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, the statement's own additions and drops counted. +// 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 @@ -1932,12 +1968,31 @@ func (d *tableDef) refusesAlter(v *ast.AlterTableStmt) bool { } } // The server drops before it adds, and adds before it alters. + constraints := maps.Clone(d.constraints) for _, cmd := range cmds { - if ast.AlterTableType(cmd.Subtype) == ast.AT_DropColumn { + 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) @@ -3022,6 +3077,7 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { 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 { @@ -3454,6 +3510,13 @@ func (s *scan) renameRoutine(v *ast.RenameStmt) { if len(parts) == 2 { fn.schema = parts[0] } + s.transferRoutine(fn, owa, tableRef{fn.schema, v.Newname}) +} + +// 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 @@ -3469,10 +3532,9 @@ func (s *scan) renameRoutine(v *ast.RenameStmt) { for j := range s.newReturns { d := &s.newReturns[j] if !d.retired && s.sameObject(*d, fn) && d.signature == sig { - d.object.table = v.Newname + d.object = to } } - to := tableRef{fn.schema, v.Newname} s.newSignatures[fn] = slices.Delete(slices.Clone(sigs), i, i+1) s.newFunctions[fn]-- s.newSignatures[to] = append(s.newSignatures[to], sig) diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 3e35a313..94fddb98 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -330,6 +330,12 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index a9ded050..34e90b1b 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -180,6 +180,12 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { 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) + } } for _, f := range s.GetFunctions() { fn := tableRef{s.GetName(), f.GetName()} @@ -188,7 +194,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { if f.GetSignature() == f.GetName()+"()" { idx.noArgs[fn] = true } - if sig, ok := syncedSignature(f); ok { + if sig, ok := readSignature(f.GetName(), f.GetSignature()); ok { idx.signatures[fn] = append(idx.signatures[fn], sig) } for _, d := range f.GetDependencyTables() { @@ -328,18 +334,17 @@ func (idx *schemaIndex) parentsOf(t tableRef) []*metadata.TableMetadata { return parents } -// syncedSignature reads a synced routine's signature, the name and the +// 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 syncedSignature(f *metadata.FunctionMetadata) (string, bool) { - sig := f.GetSignature() - if !strings.HasPrefix(sig, f.GetName()+"(") || !strings.HasSuffix(sig, ")") { +func readSignature(name, sig string) (string, bool) { + if !strings.HasPrefix(sig, name+"(") || !strings.HasSuffix(sig, ")") { return "", false } - args := strings.TrimSuffix(strings.TrimPrefix(sig, f.GetName()+"("), ")") + args := strings.TrimSuffix(strings.TrimPrefix(sig, name+"("), ")") if args == "" { return "", true } diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index f7837f75..53e082c0 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -2265,6 +2265,62 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From aeb48d1305e2c2beb1025d549754177166723c23 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 17:58:18 +0000 Subject: [PATCH 34/40] fix(pg/review): refuse alias overflow, sequence owners, duplicate triggers, missing drop signatures, TRUNCATE kinds, and generated column refs From the thirty-second Codex review of the scan: - CREATE VIEW with more column names than its query's outputs, CREATE SEQUENCE OWNED BY a missing table or column, CREATE TRIGGER of a name the synced table has without OR REPLACE, DROP FUNCTION of a signature no routine of the name has, TRUNCATE of anything but a table, and CREATE TABLE with a generated column of a column it lacks, end the scan. - Procedures listed apart from functions depend on the tables whose row type their signatures name. - A view the change made unqualified is the one a drop naming its schema means. Co-Authored-By: Claude Opus 5.5 --- pg/review/scan.go | 112 ++++++++++++++++++++++++++++----- pg/review/scan_oracle_test.go | 6 ++ pg/review/schema_index.go | 44 +++++++++++++ pg/review/target_rules_test.go | 59 +++++++++++++++++ 4 files changed, 206 insertions(+), 15 deletions(-) diff --git a/pg/review/scan.go b/pg/review/scan.go index 203ed75d..bec0d236 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -129,8 +129,11 @@ type scan struct { // sequence), which the scan does not derive. generated map[string]bool - // triggers are the target's enabled event triggers. - triggers []*metadata.EventTriggerMetadata + // 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 // pending are the tables RequirePrimaryKey reports at the end unless a // later statement settles them. @@ -163,6 +166,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]bool, droppedFunctions: make(map[tableRef]bool), newFunctions: make(map[tableRef]int), madeRoutines: make(map[string]bool), + triggersChanged: make(map[string]bool), movedRoutines: make(map[string]bool), newSignatures: make(map[tableRef][]string), renamedFrom: make(map[string]renamedRelation), @@ -541,6 +545,9 @@ func (s *scan) namesMissing(n ast.Node) bool { if fn, ok := routineOf(&ast.ObjectWithArgs{Objname: v.Funcname}); ok && s.routineMissing(fn, ast.OBJECT_FUNCTION) { return true } + if !v.Replace && v.Relation != nil && s.triggerTaken(v.Relation, 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 @@ -576,8 +583,24 @@ func (s *scan) namesMissing(n ast.Node) bool { return true } 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.TruncateStmt, *ast.RefreshMatViewStmt, *ast.ClusterStmt, *ast.VacuumStmt, *ast.GrantStmt: + *ast.RefreshMatViewStmt, *ast.ClusterStmt, *ast.VacuumStmt, *ast.GrantStmt: default: return false } @@ -596,14 +619,63 @@ func (s *scan) namesMissing(n ast.Node) bool { return missing } +// noteTriggers records the trigger names a statement creates, drops, or +// renames. +func (s *scan) noteTriggers(n ast.Node) { + switch v := n.(type) { + case *ast.CreateTrigStmt: + s.triggersChanged[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 { + if parts := nameParts(listOf(obj)); len(parts) > 0 { + s.triggersChanged[parts[len(parts)-1]] = true + } + } + } + case *ast.CreateSchemaStmt: + if v.SchemaElts != nil { + for _, e := range v.SchemaElts.Items { + s.noteTriggers(e) + } + } + } +} + +// 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(v *ast.AlterSeqStmt) bool { - if s.index == nil || v.Options == nil { +func (s *scan) ownedByRefused(options *ast.List) bool { + if s.index == nil || options == nil { return false } - for _, item := range v.Options.Items { + for _, item := range options.Items { d, ok := item.(*ast.DefElem) if !ok || d.Defname != "owned_by" { continue @@ -767,16 +839,21 @@ func viewOutputs(v *ast.ViewStmt) (outs []viewOutput, star, ok bool) { } // duplicateOutputs reports whether a view names an output column twice, -// as its column aliases or its query determine them, which the server -// refuses. +// 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 { - if hasDuplicate(nameParts(v.Aliases)) { + aliases := nameParts(v.Aliases) + if hasDuplicate(aliases) { return true } - outs, _, ok := viewOutputs(v) + 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 != "" { @@ -870,6 +947,7 @@ func (s *scan) statement(st *statement) { s.stop() return } + s.noteTriggers(st.node) switch v := st.node.(type) { case *ast.AlterTableStmt: s.alterTable(st, v) @@ -956,7 +1034,7 @@ func (s *scan) statement(st *statement) { return } } - if s.ownedByRefused(v) { + if s.ownedByRefused(v.Options) { s.stop() return } @@ -969,7 +1047,7 @@ func (s *scan) statement(st *statement) { case *ast.CreateSeqStmt: exists := s.existsWhereCreated(v.Sequence) || s.madeHere(v.Sequence) switch { - case s.schemaMissing(v.Sequence) || exists && !v.IfNotExists: + case s.schemaMissing(v.Sequence) || exists && !v.IfNotExists || s.ownedByRefused(v.Options): s.stop() return case exists: @@ -1484,7 +1562,11 @@ func (s *scan) dependsOnNew(deps []newDependency, t tableRef, except []tableRef) // dependency belongs to: written alike, under the same search path when // unqualified. func (s *scan) sameObject(d newDependency, name tableRef) bool { - return d.object == name && (name.schema != "" || d.path == s.pathVersion) + 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, @@ -2113,7 +2195,7 @@ func checksRefused(v *ast.CreateStmt) bool { for _, c := range cs { missing := func(column string) bool { return complete && !own.columns[column] } switch c.Contype { - case ast.CONSTR_CHECK: + case ast.CONSTR_CHECK, ast.CONSTR_GENERATED: if slices.ContainsFunc(columnsIn(c.RawExpr), missing) { return true } @@ -3258,7 +3340,7 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { } // A routine the target certainly lacks is not there to drop; IF // EXISTS then drops nothing. - if s.routineMissing(fn, ast.ObjectType(v.RemoveType)) { + if s.routineMissing(fn, ast.ObjectType(v.RemoveType)) || s.signatureMissing(fn, owa) { if !v.Missing_ok { s.stop() return diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 94fddb98..31a39963 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -336,6 +336,12 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index 34e90b1b..a87524b6 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -115,6 +115,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { for _, e := range db.GetExtensions() { idx.extensions[e.GetName()] = true } + var procedures []procedureRef for _, s := range db.GetSchemas() { ns := &namespace{ relations: make(map[string]relationKind), @@ -186,6 +187,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { 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()} @@ -203,6 +205,13 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { } } } + // 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()) @@ -334,6 +343,41 @@ func (idx *schemaIndex) parentsOf(t tableRef) []*metadata.TableMetadata { 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) + typ := strings.TrimSuffix(words[len(words)-1], "[]") + schema, table := "", typ + if i := strings.LastIndex(typ, "."); i >= 0 { + schema, table = typ[:i], typ[i+1:] + } + for sn, ns := range idx.schemas { + if _, ok := ns.tables[table]; ok && (schema == "" || schema == sn) { + out = append(out, tableRef{sn, table}) + } + } + } + return out +} + // 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 diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 53e082c0..251d5c90 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -2321,6 +2321,65 @@ func TestRequirePrimaryKey(t *testing.T) { }()}}, 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: "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);", From 635ccc6741b7c2c1fd55e847cf713c8df58e102c Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 18:14:43 +0000 Subject: [PATCH 35/40] fix(pg/review): follow keys tables are made with, and refuse DML, index expressions, triggers, and moves the target cannot take From the thirty-third Codex review of the scan: - A table the change makes with a primary key the scan can name is keyless again, and reported, when a later statement drops that key. - INSERT, UPDATE, DELETE, MERGE, SELECT, and EXPLAIN of a relation the target lacks, and CREATE INDEX on an expression of a column the table lacks, end the scan. - A second CREATE TRIGGER of a name on a relation the change made it on, a trigger function no routine of whose name takes no arguments, an inline CREATE SEQUENCE ... OWNED BY a missing table, and SET SCHEMA where an index or sequence moving with the table finds its name taken, end the scan. - Quoted type names in procedure signatures are unquoted. - PRIOR_BACKUP documents why a name the synced schema cannot resolve is reported only when the change creates it. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_prior_backup.go | 7 ++ pg/review/rule_require_primary_key.go | 47 +++++++++++++- pg/review/scan.go | 94 ++++++++++++++++++++++++--- pg/review/scan_oracle_test.go | 6 ++ pg/review/schema_index.go | 35 ++++++++-- pg/review/target_rules_test.go | 79 ++++++++++++++++++++-- 6 files changed, 248 insertions(+), 20 deletions(-) diff --git a/pg/review/rule_prior_backup.go b/pg/review/rule_prior_backup.go index cdc489b6..fe50d9fe 100644 --- a/pg/review/rule_prior_backup.go +++ b/pg/review/rule_prior_backup.go @@ -31,6 +31,13 @@ import ( // 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" diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index cedd8fa5..b5a89ea7 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -36,9 +36,9 @@ type pendingTable struct { // made numbers the touch that created a table the change made. made int - // key names the primary key a statement of the change added that - // settles the table, when that is all that settles it; dropping it - // leaves the table keyless again. + // 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. key string statement int @@ -138,6 +138,30 @@ func (s *scan) keyed(rv *ast.RangeVar, name string) { } } +// 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. @@ -217,6 +241,23 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { 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, + 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 } diff --git a/pg/review/scan.go b/pg/review/scan.go index bec0d236..216f6abb 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -134,6 +134,8 @@ type scan struct { // 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. @@ -167,6 +169,7 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]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), newSignatures: make(map[tableRef][]string), renamedFrom: make(map[string]renamedRelation), @@ -542,10 +545,10 @@ func (s *scan) namesMissing(n ast.Node) bool { } switch v := n.(type) { case *ast.CreateTrigStmt: - if fn, ok := routineOf(&ast.ObjectWithArgs{Objname: v.Funcname}); ok && s.routineMissing(fn, ast.OBJECT_FUNCTION) { + 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) { + 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 { @@ -619,12 +622,36 @@ func (s *scan) namesMissing(n ast.Node) bool { 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. +// 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 @@ -633,8 +660,18 @@ func (s *scan) noteTriggers(n ast.Node) { case *ast.DropStmt: if ast.ObjectType(v.RemoveType) == ast.OBJECT_TRIGGER && v.Objects != nil { for _, obj := range v.Objects.Items { - if parts := nameParts(listOf(obj)); len(parts) > 0 { - s.triggersChanged[parts[len(parts)-1]] = true + 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. + for key := range s.createdTriggers { + if strings.HasSuffix(key, "\x00"+trigger) { + delete(s.createdTriggers, key) + } } } } @@ -1118,12 +1155,32 @@ func (s *scan) statement(st *statement) { 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.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 } @@ -1675,6 +1732,11 @@ func (s *scan) create(st *statement, v *ast.CreateStmt) { 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 } @@ -1764,6 +1826,10 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { 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: @@ -1915,14 +1981,19 @@ func (s *scan) indexRefused(v *ast.IndexStmt) bool { case kindView, kindSequence, kindIndex, kindCompositeType, kindForeignTable: return true } - // A plain column the index names must be the table's. + // 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 { - if e, ok := item.(*ast.IndexElem); ok && e.Name != "" && !s.hasColumn(t, e.Name) { + 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 } } @@ -3108,12 +3179,19 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { return } schema, _, resolved := s.lookup(v.Relation) - // A schema that lacks the name must exist to take it. + // 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 { diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 31a39963..b55bac2e 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -342,6 +342,12 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index a87524b6..e987bc22 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -364,10 +364,13 @@ func (idx *schemaIndex) rowTypesIn(name, sig string) []tableRef { continue } words := strings.Fields(arg) - typ := strings.TrimSuffix(words[len(words)-1], "[]") - schema, table := "", typ - if i := strings.LastIndex(typ, "."); i >= 0 { - schema, table = typ[:i], typ[i+1:] + 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) { @@ -378,6 +381,30 @@ func (idx *schemaIndex) rowTypesIn(name, sig string) []tableRef { 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 diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 251d5c90..2133247a 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -723,11 +723,6 @@ func TestRequirePrimaryKey(t *testing.T) { 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 the table was created with is not followed", - sql: "CREATE TABLE n (id int PRIMARY KEY);\nALTER TABLE n DROP CONSTRAINT n_pkey;", - 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;", @@ -2380,6 +2375,80 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From da4024aeaf3f73c6fd7987f11efc0e52ec30479a Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 18:26:26 +0000 Subject: [PATCH 36/40] fix(pg/review): refuse domain checks, cross-kind replacements, enum alters, and drops of renamed constraint indexes From the thirty-fourth Codex review of the scan: - CREATE DOMAIN with a check naming anything but VALUE is refused. - CREATE OR REPLACE FUNCTION or PROCEDURE of a signature a synced routine of the other kind has is refused. - ALTER TYPE ... ADD or RENAME VALUE of a type that is certainly no enum where the name resolves is refused. - DROP INDEX of a constraint's index a rename gave a new name is refused. Co-Authored-By: Claude Opus 5.5 --- pg/review/scan.go | 86 +++++++++++++++++++++++++++++++++- pg/review/scan_oracle_test.go | 5 ++ pg/review/target_rules_test.go | 52 ++++++++++++++++++++ 3 files changed, 141 insertions(+), 2 deletions(-) diff --git a/pg/review/scan.go b/pg/review/scan.go index 216f6abb..a045ccb2 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -1049,7 +1049,7 @@ func (s *scan) statement(st *statement) { 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) { + if v.SqlBody != nil && s.readsMissing(v.SqlBody) || !v.IsOrReplace && s.signatureTaken(v) || v.IsOrReplace && s.replacesOtherKind(v) { s.stop() return } @@ -1113,7 +1113,19 @@ func (s *scan) statement(st *statement) { // 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. @@ -1553,6 +1565,72 @@ func routineSignature(v *ast.CreateFunctionStmt) (tableRef, string, bool) { 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) +} + +// 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 @@ -3746,10 +3824,14 @@ func (s *scan) dropsWrongKind(v *ast.DropStmt) bool { } schema, relKind, ok := s.lookup(rv) if !ok { - // A relation the change created has the kind it was made as. + // 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 { diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index b55bac2e..1ac86d83 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -348,6 +348,11 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, {"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}, diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 2133247a..5e89bc9e 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -2449,6 +2449,58 @@ func TestRequirePrimaryKey(t *testing.T) { 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 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);", From 0c038188fd3fd5ae88e88d494f777b4a1e4600a5 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 18:38:53 +0000 Subject: [PATCH 37/40] fix(pg/review): register inline triggers after their checks, and refuse type moves, event trigger drops, view column comments, and owner changes of missing objects From the thirty-fifth Codex review of the scan: - A trigger element of CREATE SCHEMA is recorded only after it passes its checks, so it is no duplicate of itself, and DROP TRIGGER clears the record of its own relation only. - ALTER TYPE or DOMAIN ... SET SCHEMA to a schema the target lacks, DROP EVENT TRIGGER of one it lacks, COMMENT ON a column a synced view lacks, and ALTER SCHEMA or routine ... OWNER TO of one it lacks, end the scan. - RENAME CONSTRAINT keeps a table the change made, and a pending table's key, under the new name; DROP EXTENSION IF EXISTS of extensions the target lacks does nothing. Co-Authored-By: Claude Opus 5.5 --- pg/review/scan.go | 85 ++++++++++++++++++++++++++++++---- pg/review/scan_oracle_test.go | 7 +++ pg/review/schema_index.go | 6 +++ pg/review/target_rules_test.go | 53 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 10 deletions(-) diff --git a/pg/review/scan.go b/pg/review/scan.go index a045ccb2..47bc4c5d 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -585,6 +585,11 @@ func (s *scan) namesMissing(n ast.Node) bool { 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. @@ -668,19 +673,15 @@ func (s *scan) noteTriggers(n ast.Node) { s.triggersChanged[trigger] = true // A trigger the change dropped from its relation may be // created there again. - for key := range s.createdTriggers { - if strings.HasSuffix(key, "\x00"+trigger) { - delete(s.createdTriggers, key) + 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)) } } } - case *ast.CreateSchemaStmt: - if v.SchemaElts != nil { - for _, e := range v.SchemaElts.Items { - s.noteTriggers(e) - } - } } } @@ -1104,6 +1105,11 @@ func (s *scan) statement(st *statement) { 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)) { @@ -1590,6 +1596,32 @@ func (s *scan) replacesOtherKind(v *ast.CreateFunctionStmt) bool { 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 @@ -1927,6 +1959,7 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { s.stop() return } + s.noteTriggers(&c) case *ast.GrantStmt: default: s.stop() @@ -3046,6 +3079,17 @@ func (s *scan) rename(v *ast.RenameStmt) { schema := s.schemaOf(v.Relation) s.constraints[[3]string{schema, v.Relation.Relname, v.Subname}] = true s.constraints[[3]string{schema, v.Relation.Relname, v.Newname}] = true + // 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) @@ -3296,7 +3340,12 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { p.schema = "" } } - case v.ObjectType == ast.OBJECT_TYPE: + 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]) } @@ -3337,10 +3386,26 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { 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 diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 1ac86d83..9538383e 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -353,6 +353,13 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, diff --git a/pg/review/schema_index.go b/pg/review/schema_index.go index e987bc22..45d05129 100644 --- a/pg/review/schema_index.go +++ b/pg/review/schema_index.go @@ -37,6 +37,8 @@ type schemaIndex struct { // 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. @@ -107,6 +109,7 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { 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), @@ -115,6 +118,9 @@ func newSchemaIndex(db *metadata.DatabaseSchemaMetadata) *schemaIndex { 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{ diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 5e89bc9e..89cbf4c9 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -2501,6 +2501,59 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From 8495b4d47c13415aa7eb336bcc26bc22b28dff6d Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 19:14:54 +0000 Subject: [PATCH 38/40] fix(pg/review): follow renamed constraints and freed names, refuse COPY and drops of renamed routines, and order CREATE SCHEMA elements as the server runs them - RENAME CONSTRAINT of a synced constraint keeps it under the new name, so a later DROP CONSTRAINT reports it and a later ADD PRIMARY KEY sees the key; the old name is gone, and a key's index takes the new name. - A rename of a resolved relation frees its old name in its schema. - ALTER FUNCTION ... RENAME of a synced routine takes its signature from the old name, so a later DROP or ALTER of it is refused. - COPY of a relation the target lacks, or of a kind COPY refuses, stops the scan. - CREATE SCHEMA elements run as the server runs them: sequences, tables, views, indexes, triggers. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 56 ++-- pg/review/rule_require_primary_key.go | 2 +- pg/review/scan.go | 320 ++++++++++++++++++--- pg/review/scan_oracle_test.go | 15 +- pg/review/target_rules_test.go | 103 +++++++ 5 files changed, 427 insertions(+), 69 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index 62d77709..d340f1aa 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -106,16 +106,17 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { } droppedNames[cmd.Name] = true s.retireReferences(v.Relation, cmd.Name) - // A constraint the change certainly dropped is not there to drop. - if known && !cmd.Missing_ok && s.dropped[[3]string{t.schema, t.table, 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 known && s.constraintKnown(t, cmd.Name) && s.dropConstraint(st, v, cmd, t, cascade, &out) { + if synced, ok := s.syncedConstraint(t, cmd.Name); known && ok && s.dropConstraint(st, v, cmd, synced, t, cascade, &out) { s.stop() return } - s.constraints[[3]string{schema, name, cmd.Name}] = true + s.constraintChanged(schema, name, cmd.Name) if cascade { s.dropKeyReferences(v.Relation, t, known, cmd.Name) } @@ -156,7 +157,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { // 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.constraints[[3]string{t.schema, t.table, fk.name}] = true + s.constraintChanged(t.schema, t.table, fk.name) s.dropped[[3]string{t.schema, t.table, fk.name}] = true } } @@ -214,7 +215,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { case ast.AT_AddConstraint: if c, ok := cmd.Def.(*ast.Constraint); ok { if c.Conname != "" { - s.constraints[[3]string{schema, name, c.Conname}] = true + s.constraintChanged(schema, name, c.Conname) } switch { case !makesIndex(c): @@ -264,7 +265,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.setColumn(schema, name, cd.Colname, true, false) for _, c := range constraintsOf(cd.Constraints) { if c.Conname != "" { - s.constraints[[3]string{schema, name, c.Conname}] = true + s.constraintChanged(schema, name, c.Conname) } if makesIndex(c) && c.Conname != "" { s.touchName(v.Relation.Schemaname, c.Conname) @@ -324,13 +325,13 @@ type droppedKeys struct { freed []string } -// dropConstraint checks a DROP CONSTRAINT against the synced table. 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, t tableRef, cascade bool, out *droppedKeys) (refused bool) { +// 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, cmd.Name) + 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 @@ -343,7 +344,7 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt // 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, cmd.Name) { + if con.kind == "check constraint" && s.index.mayInheritCheck(t, synced) { out.uncertain = true return false } @@ -356,7 +357,8 @@ func (s *scan) dropConstraint(st *statement, v *ast.AlterTableStmt, cmd *ast.Alt } } s.dropped[[3]string{t.schema, t.table, cmd.Name}] = true - if s.index.constraintIndexes[tableRef{t.schema, cmd.Name}] { + // 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 != "" { @@ -424,10 +426,10 @@ func (s *scan) dropColumn(cmd *ast.AlterTableCmd, t tableRef, cascade bool, out 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 || !s.constraintKnown(t, pk.GetName()) { + if pk == nil { return false } - if !slices.Contains(keyColumns(pk), column) { + if !s.holdsConstraint(t, pk.GetName()) || !slices.Contains(keyColumns(pk), column) { return false } if out.lost == nil { @@ -554,6 +556,9 @@ func (s *scan) mayDependOn(t tableRef, name string, columns map[string]bool) boo 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 { @@ -590,16 +595,23 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedOrigins, droppedConstr // uses reports whether a constraint name certainly names a constraint // of the table. uses := func(name string) bool { - if droppedConstraints[name] || !s.constraintKnown(t, name) || s.dropped[[3]string{t.schema, t.table, name}] { + 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, name) + _, 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) @@ -654,7 +666,7 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedOrigins, droppedConstr } if len(columns) == 0 { pk := primaryKey(table) - return addsPrimary || pk != nil && uses(pk.GetName()), true + return addsPrimary || pk != nil && holds(pk.GetName()), true } for _, key := range addedKeys { if sameKey(key, columns) { @@ -675,7 +687,7 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedOrigins, droppedConstr addedKey := false keyed := func() bool { pk := primaryKey(table) - return addedKey || pk != nil && uses(pk.GetName()) + return addedKey || pk != nil && holds(pk.GetName()) } for _, cmd := range others { switch ast.AlterTableType(cmd.Subtype) { @@ -692,7 +704,7 @@ func (s *scan) refuses(t tableRef, droppedColumns, droppedOrigins, droppedConstr } // 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 && uses(pk.GetName()) { + 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 } diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index b5a89ea7..9908def3 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -470,7 +470,7 @@ func (s *scan) createsKey(v *ast.CreateStmt) (keyed, known bool) { case pk == nil: case s.dropped[[3]string{t.schema, t.table, pk.GetName()}]: // The change dropped its key for certain. - case !s.constraintKnown(t, pk.GetName()): + case !s.holdsConstraint(t, pk.GetName()): known = false default: keyed = true diff --git a/pg/review/scan.go b/pg/review/scan.go index 47bc4c5d..7984f632 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -67,6 +67,12 @@ type scan struct { // 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 @@ -104,6 +110,9 @@ type scan struct { // by (schema, name), CREATE OR REPLACE redefined. madeRoutines map[string]bool movedRoutines 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 @@ -147,37 +156,40 @@ type scan struct { // 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), - 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), - 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), + 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), + 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), } @@ -1185,6 +1197,10 @@ func (s *scan) statement(st *statement) { return } s.data(v) + case *ast.CopyStmt: + if s.copyRefused(v) { + s.stop() + } case *ast.ExplainStmt: // EXPLAIN plans the query, which needs its relations. switch q := v.Query.(type) { @@ -1394,6 +1410,39 @@ func (s *scan) dependsIn(deps *[]newDependency, object tableRef, n ast.Node, vis }) } +// 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. @@ -1921,7 +1970,7 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { c.Schemaname = name return &c } - for _, elt := range v.SchemaElts.Items { + for _, elt := range elementOrder(v.SchemaElts) { switch e := elt.(type) { case *ast.CreateStmt: c := *e @@ -1968,6 +2017,34 @@ func (s *scan) createSchema(st *statement, v *ast.CreateSchemaStmt) { } } +// 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 @@ -3034,7 +3111,12 @@ func (s *scan) rename(v *ast.RenameStmt) { } s.follow(v.Relation, "", v.Newname) s.followParents(v.Relation, tableRef{s.schemaOf(v.Relation), v.Newname}) - s.touchName(v.Relation.Schemaname, v.Relation.Relname) + 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]} @@ -3072,13 +3154,40 @@ func (s *scan) rename(v *ast.RenameStmt) { s.touchName(schema, v.Newname) } case v.RenameType == ast.OBJECT_TABCONSTRAINT && v.Relation != nil: - if t, ok := s.table(v.Relation); ok && s.renameConstraintRefused(t, v.Subname, v.Newname) { + 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.constraints[[3]string{schema, v.Relation.Relname, v.Subname}] = true - s.constraints[[3]string{schema, v.Relation.Relname, v.Newname}] = true + 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] { @@ -3109,10 +3218,14 @@ func (s *scan) rename(v *ast.RenameStmt) { 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: - if fn, ok := routineOf(v.Object); ok && (s.routineMissing(fn, v.RenameType) || s.signatureMissing(fn, v.Object.(*ast.ObjectWithArgs)) || s.renameTaken(fn, v)) { + 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() @@ -3127,14 +3240,18 @@ 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. - if _, ok := constraint(table, from); !ok && s.constraintKnown(t, from) { + // 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 _, ok := constraint(table, to); ok && s.constraintKnown(t, to) && !s.dropped[[3]string{t.schema, t.table, to}] { - 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 + } } - return s.index.constraintIndexes[tableRef{t.schema, from}] && s.constraintKnown(t, from) && s.nameTaken(t.schema, to) + 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 @@ -3735,9 +3852,12 @@ func (s *scan) routineSignatureOf(fn tableRef, owa *ast.ObjectWithArgs) (schema, // 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 || s.madeRoutines[fn.table] || s.movedRoutines[fn.table] { + if owa.ArgsUnspecified || s.madeRoutines[fn.table] { return false } + if s.movedRoutines[fn.table] { + return s.signatureRenamed(fn, owa) + } schema, sig, ok := s.routineSignatureOf(fn, owa) if !ok { return false @@ -3816,6 +3936,67 @@ func (s *scan) renameRoutine(v *ast.RenameStmt) { 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) + } +} + +// 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. @@ -4256,7 +4437,7 @@ func (s *scan) dropReferences(rv *ast.RangeVar, t tableRef, known bool, affected if !maybe { continue } - s.constraints[[3]string{fk.owner.schema, fk.owner.table, fk.name}] = true + 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 } @@ -4268,3 +4449,54 @@ func (s *scan) dropReferences(rv *ast.RangeVar, t tableRef, known bool, affected 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 index 9538383e..c5c35438 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -63,9 +63,15 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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 or constraint is not followed. + // 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;", 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}, @@ -387,6 +393,11 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 89cbf4c9..e28de75f 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -243,6 +243,17 @@ func TestDisallowDropConstraint(t *testing.T) { 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;", @@ -2527,6 +2538,98 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "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);", From ab23c6374a95152c80dd241eb746b6c67f6fde3c Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 19:35:10 +0000 Subject: [PATCH 39/40] fix(pg/review): reopen new tables that lose a key column, refuse TEMP relations in regular schemas and types of missing schemas, and stop where the oracle's server stops - Dropping a column of the key a CREATE TABLE declared drops the key, when the statement only drops and nothing the change made depends on the column; a generated column, policy, trigger with WHEN or columns, rule, SQL-standard body, or publication the change made on a table makes a DROP COLUMN of it without CASCADE end the scan. - CREATE TEMP of a relation in a regular schema, and a type in a schema the target lacks, are refused. - A relation the change made earlier on the search path shadows a later one for DROP and the other statements that settle pending tables. - SET SCHEMA onto a name the destination holds, DROP FUNCTION of an unqualified name the change made twice, and ALTER of a signature the change never made are refused. - TestScanRulesAgainstPostgres now fails on any finding after the statement the server rejects, and on a REQUIRE_PRIMARY_KEY finding when the change never ends. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 22 +++ pg/review/rule_require_primary_key.go | 136 ++++++++++++++-- pg/review/scan.go | 173 ++++++++++++++++++++- pg/review/scan_oracle_test.go | 25 ++- pg/review/target_rules_test.go | 38 +++++ 5 files changed, 371 insertions(+), 23 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index d340f1aa..a6f4decf 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -138,6 +138,28 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { 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 { diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 9908def3..3bb577e2 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -38,8 +38,11 @@ type pendingTable struct { 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. - key string + // it; dropping it leaves the table keyless again. keyColumns are the + // columns of the key the CREATE declared, 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 @@ -96,7 +99,8 @@ func (s *scan) matching(rv *ast.RangeVar) []*pendingTable { } // resolvesBefore reports whether an unqualified name certainly means a -// synced relation of a schema the search path puts before the given one. +// 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 @@ -106,7 +110,15 @@ func (s *scan) resolvesBefore(name, schema string) bool { return false } for _, sch := range path { - if sch == schema || sch == "information_schema" || s.schemas[sch] || s.isTouched(&ast.RangeVar{Schemaname: sch, Relname: name}) { + 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 { @@ -123,6 +135,7 @@ func (s *scan) resolvesBefore(name, schema string) bool { func (p *pendingTable) settle() { p.settled = true p.key = "" + p.keyColumns = nil } // keyed settles the pending tables a statement may give a primary key, @@ -246,14 +259,15 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { // 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, - statement: st.index, - rng: rangeOf(v.Loc), - message: "creates table " + relation(v.Relation) + " without a primary key", + 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 @@ -271,6 +285,104 @@ func (s *scan) createTable(st *statement, v *ast.CreateStmt) { }) } +// 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 diff --git a/pg/review/scan.go b/pg/review/scan.go index 7984f632..31635b34 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -110,6 +110,16 @@ type scan struct { // 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 @@ -182,6 +192,9 @@ func scanTarget(ctx context.Context, stmts []statement, on map[review.Rule]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), @@ -546,6 +559,117 @@ func commandTag(n ast.Node) string { 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_") +} + +// 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, @@ -993,10 +1117,13 @@ func (s *scan) stop() { 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) { + if schema := createdIn(st.node); schema != "" && s.schemaMissing(&ast.RangeVar{Schemaname: schema}) || s.namesMissing(st.node) || + tempOutsideTemp(st.node) || s.typeSchemaMissing(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: @@ -1551,6 +1678,10 @@ func (s *scan) returns(v *ast.CreateFunctionStmt) { 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 @@ -3411,9 +3542,10 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { s.stop() return } - // A relation the change created cannot move to a schema holding - // its name. - if _, ok := s.madeIn(v.Relation); ok && (s.nameTaken(v.Newschema, v.Relation.Relname) || s.madeHere(&ast.RangeVar{Schemaname: v.Newschema, Relname: v.Relation.Relname})) { + // 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 } @@ -3486,6 +3618,7 @@ func (s *scan) setSchema(v *ast.AlterObjectSchemaStmt) { 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 } } @@ -3602,6 +3735,9 @@ func (s *scan) drop(v *ast.DropStmt) { 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) @@ -3620,7 +3756,7 @@ func (s *scan) drop(v *ast.DropStmt) { if kind == ast.OBJECT_INDEX { s.renamedKeys[rv.Relname] = true } - for _, p := range s.matching(rv) { + for _, p := range matched { p.settle() } switch kind { @@ -3672,7 +3808,7 @@ func (s *scan) dropFunctions(v *ast.DropStmt) { } // A name alone that several routines have is refused, IF EXISTS // or not. - if owa.ArgsUnspecified && s.routinesOf(fn) > 1 { + if owa.ArgsUnspecified && (s.routinesOf(fn) > 1 || fn.schema == "" && len(s.newSignatures[fn]) > 1) { s.stop() return } @@ -3852,9 +3988,21 @@ func (s *scan) routineSignatureOf(fn tableRef, owa *ast.ObjectWithArgs) (schema, // 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 || s.madeRoutines[fn.table] { + 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) } @@ -3920,6 +4068,7 @@ func routineOf(n ast.Node) (tableRef, bool) { // 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 @@ -3954,6 +4103,16 @@ func (s *scan) renameSynced(fn tableRef, owa *ast.ObjectWithArgs) { } } +// 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 diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index c5c35438..3a0da44c 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -50,9 +50,10 @@ CREATE VIEW cv AS SELECT count(*) AS c FROM cvt; // 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; -// a finding after it, or a RequirePrimaryKey finding about the end of a -// change that never gets there, is not checked. +// 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 { @@ -398,6 +399,22 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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 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) { @@ -425,7 +442,7 @@ func TestScanRulesAgainstPostgres(t *testing.T) { continue } if failedAt >= 0 && (f.Statement > failedAt || f.Rule == review.RequirePrimaryKey) { - t.Logf("not checked, after the statement the server rejects: %s", f.Message) + 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)) diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index e28de75f..6bfb67a7 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -2624,6 +2624,44 @@ func TestRequirePrimaryKey(t *testing.T) { 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: "CREATE SCHEMA makes its tables before their indexes", sql: "CREATE SCHEMA z CREATE INDEX i ON x (id) CREATE TABLE x (id int);", From bacb9dc7cefb1b6e5fc611982337d7cc1b3cb0b8 Mon Sep 17 00:00:00 2001 From: rebelice Date: Fri, 9 Oct 2026 19:47:00 +0000 Subject: [PATCH 40/40] fix(pg/review): follow keys added with their columns, and refuse REINDEX and ALTER FUNCTION of what the target lacks and constraint attributes their kind cannot take - ADD COLUMN ... PRIMARY KEY and ADD PRIMARY KEY (columns) record the key's name and columns, so a later DROP CONSTRAINT or DROP COLUMN of the key reopens the table. - REINDEX of a relation the target lacks, of the wrong kind, or of a schema the target lacks, and ALTER FUNCTION, PROCEDURE, or ROUTINE of a name or signature no routine of its kind has, stop the scan. - NOT VALID or NO INHERIT on a key or exclusion constraint, DEFERRABLE on a check, NO INHERIT on a foreign key, and NOT VALID on a column's constraint stop the scan, as the server's grammar refuses them. Co-Authored-By: Claude Opus 5.5 --- pg/review/rule_disallow_drop_constraint.go | 12 ++- pg/review/rule_require_primary_key.go | 12 +-- pg/review/scan.go | 103 ++++++++++++++++++++- pg/review/scan_oracle_test.go | 16 ++++ pg/review/target_rules_test.go | 55 +++++++++++ 5 files changed, 188 insertions(+), 10 deletions(-) diff --git a/pg/review/rule_disallow_drop_constraint.go b/pg/review/rule_disallow_drop_constraint.go index a6f4decf..f4f1d24c 100644 --- a/pg/review/rule_disallow_drop_constraint.go +++ b/pg/review/rule_disallow_drop_constraint.go @@ -258,7 +258,13 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.renamedKeys[c.Indexname] = true } if c.Contype == ast.CONSTR_PRIMARY { - s.keyed(v.Relation, s.keyName(v.Relation, c)) + // 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) @@ -293,7 +299,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { s.touchName(v.Relation.Schemaname, c.Conname) } if c.Contype == ast.CONSTR_PRIMARY { - s.keyed(v.Relation, "") + 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) @@ -307,7 +313,7 @@ func (s *scan) alterTable(st *statement, v *ast.AlterTableStmt) { if idx, ok := cmd.Def.(*ast.IndexStmt); ok { s.renamedKeys[idx.Idxname] = true if idx.Primary { - s.keyed(v.Relation, "") + s.keyed(v.Relation, "", nil) } if idx.Primary || idx.Unique { s.newKey(v.Relation) diff --git a/pg/review/rule_require_primary_key.go b/pg/review/rule_require_primary_key.go index 3bb577e2..cdd4de6e 100644 --- a/pg/review/rule_require_primary_key.go +++ b/pg/review/rule_require_primary_key.go @@ -39,8 +39,8 @@ type pendingTable struct { // 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 - // columns of the key the CREATE declared, while no statement but one - // that only drops named the table since: dropping one drops the key. + // key's columns, while no statement but one that only drops named the + // table since: dropping one drops the key. key string keyColumns []string @@ -139,14 +139,14 @@ func (p *pendingTable) settle() { } // keyed settles the pending tables a statement may give a primary key, -// recording the key's name, "" when unknown, on the one it certainly -// names. -func (s *scan) keyed(rv *ast.RangeVar, name string) { +// 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 = name + p.key, p.keyColumns = name, columns } } } diff --git a/pg/review/scan.go b/pg/review/scan.go index 31635b34..49f029f4 100644 --- a/pg/review/scan.go +++ b/pg/review/scan.go @@ -643,6 +643,51 @@ func tempOutsideTemp(n ast.Node) bool { 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 @@ -1118,7 +1163,7 @@ 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) { + tempOutsideTemp(st.node) || s.typeSchemaMissing(st.node) || constraintAttrsRefused(st.node) { s.stop() return } @@ -1328,6 +1373,14 @@ func (s *scan) statement(st *statement) { 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) { @@ -1537,6 +1590,54 @@ func (s *scan) dependsIn(deps *[]newDependency, object tableRef, n ast.Node, vis }) } +// 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 diff --git a/pg/review/scan_oracle_test.go b/pg/review/scan_oracle_test.go index 3a0da44c..7b66ab3b 100644 --- a/pg/review/scan_oracle_test.go +++ b/pg/review/scan_oracle_test.go @@ -414,6 +414,22 @@ func TestScanRulesAgainstPostgres(t *testing.T) { {"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 { diff --git a/pg/review/target_rules_test.go b/pg/review/target_rules_test.go index 6bfb67a7..a7996aaa 100644 --- a/pg/review/target_rules_test.go +++ b/pg/review/target_rules_test.go @@ -2662,6 +2662,61 @@ func TestRequirePrimaryKey(t *testing.T) { 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);",