@intentius/chant-lexicon-sql 0.117.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 +50 -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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +6 -6
- 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/templates.d.ts +1 -1
- package/dist/lint/rules/templates.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/meta.json +30 -0
- package/dist/okf/index.md +7 -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/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/activity-contracts.d.ts +9 -0
- package/dist/op/activity-contracts.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/postgres/apply/statements.d.ts +68 -0
- package/dist/postgres/apply/statements.d.ts.map +1 -1
- package/dist/postgres/import/generator.d.ts +33 -0
- package/dist/postgres/import/generator.d.ts.map +1 -1
- package/dist/postgres/live/bind.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/rules/sqlch001.ts +26 -4
- package/dist/rules/sqlch002.ts +3 -2
- 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 +6 -0
- package/src/config.ts +12 -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 +30 -0
- package/src/import-parser.ts +61 -0
- package/src/index.ts +15 -2
- package/src/lint/rules/sqlch001.ts +26 -4
- package/src/lint/rules/sqlch002.ts +3 -2
- package/src/lint/rules/templates.ts +2 -2
- package/src/lsp/template.ts +3 -3
- package/src/mcp/index.ts +6 -6
- package/src/migration-statements.test.ts +25 -0
- package/src/migration-statements.ts +1 -0
- package/src/op/activities/clickhouse-rebuild.ts +1 -0
- package/src/op/activity-contracts.ts +1 -0
- package/src/plugin.ts +29 -8
- 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 +156 -10
- package/src/postgres/import/generator.ts +118 -45
- package/src/postgres/live/bind.ts +17 -6
- package/src/postgres/live/client.ts +10 -2
- package/src/postgres/plan/plan.test.ts +2 -2
- package/src/postgres/plan/rules.ts +5 -5
- package/src/serializer.ts +14 -9
- package/src/skills/chant-sql-postgres-plan.md +3 -3
- 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 +6 -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";
|
|
@@ -32,7 +33,8 @@ import { PG_CLASSIFIER_RULES, type PgChangeClass, type PgClassifierRuleId } from
|
|
|
32
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";
|
|
36
38
|
|
|
37
39
|
const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
|
|
38
40
|
|
|
@@ -66,6 +68,21 @@ export interface PgStep {
|
|
|
66
68
|
rule?: PgClassifierRuleId;
|
|
67
69
|
/** An index this statement builds CONCURRENTLY: a failed build leaves it INVALID, and the applier drops it again. */
|
|
68
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;
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
/** What {@link alterSteps} could not make in place: changes with no statement, each with why. */
|
|
@@ -121,6 +138,56 @@ export const kindOfType = (type: string): CanonicalKind =>
|
|
|
121
138
|
const step = (sql: string, cls: PgChangeClass, rule?: PgClassifierRuleId, transactional = true): PgStep => ({ sql, class: cls, transactional, ...(rule ? { rule } : {}) });
|
|
122
139
|
const classOf = (rule: PgClassifierRuleId): PgChangeClass => PG_CLASSIFIER_RULES[rule].class;
|
|
123
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
|
+
|
|
124
191
|
// ── Statements in a template ───────────────────────────────────────────
|
|
125
192
|
|
|
126
193
|
interface Statement {
|
|
@@ -326,7 +393,11 @@ function keyConstraintSteps(table: { schema?: string; name: string }, kind: "PRI
|
|
|
326
393
|
.filter(Boolean)
|
|
327
394
|
.join(" ");
|
|
328
395
|
return [
|
|
329
|
-
{
|
|
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
|
+
},
|
|
330
401
|
step(
|
|
331
402
|
[`ALTER TABLE ${pgQualified(table.schema, table.name)} ADD CONSTRAINT ${quoteIdent(name)} ${kind} USING INDEX ${quoteIdent(name)}`, deferralSql(k)].filter(Boolean).join(" "),
|
|
332
403
|
"metadata",
|
|
@@ -335,6 +406,31 @@ function keyConstraintSteps(table: { schema?: string; name: string }, kind: "PRI
|
|
|
335
406
|
];
|
|
336
407
|
}
|
|
337
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
|
+
|
|
338
434
|
/**
|
|
339
435
|
* The constraint changes of a table or a domain, matched the way the diff
|
|
340
436
|
* matches them (`../plan/diff.ts`): a declared constraint the server lacks is
|
|
@@ -342,15 +438,20 @@ function keyConstraintSteps(table: { schema?: string; name: string }, kind: "PRI
|
|
|
342
438
|
* validated, one the declaration lost is dropped, and a changed comment is
|
|
343
439
|
* set.
|
|
344
440
|
*/
|
|
345
|
-
function constraintSteps(obj: DeclaredPgObject, live: PgDiffObject): PgStep[] {
|
|
441
|
+
function constraintSteps(obj: DeclaredPgObject, live: PgDiffObject, keep: ReadonlySet<string> = new Set()): PgStep[] {
|
|
346
442
|
const domain = obj.canonical.kind === "domain";
|
|
347
443
|
const target = objectIdent(obj.canonical);
|
|
348
444
|
const alter = domain ? `ALTER DOMAIN ${target}` : `ALTER TABLE ${target}`;
|
|
349
445
|
const defs = domain
|
|
350
446
|
? (obj.props as unknown as DomainProps).checks.map((def) => ({ kind: "CHECK" as const, def: def as CheckDef }))
|
|
351
447
|
: declaredConstraintDefs(obj.props as unknown as TableProps);
|
|
352
|
-
|
|
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)));
|
|
353
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);
|
|
354
455
|
obj.canonical.constraints.forEach((a, i) => {
|
|
355
456
|
const def = defs[i]?.def;
|
|
356
457
|
const at = remaining.findIndex((b) => sameConstraint(a, b));
|
|
@@ -361,9 +462,13 @@ function constraintSteps(obj: DeclaredPgObject, live: PgDiffObject): PgStep[] {
|
|
|
361
462
|
out.push(step(`${alter} ADD ${named(c.name)}CHECK (${c.expr})${c.notValid ? " NOT VALID" : ""}`, c.notValid ? "metadata" : "validate", c.notValid ? "SQLPG217" : "SQLPG262"));
|
|
362
463
|
} else if ((a.kind === "PRIMARY KEY" || a.kind === "UNIQUE") && !a.notValid) {
|
|
363
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)));
|
|
364
469
|
} else {
|
|
365
470
|
const rule = addConstraintRule(a);
|
|
366
|
-
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)));
|
|
367
472
|
}
|
|
368
473
|
if (a.comment !== undefined) {
|
|
369
474
|
const name = a.name ?? (def as { name?: string }).name;
|
|
@@ -374,7 +479,7 @@ function constraintSteps(obj: DeclaredPgObject, live: PgDiffObject): PgStep[] {
|
|
|
374
479
|
const b = remaining.splice(at, 1)[0]!;
|
|
375
480
|
const name = b.name ?? a.name;
|
|
376
481
|
if (!name) return;
|
|
377
|
-
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)));
|
|
378
483
|
if (b.comment !== a.comment) {
|
|
379
484
|
out.push(step(`COMMENT ON CONSTRAINT ${quoteIdent(name)} ON ${domain ? "DOMAIN " : ""}${target} IS ${a.comment === undefined ? "NULL" : pgString(a.comment)}`, "metadata", "SQLPG216"));
|
|
380
485
|
}
|
|
@@ -425,6 +530,31 @@ export const isDestructive = (c: PgChange): boolean => c.rule === "SQLPG204";
|
|
|
425
530
|
/** Changes that replace the object: drop it and create it again from its declaration. */
|
|
426
531
|
export const isRecreate = (c: PgChange): boolean => c.rule === "SQLPG252" || c.rule === "SQLPG243" || c.rule === "SQLPG281";
|
|
427
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
|
+
|
|
428
558
|
/**
|
|
429
559
|
* The statements for an object that exists on the server, change by change.
|
|
430
560
|
* `live` is the server's definition, matched to the declaration (a renamed
|
|
@@ -448,6 +578,7 @@ export function alterSteps(
|
|
|
448
578
|
const declaredColumns = new Map(d.columns.map((c) => [c.name, c]));
|
|
449
579
|
let constraintsDone = false;
|
|
450
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] ?? "")));
|
|
451
582
|
|
|
452
583
|
for (const c of changes) {
|
|
453
584
|
const no = (why: string) => unsupported.push({ change: c, why });
|
|
@@ -460,7 +591,7 @@ export function alterSteps(
|
|
|
460
591
|
continue;
|
|
461
592
|
}
|
|
462
593
|
if (c.field.startsWith("constraints.")) {
|
|
463
|
-
if (!constraintsDone) steps.push(...constraintSteps(obj, live));
|
|
594
|
+
if (!constraintsDone) steps.push(...constraintSteps(obj, live, notNullChecks));
|
|
464
595
|
constraintsDone = true;
|
|
465
596
|
continue;
|
|
466
597
|
}
|
|
@@ -491,7 +622,9 @@ export function alterSteps(
|
|
|
491
622
|
steps.push(step(def?.default !== undefined ? `${column} SET DEFAULT ${def.default}` : `${column} DROP DEFAULT`, c.class, c.rule));
|
|
492
623
|
break;
|
|
493
624
|
case "SQLPG210":
|
|
494
|
-
|
|
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));
|
|
495
628
|
break;
|
|
496
629
|
case "SQLPG211":
|
|
497
630
|
steps.push(step(`${column} DROP NOT NULL`, c.class, c.rule));
|
|
@@ -723,6 +856,9 @@ export interface PgStatementPlan {
|
|
|
723
856
|
drops: PgDropStatement[];
|
|
724
857
|
}
|
|
725
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
|
+
|
|
726
862
|
/** Drop order: what reads from a table before the table, types after the tables using them, a schema last. */
|
|
727
863
|
const DROP_ORDER: Record<CanonicalKind, number> = {
|
|
728
864
|
grant: 0,
|
|
@@ -745,6 +881,12 @@ const DROP_ORDER: Record<CanonicalKind, number> = {
|
|
|
745
881
|
role: 11,
|
|
746
882
|
};
|
|
747
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
|
+
|
|
748
890
|
/**
|
|
749
891
|
* The statements that take the current schema to the declared one, from the
|
|
750
892
|
* classified changes between them. Pure: nothing is read or sent. The applier
|
|
@@ -790,9 +932,13 @@ export function planPgStatements(input: PgStatementPlanInput): PgStatementPlan {
|
|
|
790
932
|
const onRecreated = obj.canonical.kind === "index" && recreatedRelations.has(String(obj.canonical.fields.table));
|
|
791
933
|
if (!live || onRecreated) {
|
|
792
934
|
const base = obj.canonical.kind === "extension" && !live ? input.extensionComment?.(obj.canonical.name) : undefined;
|
|
793
|
-
// An index on a table that exists is built under the scan timeout (SQLPG240, SQLPG241)
|
|
794
|
-
|
|
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));
|
|
795
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 };
|
|
796
942
|
return { verdict: "create", obj, changes: mine, steps, ...withLive };
|
|
797
943
|
}
|
|
798
944
|
|