@intentius/chant-lexicon-sql 0.116.0 → 0.118.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/clickhouse/access.d.ts +132 -0
- package/dist/clickhouse/access.d.ts.map +1 -0
- package/dist/clickhouse/apply/apply.d.ts.map +1 -1
- package/dist/clickhouse/apply/statements.d.ts +2 -0
- package/dist/clickhouse/apply/statements.d.ts.map +1 -1
- package/dist/clickhouse/entities.d.ts +171 -2
- package/dist/clickhouse/entities.d.ts.map +1 -1
- package/dist/clickhouse/http.d.ts +3 -0
- package/dist/clickhouse/http.d.ts.map +1 -1
- package/dist/clickhouse/import/generator.d.ts +28 -0
- package/dist/clickhouse/import/generator.d.ts.map +1 -1
- package/dist/clickhouse/import/ir.d.ts.map +1 -1
- package/dist/clickhouse/import/parser.d.ts +3 -3
- package/dist/clickhouse/import/parser.d.ts.map +1 -1
- package/dist/clickhouse/live/bind.d.ts.map +1 -1
- package/dist/clickhouse/live/catalog.d.ts +41 -6
- package/dist/clickhouse/live/catalog.d.ts.map +1 -1
- package/dist/clickhouse/live/describe-resources.d.ts +3 -2
- package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
- package/dist/clickhouse/parser.d.ts +54 -3
- package/dist/clickhouse/parser.d.ts.map +1 -1
- package/dist/clickhouse/plan/commands.d.ts +3 -1
- package/dist/clickhouse/plan/commands.d.ts.map +1 -1
- package/dist/clickhouse/plan/deep.d.ts.map +1 -1
- package/dist/clickhouse/plan/diff.d.ts +12 -0
- package/dist/clickhouse/plan/diff.d.ts.map +1 -1
- package/dist/clickhouse/plan/normalize.d.ts +61 -1
- package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
- package/dist/clickhouse/plan/report.d.ts.map +1 -1
- package/dist/clickhouse/plan/rules.d.ts +7 -0
- package/dist/clickhouse/plan/rules.d.ts.map +1 -1
- package/dist/clickhouse/plan/schema.d.ts +8 -4
- package/dist/clickhouse/plan/schema.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
- package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/observe.d.ts +7 -3
- package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/op.d.ts +8 -0
- package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
- package/dist/clickhouse/rebuild/steps.d.ts +48 -0
- package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
- package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
- package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
- package/dist/clickhouse/topology.d.ts.map +1 -1
- package/dist/clickhouse.d.ts +1 -1
- package/dist/clickhouse.d.ts.map +1 -1
- package/dist/codegen/classifier-page.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts.map +1 -1
- package/dist/config.d.ts +70 -7
- package/dist/config.d.ts.map +1 -1
- package/dist/core/references.d.ts +4 -2
- package/dist/core/references.d.ts.map +1 -1
- package/dist/core/statements.d.ts +11 -0
- package/dist/core/statements.d.ts.map +1 -1
- package/dist/files/build-root.d.ts +26 -0
- package/dist/files/build-root.d.ts.map +1 -0
- package/dist/files/clickhouse.d.ts +19 -0
- package/dist/files/clickhouse.d.ts.map +1 -0
- package/dist/files/common.d.ts +25 -0
- package/dist/files/common.d.ts.map +1 -0
- package/dist/files/postgres.d.ts +36 -0
- package/dist/files/postgres.d.ts.map +1 -0
- package/dist/import-parser.d.ts +25 -0
- package/dist/import-parser.d.ts.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +8 -8
- package/dist/lint/rules/sqlch001.d.ts +1 -1
- package/dist/lint/rules/sqlch001.d.ts.map +1 -1
- package/dist/lint/rules/sqlch002.d.ts.map +1 -1
- package/dist/lint/rules/sqlpg001.d.ts.map +1 -1
- package/dist/lint/rules/templates.d.ts +1 -1
- package/dist/lint/rules/templates.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 +50 -0
- package/dist/okf/index.md +11 -1
- package/dist/okf/rules/SQLCH001.md +2 -2
- package/dist/okf/types/Dictionary.md +9 -0
- package/dist/okf/types/Function.md +9 -0
- package/dist/okf/types/Grant.md +9 -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/okf/types/Role.md +9 -0
- package/dist/okf/types/RowPolicy.md +9 -0
- package/dist/okf/types/User.md +9 -0
- package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
- package/dist/op/activities/postgres-migration.d.ts.map +1 -1
- package/dist/op/activity-contracts.d.ts +19 -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 +71 -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 +37 -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/client.d.ts +3 -0
- package/dist/postgres/live/client.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/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- package/dist/rules/sqlpg001.ts +7 -4
- package/dist/rules/templates.ts +2 -2
- package/dist/serializer.d.ts +2 -1
- package/dist/serializer.d.ts.map +1 -1
- package/dist/sql-files.d.ts +79 -0
- package/dist/sql-files.d.ts.map +1 -0
- package/dist/token-source.d.ts +88 -0
- package/dist/token-source.d.ts.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/clickhouse/access.test.ts +203 -0
- package/src/clickhouse/access.ts +595 -0
- package/src/clickhouse/apply/apply.ts +29 -20
- package/src/clickhouse/apply/statements.ts +105 -8
- package/src/clickhouse/dictionary.test.ts +177 -0
- package/src/clickhouse/entities.ts +356 -6
- package/src/clickhouse/function.test.ts +91 -0
- package/src/clickhouse/http.ts +11 -2
- package/src/clickhouse/import/generator.ts +81 -46
- package/src/clickhouse/import/import.test.ts +9 -3
- package/src/clickhouse/import/ir.ts +6 -2
- package/src/clickhouse/import/parser.ts +10 -18
- package/src/clickhouse/live/access.e2e.test.ts +218 -0
- package/src/clickhouse/live/bind.ts +17 -6
- package/src/clickhouse/live/catalog.ts +106 -10
- package/src/clickhouse/live/describe-resources.ts +23 -10
- package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
- package/src/clickhouse/live/functions.e2e.test.ts +155 -0
- package/src/clickhouse/live/live.test.ts +2 -2
- package/src/clickhouse/parser.ts +136 -4
- package/src/clickhouse/plan/commands.ts +18 -7
- package/src/clickhouse/plan/deep.ts +71 -11
- package/src/clickhouse/plan/diff.ts +65 -2
- package/src/clickhouse/plan/normalize.ts +200 -6
- package/src/clickhouse/plan/report.ts +14 -3
- package/src/clickhouse/plan/rules.ts +44 -1
- package/src/clickhouse/plan/schema.ts +40 -17
- package/src/clickhouse/plan/server-format.ts +1 -1
- package/src/clickhouse/rebuild/backfill.ts +5 -25
- package/src/clickhouse/rebuild/observe.ts +7 -3
- package/src/clickhouse/rebuild/op.ts +9 -0
- package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
- package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
- package/src/clickhouse/rebuild/steps.ts +136 -1
- package/src/clickhouse/testing/fake-server.ts +2 -0
- package/src/clickhouse/testing/writable-server.ts +2 -0
- package/src/clickhouse/topology.ts +17 -2
- package/src/clickhouse.ts +19 -0
- package/src/codegen/classifier-page.ts +2 -0
- package/src/codegen/registry.ts +10 -0
- package/src/config.ts +22 -3
- package/src/core/references.ts +5 -3
- package/src/core/statements.ts +11 -1
- package/src/files/build-root.ts +62 -0
- package/src/files/clickhouse.ts +52 -0
- package/src/files/common.ts +54 -0
- package/src/files/postgres.ts +371 -0
- package/src/generated/lexicon-sql.json +50 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +14 -1
- package/src/lint/rules/postgres-templates.ts +2 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/sqlpg001.ts +7 -4
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/postgres-template.ts +2 -0
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/mcp/postgres.ts +1 -1
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +8 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activities/postgres-migration.ts +27 -2
- package/src/op/activity-contracts.ts +2 -0
- package/src/plugin.ts +29 -5
- 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/constraint-sequences.e2e.test.ts +179 -0
- package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
- package/src/postgres/apply/statements.test.ts +138 -6
- package/src/postgres/apply/statements.ts +210 -18
- package/src/postgres/entities.ts +287 -11
- package/src/postgres/entity-types.ts +4 -0
- package/src/postgres/import/generator.ts +129 -46
- package/src/postgres/import/live-export.ts +12 -2
- package/src/postgres/live/bind.ts +20 -6
- package/src/postgres/live/catalog.ts +79 -3
- package/src/postgres/live/client.ts +10 -2
- 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/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +49 -5
- 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/serializer.ts +14 -9
- package/src/skill-defs.ts +1 -1
- package/src/skills/chant-sql-postgres-migration.md +3 -1
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- package/src/skills/chant-sql-postgres.md +9 -2
- package/src/skills/chant-sql-rebuild.md +3 -2
- package/src/skills/chant-sql.md +7 -1
- package/src/sql-files.test.ts +188 -0
- package/src/sql-files.ts +218 -0
- package/src/token-source.e2e.test.ts +142 -0
- package/src/token-source.test.ts +119 -0
- package/src/token-source.ts +189 -0
- package/src/validate.ts +10 -0
|
@@ -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
|
}
|
|
@@ -60,6 +60,10 @@ import type {
|
|
|
60
60
|
IndexProps,
|
|
61
61
|
KeyDef,
|
|
62
62
|
SchemaProps,
|
|
63
|
+
DefaultPrivilegesProps,
|
|
64
|
+
GrantProps,
|
|
65
|
+
PolicyProps,
|
|
66
|
+
RoleProps,
|
|
63
67
|
RoutineProps,
|
|
64
68
|
SequenceProps,
|
|
65
69
|
TableProps,
|
|
@@ -67,6 +71,8 @@ import type {
|
|
|
67
71
|
ViewProps,
|
|
68
72
|
} from "../entities";
|
|
69
73
|
import { stringValue } from "../entities";
|
|
74
|
+
import { parseStatements } from "../parser";
|
|
75
|
+
import type { RoutineNode } from "../parser";
|
|
70
76
|
|
|
71
77
|
// ── Types ──────────────────────────────────────────────────────────────
|
|
72
78
|
|
|
@@ -319,7 +325,29 @@ export interface CanonicalConstraint {
|
|
|
319
325
|
comment?: string;
|
|
320
326
|
}
|
|
321
327
|
|
|
322
|
-
export type CanonicalKind =
|
|
328
|
+
export type CanonicalKind =
|
|
329
|
+
| "schema"
|
|
330
|
+
| "table"
|
|
331
|
+
| "index"
|
|
332
|
+
| "view"
|
|
333
|
+
| "materializedView"
|
|
334
|
+
| "sequence"
|
|
335
|
+
| "enum"
|
|
336
|
+
| "domain"
|
|
337
|
+
| "extension"
|
|
338
|
+
| "function"
|
|
339
|
+
| "procedure"
|
|
340
|
+
| "trigger"
|
|
341
|
+
| "policy"
|
|
342
|
+
| "role"
|
|
343
|
+
| "grant"
|
|
344
|
+
| "defaultPrivileges";
|
|
345
|
+
|
|
346
|
+
/** The kinds whose declarations are privileges, compared as the access they add up to (`../access/`), not one by one. */
|
|
347
|
+
export const ACCESS_KINDS: ReadonlySet<CanonicalKind> = new Set(["grant", "defaultPrivileges"]);
|
|
348
|
+
|
|
349
|
+
/** The kinds a profile manages only when it says so (`sql.profiles.<env>.access`). */
|
|
350
|
+
export const ACCESS_MANAGED_KINDS: ReadonlySet<CanonicalKind> = new Set(["policy", "role", "grant", "defaultPrivileges"]);
|
|
323
351
|
|
|
324
352
|
export interface CanonicalPgObject {
|
|
325
353
|
kind: CanonicalKind;
|
|
@@ -350,6 +378,10 @@ const kindOf: Record<string, CanonicalKind> = {
|
|
|
350
378
|
"Postgres::Function": "function",
|
|
351
379
|
"Postgres::Procedure": "procedure",
|
|
352
380
|
"Postgres::Trigger": "trigger",
|
|
381
|
+
"Postgres::Policy": "policy",
|
|
382
|
+
"Postgres::Role": "role",
|
|
383
|
+
"Postgres::Grant": "grant",
|
|
384
|
+
"Postgres::DefaultPrivileges": "defaultPrivileges",
|
|
353
385
|
};
|
|
354
386
|
|
|
355
387
|
const comment = (c: string | undefined): string | undefined => {
|
|
@@ -500,6 +532,38 @@ function routineFields(kind: "function" | "procedure", p: RoutineProps, defaultS
|
|
|
500
532
|
fields.window = p.window === true;
|
|
501
533
|
}
|
|
502
534
|
|
|
535
|
+
/** A routine named with its parameters as a GRANT writes it (`app.f(int, text)`), as the target the catalog names: `app.f(integer,text)`. */
|
|
536
|
+
export function canonicalRoutineTarget(text: string, defaultSchema: string): string {
|
|
537
|
+
const open = text.indexOf("(");
|
|
538
|
+
const name = canonicalName(open < 0 ? text : text.slice(0, open), defaultSchema);
|
|
539
|
+
if (open < 0) return name;
|
|
540
|
+
try {
|
|
541
|
+
// The parameter list read the way a CREATE FUNCTION reads it: modes, names and types.
|
|
542
|
+
const tokens = tokenizeText(`CREATE FUNCTION x${text.slice(open)} RETURNS void LANGUAGE sql AS ''`, 0);
|
|
543
|
+
const [node] = parseStatements(tokens);
|
|
544
|
+
const args = (node as RoutineNode).args.map((a) => ({ mode: (a.mode?.toLowerCase() ?? "in") as RoutineProps["args"][number]["mode"], type: tokens.slice(a.type.from, a.type.to).map((t) => t.text).join("") }));
|
|
545
|
+
return `${name}${routineSignature({ args }, defaultSchema)}`;
|
|
546
|
+
} catch {
|
|
547
|
+
return `${name}${text.slice(open).replace(/\s+/g, "")}`;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** A role's attributes that differ from `CREATE ROLE`'s defaults (NOLOGIN, INHERIT and every other NO...), sorted. */
|
|
552
|
+
const ROLE_DEFAULTS: Readonly<Record<string, boolean>> = { superuser: false, createdb: false, createrole: false, inherit: true, login: false, replication: false, bypassrls: false };
|
|
553
|
+
|
|
554
|
+
export function canonicalRoleAttributes(options: readonly string[]): string[] {
|
|
555
|
+
const set: Record<string, boolean> = { ...ROLE_DEFAULTS };
|
|
556
|
+
for (const o of options) {
|
|
557
|
+
const word = o.toLowerCase();
|
|
558
|
+
if (word.startsWith("no") && word.slice(2) in ROLE_DEFAULTS) set[word.slice(2)] = false;
|
|
559
|
+
else if (word in ROLE_DEFAULTS) set[word] = true;
|
|
560
|
+
}
|
|
561
|
+
return Object.entries(set)
|
|
562
|
+
.filter(([k, v]) => ROLE_DEFAULTS[k] !== v)
|
|
563
|
+
.map(([k, v]) => (v ? k : `no${k}`))
|
|
564
|
+
.sort();
|
|
565
|
+
}
|
|
566
|
+
|
|
503
567
|
/** The order the catalog prints a trigger's events in. */
|
|
504
568
|
const EVENT_ORDER = ["insert", "delete", "update", "truncate"];
|
|
505
569
|
|
|
@@ -538,7 +602,7 @@ function triggerFields(p: TriggerProps, defaultSchema: string, fields: Record<st
|
|
|
538
602
|
export function canonicalPgObject(entityType: string, props: Record<string, unknown>, defaultSchema = "public"): CanonicalPgObject {
|
|
539
603
|
const kind = kindOf[entityType];
|
|
540
604
|
if (!kind) throw new Error(`not a Postgres entity type: ${entityType}`);
|
|
541
|
-
const schemaOf = (p: { schema?: string }) => (kind === "schema" || kind === "extension" ? undefined : (p.schema ?? defaultSchema));
|
|
605
|
+
const schemaOf = (p: { schema?: string }) => (kind === "schema" || kind === "extension" || kind === "role" || ACCESS_KINDS.has(kind) ? undefined : (p.schema ?? defaultSchema));
|
|
542
606
|
const base: Pick<CanonicalPgObject, "kind" | "schema" | "name" | "signature"> = { kind, schema: schemaOf(props as { schema?: string }), name: String(props.name) };
|
|
543
607
|
const fields: Record<string, unknown> = { comment: comment(props.comment as string | undefined) };
|
|
544
608
|
const columns: CanonicalColumn[] = [];
|
|
@@ -572,6 +636,53 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
|
|
|
572
636
|
base.signature = ` ON ${String(fields.table)}`;
|
|
573
637
|
break;
|
|
574
638
|
}
|
|
639
|
+
case "policy": {
|
|
640
|
+
const p = props as unknown as PolicyProps;
|
|
641
|
+
fields.table = canonicalName(p.tableName, defaultSchema);
|
|
642
|
+
base.signature = ` ON ${String(fields.table)}`;
|
|
643
|
+
fields.restrictive = p.permissive === false;
|
|
644
|
+
fields.command = p.command && p.command !== "all" ? p.command : undefined;
|
|
645
|
+
const roles = [...new Set(p.roles.map((r) => r.toLowerCase() === "public" ? "public" : quoteIdent(r)))].sort();
|
|
646
|
+
fields.roles = roles.length === 1 && roles[0] === "public" ? undefined : roles;
|
|
647
|
+
fields.using = canonicalExpr(p.using);
|
|
648
|
+
fields.check = canonicalExpr(p.check);
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
case "role": {
|
|
652
|
+
const p = props as unknown as RoleProps;
|
|
653
|
+
const attributes = canonicalRoleAttributes(p.options);
|
|
654
|
+
fields.attributes = attributes.length > 0 ? attributes : undefined;
|
|
655
|
+
fields.connectionLimit = p.connectionLimit !== undefined && Number(p.connectionLimit) !== -1 ? String(Number(p.connectionLimit)) : undefined;
|
|
656
|
+
break;
|
|
657
|
+
}
|
|
658
|
+
case "grant": {
|
|
659
|
+
const p = props as unknown as GrantProps;
|
|
660
|
+
const routine = p.on === "function" || p.on === "procedure" || p.on === "routine";
|
|
661
|
+
fields.grant = {
|
|
662
|
+
action: p.action,
|
|
663
|
+
on: p.on,
|
|
664
|
+
objects: p.objectNames.map((n) => (routine ? canonicalRoutineTarget(n, defaultSchema) : p.on === "schema" ? canonicalName(n) : canonicalName(n, defaultSchema))),
|
|
665
|
+
privileges: p.privileges.map((x) => ({ privilege: x.privilege.toLowerCase(), ...(x.columns ? { columns: x.columns } : {}) })),
|
|
666
|
+
grantees: p.grantees,
|
|
667
|
+
...(p.withGrantOption ? { withGrantOption: true } : {}),
|
|
668
|
+
...(p.grantOptionFor ? { grantOptionFor: true } : {}),
|
|
669
|
+
};
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
case "defaultPrivileges": {
|
|
673
|
+
const p = props as unknown as DefaultPrivilegesProps;
|
|
674
|
+
fields.grant = {
|
|
675
|
+
action: p.action,
|
|
676
|
+
on: p.on,
|
|
677
|
+
privileges: p.privileges.map((x) => ({ privilege: x.toLowerCase() })),
|
|
678
|
+
grantees: p.grantees,
|
|
679
|
+
...(p.forRoles ? { forRoles: p.forRoles } : {}),
|
|
680
|
+
...(p.inSchemas ? { inSchemas: p.inSchemas } : {}),
|
|
681
|
+
...(p.withGrantOption ? { withGrantOption: true } : {}),
|
|
682
|
+
...(p.grantOptionFor ? { grantOptionFor: true } : {}),
|
|
683
|
+
};
|
|
684
|
+
break;
|
|
685
|
+
}
|
|
575
686
|
case "domain": {
|
|
576
687
|
const p = props as unknown as DomainProps;
|
|
577
688
|
fields.dataType = canonicalType(p.dataType, defaultSchema);
|
|
@@ -681,6 +792,8 @@ export function canonicalPgObject(entityType: string, props: Record<string, unkn
|
|
|
681
792
|
fields.using = p.using && p.using.toLowerCase() !== "heap" ? p.using.toLowerCase() : undefined;
|
|
682
793
|
fields.with = canonicalOptions(p.with);
|
|
683
794
|
fields.tablespace = p.tablespace;
|
|
795
|
+
fields.rowSecurity = p.rowSecurity === true;
|
|
796
|
+
fields.forceRowSecurity = p.forceRowSecurity === true;
|
|
684
797
|
break;
|
|
685
798
|
}
|
|
686
799
|
}
|
|
@@ -82,7 +82,7 @@ describe("columns", () => {
|
|
|
82
82
|
expect(rules(diff([table(ORDERS)], [table("CREATE TABLE app.orders (id bigint PRIMARY KEY, amount numeric(12,2) DEFAULT 0, note varchar(20) NOT NULL, status text)")]))).toEqual([
|
|
83
83
|
["columns.amount.default", "SQLPG209", "metadata"],
|
|
84
84
|
["columns.amount.notNull", "SQLPG211", "metadata"],
|
|
85
|
-
["columns.note.notNull", "SQLPG210", "
|
|
85
|
+
["columns.note.notNull", "SQLPG210", "validate"],
|
|
86
86
|
]);
|
|
87
87
|
const renamed = diff([table(ORDERS)], [table(`CREATE TABLE app.orders (id bigint PRIMARY KEY, amount numeric(12,2) NOT NULL, note varchar(20),
|
|
88
88
|
state text -- previously: status
|
|
@@ -113,7 +113,7 @@ describe("constraints", () => {
|
|
|
113
113
|
);
|
|
114
114
|
expect(rules(d)).toEqual([
|
|
115
115
|
["constraints.unique", "SQLPG221", "concurrently"],
|
|
116
|
-
["constraints.a", "SQLPG218", "
|
|
116
|
+
["constraints.a", "SQLPG218", "validate"],
|
|
117
117
|
["constraints.b", "SQLPG217", "metadata"],
|
|
118
118
|
["constraints.c", "SQLPG219", "validate"],
|
|
119
119
|
["constraints.exclude", "SQLPG222", "rewrite"],
|
|
@@ -109,9 +109,9 @@ export const PG_CLASSIFIER_RULES = {
|
|
|
109
109
|
SQLPG209: rule("SQLPG209", "metadata", "Change a column's default", "SET DEFAULT and DROP DEFAULT change only the catalog; existing rows keep their values.", ALTER_TABLE),
|
|
110
110
|
SQLPG210: rule(
|
|
111
111
|
"SQLPG210",
|
|
112
|
-
"
|
|
112
|
+
"validate",
|
|
113
113
|
"Set NOT NULL",
|
|
114
|
-
"SET NOT NULL reads the whole table under ACCESS EXCLUSIVE, unless a valid CHECK (column IS NOT NULL) constraint already proves it (since 12).
|
|
114
|
+
"SET NOT NULL alone reads the whole table under ACCESS EXCLUSIVE, unless a valid CHECK (column IS NOT NULL) constraint already proves it (since 12). So it is made as four statements: that check added NOT VALID, validated under SHARE UPDATE EXCLUSIVE while writes go on, SET NOT NULL, which the check proves without a scan, and the check dropped. Rows where the column is NULL fail the validation; the first statement's pre-check counts them.",
|
|
115
115
|
ALTER_TABLE,
|
|
116
116
|
),
|
|
117
117
|
SQLPG211: rule("SQLPG211", "metadata", "Drop NOT NULL", "DROP NOT NULL changes only the catalog.", ALTER_TABLE),
|
|
@@ -135,16 +135,16 @@ export const PG_CLASSIFIER_RULES = {
|
|
|
135
135
|
),
|
|
136
136
|
SQLPG218: rule(
|
|
137
137
|
"SQLPG218",
|
|
138
|
-
"
|
|
138
|
+
"validate",
|
|
139
139
|
"Add a CHECK constraint",
|
|
140
|
-
"ADD CONSTRAINT ... CHECK reads every row under ACCESS EXCLUSIVE.
|
|
140
|
+
"ADD CONSTRAINT ... CHECK alone reads every row under ACCESS EXCLUSIVE. On a table that exists it is made as two statements: the check added NOT VALID (SQLPG217), which reads no rows, then VALIDATE CONSTRAINT (SQLPG220) in a transaction of its own, which reads them under SHARE UPDATE EXCLUSIVE while writes go on. Rows the check refuses fail the validation; the first statement's pre-check counts them.",
|
|
141
141
|
ALTER_TABLE,
|
|
142
142
|
),
|
|
143
143
|
SQLPG219: rule(
|
|
144
144
|
"SQLPG219",
|
|
145
145
|
"validate",
|
|
146
146
|
"Add a foreign key",
|
|
147
|
-
"ADD FOREIGN KEY reads every row under SHARE ROW EXCLUSIVE on both tables, which blocks writes to them for the scan.
|
|
147
|
+
"ADD FOREIGN KEY alone reads every row under SHARE ROW EXCLUSIVE on both tables, which blocks writes to them for the scan. On a table that exists it is made as two statements: the key added NOT VALID (SQLPG217), which takes that lock briefly and reads no rows, then VALIDATE CONSTRAINT (SQLPG220) in a transaction of its own, which reads them under SHARE UPDATE EXCLUSIVE while writes go on. Orphan rows fail the validation; the first statement's pre-check counts them.",
|
|
148
148
|
ALTER_TABLE,
|
|
149
149
|
),
|
|
150
150
|
SQLPG220: rule(
|
|
@@ -306,6 +306,50 @@ export const PG_CLASSIFIER_RULES = {
|
|
|
306
306
|
`${DOCS}/sql-createtrigger.html`,
|
|
307
307
|
),
|
|
308
308
|
SQLPG285: rule("SQLPG285", "drop", "Drop a trigger", "DROP TRIGGER takes ACCESS EXCLUSIVE on its table, briefly; no row is read or lost, and the table's writes no longer fire it.", `${DOCS}/sql-droptrigger.html`),
|
|
309
|
+
SQLPG290: rule(
|
|
310
|
+
"SQLPG290",
|
|
311
|
+
"metadata",
|
|
312
|
+
"Create a policy on an existing table",
|
|
313
|
+
"CREATE POLICY takes ACCESS EXCLUSIVE on its table, briefly; no row is read. Where row-level security is on, the rows each role sees change when the transaction commits.",
|
|
314
|
+
`${DOCS}/sql-createpolicy.html`,
|
|
315
|
+
),
|
|
316
|
+
SQLPG291: rule(
|
|
317
|
+
"SQLPG291",
|
|
318
|
+
"metadata",
|
|
319
|
+
"Change a policy",
|
|
320
|
+
"ALTER POLICY changes its roles, USING and WITH CHECK under ACCESS EXCLUSIVE on the table, briefly. Its command, AS RESTRICTIVE and table cannot be altered, nor an expression taken away: the policy is dropped and created in one transaction, so no session sees the table without it.",
|
|
321
|
+
`${DOCS}/sql-alterpolicy.html`,
|
|
322
|
+
),
|
|
323
|
+
SQLPG292: rule("SQLPG292", "drop", "Drop a policy", "DROP POLICY takes ACCESS EXCLUSIVE on its table, briefly; the rows it let a role see are hidden from that role at once, or, if it was the last policy, every non-owner sees none.", `${DOCS}/sql-droppolicy.html`),
|
|
324
|
+
SQLPG293: rule(
|
|
325
|
+
"SQLPG293",
|
|
326
|
+
"metadata",
|
|
327
|
+
"Turn row-level security on or off, or force it on the owner",
|
|
328
|
+
"ALTER TABLE ... ENABLE, DISABLE, FORCE or NO FORCE ROW LEVEL SECURITY changes only the catalog, under ACCESS EXCLUSIVE, briefly. With it on and no policy that lets a role in, that role sees no rows; FORCE subjects the table's owner too.",
|
|
329
|
+
`${DOCS}/ddl-rowsecurity.html`,
|
|
330
|
+
),
|
|
331
|
+
SQLPG294: rule("SQLPG294", "metadata", "Change a role's attributes", "ALTER ROLE changes the role in the cluster's catalog, for every database on the server, at once; sessions already open keep the attributes they started with until they reconnect.", `${DOCS}/sql-alterrole.html`),
|
|
332
|
+
SQLPG296: rule(
|
|
333
|
+
"SQLPG296",
|
|
334
|
+
"metadata",
|
|
335
|
+
"Grant a privilege",
|
|
336
|
+
"GRANT adds to the object's privileges in the catalog; no row is read and the role can use it from its next statement.",
|
|
337
|
+
`${DOCS}/sql-grant.html`,
|
|
338
|
+
),
|
|
339
|
+
SQLPG297: rule(
|
|
340
|
+
"SQLPG297",
|
|
341
|
+
"metadata",
|
|
342
|
+
"Revoke a privilege",
|
|
343
|
+
"REVOKE removes from the object's privileges in the catalog. The role loses it at once: its next statement that needs it fails, so move every reader off it first.",
|
|
344
|
+
`${DOCS}/sql-revoke.html`,
|
|
345
|
+
),
|
|
346
|
+
SQLPG298: rule(
|
|
347
|
+
"SQLPG298",
|
|
348
|
+
"metadata",
|
|
349
|
+
"Change default privileges",
|
|
350
|
+
"ALTER DEFAULT PRIVILEGES changes what objects created afterwards are given; objects that exist keep their privileges.",
|
|
351
|
+
`${DOCS}/sql-alterdefaultprivileges.html`,
|
|
352
|
+
),
|
|
309
353
|
SQLPG270: rule("SQLPG270", "drop", "Drop an object", "The object and, for a table, a materialized view or a sequence, its data are gone.", `${DOCS}/sql-droptable.html`),
|
|
310
354
|
} as const satisfies Record<string, PgClassifierRule>;
|
|
311
355
|
|
|
@@ -100,10 +100,12 @@ export function pgSchemaFromBuildFile(path: string, defaultSchema = "public"): P
|
|
|
100
100
|
* The namespace an object's name is unique in: relations (tables, views,
|
|
101
101
|
* sequences, indexes) share one per schema, types and domains another,
|
|
102
102
|
* functions and procedures a third (told apart by their parameter types),
|
|
103
|
-
*
|
|
103
|
+
* a trigger's or a policy's name is unique on its table, and a role's in the
|
|
104
|
+
* cluster.
|
|
104
105
|
*/
|
|
105
106
|
export function pgNamespace(kind: string): string {
|
|
106
|
-
if (kind === "schema" || kind === "extension" || kind === "trigger") return kind;
|
|
107
|
+
if (kind === "schema" || kind === "extension" || kind === "trigger" || kind === "policy" || kind === "role") return kind;
|
|
108
|
+
if (kind === "grant" || kind === "defaultPrivileges") return "grant";
|
|
107
109
|
if (kind === "enum" || kind === "domain") return "type";
|
|
108
110
|
if (kind === "function" || kind === "procedure") return "routine";
|
|
109
111
|
return "relation";
|