diff --git a/__fixtures__/generated/generated.json b/__fixtures__/generated/generated.json index bd436b8f..854b62c8 100644 --- a/__fixtures__/generated/generated.json +++ b/__fixtures__/generated/generated.json @@ -21220,6 +21220,12 @@ "original/alter/alter-table-column-7.sql": "ALTER TABLE public.books\nADD COLUMN tags TEXT[] DEFAULT '{}'", "original/alter/alter-table-column-8.sql": "CREATE TYPE mood AS ENUM ('happy', 'sad', 'neutral')", "original/alter/alter-table-column-9.sql": "ALTER TABLE public.profiles\nADD COLUMN current_mood mood DEFAULT 'neutral'", + "misc/rename-qualified-and-sequence-privs-1.sql": "ALTER TYPE app.t2 RENAME TO t", + "misc/rename-qualified-and-sequence-privs-2.sql": "ALTER DOMAIN app.d2 RENAME TO d", + "misc/rename-qualified-and-sequence-privs-3.sql": "GRANT ALL ON SEQUENCE app.seq TO bob", + "misc/rename-qualified-and-sequence-privs-4.sql": "GRANT USAGE, SELECT ON SEQUENCE app.seq TO bob", + "misc/rename-qualified-and-sequence-privs-5.sql": "REVOKE ALL ON SEQUENCE app.seq FROM bob", + "misc/rename-qualified-and-sequence-privs-6.sql": "REVOKE UPDATE ON SEQUENCE app.seq FROM bob RESTRICT", "misc/quotes_etc-1.sql": "CREATE USER MAPPING FOR local_user SERVER \"foreign_server\" OPTIONS (user 'remote_user', password 'secret123')", "misc/quotes_etc-2.sql": "CREATE USER MAPPING FOR local_user SERVER foreign_server OPTIONS (user 'remote_user', password 'secret123')", "misc/quotes_etc-3.sql": "SELECT E'Line 1\\nLine 2'", diff --git a/__fixtures__/kitchen-sink/misc/rename-qualified-and-sequence-privs.sql b/__fixtures__/kitchen-sink/misc/rename-qualified-and-sequence-privs.sql new file mode 100644 index 00000000..ff34f4db --- /dev/null +++ b/__fixtures__/kitchen-sink/misc/rename-qualified-and-sequence-privs.sql @@ -0,0 +1,11 @@ +-- Qualified ALTER TYPE / ALTER DOMAIN RENAME must dot-join the name +-- Ref: constructive-io/pgsql-parser#328 +ALTER TYPE app.t2 RENAME TO t; +ALTER DOMAIN app.d2 RENAME TO d; + +-- GRANT/REVOKE ON SEQUENCE must keep the SEQUENCE keyword +-- Ref: constructive-io/pgsql-parser#328 +GRANT ALL ON SEQUENCE app.seq TO bob; +GRANT USAGE, SELECT ON SEQUENCE app.seq TO bob; +REVOKE ALL ON SEQUENCE app.seq FROM bob; +REVOKE UPDATE ON SEQUENCE app.seq FROM bob RESTRICT; diff --git a/packages/deparser/__tests__/kitchen-sink/misc-rename-qualified-and-sequence-privs.test.ts b/packages/deparser/__tests__/kitchen-sink/misc-rename-qualified-and-sequence-privs.test.ts new file mode 100644 index 00000000..26c335fa --- /dev/null +++ b/packages/deparser/__tests__/kitchen-sink/misc-rename-qualified-and-sequence-privs.test.ts @@ -0,0 +1,14 @@ + +import { FixtureTestUtils } from '../../test-utils'; +const fixtures = new FixtureTestUtils(); + +it('misc-rename-qualified-and-sequence-privs', async () => { + await fixtures.runFixtureTests([ + "misc/rename-qualified-and-sequence-privs-1.sql", + "misc/rename-qualified-and-sequence-privs-2.sql", + "misc/rename-qualified-and-sequence-privs-3.sql", + "misc/rename-qualified-and-sequence-privs-4.sql", + "misc/rename-qualified-and-sequence-privs-5.sql", + "misc/rename-qualified-and-sequence-privs-6.sql" +]); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index 7019ea46..7f58e17f 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -8419,6 +8419,14 @@ export class Deparser implements DeparserVisitor { } else { output.push(this.visit(node.object, context)); } + } else if ((node.renameType === 'OBJECT_TYPE' || node.renameType === 'OBJECT_DOMAIN') && (node.object as any).List) { + // Qualified type names - join List parts with dots + const items = ListUtils.unwrapList(node.object as any); + const parts = items + .map((item: any) => item.String?.sval) + .filter((s: any) => typeof s === 'string') + .map((s: string) => this.quoteIfNeeded(s)); + output.push(parts.join('.')); } else if (node.renameType === 'OBJECT_SCHEMA' && (node.object as any).List) { // Handle schema names - extract from List structure const items = ListUtils.unwrapList(node.object as any); @@ -8561,6 +8569,8 @@ export class Deparser implements DeparserVisitor { output.push('SCHEMA'); } else if (node.objtype === 'OBJECT_LANGUAGE') { output.push('LANGUAGE'); + } else if (node.objtype === 'OBJECT_SEQUENCE') { + output.push('SEQUENCE'); } else if (node.objtype === 'OBJECT_FUNCTION') { output.push('FUNCTION'); } else if (node.objtype === 'OBJECT_PROCEDURE') { diff --git a/packages/scripts/README.md b/packages/scripts/README.md index 46ef2cdc..494f083e 100644 --- a/packages/scripts/README.md +++ b/packages/scripts/README.md @@ -30,6 +30,19 @@ const { sql: verifySql } = verifyFor(facts); Nothing outside the supported vocabulary is ever guessed at: `revertFor` emits a `-- revert not derivable: ` comment plus a warning; `verifyFor` emits nothing plus a warning. The list is exported as `SUPPORTED_STATEMENTS` (and `SUPPORTED_NODE_TAGS`). +## Node-level API + +For consumers that compose inverses at the AST level (semantic diffing, migration generation) without round-tripping through deparsed text: + +```ts +import { invertStatement, existenceCheck } from '@pgsql/scripts'; + +const inverse = invertStatement(facts[0]); // AST statement nodes, [] = nothing to revert, null = not derivable +const checks = existenceCheck(facts[0]); // SelectStmt check nodes, [] = nothing to check, null = not derivable +``` + +`invertStatement` returns the per-statement inverse as wrapped AST nodes (e.g. `{ DropStmt: {...} }`); `existenceCheck` returns the raise-on-failure checks as `SelectStmt` nodes. Both return `null` instead of guessing when derivation is not possible — including partially underivable multi-command statements. + ## Supported statements | Statement | Revert | Verify | @@ -52,5 +65,32 @@ Nothing outside the supported vocabulary is ever guessed at: `revertFor` emits a | `GRANT` privileges (tables, sequences, functions, schemas) | `REVOKE` same privileges | `has_table_privilege` / `has_function_privilege` / `has_schema_privilege` | | `GRANT role TO role` | `REVOKE role FROM role` | `pg_auth_members` | | `COMMENT ON` | `COMMENT ON ... IS NULL` | — | +| `CREATE MATERIALIZED VIEW` / `CREATE TABLE AS` | `DROP MATERIALIZED VIEW` / `DROP TABLE` | `to_regclass` | +| `CREATE SERVER` | `DROP SERVER` | `pg_foreign_server` | +| `CREATE FOREIGN TABLE` | `DROP FOREIGN TABLE` | `to_regclass` | +| `CREATE USER MAPPING` | `DROP USER MAPPING` | `pg_user_mappings` | +| `CREATE COLLATION` | `DROP COLLATION` | `pg_collation` | +| `CREATE AGGREGATE` | `DROP AGGREGATE` with input signature | `to_regprocedure` | +| `CREATE OPERATOR` (binary) | `DROP OPERATOR (left, right)` | `to_regoperator` | +| `CREATE CAST` | `DROP CAST (source AS target)` | `pg_cast` | +| `CREATE PUBLICATION` | `DROP PUBLICATION` | `pg_publication` | +| `CREATE SUBSCRIPTION` | `DROP SUBSCRIPTION` | `pg_subscription` | +| `CREATE STATISTICS` | `DROP STATISTICS` | `pg_statistic_ext` | +| `CREATE EVENT TRIGGER` | `DROP EVENT TRIGGER` | `pg_event_trigger` | +| `CREATE RULE` | `DROP RULE ... ON table` | `pg_rules` | +| `ALTER TYPE ... ADD VALUE` | — (Postgres has no `DROP VALUE`; warns) | `pg_enum` | +| `ALTER TABLE ... ATTACH PARTITION` | `DETACH PARTITION` | `pg_inherits` | +| `ALTER DEFAULT PRIVILEGES ... GRANT` | `ALTER DEFAULT PRIVILEGES ... REVOKE` | `pg_default_acl` + `aclexplode` | +| `SECURITY LABEL` | `SECURITY LABEL ... IS NULL` | — | +| `CREATE FOREIGN DATA WRAPPER` | `DROP FOREIGN DATA WRAPPER` | `pg_foreign_data_wrapper` | +| `CREATE CONVERSION` | `DROP CONVERSION` | `pg_conversion` | +| `CREATE ACCESS METHOD` | `DROP ACCESS METHOD` | `pg_am` | +| `CREATE TRANSFORM` | `DROP TRANSFORM FOR type LANGUAGE lang` | `pg_transform` | +| `CREATE OPERATOR CLASS` / `FAMILY` | `DROP ... USING am` | `pg_opclass` / `pg_opfamily` | +| `CREATE TEXT SEARCH CONFIGURATION` / `DICTIONARY` / `PARSER` / `TEMPLATE` | matching `DROP` | `pg_ts_config` / `pg_ts_dict` / `pg_ts_parser` / `pg_ts_template` | +| `CREATE TABLESPACE` | `DROP TABLESPACE` | `pg_tablespace` | +| `ALTER ... RENAME TO` | rename back (both names are in the statement) | object exists under new name | +| `ALTER ... SET SCHEMA` (qualified source) | move back (both schemas are in the statement) | object exists in new schema | +| `GRANT ALL` | `REVOKE ALL` | expands to the object type's concrete privilege list | -Not derivable (warned, never guessed): `REVOKE`, unnamed constraints, `ALTER ... SET` with unknown prior value, arbitrary DML, dynamic SQL. +Not derivable (warned, never guessed): `REVOKE`, unnamed constraints, `ALTER ... SET` with unknown prior value, `ALTER ... OWNER TO` (prior owner unknown), `SET SCHEMA` on unqualified names, arbitrary DML, dynamic SQL, prefix operators. diff --git a/packages/scripts/__tests__/invert.test.ts b/packages/scripts/__tests__/invert.test.ts index 201b248e..d2cd3931 100644 --- a/packages/scripts/__tests__/invert.test.ts +++ b/packages/scripts/__tests__/invert.test.ts @@ -295,10 +295,11 @@ describe('verifyFor', () => { ); }); - it('warns on GRANT ALL rather than expanding it', () => { + it('expands GRANT ALL to one check per concrete privilege', () => { const result = verify('GRANT ALL ON app.users TO reader;'); - expect(result.sql).toEqual(''); - expect(result.warnings).toHaveLength(1); + expect(result.warnings).toEqual([]); + expect(result.sql.split('\n\n')).toHaveLength(7); + expect(result.sql).toContain("has_table_privilege('reader', 'app.users', 'TRIGGER')"); }); it('emits nothing for comments and seed DML', () => { @@ -321,3 +322,276 @@ describe('verifyFor', () => { expect(result.warnings).toEqual([]); }); }); + +describe('revertFor — extended vocabulary', () => { + it('drops materialized views and CREATE TABLE AS tables', () => { + expect(revert('CREATE MATERIALIZED VIEW app.mv AS SELECT 1 AS x;')) + .toEqual({ sql: 'DROP MATERIALIZED VIEW app.mv;', warnings: [] }); + expect(revert('CREATE TABLE app.t2 AS SELECT 1 AS x;')) + .toEqual({ sql: 'DROP TABLE app.t2;', warnings: [] }); + }); + + it('drops foreign servers, foreign tables and user mappings', () => { + expect(revert("CREATE SERVER films_server FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host 'h');")) + .toEqual({ sql: 'DROP SERVER films_server;', warnings: [] }); + expect(revert('CREATE FOREIGN TABLE app.ft (id int) SERVER films_server;')) + .toEqual({ sql: 'DROP FOREIGN TABLE app.ft;', warnings: [] }); + expect(revert("CREATE USER MAPPING FOR bob SERVER films_server OPTIONS (user 'bob');")) + .toEqual({ sql: 'DROP USER MAPPING FOR bob SERVER films_server;', warnings: [] }); + }); + + it('drops collations, aggregates (with signature) and binary operators', () => { + expect(revert("CREATE COLLATION app.mycoll (locale = 'en_US.utf8');")) + .toEqual({ sql: 'DROP COLLATION app.mycoll;', warnings: [] }); + expect(revert('CREATE AGGREGATE app.myagg (int) (sfunc = int4pl, stype = int);')) + .toEqual({ sql: 'DROP AGGREGATE app.myagg(int);', warnings: [] }); + expect(revert('CREATE OPERATOR app.=== (LEFTARG = int, RIGHTARG = int, FUNCTION = int4eq);')) + .toEqual({ sql: 'DROP OPERATOR app.===(int, int);', warnings: [] }); + }); + + it('drops casts by source and target type', () => { + expect(revert('CREATE CAST (int AS text) WITH INOUT AS IMPLICIT;')) + .toEqual({ sql: 'DROP CAST (int AS text);', warnings: [] }); + }); + + it('drops publications, subscriptions, statistics, event triggers and rules', () => { + expect(revert('CREATE PUBLICATION mypub FOR TABLE app.users;')) + .toEqual({ sql: 'DROP PUBLICATION mypub;', warnings: [] }); + expect(revert("CREATE SUBSCRIPTION mysub CONNECTION 'dbname=x' PUBLICATION mypub;")) + .toEqual({ sql: 'DROP SUBSCRIPTION mysub RESTRICT;', warnings: [] }); + expect(revert('CREATE STATISTICS app.mystats (dependencies) ON a, b FROM app.users;')) + .toEqual({ sql: 'DROP STATISTICS app.mystats;', warnings: [] }); + expect(revert('CREATE EVENT TRIGGER etrig ON ddl_command_end EXECUTE FUNCTION f();')) + .toEqual({ sql: 'DROP EVENT TRIGGER etrig;', warnings: [] }); + expect(revert('CREATE RULE myrule AS ON DELETE TO app.users DO INSTEAD NOTHING;')) + .toEqual({ sql: 'DROP RULE myrule ON app.users;', warnings: [] }); + }); + + it('detaches attached partitions', () => { + const result = revert('ALTER TABLE app.parted ATTACH PARTITION app.p1 FOR VALUES FROM (1) TO (10);'); + expect(result.sql).toEqual('ALTER TABLE app.parted \n DETACH PARTITION app.p1;'); + expect(result.warnings).toEqual([]); + }); + + it('inverts ALTER DEFAULT PRIVILEGES GRANT to REVOKE', () => { + const result = revert('ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO bob;'); + expect(result.sql).toEqual('ALTER DEFAULT PRIVILEGES IN SCHEMA app\n REVOKE SELECT ON TABLES FROM bob RESTRICT;'); + expect(result.warnings).toEqual([]); + }); + + it('nulls security labels', () => { + expect(revert("SECURITY LABEL FOR selinux ON TABLE app.users IS 'system_u';")) + .toEqual({ sql: 'SECURITY LABEL FOR selinux ON TABLE app.users IS NULL;', warnings: [] }); + }); + + it('warns for enum ADD VALUE (Postgres has no DROP VALUE)', () => { + const result = revert("ALTER TYPE app.mood ADD VALUE 'sad';"); + expect(result.sql).toContain('-- revert not derivable:'); + expect(result.warnings).toHaveLength(1); + }); + + it('warns for prefix operators and non-grant default privileges', () => { + const prefix = revert('CREATE OPERATOR app.!! (RIGHTARG = int, FUNCTION = int4um);'); + expect(prefix.warnings).toEqual(['revert not derivable: prefix operators are not supported (binary LEFTARG/RIGHTARG required)']); + const revoke = revert('ALTER DEFAULT PRIVILEGES IN SCHEMA app REVOKE SELECT ON TABLES FROM bob;'); + expect(revoke.warnings).toHaveLength(1); + }); +}); + +describe('verifyFor — extended vocabulary', () => { + it('checks matviews, CTAS tables and foreign tables via to_regclass', () => { + expect(verify('CREATE MATERIALIZED VIEW app.mv AS SELECT 1 AS x;').sql) + .toEqual("SELECT 1/(CASE WHEN to_regclass('app.mv') IS NOT NULL THEN 1 ELSE 0 END);"); + expect(verify('CREATE FOREIGN TABLE app.ft (id int) SERVER films_server;').sql) + .toEqual("SELECT 1/(CASE WHEN to_regclass('app.ft') IS NOT NULL THEN 1 ELSE 0 END);"); + }); + + it('checks foreign servers and user mappings via catalogs', () => { + expect(verify("CREATE SERVER films_server FOREIGN DATA WRAPPER postgres_fdw;").sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_foreign_server WHERE srvname = 'films_server') THEN 1 ELSE 0 END);"); + expect(verify("CREATE USER MAPPING FOR bob SERVER films_server;").sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_user_mappings WHERE srvname = 'films_server' AND usename = 'bob') THEN 1 ELSE 0 END);"); + }); + + it('checks collations, aggregates and operators', () => { + expect(verify("CREATE COLLATION app.mycoll (locale = 'en_US.utf8');").sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_collation c JOIN pg_namespace n ON n.oid = c.collnamespace WHERE c.collname = 'mycoll' AND n.nspname = 'app') THEN 1 ELSE 0 END);"); + expect(verify('CREATE AGGREGATE app.myagg (int) (sfunc = int4pl, stype = int);').sql) + .toEqual("SELECT 1/(CASE WHEN to_regprocedure('app.myagg(int)') IS NOT NULL THEN 1 ELSE 0 END);"); + expect(verify('CREATE OPERATOR app.=== (LEFTARG = int, RIGHTARG = int, FUNCTION = int4eq);').sql) + .toEqual("SELECT 1/(CASE WHEN to_regoperator('app.===(int, int)') IS NOT NULL THEN 1 ELSE 0 END);"); + }); + + it('checks casts via pg_cast', () => { + expect(verify('CREATE CAST (int AS text) WITH INOUT AS IMPLICIT;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_cast WHERE castsource = 'int'::regtype AND casttarget = 'text'::regtype) THEN 1 ELSE 0 END);"); + }); + + it('checks publications, subscriptions, statistics, event triggers and rules', () => { + expect(verify('CREATE PUBLICATION mypub FOR TABLE app.users;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'mypub') THEN 1 ELSE 0 END);"); + expect(verify("CREATE SUBSCRIPTION mysub CONNECTION 'dbname=x' PUBLICATION mypub;").sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_subscription WHERE subname = 'mysub') THEN 1 ELSE 0 END);"); + expect(verify('CREATE STATISTICS app.mystats (dependencies) ON a, b FROM app.users;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_statistic_ext s JOIN pg_namespace n ON n.oid = s.stxnamespace WHERE s.stxname = 'mystats' AND n.nspname = 'app') THEN 1 ELSE 0 END);"); + expect(verify('CREATE EVENT TRIGGER etrig ON ddl_command_end EXECUTE FUNCTION f();').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_event_trigger WHERE evtname = 'etrig') THEN 1 ELSE 0 END);"); + expect(verify('CREATE RULE myrule AS ON DELETE TO app.users DO INSTEAD NOTHING;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_rules WHERE rulename = 'myrule' AND tablename = 'users' AND schemaname = 'app') THEN 1 ELSE 0 END);"); + }); + + it('checks enum ADD VALUE labels via pg_enum', () => { + expect(verify("ALTER TYPE app.mood ADD VALUE 'sad';").sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_enum WHERE enumtypid = 'app.mood'::regtype AND enumlabel = 'sad') THEN 1 ELSE 0 END);"); + }); + + it('checks attached partitions via pg_inherits', () => { + expect(verify('ALTER TABLE app.parted ATTACH PARTITION app.p1 FOR VALUES FROM (1) TO (10);').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_inherits WHERE inhrelid = 'app.p1'::regclass AND inhparent = 'app.parted'::regclass) THEN 1 ELSE 0 END);"); + }); + + it('checks default privileges via pg_default_acl + aclexplode', () => { + expect(verify('ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO bob;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_default_acl d, aclexplode(d.defaclacl) a JOIN pg_roles r ON r.oid = a.grantee WHERE d.defaclobjtype = 'r' AND r.rolname = 'bob' AND a.privilege_type = 'SELECT' AND d.defaclnamespace IN (to_regnamespace('app'))) THEN 1 ELSE 0 END);"); + }); + + it('emits nothing for security labels (metadata only)', () => { + expect(verify("SECURITY LABEL FOR selinux ON TABLE app.users IS 'system_u';")) + .toEqual({ sql: '', warnings: [] }); + }); +}); + +describe('revertFor — stateless renames, schema moves and long-tail creates', () => { + it('renames objects back (both names are in the statement)', () => { + expect(revert('ALTER TABLE app.users RENAME TO members;')) + .toEqual({ sql: 'ALTER TABLE app.members RENAME TO users;', warnings: [] }); + expect(revert('ALTER TABLE app.users RENAME COLUMN id TO uid;')) + .toEqual({ sql: 'ALTER TABLE app.users RENAME COLUMN uid TO id;', warnings: [] }); + expect(revert('ALTER INDEX app.idx RENAME TO idx2;')) + .toEqual({ sql: 'ALTER INDEX app.idx2 RENAME TO idx;', warnings: [] }); + expect(revert('ALTER SEQUENCE app.seq RENAME TO seq2;')) + .toEqual({ sql: 'ALTER SEQUENCE app.seq2 RENAME TO seq;', warnings: [] }); + expect(revert('ALTER VIEW app.v RENAME TO v2;')) + .toEqual({ sql: 'ALTER VIEW app.v2 RENAME TO v;', warnings: [] }); + expect(revert('ALTER TYPE app.t RENAME TO t2;')) + .toEqual({ sql: 'ALTER TYPE app.t2 RENAME TO t;', warnings: [] }); + expect(revert('ALTER FUNCTION app.f(int) RENAME TO g;')) + .toEqual({ sql: 'ALTER FUNCTION app.g(int) RENAME TO f;', warnings: [] }); + expect(revert('ALTER SCHEMA app RENAME TO app2;')) + .toEqual({ sql: 'ALTER SCHEMA app2 RENAME TO app;', warnings: [] }); + }); + + it('moves objects back to their original schema', () => { + expect(revert('ALTER TABLE app.users SET SCHEMA public;')) + .toEqual({ sql: 'ALTER TABLE public.users SET SCHEMA app;', warnings: [] }); + expect(revert('ALTER FUNCTION app.f(int) SET SCHEMA public;')) + .toEqual({ sql: 'ALTER FUNCTION public.f(int) SET SCHEMA app;', warnings: [] }); + expect(revert('ALTER TYPE app.t SET SCHEMA public;')) + .toEqual({ sql: 'ALTER TYPE public.t SET SCHEMA app;', warnings: [] }); + }); + + it('warns for SET SCHEMA on unqualified names (original schema unknown)', () => { + const result = revert('ALTER TABLE users SET SCHEMA public;'); + expect(result.warnings).toEqual(['revert not derivable: SET SCHEMA on an unqualified name (original schema unknown)']); + }); + + it('drops FDWs, conversions, access methods, transforms and tablespaces', () => { + expect(revert('CREATE FOREIGN DATA WRAPPER myfdw;')) + .toEqual({ sql: 'DROP FOREIGN DATA WRAPPER myfdw;', warnings: [] }); + expect(revert("CREATE CONVERSION app.myconv FOR 'UTF8' TO 'LATIN1' FROM utf8_to_iso8859_1;")) + .toEqual({ sql: 'DROP CONVERSION app.myconv;', warnings: [] }); + expect(revert('CREATE ACCESS METHOD myam TYPE INDEX HANDLER myhandler;')) + .toEqual({ sql: 'DROP ACCESS METHOD myam;', warnings: [] }); + expect(revert('CREATE TRANSFORM FOR int LANGUAGE plperl (FROM SQL WITH FUNCTION f(int), TO SQL WITH FUNCTION g(internal));')) + .toEqual({ sql: 'DROP TRANSFORM FOR int LANGUAGE plperl;', warnings: [] }); + expect(revert("CREATE TABLESPACE myts LOCATION '/data';")) + .toEqual({ sql: 'DROP TABLESPACE myts;', warnings: [] }); + }); + + it('drops operator classes and families with their access method', () => { + expect(revert('CREATE OPERATOR CLASS app.myopc FOR TYPE int USING btree AS OPERATOR 1 <;')) + .toEqual({ sql: 'DROP OPERATOR CLASS app.myopc USING btree;', warnings: [] }); + expect(revert('CREATE OPERATOR FAMILY app.myopf USING btree;')) + .toEqual({ sql: 'DROP OPERATOR FAMILY app.myopf USING btree;', warnings: [] }); + }); + + it('drops text search objects', () => { + expect(revert('CREATE TEXT SEARCH CONFIGURATION app.mytscfg (parser = default);')) + .toEqual({ sql: 'DROP TEXT SEARCH CONFIGURATION app.mytscfg;', warnings: [] }); + expect(revert('CREATE TEXT SEARCH DICTIONARY app.mytsdict (template = simple);')) + .toEqual({ sql: 'DROP TEXT SEARCH DICTIONARY app.mytsdict;', warnings: [] }); + expect(revert('CREATE TEXT SEARCH PARSER app.mytsp (start = prsd_start, gettoken = prsd_nexttoken, end = prsd_end, lextypes = prsd_lextype);')) + .toEqual({ sql: 'DROP TEXT SEARCH PARSER app.mytsp;', warnings: [] }); + expect(revert('CREATE TEXT SEARCH TEMPLATE app.mytst (lexize = dsimple_lexize);')) + .toEqual({ sql: 'DROP TEXT SEARCH TEMPLATE app.mytst;', warnings: [] }); + }); + + it('inverts GRANT ALL to REVOKE ALL', () => { + expect(revert('GRANT ALL ON TABLE app.users TO bob;')) + .toEqual({ sql: 'REVOKE ALL ON app.users FROM bob RESTRICT;', warnings: [] }); + expect(revert('GRANT ALL ON SEQUENCE app.seq TO bob;')) + .toEqual({ sql: 'REVOKE ALL ON SEQUENCE app.seq FROM bob RESTRICT;', warnings: [] }); + }); +}); + +describe('verifyFor — stateless renames, schema moves and long-tail creates', () => { + it('checks renamed objects under the new name', () => { + expect(verify('ALTER TABLE app.users RENAME TO members;').sql) + .toEqual("SELECT 1/(CASE WHEN to_regclass('app.members') IS NOT NULL THEN 1 ELSE 0 END);"); + expect(verify('ALTER TABLE app.users RENAME COLUMN id TO uid;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'users' AND column_name = 'uid' AND table_schema = 'app') THEN 1 ELSE 0 END);"); + expect(verify('ALTER TYPE app.t RENAME TO t2;').sql) + .toEqual("SELECT 1/(CASE WHEN to_regtype('app.t2') IS NOT NULL THEN 1 ELSE 0 END);"); + expect(verify('ALTER FUNCTION app.f(int) RENAME TO g;').sql) + .toEqual("SELECT 1/(CASE WHEN to_regprocedure('app.g(int)') IS NOT NULL THEN 1 ELSE 0 END);"); + expect(verify('ALTER SCHEMA app RENAME TO app2;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = 'app2') THEN 1 ELSE 0 END);"); + }); + + it('checks moved objects in the new schema', () => { + expect(verify('ALTER TABLE app.users SET SCHEMA public;').sql) + .toEqual("SELECT 1/(CASE WHEN to_regclass('public.users') IS NOT NULL THEN 1 ELSE 0 END);"); + expect(verify('ALTER FUNCTION app.f(int) SET SCHEMA public;').sql) + .toEqual("SELECT 1/(CASE WHEN to_regprocedure('public.f(int)') IS NOT NULL THEN 1 ELSE 0 END);"); + }); + + it('checks long-tail created objects via their catalogs', () => { + expect(verify('CREATE FOREIGN DATA WRAPPER myfdw;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_foreign_data_wrapper WHERE fdwname = 'myfdw') THEN 1 ELSE 0 END);"); + expect(verify("CREATE CONVERSION app.myconv FOR 'UTF8' TO 'LATIN1' FROM utf8_to_iso8859_1;").sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_conversion c JOIN pg_namespace n ON n.oid = c.connamespace WHERE c.conname = 'myconv' AND n.nspname = 'app') THEN 1 ELSE 0 END);"); + expect(verify('CREATE ACCESS METHOD myam TYPE INDEX HANDLER myhandler;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_am WHERE amname = 'myam') THEN 1 ELSE 0 END);"); + expect(verify('CREATE TRANSFORM FOR int LANGUAGE plperl (FROM SQL WITH FUNCTION f(int), TO SQL WITH FUNCTION g(internal));').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_transform t JOIN pg_language l ON l.oid = t.trflang WHERE t.trftype = 'int'::regtype AND l.lanname = 'plperl') THEN 1 ELSE 0 END);"); + expect(verify('CREATE OPERATOR CLASS app.myopc FOR TYPE int USING btree AS OPERATOR 1 <;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_opclass c JOIN pg_am am ON am.oid = c.opcmethod JOIN pg_namespace n ON n.oid = c.opcnamespace WHERE c.opcname = 'myopc' AND am.amname = 'btree' AND n.nspname = 'app') THEN 1 ELSE 0 END);"); + expect(verify('CREATE OPERATOR FAMILY app.myopf USING btree;').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_opfamily f JOIN pg_am am ON am.oid = f.opfmethod JOIN pg_namespace n ON n.oid = f.opfnamespace WHERE f.opfname = 'myopf' AND am.amname = 'btree' AND n.nspname = 'app') THEN 1 ELSE 0 END);"); + expect(verify('CREATE TEXT SEARCH CONFIGURATION app.mytscfg (parser = default);').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_ts_config c JOIN pg_namespace n ON n.oid = c.cfgnamespace WHERE c.cfgname = 'mytscfg' AND n.nspname = 'app') THEN 1 ELSE 0 END);"); + expect(verify('CREATE TEXT SEARCH DICTIONARY app.mytsdict (template = simple);').sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_ts_dict d JOIN pg_namespace n ON n.oid = d.dictnamespace WHERE d.dictname = 'mytsdict' AND n.nspname = 'app') THEN 1 ELSE 0 END);"); + expect(verify("CREATE TABLESPACE myts LOCATION '/data';").sql) + .toEqual("SELECT 1/(CASE WHEN EXISTS (SELECT 1 FROM pg_tablespace WHERE spcname = 'myts') THEN 1 ELSE 0 END);"); + }); + + it('expands GRANT ALL to the concrete privilege list per object type', () => { + const table = verify('GRANT ALL ON TABLE app.users TO bob;'); + expect(table.warnings).toEqual([]); + expect(table.sql.split('\n\n')).toEqual([ + 'SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER' + ].map(p => `SELECT 1/(CASE WHEN has_table_privilege('bob', 'app.users', '${p}') THEN 1 ELSE 0 END);`)); + + const seq = verify('GRANT ALL ON SEQUENCE app.seq TO bob;'); + expect(seq.sql.split('\n\n')).toEqual(['USAGE', 'SELECT', 'UPDATE'] + .map(p => `SELECT 1/(CASE WHEN has_sequence_privilege('bob', 'app.seq', '${p}') THEN 1 ELSE 0 END);`)); + }); + + it('expands ALTER DEFAULT PRIVILEGES GRANT ALL per object type', () => { + const result = verify('ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT ALL ON SEQUENCES TO bob;'); + expect(result.warnings).toEqual([]); + expect(result.sql.split('\n\n')).toHaveLength(3); + expect(result.sql).toContain("a.privilege_type = 'USAGE'"); + }); +}); diff --git a/packages/scripts/__tests__/node-level.test.ts b/packages/scripts/__tests__/node-level.test.ts new file mode 100644 index 00000000..e4ab1f9e --- /dev/null +++ b/packages/scripts/__tests__/node-level.test.ts @@ -0,0 +1,64 @@ +import { classifyStatements } from '@pgsql/transform'; +import { Deparser, loadModule } from 'plpgsql-parser'; + +import { existenceCheck, invertStatement } from '../src/invert'; + +beforeAll(async () => { + await loadModule(); +}); + +const factsOf = (sql: string) => classifyStatements(sql)[0]; + +describe('invertStatement (node level)', () => { + it('returns the inverse as AST statement nodes', () => { + const nodes = invertStatement(factsOf('CREATE TABLE app.users (id int);')); + expect(nodes).not.toBeNull(); + expect(nodes!).toHaveLength(1); + expect(Object.keys(nodes![0])).toEqual(['DropStmt']); + expect(Deparser.deparse(nodes![0])).toEqual('DROP TABLE app.users'); + }); + + it('returns swapped-name AST nodes for renames', () => { + const nodes = invertStatement(factsOf('ALTER TABLE app.users RENAME TO members;')); + expect(nodes!).toHaveLength(1); + expect(Deparser.deparse(nodes![0])).toEqual('ALTER TABLE app.members RENAME TO users'); + }); + + it('returns null when no inverse is derivable', () => { + expect(invertStatement(factsOf('UPDATE app.users SET name = 1;'))).toBeNull(); + expect(invertStatement(factsOf('ALTER TABLE users SET SCHEMA public;'))).toBeNull(); + }); + + it('never returns a partial inverse for multi-command ALTER TABLE', () => { + const facts = factsOf('ALTER TABLE app.users ADD COLUMN age int, ALTER COLUMN name SET NOT NULL;'); + expect(invertStatement(facts)).toBeNull(); + }); +}); + +describe('existenceCheck (node level)', () => { + it('returns the verify predicate as SelectStmt AST nodes', () => { + const nodes = existenceCheck(factsOf('CREATE TABLE app.users (id int);')); + expect(nodes).not.toBeNull(); + expect(nodes!).toHaveLength(1); + expect(Object.keys(nodes![0])).toEqual(['SelectStmt']); + expect(Deparser.deparse(nodes![0]).replace(/\s+/g, ' ')).toEqual( + "SELECT 1 / (CASE WHEN to_regclass('app.users') IS NOT NULL THEN 1 ELSE 0 END);".replace(/;$/, '') + ); + }); + + it('returns one node per expanded GRANT ALL privilege', () => { + const nodes = existenceCheck(factsOf('GRANT ALL ON SEQUENCE app.seq TO bob;')); + expect(nodes!).toHaveLength(3); + for (const node of nodes!) { + expect(Object.keys(node)).toEqual(['SelectStmt']); + } + }); + + it('returns an empty array when nothing comes into existence', () => { + expect(existenceCheck(factsOf("COMMENT ON TABLE app.users IS 'x';"))).toEqual([]); + }); + + it('returns null when no check is derivable', () => { + expect(existenceCheck(factsOf('DO $$ BEGIN NULL; END $$;'))).toBeNull(); + }); +}); diff --git a/packages/scripts/__tests__/supported.test.ts b/packages/scripts/__tests__/supported.test.ts new file mode 100644 index 00000000..a7d666b5 --- /dev/null +++ b/packages/scripts/__tests__/supported.test.ts @@ -0,0 +1,24 @@ +import { SUPPORTED_NODE_TAGS, SUPPORTED_STATEMENTS } from '../src/supported'; + +describe('SUPPORTED_STATEMENTS', () => { + it('every entry has at least one node tag and a revert or verify derivation', () => { + for (const entry of SUPPORTED_STATEMENTS) { + expect(entry.nodeTags.length).toBeGreaterThan(0); + expect(entry.revert !== null || entry.verify !== null).toBe(true); + } + }); + + it('SUPPORTED_NODE_TAGS covers every listed node tag', () => { + for (const entry of SUPPORTED_STATEMENTS) { + for (const tag of entry.nodeTags) { + expect(SUPPORTED_NODE_TAGS.has(tag)).toBe(true); + } + } + }); + + it('does not claim support for statements that only warn', () => { + for (const tag of ['InsertStmt', 'UpdateStmt', 'DeleteStmt', 'DoStmt', 'VariableSetStmt']) { + expect(SUPPORTED_NODE_TAGS.has(tag)).toBe(false); + } + }); +}); diff --git a/packages/scripts/src/index.ts b/packages/scripts/src/index.ts index 1e54d48a..5a5cdc62 100644 --- a/packages/scripts/src/index.ts +++ b/packages/scripts/src/index.ts @@ -1,4 +1,4 @@ export type { GeneratedScript } from './invert'; -export { revertFor, verifyFor } from './invert'; +export { existenceCheck, invertStatement, revertFor, verifyFor } from './invert'; export type { SupportedStatement } from './supported'; export { SUPPORTED_NODE_TAGS, SUPPORTED_STATEMENTS } from './supported'; diff --git a/packages/scripts/src/invert.ts b/packages/scripts/src/invert.ts index 92ccffc5..145b3bc4 100644 --- a/packages/scripts/src/invert.ts +++ b/packages/scripts/src/invert.ts @@ -23,7 +23,7 @@ */ import { QuoteUtils } from '@pgsql/quotes'; import { buildStatementGraph, StatementFacts } from '@pgsql/transform'; -import { Deparser } from 'plpgsql-parser'; +import { Deparser, parseSql } from 'plpgsql-parser'; /** A generated script plus non-fatal notes about what could not be derived. */ export interface GeneratedScript { @@ -98,12 +98,32 @@ function functionSignatureText(node: AnyNode): string { /** Deep-clone an AST node so generation never mutates the input facts. */ const clone = (node: T): T => JSON.parse(JSON.stringify(node)); +/** Render a name list (`[{ String: { sval } }, ...]`) as quoted SQL text. */ +function nameListText(names: AnyNode[] | undefined): string { + const parts = (names ?? []) + .map((n: AnyNode) => n?.String?.sval) + .filter((s: any) => typeof s === 'string'); + return parts.length > 1 + ? qname(parts[parts.length - 2], parts[parts.length - 1]) + : qname(null, parts[0] ?? ''); +} + +/** The `FunctionParameter` args of a `DefineStmt` (aggregates). */ +const defineArgs = (node: AnyNode): AnyNode[] => + (node.args?.[0]?.List?.items ?? []) + .map((p: AnyNode) => p?.FunctionParameter) + .filter(Boolean); + +/** DefElem lookup by name within a definition list. */ +const defElem = (definition: AnyNode[] | undefined, name: string): AnyNode | undefined => + (definition ?? []).map((d: AnyNode) => d?.DefElem).find((d: AnyNode) => d?.defname === name); + /** * Invert one classified statement into its revert statements, or a * not-derivable reason. Returns `null` for statements that need no revert * of their own (currently none — everything either inverts or warns). */ -function invertStatement(facts: StatementFacts, warnings: string[]): Emitted[] { +function emitInverse(facts: StatementFacts, warnings: string[]): Emitted[] { const node = facts.stmt?.[facts.nodeTag]; const notDerivable = (reason: string): Emitted[] => { warnings.push(`revert not derivable: ${reason}`); @@ -118,6 +138,14 @@ function invertStatement(facts: StatementFacts, warnings: string[]): Emitted[] { return [{ stmt: dropStmt('OBJECT_SCHEMA', [strNode(node.schemaname)]) }]; case 'CreateStmt': return [{ stmt: dropStmt('OBJECT_TABLE', [{ List: { items: nameItems(node.relation) } }]) }]; + case 'CreateTableAsStmt': { + if (node.objtype !== 'OBJECT_MATVIEW' && node.objtype !== 'OBJECT_TABLE') { + return notDerivable(`no inverse known for CREATE ... AS with objtype ${node.objtype}`); + } + return [{ + stmt: dropStmt(node.objtype, [{ List: { items: nameItems(node.into.rel) } }]) + }]; + } case 'ViewStmt': return [{ stmt: dropStmt('OBJECT_VIEW', [{ List: { items: nameItems(node.view) } }]) }]; case 'IndexStmt': { @@ -158,6 +186,96 @@ function invertStatement(facts: StatementFacts, warnings: string[]): Emitted[] { } case 'CreateExtensionStmt': return [{ stmt: dropStmt('OBJECT_EXTENSION', [strNode(node.extname)]) }]; + case 'CreateForeignServerStmt': + return [{ stmt: dropStmt('OBJECT_FOREIGN_SERVER', [strNode(node.servername)]) }]; + case 'CreateForeignTableStmt': + return [{ + stmt: dropStmt('OBJECT_FOREIGN_TABLE', [{ List: { items: nameItems(node.base.relation) } }]) + }]; + case 'CreateUserMappingStmt': + return [{ + stmt: { + DropUserMappingStmt: { + user: clone(node.user), + servername: node.servername + } + } + }]; + case 'DefineStmt': + return invertDefine(node, notDerivable); + case 'CreateCastStmt': + return [{ + stmt: dropStmt('OBJECT_CAST', [ + { List: { items: [{ TypeName: clone(node.sourcetype) }, { TypeName: clone(node.targettype) }] } } + ]) + }]; + case 'CreatePublicationStmt': + return [{ stmt: dropStmt('OBJECT_PUBLICATION', [strNode(node.pubname)]) }]; + case 'CreateSubscriptionStmt': + return [{ + stmt: { DropSubscriptionStmt: { subname: node.subname, behavior: 'DROP_RESTRICT' } } + }]; + case 'CreateStatsStmt': + return [{ + stmt: dropStmt('OBJECT_STATISTIC_EXT', [{ List: { items: clone(node.defnames) } }]) + }]; + case 'CreateEventTrigStmt': + return [{ stmt: dropStmt('OBJECT_EVENT_TRIGGER', [strNode(node.trigname)]) }]; + case 'RuleStmt': { + const items = [...nameItems(node.relation), strNode(node.rulename)]; + return [{ stmt: dropStmt('OBJECT_RULE', [{ List: { items } }]) }]; + } + case 'AlterEnumStmt': + return notDerivable( + `enum value ${JSON.stringify(node.newVal)} cannot be dropped (Postgres has no DROP VALUE)` + ); + case 'AlterDefaultPrivilegesStmt': { + if (node.action?.is_grant !== true) { + return notDerivable('ALTER DEFAULT PRIVILEGES REVOKE has no mechanical inverse (prior grants unknown)'); + } + const inverted = clone(node); + delete inverted.action.is_grant; + delete inverted.action.grant_option; + delete inverted.action.grantor; + return [{ stmt: { AlterDefaultPrivilegesStmt: inverted } }]; + } + case 'SecLabelStmt': { + const nulled = clone(node); + delete nulled.label; + return [{ stmt: { SecLabelStmt: nulled } }]; + } + case 'CreateFdwStmt': + return [{ stmt: dropStmt('OBJECT_FDW', [strNode(node.fdwname)]) }]; + case 'CreateConversionStmt': + return [{ + stmt: dropStmt('OBJECT_CONVERSION', [{ List: { items: clone(node.conversion_name) } }]) + }]; + case 'CreateAmStmt': + return [{ stmt: dropStmt('OBJECT_ACCESS_METHOD', [strNode(node.amname)]) }]; + case 'CreateTransformStmt': + return [{ + stmt: dropStmt('OBJECT_TRANSFORM', [ + { List: { items: [{ TypeName: clone(node.type_name) }, strNode(node.lang)] } } + ]) + }]; + case 'CreateOpClassStmt': + return [{ + stmt: dropStmt('OBJECT_OPCLASS', [ + { List: { items: [strNode(node.amname), ...clone(node.opclassname)] } } + ]) + }]; + case 'CreateOpFamilyStmt': + return [{ + stmt: dropStmt('OBJECT_OPFAMILY', [ + { List: { items: [strNode(node.amname), ...clone(node.opfamilyname)] } } + ]) + }]; + case 'CreateTableSpaceStmt': + return [{ stmt: { DropTableSpaceStmt: { tablespacename: node.tablespacename } } }]; + case 'RenameStmt': + return invertRename(node, notDerivable); + case 'AlterObjectSchemaStmt': + return invertSetSchema(node, notDerivable); case 'CreateRoleStmt': return [{ stmt: { @@ -202,6 +320,116 @@ function invertStatement(facts: StatementFacts, warnings: string[]): Emitted[] { } } +/** Inverses for `DefineStmt` objects: collations, aggregates, operators. */ +function invertDefine(node: AnyNode, notDerivable: (reason: string) => Emitted[]): Emitted[] { + switch (node.kind) { + case 'OBJECT_COLLATION': + return [{ + stmt: dropStmt('OBJECT_COLLATION', [{ List: { items: clone(node.defnames) } }]) + }]; + case 'OBJECT_AGGREGATE': { + const objargs = defineArgs(node).map((p: AnyNode) => ({ TypeName: clone(p.argType) })); + return [{ + stmt: dropStmt('OBJECT_AGGREGATE', [{ + ObjectWithArgs: { objname: clone(node.defnames), objargs, args_unspecified: false } + }]) + }]; + } + case 'OBJECT_OPERATOR': { + const left = defElem(node.definition, 'leftarg'); + const right = defElem(node.definition, 'rightarg'); + if (!left?.arg?.TypeName || !right?.arg?.TypeName) { + return notDerivable('prefix operators are not supported (binary LEFTARG/RIGHTARG required)'); + } + return [{ + stmt: dropStmt('OBJECT_OPERATOR', [{ + ObjectWithArgs: { + objname: clone(node.defnames), + objargs: [{ TypeName: clone(left.arg.TypeName) }, { TypeName: clone(right.arg.TypeName) }] + } + }]) + }]; + } + case 'OBJECT_TSCONFIGURATION': + case 'OBJECT_TSDICTIONARY': + case 'OBJECT_TSPARSER': + case 'OBJECT_TSTEMPLATE': + return [{ + stmt: dropStmt(node.kind, [{ List: { items: clone(node.defnames) } }]) + }]; + default: + return notDerivable(`no inverse known for CREATE (DefineStmt) with kind ${node.kind}`); + } +} + +/** + * Invert a RENAME by swapping the two names the statement already carries: + * `ALTER ... RENAME old TO new` becomes `ALTER ... RENAME new TO old`. + */ +function invertRename(node: AnyNode, notDerivable: (reason: string) => Emitted[]): Emitted[] { + const inverted = clone(node); + switch (node.renameType) { + case 'OBJECT_TABLE': + case 'OBJECT_INDEX': + case 'OBJECT_SEQUENCE': + case 'OBJECT_VIEW': + case 'OBJECT_MATVIEW': + case 'OBJECT_FOREIGN_TABLE': + inverted.relation.relname = node.newname; + inverted.newname = node.relation.relname; + break; + case 'OBJECT_COLUMN': + case 'OBJECT_SCHEMA': + inverted.subname = node.newname; + inverted.newname = node.subname; + break; + case 'OBJECT_TYPE': + case 'OBJECT_DOMAIN': { + const items = inverted.object?.List?.items; + if (!items?.length) return notDerivable(`RENAME ${node.renameType} without a qualified name`); + inverted.newname = items[items.length - 1].String.sval; + items[items.length - 1] = strNode(node.newname); + break; + } + case 'OBJECT_FUNCTION': + case 'OBJECT_PROCEDURE': + case 'OBJECT_AGGREGATE': { + const objname = inverted.object?.ObjectWithArgs?.objname; + if (!objname?.length) return notDerivable(`RENAME ${node.renameType} without a function name`); + inverted.newname = objname[objname.length - 1].String.sval; + objname[objname.length - 1] = strNode(node.newname); + break; + } + default: + return notDerivable(`no inverse known for RENAME ${node.renameType}`); + } + return [{ stmt: { RenameStmt: inverted } }]; +} + +/** + * Invert a SET SCHEMA by moving the object back: the statement carries both + * the old (qualified name) and new schema, so the swap is mechanical. An + * unqualified source name would leave the original schema unknown — warned. + */ +function invertSetSchema(node: AnyNode, notDerivable: (reason: string) => Emitted[]): Emitted[] { + const inverted = clone(node); + if (node.relation) { + if (!node.relation.schemaname) { + return notDerivable('SET SCHEMA on an unqualified name (original schema unknown)'); + } + inverted.relation.schemaname = node.newschema; + inverted.newschema = node.relation.schemaname; + return [{ stmt: { AlterObjectSchemaStmt: inverted } }]; + } + const objname = inverted.object?.ObjectWithArgs?.objname ?? inverted.object?.List?.items; + if (!objname || objname.length < 2) { + return notDerivable('SET SCHEMA on an unqualified name (original schema unknown)'); + } + inverted.newschema = objname[objname.length - 2].String.sval; + objname[objname.length - 2] = strNode(node.newschema); + return [{ stmt: { AlterObjectSchemaStmt: inverted } }]; +} + /** * Invert an ALTER TABLE statement command by command, in reverse command * order. Each invertible command becomes its own single-command ALTER so @@ -257,6 +485,19 @@ function invertAlterTable(node: AnyNode, warnings: string[]): Emitted[] { case 'AT_ForceRowSecurity': out.push(alterWith({ subtype: 'AT_NoForceRowSecurity' })); break; + case 'AT_AttachPartition': { + const partition = cmd.def?.PartitionCmd?.name; + if (!partition) { + out.push(notDerivable(`ATTACH PARTITION on ${table} without a partition name`)); + break; + } + out.push(alterWith({ + subtype: 'AT_DetachPartition', + def: { PartitionCmd: { name: { RangeVar: clone(partition) } } }, + behavior: 'DROP_RESTRICT' + })); + break; + } default: out.push(notDerivable(`ALTER TABLE ${table} ${cmd.subtype} (prior state unknown)`)); } @@ -279,7 +520,7 @@ export function revertFor(facts: StatementFacts[]): GeneratedScript { const pieces: string[] = []; for (const i of reverseOrder) { - for (const emitted of invertStatement(facts[i], warnings)) { + for (const emitted of emitInverse(facts[i], warnings)) { if ('stmt' in emitted) { pieces.push(`${Deparser.deparse(emitted.stmt)};`); } else { @@ -295,7 +536,7 @@ const check = (condition: string): string => `SELECT 1/(CASE WHEN ${condition} THEN 1 ELSE 0 END);`; /** The verify checks for one classified statement. */ -function verifyStatement(facts: StatementFacts, warnings: string[]): string[] { +function emitChecks(facts: StatementFacts, warnings: string[]): string[] { const node = facts.stmt?.[facts.nodeTag]; const notDerivable = (reason: string): string[] => { warnings.push(`verify not derivable: ${reason}`); @@ -349,6 +590,100 @@ function verifyStatement(facts: StatementFacts, warnings: string[]): string[] { return [check(`EXISTS (SELECT 1 FROM pg_extension WHERE extname = ${lit(node.extname)})`)]; case 'CreateRoleStmt': return [check(`EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${lit(node.role)})`)]; + case 'CreateTableAsStmt': { + if (node.objtype !== 'OBJECT_MATVIEW' && node.objtype !== 'OBJECT_TABLE') { + return notDerivable(`no existence check known for CREATE ... AS with objtype ${node.objtype}`); + } + return [check(`to_regclass(${lit(qname(node.into.rel.schemaname, node.into.rel.relname))}) IS NOT NULL`)]; + } + case 'CreateForeignServerStmt': + return [check(`EXISTS (SELECT 1 FROM pg_foreign_server WHERE srvname = ${lit(node.servername)})`)]; + case 'CreateForeignTableStmt': + return [check( + `to_regclass(${lit(qname(node.base.relation.schemaname, node.base.relation.relname))}) IS NOT NULL` + )]; + case 'CreateUserMappingStmt': { + const user = node.user?.roletype === 'ROLESPEC_PUBLIC' ? 'public' : node.user?.rolename; + if (!user) return notDerivable('CREATE USER MAPPING without a resolvable user'); + return [check( + `EXISTS (SELECT 1 FROM pg_user_mappings WHERE srvname = ${lit(node.servername)} ` + + `AND usename = ${lit(user)})` + )]; + } + case 'DefineStmt': + return verifyDefine(node, notDerivable); + case 'CreateCastStmt': { + const source = Deparser.deparse({ TypeName: node.sourcetype }); + const target = Deparser.deparse({ TypeName: node.targettype }); + return [check( + `EXISTS (SELECT 1 FROM pg_cast WHERE castsource = ${lit(source)}::regtype ` + + `AND casttarget = ${lit(target)}::regtype)` + )]; + } + case 'CreatePublicationStmt': + return [check(`EXISTS (SELECT 1 FROM pg_publication WHERE pubname = ${lit(node.pubname)})`)]; + case 'CreateSubscriptionStmt': + return [check(`EXISTS (SELECT 1 FROM pg_subscription WHERE subname = ${lit(node.subname)})`)]; + case 'CreateStatsStmt': + return [check( + `EXISTS (SELECT 1 FROM pg_statistic_ext s JOIN pg_namespace n ON n.oid = s.stxnamespace ` + + `WHERE s.stxname = ${lit(defNameOnly(node.defnames))}${defNamespaceCond(node.defnames)})` + )]; + case 'CreateEventTrigStmt': + return [check(`EXISTS (SELECT 1 FROM pg_event_trigger WHERE evtname = ${lit(node.trigname)})`)]; + case 'RuleStmt': { + const conds = [`rulename = ${lit(node.rulename)}`, `tablename = ${lit(node.relation.relname)}`]; + if (node.relation.schemaname) conds.push(`schemaname = ${lit(node.relation.schemaname)}`); + return [check(`EXISTS (SELECT 1 FROM pg_rules WHERE ${conds.join(' AND ')})`)]; + } + case 'AlterEnumStmt': { + if (!node.newVal) return notDerivable('ALTER TYPE without ADD VALUE has nothing to verify'); + return [check( + `EXISTS (SELECT 1 FROM pg_enum WHERE enumtypid = ${lit(nameListText(node.typeName))}::regtype ` + + `AND enumlabel = ${lit(node.newVal)})` + )]; + } + case 'AlterDefaultPrivilegesStmt': + return verifyDefaultPrivileges(node, warnings); + case 'CreateFdwStmt': + return [check(`EXISTS (SELECT 1 FROM pg_foreign_data_wrapper WHERE fdwname = ${lit(node.fdwname)})`)]; + case 'CreateConversionStmt': + return [check( + `EXISTS (SELECT 1 FROM pg_conversion c JOIN pg_namespace n ON n.oid = c.connamespace ` + + `WHERE c.conname = ${lit(defNameOnly(node.conversion_name))}${defNamespaceCond(node.conversion_name)})` + )]; + case 'CreateAmStmt': + return [check(`EXISTS (SELECT 1 FROM pg_am WHERE amname = ${lit(node.amname)})`)]; + case 'CreateTransformStmt': { + const type = Deparser.deparse({ TypeName: node.type_name }); + return [check( + `EXISTS (SELECT 1 FROM pg_transform t JOIN pg_language l ON l.oid = t.trflang ` + + `WHERE t.trftype = ${lit(type)}::regtype AND l.lanname = ${lit(node.lang)})` + )]; + } + case 'CreateOpClassStmt': + return [check( + `EXISTS (SELECT 1 FROM pg_opclass c JOIN pg_am am ON am.oid = c.opcmethod ` + + `JOIN pg_namespace n ON n.oid = c.opcnamespace ` + + `WHERE c.opcname = ${lit(defNameOnly(node.opclassname))} AND am.amname = ${lit(node.amname)}` + + `${defNamespaceCond(node.opclassname)})` + )]; + case 'CreateOpFamilyStmt': + return [check( + `EXISTS (SELECT 1 FROM pg_opfamily f JOIN pg_am am ON am.oid = f.opfmethod ` + + `JOIN pg_namespace n ON n.oid = f.opfnamespace ` + + `WHERE f.opfname = ${lit(defNameOnly(node.opfamilyname))} AND am.amname = ${lit(node.amname)}` + + `${defNamespaceCond(node.opfamilyname)})` + )]; + case 'CreateTableSpaceStmt': + return [check(`EXISTS (SELECT 1 FROM pg_tablespace WHERE spcname = ${lit(node.tablespacename)})`)]; + case 'RenameStmt': + return verifyRename(node, notDerivable); + case 'AlterObjectSchemaStmt': + return verifySetSchema(node, notDerivable); + case 'SecLabelStmt': + // Like comments: metadata only, nothing comes into existence. + return []; case 'GrantStmt': return verifyGrant(node, warnings); case 'GrantRoleStmt': { @@ -383,16 +718,253 @@ function verifyStatement(facts: StatementFacts, warnings: string[]): string[] { } } +/** Last element of a DefineStmt name list. */ +const defNameOnly = (defnames: AnyNode[] | undefined): string => { + const parts = (defnames ?? []) + .map((n: AnyNode) => n?.String?.sval) + .filter((s: any) => typeof s === 'string'); + return parts[parts.length - 1] ?? ''; +}; + +/** Optional namespace condition for a qualified DefineStmt name list. */ +const defNamespaceCond = (defnames: AnyNode[] | undefined): string => { + const parts = (defnames ?? []) + .map((n: AnyNode) => n?.String?.sval) + .filter((s: any) => typeof s === 'string'); + return parts.length > 1 ? ` AND n.nspname = ${lit(parts[parts.length - 2])}` : ''; +}; + +/** Existence checks for DefineStmt objects: collations, aggregates, operators. */ +function verifyDefine(node: AnyNode, notDerivable: (reason: string) => string[]): string[] { + switch (node.kind) { + case 'OBJECT_COLLATION': + return [check( + `EXISTS (SELECT 1 FROM pg_collation c JOIN pg_namespace n ON n.oid = c.collnamespace ` + + `WHERE c.collname = ${lit(defNameOnly(node.defnames))}${defNamespaceCond(node.defnames)})` + )]; + case 'OBJECT_AGGREGATE': { + const args = defineArgs(node).map((p: AnyNode) => Deparser.deparse({ TypeName: p.argType })); + const signature = `${nameListText(node.defnames)}(${args.join(', ')})`; + return [check(`to_regprocedure(${lit(signature)}) IS NOT NULL`)]; + } + case 'OBJECT_OPERATOR': { + const left = defElem(node.definition, 'leftarg'); + const right = defElem(node.definition, 'rightarg'); + if (!left?.arg?.TypeName || !right?.arg?.TypeName) { + return notDerivable('prefix operators are not supported (binary LEFTARG/RIGHTARG required)'); + } + const args = [left.arg.TypeName, right.arg.TypeName] + .map((t: AnyNode) => Deparser.deparse({ TypeName: t })); + // Operator names are not identifiers: never quote them. + const parts = (node.defnames ?? []) + .map((n: AnyNode) => n?.String?.sval) + .filter((s: any) => typeof s === 'string'); + const op = parts[parts.length - 1] ?? ''; + const qualified = parts.length > 1 ? `${qname(null, parts[parts.length - 2])}.${op}` : op; + return [check(`to_regoperator(${lit(`${qualified}(${args.join(', ')})`)}) IS NOT NULL`)]; + } + case 'OBJECT_TSCONFIGURATION': + return [check( + `EXISTS (SELECT 1 FROM pg_ts_config c JOIN pg_namespace n ON n.oid = c.cfgnamespace ` + + `WHERE c.cfgname = ${lit(defNameOnly(node.defnames))}${defNamespaceCond(node.defnames)})` + )]; + case 'OBJECT_TSDICTIONARY': + return [check( + `EXISTS (SELECT 1 FROM pg_ts_dict d JOIN pg_namespace n ON n.oid = d.dictnamespace ` + + `WHERE d.dictname = ${lit(defNameOnly(node.defnames))}${defNamespaceCond(node.defnames)})` + )]; + case 'OBJECT_TSPARSER': + return [check( + `EXISTS (SELECT 1 FROM pg_ts_parser p JOIN pg_namespace n ON n.oid = p.prsnamespace ` + + `WHERE p.prsname = ${lit(defNameOnly(node.defnames))}${defNamespaceCond(node.defnames)})` + )]; + case 'OBJECT_TSTEMPLATE': + return [check( + `EXISTS (SELECT 1 FROM pg_ts_template t JOIN pg_namespace n ON n.oid = t.tmplnamespace ` + + `WHERE t.tmplname = ${lit(defNameOnly(node.defnames))}${defNamespaceCond(node.defnames)})` + )]; + default: + return notDerivable(`no existence check known for CREATE (DefineStmt) with kind ${node.kind}`); + } +} + +/** Existence checks for RENAME: the object exists under its new name. */ +function verifyRename(node: AnyNode, notDerivable: (reason: string) => string[]): string[] { + switch (node.renameType) { + case 'OBJECT_TABLE': + case 'OBJECT_INDEX': + case 'OBJECT_SEQUENCE': + case 'OBJECT_VIEW': + case 'OBJECT_MATVIEW': + case 'OBJECT_FOREIGN_TABLE': + return [check(`to_regclass(${lit(qname(node.relation.schemaname, node.newname))}) IS NOT NULL`)]; + case 'OBJECT_COLUMN': { + const conds = [`table_name = ${lit(node.relation.relname)}`, `column_name = ${lit(node.newname)}`]; + if (node.relation.schemaname) conds.push(`table_schema = ${lit(node.relation.schemaname)}`); + return [check(`EXISTS (SELECT 1 FROM information_schema.columns WHERE ${conds.join(' AND ')})`)]; + } + case 'OBJECT_SCHEMA': + return [check( + `EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = ${lit(node.newname)})` + )]; + case 'OBJECT_TYPE': + case 'OBJECT_DOMAIN': { + const items = node.object?.List?.items; + if (!items?.length) return notDerivable(`RENAME ${node.renameType} without a qualified name`); + const parts = items + .map((n: AnyNode) => n?.String?.sval) + .filter((s: any) => typeof s === 'string'); + const schema = parts.length > 1 ? parts[parts.length - 2] : null; + return [check(`to_regtype(${lit(qname(schema, node.newname))}) IS NOT NULL`)]; + } + case 'OBJECT_FUNCTION': + case 'OBJECT_PROCEDURE': + case 'OBJECT_AGGREGATE': { + const owa = node.object?.ObjectWithArgs; + if (!owa?.objname?.length) return notDerivable(`RENAME ${node.renameType} without a function name`); + const parts = owa.objname + .map((n: AnyNode) => n?.String?.sval) + .filter((s: any) => typeof s === 'string'); + const schema = parts.length > 1 ? parts[parts.length - 2] : null; + const args = (owa.objargs ?? []).map((t: AnyNode) => Deparser.deparse(t)); + return [check( + `to_regprocedure(${lit(`${qname(schema, node.newname)}(${args.join(', ')})`)}) IS NOT NULL` + )]; + } + default: + return notDerivable(`no existence check known for RENAME ${node.renameType}`); + } +} + +/** Existence checks for SET SCHEMA: the object exists in the new schema. */ +function verifySetSchema(node: AnyNode, notDerivable: (reason: string) => string[]): string[] { + switch (node.objectType) { + case 'OBJECT_TABLE': + case 'OBJECT_SEQUENCE': + case 'OBJECT_VIEW': + case 'OBJECT_MATVIEW': + case 'OBJECT_FOREIGN_TABLE': + return [check(`to_regclass(${lit(qname(node.newschema, node.relation.relname))}) IS NOT NULL`)]; + case 'OBJECT_TYPE': + case 'OBJECT_DOMAIN': { + const items = node.object?.List?.items; + if (!items?.length) return notDerivable(`SET SCHEMA ${node.objectType} without a name`); + const parts = items + .map((n: AnyNode) => n?.String?.sval) + .filter((s: any) => typeof s === 'string'); + return [check(`to_regtype(${lit(qname(node.newschema, parts[parts.length - 1]))}) IS NOT NULL`)]; + } + case 'OBJECT_FUNCTION': + case 'OBJECT_PROCEDURE': + case 'OBJECT_AGGREGATE': { + const owa = node.object?.ObjectWithArgs; + if (!owa?.objname?.length) return notDerivable(`SET SCHEMA ${node.objectType} without a function name`); + const parts = owa.objname + .map((n: AnyNode) => n?.String?.sval) + .filter((s: any) => typeof s === 'string'); + const args = (owa.objargs ?? []).map((t: AnyNode) => Deparser.deparse(t)); + return [check( + `to_regprocedure(${lit(`${qname(node.newschema, parts[parts.length - 1])}(${args.join(', ')})`)}) IS NOT NULL` + )]; + } + default: + return notDerivable(`no existence check known for SET SCHEMA on ${node.objectType}`); + } +} + +/** + * The concrete privileges GRANT ALL expands to, per object type. Fixed + * PostgreSQL knowledge, not database state. + */ +const ALL_PRIVILEGES: Record = { + OBJECT_TABLE: ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER'], + OBJECT_SEQUENCE: ['USAGE', 'SELECT', 'UPDATE'], + OBJECT_FUNCTION: ['EXECUTE'], + OBJECT_PROCEDURE: ['EXECUTE'], + OBJECT_SCHEMA: ['CREATE', 'USAGE'], + OBJECT_TYPE: ['USAGE'] +}; + +/** ACL objtype codes used by pg_default_acl.defaclobjtype. */ +const DEFAULT_ACL_OBJTYPE: Record = { + OBJECT_TABLE: 'r', + OBJECT_SEQUENCE: 'S', + OBJECT_FUNCTION: 'f', + OBJECT_TYPE: 'T', + OBJECT_SCHEMA: 'n' +}; + +/** Checks for ALTER DEFAULT PRIVILEGES ... GRANT via pg_default_acl. */ +function verifyDefaultPrivileges(node: AnyNode, warnings: string[]): string[] { + const action = node.action; + if (action?.is_grant !== true) return []; + + const objtype = DEFAULT_ACL_OBJTYPE[action.objtype]; + if (!objtype) { + warnings.push(`verify not derivable: ALTER DEFAULT PRIVILEGES on ${action.objtype}`); + return []; + } + + let privNames: string[] = (action.privileges ?? []) + .map((p: AnyNode) => p?.AccessPriv?.priv_name) + .filter((s: any) => typeof s === 'string'); + if (privNames.length === 0) { + // GRANT ALL: expand to the object type's full privilege list. + privNames = ALL_PRIVILEGES[action.objtype] ?? []; + if (privNames.length === 0) { + warnings.push(`verify not derivable: ALTER DEFAULT PRIVILEGES GRANT ALL on ${action.objtype}`); + return []; + } + } + + const grantees: string[] = (action.grantees ?? []) + .map((g: AnyNode) => { + const spec = g?.RoleSpec; + if (spec?.roletype === 'ROLESPEC_PUBLIC') return 'public'; + return spec?.rolename; + }) + .filter((s: any) => typeof s === 'string'); + + const schemas: string[] = (node.options ?? []) + .filter((o: AnyNode) => o?.DefElem?.defname === 'schemas') + .flatMap((o: AnyNode) => o.DefElem.arg?.List?.items ?? []) + .map((s: AnyNode) => s?.String?.sval) + .filter((s: any) => typeof s === 'string'); + + const out: string[] = []; + for (const grantee of grantees) { + for (const privilege of privNames) { + const conds = [ + `d.defaclobjtype = ${lit(objtype)}`, + `r.rolname = ${lit(grantee)}`, + `a.privilege_type = ${lit(privilege.toUpperCase())}` + ]; + if (schemas.length > 0) { + conds.push(`d.defaclnamespace IN (${schemas.map(s => `to_regnamespace(${lit(qname(null, s))})`).join(', ')})`); + } + out.push(check( + `EXISTS (SELECT 1 FROM pg_default_acl d, aclexplode(d.defaclacl) a ` + + `JOIN pg_roles r ON r.oid = a.grantee WHERE ${conds.join(' AND ')})` + )); + } + } + return out; +} + /** Privilege checks for a GRANT: one per (grantee, privilege, object). */ function verifyGrant(node: AnyNode, warnings: string[]): string[] { if (node.is_grant !== true) return []; - const privNames: string[] = (node.privileges ?? []) + let privNames: string[] = (node.privileges ?? []) .map((p: AnyNode) => p?.AccessPriv?.priv_name) .filter((s: any) => typeof s === 'string'); if (privNames.length === 0) { - warnings.push('verify not derivable: GRANT ALL expands per object type; grant privileges explicitly to verify them'); - return []; + // GRANT ALL: expand to the object type's full privilege list. + privNames = ALL_PRIVILEGES[node.objtype] ?? []; + if (privNames.length === 0) { + warnings.push(`verify not derivable: GRANT ALL on ${node.objtype ?? 'unknown object type'}`); + return []; + } } const grantees: string[] = (node.grantees ?? []) @@ -409,15 +981,17 @@ function verifyGrant(node: AnyNode, warnings: string[]): string[] { const privilege = priv.toUpperCase(); switch (node.objtype) { case 'OBJECT_TABLE': - case 'OBJECT_SEQUENCE': + case 'OBJECT_SEQUENCE': { + const fn = node.objtype === 'OBJECT_SEQUENCE' ? 'has_sequence_privilege' : 'has_table_privilege'; for (const obj of node.objects ?? []) { const rel = obj?.RangeVar; if (!rel) continue; out.push(check( - `has_table_privilege(${lit(grantee)}, ${lit(qname(rel.schemaname, rel.relname))}, ${lit(privilege)})` + `${fn}(${lit(grantee)}, ${lit(qname(rel.schemaname, rel.relname))}, ${lit(privilege)})` )); } break; + } case 'OBJECT_FUNCTION': case 'OBJECT_PROCEDURE': for (const obj of node.objects ?? []) { @@ -493,6 +1067,16 @@ function verifyAlterTable(node: AnyNode, warnings: string[]): string[] { case 'AT_ForceRowSecurity': out.push(check(relCondition('c.relforcerowsecurity'))); break; + case 'AT_AttachPartition': { + const partition = cmd.def?.PartitionCmd?.name; + if (!partition) break; + out.push(check( + `EXISTS (SELECT 1 FROM pg_inherits WHERE ` + + `inhrelid = ${lit(qname(partition.schemaname, partition.relname))}::regclass ` + + `AND inhparent = ${lit(table)}::regclass)` + )); + break; + } default: // Nothing verifiable comes into existence. break; @@ -512,7 +1096,49 @@ export function verifyFor(facts: StatementFacts[]): GeneratedScript { const warnings: string[] = []; const pieces: string[] = []; for (const fact of facts) { - pieces.push(...verifyStatement(fact, warnings)); + pieces.push(...emitChecks(fact, warnings)); } return { sql: pieces.join('\n\n'), warnings }; } + +/** + * The inverse of one classified statement as raw AST nodes (each one a + * wrapped statement node, e.g. `{ DropStmt: {...} }`), or `null` when no + * inverse is mechanically derivable. An empty array means the statement + * needs no revert of its own. + * + * This is the node-level layer under {@link revertFor}: consumers that + * compose inverses at the AST level (semantic diffing, migration + * generation) use this instead of round-tripping through deparsed text. + * A statement whose inverse is only partially derivable (e.g. an ALTER + * TABLE where one command cannot be inverted) returns `null` — partial + * inverses are never silently produced. + */ +export function invertStatement(facts: StatementFacts): AnyNode[] | null { + const warnings: string[] = []; + const emitted = emitInverse(facts, warnings); + if (warnings.length > 0) return null; + return emitted + .filter((e): e is { stmt: AnyNode } => 'stmt' in e) + .map(e => e.stmt); +} + +/** + * The existence checks for one classified statement as raw AST nodes + * (each one a wrapped `SelectStmt` using the raise-on-failure division + * idiom), or `null` when no check is mechanically derivable. An empty + * array means nothing comes into existence (comments, DML, security + * labels). + * + * Node-level layer under {@link verifyFor}. Requires the parser WASM + * module to be loaded (`loadModule()` from `plpgsql-parser`). + */ +export function existenceCheck(facts: StatementFacts): AnyNode[] | null { + const warnings: string[] = []; + const checks = emitChecks(facts, warnings); + if (warnings.length > 0) return null; + return checks.map(sql => { + const parsed = parseSql(sql); + return clone(parsed.stmts[0].stmt) as AnyNode; + }); +} diff --git a/packages/scripts/src/supported.ts b/packages/scripts/src/supported.ts index 53736a47..6793b269 100644 --- a/packages/scripts/src/supported.ts +++ b/packages/scripts/src/supported.ts @@ -126,6 +126,150 @@ export const SUPPORTED_STATEMENTS: readonly SupportedStatement[] = [ nodeTags: ['CommentStmt'], revert: 'COMMENT ON ... IS NULL', verify: null + }, + { + statement: 'CREATE MATERIALIZED VIEW / CREATE TABLE AS', + nodeTags: ['CreateTableAsStmt'], + revert: 'DROP MATERIALIZED VIEW / DROP TABLE', + verify: 'to_regclass IS NOT NULL' + }, + { + statement: 'CREATE SERVER', + nodeTags: ['CreateForeignServerStmt'], + revert: 'DROP SERVER', + verify: 'pg_foreign_server existence check' + }, + { + statement: 'CREATE FOREIGN TABLE', + nodeTags: ['CreateForeignTableStmt'], + revert: 'DROP FOREIGN TABLE', + verify: 'to_regclass IS NOT NULL' + }, + { + statement: 'CREATE USER MAPPING', + nodeTags: ['CreateUserMappingStmt'], + revert: 'DROP USER MAPPING', + verify: 'pg_user_mappings existence check' + }, + { + statement: 'CREATE COLLATION / AGGREGATE / OPERATOR (binary) / TEXT SEARCH objects', + nodeTags: ['DefineStmt'], + revert: 'DROP COLLATION / DROP AGGREGATE (with signature) / DROP OPERATOR (left, right) / DROP TEXT SEARCH ...', + verify: 'pg_collation / to_regprocedure / to_regoperator / pg_ts_*' + }, + { + statement: 'CREATE CAST', + nodeTags: ['CreateCastStmt'], + revert: 'DROP CAST (source AS target)', + verify: 'pg_cast existence check' + }, + { + statement: 'CREATE PUBLICATION', + nodeTags: ['CreatePublicationStmt'], + revert: 'DROP PUBLICATION', + verify: 'pg_publication existence check' + }, + { + statement: 'CREATE SUBSCRIPTION', + nodeTags: ['CreateSubscriptionStmt'], + revert: 'DROP SUBSCRIPTION', + verify: 'pg_subscription existence check' + }, + { + statement: 'CREATE STATISTICS', + nodeTags: ['CreateStatsStmt'], + revert: 'DROP STATISTICS', + verify: 'pg_statistic_ext existence check' + }, + { + statement: 'CREATE EVENT TRIGGER', + nodeTags: ['CreateEventTrigStmt'], + revert: 'DROP EVENT TRIGGER', + verify: 'pg_event_trigger existence check' + }, + { + statement: 'CREATE RULE', + nodeTags: ['RuleStmt'], + revert: 'DROP RULE ... ON table', + verify: 'pg_rules existence check' + }, + { + statement: 'ALTER TYPE ... ADD VALUE', + nodeTags: ['AlterEnumStmt'], + revert: null, + verify: 'pg_enum label existence check' + }, + { + statement: 'ALTER TABLE ... ATTACH PARTITION', + nodeTags: ['AlterTableStmt'], + revert: 'ALTER TABLE ... DETACH PARTITION', + verify: 'pg_inherits existence check' + }, + { + statement: 'ALTER DEFAULT PRIVILEGES ... GRANT', + nodeTags: ['AlterDefaultPrivilegesStmt'], + revert: 'ALTER DEFAULT PRIVILEGES ... REVOKE', + verify: 'pg_default_acl + aclexplode privilege check' + }, + { + statement: 'SECURITY LABEL', + nodeTags: ['SecLabelStmt'], + revert: 'SECURITY LABEL ... IS NULL', + verify: null + }, + { + statement: 'CREATE FOREIGN DATA WRAPPER', + nodeTags: ['CreateFdwStmt'], + revert: 'DROP FOREIGN DATA WRAPPER', + verify: 'pg_foreign_data_wrapper existence check' + }, + { + statement: 'CREATE CONVERSION', + nodeTags: ['CreateConversionStmt'], + revert: 'DROP CONVERSION', + verify: 'pg_conversion existence check' + }, + { + statement: 'CREATE ACCESS METHOD', + nodeTags: ['CreateAmStmt'], + revert: 'DROP ACCESS METHOD', + verify: 'pg_am existence check' + }, + { + statement: 'CREATE TRANSFORM', + nodeTags: ['CreateTransformStmt'], + revert: 'DROP TRANSFORM FOR type LANGUAGE lang', + verify: 'pg_transform existence check' + }, + { + statement: 'CREATE OPERATOR CLASS', + nodeTags: ['CreateOpClassStmt'], + revert: 'DROP OPERATOR CLASS ... USING am', + verify: 'pg_opclass existence check' + }, + { + statement: 'CREATE OPERATOR FAMILY', + nodeTags: ['CreateOpFamilyStmt'], + revert: 'DROP OPERATOR FAMILY ... USING am', + verify: 'pg_opfamily existence check' + }, + { + statement: 'CREATE TABLESPACE', + nodeTags: ['CreateTableSpaceStmt'], + revert: 'DROP TABLESPACE', + verify: 'pg_tablespace existence check' + }, + { + statement: 'ALTER ... RENAME TO (tables, columns, indexes, sequences, views, types, functions, schemas)', + nodeTags: ['RenameStmt'], + revert: 'ALTER ... RENAME back (the statement carries both names)', + verify: 'object exists under the new name' + }, + { + statement: 'ALTER ... SET SCHEMA (schema-qualified source)', + nodeTags: ['AlterObjectSchemaStmt'], + revert: 'ALTER ... SET SCHEMA back (the statement carries both schemas)', + verify: 'object exists in the new schema' } ];