@intentius/chant-lexicon-sql 0.117.0 → 0.118.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/dist/clickhouse/access.d.ts +132 -0
  2. package/dist/clickhouse/access.d.ts.map +1 -0
  3. package/dist/clickhouse/apply/apply.d.ts.map +1 -1
  4. package/dist/clickhouse/apply/statements.d.ts +2 -0
  5. package/dist/clickhouse/apply/statements.d.ts.map +1 -1
  6. package/dist/clickhouse/entities.d.ts +171 -2
  7. package/dist/clickhouse/entities.d.ts.map +1 -1
  8. package/dist/clickhouse/http.d.ts +3 -0
  9. package/dist/clickhouse/http.d.ts.map +1 -1
  10. package/dist/clickhouse/import/generator.d.ts +28 -0
  11. package/dist/clickhouse/import/generator.d.ts.map +1 -1
  12. package/dist/clickhouse/import/ir.d.ts.map +1 -1
  13. package/dist/clickhouse/import/parser.d.ts +3 -3
  14. package/dist/clickhouse/import/parser.d.ts.map +1 -1
  15. package/dist/clickhouse/live/bind.d.ts.map +1 -1
  16. package/dist/clickhouse/live/catalog.d.ts +41 -6
  17. package/dist/clickhouse/live/catalog.d.ts.map +1 -1
  18. package/dist/clickhouse/live/describe-resources.d.ts +3 -2
  19. package/dist/clickhouse/live/describe-resources.d.ts.map +1 -1
  20. package/dist/clickhouse/parser.d.ts +54 -3
  21. package/dist/clickhouse/parser.d.ts.map +1 -1
  22. package/dist/clickhouse/plan/commands.d.ts +3 -1
  23. package/dist/clickhouse/plan/commands.d.ts.map +1 -1
  24. package/dist/clickhouse/plan/deep.d.ts.map +1 -1
  25. package/dist/clickhouse/plan/diff.d.ts +12 -0
  26. package/dist/clickhouse/plan/diff.d.ts.map +1 -1
  27. package/dist/clickhouse/plan/normalize.d.ts +61 -1
  28. package/dist/clickhouse/plan/normalize.d.ts.map +1 -1
  29. package/dist/clickhouse/plan/report.d.ts.map +1 -1
  30. package/dist/clickhouse/plan/rules.d.ts +7 -0
  31. package/dist/clickhouse/plan/rules.d.ts.map +1 -1
  32. package/dist/clickhouse/plan/schema.d.ts +8 -4
  33. package/dist/clickhouse/plan/schema.d.ts.map +1 -1
  34. package/dist/clickhouse/rebuild/backfill.d.ts +3 -1
  35. package/dist/clickhouse/rebuild/backfill.d.ts.map +1 -1
  36. package/dist/clickhouse/rebuild/observe.d.ts +7 -3
  37. package/dist/clickhouse/rebuild/observe.d.ts.map +1 -1
  38. package/dist/clickhouse/rebuild/op.d.ts +8 -0
  39. package/dist/clickhouse/rebuild/op.d.ts.map +1 -1
  40. package/dist/clickhouse/rebuild/steps.d.ts +48 -0
  41. package/dist/clickhouse/rebuild/steps.d.ts.map +1 -1
  42. package/dist/clickhouse/testing/fake-server.d.ts.map +1 -1
  43. package/dist/clickhouse/testing/writable-server.d.ts.map +1 -1
  44. package/dist/clickhouse/topology.d.ts.map +1 -1
  45. package/dist/clickhouse.d.ts +1 -1
  46. package/dist/clickhouse.d.ts.map +1 -1
  47. package/dist/codegen/classifier-page.d.ts.map +1 -1
  48. package/dist/codegen/registry.d.ts.map +1 -1
  49. package/dist/config.d.ts +50 -7
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/core/references.d.ts +4 -2
  52. package/dist/core/references.d.ts.map +1 -1
  53. package/dist/core/statements.d.ts +11 -0
  54. package/dist/core/statements.d.ts.map +1 -1
  55. package/dist/files/build-root.d.ts +26 -0
  56. package/dist/files/build-root.d.ts.map +1 -0
  57. package/dist/files/clickhouse.d.ts +19 -0
  58. package/dist/files/clickhouse.d.ts.map +1 -0
  59. package/dist/files/common.d.ts +25 -0
  60. package/dist/files/common.d.ts.map +1 -0
  61. package/dist/files/postgres.d.ts +36 -0
  62. package/dist/files/postgres.d.ts.map +1 -0
  63. package/dist/import-parser.d.ts +25 -0
  64. package/dist/import-parser.d.ts.map +1 -0
  65. package/dist/index.d.ts +3 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/integrity.json +6 -6
  68. package/dist/lint/rules/sqlch001.d.ts +1 -1
  69. package/dist/lint/rules/sqlch001.d.ts.map +1 -1
  70. package/dist/lint/rules/sqlch002.d.ts.map +1 -1
  71. package/dist/lint/rules/templates.d.ts +1 -1
  72. package/dist/lint/rules/templates.d.ts.map +1 -1
  73. package/dist/manifest.json +1 -1
  74. package/dist/meta.json +30 -0
  75. package/dist/okf/index.md +7 -1
  76. package/dist/okf/rules/SQLCH001.md +2 -2
  77. package/dist/okf/types/Dictionary.md +9 -0
  78. package/dist/okf/types/Function.md +9 -0
  79. package/dist/okf/types/Grant.md +9 -0
  80. package/dist/okf/types/Role.md +9 -0
  81. package/dist/okf/types/RowPolicy.md +9 -0
  82. package/dist/okf/types/User.md +9 -0
  83. package/dist/op/activities/clickhouse-rebuild.d.ts.map +1 -1
  84. package/dist/op/activity-contracts.d.ts +9 -0
  85. package/dist/op/activity-contracts.d.ts.map +1 -1
  86. package/dist/plugin.d.ts.map +1 -1
  87. package/dist/postgres/apply/statements.d.ts +68 -0
  88. package/dist/postgres/apply/statements.d.ts.map +1 -1
  89. package/dist/postgres/import/generator.d.ts +33 -0
  90. package/dist/postgres/import/generator.d.ts.map +1 -1
  91. package/dist/postgres/live/bind.d.ts.map +1 -1
  92. package/dist/postgres/live/client.d.ts +3 -0
  93. package/dist/postgres/live/client.d.ts.map +1 -1
  94. package/dist/rules/sqlch001.ts +26 -4
  95. package/dist/rules/sqlch002.ts +3 -2
  96. package/dist/rules/templates.ts +2 -2
  97. package/dist/serializer.d.ts +2 -1
  98. package/dist/serializer.d.ts.map +1 -1
  99. package/dist/sql-files.d.ts +79 -0
  100. package/dist/sql-files.d.ts.map +1 -0
  101. package/dist/token-source.d.ts +88 -0
  102. package/dist/token-source.d.ts.map +1 -0
  103. package/dist/validate.d.ts.map +1 -1
  104. package/package.json +2 -2
  105. package/src/clickhouse/access.test.ts +203 -0
  106. package/src/clickhouse/access.ts +595 -0
  107. package/src/clickhouse/apply/apply.ts +29 -20
  108. package/src/clickhouse/apply/statements.ts +105 -8
  109. package/src/clickhouse/dictionary.test.ts +177 -0
  110. package/src/clickhouse/entities.ts +356 -6
  111. package/src/clickhouse/function.test.ts +91 -0
  112. package/src/clickhouse/http.ts +11 -2
  113. package/src/clickhouse/import/generator.ts +81 -46
  114. package/src/clickhouse/import/import.test.ts +9 -3
  115. package/src/clickhouse/import/ir.ts +6 -2
  116. package/src/clickhouse/import/parser.ts +10 -18
  117. package/src/clickhouse/live/access.e2e.test.ts +218 -0
  118. package/src/clickhouse/live/bind.ts +17 -6
  119. package/src/clickhouse/live/catalog.ts +106 -10
  120. package/src/clickhouse/live/describe-resources.ts +23 -10
  121. package/src/clickhouse/live/dictionaries.e2e.test.ts +186 -0
  122. package/src/clickhouse/live/functions.e2e.test.ts +155 -0
  123. package/src/clickhouse/live/live.test.ts +2 -2
  124. package/src/clickhouse/parser.ts +136 -4
  125. package/src/clickhouse/plan/commands.ts +18 -7
  126. package/src/clickhouse/plan/deep.ts +71 -11
  127. package/src/clickhouse/plan/diff.ts +65 -2
  128. package/src/clickhouse/plan/normalize.ts +200 -6
  129. package/src/clickhouse/plan/report.ts +14 -3
  130. package/src/clickhouse/plan/rules.ts +44 -1
  131. package/src/clickhouse/plan/schema.ts +40 -17
  132. package/src/clickhouse/plan/server-format.ts +1 -1
  133. package/src/clickhouse/rebuild/backfill.ts +5 -25
  134. package/src/clickhouse/rebuild/observe.ts +7 -3
  135. package/src/clickhouse/rebuild/op.ts +9 -0
  136. package/src/clickhouse/rebuild/rebuild.e2e.test.ts +62 -0
  137. package/src/clickhouse/rebuild/rebuild.test.ts +54 -0
  138. package/src/clickhouse/rebuild/steps.ts +136 -1
  139. package/src/clickhouse/testing/fake-server.ts +2 -0
  140. package/src/clickhouse/testing/writable-server.ts +2 -0
  141. package/src/clickhouse/topology.ts +17 -2
  142. package/src/clickhouse.ts +19 -0
  143. package/src/codegen/classifier-page.ts +2 -0
  144. package/src/codegen/registry.ts +6 -0
  145. package/src/config.ts +12 -3
  146. package/src/core/references.ts +5 -3
  147. package/src/core/statements.ts +11 -1
  148. package/src/files/build-root.ts +62 -0
  149. package/src/files/clickhouse.ts +52 -0
  150. package/src/files/common.ts +54 -0
  151. package/src/files/postgres.ts +371 -0
  152. package/src/generated/lexicon-sql.json +30 -0
  153. package/src/import-parser.ts +61 -0
  154. package/src/index.ts +15 -2
  155. package/src/lint/rules/sqlch001.ts +26 -4
  156. package/src/lint/rules/sqlch002.ts +3 -2
  157. package/src/lint/rules/templates.ts +2 -2
  158. package/src/lsp/template.ts +3 -3
  159. package/src/mcp/index.ts +6 -6
  160. package/src/migration-statements.test.ts +25 -0
  161. package/src/migration-statements.ts +1 -0
  162. package/src/op/activities/clickhouse-rebuild.ts +1 -0
  163. package/src/op/activity-contracts.ts +1 -0
  164. package/src/plugin.ts +29 -8
  165. package/src/postgres/apply/constraint-sequences.e2e.test.ts +179 -0
  166. package/src/postgres/apply/safe-sequences.e2e.test.ts +176 -0
  167. package/src/postgres/apply/statements.test.ts +138 -6
  168. package/src/postgres/apply/statements.ts +156 -10
  169. package/src/postgres/import/generator.ts +118 -45
  170. package/src/postgres/live/bind.ts +17 -6
  171. package/src/postgres/live/client.ts +10 -2
  172. package/src/postgres/plan/plan.test.ts +2 -2
  173. package/src/postgres/plan/rules.ts +5 -5
  174. package/src/serializer.ts +14 -9
  175. package/src/skills/chant-sql-postgres-plan.md +3 -3
  176. package/src/skills/chant-sql-rebuild.md +3 -2
  177. package/src/skills/chant-sql.md +7 -1
  178. package/src/sql-files.test.ts +188 -0
  179. package/src/sql-files.ts +218 -0
  180. package/src/token-source.e2e.test.ts +142 -0
  181. package/src/token-source.test.ts +119 -0
  182. package/src/token-source.ts +189 -0
  183. package/src/validate.ts +6 -0
