@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.
Files changed (183) hide show
  1. package/dist/clickhouse/access.d.ts +132 -0
  2. package/dist/clickhouse/access.d.ts.map +1 -0
  3. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  4. package/dist/clickhouse/apply/statements.d.ts +2 -0
  5. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  6. package/dist/clickhouse/entities.d.ts +171 -2
  7. package/dist/clickhouse/entities.d.ts.map +1 -1
  8. package/dist/clickhouse/http.d.ts +3 -0
  9. package/dist/clickhouse/http.d.ts.map +1 -1
  10. package/dist/clickhouse/import/generator.d.ts +28 -0
  11. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  12. package/dist/clickhouse/import/ir.d.ts.map +1 -1
  13. package/dist/clickhouse/import/parser.d.ts +3 -3
  14. package/dist/clickhouse/import/parser.d.ts.map +1 -1
  15. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  16. package/dist/clickhouse/live/catalog.d.ts +41 -6
  17. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  18. package/dist/clickhouse/live/describe-resources.d.ts +3 -2
  19. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  20. package/dist/clickhouse/parser.d.ts +54 -3
  21. package/dist/clickhouse/parser.d.ts.map +1 -1
  22. package/dist/clickhouse/plan/commands.d.ts +3 -1
  23. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  24. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  25. package/dist/clickhouse/plan/diff.d.ts +12 -0
  26. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  27. package/dist/clickhouse/plan/normalize.d.ts +61 -1
  28. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  29. package/dist/clickhouse/plan/report.d.ts.map +1 -1
  30. package/dist/clickhouse/plan/rules.d.ts +7 -0
  31. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  32. package/dist/clickhouse/plan/schema.d.ts +8 -4
  33. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  34. package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
  35. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  36. package/dist/clickhouse/rebuild/observe.d.ts +7 -3
  37. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  38. package/dist/clickhouse/rebuild/op.d.ts +8 -0
  39. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  40. package/dist/clickhouse/rebuild/steps.d.ts +48 -0
  41. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  42. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  43. package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
  44. package/dist/clickhouse/topology.d.ts.map +1 -1
  45. package/dist/clickhouse.d.ts +1 -1
  46. package/dist/clickhouse.d.ts.map +1 -1
  47. package/dist/codegen/classifier-page.d.ts.map +1 -1
  48. package/dist/codegen/registry.d.ts.map +1 -1
  49. package/dist/config.d.ts +50 -7
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/core/references.d.ts +4 -2
  52. package/dist/core/references.d.ts.map +1 -1
  53. package/dist/core/statements.d.ts +11 -0
  54. package/dist/core/statements.d.ts.map +1 -1
  55. package/dist/files/build-root.d.ts +26 -0
  56. package/dist/files/build-root.d.ts.map +1 -0
  57. package/dist/files/clickhouse.d.ts +19 -0
  58. package/dist/files/clickhouse.d.ts.map +1 -0
  59. package/dist/files/common.d.ts +25 -0
  60. package/dist/files/common.d.ts.map +1 -0
  61. package/dist/files/postgres.d.ts +36 -0
  62. package/dist/files/postgres.d.ts.map +1 -0
  63. package/dist/import-parser.d.ts +25 -0
  64. package/dist/import-parser.d.ts.map +1 -0
  65. package/dist/index.d.ts +3 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/integrity.json +6 -6
  68. package/dist/lint/rules/sqlch001.d.ts +1 -1
  69. package/dist/lint/rules/sqlch001.d.ts.map +1 -1
  70. package/dist/lint/rules/sqlch002.d.ts.map +1 -1
  71. package/dist/lint/rules/templates.d.ts +1 -1
  72. package/dist/lint/rules/templates.d.ts.map +1 -1
  73. package/dist/manifest.json +1 -1
  74. package/dist/meta.json +30 -0
  75. package/dist/okf/index.md +7 -1
  76. package/dist/okf/rules/SQLCH001.md +2 -2
  77. package/dist/okf/types/Dictionary.md +9 -0
  78. package/dist/okf/types/Function.md +9 -0
  79. package/dist/okf/types/Grant.md +9 -0
  80. package/dist/okf/types/Role.md +9 -0
  81. package/dist/okf/types/RowPolicy.md +9 -0
  82. package/dist/okf/types/User.md +9 -0
  83. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  84. package/dist/op/activity-contracts.d.ts +9 -0
  85. package/dist/op/activity-contracts.d.ts.map +1 -1
  86. package/dist/plugin.d.ts.map +1 -1
  87. package/dist/postgres/apply/statements.d.ts +68 -0
  88. package/dist/postgres/apply/statements.d.ts.map +1 -1
  89. package/dist/postgres/import/generator.d.ts +33 -0
  90. package/dist/postgres/import/generator.d.ts.map +1 -1
  91. package/dist/postgres/live/bind.d.ts.map +1 -1
  92. package/dist/postgres/live/client.d.ts +3 -0
  93. package/dist/postgres/live/client.d.ts.map +1 -1
  94. package/dist/rules/sqlch001.ts +26 -4
  95. package/dist/rules/sqlch002.ts +3 -2
  96. package/dist/rules/templates.ts +2 -2
  97. package/dist/serializer.d.ts +2 -1
  98. package/dist/serializer.d.ts.map +1 -1
  99. package/dist/sql-files.d.ts +79 -0
  100. package/dist/sql-files.d.ts.map +1 -0
  101. package/dist/token-source.d.ts +88 -0
  102. package/dist/token-source.d.ts.map +1 -0
  103. package/dist/validate.d.ts.map +1 -1
  104. package/package.json +2 -2
  105. package/src/clickhouse/access.test.ts +203 -0
  106. package/src/clickhouse/access.ts +595 -0
  107. package/src/clickhouse/apply/apply.ts +29 -20
  108. package/src/clickhouse/apply/statements.ts +105 -8
  109. package/src/clickhouse/dictionary.test.ts +177 -0
  110. package/src/clickhouse/entities.ts +356 -6
  111. package/src/clickhouse/function.test.ts +91 -0
  112. package/src/clickhouse/http.ts +11 -2
  113. package/src/clickhouse/import/generator.ts +81 -46
  114. package/src/clickhouse/import/import.test.ts +9 -3
  115. package/src/clickhouse/import/ir.ts +6 -2
  116. package/src/clickhouse/import/parser.ts +10 -18
  117. package/src/clickhouse/live/access.e2e.test.ts +218 -0
  118. package/src/clickhouse/live/bind.ts +17 -6
  119. package/src/clickhouse/live/catalog.ts +106 -10
  120. package/src/clickhouse/live/describe-resources.ts +23 -10
  121. package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
  122. package/src/clickhouse/live/functions.e2e.test.ts +155 -0
  123. package/src/clickhouse/live/live.test.ts +2 -2
  124. package/src/clickhouse/parser.ts +136 -4
  125. package/src/clickhouse/plan/commands.ts +18 -7
  126. package/src/clickhouse/plan/deep.ts +71 -11
  127. package/src/clickhouse/plan/diff.ts +65 -2
  128. package/src/clickhouse/plan/normalize.ts +200 -6
  129. package/src/clickhouse/plan/report.ts +14 -3
  130. package/src/clickhouse/plan/rules.ts +44 -1
  131. package/src/clickhouse/plan/schema.ts +40 -17
  132. package/src/clickhouse/plan/server-format.ts +1 -1
  133. package/src/clickhouse/rebuild/backfill.ts +5 -25
  134. package/src/clickhouse/rebuild/observe.ts +7 -3
  135. package/src/clickhouse/rebuild/op.ts +9 -0
  136. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
  137. package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
  138. package/src/clickhouse/rebuild/steps.ts +136 -1
  139. package/src/clickhouse/testing/fake-server.ts +2 -0
  140. package/src/clickhouse/testing/writable-server.ts +2 -0
  141. package/src/clickhouse/topology.ts +17 -2
  142. package/src/clickhouse.ts +19 -0
  143. package/src/codegen/classifier-page.ts +2 -0
  144. package/src/codegen/registry.ts +6 -0
  145. package/src/config.ts +12 -3
  146. package/src/core/references.ts +5 -3
  147. package/src/core/statements.ts +11 -1
  148. package/src/files/build-root.ts +62 -0
  149. package/src/files/clickhouse.ts +52 -0
  150. package/src/files/common.ts +54 -0
  151. package/src/files/postgres.ts +371 -0
  152. package/src/generated/lexicon-sql.json +30 -0
  153. package/src/import-parser.ts +61 -0
  154. package/src/index.ts +15 -2
  155. package/src/lint/rules/sqlch001.ts +26 -4
  156. package/src/lint/rules/sqlch002.ts +3 -2
  157. package/src/lint/rules/templates.ts +2 -2
  158. package/src/lsp/template.ts +3 -3
  159. package/src/mcp/index.ts +6 -6
  160. package/src/migration-statements.test.ts +25 -0
  161. package/src/migration-statements.ts +1 -0
  162. package/src/op/activities/clickhouse-rebuild.ts +1 -0
  163. package/src/op/activity-contracts.ts +1 -0
  164. package/src/plugin.ts +29 -8
  165. package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
  166. package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
  167. package/src/postgres/apply/statements.test.ts +138 -6
  168. package/src/postgres/apply/statements.ts +156 -10
  169. package/src/postgres/import/generator.ts +118 -45
  170. package/src/postgres/live/bind.ts +17 -6
  171. package/src/postgres/live/client.ts +10 -2
  172. package/src/postgres/plan/plan.test.ts +2 -2
  173. package/src/postgres/plan/rules.ts +5 -5
  174. package/src/serializer.ts +14 -9
  175. package/src/skills/chant-sql-postgres-plan.md +3 -3
  176. package/src/skills/chant-sql-rebuild.md +3 -2
  177. package/src/skills/chant-sql.md +7 -1
  178. package/src/sql-files.test.ts +188 -0
  179. package/src/sql-files.ts +218 -0
  180. package/src/token-source.e2e.test.ts +142 -0
  181. package/src/token-source.test.ts +119 -0
  182. package/src/token-source.ts +189 -0
  183. 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(["ALTER TABLE app.o ADD FOREIGN KEY (u) REFERENCES app.users (id) ON DELETE CASCADE"]);
71
- expect(r.steps[0]).toMatchObject({ class: "validate", rule: "SQLPG219" });
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
- { sql: "CREATE UNIQUE INDEX CONCURRENTLY t_email_key ON app.t (email)", class: "concurrently", rule: "SQLPG221", transactional: false, buildsIndex: "app.t_email_key" },
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 type { CheckDef, ColumnDef, DomainProps, ExclusionDef, ForeignKeyDef, KeyDef, PolicyProps, SequenceProps, TableProps } from "../entities";
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
- { ...step(build, "concurrently", "SQLPG221", false), buildsIndex: index },
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
- const remaining = [...live.constraints];
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
- steps.push(step(`${column} SET NOT NULL`, c.class, c.rule));
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); one on a new table is part of the create.
794
- const how = mine.find((c) => c.rule === "SQLPG200" || c.rule === "SQLPG240" || c.rule === "SQLPG241");
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