@intentius/chant-lexicon-sql 0.116.0 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +20 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -5
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lsp/postgres-template.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +20 -0
- package/dist/okf/index.md +4 -0
- package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
- package/dist/okf/types/PostgresGrant.md +9 -0
- package/dist/okf/types/PostgresPolicy.md +9 -0
- package/dist/okf/types/PostgresRole.md +9 -0
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +10 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/access/acl.d.ts +119 -0
- package/dist/postgres/access/acl.d.ts.map +1 -0
- package/dist/postgres/access/import.d.ts +13 -0
- package/dist/postgres/access/import.d.ts.map +1 -0
- package/dist/postgres/access/live.d.ts +24 -0
- package/dist/postgres/access/live.d.ts.map +1 -0
- package/dist/postgres/apply/apply.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +3 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/entities.d.ts +106 -4
- package/dist/postgres/entities.d.ts.map +1 -1
- package/dist/postgres/entity-types.d.ts +4 -0
- package/dist/postgres/entity-types.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +4 -1
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/import/live-export.d.ts +6 -0
- package/dist/postgres/import/live-export.d.ts.map +1 -1
- package/dist/postgres/live/bind.d.ts +2 -0
- package/dist/postgres/live/bind.d.ts.map +1 -1
- package/dist/postgres/live/catalog.d.ts +11 -2
- package/dist/postgres/live/catalog.d.ts.map +1 -1
- package/dist/postgres/live/describe-resources.d.ts +9 -0
- package/dist/postgres/live/describe-resources.d.ts.map +1 -1
- package/dist/postgres/migrate/op.d.ts +29 -0
- package/dist/postgres/migrate/op.d.ts.map +1 -1
- package/dist/postgres/parser.d.ts +76 -3
- package/dist/postgres/parser.d.ts.map +1 -1
- package/dist/postgres/plan/commands.d.ts +12 -0
- package/dist/postgres/plan/commands.d.ts.map +1 -1
- package/dist/postgres/plan/deep.d.ts.map +1 -1
- package/dist/postgres/plan/diff.d.ts +10 -1
- package/dist/postgres/plan/diff.d.ts.map +1 -1
- package/dist/postgres/plan/normalize.d.ts +8 -1
- package/dist/postgres/plan/normalize.d.ts.map +1 -1
- package/dist/postgres/plan/rules.d.ts +8 -0
- package/dist/postgres/plan/rules.d.ts.map +1 -1
- package/dist/postgres/plan/schema.d.ts +2 -1
- package/dist/postgres/plan/schema.d.ts.map +1 -1
- package/dist/postgres/plan/server-normalize.d.ts +4 -1
- package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
- package/dist/postgres.d.ts +1 -1
- package/dist/postgres.d.ts.map +1 -1
- package/dist/rules/postgres-templates.ts +2 -2
- package/dist/rules/sqlpg001.ts +7 -4
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen/registry.ts +4 -0
- package/src/config.ts +10 -0
- package/src/generated/lexicon-sql.json +20 -0
- package/src/index.ts +1 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lsp/postgres-template.ts +2 -0
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.ts +7 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +1 -0
- package/src/plugin.ts +3 -0
- package/src/postgres/access/access.e2e.test.ts +191 -0
- package/src/postgres/access/access.test.ts +147 -0
- package/src/postgres/access/acl.ts +400 -0
- package/src/postgres/access/import.ts +64 -0
- package/src/postgres/access/live.ts +123 -0
- package/src/postgres/apply/apply.ts +117 -4
- package/src/postgres/apply/statements.ts +55 -9
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +14 -4
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +3 -0
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/describe-resources.ts +27 -3
- package/src/postgres/migrate/migrate.test.ts +24 -1
- package/src/postgres/migrate/op.ts +80 -35
- package/src/postgres/migrate/outer.e2e.test.ts +163 -0
- package/src/postgres/parser.ts +247 -8
- package/src/postgres/plan/commands.ts +60 -7
- package/src/postgres/plan/deep.ts +24 -3
- package/src/postgres/plan/diff.ts +71 -6
- package/src/postgres/plan/normalize.ts +115 -2
- package/src/postgres/plan/rules.ts +44 -0
- package/src/postgres/plan/schema.ts +4 -2
- package/src/postgres/plan/server-normalize.ts +29 -1
- package/src/postgres.ts +11 -0
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres.md +9 -2
- package/src/validate.ts +4 -0
package/src/postgres/parser.ts
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
* A hand-written, lossless parser for the Postgres statements the dialect
|
|
3
3
|
* declares (chant #3278, #3279): `CREATE SCHEMA`, `TABLE`, `INDEX`, `VIEW`,
|
|
4
4
|
* `MATERIALIZED VIEW`, `SEQUENCE`, `TYPE ... AS ENUM`, `DOMAIN`, `EXTENSION`,
|
|
5
|
-
* `FUNCTION`, `PROCEDURE` and `TRIGGER` (#3680), and `
|
|
6
|
-
*
|
|
5
|
+
* `FUNCTION`, `PROCEDURE` and `TRIGGER` (#3680), `POLICY` and `ROLE`, `GRANT`,
|
|
6
|
+
* `REVOKE` and `ALTER DEFAULT PRIVILEGES` on schemas, tables, columns,
|
|
7
|
+
* sequences and routines, `ALTER TABLE ... ROW LEVEL SECURITY` (#3681), and
|
|
8
|
+
* `COMMENT ON` for those objects.
|
|
7
9
|
*
|
|
8
10
|
* A function's or procedure's body is a string (`AS $$ ... $$`), kept
|
|
9
11
|
* verbatim: Postgres stores it as written and prints it back unchanged. A
|
|
@@ -260,6 +262,58 @@ export interface TriggerNode {
|
|
|
260
262
|
args: Span[];
|
|
261
263
|
}
|
|
262
264
|
|
|
265
|
+
export interface PolicyNode {
|
|
266
|
+
statement: "policy";
|
|
267
|
+
name: NameNode;
|
|
268
|
+
table: NameNode;
|
|
269
|
+
/** `AS PERMISSIVE` (true, the default) or `AS RESTRICTIVE` (false). */
|
|
270
|
+
permissive?: boolean;
|
|
271
|
+
command?: "ALL" | "SELECT" | "INSERT" | "UPDATE" | "DELETE";
|
|
272
|
+
/** `TO role, ...`: role names, `public` for PUBLIC. */
|
|
273
|
+
roles: Array<{ name: string; span: Span }>;
|
|
274
|
+
/** `USING (...)`, parentheses excluded. */
|
|
275
|
+
using?: Span;
|
|
276
|
+
/** `WITH CHECK (...)`, parentheses excluded. */
|
|
277
|
+
check?: Span;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export interface RoleNode {
|
|
281
|
+
statement: "role";
|
|
282
|
+
name: NameNode;
|
|
283
|
+
/** The options, each by its key word (`LOGIN`, `NOINHERIT`, `CONNECTION LIMIT`, `VALID UNTIL`), with a value where it takes one. */
|
|
284
|
+
options: Array<{ option: string; value?: Span }>;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** What a GRANT or REVOKE is on: an object kind, or (for default privileges) a kind of object created later. */
|
|
288
|
+
export type GrantTarget = "TABLE" | "SEQUENCE" | "SCHEMA" | "FUNCTION" | "PROCEDURE" | "ROUTINE" | "TABLES" | "SEQUENCES" | "FUNCTIONS" | "ROUTINES" | "TYPES" | "SCHEMAS";
|
|
289
|
+
|
|
290
|
+
export interface GrantNode {
|
|
291
|
+
statement: "grant";
|
|
292
|
+
revoke: boolean;
|
|
293
|
+
/** `REVOKE GRANT OPTION FOR ...`. */
|
|
294
|
+
grantOptionFor: boolean;
|
|
295
|
+
/** Each privilege, upper case (`ALL` for ALL [PRIVILEGES]), with the columns it is limited to. */
|
|
296
|
+
privileges: Array<{ privilege: string; columns: Array<{ name: string; span: Span }> }>;
|
|
297
|
+
on: GrantTarget;
|
|
298
|
+
/** The objects, each with a routine's parameter list as written (parentheses excluded). Empty for default privileges. */
|
|
299
|
+
objects: Array<{ name: NameNode; args?: Span }>;
|
|
300
|
+
/** Role names, `public` for PUBLIC. */
|
|
301
|
+
grantees: Array<{ name: string; span: Span }>;
|
|
302
|
+
withGrantOption: boolean;
|
|
303
|
+
/** `ALTER DEFAULT PRIVILEGES FOR ROLE ...`. */
|
|
304
|
+
forRoles?: Array<{ name: string; span: Span }>;
|
|
305
|
+
/** `ALTER DEFAULT PRIVILEGES IN SCHEMA ...`. */
|
|
306
|
+
inSchemas?: Array<{ name: string; span: Span }>;
|
|
307
|
+
/** True for `ALTER DEFAULT PRIVILEGES`. */
|
|
308
|
+
defaults: boolean;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export interface RowSecurityNode {
|
|
312
|
+
statement: "rowSecurity";
|
|
313
|
+
table: NameNode;
|
|
314
|
+
action: "ENABLE" | "DISABLE" | "FORCE" | "NO FORCE";
|
|
315
|
+
}
|
|
316
|
+
|
|
263
317
|
export interface CommentNode {
|
|
264
318
|
statement: "comment";
|
|
265
319
|
/** TABLE, COLUMN, INDEX, VIEW, MATERIALIZED VIEW, SEQUENCE, TYPE, DOMAIN, SCHEMA, EXTENSION, FUNCTION, PROCEDURE, TRIGGER, CONSTRAINT, DOMAIN CONSTRAINT. */
|
|
@@ -284,6 +338,10 @@ export type StatementNode =
|
|
|
284
338
|
| ExtensionNode
|
|
285
339
|
| RoutineNode
|
|
286
340
|
| TriggerNode
|
|
341
|
+
| PolicyNode
|
|
342
|
+
| RoleNode
|
|
343
|
+
| GrantNode
|
|
344
|
+
| RowSecurityNode
|
|
287
345
|
| CommentNode;
|
|
288
346
|
|
|
289
347
|
const isNameToken = (t: Token | undefined): boolean =>
|
|
@@ -805,8 +863,15 @@ class PgParser extends SqlCursor {
|
|
|
805
863
|
|
|
806
864
|
parse(): StatementNode {
|
|
807
865
|
if (this.accept("COMMENT")) return this.comment();
|
|
866
|
+
if (this.accept("GRANT")) return this.grant(false, false);
|
|
867
|
+
if (this.accept("REVOKE")) return this.grant(true, false);
|
|
868
|
+
if (this.acceptSeq("ALTER", "DEFAULT", "PRIVILEGES")) return this.defaultPrivileges();
|
|
869
|
+
if (this.acceptSeq("ALTER", "TABLE")) return this.rowSecurity();
|
|
808
870
|
this.expect("CREATE");
|
|
809
871
|
const orReplace = this.acceptSeq("OR", "REPLACE");
|
|
872
|
+
if (!orReplace && this.accept("POLICY")) return this.policy();
|
|
873
|
+
if (!orReplace && this.accept("ROLE")) return this.role();
|
|
874
|
+
if (!orReplace && kw(this.peek(), "USER", "GROUP")) this.fail("expected ROLE (declare a role with CREATE ROLE; LOGIN is an option of it)");
|
|
810
875
|
if (this.accept("FUNCTION")) return this.routine("function", orReplace);
|
|
811
876
|
if (this.accept("PROCEDURE")) return this.routine("procedure", orReplace);
|
|
812
877
|
if (this.accept("TRIGGER")) return this.trigger(orReplace, false);
|
|
@@ -825,7 +890,7 @@ class PgParser extends SqlCursor {
|
|
|
825
890
|
const recursive = this.accept("RECURSIVE");
|
|
826
891
|
if (!orReplace && !recursive && persistence === undefined && this.acceptSeq("MATERIALIZED", "VIEW")) return this.view(true, false, false, false);
|
|
827
892
|
if (this.accept("VIEW")) return this.view(false, orReplace, persistence === "temporary", recursive);
|
|
828
|
-
return this.fail("expected SCHEMA, TABLE, INDEX, VIEW, MATERIALIZED VIEW, SEQUENCE, TYPE, DOMAIN, EXTENSION, FUNCTION, PROCEDURE or
|
|
893
|
+
return this.fail("expected SCHEMA, TABLE, INDEX, VIEW, MATERIALIZED VIEW, SEQUENCE, TYPE, DOMAIN, EXTENSION, FUNCTION, PROCEDURE, TRIGGER, POLICY or ROLE");
|
|
829
894
|
}
|
|
830
895
|
|
|
831
896
|
private schema(): SchemaNode {
|
|
@@ -1306,6 +1371,165 @@ class PgParser extends SqlCursor {
|
|
|
1306
1371
|
return node;
|
|
1307
1372
|
}
|
|
1308
1373
|
|
|
1374
|
+
/** A role name where one is granted to or named: an identifier, an interpolation, or PUBLIC (`public`). */
|
|
1375
|
+
private roleSpec(what = "a role name"): { name: string; span: Span } {
|
|
1376
|
+
const t = this.peek();
|
|
1377
|
+
if (kw(t, "CURRENT_USER", "CURRENT_ROLE", "SESSION_USER")) this.fail(`expected ${what} (name the role: ${t!.text.toUpperCase()} is whichever role runs the statement)`);
|
|
1378
|
+
if (kw(t, "PUBLIC")) {
|
|
1379
|
+
const i = this.idx();
|
|
1380
|
+
this.p++;
|
|
1381
|
+
return { name: "public", span: { from: i, to: i + 1, refs: [] } };
|
|
1382
|
+
}
|
|
1383
|
+
const n = this.next();
|
|
1384
|
+
if (!isNameToken(n)) this.fail(`expected ${what}`, n);
|
|
1385
|
+
const i = this.idx(-1);
|
|
1386
|
+
return { name: n.kind === "ref" ? "" : identValue(n), span: { from: i, to: i + 1, refs: n.kind === "ref" ? [n.part] : [] } };
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
private roleList(what?: string): Array<{ name: string; span: Span }> {
|
|
1390
|
+
const out = [this.roleSpec(what)];
|
|
1391
|
+
while (this.acceptPunct(",")) out.push(this.roleSpec(what));
|
|
1392
|
+
return out;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
private policy(): PolicyNode {
|
|
1396
|
+
const { text, span } = this.nameTok("a policy name");
|
|
1397
|
+
this.expect("ON");
|
|
1398
|
+
const node: PolicyNode = { statement: "policy", name: { pieces: [text], span }, table: this.qualifiedName("a table name"), roles: [] };
|
|
1399
|
+
if (this.accept("AS")) {
|
|
1400
|
+
if (this.accept("PERMISSIVE")) node.permissive = true;
|
|
1401
|
+
else {
|
|
1402
|
+
this.expect("RESTRICTIVE");
|
|
1403
|
+
node.permissive = false;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
if (this.accept("FOR")) {
|
|
1407
|
+
if (!this.accept("ALL", "SELECT", "INSERT", "UPDATE", "DELETE")) this.fail("expected ALL, SELECT, INSERT, UPDATE or DELETE");
|
|
1408
|
+
node.command = this.lastSeen()!.text.toUpperCase() as PolicyNode["command"];
|
|
1409
|
+
}
|
|
1410
|
+
if (this.accept("TO")) node.roles = this.roleList();
|
|
1411
|
+
if (this.accept("USING")) {
|
|
1412
|
+
if (!this.isPunct("(")) this.fail("expected '('");
|
|
1413
|
+
node.using = this.parenthesized();
|
|
1414
|
+
}
|
|
1415
|
+
if (this.acceptSeq("WITH", "CHECK")) {
|
|
1416
|
+
if (!this.isPunct("(")) this.fail("expected '('");
|
|
1417
|
+
node.check = this.parenthesized();
|
|
1418
|
+
}
|
|
1419
|
+
return node;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private role(): RoleNode {
|
|
1423
|
+
const { text, span } = this.nameTok("a role name");
|
|
1424
|
+
const node: RoleNode = { statement: "role", name: { pieces: [text], span }, options: [] };
|
|
1425
|
+
this.accept("WITH");
|
|
1426
|
+
const FLAGS = ["SUPERUSER", "NOSUPERUSER", "CREATEDB", "NOCREATEDB", "CREATEROLE", "NOCREATEROLE", "INHERIT", "NOINHERIT", "LOGIN", "NOLOGIN", "REPLICATION", "NOREPLICATION", "BYPASSRLS", "NOBYPASSRLS"];
|
|
1427
|
+
for (;;) {
|
|
1428
|
+
if (this.atEnd()) return node;
|
|
1429
|
+
if (kw(this.peek(), ...FLAGS)) node.options.push({ option: this.next().text.toUpperCase() });
|
|
1430
|
+
else if (this.acceptSeq("CONNECTION", "LIMIT")) {
|
|
1431
|
+
const from = this.idx();
|
|
1432
|
+
if (this.isOp("-")) this.p++;
|
|
1433
|
+
const t = this.next();
|
|
1434
|
+
if (t.kind !== "number" && t.kind !== "ref") this.fail("expected a number", t);
|
|
1435
|
+
node.options.push({ option: "CONNECTION LIMIT", value: this.span(from, t.kind === "ref" ? [t.part] : []) });
|
|
1436
|
+
} else if (kw(this.peek(), "PASSWORD", "ENCRYPTED", "UNENCRYPTED") || this.is("VALID", "UNTIL")) {
|
|
1437
|
+
this.fail("expected a role option (a password is the environment's secret, never a declaration's: set it where the environment's credentials are kept)");
|
|
1438
|
+
} else if (kw(this.peek(), "IN", "ROLE", "ADMIN", "USER", "SYSID")) {
|
|
1439
|
+
this.fail("expected a role option (who is a member of which role is the environment's: grant memberships where the roles are provisioned)");
|
|
1440
|
+
} else return this.fail("expected a role option");
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/** `GRANT|REVOKE privileges ON target TO|FROM roles`, after GRANT or REVOKE; for default privileges, `on` is a plural kind and there are no objects. */
|
|
1445
|
+
private grant(revoke: boolean, defaults: boolean): GrantNode {
|
|
1446
|
+
const grantOptionFor = revoke && this.acceptSeq("GRANT", "OPTION", "FOR");
|
|
1447
|
+
const privileges: GrantNode["privileges"] = [];
|
|
1448
|
+
if (this.accept("ALL")) {
|
|
1449
|
+
this.accept("PRIVILEGES");
|
|
1450
|
+
const columns = this.isPunct("(") ? this.nameList() : [];
|
|
1451
|
+
privileges.push({ privilege: "ALL", columns });
|
|
1452
|
+
} else {
|
|
1453
|
+
const KNOWN = ["SELECT", "INSERT", "UPDATE", "DELETE", "TRUNCATE", "REFERENCES", "TRIGGER", "MAINTAIN", "USAGE", "CREATE", "EXECUTE"];
|
|
1454
|
+
do {
|
|
1455
|
+
const t = this.next();
|
|
1456
|
+
if (!kw(t, ...KNOWN)) {
|
|
1457
|
+
if (t.kind === "ident" || t.kind === "qident" || t.kind === "ref") {
|
|
1458
|
+
this.fail("expected a privilege (a role granted to a role is membership, which is the environment's: grant it where the roles are provisioned)", t);
|
|
1459
|
+
}
|
|
1460
|
+
this.fail("expected a privilege", t);
|
|
1461
|
+
}
|
|
1462
|
+
privileges.push({ privilege: t.text.toUpperCase(), columns: this.isPunct("(") ? this.nameList() : [] });
|
|
1463
|
+
} while (this.acceptPunct(","));
|
|
1464
|
+
}
|
|
1465
|
+
this.expect("ON");
|
|
1466
|
+
const node: GrantNode = { statement: "grant", revoke, grantOptionFor, privileges, on: "TABLE", objects: [], grantees: [], withGrantOption: false, defaults };
|
|
1467
|
+
if (defaults) {
|
|
1468
|
+
if (!this.accept("TABLES", "SEQUENCES", "FUNCTIONS", "ROUTINES", "TYPES", "SCHEMAS")) this.fail("expected TABLES, SEQUENCES, FUNCTIONS, ROUTINES, TYPES or SCHEMAS");
|
|
1469
|
+
node.on = this.lastSeen()!.text.toUpperCase() as GrantTarget;
|
|
1470
|
+
} else {
|
|
1471
|
+
if (this.accept("ALL")) {
|
|
1472
|
+
this.fail("expected an object (ALL ... IN SCHEMA grants on what exists when it runs; grant on each declared object, and use ALTER DEFAULT PRIVILEGES for what is created later)", this.lastSeen());
|
|
1473
|
+
}
|
|
1474
|
+
if (kw(this.peek(), "DATABASE", "DOMAIN", "TYPE", "LANGUAGE", "LARGE", "TABLESPACE", "FOREIGN", "PARAMETER")) {
|
|
1475
|
+
this.fail("expected TABLE, SEQUENCE, SCHEMA, FUNCTION, PROCEDURE or ROUTINE (privileges on this kind of object are not declared)");
|
|
1476
|
+
}
|
|
1477
|
+
if (this.accept("TABLE", "SEQUENCE", "SCHEMA", "FUNCTION", "PROCEDURE", "ROUTINE")) node.on = this.lastSeen()!.text.toUpperCase() as GrantTarget;
|
|
1478
|
+
const routine = node.on === "FUNCTION" || node.on === "PROCEDURE" || node.on === "ROUTINE";
|
|
1479
|
+
do {
|
|
1480
|
+
const name = this.qualifiedName(node.on === "SCHEMA" ? "a schema name" : "an object name");
|
|
1481
|
+
node.objects.push(routine && this.isPunct("(") ? { name, args: this.parenthesized() } : { name });
|
|
1482
|
+
} while (this.acceptPunct(","));
|
|
1483
|
+
}
|
|
1484
|
+
if (revoke) this.expect("FROM");
|
|
1485
|
+
else this.expect("TO");
|
|
1486
|
+
this.accept("GROUP");
|
|
1487
|
+
node.grantees = this.roleList();
|
|
1488
|
+
if (!revoke && this.acceptSeq("WITH", "GRANT", "OPTION")) node.withGrantOption = true;
|
|
1489
|
+
if (this.acceptSeq("GRANTED", "BY")) this.fail("expected the end of the statement (GRANTED BY names another grantor; chant grants as the role it runs as)", this.lastSeen());
|
|
1490
|
+
if (revoke && this.accept("CASCADE", "RESTRICT")) {
|
|
1491
|
+
if (this.lastSeen()!.text.toUpperCase() === "CASCADE") this.fail("expected the end of the statement (CASCADE revokes what others granted from it; revoke each grant)", this.lastSeen());
|
|
1492
|
+
}
|
|
1493
|
+
return node;
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
private defaultPrivileges(): GrantNode {
|
|
1497
|
+
let forRoles: GrantNode["forRoles"];
|
|
1498
|
+
let inSchemas: GrantNode["inSchemas"];
|
|
1499
|
+
for (;;) {
|
|
1500
|
+
if (this.accept("FOR")) {
|
|
1501
|
+
this.expect("ROLE", "USER");
|
|
1502
|
+
forRoles = this.roleList();
|
|
1503
|
+
} else if (this.acceptSeq("IN", "SCHEMA")) {
|
|
1504
|
+
inSchemas = [];
|
|
1505
|
+
do inSchemas.push((({ text, span }) => ({ name: text, span }))(this.nameTok("a schema name")));
|
|
1506
|
+
while (this.acceptPunct(","));
|
|
1507
|
+
} else break;
|
|
1508
|
+
}
|
|
1509
|
+
let node: GrantNode;
|
|
1510
|
+
if (this.accept("GRANT")) node = this.grant(false, true);
|
|
1511
|
+
else {
|
|
1512
|
+
this.expect("REVOKE");
|
|
1513
|
+
node = this.grant(true, true);
|
|
1514
|
+
}
|
|
1515
|
+
return { ...node, ...(forRoles ? { forRoles } : {}), ...(inSchemas ? { inSchemas } : {}) };
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
private rowSecurity(): RowSecurityNode {
|
|
1519
|
+
this.accept("ONLY");
|
|
1520
|
+
const table = this.qualifiedName("a table name");
|
|
1521
|
+
let action: RowSecurityNode["action"];
|
|
1522
|
+
if (this.accept("ENABLE")) action = "ENABLE";
|
|
1523
|
+
else if (this.accept("DISABLE")) action = "DISABLE";
|
|
1524
|
+
else if (this.accept("FORCE")) action = "FORCE";
|
|
1525
|
+
else if (this.acceptSeq("NO", "FORCE")) action = "NO FORCE";
|
|
1526
|
+
else return this.fail("expected ENABLE, DISABLE, FORCE or NO FORCE ROW LEVEL SECURITY (a table's other changes are its CREATE TABLE's)");
|
|
1527
|
+
this.expect("ROW");
|
|
1528
|
+
this.expect("LEVEL");
|
|
1529
|
+
this.expect("SECURITY");
|
|
1530
|
+
return { statement: "rowSecurity", table, action };
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1309
1533
|
private comment(): CommentNode {
|
|
1310
1534
|
this.expect("ON");
|
|
1311
1535
|
let objectType: string;
|
|
@@ -1318,16 +1542,19 @@ class PgParser extends SqlCursor {
|
|
|
1318
1542
|
this.expect("ON");
|
|
1319
1543
|
if (this.accept("DOMAIN")) objectType = "DOMAIN CONSTRAINT";
|
|
1320
1544
|
on = this.qualifiedName("a table name");
|
|
1321
|
-
} else if (this.accept("TRIGGER")) {
|
|
1322
|
-
objectType =
|
|
1323
|
-
target = this.qualifiedName(
|
|
1545
|
+
} else if (this.accept("TRIGGER", "POLICY")) {
|
|
1546
|
+
objectType = this.lastSeen()!.text.toUpperCase();
|
|
1547
|
+
target = this.qualifiedName(`a ${objectType.toLowerCase()} name`);
|
|
1324
1548
|
this.expect("ON");
|
|
1325
1549
|
on = this.qualifiedName("a table name");
|
|
1550
|
+
} else if (this.accept("ROLE")) {
|
|
1551
|
+
objectType = "ROLE";
|
|
1552
|
+
target = this.qualifiedName("a role name");
|
|
1326
1553
|
} else {
|
|
1327
1554
|
if (this.acceptSeq("MATERIALIZED", "VIEW")) objectType = "MATERIALIZED VIEW";
|
|
1328
1555
|
else if (this.accept("TABLE", "COLUMN", "INDEX", "VIEW", "SEQUENCE", "TYPE", "DOMAIN", "SCHEMA", "EXTENSION", "FUNCTION", "PROCEDURE")) {
|
|
1329
1556
|
objectType = this.tokens[this.idx(-1)]!.text.toUpperCase();
|
|
1330
|
-
} else return this.fail("expected TABLE, COLUMN, INDEX, VIEW, MATERIALIZED VIEW, SEQUENCE, TYPE, DOMAIN, SCHEMA, EXTENSION, FUNCTION, PROCEDURE, TRIGGER or CONSTRAINT");
|
|
1557
|
+
} else return this.fail("expected TABLE, COLUMN, INDEX, VIEW, MATERIALIZED VIEW, SEQUENCE, TYPE, DOMAIN, SCHEMA, EXTENSION, FUNCTION, PROCEDURE, TRIGGER, POLICY, ROLE or CONSTRAINT");
|
|
1331
1558
|
target = this.qualifiedName("an object name");
|
|
1332
1559
|
if ((objectType === "FUNCTION" || objectType === "PROCEDURE") && this.isPunct("(")) args = this.parenthesized();
|
|
1333
1560
|
}
|
|
@@ -1357,7 +1584,19 @@ class PgParser extends SqlCursor {
|
|
|
1357
1584
|
}
|
|
1358
1585
|
|
|
1359
1586
|
const describe = (n: StatementNode): string =>
|
|
1360
|
-
n.statement === "comment"
|
|
1587
|
+
n.statement === "comment"
|
|
1588
|
+
? "COMMENT ON"
|
|
1589
|
+
: n.statement === "enum"
|
|
1590
|
+
? "type definition"
|
|
1591
|
+
: n.statement === "grant"
|
|
1592
|
+
? n.defaults
|
|
1593
|
+
? "ALTER DEFAULT PRIVILEGES"
|
|
1594
|
+
: n.revoke
|
|
1595
|
+
? "REVOKE"
|
|
1596
|
+
: "GRANT"
|
|
1597
|
+
: n.statement === "rowSecurity"
|
|
1598
|
+
? "ALTER TABLE"
|
|
1599
|
+
: `${n.statement} definition`;
|
|
1361
1600
|
|
|
1362
1601
|
/**
|
|
1363
1602
|
* Parse a template: one CREATE, optionally followed by COMMENT ON statements,
|
|
@@ -18,6 +18,10 @@ import { keyedByQualifiedName, pgBuildFileMajor, pgSchemaFromBuildFile, pgSchema
|
|
|
18
18
|
import { serverNormalized } from "./server-normalize";
|
|
19
19
|
import { POSTGRES_ENTITY_TYPES } from "../entity-types";
|
|
20
20
|
import { migrationOpSuggestions } from "../migrate/handoff";
|
|
21
|
+
import { POSTGRES_LATEST_MAJOR } from "../../spec/postgres-pin";
|
|
22
|
+
import { ACCESS_MANAGED_KINDS } from "./normalize";
|
|
23
|
+
import { declaredAccess, diffAccess, predictedAccess, targetKey } from "../access/acl";
|
|
24
|
+
import { readLiveAccess } from "../access/live";
|
|
21
25
|
|
|
22
26
|
export function emitPg(diff: PgSchemaDiff, json: boolean, title: string): number {
|
|
23
27
|
console.log(json ? JSON.stringify(diff, null, 2) : renderPgDiff(diff, { title }));
|
|
@@ -69,6 +73,8 @@ export interface PgServerPlan {
|
|
|
69
73
|
diff: PgSchemaDiff;
|
|
70
74
|
/** The major the changes were classified for: the server's, else the one asked for. */
|
|
71
75
|
major?: number;
|
|
76
|
+
/** The schemas the read was limited to; undefined is every schema. */
|
|
77
|
+
scope?: string[];
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
/**
|
|
@@ -83,29 +89,76 @@ export async function planAgainstClient(
|
|
|
83
89
|
build: readonly PgSchemaObject[],
|
|
84
90
|
options: { major?: number; environment?: string; readLive?: typeof readLiveSchema; serverNormalize?: typeof serverNormalized } = {},
|
|
85
91
|
): Promise<PgServerPlan> {
|
|
86
|
-
const
|
|
92
|
+
const managed = target.access === true;
|
|
93
|
+
const { kept, unmanaged } = accessScoped(keyedByQualifiedName(build), managed);
|
|
94
|
+
const declaredRaw = kept;
|
|
87
95
|
const scope = scopeFor(
|
|
88
96
|
target,
|
|
89
|
-
declaredRaw.map((o) => ({ type: POSTGRES_ENTITY_TYPES[o.canonical.kind], props: { name: o.canonical.name, ...(o.canonical.schema ? { schema: o.canonical.schema } : {}) } })),
|
|
97
|
+
declaredRaw.filter((o) => o.canonical.kind !== "role" && o.canonical.kind !== "grant" && o.canonical.kind !== "defaultPrivileges").map((o) => ({ type: POSTGRES_ENTITY_TYPES[o.canonical.kind], props: { name: o.canonical.name, ...(o.canonical.schema ? { schema: o.canonical.schema } : {}) } })),
|
|
90
98
|
);
|
|
91
|
-
const
|
|
99
|
+
const roles = declaredRaw.filter((o) => o.canonical.kind === "role").map((o) => o.canonical.name);
|
|
100
|
+
const liveObjects = markProviderOwned(await (options.readLive ?? readLiveSchema)(client, { schemas: scope, access: managed, roles }), target.provider);
|
|
92
101
|
const live = pgSchemaFromLive(liveObjects, target.defaultSchema);
|
|
93
102
|
const liveByKey = new Map(live.map((o) => [o.key, o]));
|
|
94
103
|
const declared: PgSchemaObject[] = [];
|
|
95
104
|
for (const o of declaredRaw) {
|
|
96
105
|
const l = liveByKey.get(o.key);
|
|
97
|
-
if (l && differs(o, l) && (o.canonical.kind === "table" || o.canonical.kind === "view" || o.canonical.kind === "materializedView" || o.canonical.kind === "trigger")) {
|
|
106
|
+
if (l && differs(o, l) && (o.canonical.kind === "table" || o.canonical.kind === "view" || o.canonical.kind === "materializedView" || o.canonical.kind === "trigger" || o.canonical.kind === "policy")) {
|
|
98
107
|
declared.push({ ...o, canonical: { ...o.canonical, ...(await (options.serverNormalize ?? serverNormalized)(client, o.canonical, target.defaultSchema)) } });
|
|
99
108
|
} else declared.push(o);
|
|
100
109
|
}
|
|
101
110
|
// The build's major, else the server's own when it differs, since the server is what takes the locks.
|
|
102
111
|
const running = await serverMajor(client).catch(() => undefined);
|
|
103
112
|
const major = running ?? options.major;
|
|
104
|
-
const diff = diffPgSchemas(live, declared, { major });
|
|
113
|
+
const diff = diffPgSchemas(live, declared, { major, access: false });
|
|
114
|
+
if (managed) {
|
|
115
|
+
const want = declaredAccess(declared, { major: major ?? POSTGRES_LATEST_MAJOR, ...(await currentRole(client)) });
|
|
116
|
+
const have = await readLiveAccess(client, want.targets, { ...(scope ? { schemas: scope } : {}), forRoles: want.forRoles, declaredDefaults: want.targets.filter((t) => t.kind === "default") });
|
|
117
|
+
// An object this plan creates is given what the server gives a new one.
|
|
118
|
+
const missing = want.targets.filter((t) => t.kind !== "default" && t.kind !== "column" && !have.present.has(targetKey(t)));
|
|
119
|
+
const now = new Map([...have.state, ...predictedAccess(missing, have)]);
|
|
120
|
+
const access = diffAccess(now, want.state, want.exportsOf);
|
|
121
|
+
if (access.length > 0) {
|
|
122
|
+
diff.changes.push(...access.map((a) => a.change));
|
|
123
|
+
diff.access = access;
|
|
124
|
+
}
|
|
125
|
+
} else if (unmanaged > 0) {
|
|
126
|
+
diff.hints.push(`${unmanaged} access declaration${unmanaged === 1 ? " is" : "s are"} not planned: ${options.environment ?? "this environment"}'s profile does not manage access (sql.profiles.<env>.access)`);
|
|
127
|
+
}
|
|
105
128
|
if (running !== undefined && options.major !== undefined && running !== options.major) {
|
|
106
129
|
diff.hints.push(`the build targets Postgres ${options.major} but ${options.environment ?? "the server"} runs Postgres ${running}; changes are classified for ${running}`);
|
|
107
130
|
}
|
|
108
|
-
return { declared, live, liveObjects, diff, ...(major !== undefined ? { major } : {}) };
|
|
131
|
+
return { declared, live, liveObjects, diff, ...(major !== undefined ? { major } : {}), ...(scope ? { scope } : {}) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The role a session runs as: the one whose own privileges owning an object gives, and whose default privileges apply. */
|
|
135
|
+
async function currentRole(client: PostgresClient): Promise<{ self?: string }> {
|
|
136
|
+
const rows = await client.query<{ self: string }>("SELECT current_user AS self").catch(() => []);
|
|
137
|
+
return rows[0]?.self ? { self: rows[0].self } : {};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The declarations a plan compares: all of them where the profile manages
|
|
142
|
+
* access; otherwise none of the policies, roles, grants and default
|
|
143
|
+
* privileges, and no table's row-level security, so a project that manages
|
|
144
|
+
* access elsewhere is not affected. `unmanaged` counts what was left out.
|
|
145
|
+
*/
|
|
146
|
+
export function accessScoped(objects: readonly PgSchemaObject[], managed: boolean): { kept: PgSchemaObject[]; unmanaged: number } {
|
|
147
|
+
if (managed) return { kept: [...objects], unmanaged: 0 };
|
|
148
|
+
let unmanaged = 0;
|
|
149
|
+
const kept: PgSchemaObject[] = [];
|
|
150
|
+
for (const o of objects) {
|
|
151
|
+
if (ACCESS_MANAGED_KINDS.has(o.canonical.kind)) {
|
|
152
|
+
unmanaged++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (o.canonical.kind === "table" && (o.canonical.fields.rowSecurity || o.canonical.fields.forceRowSecurity)) {
|
|
156
|
+
unmanaged++;
|
|
157
|
+
const { rowSecurity: _r, forceRowSecurity: _f, ...fields } = o.canonical.fields;
|
|
158
|
+
kept.push({ ...o, canonical: { ...o.canonical, fields } });
|
|
159
|
+
} else kept.push(o);
|
|
160
|
+
}
|
|
161
|
+
return { kept, unmanaged };
|
|
109
162
|
}
|
|
110
163
|
|
|
111
164
|
/** The declared objects against the server, keys by qualified name and labels with the export name. */
|
|
@@ -116,7 +169,7 @@ export async function planPgAgainstServer(environment: string, buildFile: string
|
|
|
116
169
|
const { declared, diff } = await planAgainstClient(client, target, pgSchemaFromBuildFile(buildFile, target.defaultSchema), { ...(targeted !== undefined ? { major: targeted } : {}), environment });
|
|
117
170
|
const { migrationOps } = withMigrationOps(diff, declared, environment, target.defaultSchema);
|
|
118
171
|
const label = new Map(declared.map((o) => [o.key, `${o.canonical.exportName} (${o.key.slice(o.key.indexOf(" ") + 1)})`]));
|
|
119
|
-
const changes = diff.changes.map((c) => ({ ...c, object: label.get(c.object) ?? c.object }));
|
|
172
|
+
const changes = diff.changes.map((c) => ({ ...c, object: label.get(c.object) ?? (c.object.startsWith("acl ") ? c.object.slice(4) : c.object) }));
|
|
120
173
|
return { changes, hints: diff.hints, refused: changes.filter((c) => c.class === "expand"), ...(migrationOps ? { migrationOps } : {}) };
|
|
121
174
|
} finally {
|
|
122
175
|
await client.end();
|
|
@@ -17,7 +17,7 @@ import { deepObservation, type DeepObservationResult, type DeepResourceObservati
|
|
|
17
17
|
import type { UnobservedEntity } from "@intentius/chant/lexicon";
|
|
18
18
|
import { bindPostgres, classifyPostgresFailure, type BindOptions } from "../live/bind";
|
|
19
19
|
import { liveKey, readLiveSchema, type LivePgObject } from "../live/catalog";
|
|
20
|
-
import { declaredAddress, scopeFor } from "../live/describe-resources";
|
|
20
|
+
import { accessUnobserved, declaredAddress, scopeFor } from "../live/describe-resources";
|
|
21
21
|
import * as tags from "../entities";
|
|
22
22
|
import { POSTGRES_ENTITY_TYPES } from "../entity-types";
|
|
23
23
|
import { canonicalPgObject, columnShape, sameConstraint, type CanonicalPgObject } from "./normalize";
|
|
@@ -39,6 +39,10 @@ const TAG: Record<string, (s: TemplateStringsArray) => { props: object }> = {
|
|
|
39
39
|
[POSTGRES_ENTITY_TYPES.function]: tags.func,
|
|
40
40
|
[POSTGRES_ENTITY_TYPES.procedure]: tags.procedure,
|
|
41
41
|
[POSTGRES_ENTITY_TYPES.trigger]: tags.trigger,
|
|
42
|
+
[POSTGRES_ENTITY_TYPES.policy]: tags.policy,
|
|
43
|
+
[POSTGRES_ENTITY_TYPES.role]: tags.role,
|
|
44
|
+
[POSTGRES_ENTITY_TYPES.grant]: tags.grant,
|
|
45
|
+
[POSTGRES_ENTITY_TYPES.defaultPrivileges]: tags.grant,
|
|
42
46
|
};
|
|
43
47
|
|
|
44
48
|
/** The props of what the server printed, parsed with the declaration's tag. */
|
|
@@ -105,6 +109,14 @@ const PROP_OF_FIELD: Record<string, string[]> = {
|
|
|
105
109
|
deferrable: ["deferrable"],
|
|
106
110
|
initiallyDeferred: ["initiallyDeferred"],
|
|
107
111
|
referencing: ["referencing"],
|
|
112
|
+
restrictive: ["permissive"],
|
|
113
|
+
command: ["command"],
|
|
114
|
+
roles: ["roles"],
|
|
115
|
+
check: ["check"],
|
|
116
|
+
attributes: ["options"],
|
|
117
|
+
connectionLimit: ["connectionLimit"],
|
|
118
|
+
rowSecurity: ["rowSecurity"],
|
|
119
|
+
forceRowSecurity: ["forceRowSecurity"],
|
|
108
120
|
};
|
|
109
121
|
|
|
110
122
|
/** Props keys that hold constraints, by constraint kind. */
|
|
@@ -190,6 +202,8 @@ export async function observeResourcesDeep(
|
|
|
190
202
|
try {
|
|
191
203
|
live = await readLiveSchema(bound.client, {
|
|
192
204
|
schemas: scopeFor(target, declared.filter((x) => x.entity?.entityType.startsWith("Postgres::")).map((x) => ({ type: x.entity!.entityType, props: x.entity!.props }))),
|
|
205
|
+
access: target.access === true,
|
|
206
|
+
roles: declared.filter((x) => x.entity?.entityType === POSTGRES_ENTITY_TYPES.role).map((x) => String(x.entity!.props.name)),
|
|
193
207
|
});
|
|
194
208
|
} catch (err) {
|
|
195
209
|
await bound.client.end();
|
|
@@ -207,6 +221,11 @@ export async function observeResourcesDeep(
|
|
|
207
221
|
unobserved[name] = { type: entity?.entityType ?? "", reason: "unsupported-kind" };
|
|
208
222
|
continue;
|
|
209
223
|
}
|
|
224
|
+
const why = accessUnobserved(entity.entityType, target.access === true);
|
|
225
|
+
if (why) {
|
|
226
|
+
unobserved[name] = { type: entity.entityType, ...why };
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
210
229
|
const { schema, name: objectName, signature } = declaredAddress({ type: entity.entityType, props: entity.props }, target.defaultSchema);
|
|
211
230
|
const o = byKey.get(liveKey(entity.entityType, schema, objectName, signature));
|
|
212
231
|
if (!o) continue;
|
|
@@ -214,8 +233,10 @@ export async function observeResourcesDeep(
|
|
|
214
233
|
const lp = liveProps(o);
|
|
215
234
|
let d = canonicalPgObject(entity.entityType, entity.props, target.defaultSchema);
|
|
216
235
|
const l = canonicalPgObject(o.type, lp, target.defaultSchema);
|
|
217
|
-
// A trigger's WHEN as the server would print
|
|
218
|
-
if (d.kind === "trigger" && d.fields.when !== l.fields.when) d
|
|
236
|
+
// A trigger's WHEN and a policy's expressions as the server would print them, when the rules leave them different.
|
|
237
|
+
if ((d.kind === "trigger" && d.fields.when !== l.fields.when) || (d.kind === "policy" && (d.fields.using !== l.fields.using || d.fields.check !== l.fields.check))) {
|
|
238
|
+
d = await serverNormalized(client, d, target.defaultSchema);
|
|
239
|
+
}
|
|
219
240
|
resources[name] = { type: o.type, physicalId: o.oid, properties: inDeclaredVocabulary(entity.props, lp, d, l) };
|
|
220
241
|
} catch (err) {
|
|
221
242
|
unobserved[name] = { type: entity.entityType, reason: "read-failed", detail: `the server's definition does not parse: ${(err as Error).message}` };
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { classifiedChange, type ChangeSet, type ClassifiedChange } from "../../core/classifier";
|
|
17
17
|
import { matchByIdentity } from "../../core/diff";
|
|
18
18
|
import { PG_CLASSIFIER_RULES, type PgChangeClass, type PgClassifierRuleId } from "./rules";
|
|
19
|
-
import { sameConstraint, serialBase, type CanonicalColumn, type CanonicalConstraint } from "./normalize";
|
|
19
|
+
import { ACCESS_KINDS, sameConstraint, serialBase, type CanonicalColumn, type CanonicalConstraint } from "./normalize";
|
|
20
|
+
import { builtinEntries, declaredAccess, diffAccess, entryKey, targetKey, type AccessChange } from "../access/acl";
|
|
20
21
|
import { pgNamespace, type PgDiffObject, type PgSchemaObject } from "./schema";
|
|
21
22
|
import { POSTGRES_LATEST_MAJOR } from "../../spec/postgres-pin";
|
|
22
23
|
import type { PostgresMigrationOpSuggestion } from "../migrate/handoff";
|
|
@@ -24,6 +25,12 @@ import type { PostgresMigrationOpSuggestion } from "../migrate/handoff";
|
|
|
24
25
|
/** What the diff needs to know beyond the two schemas: the server's major (`sql.postgresMajor`, else the newest pinned). */
|
|
25
26
|
export interface PgDiffOptions {
|
|
26
27
|
major?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Whether to compare the access the two sides declare (grants, revokes,
|
|
30
|
+
* default privileges). On by default, for a diff between two builds; a plan
|
|
31
|
+
* against a server compares the server's access itself (`./commands.ts`).
|
|
32
|
+
*/
|
|
33
|
+
access?: boolean;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
export type PgChange = ClassifiedChange<PgClassifierRuleId, PgChangeClass>;
|
|
@@ -33,12 +40,14 @@ export interface PgSchemaDiff extends ChangeSet<PgChange> {
|
|
|
33
40
|
refused: PgChange[];
|
|
34
41
|
/** The `PostgresMigrationOp` to run for each refused column rename or type change (#3281). */
|
|
35
42
|
migrationOps?: PostgresMigrationOpSuggestion[];
|
|
43
|
+
/** The access changes among `changes`, each with its GRANT, REVOKE or ALTER DEFAULT PRIVILEGES statements (#3681). */
|
|
44
|
+
access?: AccessChange[];
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
const change = (object: string, field: string, rule: PgClassifierRuleId, before?: unknown, after?: unknown, extra: Partial<PgChange> = {}): PgChange =>
|
|
39
48
|
classifiedChange(PG_CLASSIFIER_RULES, object, field, rule, before, after, extra);
|
|
40
49
|
|
|
41
|
-
const qualified = (o: PgDiffObject) => `${o.schema ? `${o.schema}.` : ""}${o.name}${o.kind === "trigger" ? "" : (o.signature ?? "")}`;
|
|
50
|
+
const qualified = (o: PgDiffObject) => `${o.schema ? `${o.schema}.` : ""}${o.name}${o.kind === "trigger" || o.kind === "policy" ? "" : (o.signature ?? "")}`;
|
|
42
51
|
|
|
43
52
|
// ── Columns ────────────────────────────────────────────────────────────
|
|
44
53
|
|
|
@@ -294,7 +303,31 @@ function diffObject(key: string, before: PgDiffObject, after: PgDiffObject, out:
|
|
|
294
303
|
}
|
|
295
304
|
break;
|
|
296
305
|
}
|
|
306
|
+
case "policy": {
|
|
307
|
+
// ALTER POLICY sets the roles, USING and WITH CHECK; the command and AS RESTRICTIVE are fixed, and neither expression can be taken away.
|
|
308
|
+
const definition = ["table", "restrictive", "command", "roles", "using", "check"];
|
|
309
|
+
const changed = definition.filter((k) => !same(before.fields[k], after.fields[k]));
|
|
310
|
+
if (changed.length === 0) break;
|
|
311
|
+
const recreate =
|
|
312
|
+
!same(before.fields.table, after.fields.table) ||
|
|
313
|
+
!same(before.fields.restrictive, after.fields.restrictive) ||
|
|
314
|
+
!same(before.fields.command, after.fields.command) ||
|
|
315
|
+
(before.fields.using !== undefined && after.fields.using === undefined) ||
|
|
316
|
+
(before.fields.check !== undefined && after.fields.check === undefined);
|
|
317
|
+
out.push(
|
|
318
|
+
change(key, changed.join(", "), "SQLPG291", changed.map((k) => `${k} ${show(before.fields[k]) ?? "-"}`).join("; "), changed.map((k) => `${k} ${show(after.fields[k]) ?? "-"}`).join("; "), recreate ? { note: "dropped and created in one transaction: ALTER POLICY cannot change its command, AS RESTRICTIVE or table, or take an expression away" } : {}),
|
|
319
|
+
);
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
case "role":
|
|
323
|
+
field("attributes", "SQLPG294");
|
|
324
|
+
field("connectionLimit", "SQLPG294");
|
|
325
|
+
break;
|
|
297
326
|
case "table":
|
|
327
|
+
if (!same(before.fields.rowSecurity, after.fields.rowSecurity) || !same(before.fields.forceRowSecurity, after.fields.forceRowSecurity)) {
|
|
328
|
+
const state = (o: PgDiffObject) => (o.fields.rowSecurity ? (o.fields.forceRowSecurity ? "enabled, forced" : "enabled") : o.fields.forceRowSecurity ? "disabled, forced" : "disabled");
|
|
329
|
+
out.push(change(key, "rowSecurity", "SQLPG293", state(before), state(after), after.fields.rowSecurity && !before.fields.rowSecurity ? { note: "with row-level security on, a role the table's policies do not let in sees no rows" } : {}));
|
|
330
|
+
}
|
|
298
331
|
diffColumns(key, before, after, out, hints, false, major);
|
|
299
332
|
diffConstraints(key, before, after, out, false);
|
|
300
333
|
field("with", "SQLPG224");
|
|
@@ -334,8 +367,11 @@ export function matchPgObjects(before: readonly PgSchemaObject[], after: readonl
|
|
|
334
367
|
});
|
|
335
368
|
}
|
|
336
369
|
|
|
337
|
-
export function diffPgSchemas(
|
|
370
|
+
export function diffPgSchemas(beforeAll: readonly PgSchemaObject[], afterAll: readonly PgSchemaObject[], options: PgDiffOptions = {}): PgSchemaDiff {
|
|
338
371
|
const major = options.major ?? POSTGRES_LATEST_MAJOR;
|
|
372
|
+
// Grants and default privileges are compared as the access they add up to, below, not one declaration against another.
|
|
373
|
+
const before = beforeAll.filter((o) => !ACCESS_KINDS.has(o.canonical.kind));
|
|
374
|
+
const after = afterAll.filter((o) => !ACCESS_KINDS.has(o.canonical.kind));
|
|
339
375
|
const matches = matchPgObjects(before, after);
|
|
340
376
|
const newTables = createdTables(matches);
|
|
341
377
|
const changes: PgChange[] = [];
|
|
@@ -347,6 +383,8 @@ export function diffPgSchemas(before: readonly PgSchemaObject[], after: readonly
|
|
|
347
383
|
changes.push(change(m.after.key, "index", o.concurrently ? "SQLPG240" : "SQLPG241", undefined, qualified(o)));
|
|
348
384
|
} else if (o.kind === "trigger" && !newTables.has(String(o.fields.table))) {
|
|
349
385
|
changes.push(change(m.after.key, "trigger", "SQLPG283", undefined, `${o.name}${o.signature ?? ""}`));
|
|
386
|
+
} else if (o.kind === "policy" && !newTables.has(String(o.fields.table))) {
|
|
387
|
+
changes.push(change(m.after.key, "policy", "SQLPG290", undefined, `${o.name}${o.signature ?? ""}`));
|
|
350
388
|
} else changes.push(change(m.after.key, o.kind, "SQLPG200", undefined, qualified(o)));
|
|
351
389
|
} else if (m.kind === "dropped") {
|
|
352
390
|
const o = m.before.canonical;
|
|
@@ -354,10 +392,37 @@ export function diffPgSchemas(before: readonly PgSchemaObject[], after: readonly
|
|
|
354
392
|
hints.push(`${m.before.key} is kept by ${o.foreign}; it is not chant's to drop and is left alone`);
|
|
355
393
|
continue;
|
|
356
394
|
}
|
|
395
|
+
if (o.kind === "role") {
|
|
396
|
+
hints.push(`role ${o.name} is no longer declared; a role is the cluster's and is never dropped`);
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
357
399
|
const destructive = o.kind === "table" || o.kind === "materializedView" || o.kind === "sequence";
|
|
358
|
-
const rule = o.kind === "index" ? "SQLPG242" : o.kind === "trigger" ? "SQLPG285" : "SQLPG270";
|
|
359
|
-
changes.push(change(m.before.key, o.kind, rule, o.kind === "trigger" ? `${o.name}${o.signature ?? ""}` : qualified(o), undefined, destructive ? { destructive: true } : {}));
|
|
400
|
+
const rule = o.kind === "index" ? "SQLPG242" : o.kind === "trigger" ? "SQLPG285" : o.kind === "policy" ? "SQLPG292" : "SQLPG270";
|
|
401
|
+
changes.push(change(m.before.key, o.kind, rule, o.kind === "trigger" || o.kind === "policy" ? `${o.name}${o.signature ?? ""}` : qualified(o), undefined, destructive ? { destructive: true } : {}));
|
|
360
402
|
} else diffObject(m.after.key, m.before.canonical, m.after.canonical, changes, hints, major);
|
|
361
403
|
}
|
|
362
|
-
|
|
404
|
+
const access = options.access === false ? [] : buildAccessChanges(beforeAll, afterAll, major);
|
|
405
|
+
changes.push(...access.map((a) => a.change));
|
|
406
|
+
return { changes, hints, refused: changes.filter((c) => c.class === "expand"), ...(access.length > 0 ? { access } : {}) };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* The access change between two builds: on what the newer build decides. An
|
|
411
|
+
* object only it declares starts from what Postgres gives a new one; an
|
|
412
|
+
* object only the older one declared is dropped with its privileges.
|
|
413
|
+
*/
|
|
414
|
+
function buildAccessChanges(before: readonly PgSchemaObject[], after: readonly PgSchemaObject[], major: number): AccessChange[] {
|
|
415
|
+
const b = declaredAccess(before, { major });
|
|
416
|
+
const a = declaredAccess(after, { major });
|
|
417
|
+
const decided = new Set(a.targets.map(targetKey));
|
|
418
|
+
const known = new Set(b.targets.map(targetKey));
|
|
419
|
+
const from = new Map([...b.state].filter(([, e]) => decided.has(targetKey(e.target))));
|
|
420
|
+
for (const t of a.targets) {
|
|
421
|
+
if (known.has(targetKey(t)) || t.kind === "default" || t.kind === "column") continue;
|
|
422
|
+
for (const e of builtinEntries(t.kind)) {
|
|
423
|
+
const key = entryKey(t, e.grantee);
|
|
424
|
+
from.set(key, { target: t, grantee: e.grantee, privileges: new Map([...(from.get(key)?.privileges ?? []), [e.privilege, false]]) });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return diffAccess(from, a.state, a.exportsOf);
|
|
363
428
|
}
|