@@ -0,0 +1,188 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
2
+ import { mkdir, realpath, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { build } from "@intentius/chant/build";
6
+ import { readSqlFile, sqlFileDeclarations, SqlFileError } from "./sql-files";
7
+ import { sqlPlugin } from "./plugin";
8
+ import { sqlSerializer } from "./serializer";
9
+ import { guessDialect, SqlFileParser } from "./import-parser";
10
+
11
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
12
+
13
+ const PG_DDL = `BEGIN;
14
+ CREATE TYPE app.status AS ENUM ('placed', 'paid');
15
+ CREATE TABLE app.orders (
16
+ id bigint PRIMARY KEY,
17
+ user_id bigint NOT NULL,
18
+ status app.status NOT NULL
19
+ );
20
+ CREATE TABLE app.users (id bigint PRIMARY KEY, email text NOT NULL);
21
+ ALTER TABLE app.orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES app.users (id);
22
+ CREATE INDEX orders_user_idx ON app.orders (user_id);
23
+ COMMENT ON TABLE app.users IS 'People';
24
+ COMMENT ON COLUMN app.users.email IS 'Login';
25
+ ALTER TABLE app.users ENABLE ROW LEVEL SECURITY;
26
+ COMMIT;
27
+ `;
28
+
29
+ describe("reading a file of Postgres DDL", () => {
30
+ test("each CREATE is an object, with its comments, row security and added constraints folded in", () => {
31
+ const objects = readSqlFile("postgres", PG_DDL, { origin: "schema.sql" });
32
+ expect(objects.map((o) => [o.export, o.type, o.name])).toEqual([
33
+ ["status", "Postgres::Enum", "app.status"],
34
+ ["orders", "Postgres::Table", "app.orders"],
35
+ ["users", "Postgres::Table", "app.users"],
36
+ ["ordersUserIdx", "Postgres::Index", "app.orders_user_idx"],
37
+ ]);
38
+ expect(objects[1]!.ddl).toContain("CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES app.users (id)\n)");
39
+ expect(objects[2]!.ddl).toBe(
40
+ "CREATE TABLE app.users (id bigint PRIMARY KEY, email text NOT NULL);\nCOMMENT ON TABLE app.users IS 'People';\nCOMMENT ON COLUMN app.users.email IS 'Login';\nALTER TABLE app.users ENABLE ROW LEVEL SECURITY",
41
+ );
42
+ });
43
+
44
+ test("a statement that declares nothing or does not parse is an error naming it, and every one is named", () => {
45
+ const read = () => readSqlFile("postgres", "CREATE TABLE a (id int);\nINSERT INTO a VALUES (1);\nCREATE TABLE b (id int,);\nCOMMENT ON TABLE c IS 'x';", { origin: "db/schema.sql" });
46
+ expect(read).toThrow(SqlFileError);
47
+ expect(read).toThrow(/^db\/schema\.sql: 3 statements chant cannot read as declarations/);
48
+ expect(read).toThrow(/not a statement that declares an object: INSERT INTO a VALUES \(1\)/);
49
+ expect(read).toThrow(/does not parse \(.*\): CREATE TABLE b \(id int,\)/);
50
+ expect(read).toThrow(/COMMENT ON TABLE for an object the file does not create: COMMENT ON TABLE c IS 'x'/);
51
+ });
52
+
53
+ test("schema qualifies the names the DDL leaves unqualified", () => {
54
+ const { content } = sqlFileDeclarations("postgres", "CREATE TYPE s AS ENUM ('a');\nCREATE TABLE u (id int PRIMARY KEY, k s);\nCREATE TABLE o (u int REFERENCES u (id));", { origin: "x", schema: "app" });
55
+ expect(content).toContain("CREATE TABLE app.u (id int PRIMARY KEY, k ${s})");
56
+ expect(content).toContain("CREATE TABLE app.o (u int REFERENCES ${u} (id))");
57
+ });
58
+ });
59
+
60
+ describe("chant import of a .sql file", () => {
61
+ test("the dialect is read off the statements, and Postgres DDL imports as Postgres declarations", () => {
62
+ expect(guessDialect(PG_DDL)).toBe("postgres");
63
+ expect(guessDialect("CREATE TABLE a.t (x UInt8) ENGINE = Log")).toBe("clickhouse");
64
+ expect(guessDialect("CREATE VIEW a.v AS SELECT 1")).toBe("clickhouse");
65
+ expect(guessDialect("CREATE FUNCTION f AS (x) -> x + 1")).toBe("clickhouse");
66
+ expect(guessDialect("CREATE FUNCTION app.f() RETURNS int LANGUAGE sql AS $$ SELECT 1 $$")).toBe("postgres");
67
+ expect(new SqlFileParser().parse(PG_DDL).resources.map((r) => r.type)).toEqual(["Postgres::Enum", "Postgres::Table", "Postgres::Table", "Postgres::Index"]);
68
+ });
69
+
70
+ test("a statement it cannot read stops the import, named", () => {
71
+ expect(() => new SqlFileParser("clickhouse").parse("CREATE DATABASE a;\nINSERT INTO a.t VALUES (1)")).toThrow(/not a CREATE statement: INSERT INTO a\.t VALUES \(1\)/);
72
+ });
73
+ });
74
+
75
+ /** A project directory under the repository, so its imports resolve. */
76
+ async function project(name: string, files: Record<string, string>): Promise<string> {
77
+ const path = join(repoRoot, ".cache", `sql-3646-${name}-${process.pid}`);
78
+ await rm(path, { recursive: true, force: true });
79
+ await mkdir(join(path, "src"), { recursive: true });
80
+ const dir = await realpath(path);
81
+ for (const [file, content] of Object.entries(files)) await writeFile(join(dir, "src", file), content);
82
+ return dir;
83
+ }
84
+
85
+ /** A build document with each DDL's whitespace runs collapsed. */
86
+ const squeeze = (doc: unknown): unknown => JSON.parse(JSON.stringify(doc, (k, v: unknown) => (k === "ddl" && typeof v === "string" ? v.replace(/\s+/g, " ") : v)));
87
+
88
+ async function buildProject(dir: string, config: Record<string, unknown> = {}) {
89
+ const result = await build(join(dir, "src"), [sqlSerializer], undefined, {
90
+ lexicons: ["sql"],
91
+ intrinsics: sqlPlugin.intrinsics?.() ?? [],
92
+ buildRoots: [(ctx) => sqlPlugin.buildRoots!({ projectRoot: dir, config, entities: ctx.entities, ...(ctx.sourceDir ? { sourceDir: ctx.sourceDir } : {}) })],
93
+ });
94
+ const out = result.outputs.get("sql");
95
+ const primary = typeof out === "string" ? out : out?.primary;
96
+ return { errors: result.errors.map((e) => e.message), doc: primary ? (JSON.parse(primary) as { applyOrder: string[]; objects: Array<Record<string, unknown>> }) : undefined };
97
+ }
98
+
99
+ const CH_DDL = `CREATE DATABASE analytics;
100
+ CREATE TABLE analytics.events (ts DateTime, kind String) ENGINE = MergeTree ORDER BY ts;
101
+ CREATE TABLE analytics.counts (kind String, n UInt64) ENGINE = SummingMergeTree ORDER BY kind;
102
+ CREATE MATERIALIZED VIEW analytics.counts_mv TO analytics.counts AS SELECT kind, count() AS n FROM analytics.events GROUP BY kind;
103
+ CREATE DICTIONARY analytics.kinds (kind String, label String DEFAULT '') PRIMARY KEY kind SOURCE(clickhouse(table 'counts' db 'analytics')) LAYOUT(complex_key_hashed()) LIFETIME(300);
104
+ CREATE FUNCTION kind_label AS (k) -> upper(k);
105
+ `;
106
+
107
+ describe("chant build reads the .sql files in the source directory", () => {
108
+ const dirs: string[] = [];
109
+ afterAll(async () => {
110
+ for (const d of dirs) await rm(d, { recursive: true, force: true });
111
+ });
112
+
113
+ for (const [dialect, ddl] of [
114
+ ["postgres", PG_DDL.replace("CREATE TYPE", "CREATE SCHEMA app;\nCREATE TYPE")],
115
+ ["clickhouse", CH_DDL],
116
+ ] as const) {
117
+ test(`${dialect}: the same build as the declarations chant import writes for the file`, async () => {
118
+ const fromSql = await project(`${dialect}-sql`, { "schema.sql": ddl });
119
+ const fromTs = await project(`${dialect}-ts`, { "schema.ts": sqlFileDeclarations(dialect, ddl, { origin: "schema.sql" }).content });
120
+ dirs.push(fromSql, fromTs);
121
+ const a = await buildProject(fromSql, { sql: { dialect } });
122
+ const b = await buildProject(fromTs, { sql: { dialect } });
123
+ expect(a.errors).toEqual([]);
124
+ expect(b.errors).toEqual([]);
125
+ // The import indents a template's lines under its declaration; the statements are otherwise the same.
126
+ expect(squeeze(a.doc)).toEqual(squeeze(b.doc));
127
+ expect(a.doc!.objects.length).toBeGreaterThan(2);
128
+ });
129
+ }
130
+
131
+ let mixed: string;
132
+ beforeAll(async () => {
133
+ mixed = await project("mixed", {
134
+ "app.ts": `import { schema, view } from "@intentius/chant-lexicon-sql/postgres";
135
+ export const app = schema\`CREATE SCHEMA app\`;
136
+ export const activeUsers = view\`CREATE VIEW app.active_users AS SELECT id FROM app.users\`;
137
+ `,
138
+ "users.sql": "CREATE TABLE app.users (id bigint PRIMARY KEY);\nCREATE TABLE app.notes (user_id bigint REFERENCES app.users (id));\n",
139
+ });
140
+ dirs.push(mixed);
141
+ });
142
+
143
+ test("a file's object and a template reference each other, and the order comes from the references", async () => {
144
+ const { errors, doc } = await buildProject(mixed);
145
+ expect(errors).toEqual([]);
146
+ const objects = new Map(doc!.objects.map((o) => [o.export as string, o]));
147
+ // The file's table names the template's schema; the template's view names the file's table as text.
148
+ expect(objects.get("users")!.dependsOn).toEqual(["app"]);
149
+ expect(objects.get("notes")!.dependsOn).toEqual(["app", "users"]);
150
+ expect(objects.get("activeUsers")!.dependsOn).toEqual(["users"]);
151
+ expect(doc!.applyOrder).toEqual(["app", "users", "activeUsers", "notes"]);
152
+ });
153
+
154
+ test("a statement the build cannot read is a build error naming the file and the statement", async () => {
155
+ const dir = await project("bad", { "app.ts": `import { schema } from "@intentius/chant-lexicon-sql/postgres";\nexport const app = schema\`CREATE SCHEMA app\`;\n`, "bad.sql": "CREATE TABLE app.t (id int);\nDROP TABLE app.old;\n" });
156
+ dirs.push(dir);
157
+ const { errors } = await buildProject(dir);
158
+ expect(errors.join("\n")).toMatch(/src\/bad\.sql: a statement chant cannot read as declarations:\n {2}- not a statement that declares an object: DROP TABLE app\.old/);
159
+ });
160
+
161
+ test("an object declared in a file and a template both is an error naming both", async () => {
162
+ const dir = await project("twice", { "app.ts": `import { table } from "@intentius/chant-lexicon-sql/postgres";\nexport const accounts = table\`CREATE TABLE app.users (id int)\`;\n`, "users.sql": "CREATE TABLE app.users (id int);\n" });
163
+ dirs.push(dir);
164
+ const { errors } = await buildProject(dir);
165
+ expect(errors.join("\n")).toMatch(/app\.users is declared twice, here and as accounts/);
166
+ });
167
+
168
+ test("a file marked chant-discovery-skip is not read", async () => {
169
+ const dir = await project("skip", { "app.ts": `import { schema } from "@intentius/chant-lexicon-sql/postgres";\nexport const app = schema\`CREATE SCHEMA app\`;\n`, "seed.sql": "-- chant-discovery-skip\nINSERT INTO app.t VALUES (1);\n" });
170
+ dirs.push(dir);
171
+ const { errors, doc } = await buildProject(dir);
172
+ expect(errors).toEqual([]);
173
+ expect(doc!.applyOrder).toEqual(["app"]);
174
+ });
175
+ });
176
+
177
+ describe("references between a file's objects", () => {
178
+ test("a bare name where SQL names an object is a reference, and a cycle is an error", async () => {
179
+ const { sqlFileEntities } = await import("./sql-files");
180
+ const entities = sqlFileEntities("postgres", [
181
+ { origin: "a.sql", ddl: "CREATE TABLE orders (user_id int REFERENCES users (id));\nCREATE TABLE users (id int PRIMARY KEY);\nCREATE VIEW recent AS SELECT * FROM orders JOIN users ON true;" },
182
+ ]);
183
+ const deps = (n: string) => ((entities.get(n) as unknown as { dependsOn: Array<{ sqlName: string }> }).dependsOn).map((d) => d.sqlName);
184
+ expect(deps("orders")).toEqual(["users"]);
185
+ expect(deps("recent")).toEqual(["orders", "users"]);
186
+ expect(() => sqlFileEntities("postgres", [{ origin: "b.sql", ddl: "CREATE TABLE a (b int REFERENCES b (id), id int PRIMARY KEY);\nCREATE TABLE b (a int REFERENCES a (id), id int PRIMARY KEY);" }])).toThrow(/reference each other/);
187
+ });
188
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Plain `.sql` files as a schema source (#3646): DDL read into the same
3
+ * declarations the tagged templates make. Also at the package root.
4
+ *
5
+ * - {@link readSqlFile}: the objects a file of DDL declares, each with its
6
+ * export name, type, qualified name and DDL.
7
+ * - {@link sqlFileDeclarations}: the same file as TypeScript, one `schema.ts`
8
+ * of `table`, `view`, ... templates, as `chant import <file>.sql` writes it.
9
+ * - {@link sqlFileEntities}: the declarations themselves, with the references
10
+ * between the files' objects, and to objects declared elsewhere, as
11
+ * interpolations, so dependency order comes from the graph. The build uses
12
+ * it for every `.sql` file in the source directory.
13
+ *
14
+ * Postgres reads each `CREATE`, `GRANT`, `REVOKE` and `ALTER DEFAULT
15
+ * PRIVILEGES` as an object, with `COMMENT ON`, `ALTER TABLE ... ROW LEVEL
16
+ * SECURITY` and `ALTER TABLE ... ADD <table constraint>` folded into the
17
+ * object they finish; ClickHouse reads `CREATE DATABASE`, `TABLE`, `VIEW`,
18
+ * `MATERIALIZED VIEW`, `DICTIONARY` and `FUNCTION`. Any other statement, and any
19
+ * statement that does not parse, throws a {@link SqlFileError} naming every
20
+ * such statement: none is left out.
21
+ *
22
+ * A name is a reference where it is qualified (`app.users`), or bare where an
23
+ * object is named (`REFERENCES users`, `FROM users`, an index's table, a
24
+ * column of an enum type) and an object declared without a schema has it.
25
+ */
26
+
27
+ import type { Declarable } from "@intentius/chant/declarable";
28
+ import type { SqlDialect } from "./dialects";
29
+ import { SqlFileError, type ReadOptions } from "./files/common";
30
+ import { postgresTag, readPostgres, templateStrings } from "./files/postgres";
31
+ import { clickhouseTag, readClickHouse } from "./files/clickhouse";
32
+ import { exportNames as pgExportNames, objectsToIR as pgObjectsToIR, type ImportedPgObject } from "./postgres/import/ir";
33
+ import { exportNames as chExportNames, objectsToIR as chObjectsToIR, type ImportedObject } from "./clickhouse/import/ir";
34
+ import { PostgresGenerator, postgresTemplates, type PgTemplateItem, type TemplateBody } from "./postgres/import/generator";
35
+ import { ClickHouseGenerator, clickhouseTemplates } from "./clickhouse/import/generator";
36
+ import { isPostgresObject } from "./postgres/entities";
37
+ import { isClickHouseObject } from "./clickhouse/entities";
38
+
39
+ export { SqlFileError };
40
+ export type { ReadOptions as SqlFileOptions };
41
+
42
+ /** One object a file of DDL declares. */
43
+ export interface SqlFileObject {
44
+ /** The export it is declared as. */
45
+ export: string;
46
+ /** Its entity type, `Postgres::Table`. */
47
+ type: string;
48
+ /** `schema.name` (Postgres), `database.name` (ClickHouse); the name alone when the DDL does not qualify it. */
49
+ name: string;
50
+ /** Its statements: the CREATE, then what was folded into it. */
51
+ ddl: string;
52
+ /** The file it came from, the `origin` it was read under. */
53
+ origin: string;
54
+ }
55
+
56
+ /** A file to read: its DDL and the name errors give it. */
57
+ export interface SqlFileSource extends ReadOptions {
58
+ ddl: string;
59
+ }
60
+
61
+ type Imported = ImportedPgObject | ImportedObject;
62
+
63
+ const qualifierOf = (o: Imported): string | undefined => ("schema" in o ? o.schema : "database" in o ? o.database : undefined);
64
+ const qualifiedName = (o: Imported): string => (qualifierOf(o) ? `${qualifierOf(o)}.${o.name}` : o.name);
65
+
66
+ function readObjects(dialect: SqlDialect, sources: readonly SqlFileSource[]): Array<Imported & { origin: string }> {
67
+ const out: Array<Imported & { origin: string }> = [];
68
+ for (const source of sources) {
69
+ const problems: string[] = [];
70
+ const objects = dialect === "postgres" ? readPostgres(source.ddl, source, problems) : readClickHouse(source.ddl, source, problems);
71
+ if (problems.length > 0) throw new SqlFileError(source.origin, problems);
72
+ out.push(...objects.map((o) => ({ ...o, origin: source.origin })));
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** Export names for objects read from files, unique across them. */
78
+ function namesFor(dialect: SqlDialect, objects: readonly Imported[]): string[] {
79
+ return dialect === "postgres" ? pgExportNames(objects as ImportedPgObject[]) : chExportNames(objects as ImportedObject[]);
80
+ }
81
+
82
+ /** The objects a file of DDL declares. Throws a {@link SqlFileError} naming each statement it cannot read. */
83
+ export function readSqlFile(dialect: SqlDialect, ddl: string, options: ReadOptions): SqlFileObject[] {
84
+ const objects = readObjects(dialect, [{ ...options, ddl }]);
85
+ const names = namesFor(dialect, objects);
86
+ return objects.map((o, i) => ({ export: names[i]!, type: o.type, name: qualifiedName(o), ddl: o.ddl, origin: o.origin }));
87
+ }
88
+
89
+ /**
90
+ * A file of DDL as TypeScript declarations: the `schema.ts` `chant import`
91
+ * writes, with `header` as comment lines at its top, and the objects in it.
92
+ */
93
+ export function sqlFileDeclarations(dialect: SqlDialect, ddl: string, options: ReadOptions & { header?: string }): { content: string; objects: SqlFileObject[] } {
94
+ const objects = readObjects(dialect, [{ ...options, ddl }]);
95
+ if (objects.length === 0) throw new SqlFileError(options.origin, ["the DDL creates no objects"]);
96
+ const ir = dialect === "postgres" ? pgObjectsToIR(objects as ImportedPgObject[]) : chObjectsToIR(objects as ImportedObject[]);
97
+ const files = dialect === "postgres" ? new PostgresGenerator(options.header).generate(ir) : new ClickHouseGenerator(options.header).generate(ir);
98
+ const [file] = files;
99
+ if (!file) throw new SqlFileError(options.origin, ["the DDL creates no objects"]);
100
+ return {
101
+ content: file.content,
102
+ objects: ir.resources.map((r, i) => ({ export: r.logicalId, type: r.type, name: qualifiedName(objects[i]!), ddl: objects[i]!.ddl, origin: options.origin })),
103
+ };
104
+ }
105
+
106
+ /** Where an entity read from a `.sql` file came from: its `origin`. Undefined for any other entity. */
107
+ export function sqlFileOf(entity: unknown): string | undefined {
108
+ return typeof entity === "object" && entity !== null ? ((entity as Record<symbol, unknown>)[FROM_FILE] as string | undefined) : undefined;
109
+ }
110
+
111
+ const FROM_FILE = Symbol.for("chant.sql.fromFile");
112
+
113
+ /** A declared entity as a template target: its export, type and name. */
114
+ function targetOf(dialect: SqlDialect, exportName: string, entity: Declarable): PgTemplateItem | undefined {
115
+ const p = (entity as unknown as { props: { schema?: string; database?: string; name?: string; ddl?: string } }).props;
116
+ if (typeof p.name !== "string") return undefined;
117
+ const qualifier = dialect === "postgres" ? p.schema : p.database;
118
+ return {
119
+ exportName,
120
+ type: entity.entityType,
121
+ ...(qualifier ? (dialect === "postgres" ? { schema: qualifier } : { database: qualifier }) : {}),
122
+ name: p.name,
123
+ ddl: typeof p.ddl === "string" ? p.ddl : "",
124
+ } as PgTemplateItem;
125
+ }
126
+
127
+ function templates(dialect: SqlDialect, items: readonly PgTemplateItem[], targets: readonly PgTemplateItem[]): { order: PgTemplateItem[]; bodies: Map<string, TemplateBody> } {
128
+ return dialect === "postgres" ? postgresTemplates(items, targets) : (clickhouseTemplates(items, targets) as { order: PgTemplateItem[]; bodies: Map<string, TemplateBody> });
129
+ }
130
+
131
+ /** The entities of one dialect in a set, by export name. */
132
+ function ofDialect(dialect: SqlDialect, entities: ReadonlyMap<string, Declarable> | undefined): Map<string, Declarable> {
133
+ const is = dialect === "postgres" ? isPostgresObject : isClickHouseObject;
134
+ return new Map([...(entities ?? new Map<string, Declarable>())].filter(([, e]) => is(e)));
135
+ }
136
+
137
+ /**
138
+ * The declarations the files make, by export name. `known` holds the
139
+ * entities declared elsewhere (the project's tagged templates): a file's
140
+ * reference to one of them is an interpolation of it, as an import would
141
+ * write it, and an export name or an object one of them already declares is
142
+ * an error naming both.
143
+ */
144
+ export function sqlFileEntities(dialect: SqlDialect, sources: readonly SqlFileSource[], options: { known?: ReadonlyMap<string, Declarable> } = {}): Map<string, Declarable> {
145
+ const objects = readObjects(dialect, sources);
146
+ const names = namesFor(dialect, objects);
147
+ const known = ofDialect(dialect, options.known);
148
+ const problems: string[] = [];
149
+
150
+ const items: PgTemplateItem[] = objects.map((o, i) => ({ exportName: names[i]!, type: o.type, ...("schema" in o && o.schema ? { schema: o.schema } : {}), ...("database" in o && o.database ? { database: o.database } : {}), name: o.name, ddl: o.ddl }) as PgTemplateItem);
151
+ const origin = new Map(objects.map((o, i) => [names[i]!, o.origin]));
152
+ const knownItems = [...known].map(([n, e]) => targetOf(dialect, n, e)).filter((t): t is PgTemplateItem => t !== undefined);
153
+ const declared = new Map(knownItems.map((t) => [`${t.type} ${qualifiedName(t as Imported)}`, t.exportName]));
154
+ const seen = new Map<string, string>();
155
+ const where = (at: string | undefined) => (sources.length > 1 && at ? `${at}: ` : "");
156
+ const label = sources.length === 1 ? sources[0]!.origin : "the .sql files";
157
+ for (const [i, it] of items.entries()) {
158
+ const what = `${it.type} ${qualifiedName(it as Imported)}`;
159
+ const at = objects[i]!.origin;
160
+ const q = qualifiedName(it as Imported);
161
+ if (options.known?.has(it.exportName)) problems.push(`${where(at)}${q} is exported as ${it.exportName}, a name another declaration already has; rename that one`);
162
+ const other = declared.get(what);
163
+ if (other) problems.push(`${where(at)}${q} is declared twice, here and as ${other}; leave it to one of them`);
164
+ const again = seen.get(what);
165
+ if (again) problems.push(`${where(at)}${q} is declared twice, here and in ${again}; leave it to one of them`);
166
+ seen.set(what, at);
167
+ }
168
+ if (problems.length > 0) throw new SqlFileError(label, problems);
169
+
170
+ const { order, bodies } = templates(dialect, items, [...items, ...knownItems]);
171
+ const built = new Map<string, Declarable>();
172
+ const failed = new Set<string>();
173
+ const tagOf = dialect === "postgres" ? postgresTag : clickhouseTag;
174
+ for (const it of order) {
175
+ const body = bodies.get(it.exportName)!;
176
+ const values = body.refs.map((r) => built.get(r) ?? known.get(r));
177
+ const missing = body.refs.find((_, i) => values[i] === undefined);
178
+ if (missing !== undefined) {
179
+ failed.add(it.exportName);
180
+ // What references an object that failed is not reported again.
181
+ if (failed.has(missing)) continue;
182
+ problems.push(`${where(origin.get(it.exportName))}${qualifiedName(it as Imported)} and ${missing} reference each other, which no declaration can; break the cycle`);
183
+ continue;
184
+ }
185
+ try {
186
+ const entity = tagOf(it.type)!(templateStrings(body.parts), ...values);
187
+ Object.defineProperty(entity, FROM_FILE, { value: origin.get(it.exportName), enumerable: false });
188
+ built.set(it.exportName, entity);
189
+ } catch (e) {
190
+ failed.add(it.exportName);
191
+ problems.push(`${where(origin.get(it.exportName))}${qualifiedName(it as Imported)}: ${e instanceof Error ? e.message : String(e)}`);
192
+ }
193
+ }
194
+ if (problems.length > 0) throw new SqlFileError(label, problems);
195
+ // In the files' order, not the dependency order.
196
+ return new Map(items.filter((it) => built.has(it.exportName)).map((it) => [it.exportName, built.get(it.exportName)!]));
197
+ }
198
+
199
+ /**
200
+ * The objects read from `.sql` files that each other object names in its
201
+ * DDL as text, by export name: a tagged template cannot import a file's
202
+ * object, so it names it, and this is the edge the dependency order needs.
203
+ * Empty when no object came from a file.
204
+ */
205
+ export function sqlFileReferences(dialect: SqlDialect, objects: ReadonlyMap<string, Declarable>): Map<string, string[]> {
206
+ const fromFiles = [...objects].filter(([, e]) => sqlFileOf(e) !== undefined);
207
+ const out = new Map<string, string[]>();
208
+ if (fromFiles.length === 0) return out;
209
+ const targets = fromFiles.map(([n, e]) => targetOf(dialect, n, e)).filter((t): t is PgTemplateItem => t !== undefined);
210
+ for (const [name, entity] of objects) {
211
+ if (sqlFileOf(entity) !== undefined) continue;
212
+ const item = targetOf(dialect, name, entity);
213
+ if (!item || !item.ddl) continue;
214
+ const refs = templates(dialect, [item], targets).bodies.get(name)?.refs ?? [];
215
+ if (refs.length > 0) out.set(name, [...new Set(refs)]);
216
+ }
217
+ return out;
218
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Token sources against a live server (#3685). A `command` source stands in
3
+ * for the token endpoint: it prints a password from a file, and the test
4
+ * rotates the role's password and the file together, as a cloud rotates its
5
+ * tokens. A profile with that source connects; after the token's lifetime a
6
+ * new one is minted and the next connection uses it; a token the server
7
+ * refuses is forgotten, so the next attempt mints again.
8
+ *
9
+ * Postgres: `CHANT_SQL_E2E_POSTGRES_URL` (with
10
+ * `CHANT_SQL_E2E_POSTGRES_PASSWORD`) names a running server, else a
11
+ * throwaway one, skipped when Docker is not available. ClickHouse:
12
+ * `CHANT_SQL_E2E_CLICKHOUSE_URL` (as `default`, with
13
+ * `CHANT_SQL_E2E_CLICKHOUSE_PASSWORD`), skipped when not set. The test makes
14
+ * only the user `chant_e2e_3685` on each, 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 "./postgres/testing/server";
22
+ import { connectPostgres, PostgresQueryError, type PostgresClient, type PostgresEndpoint } from "./postgres/live/client";
23
+ import { resolveBoundTarget } from "./postgres/live/bind";
24
+ import { bindClickHouse } from "./clickhouse/live/bind";
25
+ import { clickhouseQuery, ClickHouseQueryError, type ClickHouseEndpoint } from "./clickhouse/http";
26
+
27
+ const ROLE = "chant_e2e_3685";
28
+ const dir = mkdtempSync(join(tmpdir(), "chant-token-"));
29
+ const tokenFile = join(dir, "token");
30
+ const mint = (password: string) => writeFileSync(tokenFile, password);
31
+ const command = ["cat", tokenFile];
32
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
33
+
34
+ const pgGiven = process.env.CHANT_SQL_E2E_POSTGRES_URL;
35
+ const pgEnabled = pgGiven ? true : await dockerAvailable();
36
+ let server: TestPostgres | undefined;
37
+ let admin: PostgresClient;
38
+ let pgUrl: string;
39
+
40
+ describe.skipIf(!pgEnabled)("a Postgres profile with a command token source", () => {
41
+ beforeAll(async () => {
42
+ let endpoint: PostgresEndpoint;
43
+ if (pgGiven) endpoint = { url: pgGiven, password: process.env.CHANT_SQL_E2E_POSTGRES_PASSWORD ?? "" };
44
+ else {
45
+ server = await startTestPostgres();
46
+ endpoint = server.endpoint();
47
+ }
48
+ admin = await connectPostgres(endpoint);
49
+ await admin.query(`DROP ROLE IF EXISTS ${ROLE}`);
50
+ await admin.query(`CREATE ROLE ${ROLE} LOGIN PASSWORD 'first-token'`);
51
+ const url = new URL(endpoint.url);
52
+ url.username = "";
53
+ url.password = "";
54
+ pgUrl = url.toString();
55
+ }, 600_000);
56
+
57
+ afterAll(async () => {
58
+ await admin?.query(`DROP ROLE IF EXISTS ${ROLE}`).catch(() => undefined);
59
+ await admin?.end();
60
+ await server?.stop();
61
+ });
62
+
63
+ const whoami = async (endpoint: PostgresEndpoint) => {
64
+ const client = await connectPostgres(endpoint);
65
+ try {
66
+ const [row] = await client.query<{ u: string }>("SELECT current_user AS u");
67
+ return row?.u;
68
+ } finally {
69
+ await client.end();
70
+ }
71
+ };
72
+
73
+ test("connects with the minted token, mints again after its lifetime, and forgets a refused one", async () => {
74
+ mint("first-token");
75
+ const config = { sql: { profiles: { e2e: { url: pgUrl, user: { env: "E2E_USER" }, password: { token: "command" as const, command, ttlSeconds: 1 } } } } };
76
+ const target = await resolveBoundTarget({ environment: "e2e", config, env: { ...process.env, E2E_USER: ROLE } });
77
+ expect(await whoami(target.endpoint)).toBe(ROLE);
78
+
79
+ // Rotated: the cached token has expired by the next connect, so the new one is minted.
80
+ await admin.query(`ALTER ROLE ${ROLE} PASSWORD 'second-token'`);
81
+ mint("second-token");
82
+ await sleep(1_000);
83
+ expect(await whoami(target.endpoint)).toBe(ROLE);
84
+
85
+ // Rotated before the cached token's lifetime is up: the server refuses it, and the retry mints.
86
+ const long = await resolveBoundTarget({
87
+ environment: "e2e",
88
+ config: { sql: { profiles: { e2e: { ...config.sql.profiles.e2e, password: { token: "command" as const, command, ttlSeconds: 3600 } } } } },
89
+ env: { ...process.env, E2E_USER: ROLE },
90
+ });
91
+ expect(await whoami(long.endpoint)).toBe(ROLE);
92
+ await admin.query(`ALTER ROLE ${ROLE} PASSWORD 'third-token'`);
93
+ mint("third-token");
94
+ const refused = await whoami(long.endpoint).catch((e: unknown) => e);
95
+ expect(refused).toBeInstanceOf(PostgresQueryError);
96
+ expect((refused as PostgresQueryError).code).toBe("28P01");
97
+ expect(await whoami(long.endpoint)).toBe(ROLE);
98
+ }, 60_000);
99
+ });
100
+
101
+ const chGiven = process.env.CHANT_SQL_E2E_CLICKHOUSE_URL;
102
+
103
+ describe.skipIf(!chGiven)("a ClickHouse profile with a command token source", () => {
104
+ const admin: ClickHouseEndpoint = { url: chGiven ?? "", user: "default", password: process.env.CHANT_SQL_E2E_CLICKHOUSE_PASSWORD ?? "" };
105
+
106
+ beforeAll(async () => {
107
+ await clickhouseQuery(admin, `DROP USER IF EXISTS ${ROLE}`);
108
+ await clickhouseQuery(admin, `CREATE USER ${ROLE} IDENTIFIED BY 'first-token'`);
109
+ });
110
+
111
+ afterAll(async () => {
112
+ await clickhouseQuery(admin, `DROP USER IF EXISTS ${ROLE}`).catch(() => undefined);
113
+ });
114
+
115
+ test("each request carries a valid token; a refused one is minted again", async () => {
116
+ mint("first-token");
117
+ const config = { sql: { profiles: { e2e: { url: chGiven!, user: { env: "E2E_USER" }, password: { token: "command" as const, command, ttlSeconds: 1 } } } } };
118
+ const target = await bindClickHouse({ environment: "e2e", config, env: { ...process.env, E2E_USER: ROLE } });
119
+ const whoami = async () => (await clickhouseQuery<{ u: string }>(target.endpoint, "SELECT currentUser() AS u"))[0]?.u;
120
+ expect(await whoami()).toBe(ROLE);
121
+
122
+ await clickhouseQuery(admin, `ALTER USER ${ROLE} IDENTIFIED BY 'second-token'`);
123
+ mint("second-token");
124
+ await sleep(1_000);
125
+ expect(await whoami()).toBe(ROLE);
126
+
127
+ // Rotated before the cached token's lifetime is up: refused once, then minted again.
128
+ const long = await bindClickHouse({
129
+ environment: "e2e",
130
+ config: { sql: { profiles: { e2e: { ...config.sql.profiles.e2e, password: { token: "command" as const, command, ttlSeconds: 3600 } } } } },
131
+ env: { ...process.env, E2E_USER: ROLE },
132
+ });
133
+ const longWhoami = async () => (await clickhouseQuery<{ u: string }>(long.endpoint, "SELECT currentUser() AS u"))[0]?.u;
134
+ expect(await longWhoami()).toBe(ROLE);
135
+ await clickhouseQuery(admin, `ALTER USER ${ROLE} IDENTIFIED BY 'third-token'`);
136
+ mint("third-token");
137
+ await expect(longWhoami()).rejects.toBeInstanceOf(ClickHouseQueryError);
138
+ expect(await longWhoami()).toBe(ROLE);
139
+ }, 60_000);
140
+ });
141
+
142
+ afterAll(() => rmSync(dir, { recursive: true, force: true }));