@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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
|
-
import { alterSteps, columnDefinition, constraintSql, createSteps, dropStatement, splitStatements, type PgStep } from "./statements";
|
|
2
|
+
import { alterSteps, columnDefinition, constraintSql, createSteps, dropStatement, planPgStatements, splitStatements, type PgStep } from "./statements";
|
|
3
3
|
import { declaredObjects } from "./apply";
|
|
4
4
|
import { keyedByQualifiedName, pgSchemaFromLive, type PgSchemaObject } from "../plan/schema";
|
|
5
5
|
import { diffPgSchemas, matchPgObjects } from "../plan/diff";
|
|
@@ -31,7 +31,10 @@ describe("columns", () => {
|
|
|
31
31
|
"ALTER TABLE app.t ALTER COLUMN b DROP DEFAULT",
|
|
32
32
|
]);
|
|
33
33
|
expect(sql(steps(T, "CREATE TABLE app.t (a text, b text NOT NULL)", "CREATE TABLE app.t (a text NOT NULL, b text)"))).toEqual([
|
|
34
|
+
"ALTER TABLE app.t ADD CONSTRAINT a__chant_nn CHECK (a IS NOT NULL) NOT VALID",
|
|
35
|
+
"ALTER TABLE app.t VALIDATE CONSTRAINT a__chant_nn",
|
|
34
36
|
"ALTER TABLE app.t ALTER COLUMN a SET NOT NULL",
|
|
37
|
+
"ALTER TABLE app.t DROP CONSTRAINT a__chant_nn",
|
|
35
38
|
"ALTER TABLE app.t ALTER COLUMN b DROP NOT NULL",
|
|
36
39
|
]);
|
|
37
40
|
});
|
|
@@ -62,19 +65,52 @@ describe("constraints", () => {
|
|
|
62
65
|
expect(added.steps[0]).toMatchObject({ class: "metadata", rule: "SQLPG217" });
|
|
63
66
|
const validated = steps(T, "CREATE TABLE app.t (a int, CONSTRAINT a_pos CHECK (a > 0) NOT VALID)", "CREATE TABLE app.t (a int, CONSTRAINT a_pos CHECK (a > 0))");
|
|
64
67
|
expect(sql(validated)).toEqual(["ALTER TABLE app.t VALIDATE CONSTRAINT a_pos"]);
|
|
65
|
-
expect(validated.steps[0]).toMatchObject({ class: "validate", rule: "SQLPG220" });
|
|
68
|
+
expect(validated.steps[0]).toMatchObject({ class: "validate", rule: "SQLPG220", precheck: { sql: "SELECT count(*) AS n FROM app.t WHERE NOT (a > 0)" } });
|
|
69
|
+
expect(added.steps[0]!.precheck).toBeUndefined();
|
|
66
70
|
});
|
|
67
71
|
|
|
68
|
-
test("a column-level foreign key is added as a table constraint", () => {
|
|
72
|
+
test("a column-level foreign key is added NOT VALID as a table constraint, then validated", () => {
|
|
69
73
|
const r = steps(T, "CREATE TABLE app.o (u bigint)", "CREATE TABLE app.o (u bigint REFERENCES app.users (id) ON DELETE CASCADE)");
|
|
70
|
-
expect(sql(r)).toEqual([
|
|
71
|
-
expect(r.steps[0]).toMatchObject({
|
|
74
|
+
expect(sql(r)).toEqual([expect.stringMatching(/^ALTER TABLE app\.o ADD CONSTRAINT o_[0-9a-f]{8}_fkey FOREIGN KEY \(u\) REFERENCES app\.users \(id\) ON DELETE CASCADE NOT VALID$/), expect.stringMatching(/^ALTER TABLE app\.o VALIDATE CONSTRAINT o_[0-9a-f]{8}_fkey$/)]);
|
|
75
|
+
expect(r.steps[0]).toMatchObject({
|
|
76
|
+
class: "metadata",
|
|
77
|
+
rule: "SQLPG217",
|
|
78
|
+
precheck: { sql: "SELECT count(*) AS n FROM app.o AS c WHERE c.u IS NOT NULL AND NOT EXISTS (SELECT 1 FROM app.users AS p WHERE p.id = c.u)" },
|
|
79
|
+
});
|
|
80
|
+
expect(r.steps[1]).toMatchObject({ class: "validate", rule: "SQLPG220", transactional: true });
|
|
81
|
+
expect(r.steps[1]!.precheck).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a CHECK on a table that exists is added NOT VALID, then validated (#3703)", () => {
|
|
85
|
+
const r = steps(T, "CREATE TABLE app.t (a int)", "CREATE TABLE app.t (a int, CONSTRAINT a_pos CHECK (a > 0))");
|
|
86
|
+
expect(r.steps.map((s) => [s.sql, s.class, s.rule])).toEqual([
|
|
87
|
+
["ALTER TABLE app.t ADD CONSTRAINT a_pos CHECK (a > 0) NOT VALID", "metadata", "SQLPG217"],
|
|
88
|
+
["ALTER TABLE app.t VALIDATE CONSTRAINT a_pos", "validate", "SQLPG220"],
|
|
89
|
+
]);
|
|
90
|
+
// Interrupted after the first statement: the server holds it NOT VALID, and the next run only validates it.
|
|
91
|
+
const resumed = steps(T, "CREATE TABLE app.t (a int, CONSTRAINT a_pos CHECK (a > 0) NOT VALID)", "CREATE TABLE app.t (a int, CONSTRAINT a_pos CHECK (a > 0))");
|
|
92
|
+
expect(resumed.steps.map((s) => [s.sql, s.class, s.rule])).toEqual([["ALTER TABLE app.t VALIDATE CONSTRAINT a_pos", "validate", "SQLPG220"]]);
|
|
93
|
+
// An unnamed check is given a name for VALIDATE, and that named constraint still matches the unnamed declaration.
|
|
94
|
+
const unnamed = steps(T, "CREATE TABLE app.t (a int)", "CREATE TABLE app.t (a int CHECK (a > 0))");
|
|
95
|
+
expect(sql(unnamed)).toEqual([expect.stringMatching(/^ALTER TABLE app\.t ADD CONSTRAINT t_[0-9a-f]{8}_check CHECK \(a > 0\) NOT VALID$/), expect.stringMatching(/^ALTER TABLE app\.t VALIDATE CONSTRAINT t_[0-9a-f]{8}_check$/)]);
|
|
96
|
+
const given = /CONSTRAINT (\S+)/.exec(unnamed.steps[0]!.sql)![1];
|
|
97
|
+
expect(steps(T, `CREATE TABLE app.t (a int, CONSTRAINT ${given} CHECK (a > 0) NOT VALID)`, "CREATE TABLE app.t (a int CHECK (a > 0))").steps.map((s) => s.sql)).toEqual([`ALTER TABLE app.t VALIDATE CONSTRAINT ${given}`]);
|
|
72
98
|
});
|
|
73
99
|
|
|
74
100
|
test("a unique constraint is an index built CONCURRENTLY, then the constraint made from it", () => {
|
|
75
101
|
const r = steps(T, "CREATE TABLE app.t (email text)", "CREATE TABLE app.t (email text UNIQUE)");
|
|
76
102
|
expect(r.steps).toEqual([
|
|
77
|
-
{
|
|
103
|
+
{
|
|
104
|
+
sql: "CREATE UNIQUE INDEX CONCURRENTLY t_email_key ON app.t (email)",
|
|
105
|
+
class: "concurrently",
|
|
106
|
+
rule: "SQLPG221",
|
|
107
|
+
transactional: false,
|
|
108
|
+
buildsIndex: "app.t_email_key",
|
|
109
|
+
precheck: {
|
|
110
|
+
sql: "SELECT (SELECT count(*) FROM (SELECT 1 FROM app.t WHERE email IS NOT NULL GROUP BY email HAVING count(*) > 1) AS duplicates) AS n",
|
|
111
|
+
detail: "values of (email) held by more than one row, which a unique index fails on",
|
|
112
|
+
},
|
|
113
|
+
},
|
|
78
114
|
{ sql: "ALTER TABLE app.t ADD CONSTRAINT t_email_key UNIQUE USING INDEX t_email_key", class: "metadata", rule: "SQLPG221", transactional: true },
|
|
79
115
|
]);
|
|
80
116
|
});
|
|
@@ -153,3 +189,99 @@ describe("create and drop", () => {
|
|
|
153
189
|
]);
|
|
154
190
|
});
|
|
155
191
|
});
|
|
192
|
+
|
|
193
|
+
describe("safe sequences and pre-checks (#3686)", () => {
|
|
194
|
+
test("SET NOT NULL on a table that exists is a NOT VALID check, its validation, SET NOT NULL and the check dropped, each classified", () => {
|
|
195
|
+
const r = steps(T, "CREATE TABLE app.t (a text)", "CREATE TABLE app.t (a text NOT NULL)");
|
|
196
|
+
expect(r.steps.map((s) => [s.sql, s.class, s.rule, s.transactional])).toEqual([
|
|
197
|
+
["ALTER TABLE app.t ADD CONSTRAINT a__chant_nn CHECK (a IS NOT NULL) NOT VALID", "metadata", "SQLPG217", true],
|
|
198
|
+
["ALTER TABLE app.t VALIDATE CONSTRAINT a__chant_nn", "validate", "SQLPG220", true],
|
|
199
|
+
["ALTER TABLE app.t ALTER COLUMN a SET NOT NULL", "metadata", "SQLPG210", true],
|
|
200
|
+
["ALTER TABLE app.t DROP CONSTRAINT a__chant_nn", "metadata", "SQLPG223", true],
|
|
201
|
+
]);
|
|
202
|
+
// The NULL count goes with the first statement, so a caller checks it before anything runs.
|
|
203
|
+
expect(r.steps[0]!.precheck).toEqual({ sql: "SELECT count(*) AS n FROM app.t WHERE a IS NULL", detail: "rows where a is NULL, which SET NOT NULL fails on" });
|
|
204
|
+
expect(r.steps.slice(1).every((s) => s.precheck === undefined)).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("the plan classes the change as a validation, the same on every supported major", () => {
|
|
208
|
+
for (const major of [14, 18]) {
|
|
209
|
+
expect(sql(steps(T, "CREATE TABLE app.t (a text)", "CREATE TABLE app.t (a text NOT NULL)", major))).toHaveLength(4);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("a check left by an interrupted run is validated as found, not added again or dropped as undeclared", () => {
|
|
214
|
+
const r = steps(T, "CREATE TABLE app.t (a text, CONSTRAINT a__chant_nn CHECK (a IS NOT NULL) NOT VALID)", "CREATE TABLE app.t (a text NOT NULL)");
|
|
215
|
+
expect(sql(r)).toEqual([
|
|
216
|
+
"ALTER TABLE app.t VALIDATE CONSTRAINT a__chant_nn",
|
|
217
|
+
"ALTER TABLE app.t ALTER COLUMN a SET NOT NULL",
|
|
218
|
+
"ALTER TABLE app.t DROP CONSTRAINT a__chant_nn",
|
|
219
|
+
]);
|
|
220
|
+
expect(r.steps[0]!.precheck?.sql).toBe("SELECT count(*) AS n FROM app.t WHERE a IS NULL");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("a valid declared check that proves NOT NULL keeps SET NOT NULL to one statement", () => {
|
|
224
|
+
const r = steps(T, "CREATE TABLE app.t (a text, CONSTRAINT a_nn CHECK (a IS NOT NULL))", "CREATE TABLE app.t (a text NOT NULL, CONSTRAINT a_nn CHECK (a IS NOT NULL))");
|
|
225
|
+
expect(r.steps.map((s) => [s.sql, s.class])).toEqual([["ALTER TABLE app.t ALTER COLUMN a SET NOT NULL", "metadata"]]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("a primary key counts duplicates and NULL keys; NULLS NOT DISTINCT counts NULLs as equal", () => {
|
|
229
|
+
const pk = steps(T, "CREATE TABLE app.t (a int, b int)", "CREATE TABLE app.t (a int, b int, PRIMARY KEY (a, b))");
|
|
230
|
+
// The key's columns become NOT NULL first, each with its own sequence and NULL count.
|
|
231
|
+
expect(pk.steps.filter((s) => s.precheck).map((s) => s.rule)).toEqual(["SQLPG217", "SQLPG217", "SQLPG221"]);
|
|
232
|
+
expect(pk.steps.find((s) => s.rule === "SQLPG221")!.precheck?.sql).toBe(
|
|
233
|
+
"SELECT (SELECT count(*) FROM (SELECT 1 FROM app.t GROUP BY a, b HAVING count(*) > 1) AS duplicates) + (SELECT count(*) FROM app.t WHERE a IS NULL OR b IS NULL) AS n",
|
|
234
|
+
);
|
|
235
|
+
const nnd = steps(T, "CREATE TABLE app.t (a int)", "CREATE TABLE app.t (a int, UNIQUE NULLS NOT DISTINCT (a))");
|
|
236
|
+
expect(nnd.steps[0]!.precheck?.sql).toBe("SELECT (SELECT count(*) FROM (SELECT 1 FROM app.t GROUP BY a HAVING count(*) > 1) AS duplicates) AS n");
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a CHECK added valid counts the rows it refuses; a NOT ENFORCED one has nothing to check", () => {
|
|
240
|
+
const r = steps(T, "CREATE TABLE app.t (a int)", "CREATE TABLE app.t (a int, CONSTRAINT a_pos CHECK (a > 0))");
|
|
241
|
+
expect(r.steps[0]).toMatchObject({ rule: "SQLPG217", precheck: { sql: "SELECT count(*) AS n FROM app.t WHERE NOT (a > 0)", detail: "rows that fail the check a_pos (a > 0)" } });
|
|
242
|
+
const unenforced = steps(T, "CREATE TABLE app.t (a int)", "CREATE TABLE app.t (a int, CONSTRAINT a_pos CHECK (a > 0) NOT ENFORCED)");
|
|
243
|
+
expect(unenforced.steps[0]!.precheck).toBeUndefined();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("a foreign key with no referenced columns, or MATCH FULL, carries no pre-check", () => {
|
|
247
|
+
expect(steps(T, "CREATE TABLE app.o (u bigint)", "CREATE TABLE app.o (u bigint REFERENCES app.users)").steps[0]!.precheck).toBeUndefined();
|
|
248
|
+
expect(steps(T, "CREATE TABLE app.o (u bigint)", "CREATE TABLE app.o (u bigint REFERENCES app.users (id) MATCH FULL)").steps[0]!.precheck).toBeUndefined();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe("a trigger or policy on a table", () => {
|
|
253
|
+
const TABLE = "CREATE TABLE app.t (id int)";
|
|
254
|
+
const declared = declaredObjects(
|
|
255
|
+
JSON.stringify({
|
|
256
|
+
dialect: "postgres",
|
|
257
|
+
objects: [
|
|
258
|
+
{ export: "t", type: T, dependsOn: [], ddl: TABLE },
|
|
259
|
+
{ export: "f", type: "Postgres::Function", dependsOn: [], ddl: "CREATE FUNCTION app.f() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$" },
|
|
260
|
+
{ export: "g", type: "Postgres::Trigger", dependsOn: ["t", "f"], ddl: "CREATE TRIGGER g BEFORE INSERT ON app.t FOR EACH ROW EXECUTE FUNCTION app.f()" },
|
|
261
|
+
{ export: "p", type: "Postgres::Policy", dependsOn: ["t"], ddl: "CREATE POLICY p ON app.t USING (true)" },
|
|
262
|
+
],
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
/** The create steps of the trigger and the policy, the table already on the server or not. */
|
|
266
|
+
function created(live: LivePgObject[]): Record<string, PgStep[]> {
|
|
267
|
+
const after: PgSchemaObject[] = declared.map((o) => ({ key: o.key, canonical: o.canonical }));
|
|
268
|
+
const current = pgSchemaFromLive(live, "public");
|
|
269
|
+
const plan = planPgStatements({ declared, changes: diffPgSchemas(current, after).changes, current, major: 18 });
|
|
270
|
+
return Object.fromEntries(plan.objects.flatMap((o) => (o.verdict === "create" ? [[o.obj.exportName, o.steps]] : [])));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
test("on an existing table, the CREATE carries the lock rule and its class", () => {
|
|
274
|
+
const r = created([{ type: T, statement: TABLE, name: "t", oid: "1" } as unknown as LivePgObject]);
|
|
275
|
+
expect(r.t).toBeUndefined();
|
|
276
|
+
expect(r.g![0]).toMatchObject({ rule: "SQLPG283", class: "metadata" });
|
|
277
|
+
expect(r.g![0]!.sql).toMatch(/^CREATE TRIGGER g/);
|
|
278
|
+
expect(r.p![0]).toMatchObject({ rule: "SQLPG290", class: "metadata" });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("on a table this plan creates, it is part of the create", () => {
|
|
282
|
+
const r = created([]);
|
|
283
|
+
expect(r.t![0]).toMatchObject({ rule: "SQLPG200", class: "create" });
|
|
284
|
+
expect(r.g![0]).toMatchObject({ rule: "SQLPG200", class: "create" });
|
|
285
|
+
expect(r.p![0]).toMatchObject({ rule: "SQLPG200", class: "create" });
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* transaction block at all (`CREATE INDEX CONCURRENTLY` may not).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
23
24
|
import type { OwnershipMarker } from "@intentius/chant/ownership";
|
|
24
25
|
import { stampedComment } from "../../core/ownership";
|
|
25
26
|
import { isTrivia, tokenizeText, type Token } from "../tokens";
|
|
@@ -29,10 +30,13 @@ import { POSTGRES_ENTITY_TYPES, type PostgresEntityType } from "../entity-types"
|
|
|
29
30
|
import { addConstraintRule, matchPgObjects } from "../plan/diff";
|
|
30
31
|
import type { PgChange } from "../plan/diff";
|
|
31
32
|
import { PG_CLASSIFIER_RULES, type PgChangeClass, type PgClassifierRuleId } from "../plan/rules";
|
|
32
|
-
import { canonicalOptions, sameConstraint, type CanonicalConstraint, type CanonicalKind } from "../plan/normalize";
|
|
33
|
+
import { ACCESS_KINDS, canonicalOptions, sameConstraint, type CanonicalConstraint, type CanonicalKind } from "../plan/normalize";
|
|
33
34
|
import type { PgDiffObject, PgSchemaObject } from "../plan/schema";
|
|
34
35
|
import { migrationTarget } from "../migrate/handoff";
|
|
35
|
-
import
|
|
36
|
+
import { pgName } from "../migrate/names";
|
|
37
|
+
import type { CheckDef, ColumnDef, DomainProps, ExclusionDef, ForeignKeyDef, IndexProps, KeyDef, PolicyProps, SequenceProps, TableProps } from "../entities";
|
|
38
|
+
|
|
39
|
+
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
36
40
|
|
|
37
41
|
/** One object as a build declares it, in the shape the applier works with. */
|
|
38
42
|
export interface DeclaredPgObject {
|
|
@@ -64,6 +68,21 @@ export interface PgStep {
|
|
|
64
68
|
rule?: PgClassifierRuleId;
|
|
65
69
|
/** An index this statement builds CONCURRENTLY: a failed build leaves it INVALID, and the applier drops it again. */
|
|
66
70
|
buildsIndex?: string;
|
|
71
|
+
/** A query to run before the statement, whose success depends on the rows already in the table (#3686). */
|
|
72
|
+
precheck?: PgPrecheck;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* A data-dependent statement's pre-check (#3686): a query that returns one
|
|
77
|
+
* row with one column, `n`, the count of rows (or groups of rows) the
|
|
78
|
+
* statement would fail on. Zero means it can run. Nothing runs it on its own:
|
|
79
|
+
* a caller runs every step's pre-check before the first statement, and stops
|
|
80
|
+
* when one is not zero. It reads the table without locking it beyond `SELECT`.
|
|
81
|
+
*/
|
|
82
|
+
export interface PgPrecheck {
|
|
83
|
+
sql: string;
|
|
84
|
+
/** What a count above zero means, e.g. `rows where email is NULL, which SET NOT NULL fails on`. */
|
|
85
|
+
detail: string;
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
/** What {@link alterSteps} could not make in place: changes with no statement, each with why. */
|
|
@@ -86,9 +105,9 @@ export const pgQualified = (schema: string | undefined, name: string): string =>
|
|
|
86
105
|
* trigger with its table (`touch ON app.users`).
|
|
87
106
|
*/
|
|
88
107
|
export const objectIdent = (o: { kind: CanonicalKind; schema?: string; name: string; signature?: string }): string =>
|
|
89
|
-
o.kind === "schema" || o.kind === "extension"
|
|
108
|
+
o.kind === "schema" || o.kind === "extension" || o.kind === "role"
|
|
90
109
|
? quoteIdent(o.name)
|
|
91
|
-
: o.kind === "trigger"
|
|
110
|
+
: o.kind === "trigger" || o.kind === "policy"
|
|
92
111
|
? `${quoteIdent(o.name)}${o.signature ?? ""}`
|
|
93
112
|
: `${pgQualified(o.schema, o.name)}${o.signature ?? ""}`;
|
|
94
113
|
|
|
@@ -106,6 +125,10 @@ export const KIND_WORDS: Readonly<Record<CanonicalKind, string>> = {
|
|
|
106
125
|
function: "FUNCTION",
|
|
107
126
|
procedure: "PROCEDURE",
|
|
108
127
|
trigger: "TRIGGER",
|
|
128
|
+
policy: "POLICY",
|
|
129
|
+
role: "ROLE",
|
|
130
|
+
grant: "GRANT",
|
|
131
|
+
defaultPrivileges: "DEFAULT PRIVILEGES",
|
|
109
132
|
};
|
|
110
133
|
|
|
111
134
|
/** The canonical kind of an entity type. */
|
|
@@ -115,6 +138,56 @@ export const kindOfType = (type: string): CanonicalKind =>
|
|
|
115
138
|
const step = (sql: string, cls: PgChangeClass, rule?: PgClassifierRuleId, transactional = true): PgStep => ({ sql, class: cls, transactional, ...(rule ? { rule } : {}) });
|
|
116
139
|
const classOf = (rule: PgClassifierRuleId): PgChangeClass => PG_CLASSIFIER_RULES[rule].class;
|
|
117
140
|
|
|
141
|
+
// ── Pre-checks (#3686) ─────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/** The rows of `table` with `column` NULL: what SET NOT NULL fails on. */
|
|
144
|
+
export function nullPrecheck(table: string, column: string): PgPrecheck {
|
|
145
|
+
return { sql: `SELECT count(*) AS n FROM ${table} WHERE ${quoteIdent(column)} IS NULL`, detail: `rows where ${column} is NULL, which SET NOT NULL fails on` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The groups of rows of `table` with the same `columns`: what a unique index
|
|
150
|
+
* or a unique or primary key constraint fails on. NULLs are distinct unless
|
|
151
|
+
* `nullsNotDistinct`; a primary key also counts the rows with a NULL key
|
|
152
|
+
* column, which its implied NOT NULL fails on. `where` is a partial index's
|
|
153
|
+
* predicate.
|
|
154
|
+
*/
|
|
155
|
+
export function duplicatePrecheck(table: string, columns: readonly string[], opts: { primaryKey?: boolean; nullsNotDistinct?: boolean; where?: string } = {}): PgPrecheck {
|
|
156
|
+
const cols = columns.map(quoteIdent);
|
|
157
|
+
const filter = [...(opts.primaryKey || opts.nullsNotDistinct ? [] : cols.map((c) => `${c} IS NOT NULL`)), ...(opts.where ? [`(${opts.where})`] : [])];
|
|
158
|
+
const groups = `SELECT count(*) FROM (SELECT 1 FROM ${table}${filter.length ? ` WHERE ${filter.join(" AND ")}` : ""} GROUP BY ${cols.join(", ")} HAVING count(*) > 1) AS duplicates`;
|
|
159
|
+
const what = columns.join(", ");
|
|
160
|
+
if (!opts.primaryKey) return { sql: `SELECT (${groups}) AS n`, detail: `values of (${what}) held by more than one row, which a unique index fails on` };
|
|
161
|
+
return {
|
|
162
|
+
sql: `SELECT (${groups}) + (SELECT count(*) FROM ${table} WHERE ${cols.map((c) => `${c} IS NULL`).join(" OR ")}) AS n`,
|
|
163
|
+
detail: `values of (${what}) held by more than one row, and rows with one of them NULL, which a primary key fails on`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The rows of `table` that fail a CHECK: its expression false (a NULL result passes). */
|
|
168
|
+
export function checkPrecheck(table: string, expr: string, name?: string): PgPrecheck {
|
|
169
|
+
return { sql: `SELECT count(*) AS n FROM ${table} WHERE NOT (${expr})`, detail: `rows that fail the check${name ? ` ${name}` : ""} (${expr})` };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The rows of `table` whose foreign key references no row: every key column
|
|
174
|
+
* set and no referenced row with those values (MATCH SIMPLE, the default).
|
|
175
|
+
* Undefined for a foreign key that names no referenced columns or is MATCH
|
|
176
|
+
* FULL, which the query does not cover.
|
|
177
|
+
*/
|
|
178
|
+
export function foreignKeyPrecheck(table: string, fk: ForeignKeyDef): PgPrecheck | undefined {
|
|
179
|
+
if (fk.refColumns.length !== fk.columns.length || (fk.match !== undefined && fk.match.toUpperCase() !== "SIMPLE")) return undefined;
|
|
180
|
+
const child = fk.columns.map((c) => `c.${quoteIdent(c)}`);
|
|
181
|
+
const join = fk.refColumns.map((r, i) => `p.${quoteIdent(r)} = ${child[i]}`).join(" AND ");
|
|
182
|
+
return {
|
|
183
|
+
sql: `SELECT count(*) AS n FROM ${table} AS c WHERE ${child.map((c) => `${c} IS NOT NULL`).join(" AND ")} AND NOT EXISTS (SELECT 1 FROM ${fk.refTable} AS p WHERE ${join})`,
|
|
184
|
+
detail: `rows whose (${fk.columns.join(", ")}) references no row of ${fk.refTable}, which the foreign key${fk.name ? ` ${fk.name}` : ""} fails on`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The check that proves a column NOT NULL while SET NOT NULL runs (SQLPG210), named as the migration Op names its own. */
|
|
189
|
+
export const notNullCheckName = (column: string): string => pgName(`${column}__chant_nn`);
|
|
190
|
+
|
|
118
191
|
// ── Statements in a template ───────────────────────────────────────────
|
|
119
192
|
|
|
120
193
|
interface Statement {
|
|
@@ -172,12 +245,14 @@ export function commentStatement(obj: Pick<DeclaredPgObject, "canonical">, marke
|
|
|
172
245
|
export function createSteps(
|
|
173
246
|
obj: Pick<DeclaredPgObject, "ddl" | "canonical" | "props">,
|
|
174
247
|
marker: OwnershipMarker | undefined,
|
|
175
|
-
opts: { base?: string; orReplace?: boolean; cls?: PgChangeClass; rule?: PgClassifierRuleId } = {},
|
|
248
|
+
opts: { base?: string; orReplace?: boolean; cls?: PgChangeClass; rule?: PgClassifierRuleId; access?: boolean } = {},
|
|
176
249
|
): PgStep[] {
|
|
177
250
|
const cls = opts.cls ?? "create";
|
|
178
251
|
const out: PgStep[] = [];
|
|
179
252
|
splitStatements(obj.ddl).forEach((s, i) => {
|
|
180
253
|
if (i > 0 && isOwnComment(s, obj.canonical.kind)) return;
|
|
254
|
+
// A table's row-level security is access, which only a profile that manages access applies.
|
|
255
|
+
if (i > 0 && opts.access === false && firstNode(s)?.statement === "rowSecurity") return;
|
|
181
256
|
let sql = s.text;
|
|
182
257
|
const replaceable = obj.canonical.kind === "view" || obj.canonical.kind === "function" || obj.canonical.kind === "procedure" || (obj.canonical.kind === "trigger" && obj.canonical.fields.constraint !== true);
|
|
183
258
|
if (i === 0 && opts.orReplace && replaceable && !/^\s*CREATE\s+OR\s+REPLACE\b/i.test(sql)) sql = sql.replace(/\bCREATE\s+/i, "CREATE OR REPLACE ");
|
|
@@ -192,7 +267,7 @@ export function createSteps(
|
|
|
192
267
|
export function dropStatement(kind: CanonicalKind, schema: string | undefined, name: string, signature?: string): PgStep {
|
|
193
268
|
const what = objectIdent({ kind, ...(schema !== undefined ? { schema } : {}), name, ...(signature !== undefined ? { signature } : {}) });
|
|
194
269
|
if (kind === "index") return step(`DROP INDEX CONCURRENTLY ${what}`, "concurrently", "SQLPG242", false);
|
|
195
|
-
return step(`DROP ${KIND_WORDS[kind]} ${what}`, "drop", kind === "trigger" ? "SQLPG285" : "SQLPG270");
|
|
270
|
+
return step(`DROP ${KIND_WORDS[kind]} ${what}`, "drop", kind === "trigger" ? "SQLPG285" : kind === "policy" ? "SQLPG292" : "SQLPG270");
|
|
196
271
|
}
|
|
197
272
|
|
|
198
273
|
// ── Columns ────────────────────────────────────────────────────────────
|
|
@@ -318,7 +393,11 @@ function keyConstraintSteps(table: { schema?: string; name: string }, kind: "PRI
|
|
|
318
393
|
.filter(Boolean)
|
|
319
394
|
.join(" ");
|
|
320
395
|
return [
|
|
321
|
-
{
|
|
396
|
+
{
|
|
397
|
+
...step(build, "concurrently", "SQLPG221", false),
|
|
398
|
+
buildsIndex: index,
|
|
399
|
+
precheck: duplicatePrecheck(pgQualified(table.schema, table.name), k.columns, { primaryKey: kind === "PRIMARY KEY", ...(k.nullsNotDistinct ? { nullsNotDistinct: true } : {}) }),
|
|
400
|
+
},
|
|
322
401
|
step(
|
|
323
402
|
[`ALTER TABLE ${pgQualified(table.schema, table.name)} ADD CONSTRAINT ${quoteIdent(name)} ${kind} USING INDEX ${quoteIdent(name)}`, deferralSql(k)].filter(Boolean).join(" "),
|
|
324
403
|
"metadata",
|
|
@@ -327,6 +406,31 @@ function keyConstraintSteps(table: { schema?: string; name: string }, kind: "PRI
|
|
|
327
406
|
];
|
|
328
407
|
}
|
|
329
408
|
|
|
409
|
+
/**
|
|
410
|
+
* A CHECK (SQLPG218) or a foreign key (SQLPG219) added to a table that
|
|
411
|
+
* exists, without reading its rows under a lock that blocks writes: the
|
|
412
|
+
* constraint added NOT VALID, which checks new rows only (SQLPG217), then
|
|
413
|
+
* validated (SQLPG220), which reads the rows under SHARE UPDATE EXCLUSIVE
|
|
414
|
+
* while writes go on. The applier commits between the two, since their
|
|
415
|
+
* classes differ, so the validation runs in a transaction of its own. A run
|
|
416
|
+
* interrupted after the first statement leaves the constraint NOT VALID on
|
|
417
|
+
* the server, and the next plan validates it as found. The first statement
|
|
418
|
+
* carries the pre-check (the rows the check or the foreign key refuses).
|
|
419
|
+
* VALIDATE needs a name, so a constraint declared without one is given
|
|
420
|
+
* {@link unnamedConstraintName}'s; the diff matches an unnamed declaration
|
|
421
|
+
* to the constraint by its body, whatever it is called.
|
|
422
|
+
*/
|
|
423
|
+
/** The name given to a CHECK or foreign key declared without one: `<table>_<hash of its body>_check` or `_fkey`. */
|
|
424
|
+
export function unnamedConstraintName(table: string, c: Pick<CanonicalConstraint, "kind" | "body">): string {
|
|
425
|
+
const hash = createHash("sha256").update(c.body).digest("hex").slice(0, 8);
|
|
426
|
+
return pgName(`${table}_${hash}_${c.kind === "CHECK" ? "check" : "fkey"}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function notValidThenValidateSteps(alter: string, kind: "CHECK" | "FOREIGN KEY", def: CheckDef | ForeignKeyDef, precheck: PgPrecheck | undefined): PgStep[] {
|
|
430
|
+
const add = step(`${alter} ADD ${constraintSql(kind, { ...def, notValid: true })}`, "metadata", "SQLPG217");
|
|
431
|
+
return [precheck ? { ...add, precheck } : add, step(`${alter} VALIDATE CONSTRAINT ${quoteIdent(def.name!)}`, "validate", "SQLPG220")];
|
|
432
|
+
}
|
|
433
|
+
|
|
330
434
|
/**
|
|
331
435
|
* The constraint changes of a table or a domain, matched the way the diff
|
|
332
436
|
* matches them (`../plan/diff.ts`): a declared constraint the server lacks is
|
|
@@ -334,15 +438,20 @@ function keyConstraintSteps(table: { schema?: string; name: string }, kind: "PRI
|
|
|
334
438
|
* validated, one the declaration lost is dropped, and a changed comment is
|
|
335
439
|
* set.
|
|
336
440
|
*/
|
|
337
|
-
function constraintSteps(obj: DeclaredPgObject, live: PgDiffObject): PgStep[] {
|
|
441
|
+
function constraintSteps(obj: DeclaredPgObject, live: PgDiffObject, keep: ReadonlySet<string> = new Set()): PgStep[] {
|
|
338
442
|
const domain = obj.canonical.kind === "domain";
|
|
339
443
|
const target = objectIdent(obj.canonical);
|
|
340
444
|
const alter = domain ? `ALTER DOMAIN ${target}` : `ALTER TABLE ${target}`;
|
|
341
445
|
const defs = domain
|
|
342
446
|
? (obj.props as unknown as DomainProps).checks.map((def) => ({ kind: "CHECK" as const, def: def as CheckDef }))
|
|
343
447
|
: declaredConstraintDefs(obj.props as unknown as TableProps);
|
|
344
|
-
|
|
448
|
+
// A NOT NULL proof left by an interrupted SET NOT NULL (SQLPG210) is that sequence's to reuse and drop.
|
|
449
|
+
const remaining = live.constraints.filter((b) => !(b.name && keep.has(b.name)));
|
|
345
450
|
const out: PgStep[] = [];
|
|
451
|
+
/** What the rows must pass for a CHECK or a foreign key to be valid. */
|
|
452
|
+
const precheckOf = (kind: CanonicalConstraint["kind"], def: unknown): PgPrecheck | undefined =>
|
|
453
|
+
domain || def === undefined ? undefined : kind === "CHECK" ? checkPrecheck(target, (def as CheckDef).expr, (def as CheckDef).name) : kind === "FOREIGN KEY" ? foreignKeyPrecheck(target, def as ForeignKeyDef) : undefined;
|
|
454
|
+
const withPrecheck = (s: PgStep, p: PgPrecheck | undefined): PgStep => (p ? { ...s, precheck: p } : s);
|
|
346
455
|
obj.canonical.constraints.forEach((a, i) => {
|
|
347
456
|
const def = defs[i]?.def;
|
|
348
457
|
const at = remaining.findIndex((b) => sameConstraint(a, b));
|
|
@@ -353,9 +462,13 @@ function constraintSteps(obj: DeclaredPgObject, live: PgDiffObject): PgStep[] {
|
|
|
353
462
|
out.push(step(`${alter} ADD ${named(c.name)}CHECK (${c.expr})${c.notValid ? " NOT VALID" : ""}`, c.notValid ? "metadata" : "validate", c.notValid ? "SQLPG217" : "SQLPG262"));
|
|
354
463
|
} else if ((a.kind === "PRIMARY KEY" || a.kind === "UNIQUE") && !a.notValid) {
|
|
355
464
|
out.push(...keyConstraintSteps(obj.canonical, a.kind, def as KeyDef));
|
|
465
|
+
} else if ((a.kind === "CHECK" || a.kind === "FOREIGN KEY") && !a.notValid && !(def as { notEnforced?: boolean }).notEnforced) {
|
|
466
|
+
const d = def as CheckDef | ForeignKeyDef;
|
|
467
|
+
const withName = d.name ? d : { ...d, name: unnamedConstraintName(obj.canonical.name, a) };
|
|
468
|
+
out.push(...notValidThenValidateSteps(alter, a.kind, withName, precheckOf(a.kind, withName)));
|
|
356
469
|
} else {
|
|
357
470
|
const rule = addConstraintRule(a);
|
|
358
|
-
out.push(step(`${alter} ADD ${constraintSql(a.kind, def)}`, classOf(rule), rule));
|
|
471
|
+
out.push(withPrecheck(step(`${alter} ADD ${constraintSql(a.kind, def)}`, classOf(rule), rule), rule === "SQLPG217" || (def as { notEnforced?: boolean }).notEnforced ? undefined : precheckOf(a.kind, def)));
|
|
359
472
|
}
|
|
360
473
|
if (a.comment !== undefined) {
|
|
361
474
|
const name = a.name ?? (def as { name?: string }).name;
|
|
@@ -366,7 +479,7 @@ function constraintSteps(obj: DeclaredPgObject, live: PgDiffObject): PgStep[] {
|
|
|
366
479
|
const b = remaining.splice(at, 1)[0]!;
|
|
367
480
|
const name = b.name ?? a.name;
|
|
368
481
|
if (!name) return;
|
|
369
|
-
if (b.notValid && !a.notValid) out.push(step(`${alter} VALIDATE CONSTRAINT ${quoteIdent(name)}`, "validate", "SQLPG220"));
|
|
482
|
+
if (b.notValid && !a.notValid) out.push(withPrecheck(step(`${alter} VALIDATE CONSTRAINT ${quoteIdent(name)}`, "validate", "SQLPG220"), precheckOf(a.kind, def)));
|
|
370
483
|
if (b.comment !== a.comment) {
|
|
371
484
|
out.push(step(`COMMENT ON CONSTRAINT ${quoteIdent(name)} ON ${domain ? "DOMAIN " : ""}${target} IS ${a.comment === undefined ? "NULL" : pgString(a.comment)}`, "metadata", "SQLPG216"));
|
|
372
485
|
}
|
|
@@ -417,6 +530,31 @@ export const isDestructive = (c: PgChange): boolean => c.rule === "SQLPG204";
|
|
|
417
530
|
/** Changes that replace the object: drop it and create it again from its declaration. */
|
|
418
531
|
export const isRecreate = (c: PgChange): boolean => c.rule === "SQLPG252" || c.rule === "SQLPG243" || c.rule === "SQLPG281";
|
|
419
532
|
|
|
533
|
+
/**
|
|
534
|
+
* SET NOT NULL on a table that exists, without reading the table under
|
|
535
|
+
* ACCESS EXCLUSIVE (SQLPG210): a CHECK (column IS NOT NULL) added NOT VALID,
|
|
536
|
+
* which reads no rows; validated, which reads them under SHARE UPDATE
|
|
537
|
+
* EXCLUSIVE while writes go on; then SET NOT NULL, which the valid check
|
|
538
|
+
* proves without a scan (12 and later), and the check dropped. The applier
|
|
539
|
+
* commits between the catalog changes and the validation, so the brief
|
|
540
|
+
* ACCESS EXCLUSIVE locks are never held across the scan. A check left by an
|
|
541
|
+
* interrupted run is validated as found. The first statement carries the
|
|
542
|
+
* NULL count as its pre-check. 18's `NOT NULL ... NOT VALID` would save the
|
|
543
|
+
* last statement; the check is used on every major for one sequence.
|
|
544
|
+
*/
|
|
545
|
+
export function setNotNullSteps(table: string, column: string, live?: Pick<PgDiffObject, "constraints">): PgStep[] {
|
|
546
|
+
const check = quoteIdent(notNullCheckName(column));
|
|
547
|
+
const left = live?.constraints.some((k) => k.name === notNullCheckName(column)) ?? false;
|
|
548
|
+
const steps = [
|
|
549
|
+
...(left ? [] : [step(`ALTER TABLE ${table} ADD CONSTRAINT ${check} CHECK (${quoteIdent(column)} IS NOT NULL) NOT VALID`, "metadata", "SQLPG217")]),
|
|
550
|
+
step(`ALTER TABLE ${table} VALIDATE CONSTRAINT ${check}`, "validate", "SQLPG220"),
|
|
551
|
+
step(`ALTER TABLE ${table} ALTER COLUMN ${quoteIdent(column)} SET NOT NULL`, "metadata", "SQLPG210"),
|
|
552
|
+
step(`ALTER TABLE ${table} DROP CONSTRAINT ${check}`, "metadata", "SQLPG223"),
|
|
553
|
+
];
|
|
554
|
+
steps[0] = { ...steps[0]!, precheck: nullPrecheck(table, column) };
|
|
555
|
+
return steps;
|
|
556
|
+
}
|
|
557
|
+
|
|
420
558
|
/**
|
|
421
559
|
* The statements for an object that exists on the server, change by change.
|
|
422
560
|
* `live` is the server's definition, matched to the declaration (a renamed
|
|
@@ -440,6 +578,7 @@ export function alterSteps(
|
|
|
440
578
|
const declaredColumns = new Map(d.columns.map((c) => [c.name, c]));
|
|
441
579
|
let constraintsDone = false;
|
|
442
580
|
let recreated = false;
|
|
581
|
+
const notNullChecks = new Set(changes.filter((c) => c.rule === "SQLPG210" && c.class !== "metadata").map((c) => notNullCheckName(COLUMN_FIELD.exec(c.field)?.[1] ?? "")));
|
|
443
582
|
|
|
444
583
|
for (const c of changes) {
|
|
445
584
|
const no = (why: string) => unsupported.push({ change: c, why });
|
|
@@ -452,7 +591,7 @@ export function alterSteps(
|
|
|
452
591
|
continue;
|
|
453
592
|
}
|
|
454
593
|
if (c.field.startsWith("constraints.")) {
|
|
455
|
-
if (!constraintsDone) steps.push(...constraintSteps(obj, live));
|
|
594
|
+
if (!constraintsDone) steps.push(...constraintSteps(obj, live, notNullChecks));
|
|
456
595
|
constraintsDone = true;
|
|
457
596
|
continue;
|
|
458
597
|
}
|
|
@@ -483,7 +622,9 @@ export function alterSteps(
|
|
|
483
622
|
steps.push(step(def?.default !== undefined ? `${column} SET DEFAULT ${def.default}` : `${column} DROP DEFAULT`, c.class, c.rule));
|
|
484
623
|
break;
|
|
485
624
|
case "SQLPG210":
|
|
486
|
-
|
|
625
|
+
// A valid check the server already holds proves it (the diff classes that metadata): SET NOT NULL alone.
|
|
626
|
+
if (c.class === "metadata") steps.push(step(`${column} SET NOT NULL`, c.class, c.rule));
|
|
627
|
+
else steps.push(...setNotNullSteps(target, name, live));
|
|
487
628
|
break;
|
|
488
629
|
case "SQLPG211":
|
|
489
630
|
steps.push(step(`${column} DROP NOT NULL`, c.class, c.rule));
|
|
@@ -553,6 +694,36 @@ export function alterSteps(
|
|
|
553
694
|
case "SQLPG280":
|
|
554
695
|
steps.push(...createSteps(obj, opts.marker, { orReplace: true, cls: c.class, rule: c.rule }).slice(0, 1));
|
|
555
696
|
break;
|
|
697
|
+
case "SQLPG291": {
|
|
698
|
+
const p = obj.props as unknown as PolicyProps;
|
|
699
|
+
const recreate =
|
|
700
|
+
["table", "restrictive", "command"].some((k) => !same(d.fields[k], live.fields[k])) ||
|
|
701
|
+
(live.fields.using !== undefined && d.fields.using === undefined) ||
|
|
702
|
+
(live.fields.check !== undefined && d.fields.check === undefined);
|
|
703
|
+
if (recreate) {
|
|
704
|
+
steps.push(step(`DROP POLICY ${objectIdent(live)}`, c.class, c.rule));
|
|
705
|
+
steps.push(...createSteps(obj, opts.marker, { cls: c.class, rule: c.rule }));
|
|
706
|
+
} else {
|
|
707
|
+
const roles = p.roles.map((r) => (r === "public" ? "PUBLIC" : quoteIdent(r))).join(", ");
|
|
708
|
+
steps.push(step([`ALTER POLICY ${objectIdent(d)} TO ${roles}`, p.using ? `USING (${p.using})` : "", p.check ? `WITH CHECK (${p.check})` : ""].filter(Boolean).join(" "), c.class, c.rule));
|
|
709
|
+
}
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
case "SQLPG293":
|
|
713
|
+
if (!same(d.fields.rowSecurity, live.fields.rowSecurity)) steps.push(step(`${alterKind} ${d.fields.rowSecurity ? "ENABLE" : "DISABLE"} ROW LEVEL SECURITY`, c.class, c.rule));
|
|
714
|
+
if (!same(d.fields.forceRowSecurity, live.fields.forceRowSecurity)) steps.push(step(`${alterKind} ${d.fields.forceRowSecurity ? "FORCE" : "NO FORCE"} ROW LEVEL SECURITY`, c.class, c.rule));
|
|
715
|
+
break;
|
|
716
|
+
case "SQLPG294": {
|
|
717
|
+
if (steps.some((x) => x.rule === "SQLPG294")) break;
|
|
718
|
+
const want = new Set((d.fields.attributes as string[] | undefined) ?? []);
|
|
719
|
+
const have = new Set((live.fields.attributes as string[] | undefined) ?? []);
|
|
720
|
+
const flip = (a: string) => (a.startsWith("no") ? a.slice(2) : `no${a}`);
|
|
721
|
+
// An attribute at its default is in neither set: write the side that changes.
|
|
722
|
+
const words = [...[...want].filter((a) => !have.has(a)), ...[...have].filter((a) => !want.has(a)).map(flip)];
|
|
723
|
+
const limit = d.fields.connectionLimit !== live.fields.connectionLimit ? [`CONNECTION LIMIT ${String(d.fields.connectionLimit ?? -1)}`] : [];
|
|
724
|
+
steps.push(step(`ALTER ROLE ${objectIdent(d)} WITH ${[...words.map((w) => w.toUpperCase()), ...limit].join(" ")}`, c.class, c.rule));
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
556
727
|
case "SQLPG284":
|
|
557
728
|
if (d.fields.constraint === true || live.fields.constraint === true || d.signature !== live.signature) {
|
|
558
729
|
steps.push(step(`DROP TRIGGER ${objectIdent(live)}`, c.class, c.rule));
|
|
@@ -674,6 +845,8 @@ export interface PgStatementPlanInput {
|
|
|
674
845
|
carriesMarker?: (live: PgDiffObject, currentKey: string) => boolean;
|
|
675
846
|
/** The comment an extension's control file sets, kept under the trailer when the declaration sets none. */
|
|
676
847
|
extensionComment?: (name: string) => string | undefined;
|
|
848
|
+
/** Whether access is managed: off, a table is created without its row-level security statements. Default: on. */
|
|
849
|
+
access?: boolean;
|
|
677
850
|
}
|
|
678
851
|
|
|
679
852
|
export interface PgStatementPlan {
|
|
@@ -683,8 +856,14 @@ export interface PgStatementPlan {
|
|
|
683
856
|
drops: PgDropStatement[];
|
|
684
857
|
}
|
|
685
858
|
|
|
859
|
+
/** The rules that create an object, which label its create statements. */
|
|
860
|
+
const CREATE_RULES: ReadonlySet<string> = new Set(["SQLPG200", "SQLPG240", "SQLPG241", "SQLPG283", "SQLPG290"]);
|
|
861
|
+
|
|
686
862
|
/** Drop order: what reads from a table before the table, types after the tables using them, a schema last. */
|
|
687
863
|
const DROP_ORDER: Record<CanonicalKind, number> = {
|
|
864
|
+
grant: 0,
|
|
865
|
+
defaultPrivileges: 0,
|
|
866
|
+
policy: 0,
|
|
688
867
|
trigger: 0,
|
|
689
868
|
materializedView: 1,
|
|
690
869
|
view: 2,
|
|
@@ -698,8 +877,16 @@ const DROP_ORDER: Record<CanonicalKind, number> = {
|
|
|
698
877
|
enum: 8,
|
|
699
878
|
extension: 9,
|
|
700
879
|
schema: 10,
|
|
880
|
+
// A role is the cluster's: never dropped by a prune (`../live/catalog.ts` reads only the declared ones).
|
|
881
|
+
role: 11,
|
|
701
882
|
};
|
|
702
883
|
|
|
884
|
+
/** A unique index's duplicate count, when every element is a plain column; undefined otherwise. */
|
|
885
|
+
function uniqueIndexPrecheck(p: IndexProps): PgPrecheck | undefined {
|
|
886
|
+
if (!p.unique || p.elements.length === 0 || p.elements.some((e) => e.column === undefined)) return undefined;
|
|
887
|
+
return duplicatePrecheck(p.tableName, p.elements.map((e) => e.column!), { ...(p.nullsNotDistinct ? { nullsNotDistinct: true } : {}), ...(p.where ? { where: p.where } : {}) });
|
|
888
|
+
}
|
|
889
|
+
|
|
703
890
|
/**
|
|
704
891
|
* The statements that take the current schema to the declared one, from the
|
|
705
892
|
* classified changes between them. Pure: nothing is read or sent. The applier
|
|
@@ -718,7 +905,8 @@ export function planPgStatements(input: PgStatementPlanInput): PgStatementPlan {
|
|
|
718
905
|
}
|
|
719
906
|
|
|
720
907
|
const recreatedRelations = new Set<string>();
|
|
721
|
-
|
|
908
|
+
// Grants and default privileges are access, made by their own statements (`../access/acl.ts`).
|
|
909
|
+
const objects = input.declared.filter((obj) => !ACCESS_KINDS.has(obj.canonical.kind)).map((obj): PgObjectStatements => {
|
|
722
910
|
const key = keyOf(obj);
|
|
723
911
|
const currentKey = matched.get(key);
|
|
724
912
|
const live = currentKey !== undefined ? currentByKey.get(currentKey) : undefined;
|
|
@@ -744,9 +932,13 @@ export function planPgStatements(input: PgStatementPlanInput): PgStatementPlan {
|
|
|
744
932
|
const onRecreated = obj.canonical.kind === "index" && recreatedRelations.has(String(obj.canonical.fields.table));
|
|
745
933
|
if (!live || onRecreated) {
|
|
746
934
|
const base = obj.canonical.kind === "extension" && !live ? input.extensionComment?.(obj.canonical.name) : undefined;
|
|
747
|
-
// An index on a table that exists is built under the scan timeout (SQLPG240, SQLPG241)
|
|
748
|
-
|
|
749
|
-
const
|
|
935
|
+
// An index on a table that exists is built under the scan timeout (SQLPG240, SQLPG241), and a trigger or policy
|
|
936
|
+
// on one locks it (SQLPG283, SQLPG290); on a new table each is part of the create (SQLPG200).
|
|
937
|
+
const how = mine.find((c) => CREATE_RULES.has(c.rule));
|
|
938
|
+
const steps = createSteps(obj, input.marker, { ...(base !== undefined ? { base } : {}), ...(how ? { cls: how.class, rule: how.rule } : {}), ...(input.access === false ? { access: false } : {}) });
|
|
939
|
+
// A unique index on a table that has rows fails on duplicates: its build carries their count.
|
|
940
|
+
const unique = how && how.rule !== "SQLPG200" && obj.canonical.kind === "index" ? uniqueIndexPrecheck(obj.props as unknown as IndexProps) : undefined;
|
|
941
|
+
if (unique && steps[0]) steps[0] = { ...steps[0], precheck: unique };
|
|
750
942
|
return { verdict: "create", obj, changes: mine, steps, ...withLive };
|
|
751
943
|
}
|
|
752
944
|
|
|
@@ -770,7 +962,7 @@ export function planPgStatements(input: PgStatementPlanInput): PgStatementPlan {
|
|
|
770
962
|
|
|
771
963
|
const drops: PgDropStatement[] = [];
|
|
772
964
|
for (const c of input.changes) {
|
|
773
|
-
if (!((c.rule === "SQLPG270" || c.rule === "SQLPG242" || c.rule === "SQLPG285") && c.after === undefined)) continue;
|
|
965
|
+
if (!((c.rule === "SQLPG270" || c.rule === "SQLPG242" || c.rule === "SQLPG285" || c.rule === "SQLPG292") && c.after === undefined)) continue;
|
|
774
966
|
const o = currentByKey.get(c.object);
|
|
775
967
|
if (!o || o.foreign) continue;
|
|
776
968
|
const schema = o.kind === "schema" || o.kind === "extension" ? undefined : o.schema;
|