@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.
Files changed (259) 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 +70 -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 +2 -1
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/integrity.json +8 -8
  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/sqlpg001.d.ts.map +1 -1
  72. package/dist/lint/rules/templates.d.ts +1 -1
  73. package/dist/lint/rules/templates.d.ts.map +1 -1
  74. package/dist/lsp/postgres-template.d.ts.map +1 -1
  75. package/dist/manifest.json +1 -1
  76. package/dist/meta.json +50 -0
  77. package/dist/okf/index.md +11 -1
  78. package/dist/okf/rules/SQLCH001.md +2 -2
  79. package/dist/okf/types/Dictionary.md +9 -0
  80. package/dist/okf/types/Function.md +9 -0
  81. package/dist/okf/types/Grant.md +9 -0
  82. package/dist/okf/types/PostgresDefaultPrivileges.md +9 -0
  83. package/dist/okf/types/PostgresGrant.md +9 -0
  84. package/dist/okf/types/PostgresPolicy.md +9 -0
  85. package/dist/okf/types/PostgresRole.md +9 -0
  86. package/dist/okf/types/Role.md +9 -0
  87. package/dist/okf/types/RowPolicy.md +9 -0
  88. package/dist/okf/types/User.md +9 -0
  89. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  90. package/dist/op/activities/postgres-migration.d.ts.map +1 -1
  91. package/dist/op/activity-contracts.d.ts +19 -0
  92. package/dist/op/activity-contracts.d.ts.map +1 -1
  93. package/dist/plugin.d.ts.map +1 -1
  94. package/dist/postgres/access/acl.d.ts +119 -0
  95. package/dist/postgres/access/acl.d.ts.map +1 -0
  96. package/dist/postgres/access/import.d.ts +13 -0
  97. package/dist/postgres/access/import.d.ts.map +1 -0
  98. package/dist/postgres/access/live.d.ts +24 -0
  99. package/dist/postgres/access/live.d.ts.map +1 -0
  100. package/dist/postgres/apply/apply.d.ts.map +1 -1
  101. package/dist/postgres/apply/statements.d.ts +71 -0
  102. package/dist/postgres/apply/statements.d.ts.map +1 -1
  103. package/dist/postgres/entities.d.ts +106 -4
  104. package/dist/postgres/entities.d.ts.map +1 -1
  105. package/dist/postgres/entity-types.d.ts +4 -0
  106. package/dist/postgres/entity-types.d.ts.map +1 -1
  107. package/dist/postgres/import/generator.d.ts +37 -1
  108. package/dist/postgres/import/generator.d.ts.map +1 -1
  109. package/dist/postgres/import/live-export.d.ts +6 -0
  110. package/dist/postgres/import/live-export.d.ts.map +1 -1
  111. package/dist/postgres/live/bind.d.ts +2 -0
  112. package/dist/postgres/live/bind.d.ts.map +1 -1
  113. package/dist/postgres/live/catalog.d.ts +11 -2
  114. package/dist/postgres/live/catalog.d.ts.map +1 -1
  115. package/dist/postgres/live/client.d.ts +3 -0
  116. package/dist/postgres/live/client.d.ts.map +1 -1
  117. package/dist/postgres/live/describe-resources.d.ts +9 -0
  118. package/dist/postgres/live/describe-resources.d.ts.map +1 -1
  119. package/dist/postgres/migrate/op.d.ts +29 -0
  120. package/dist/postgres/migrate/op.d.ts.map +1 -1
  121. package/dist/postgres/parser.d.ts +76 -3
  122. package/dist/postgres/parser.d.ts.map +1 -1
  123. package/dist/postgres/plan/commands.d.ts +12 -0
  124. package/dist/postgres/plan/commands.d.ts.map +1 -1
  125. package/dist/postgres/plan/deep.d.ts.map +1 -1
  126. package/dist/postgres/plan/diff.d.ts +10 -1
  127. package/dist/postgres/plan/diff.d.ts.map +1 -1
  128. package/dist/postgres/plan/normalize.d.ts +8 -1
  129. package/dist/postgres/plan/normalize.d.ts.map +1 -1
  130. package/dist/postgres/plan/rules.d.ts +8 -0
  131. package/dist/postgres/plan/rules.d.ts.map +1 -1
  132. package/dist/postgres/plan/schema.d.ts +2 -1
  133. package/dist/postgres/plan/schema.d.ts.map +1 -1
  134. package/dist/postgres/plan/server-normalize.d.ts +4 -1
  135. package/dist/postgres/plan/server-normalize.d.ts.map +1 -1
  136. package/dist/postgres.d.ts +1 -1
  137. package/dist/postgres.d.ts.map +1 -1
  138. package/dist/rules/postgres-templates.ts +2 -2
  139. package/dist/rules/sqlch001.ts +26 -4
  140. package/dist/rules/sqlch002.ts +3 -2
  141. package/dist/rules/sqlpg001.ts +7 -4
  142. package/dist/rules/templates.ts +2 -2
  143. package/dist/serializer.d.ts +2 -1
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/sql-files.d.ts +79 -0
  146. package/dist/sql-files.d.ts.map +1 -0
  147. package/dist/token-source.d.ts +88 -0
  148. package/dist/token-source.d.ts.map +1 -0
  149. package/dist/validate.d.ts.map +1 -1
  150. package/package.json +2 -2
  151. package/src/clickhouse/access.test.ts +203 -0
  152. package/src/clickhouse/access.ts +595 -0
  153. package/src/clickhouse/apply/apply.ts +29 -20
  154. package/src/clickhouse/apply/statements.ts +105 -8
  155. package/src/clickhouse/dictionary.test.ts +177 -0
  156. package/src/clickhouse/entities.ts +356 -6
  157. package/src/clickhouse/function.test.ts +91 -0
  158. package/src/clickhouse/http.ts +11 -2
  159. package/src/clickhouse/import/generator.ts +81 -46
  160. package/src/clickhouse/import/import.test.ts +9 -3
  161. package/src/clickhouse/import/ir.ts +6 -2
  162. package/src/clickhouse/import/parser.ts +10 -18
  163. package/src/clickhouse/live/access.e2e.test.ts +218 -0
  164. package/src/clickhouse/live/bind.ts +17 -6
  165. package/src/clickhouse/live/catalog.ts +106 -10
  166. package/src/clickhouse/live/describe-resources.ts +23 -10
  167. package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
  168. package/src/clickhouse/live/functions.e2e.test.ts +155 -0
  169. package/src/clickhouse/live/live.test.ts +2 -2
  170. package/src/clickhouse/parser.ts +136 -4
  171. package/src/clickhouse/plan/commands.ts +18 -7
  172. package/src/clickhouse/plan/deep.ts +71 -11
  173. package/src/clickhouse/plan/diff.ts +65 -2
  174. package/src/clickhouse/plan/normalize.ts +200 -6
  175. package/src/clickhouse/plan/report.ts +14 -3
  176. package/src/clickhouse/plan/rules.ts +44 -1
  177. package/src/clickhouse/plan/schema.ts +40 -17
  178. package/src/clickhouse/plan/server-format.ts +1 -1
  179. package/src/clickhouse/rebuild/backfill.ts +5 -25
  180. package/src/clickhouse/rebuild/observe.ts +7 -3
  181. package/src/clickhouse/rebuild/op.ts +9 -0
  182. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
  183. package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
  184. package/src/clickhouse/rebuild/steps.ts +136 -1
  185. package/src/clickhouse/testing/fake-server.ts +2 -0
  186. package/src/clickhouse/testing/writable-server.ts +2 -0
  187. package/src/clickhouse/topology.ts +17 -2
  188. package/src/clickhouse.ts +19 -0
  189. package/src/codegen/classifier-page.ts +2 -0
  190. package/src/codegen/registry.ts +10 -0
  191. package/src/config.ts +22 -3
  192. package/src/core/references.ts +5 -3
  193. package/src/core/statements.ts +11 -1
  194. package/src/files/build-root.ts +62 -0
  195. package/src/files/clickhouse.ts +52 -0
  196. package/src/files/common.ts +54 -0
  197. package/src/files/postgres.ts +371 -0
  198. package/src/generated/lexicon-sql.json +50 -0
  199. package/src/import-parser.ts +61 -0
  200. package/src/index.ts +14 -1
  201. package/src/lint/rules/postgres-templates.ts +2 -2
  202. package/src/lint/rules/sqlch001.ts +26 -4
  203. package/src/lint/rules/sqlch002.ts +3 -2
  204. package/src/lint/rules/sqlpg001.ts +7 -4
  205. package/src/lint/rules/templates.ts +2 -2
  206. package/src/lsp/postgres-template.ts +2 -0
  207. package/src/lsp/template.ts +3 -3
  208. package/src/mcp/index.ts +6 -6
  209. package/src/mcp/postgres.ts +1 -1
  210. package/src/migration-statements.test.ts +25 -0
  211. package/src/migration-statements.ts +8 -0
  212. package/src/op/activities/clickhouse-rebuild.ts +1 -0
  213. package/src/op/activities/postgres-migration.ts +27 -2
  214. package/src/op/activity-contracts.ts +2 -0
  215. package/src/plugin.ts +29 -5
  216. package/src/postgres/access/access.e2e.test.ts +191 -0
  217. package/src/postgres/access/access.test.ts +147 -0
  218. package/src/postgres/access/acl.ts +400 -0
  219. package/src/postgres/access/import.ts +64 -0
  220. package/src/postgres/access/live.ts +123 -0
  221. package/src/postgres/apply/apply.ts +117 -4
  222. package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
  223. package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
  224. package/src/postgres/apply/statements.test.ts +138 -6
  225. package/src/postgres/apply/statements.ts +210 -18
  226. package/src/postgres/entities.ts +287 -11
  227. package/src/postgres/entity-types.ts +4 -0
  228. package/src/postgres/import/generator.ts +129 -46
  229. package/src/postgres/import/live-export.ts +12 -2
  230. package/src/postgres/live/bind.ts +20 -6
  231. package/src/postgres/live/catalog.ts +79 -3
  232. package/src/postgres/live/client.ts +10 -2
  233. package/src/postgres/live/describe-resources.ts +27 -3
  234. package/src/postgres/migrate/migrate.test.ts +24 -1
  235. package/src/postgres/migrate/op.ts +80 -35
  236. package/src/postgres/migrate/outer.e2e.test.ts +163 -0
  237. package/src/postgres/parser.ts +247 -8
  238. package/src/postgres/plan/commands.ts +60 -7
  239. package/src/postgres/plan/deep.ts +24 -3
  240. package/src/postgres/plan/diff.ts +71 -6
  241. package/src/postgres/plan/normalize.ts +115 -2
  242. package/src/postgres/plan/plan.test.ts +2 -2
  243. package/src/postgres/plan/rules.ts +49 -5
  244. package/src/postgres/plan/schema.ts +4 -2
  245. package/src/postgres/plan/server-normalize.ts +29 -1
  246. package/src/postgres.ts +11 -0
  247. package/src/serializer.ts +14 -9
  248. package/src/skill-defs.ts +1 -1
  249. package/src/skills/chant-sql-postgres-migration.md +3 -1
  250. package/src/skills/chant-sql-postgres-plan.md +3 -3
  251. package/src/skills/chant-sql-postgres.md +9 -2
  252. package/src/skills/chant-sql-rebuild.md +3 -2
  253. package/src/skills/chant-sql.md +7 -1
  254. package/src/sql-files.test.ts +188 -0
  255. package/src/sql-files.ts +218 -0
  256. package/src/token-source.e2e.test.ts +142 -0
  257. package/src/token-source.test.ts +119 -0
  258. package/src/token-source.ts +189 -0
  259. 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(["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";
@@ -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 type { CheckDef, ColumnDef, DomainProps, ExclusionDef, ForeignKeyDef, KeyDef, 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";
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
- { ...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
+ },
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
- 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)));
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
- 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));
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
- const objects = input.declared.map((obj): PgObjectStatements => {
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); one on a new table is part of the create.
748
- const how = mine.find((c) => c.rule === "SQLPG200" || c.rule === "SQLPG240" || c.rule === "SQLPG241");
749
- const steps = createSteps(obj, input.marker, { ...(base !== undefined ? { base } : {}), ...(how ? { cls: how.class, rule: how.rule } : {}) });
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;