@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
@@ -152,6 +152,8 @@ export function postgresDeclarations(content: string, fileName = "file.ts"): Pos
152
152
  case "function":
153
153
  case "procedure":
154
154
  case "trigger":
155
+ case "policy":
156
+ case "role":
155
157
  sqlName = text(node.name.span);
156
158
  kind = node.statement;
157
159
  break;
@@ -143,13 +143,13 @@ export function declarations(content: string, fileName = "file.ts"): Declared[]
143
143
  const tokens = tokenize(found.parts);
144
144
  const node = parseCreate(tokens);
145
145
  const columns =
146
- node.statement === "database"
146
+ node.statement === "database" || node.statement === "function"
147
147
  ? []
148
- : node.columns.map((c) => ({
148
+ : (node.statement === "dictionary" ? node.attributes : node.columns).map((c) => ({
149
149
  name: c.name || spanText(found, tokens, c.nameSpan, source) || "",
150
150
  ...(spanText(found, tokens, c.type, source) ? { type: spanText(found, tokens, c.type, source)! } : {}),
151
151
  }));
152
- const engine = node.engine?.name || spanText(found, tokens, node.engine?.nameSpan, source);
152
+ const engine = node.statement === "dictionary" || node.statement === "function" ? undefined : node.engine?.name || spanText(found, tokens, node.engine?.nameSpan, source);
153
153
  out.push({
154
154
  name: decl.name.text,
155
155
  tag: found.tag,
package/src/mcp/index.ts CHANGED
@@ -10,7 +10,7 @@ import type { McpResourceContribution, McpToolContribution } from "@intentius/ch
10
10
  import { createCatalogResource, createDiffTool } from "@intentius/chant/lexicon-plugin-helpers";
11
11
  import { sqlSerializer } from "../serializer";
12
12
  import { catalogIndex } from "../lsp/catalog";
13
- import { database, table, view } from "../clickhouse/entities";
13
+ import { database, dictionary, func, grant, policy, role, table, user, view } from "../clickhouse/entities";
14
14
  import { SqlSyntaxError } from "../clickhouse/tokens";
15
15
  import { existsSync, readFileSync } from "fs";
16
16
  import { diffSchemas } from "../clickhouse/plan/diff";
@@ -122,12 +122,12 @@ const searchTool: McpToolContribution = {
122
122
  const parseTool: McpToolContribution = {
123
123
  name: "parse-ddl",
124
124
  description:
125
- "Parse one CREATE statement the way its tag does (dialect clickhouse, the default: table, view, database; dialect postgres: schema, table, index, view, sequence, type, domain, extension), and return the entity it builds (columns, engine, keys, settings, and for a view its lineage). A statement that does not parse comes back as the error with its line and column, the same one SQLCH001 (ClickHouse) or SQLPG001 (Postgres) reports. The statement is plain DDL: there are no interpolations.",
125
+ "Parse one CREATE statement the way its tag does (dialect clickhouse, the default: table, view, database, dictionary, func, user, role, policy, grant; dialect postgres: schema, table, index, view, sequence, type, domain, extension), and return the entity it builds (columns, engine, keys, settings, and for a view its lineage). A statement that does not parse comes back as the error with its line and column, the same one SQLCH001 (ClickHouse) or SQLPG001 (Postgres) reports. The statement is plain DDL: there are no interpolations.",
126
126
  inputSchema: {
127
127
  type: "object",
128
128
  properties: {
129
129
  dialect: dialectProp,
130
- tag: { type: "string", enum: [...new Set(["table", "view", "database", ...POSTGRES_TAGS])], description: "The tag the statement belongs in (ClickHouse: table, view, database; Postgres: " + POSTGRES_TAGS.join(", ") + ")" },
130
+ tag: { type: "string", enum: [...new Set(["table", "view", "database", "dictionary", "func", "user", "role", "policy", "grant", ...POSTGRES_TAGS])], description: "The tag the statement belongs in (ClickHouse: table, view, database, dictionary, func, user, role, policy, grant; Postgres: " + POSTGRES_TAGS.join(", ") + ")" },
131
131
  ddl: { type: "string", description: "The CREATE statement" },
132
132
  },
133
133
  required: ["tag", "ddl"],
@@ -136,8 +136,8 @@ const parseTool: McpToolContribution = {
136
136
  const ddl = String(params.ddl ?? "");
137
137
  const tag = String(params.tag);
138
138
  if (asDialect(params.dialect) === "postgres") return postgresParse(tag, ddl, position);
139
- const build = tag === "table" ? table : tag === "view" ? view : tag === "database" ? database : undefined;
140
- if (!build) throw new Error("tag must be table, view or database");
139
+ const build = tag === "table" ? table : tag === "view" ? view : tag === "database" ? database : tag === "dictionary" ? dictionary : tag === "func" ? func : tag === "user" ? user : tag === "role" ? role : tag === "policy" ? policy : tag === "grant" ? grant : undefined;
140
+ if (!build) throw new Error("tag must be table, view, database, dictionary, func, user, role, policy or grant");
141
141
  const strings = Object.assign([ddl], { raw: [ddl] }) as unknown as TemplateStringsArray;
142
142
  try {
143
143
  const entity = build(strings);
@@ -276,7 +276,7 @@ export function sqlMcpResources(): McpResourceContribution[] {
276
276
  createCatalogResource(
277
277
  new URL("../plugin.ts", import.meta.url).href,
278
278
  "SQL Entity Catalog",
279
- "The entity kinds the sql lexicon declares (database, table, view, materialized view)",
279
+ "The entity kinds the sql lexicon declares (database, table, view, materialized view, dictionary, function, user, role, row policy, grant)",
280
280
  "lexicon-sql.json",
281
281
  "sql",
282
282
  ),
@@ -81,7 +81,7 @@ export function postgresSearch(params: Record<string, unknown>): unknown {
81
81
  };
82
82
  }
83
83
 
84
- const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension, func: pg.func, procedure: pg.procedure, trigger: pg.trigger } as const;
84
+ const TAGS = { schema: pg.schema, table: pg.table, index: pg.index, view: pg.view, sequence: pg.sequence, type: pg.type, domain: pg.domain, extension: pg.extension, func: pg.func, procedure: pg.procedure, trigger: pg.trigger, policy: pg.policy, role: pg.role, grant: pg.grant } as const;
85
85
  export const POSTGRES_TAGS = Object.keys(TAGS);
86
86
 
87
87
  /** Parse one statement with the Postgres tag; an error comes back as SQLPG001 with its line and column. */
@@ -150,6 +150,31 @@ const PG_AFTER = build("postgres", [APP, USERS_V2, EMAIL_IDX, ORDERS_RENAMED], {
150
150
  describe("Postgres", () => {
151
151
  const doc = diffStatements(PG_BEFORE, PG_AFTER, { marker: MARKER, env: "prod" });
152
152
 
153
+ test("a unique index on a table that exists carries its duplicate count, one on a new table none (#3686)", () => {
154
+ const unique: Obj = { export: "usersNick", type: "Postgres::Index", dependsOn: ["users"], ddl: "CREATE UNIQUE INDEX CONCURRENTLY users_nick_key ON app.users (nick) WHERE nick <> ''" };
155
+ const d = diffStatements(build("postgres", [APP, USERS]), build("postgres", [APP, USERS, unique]));
156
+ const built = d.steps.find((s): s is StatementStep => s.kind === "statement" && s.sql.startsWith("CREATE UNIQUE INDEX"));
157
+ expect(built?.precheck).toEqual({
158
+ sql: "SELECT (SELECT count(*) FROM (SELECT 1 FROM app.users WHERE nick IS NOT NULL AND (nick <> '') GROUP BY nick HAVING count(*) > 1) AS duplicates) AS n",
159
+ detail: "values of (nick) held by more than one row, which a unique index fails on",
160
+ });
161
+ expect(renderStatements(d)).toContain(`-- pre-check, must return 0 (values of (nick) held by more than one row, which a unique index fails on): ${built!.precheck!.sql}`);
162
+ const fresh = diffStatements(build("postgres", [APP]), build("postgres", [APP, USERS, unique]));
163
+ expect(fresh.steps.some((s) => s.kind === "statement" && s.precheck !== undefined)).toBe(false);
164
+ });
165
+
166
+ test("SET NOT NULL renders as its four statements, the first with the NULL count (#3686)", () => {
167
+ const d = diffStatements(build("postgres", [APP, USERS]), build("postgres", [APP, { ...USERS, ddl: USERS.ddl.replace("nick text", "nick text NOT NULL") }]));
168
+ const st = d.steps.filter((s): s is StatementStep => s.kind === "statement");
169
+ expect(st.map((s) => [s.sql, s.rule, s.class])).toEqual([
170
+ ["ALTER TABLE app.users ADD CONSTRAINT nick__chant_nn CHECK (nick IS NOT NULL) NOT VALID", "SQLPG217", "metadata"],
171
+ ["ALTER TABLE app.users VALIDATE CONSTRAINT nick__chant_nn", "SQLPG220", "validate"],
172
+ ["ALTER TABLE app.users ALTER COLUMN nick SET NOT NULL", "SQLPG210", "metadata"],
173
+ ["ALTER TABLE app.users DROP CONSTRAINT nick__chant_nn", "SQLPG223", "metadata"],
174
+ ]);
175
+ expect(st[0]!.precheck?.sql).toBe("SELECT count(*) AS n FROM app.users WHERE nick IS NULL");
176
+ });
177
+
153
178
  test("the statements are ordered, each with its change's rule and class and whether it may run in a transaction", () => {
154
179
  expect(doc).toMatchObject({ dialect: "postgres", defaultSchema: "public", major: 18 });
155
180
  expect(doc.steps.filter((s): s is StatementStep => s.kind === "statement").map((s) => [s.object, s.sql, s.rule, s.class, s.transactional])).toEqual([
@@ -234,9 +234,17 @@ function postgresStatements(beforeJson: string, afterJson: string, options: Diff
234
234
  class: s.class,
235
235
  transactional: s.transactional,
236
236
  ...(s.rule === "SQLPG204" || isDestructive(entry.changes, s.rule) ? { destructive: true } : {}),
237
+ ...(s.precheck ? { precheck: { sql: s.precheck.sql, detail: s.precheck.detail } } : {}),
237
238
  });
238
239
  }
239
240
  }
241
+ // Privileges (#3681), after the objects they are on: one statement each, under the declaration behind it.
242
+ const afterByExport = new Map(after.map((o) => [o.exportName, o]));
243
+ for (const c of diff.access ?? []) {
244
+ const decl = c.exports.map((e) => afterByExport.get(e)).find((o) => o !== undefined);
245
+ const where = decl ? at(decl.exportName, decl.type, decl.name) : at(c.change.object, c.target.kind === "default" ? POSTGRES_ENTITY_TYPES.defaultPrivileges : POSTGRES_ENTITY_TYPES.grant, c.change.object.slice(4));
246
+ for (const sql of c.sql) steps.push({ kind: "statement", ...where, sql, rule: c.change.rule, class: c.change.class, transactional: true });
247
+ }
240
248
  const beforeByExport = new Map(before.map((o) => [o.exportName, o]));
241
249
  for (const d of plan.drops) {
242
250
  const o = beforeByExport.get(d.key);
@@ -71,6 +71,7 @@ export async function rebuildRun(args: ClickHouseRebuildArgs, signal: AbortSigna
71
71
  retainMs: parseDuration(args.retain ?? "7d"),
72
72
  ...(args.mutationTimeout ? { mutationTimeoutMs: parseDuration(args.mutationTimeout) } : {}),
73
73
  ...(args.replicaTimeout ? { replicaTimeoutMs: parseDuration(args.replicaTimeout) } : {}),
74
+ ...(args.cutoverTimeout ? { cutoverTimeoutMs: parseDuration(args.cutoverTimeout) } : {}),
74
75
  log: deps.log ?? ((line: string) => console.log(line)),
75
76
  ...(signal ? { signal } : {}),
76
77
  ...(runId ? { runId } : {}),
@@ -17,8 +17,8 @@ import { resolveBoundTarget } from "../../postgres/live/bind";
17
17
  import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../../postgres/live/client";
18
18
  import { DEFAULT_POSTGRES_APPLY_TIMEOUTS, declaredObjects } from "../../postgres/apply/apply";
19
19
  import { PG_CLASSIFIER_RULES } from "../../postgres/plan/rules";
20
- import { declaredTable, migrationPlanDigest, observeMigration } from "../../postgres/migrate/observe";
21
- import { backfill, verifyMigration, type BackfillDeps, type BackfillResult, type VerifyResult } from "../../postgres/migrate/backfill";
20
+ import { declaredTable, migrationPlanDigest, MigrationRefusal, observeMigration } from "../../postgres/migrate/observe";
21
+ import { backfill, MigrationVerificationError, verifyMigration, type BackfillDeps, type BackfillResult, type VerifyResult } from "../../postgres/migrate/backfill";
22
22
  import {
23
23
  carryOver,
24
24
  compensate,
@@ -60,8 +60,33 @@ async function loadConfig(cwd: string): Promise<Pick<ChantConfig, "sql" | "owner
60
60
  }
61
61
  }
62
62
 
63
+ /**
64
+ * With `onFailure: "keep"` (#3687) no onFailure drops what the expand added,
65
+ * so a step that fails says what a person does instead: a refusal or a
66
+ * failed verification would otherwise fail every run after it the same way.
67
+ */
68
+ const ON_FAILURE_DROPS = "onFailure drops what the expand added, and the next run starts again.";
69
+
70
+ function noting(args: PostgresMigrationArgs, err: unknown): unknown {
71
+ if (args.keepOnFailure && (err instanceof MigrationRefusal || err instanceof MigrationVerificationError)) {
72
+ const kept =
73
+ `This migration keeps what the expand added on failure (onFailure: "keep"), so nothing drops it: ` +
74
+ `to start again from a new column, run the Op once with onFailure: "drop" (the default), whose onFailure drops it when this step fails again.`;
75
+ err.message = err.message.includes(ON_FAILURE_DROPS) ? err.message.replace(ON_FAILURE_DROPS, kept) : `${err.message.replace(/\.?$/, ".")} ${kept}`;
76
+ }
77
+ return err;
78
+ }
79
+
63
80
  /** Bind the server, connect and read the declaration, run `body` with it, then close the connection. */
64
81
  async function withRun<T>(args: PostgresMigrationArgs, signal: AbortSignal | undefined, deps: PostgresMigrationDeps, body: (run: MigrationRun) => Promise<T>): Promise<T> {
82
+ try {
83
+ return await bound(args, signal, deps, body);
84
+ } catch (err) {
85
+ throw noting(args, err);
86
+ }
87
+ }
88
+
89
+ async function bound<T>(args: PostgresMigrationArgs, signal: AbortSignal | undefined, deps: PostgresMigrationDeps, body: (run: MigrationRun) => Promise<T>): Promise<T> {
65
90
  const cwd = args.cwd ?? process.cwd();
66
91
  const config = deps.config ?? (await loadConfig(cwd));
67
92
  const marker = resolveOwnershipMarker(args, config, "PostgresMigrationOp");
@@ -31,6 +31,7 @@ const rebuildArgs = z.strictObject({
31
31
  retain: z.string().optional(),
32
32
  mutationTimeout: z.string().optional(),
33
33
  replicaTimeout: z.string().optional(),
34
+ cutoverTimeout: z.string().optional(),
34
35
  stack: z.string().optional(),
35
36
  ownershipEnv: z.string().optional(),
36
37
  cwd: z.string().optional(),
@@ -127,6 +128,7 @@ const migrationArgs = z.strictObject({
127
128
  stack: z.string().optional(),
128
129
  ownershipEnv: z.string().optional(),
129
130
  cwd: z.string().optional(),
131
+ keepOnFailure: z.boolean().optional(),
130
132
  });
131
133
 
132
134
  const migrationState = z.enum(["migrate", "switched", "done"]);
package/src/plugin.ts CHANGED
@@ -18,7 +18,7 @@ import { completions } from "./lsp/completions";
18
18
  import { hover } from "./lsp/hover";
19
19
  import { sqlMcpResources, sqlMcpTools } from "./mcp";
20
20
  import { sqlConfigSchema } from "./config";
21
- import { ClickHouseSqlParser } from "./clickhouse/import/parser";
21
+ import { SqlFileParser, dialectOption } from "./import-parser";
22
22
  import { sqlTemplateGenerator } from "./import-generator";
23
23
  import { sqlCommands } from "./clickhouse/plan/commands";
24
24
  import { sqlDeepNormalizationHooks } from "./clickhouse/plan/deep";
@@ -97,15 +97,20 @@ export const sqlPlugin: LexiconPlugin = {
97
97
  isTag: true,
98
98
  description: "A CREATE VIEW or CREATE MATERIALIZED VIEW, parsed into a view entity with its lineage",
99
99
  },
100
+ { name: "dictionary", isTag: true, description: "A ClickHouse CREATE DICTIONARY, parsed into a dictionary entity with its attributes, key, source and layout" },
101
+ { name: "user", isTag: true, description: "A ClickHouse CREATE USER, parsed into a user entity; its password is the environment's" },
100
102
  { name: "schema", isTag: true, description: "A Postgres CREATE SCHEMA, parsed into a schema entity" },
101
103
  { name: "index", isTag: true, description: "A Postgres CREATE INDEX, parsed into an index entity" },
102
104
  { name: "sequence", isTag: true, description: "A Postgres CREATE SEQUENCE, parsed into a sequence entity" },
103
105
  { name: "type", isTag: true, description: "A Postgres CREATE TYPE ... AS ENUM, parsed into an enum entity" },
104
106
  { name: "domain", isTag: true, description: "A Postgres CREATE DOMAIN, parsed into a domain entity" },
105
107
  { name: "extension", isTag: true, description: "A Postgres CREATE EXTENSION, parsed into an extension entity" },
106
- { name: "func", isTag: true, description: "A Postgres CREATE FUNCTION, parsed into a function entity with its body kept verbatim" },
108
+ { name: "func", isTag: true, description: "A Postgres CREATE FUNCTION, parsed into a function entity with its body kept verbatim, or a ClickHouse CREATE FUNCTION (a named lambda)" },
107
109
  { name: "procedure", isTag: true, description: "A Postgres CREATE PROCEDURE, parsed into a procedure entity with its body kept verbatim" },
108
110
  { name: "trigger", isTag: true, description: "A Postgres CREATE TRIGGER, parsed into a trigger entity on its table" },
111
+ { name: "policy", isTag: true, description: "A Postgres CREATE POLICY, parsed into a row-level security policy on its table, or a ClickHouse CREATE ROW POLICY" },
112
+ { name: "role", isTag: true, description: "A Postgres CREATE ROLE without a password or memberships, parsed into a role entity, or a ClickHouse CREATE ROLE" },
113
+ { name: "grant", isTag: true, description: "A Postgres GRANT, REVOKE or ALTER DEFAULT PRIVILEGES, parsed into the privileges it declares, or a ClickHouse GRANT of privileges or roles" },
109
114
  {
110
115
  name: "literal",
111
116
  isTag: false,
@@ -152,9 +157,28 @@ export const sqlPlugin: LexiconPlugin = {
152
157
  return sqlMcpResources();
153
158
  },
154
159
 
155
- /** `chant import schema.sql`: a file of ClickHouse CREATE statements. */
156
- templateParser() {
157
- return new ClickHouseSqlParser();
160
+ /** `chant import schema.sql`: a file of Postgres or ClickHouse DDL (`./import-parser.ts`). */
161
+ templateParser(options) {
162
+ return new SqlFileParser(dialectOption(options));
163
+ },
164
+
165
+ parserOptions() {
166
+ return [
167
+ {
168
+ name: "dialect",
169
+ type: "string" as const,
170
+ description: "the DDL's dialect, clickhouse or postgres; read off its statements when omitted",
171
+ },
172
+ ];
173
+ },
174
+
175
+ /**
176
+ * The `.sql` files in the source directory, read into declarations beside
177
+ * the tagged templates (`./files/build-root.ts`, #3646).
178
+ */
179
+ async buildRoots(ctx) {
180
+ const { sqlFilesBuildRoot } = await import("./files/build-root");
181
+ return sqlFilesBuildRoot(ctx);
158
182
  },
159
183
 
160
184
  /** One generator for both dialects: each IR resource's type says whose declarations it is. */
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Access against a live server (#3681): a declared role, a table with
3
+ * row-level security and a policy, grants on a schema, a table, a column and
4
+ * a function, a revoke from PUBLIC, and default privileges, applied where the
5
+ * profile manages access; then a grant and a revoke made by hand are drift
6
+ * the plan reports and an apply undoes; a profile that does not manage access
7
+ * plans none of it; and an import writes the access back as declarations.
8
+ *
9
+ * The server: `CHANT_SQL_E2E_POSTGRES_URL` (a `postgres://user@host:port/db`
10
+ * URL, with `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running one, such as
11
+ * the one `chant emulator up --lexicon sql` starts. Without it, a throwaway
12
+ * server at the pin, skipped cleanly when Docker is not available. The test
13
+ * writes only to the schema `chant_e2e_3681` and the roles
14
+ * `chant_e2e_3681_reader` and `chant_e2e_3681_writer`, all dropped afterwards.
15
+ */
16
+
17
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { dockerAvailable, startTestPostgres, type TestPostgres } from "../testing/server";
22
+ import { connectPostgres, type PostgresClient, type PostgresEndpoint } from "../live/client";
23
+ import { readLiveSchema } from "../live/catalog";
24
+ import { planPgAgainstServer } from "../plan/commands";
25
+ import { postgresApply } from "../../op/activities/postgres-apply";
26
+ import { objectsToIR } from "../import/ir";
27
+ import { PostgresGenerator } from "../import/generator";
28
+ import { importedAccess } from "./import";
29
+
30
+ const S = "chant_e2e_3681";
31
+ const READER = "chant_e2e_3681_reader";
32
+ const WRITER = "chant_e2e_3681_writer";
33
+ const given = process.env.CHANT_SQL_E2E_POSTGRES_URL;
34
+ const enabled = given ? true : await dockerAvailable();
35
+ const dir = mkdtempSync(join(tmpdir(), "chant-pg-access-"));
36
+ let server: TestPostgres | undefined;
37
+ let endpoint: PostgresEndpoint;
38
+ let admin: PostgresClient;
39
+
40
+ const cleanup = async () => {
41
+ await admin.query(`DROP SCHEMA IF EXISTS ${S} CASCADE`);
42
+ for (const r of [READER, WRITER]) await admin.query(`DROP ROLE IF EXISTS ${r}`);
43
+ };
44
+
45
+ beforeAll(async () => {
46
+ if (!enabled) return;
47
+ if (given) endpoint = { url: given, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
48
+ else {
49
+ server = await startTestPostgres();
50
+ endpoint = server.endpoint();
51
+ }
52
+ admin = await connectPostgres(endpoint);
53
+ await cleanup();
54
+ // The writer is the environment's: provisioned outside the declarations, only named in them.
55
+ await admin.query(`CREATE ROLE ${WRITER} NOLOGIN`);
56
+ }, 600_000);
57
+
58
+ afterAll(async () => {
59
+ if (enabled) {
60
+ await cleanup().catch(() => undefined);
61
+ await admin?.end();
62
+ }
63
+ await server?.stop();
64
+ rmSync(dir, { recursive: true, force: true });
65
+ });
66
+
67
+ const MARKER = { stack: "e2e3681", env: "test" };
68
+ const profile = (access: boolean) => ({
69
+ config: { ownership: MARKER, sql: { profiles: { e2e: { url: endpoint.url, password: { env: "PG_E2E_PASSWORD" }, schemas: [S], ...(access ? { access: true } : {}) } } } },
70
+ env: { PG_E2E_PASSWORD: endpoint.password ?? "" },
71
+ });
72
+
73
+ interface Obj {
74
+ export: string;
75
+ type: string;
76
+ ddl: string;
77
+ dependsOn?: string[];
78
+ }
79
+
80
+ const OBJECTS: Obj[] = [
81
+ { export: "app", type: "Postgres::Schema", ddl: `CREATE SCHEMA ${S}` },
82
+ { export: "reader", type: "Postgres::Role", ddl: `CREATE ROLE ${READER} NOLOGIN` },
83
+ {
84
+ export: "orders",
85
+ type: "Postgres::Table",
86
+ dependsOn: ["app"],
87
+ ddl: `CREATE TABLE ${S}.orders (id bigint PRIMARY KEY, tenant text NOT NULL, note varchar(40));\nALTER TABLE ${S}.orders ENABLE ROW LEVEL SECURITY`,
88
+ },
89
+ {
90
+ export: "ordersTenant",
91
+ type: "Postgres::Policy",
92
+ dependsOn: ["orders", "reader"],
93
+ ddl: `CREATE POLICY orders_tenant ON ${S}.orders FOR SELECT TO ${READER} USING (tenant = current_setting('app.tenant', true) AND note <> 'hidden')`,
94
+ },
95
+ { export: "total", type: "Postgres::Function", dependsOn: ["app"], ddl: `CREATE FUNCTION ${S}.total() RETURNS bigint LANGUAGE sql AS 'SELECT 1::bigint'` },
96
+ { export: "useApp", type: "Postgres::Grant", dependsOn: ["app", "reader"], ddl: `GRANT USAGE ON SCHEMA ${S} TO ${READER}` },
97
+ { export: "readOrders", type: "Postgres::Grant", dependsOn: ["orders", "reader"], ddl: `GRANT SELECT ON ${S}.orders TO ${READER}` },
98
+ { export: "writeNote", type: "Postgres::Grant", dependsOn: ["orders"], ddl: `GRANT UPDATE (note) ON ${S}.orders TO ${WRITER}` },
99
+ { export: "totalPrivate", type: "Postgres::Grant", dependsOn: ["total"], ddl: `REVOKE EXECUTE ON FUNCTION ${S}.total() FROM PUBLIC` },
100
+ { export: "totalReader", type: "Postgres::Grant", dependsOn: ["total", "reader"], ddl: `GRANT EXECUTE ON FUNCTION ${S}.total() TO ${READER}` },
101
+ { export: "readNewTables", type: "Postgres::DefaultPrivileges", dependsOn: ["app", "reader"], ddl: `ALTER DEFAULT PRIVILEGES IN SCHEMA ${S} GRANT SELECT ON TABLES TO ${READER}` },
102
+ ];
103
+
104
+ function writeBuild(file: string, objects: Obj[]): string {
105
+ const path = join(dir, file);
106
+ writeFileSync(path, JSON.stringify({ dialect: "postgres", postgresMajor: 18, objects: objects.map((o) => ({ dependsOn: [], ...o })) }));
107
+ return path;
108
+ }
109
+
110
+ const apply = (path: string, access = true) => postgresApply({ buildPath: path, environment: "e2e" }, undefined, { ...profile(access), log: () => undefined });
111
+ const plan = (path: string, access = true) => planPgAgainstServer("e2e", path, profile(access));
112
+ const rules = async (path: string) => (await plan(path)).changes.map((c) => [c.object, c.rule]);
113
+
114
+ describe.skipIf(!enabled)("access on a live server", () => {
115
+ test("applied, then drift by hand reported and undone, unmanaged left alone, imported back", async () => {
116
+ const build = writeBuild("v1.json", OBJECTS);
117
+
118
+ // A profile that does not manage access: the access declarations are filtered, the table is created without row-level security.
119
+ const off = await apply(build, false);
120
+ expect(off.failed).toEqual([]);
121
+ expect(off.notAttempted.map((n) => n.reason)).toEqual(Array(8).fill("filtered"));
122
+ expect(off.notAttempted).toHaveLength(8);
123
+ expect((await admin.query<{ rls: boolean }>(`SELECT relrowsecurity AS rls FROM pg_class WHERE oid = '${S}.orders'::regclass`))[0]!.rls).toBe(false);
124
+ const unmanaged = await plan(build, false);
125
+ expect(unmanaged.changes).toEqual([]);
126
+ expect(unmanaged.hints.join(" ")).toContain("9 access declarations are not planned");
127
+
128
+ // Managed: every access declaration applied.
129
+ expect(await rules(build)).toEqual([
130
+ [`reader (${READER})`, "SQLPG200"],
131
+ [`orders (${S}.orders)`, "SQLPG293"],
132
+ [`ordersTenant (${S}.orders_tenant ON ${S}.orders)`, "SQLPG290"],
133
+ [`default privileges in ${S} on tables TO ${READER}`, "SQLPG298"],
134
+ [`relation ${S}.orders (note) TO ${WRITER}`, "SQLPG296"],
135
+ [`relation ${S}.orders TO ${READER}`, "SQLPG296"],
136
+ [`routine ${S}.total() TO PUBLIC`, "SQLPG297"],
137
+ [`routine ${S}.total() TO ${READER}`, "SQLPG296"],
138
+ [`schema ${S} TO ${READER}`, "SQLPG296"],
139
+ ]);
140
+ const on = await apply(build);
141
+ expect(on.failed).toEqual([]);
142
+ expect(on.applied.map((a) => [a.name, a.action])).toEqual([
143
+ [S, "unchanged"],
144
+ [`${S}.total()`, "unchanged"],
145
+ [READER, "created"],
146
+ [`${S}.orders`, "updated"],
147
+ [`orders_tenant ON ${S}.orders`, "created"],
148
+ [`grant usage on schema ${S} to ${READER}`, "updated"],
149
+ [`grant select on table ${S}.orders to ${READER}`, "updated"],
150
+ [`grant update(note) on table ${S}.orders to ${WRITER}`, "updated"],
151
+ [`revoke execute on function ${S}.total() from public`, "updated"],
152
+ [`grant execute on function ${S}.total() to ${READER}`, "updated"],
153
+ [`default privileges grant select on tables in ${S} to ${READER}`, "updated"],
154
+ ]);
155
+ expect(await rules(build)).toEqual([]);
156
+ const can = async (sql: string) => (await admin.query<{ ok: boolean }>(sql))[0]!.ok;
157
+ expect(await can(`SELECT has_table_privilege('${READER}', '${S}.orders', 'SELECT') AS ok`)).toBe(true);
158
+ expect(await can(`SELECT has_column_privilege('${WRITER}', '${S}.orders', 'note', 'UPDATE') AS ok`)).toBe(true);
159
+ expect(await can(`SELECT has_function_privilege('public', '${S}.total()', 'EXECUTE') AS ok`)).toBe(false);
160
+ expect(await can(`SELECT relrowsecurity AS ok FROM pg_class WHERE oid = '${S}.orders'::regclass`)).toBe(true);
161
+
162
+ // By hand: a grant nobody declared, and a declared grant revoked. The plan reports both; an apply undoes both.
163
+ await admin.query(`GRANT INSERT ON ${S}.orders TO ${WRITER}`);
164
+ await admin.query(`REVOKE SELECT ON ${S}.orders FROM ${READER}`);
165
+ expect(await rules(build)).toEqual([
166
+ [`relation ${S}.orders TO ${READER}`, "SQLPG296"],
167
+ [`relation ${S}.orders TO ${WRITER}`, "SQLPG297"],
168
+ ]);
169
+ const undo = await apply(build);
170
+ expect(undo.failed).toEqual([]);
171
+ expect(undo.statements.map((s) => s.sql)).toEqual(expect.arrayContaining([`GRANT SELECT ON TABLE ${S}.orders TO ${READER}`, `REVOKE INSERT ON TABLE ${S}.orders FROM ${WRITER}`]));
172
+ expect(await rules(build)).toEqual([]);
173
+
174
+ // A policy changed by hand is drift too.
175
+ await admin.query(`ALTER POLICY orders_tenant ON ${S}.orders USING (true)`);
176
+ expect(await rules(build)).toEqual([[`ordersTenant (${S}.orders_tenant ON ${S}.orders)`, "SQLPG291"]]);
177
+ await apply(build);
178
+ expect(await rules(build)).toEqual([]);
179
+
180
+ // Import: the policy and the row-level security with the table, the privileges as grant declarations.
181
+ const live = await readLiveSchema(admin, { schemas: [S], access: true });
182
+ const access = await importedAccess(admin, live, [S]);
183
+ const [file] = new PostgresGenerator().generate(objectsToIR([...live.map((o) => ({ type: o.type, schema: o.schema, name: o.name, ddl: o.statement })), ...access]));
184
+ expect(file!.content).toContain("export const ordersTenant = policy`");
185
+ expect(file!.content).toContain(`ALTER TABLE \${chantE2e3681Schema}.orders ENABLE ROW LEVEL SECURITY`);
186
+ expect(file!.content).toContain(`GRANT SELECT ON TABLE \${orders} TO ${READER}`);
187
+ expect(file!.content).toContain(`REVOKE EXECUTE ON FUNCTION \${total}() FROM PUBLIC`);
188
+ expect(file!.content).toContain(`ALTER DEFAULT PRIVILEGES IN SCHEMA \${chantE2e3681Schema} GRANT SELECT ON TABLES TO ${READER}`);
189
+ expect(file!.content).toContain(`GRANT USAGE ON SCHEMA \${chantE2e3681Schema} TO ${READER}`);
190
+ }, 600_000);
191
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Row-level security, roles, grants and default privileges (#3681): what the
3
+ * tags parse and refuse, how the declarations add up to access, and how a
4
+ * change is classified and made.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { func, grant, policy, role, schema, table } from "../entities";
9
+ import { diffObject, keyedByQualifiedName, pgSchemaFromLive, type PgSchemaObject } from "../plan/schema";
10
+ import { diffPgSchemas, matchPgObjects } from "../plan/diff";
11
+ import { accessScoped } from "../plan/commands";
12
+ import { alterSteps } from "../apply/statements";
13
+ import { declaredObjects } from "../apply/apply";
14
+ import type { LivePgObject } from "../live/catalog";
15
+ import { declaredAccess, diffAccess } from "./acl";
16
+
17
+ const app = schema`CREATE SCHEMA app`;
18
+ const reader = role`CREATE ROLE app_reader NOLOGIN`;
19
+ const orders = table`
20
+ CREATE TABLE ${app}.orders (id bigint PRIMARY KEY, tenant text NOT NULL, note text);
21
+ ALTER TABLE ${app}.orders ENABLE ROW LEVEL SECURITY;
22
+ ALTER TABLE ${app}.orders FORCE ROW LEVEL SECURITY`;
23
+
24
+ describe("the tags", () => {
25
+ test("a table's row-level security, a policy, a role", () => {
26
+ expect(orders.props).toMatchObject({ rowSecurity: true, forceRowSecurity: true });
27
+ const p = policy`CREATE POLICY tenant_rows ON ${orders} AS RESTRICTIVE FOR SELECT TO ${reader}, PUBLIC USING (${orders.columns.tenant} = current_setting('app.tenant'))`;
28
+ expect(p.entityType).toBe("Postgres::Policy");
29
+ expect(p.props).toMatchObject({ schema: "app", name: "tenant_rows", tableName: "app.orders", permissive: false, command: "select", roles: ["app_reader", "public"], using: "tenant = current_setting('app.tenant')" });
30
+ expect(p.dependsOn).toEqual(expect.arrayContaining([orders, reader]));
31
+ expect(reader.props).toMatchObject({ name: "app_reader", options: ["nologin"] });
32
+ });
33
+
34
+ test("grants on a schema, a table's columns and a routine, a revoke, default privileges", () => {
35
+ const f = func`CREATE FUNCTION ${app}.total(n int) RETURNS bigint LANGUAGE sql AS 'select 1'`;
36
+ expect(grant`GRANT USAGE ON SCHEMA ${app} TO ${reader}`.props).toMatchObject({ action: "grant", on: "schema", objectNames: ["app"], grantees: ["app_reader"] });
37
+ expect(grant`GRANT SELECT, UPDATE (note) ON ${orders} TO writer WITH GRANT OPTION`.props).toMatchObject({
38
+ on: "table",
39
+ privileges: [{ privilege: "select" }, { privilege: "update", columns: ["note"] }],
40
+ withGrantOption: true,
41
+ });
42
+ expect(grant`REVOKE EXECUTE ON FUNCTION ${f} FROM PUBLIC`.props).toMatchObject({ action: "revoke", objectNames: ["app.total(int)"], grantees: ["public"] });
43
+ const d = grant`ALTER DEFAULT PRIVILEGES IN SCHEMA ${app} GRANT SELECT ON TABLES TO ${reader}`;
44
+ expect(d.entityType).toBe("Postgres::DefaultPrivileges");
45
+ expect(d.props).toMatchObject({ inSchemas: ["app"], on: "tables", privileges: ["select"] });
46
+ });
47
+
48
+ test("secrets, memberships, ALL ... IN SCHEMA and an unnamed overload are refused", () => {
49
+ expect(() => role`CREATE ROLE r LOGIN PASSWORD 'x'`).toThrow(/environment's secret/);
50
+ expect(() => role`CREATE ROLE r IN ROLE admins`).toThrow(/environment's/);
51
+ expect(() => grant`GRANT admins TO r`).toThrow(/membership/);
52
+ expect(() => grant`GRANT SELECT ON ALL TABLES IN SCHEMA app TO r`).toThrow(/ALTER DEFAULT PRIVILEGES/);
53
+ expect(() => grant`GRANT EXECUTE ON FUNCTION app.f TO r`).toThrow(/parameter types/);
54
+ expect(() => table`ALTER TABLE app.t ENABLE ROW LEVEL SECURITY`).toThrow(/after the table's CREATE TABLE/);
55
+ });
56
+ });
57
+
58
+ // ── Access ─────────────────────────────────────────────────────────────
59
+
60
+ const decl = (type: string, ddl: string, key = ddl): PgSchemaObject => ({ key, canonical: { ...diffObject(type, ddl, "public"), exportName: key } });
61
+ const T = (ddl: string) => decl("Postgres::Table", ddl);
62
+ const F = (ddl: string) => decl("Postgres::Function", ddl);
63
+ const G = (ddl: string) => decl("Postgres::Grant", ddl);
64
+ const D = (ddl: string) => decl("Postgres::DefaultPrivileges", ddl);
65
+
66
+ const entries = (objects: PgSchemaObject[]) =>
67
+ Object.fromEntries([...declaredAccess(objects, { major: 18 }).state].map(([k, e]) => [k, [...e.privileges].map(([p, g]) => `${p}${g ? "*" : ""}`).sort().join(",")]));
68
+
69
+ describe("the access declarations add up to", () => {
70
+ test("a routine's EXECUTE to PUBLIC unless revoked; grants added, revokes taken away, in order", () => {
71
+ expect(entries([F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'")])).toEqual({ "routine app.f() TO PUBLIC": "execute" });
72
+ expect(
73
+ entries([
74
+ F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'"),
75
+ T("CREATE TABLE app.t (a int, b int)"),
76
+ G("REVOKE EXECUTE ON FUNCTION app.f() FROM PUBLIC"),
77
+ G("GRANT ALL ON app.t TO r WITH GRANT OPTION"),
78
+ G("REVOKE GRANT OPTION FOR DELETE ON app.t FROM r"),
79
+ G("REVOKE TRUNCATE, MAINTAIN ON app.t FROM r"),
80
+ G("GRANT SELECT (b) ON app.t TO s"),
81
+ ]),
82
+ ).toEqual({ "relation app.t TO r": "delete,insert*,references*,select*,trigger*,update*", "relation app.t (b) TO s": "select" });
83
+ });
84
+
85
+ test("default privileges are compared themselves, and given to the objects chant creates", () => {
86
+ expect(
87
+ entries([
88
+ T("CREATE TABLE app.t (a int)"),
89
+ D("ALTER DEFAULT PRIVILEGES IN SCHEMA app GRANT SELECT ON TABLES TO r"),
90
+ D("ALTER DEFAULT PRIVILEGES REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC"),
91
+ F("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'"),
92
+ ]),
93
+ ).toEqual({ "default privileges in app on tables TO r": "select", "relation app.t TO r": "select" });
94
+ });
95
+
96
+ test("a change is a GRANT, a REVOKE, or ALTER DEFAULT PRIVILEGES", () => {
97
+ const before = declaredAccess([T("CREATE TABLE app.t (a int)"), G("GRANT SELECT, INSERT ON app.t TO r")], { major: 18 });
98
+ const after = declaredAccess([T("CREATE TABLE app.t (a int)"), G("GRANT SELECT ON app.t TO r WITH GRANT OPTION"), G("GRANT USAGE ON SCHEMA app TO r"), D("ALTER DEFAULT PRIVILEGES FOR ROLE owner IN SCHEMA app GRANT SELECT ON TABLES TO r")], { major: 18 });
99
+ expect(diffAccess(before.state, after.state).map((c) => [c.change.object, c.change.rule, c.sql])).toEqual([
100
+ ["acl default privileges for owner in app on tables TO r", "SQLPG298", ["ALTER DEFAULT PRIVILEGES FOR ROLE owner IN SCHEMA app GRANT SELECT ON TABLES TO r"]],
101
+ ["acl relation app.t TO r", "SQLPG296", ["REVOKE INSERT ON TABLE app.t FROM r", "GRANT SELECT ON TABLE app.t TO r WITH GRANT OPTION"]],
102
+ ["acl schema app TO r", "SQLPG296", ["GRANT USAGE ON SCHEMA app TO r"]],
103
+ ]);
104
+ });
105
+
106
+ test("between two builds, a new function's EXECUTE to PUBLIC is not a change", () => {
107
+ const f = "CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS 'select 1'";
108
+ expect(diffPgSchemas([], [F(f)]).changes.map((c) => c.rule)).toEqual(["SQLPG200"]);
109
+ expect(diffPgSchemas([], [F(f), G("REVOKE EXECUTE ON FUNCTION app.f() FROM PUBLIC")]).changes.map((c) => [c.object, c.rule])).toEqual([
110
+ [f, "SQLPG200"],
111
+ ["acl routine app.f() TO PUBLIC", "SQLPG297"],
112
+ ]);
113
+ });
114
+
115
+ test("a profile that does not manage access leaves out the access declarations and a table's row-level security", () => {
116
+ const { kept, unmanaged } = accessScoped(keyedByQualifiedName([T("CREATE TABLE app.t (a int);\nALTER TABLE app.t ENABLE ROW LEVEL SECURITY"), G("GRANT SELECT ON app.t TO r")]), false);
117
+ expect(unmanaged).toBe(2);
118
+ expect(kept.map((o) => [o.canonical.kind, o.canonical.fields.rowSecurity])).toEqual([["table", undefined]]);
119
+ });
120
+ });
121
+
122
+ // ── Policies, row-level security, roles ────────────────────────────────
123
+
124
+ const live = (type: string, statement: string) => pgSchemaFromLive([{ type, statement, name: "", oid: "1" } as LivePgObject], "public");
125
+ function steps(type: string, liveDdl: string, declaredDdl: string): string[] {
126
+ const [obj] = declaredObjects(JSON.stringify({ dialect: "postgres", objects: [{ export: "x", type, ddl: declaredDdl, dependsOn: [] }] }));
127
+ const after = keyedByQualifiedName([{ key: "x", canonical: obj!.canonical }]);
128
+ const before = live(type, liveDdl);
129
+ const m = matchPgObjects(before, after).find((x) => x.kind === "matched")!;
130
+ return alterSteps(obj!, m.before!.canonical, diffPgSchemas(before, after).changes, { major: 18 }).steps.map((s) => s.sql);
131
+ }
132
+
133
+ describe("the classifier and the statements", () => {
134
+ const P = "Postgres::Policy";
135
+ test("a policy's roles and expressions are ALTER POLICY; its command is a drop and a create", () => {
136
+ const p1 = "CREATE POLICY p ON app.t FOR SELECT TO r USING (a > 0)";
137
+ expect(steps(P, p1, "CREATE POLICY p ON app.t FOR SELECT TO r, s USING (a > 1)")).toEqual(["ALTER POLICY p ON app.t TO r, s USING (a > 1)"]);
138
+ expect(steps(P, p1, "CREATE POLICY p ON app.t FOR UPDATE TO r USING (a > 0)").slice(0, 2)).toEqual(["DROP POLICY p ON app.t", "CREATE POLICY p ON app.t FOR UPDATE TO r USING (a > 0)"]);
139
+ expect(diffPgSchemas([], keyedByQualifiedName([decl(P, p1)])).changes.map((c) => c.rule)).toEqual(["SQLPG290"]);
140
+ expect(diffPgSchemas(live(P, p1), []).changes.map((c) => c.rule)).toEqual(["SQLPG292"]);
141
+ });
142
+
143
+ test("row-level security toggled, and a role's attributes changed", () => {
144
+ expect(steps("Postgres::Table", "CREATE TABLE app.t (a int)", "CREATE TABLE app.t (a int);\nALTER TABLE app.t ENABLE ROW LEVEL SECURITY")).toEqual(["ALTER TABLE app.t ENABLE ROW LEVEL SECURITY"]);
145
+ expect(steps("Postgres::Role", "CREATE ROLE r", "CREATE ROLE r LOGIN NOINHERIT CONNECTION LIMIT 5")).toEqual(["ALTER ROLE r WITH LOGIN NOINHERIT CONNECTION LIMIT 5"]);
146
+ });
147
+ });