@intentius/chant-lexicon-sql 0.120.0 → 0.121.1

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Finding a SQL user-defined function in what ClickHouse stored (#3745).
3
+ *
4
+ * ClickHouse does not keep a call to a SQL function in a view's query or a
5
+ * column's DEFAULT: it writes the function's body in place of the call, with
6
+ * the arguments substituted, and a nested call expanded the same way.
7
+ * `CREATE FUNCTION tax AS x -> (x * 0.2)` used as `tax(amt)` reads back as
8
+ * `amt * 0.2`. So an import can't look for the name; it looks for the body.
9
+ *
10
+ * Each function's body is expanded (a call to another function becomes that
11
+ * function's body), its parameters become wildcards, and the result is
12
+ * matched against the stored statement's tokens. Parentheses that only
13
+ * group are dropped on both sides, since the server adds or leaves them by
14
+ * precedence where the body lands. A wildcard matches one balanced
15
+ * expression, and a parameter used twice must match the same text twice.
16
+ *
17
+ * A function whose body is just a parameter (`x -> x`) leaves nothing to
18
+ * look for, so it is never found this way.
19
+ */
20
+ import { type Token } from "../tokens.js";
21
+ /** One item of a pattern: a token's text, or the parameter at `arg`. */
22
+ type Item = {
23
+ lit: string;
24
+ } | {
25
+ arg: number;
26
+ };
27
+ /** A function's parameters and body, from `CREATE FUNCTION name AS (x, y) -> body`; undefined when it doesn't read that way. */
28
+ export declare function functionLambda(statement: string): {
29
+ params: string[];
30
+ body: Token[];
31
+ } | undefined;
32
+ /** What to look for: the function's expanded body, with its parameters as wildcards. Undefined when nothing but a parameter is left to match. */
33
+ export declare function inlinedPattern(name: string, lambdas: ReadonlyMap<string, {
34
+ params: string[];
35
+ body: Token[];
36
+ }>): Item[] | undefined;
37
+ /** Whether `pattern` appears in `statement`'s tokens. */
38
+ export declare function containsInlined(statement: string, pattern: readonly Item[]): boolean;
39
+ /**
40
+ * The functions among `functions` whose bodies appear inlined in any of
41
+ * `statements`. A function is matched with the others' bodies expanded into
42
+ * its own, as the server does.
43
+ */
44
+ export declare function inlinedFunctions(statements: readonly string[], functions: ReadonlyArray<{
45
+ name: string;
46
+ statement: string;
47
+ }>): string[];
48
+ export {};
49
+ //# sourceMappingURL=inlined.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inlined.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/import/inlined.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAA0B,KAAK,KAAK,EAAE,MAAM,WAAW,CAAC;AAG/D,wEAAwE;AACxE,KAAK,IAAI,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAE9C,gIAAgI;AAChI,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,KAAK,EAAE,CAAA;CAAE,GAAG,SAAS,CAiBjG;AAuFD,iJAAiJ;AACjJ,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,EAAE,KAAK,EAAE,CAAA;CAAE,CAAC,GAAG,IAAI,EAAE,GAAG,SAAS,CAOlI;AAKD,yDAAyD;AACzD,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,IAAI,EAAE,GAAG,OAAO,CAmCpF;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,SAAS,MAAM,EAAE,EAAE,SAAS,EAAE,aAAa,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC,GAAG,MAAM,EAAE,CAYvI"}
@@ -17,9 +17,11 @@
17
17
  * `goose_db_version`) is left out with a warning (#3676), as on Postgres.
18
18
  * - A SQL user-defined function belongs to no database, and every project's
19
19
  * functions sit side by side on a server (#3718). Without a selector, an
20
- * import adopts only the functions the imported objects call (and those
20
+ * import adopts only the functions the imported objects use (and those
21
21
  * functions call), and the ones `sql.profiles.<env>.importFunctions` names;
22
- * a warning names the rest. A function carries no comment, so `owned` does
22
+ * a warning names the rest. ClickHouse stores a call as the function's body
23
+ * (#3745), so a use is found by that body (`./inlined.ts`) as well as by
24
+ * the name. A function carries no comment, so `owned` does
23
25
  * not apply to it.
24
26
  */
25
27
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
@@ -29,7 +31,8 @@ import type { LiveObject } from "../live/catalog.js";
29
31
  export declare function calledFunctions(ddl: string, names: ReadonlySet<string>): string[];
30
32
  /**
31
33
  * The functions an import adopts (#3718): those the objects call, those
32
- * `patterns` name, and the functions those call in turn; and the rest.
34
+ * whose bodies the objects hold in place of a call (#3745, `./inlined.ts`),
35
+ * those `patterns` name, and the functions those call in turn; and the rest.
33
36
  */
34
37
  export declare function scopedFunctions(objects: readonly LiveObject[], functions: readonly LiveObject[], patterns?: readonly string[]): {
35
38
  adopted: LiveObject[];
@@ -1 +1 @@
1
- {"version":3,"file":"live-export.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/import/live-export.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAOhE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAElD,6EAA6E;AAC7E,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAWjF;AAKD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,EAAE,SAAS,EAAE,SAAS,UAAU,EAAE,EAAE,QAAQ,GAAE,SAAS,MAAM,EAAO,GAAG;IAAE,OAAO,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAehL;AAED,MAAM,WAAW,aAAc,SAAQ,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC;IACrE,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAsB,eAAe,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAsCvF"}
1
+ {"version":3,"file":"live-export.d.ts","sourceRoot":"","sources":["../../../src/clickhouse/import/live-export.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AACnF,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,cAAc,CAAC;AAOhE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAGlD,6EAA6E;AAC7E,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,MAAM,EAAE,CAWjF;AAKD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,SAAS,UAAU,EAAE,EAAE,SAAS,EAAE,SAAS,UAAU,EAAE,EAAE,QAAQ,GAAE,SAAS,MAAM,EAAO,GAAG;IAAE,OAAO,EAAE,UAAU,EAAE,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAgBhL;AAED,MAAM,WAAW,aAAc,SAAQ,IAAI,CAAC,WAAW,EAAE,aAAa,CAAC;IACrE,WAAW,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,wBAAsB,eAAe,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAsCvF"}
package/dist/config.d.ts CHANGED
@@ -89,10 +89,12 @@ export declare const sqlProfileSchema: z.ZodObject<{
89
89
  defaultDatabase: z.ZodOptional<z.ZodString>;
90
90
  /**
91
91
  * ClickHouse import (#3718): the SQL user-defined functions `chant import
92
- * --from <env>` adopts besides those the imported objects call, by name, or
92
+ * --from <env>` adopts besides those the imported objects use, by name, or
93
93
  * by prefix with a trailing `*` (`shop_*`). A function belongs to no
94
94
  * database, so without this only the functions the imported tables, views
95
- * and dictionaries call (and the functions those call) are imported.
95
+ * and dictionaries use (and the functions those call) are imported. A use
96
+ * is found by name or, since ClickHouse stores a call as the function's
97
+ * body, by that body (#3745).
96
98
  */
97
99
  importFunctions: z.ZodOptional<z.ZodArray<z.ZodString>>;
98
100
  /**
@@ -256,10 +258,12 @@ export declare const sqlConfigSchema: z.ZodObject<{
256
258
  defaultDatabase: z.ZodOptional<z.ZodString>;
257
259
  /**
258
260
  * ClickHouse import (#3718): the SQL user-defined functions `chant import
259
- * --from <env>` adopts besides those the imported objects call, by name, or
261
+ * --from <env>` adopts besides those the imported objects use, by name, or
260
262
  * by prefix with a trailing `*` (`shop_*`). A function belongs to no
261
263
  * database, so without this only the functions the imported tables, views
262
- * and dictionaries call (and the functions those call) are imported.
264
+ * and dictionaries use (and the functions those call) are imported. A use
265
+ * is found by name or, since ClickHouse stores a call as the function's
266
+ * body, by that body (#3745).
263
267
  */
264
268
  importFunctions: z.ZodOptional<z.ZodArray<z.ZodString>>;
265
269
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAmC3D,eAAO,MAAM,gBAAgB;IAC3B;;;;;OAKG;;IAEH,2EAA2E;;;;IAE3E;;;;;;;OAOG;;;;;;;;;;;;;;;;;;IAEH;;;;OAIG;;IAEH,qFAAqF;;IAErF;;;;;;OAMG;;IAEH;;;;;;;;;OASG;;;;;;;;;;;;;;IAEH;;;;;OAKG;;IAEH,4FAA4F;;IAE5F;;;;;OAKG;;IAEH;;;;OAIG;;;;;;;;;IAEH;;;;;;;;OAQG;;IAEH;;;OAGG;;IAEH;;;;OAIG;;IAEH;;;;;;;;;OASG;;kBAEH,CAAC;AAcH,eAAO,MAAM,eAAe;IAC1B,iEAAiE;;;;;;;;IAEjE;;;;OAIG;;;;;;;;;IAEH;;;;OAIG;;IAMH,wCAAwC;;QApIxC;;;;;WAKG;;QAEH,2EAA2E;;;;QAE3E;;;;;;;WAOG;;;;;;;;;;;;;;;;;;QAEH;;;;WAIG;;QAEH,qFAAqF;;QAErF;;;;;;WAMG;;QAEH;;;;;;;;;WASG;;;;;;;;;;;;;;QAEH;;;;;WAKG;;QAEH,4FAA4F;;QAE5F;;;;;WAKG;;QAEH;;;;WAIG;;;;;;;;;QAEH;;;;;;;;WAQG;;QAEH;;;WAGG;;QAEH;;;;WAIG;;QAEH;;;;;;;;;WASG;;;kBAqCH,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAExD,OAAO,QAAQ,yBAAyB,CAAC;IACvC,UAAU,WAAW;QACnB,GAAG,CAAC,EAAE,SAAS,CAAC;KACjB;CACF;AAED,4EAA4E;AAC5E,MAAM,MAAM,kBAAkB,GAAG,WAAW,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC"}
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,yBAAyB,CAAC;AAmC3D,eAAO,MAAM,gBAAgB;IAC3B;;;;;OAKG;;IAEH,2EAA2E;;;;IAE3E;;;;;;;OAOG;;;;;;;;;;;;;;;;;;IAEH;;;;OAIG;;IAEH,qFAAqF;;IAErF;;;;;;;;OAQG;;IAEH;;;;;;;;;OASG;;;;;;;;;;;;;;IAEH;;;;;OAKG;;IAEH,4FAA4F;;IAE5F;;;;;OAKG;;IAEH;;;;OAIG;;;;;;;;;IAEH;;;;;;;;OAQG;;IAEH;;;OAGG;;IAEH;;;;OAIG;;IAEH;;;;;;;;;OASG;;kBAEH,CAAC;AAcH,eAAO,MAAM,eAAe;IAC1B,iEAAiE;;;;;;;;IAEjE;;;;OAIG;;;;;;;;;IAEH;;;;OAIG;;IAMH,wCAAwC;;QAtIxC;;;;;WAKG;;QAEH,2EAA2E;;;;QAE3E;;;;;;;WAOG;;;;;;;;;;;;;;;;;;QAEH;;;;WAIG;;QAEH,qFAAqF;;QAErF;;;;;;;;WAQG;;QAEH;;;;;;;;;WASG;;;;;;;;;;;;;;QAEH;;;;;WAKG;;QAEH,4FAA4F;;QAE5F;;;;;WAKG;;QAEH;;;;WAIG;;;;;;;;;QAEH;;;;;;;;WAQG;;QAEH;;;WAGG;;QAEH;;;;WAIG;;QAEH;;;;;;;;;WASG;;;kBAqCH,CAAC;AAEH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,MAAM,MAAM,SAAS,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC;AAExD,OAAO,QAAQ,yBAAyB,CAAC;IACvC,UAAU,WAAW;QACnB,GAAG,CAAC,EAAE,SAAS,CAAC;KACjB;CACF;AAED,4EAA4E;AAC5E,MAAM,MAAM,kBAAkB,GAAG,WAAW,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "e8638782cde7d627ffe77497c2cfb02f0843633475ad78354bdea13255de084e",
4
+ "manifest.json": "81cfc7a318ff52b02fbc21be6456ae27b73969fd2c3919f4b59c7c3af76fe12e",
5
5
  "meta.json": "44b1dcfa5b5e5be2239fdc9614ab6c11d3f1461d64e61d572e3658af6c96aa68",
6
6
  "types/index.d.ts": "0286ebb367773e84c76818b0f69cb14cff8328fd2f8c65ed87563c8d5815458c",
7
7
  "rules/postgres-templates.ts": "da6e305bc2ff3d0d64a20925fe588e051c2a4b556b37f2581c61c314ef7c580d",
@@ -57,5 +57,5 @@
57
57
  "rules/sqlpg117.ts": "3d5c2d57ccdee3cf91c3ba3cb6363750787a850df8ebd75e9cc3f33113303827",
58
58
  "rules/sqlpg118.ts": "e2a40a88a9374c4d99d02118ca4c60877ba0c6eb1ce603a93b24f5fd23d81dde"
59
59
  },
60
- "composite": "ab0994a9da91d9144f35b3d143c52ecae05f9612c94034eba2ba39256b7aef19"
60
+ "composite": "6fb06c039bfa093fb3afe3e0d1e65910acc7b983612ec1e8bc1cd0db97804a56"
61
61
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sql",
3
- "version": "0.120.0",
3
+ "version": "0.121.1",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "SQL",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-sql",
3
- "version": "0.120.0",
3
+ "version": "0.121.1",
4
4
  "description": "SQL lexicon for chant — database schema as typed declarations, one dialect at a time; ClickHouse first, with types read from a pinned server's catalog",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -70,7 +70,7 @@
70
70
  "typescript": "^5.9.3"
71
71
  },
72
72
  "peerDependencies": {
73
- "@intentius/chant": "^0.120.0",
73
+ "@intentius/chant": "^0.121.1",
74
74
  "typescript": "^5.9.3"
75
75
  }
76
76
  }
@@ -188,10 +188,18 @@ describe("live export of SQL functions (#3718)", () => {
188
188
  engine: "MergeTree",
189
189
  statement: "CREATE TABLE shop.orders\n(\n `id` UInt64,\n `net` Float64 DEFAULT shop_net(id)\n)\nENGINE = MergeTree\nORDER BY id",
190
190
  },
191
+ {
192
+ // ClickHouse stores a call as the function's body (#3745): this is shop_gross(net).
193
+ database: "shop",
194
+ name: "gross",
195
+ engine: "View",
196
+ statement: "CREATE VIEW shop.gross\n(\n `g` Float64\n)\nAS SELECT net + (net * 0.07) AS g\nFROM shop.orders",
197
+ },
191
198
  ],
192
199
  {
193
200
  functions: [
194
201
  { name: "other_score", statement: "CREATE FUNCTION other_score AS x -> (x * 2)" },
202
+ { name: "shop_gross", statement: "CREATE FUNCTION shop_gross AS x -> (x + (x * 0.07))" },
195
203
  { name: "shop_net", statement: "CREATE FUNCTION shop_net AS x -> shop_tax(x)" },
196
204
  { name: "shop_tax", statement: "CREATE FUNCTION shop_tax AS x -> (x * 0.2)" },
197
205
  { name: "shop_unused", statement: "CREATE FUNCTION shop_unused AS x -> x" },
@@ -206,14 +214,14 @@ describe("live export of SQL functions (#3718)", () => {
206
214
 
207
215
  test("adopts the functions the imported objects call, and the ones those call; a warning names the rest", async () => {
208
216
  const ir = await run();
209
- expect(ir.resources.map((r) => r.properties.name)).toEqual(["shop", "orders", "shop_net", "shop_tax"]);
217
+ expect(ir.resources.map((r) => r.properties.name)).toEqual(["shop", "orders", "gross", "shop_gross", "shop_net", "shop_tax"]);
210
218
  expect(ir.warnings).toEqual([
211
- "2 SQL functions on the server are not imported, since no imported object calls them: other_score, shop_unused. Name them in sql.profiles.x.importFunctions to import them.",
219
+ "2 SQL functions on the server are not imported, since no imported object uses them: other_score, shop_unused. Name them in sql.profiles.x.importFunctions to import them.",
212
220
  ]);
213
221
  });
214
222
 
215
223
  test("importFunctions names more, by name or by prefix", async () => {
216
- expect((await run({ importFunctions: ["shop_*"] })).resources.map((r) => r.properties.name)).toEqual(["shop", "orders", "shop_net", "shop_tax", "shop_unused"]);
224
+ expect((await run({ importFunctions: ["shop_*"] })).resources.map((r) => r.properties.name)).toEqual(["shop", "orders", "gross", "shop_gross", "shop_net", "shop_tax", "shop_unused"]);
217
225
  const ir = await run({ importFunctions: ["other_score"] });
218
226
  expect(ir.resources.map((r) => r.properties.name)).toContain("other_score");
219
227
  expect(ir.warnings).toEqual([expect.stringContaining(": shop_unused.")]);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Import adopts a SQL function a view or a column default uses (#3745),
3
+ * although ClickHouse stores the function's body there in place of the call.
4
+ *
5
+ * `CLICKHOUSE_URL` (with `CLICKHOUSE_USER` and `CLICKHOUSE_PASSWORD`) names a
6
+ * running server, such as the one `chant emulator up --lexicon sql` starts.
7
+ * Without it, a throwaway server at the pin, skipped cleanly when Docker is
8
+ * not available. Functions are global to the server, so everything here is
9
+ * named `chant_e2e_3745*` and dropped afterwards.
10
+ */
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
13
+ import { dockerAvailable, startScratchServer, type ScratchServer } from "../container";
14
+ import { clickhouseQuery, type ClickHouseEndpoint } from "../http";
15
+ import { clickhouseImage } from "../../spec/pin";
16
+ import { exportResources } from "./live-export";
17
+
18
+ const PREFIX = "chant_e2e_3745";
19
+ const DB = `${PREFIX}_shop`;
20
+ const TAX = `${PREFIX}_tax`;
21
+ const NET = `${PREFIX}_net`;
22
+ const LABEL = `${PREFIX}_label`;
23
+ const UNUSED = `${PREFIX}_unused`;
24
+ const fromEnv = process.env.CLICKHOUSE_URL;
25
+ const docker = await dockerAvailable();
26
+ const enabled = fromEnv !== undefined || docker;
27
+ let server: ScratchServer | undefined;
28
+ let endpoint: ClickHouseEndpoint;
29
+ const q = (sql: string) => clickhouseQuery(endpoint, sql);
30
+
31
+ const cleanup = async () => {
32
+ await q(`DROP DATABASE IF EXISTS ${DB} SYNC`).catch(() => undefined);
33
+ // NET calls TAX, so it goes first.
34
+ for (const f of [NET, TAX, LABEL, UNUSED]) await q(`DROP FUNCTION IF EXISTS ${f}`).catch(() => undefined);
35
+ };
36
+
37
+ beforeAll(async () => {
38
+ if (!enabled) return;
39
+ if (fromEnv !== undefined) {
40
+ endpoint = {
41
+ url: fromEnv,
42
+ ...(process.env.CLICKHOUSE_USER ? { user: process.env.CLICKHOUSE_USER } : {}),
43
+ ...(process.env.CLICKHOUSE_PASSWORD ? { password: process.env.CLICKHOUSE_PASSWORD } : {}),
44
+ };
45
+ } else {
46
+ server = await startScratchServer(clickhouseImage(), { namePrefix: "chant-sql-inlined" });
47
+ endpoint = server.endpoint;
48
+ }
49
+ await cleanup();
50
+ await q(`CREATE FUNCTION ${TAX} AS (x) -> x * 0.2`);
51
+ await q(`CREATE FUNCTION ${NET} AS (x, y) -> ${TAX}(x) + if(y > 1, y, 0)`);
52
+ await q(`CREATE FUNCTION ${LABEL} AS (s) -> concat('[', lower(s), ']')`);
53
+ await q(`CREATE FUNCTION ${UNUSED} AS (x) -> x - 42`);
54
+ await q(`CREATE DATABASE ${DB}`);
55
+ await q(`CREATE TABLE ${DB}.orders (id UInt64, kind String, amt Float64 DEFAULT ${TAX}(id)) ENGINE = MergeTree ORDER BY id`);
56
+ await q(`CREATE VIEW ${DB}.net AS SELECT id, ${NET}(amt, id) AS n FROM ${DB}.orders`);
57
+ await q(`CREATE MATERIALIZED VIEW ${DB}.labels ENGINE = MergeTree ORDER BY id AS SELECT id, ${LABEL}(kind) AS l FROM ${DB}.orders`);
58
+ }, 600_000);
59
+
60
+ afterAll(async () => {
61
+ if (enabled) await cleanup();
62
+ await server?.stop();
63
+ });
64
+
65
+ describe.skipIf(!enabled)("import finds a function by its body (#3745)", () => {
66
+ test("a view, a materialized view and a column default using functions adopt them; an unused one is named with the importFunctions hint", async () => {
67
+ // The premise: the server keeps no call to the function.
68
+ const [view] = await q(`SELECT create_table_query AS ddl FROM system.tables WHERE database = '${DB}' AND name = 'net'`);
69
+ expect(String(view!.ddl)).not.toContain(NET);
70
+
71
+ const profile = {
72
+ url: endpoint.url,
73
+ databases: [DB],
74
+ ...(endpoint.user !== undefined ? { user: { env: "CH_USER" } } : {}),
75
+ ...(endpoint.password !== undefined ? { password: { env: "CH_PASSWORD" } } : {}),
76
+ };
77
+ const ir = await exportResources({
78
+ environment: "e2e",
79
+ config: { sql: { profiles: { e2e: profile } } } as never,
80
+ env: { CH_USER: endpoint.user, CH_PASSWORD: endpoint.password },
81
+ });
82
+ const names = ir.resources.map((r) => String(r.properties.name));
83
+ expect(names).toEqual(expect.arrayContaining(["orders", "net", "labels", TAX, NET, LABEL]));
84
+ expect(names).not.toContain(UNUSED);
85
+ expect(ir.warnings).toEqual(expect.arrayContaining([expect.stringMatching(new RegExp(`not imported, since no imported object uses (it|them): .*${UNUSED}.*importFunctions`))]));
86
+ });
87
+ });
@@ -0,0 +1,48 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { inlinedFunctions } from "./inlined";
3
+
4
+ // What the server prints for each, as read from system.functions (#3745).
5
+ const FUNCTIONS = [
6
+ { name: "tax", statement: "CREATE FUNCTION tax AS x -> (x * 0.2)" },
7
+ { name: "net", statement: "CREATE FUNCTION net AS (x, y) -> (tax(x) + if(y > 1, y, 0))" },
8
+ { name: "ident", statement: "CREATE FUNCTION ident AS x -> x" },
9
+ { name: "label", statement: "CREATE FUNCTION label AS s -> concat('[', lower(s), ']')" },
10
+ ];
11
+
12
+ const found = (...statements: string[]) => inlinedFunctions(statements, FUNCTIONS);
13
+
14
+ describe("a function found by its body in a stored definition (#3745)", () => {
15
+ test("a column DEFAULT holds the body with the argument in place of the parameter", () => {
16
+ expect(found("CREATE TABLE d.t\n(\n `id` UInt64,\n `amt` Float64 DEFAULT id * 0.2\n)\nENGINE = MergeTree\nORDER BY id")).toEqual(["tax"]);
17
+ });
18
+
19
+ test("a nested call is matched expanded, and so is the function it calls", () => {
20
+ expect(found("CREATE VIEW d.v\n(\n `z` Float64\n)\nAS SELECT (amt * 0.2) + if(id > 1, id, 0) AS z\nFROM d.t")).toEqual(["tax", "net"]);
21
+ });
22
+
23
+ test("the server's parentheses around the body or an argument don't matter", () => {
24
+ expect(found("CREATE VIEW d.v AS SELECT id FROM d.t WHERE (id * 0.2) > 1")).toEqual(["tax"]);
25
+ expect(found("CREATE VIEW d.v AS SELECT (a + b) * 0.2 AS t FROM d.t")).toEqual(["tax"]);
26
+ expect(found("CREATE VIEW d.v AS SELECT concat('[', lower(name), ']') AS l FROM d.t")).toEqual(["label"]);
27
+ });
28
+
29
+ test("a parameter used twice must take the same argument twice", () => {
30
+ expect(found("CREATE VIEW d.v AS SELECT (a * 0.2) + if(b > 1, c, 0) AS z FROM d.t")).toEqual(["tax"]);
31
+ });
32
+
33
+ test("two parameters and an operator are enough, so such a body is found wherever that operator is", () => {
34
+ const scale = [{ name: "scale", statement: "CREATE FUNCTION scale AS (x, k) -> (x * k)" }];
35
+ expect(inlinedFunctions(["CREATE VIEW d.v AS SELECT price * qty AS total FROM d.t"], scale)).toEqual(["scale"]);
36
+ expect(inlinedFunctions(["CREATE VIEW d.v AS SELECT price + qty AS total FROM d.t"], scale)).toEqual([]);
37
+ });
38
+
39
+ test("a body that is only a parameter is never found, and nothing else is found where the body is absent", () => {
40
+ expect(found("CREATE VIEW d.v AS SELECT id FROM d.t")).toEqual([]);
41
+ expect(found("CREATE VIEW d.v AS SELECT id * 0.3 FROM d.t")).toEqual([]);
42
+ });
43
+
44
+ test("an argument never spans a comma or the next clause", () => {
45
+ expect(found("CREATE VIEW d.v AS SELECT a, 0.2 FROM d.t")).toEqual([]);
46
+ expect(found("CREATE VIEW d.v AS SELECT x FROM d.t WHERE 1 + if(y > 1, y, 0)")).toEqual([]);
47
+ });
48
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Finding a SQL user-defined function in what ClickHouse stored (#3745).
3
+ *
4
+ * ClickHouse does not keep a call to a SQL function in a view's query or a
5
+ * column's DEFAULT: it writes the function's body in place of the call, with
6
+ * the arguments substituted, and a nested call expanded the same way.
7
+ * `CREATE FUNCTION tax AS x -> (x * 0.2)` used as `tax(amt)` reads back as
8
+ * `amt * 0.2`. So an import can't look for the name; it looks for the body.
9
+ *
10
+ * Each function's body is expanded (a call to another function becomes that
11
+ * function's body), its parameters become wildcards, and the result is
12
+ * matched against the stored statement's tokens. Parentheses that only
13
+ * group are dropped on both sides, since the server adds or leaves them by
14
+ * precedence where the body lands. A wildcard matches one balanced
15
+ * expression, and a parameter used twice must match the same text twice.
16
+ *
17
+ * A function whose body is just a parameter (`x -> x`) leaves nothing to
18
+ * look for, so it is never found this way.
19
+ */
20
+
21
+ import { isTrivia, tokenizeText, type Token } from "../tokens";
22
+ import { unquote } from "../parser";
23
+
24
+ /** One item of a pattern: a token's text, or the parameter at `arg`. */
25
+ type Item = { lit: string } | { arg: number };
26
+
27
+ /** A function's parameters and body, from `CREATE FUNCTION name AS (x, y) -> body`; undefined when it doesn't read that way. */
28
+ export function functionLambda(statement: string): { params: string[]; body: Token[] } | undefined {
29
+ let tokens: Token[];
30
+ try {
31
+ tokens = tokenizeText(statement, 0).filter((t) => !isTrivia(t));
32
+ } catch {
33
+ return undefined;
34
+ }
35
+ const as = tokens.findIndex((t) => t.kind === "ident" && t.text.toUpperCase() === "AS");
36
+ const arrow = tokens.findIndex((t, i) => i > as && t.kind === "op" && t.text === "->");
37
+ if (as < 0 || arrow < 0) return undefined;
38
+ const params = tokens
39
+ .slice(as + 1, arrow)
40
+ .filter((t) => t.kind === "ident" || t.kind === "qident")
41
+ .map(nameOf);
42
+ const body = tokens.slice(arrow + 1);
43
+ while (body.length > 0 && body[body.length - 1]!.kind === "punct" && body[body.length - 1]!.text === ";") body.pop();
44
+ return { params, body };
45
+ }
46
+
47
+ const nameOf = (t: Token): string => (t.kind === "qident" ? unquote(t.text) : t.text);
48
+ const isPunct = (t: Token | undefined, text: string): boolean => t?.kind === "punct" && t.text === text;
49
+ const isName = (t: Token | undefined): boolean => t?.kind === "ident" || t?.kind === "qident";
50
+
51
+ /** Words a `(` can follow that make it a grouping, not a call. */
52
+ const GROUPING_AFTER = new Set(
53
+ ["AND", "OR", "NOT", "IS", "LIKE", "ILIKE", "BETWEEN", "WHEN", "THEN", "ELSE", "CASE", "SELECT", "WHERE", "PREWHERE", "HAVING", "FROM", "ON", "AS", "BY", "DEFAULT", "MATERIALIZED", "ALIAS", "EPHEMERAL", "TTL", "INTERVAL", "DISTINCT", "ALL"],
54
+ );
55
+
56
+ /** Words a wildcard never spans: they start the next clause or column attribute. */
57
+ const CLAUSE = new Set(
58
+ ["SELECT", "FROM", "WHERE", "PREWHERE", "GROUP", "ORDER", "HAVING", "LIMIT", "AS", "ENGINE", "SETTINGS", "DEFAULT", "MATERIALIZED", "ALIAS", "EPHEMERAL", "CODEC", "TTL", "COMMENT", "JOIN", "ON", "USING", "UNION", "WITH"],
59
+ );
60
+
61
+ /** The tokens with grouping parentheses taken out; a call's parentheses stay. Trivia is assumed gone. */
62
+ function ungroup(tokens: readonly Token[]): Token[] {
63
+ const out: Token[] = [];
64
+ const grouping: boolean[] = [];
65
+ tokens.forEach((t, i) => {
66
+ if (isPunct(t, "(")) {
67
+ const prev = tokens[i - 1];
68
+ const call = (isName(prev) && !(prev!.kind === "ident" && GROUPING_AFTER.has(prev!.text.toUpperCase()))) || isPunct(prev, ")");
69
+ grouping.push(!call);
70
+ if (call) out.push(t);
71
+ } else if (isPunct(t, ")")) {
72
+ if (grouping.pop() === false) out.push(t);
73
+ } else out.push(t);
74
+ });
75
+ return out;
76
+ }
77
+
78
+ /** The index past the `)` matching the `(` at `open`, and the arguments between, split at top-level commas. */
79
+ function callArgs(tokens: readonly Token[], open: number): { args: Token[][]; end: number } | undefined {
80
+ const args: Token[][] = [[]];
81
+ let depth = 0;
82
+ for (let i = open + 1; i < tokens.length; i++) {
83
+ const t = tokens[i]!;
84
+ if (isPunct(t, "(")) depth++;
85
+ else if (isPunct(t, ")")) {
86
+ if (depth === 0) return { args: args.length === 1 && args[0]!.length === 0 ? [] : args, end: i + 1 };
87
+ depth--;
88
+ } else if (depth === 0 && isPunct(t, ",")) {
89
+ args.push([]);
90
+ continue;
91
+ }
92
+ args[args.length - 1]!.push(t);
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ /** A placeholder token for parameter `n`, which can't collide with anything a statement holds. */
98
+ const placeholder = (n: number): Token => ({ kind: "ident", text: `\u0000${n}`, part: 0, start: 0, end: 0 });
99
+
100
+ /**
101
+ * `tokens` with `params` replaced by their arguments' tokens and each call to
102
+ * a function in `lambdas` replaced by that function's body, recursively.
103
+ */
104
+ function expand(tokens: readonly Token[], params: ReadonlyMap<string, Token[]>, lambdas: ReadonlyMap<string, { params: string[]; body: Token[] }>, seen: ReadonlySet<string>): Token[] {
105
+ const out: Token[] = [];
106
+ for (let i = 0; i < tokens.length; i++) {
107
+ const t = tokens[i]!;
108
+ if (isName(t)) {
109
+ const name = nameOf(t);
110
+ const callee = lambdas.get(name);
111
+ if (callee && isPunct(tokens[i + 1], "(") && !seen.has(name)) {
112
+ const call = callArgs(tokens, i + 1);
113
+ if (call && call.args.length === callee.params.length) {
114
+ const bound = new Map(callee.params.map((p, n) => [p, expand(call.args[n]!, params, lambdas, seen)]));
115
+ out.push(...expand(callee.body, bound, lambdas, new Set([...seen, name])));
116
+ i = call.end - 1;
117
+ continue;
118
+ }
119
+ }
120
+ const arg = params.get(name);
121
+ // A parameter is a bare name: `x`, not the `x` in `t.x` or a call `x(...)`.
122
+ if (arg && !isPunct(tokens[i - 1], ".") && !isPunct(tokens[i + 1], "(")) {
123
+ out.push(...arg);
124
+ continue;
125
+ }
126
+ }
127
+ out.push(t);
128
+ }
129
+ return out;
130
+ }
131
+
132
+ /** What to look for: the function's expanded body, with its parameters as wildcards. Undefined when nothing but a parameter is left to match. */
133
+ export function inlinedPattern(name: string, lambdas: ReadonlyMap<string, { params: string[]; body: Token[] }>): Item[] | undefined {
134
+ const own = lambdas.get(name);
135
+ if (!own) return undefined;
136
+ const params = new Map(own.params.map((p, n) => [p, [placeholder(n)]]));
137
+ const tokens = ungroup(expand(own.body, params, lambdas, new Set([name])));
138
+ const items: Item[] = tokens.map((t) => (t.text.startsWith("\u0000") ? { arg: Number(t.text.slice(1)) } : { lit: t.text }));
139
+ return items.some((i) => "lit" in i) ? items : undefined;
140
+ }
141
+
142
+ /** A budget on matching steps per statement, so a pathological statement can't stall an import. */
143
+ const STEP_LIMIT = 200_000;
144
+
145
+ /** Whether `pattern` appears in `statement`'s tokens. */
146
+ export function containsInlined(statement: string, pattern: readonly Item[]): boolean {
147
+ let raw: Token[];
148
+ try {
149
+ raw = tokenizeText(statement, 0).filter((t) => !isTrivia(t));
150
+ } catch {
151
+ return false;
152
+ }
153
+ const text = ungroup(raw).map((t) => t.text);
154
+ const lits = pattern.flatMap((i) => ("lit" in i ? [i.lit] : []));
155
+ const present = new Set(text);
156
+ if (!lits.every((l) => present.has(l))) return false;
157
+ let steps = 0;
158
+ const match = (p: number, at: number, bound: Map<number, string[]>): boolean => {
159
+ if (++steps > STEP_LIMIT) return false;
160
+ if (p === pattern.length) return true;
161
+ const item = pattern[p]!;
162
+ if ("lit" in item) return text[at] === item.lit && match(p + 1, at + 1, bound);
163
+ const known = bound.get(item.arg);
164
+ if (known) return known.every((s, k) => text[at + k] === s) && match(p + 1, at + known.length, bound);
165
+ // One balanced expression: never past a top-level comma, an unmatched `)`, or the next clause.
166
+ let depth = 0;
167
+ for (let end = at; end < text.length; end++) {
168
+ const s = text[end]!;
169
+ if (depth === 0 && (s === "," || s === ";" || s === ")" || CLAUSE.has(s.toUpperCase()))) return false;
170
+ if (s === "(") depth++;
171
+ else if (s === ")") depth--;
172
+ if (depth > 0) continue;
173
+ bound.set(item.arg, text.slice(at, end + 1));
174
+ if (match(p + 1, end + 1, bound)) return true;
175
+ bound.delete(item.arg);
176
+ }
177
+ return false;
178
+ };
179
+ for (let at = 0; at < text.length; at++) if (match(0, at, new Map())) return true;
180
+ return false;
181
+ }
182
+
183
+ /**
184
+ * The functions among `functions` whose bodies appear inlined in any of
185
+ * `statements`. A function is matched with the others' bodies expanded into
186
+ * its own, as the server does.
187
+ */
188
+ export function inlinedFunctions(statements: readonly string[], functions: ReadonlyArray<{ name: string; statement: string }>): string[] {
189
+ const lambdas = new Map<string, { params: string[]; body: Token[] }>();
190
+ for (const f of functions) {
191
+ const l = functionLambda(f.statement);
192
+ if (l) lambdas.set(f.name, l);
193
+ }
194
+ const out: string[] = [];
195
+ for (const f of functions) {
196
+ const pattern = inlinedPattern(f.name, lambdas);
197
+ if (pattern && statements.some((s) => containsInlined(s, pattern))) out.push(f.name);
198
+ }
199
+ return out;
200
+ }
@@ -17,9 +17,11 @@
17
17
  * `goose_db_version`) is left out with a warning (#3676), as on Postgres.
18
18
  * - A SQL user-defined function belongs to no database, and every project's
19
19
  * functions sit side by side on a server (#3718). Without a selector, an
20
- * import adopts only the functions the imported objects call (and those
20
+ * import adopts only the functions the imported objects use (and those
21
21
  * functions call), and the ones `sql.profiles.<env>.importFunctions` names;
22
- * a warning names the rest. A function carries no comment, so `owned` does
22
+ * a warning names the rest. ClickHouse stores a call as the function's body
23
+ * (#3745), so a use is found by that body (`./inlined.ts`) as well as by
24
+ * the name. A function carries no comment, so `owned` does
23
25
  * not apply to it.
24
26
  */
25
27
 
@@ -32,6 +34,7 @@ import { CLICKHOUSE_ENTITY_TYPES } from "../entities";
32
34
  import { isTrivia, tokenizeText } from "../tokens";
33
35
  import { unquote } from "../parser";
34
36
  import type { LiveObject } from "../live/catalog";
37
+ import { inlinedFunctions } from "./inlined";
35
38
 
36
39
  /** The functions among `names` a statement calls: a name followed by `(`. */
37
40
  export function calledFunctions(ddl: string, names: ReadonlySet<string>): string[] {
@@ -52,13 +55,15 @@ const named = (patterns: readonly string[], name: string) => patterns.some((p) =
52
55
 
53
56
  /**
54
57
  * The functions an import adopts (#3718): those the objects call, those
55
- * `patterns` name, and the functions those call in turn; and the rest.
58
+ * whose bodies the objects hold in place of a call (#3745, `./inlined.ts`),
59
+ * those `patterns` name, and the functions those call in turn; and the rest.
56
60
  */
57
61
  export function scopedFunctions(objects: readonly LiveObject[], functions: readonly LiveObject[], patterns: readonly string[] = []): { adopted: LiveObject[]; skipped: string[] } {
58
62
  const byName = new Map(functions.map((f) => [f.name, f]));
59
63
  const names = new Set(byName.keys());
60
64
  const adopted = new Set<string>(functions.filter((f) => named(patterns, f.name)).map((f) => f.name));
61
65
  for (const o of objects) for (const n of calledFunctions(o.statement, names)) adopted.add(n);
66
+ for (const n of inlinedFunctions(objects.map((o) => o.statement), functions)) adopted.add(n);
62
67
  const queue = [...adopted];
63
68
  while (queue.length > 0) {
64
69
  for (const n of calledFunctions(byName.get(queue.pop()!)!.statement, names)) {
@@ -99,7 +104,7 @@ export async function exportResources(options: ExportOptions): Promise<ExportedT
99
104
  selected.push(...adopted);
100
105
  if (skipped.length > 0) {
101
106
  warnings.push(
102
- `${skipped.length} SQL function${skipped.length === 1 ? "" : "s"} on the server ${skipped.length === 1 ? "is" : "are"} not imported, since no imported object calls ${skipped.length === 1 ? "it" : "them"}: ${skipped.join(", ")}. Name ${skipped.length === 1 ? "it" : "them"} in sql.profiles.${options.environment}.importFunctions to import ${skipped.length === 1 ? "it" : "them"}.`,
107
+ `${skipped.length} SQL function${skipped.length === 1 ? "" : "s"} on the server ${skipped.length === 1 ? "is" : "are"} not imported, since no imported object uses ${skipped.length === 1 ? "it" : "them"}: ${skipped.join(", ")}. Name ${skipped.length === 1 ? "it" : "them"} in sql.profiles.${options.environment}.importFunctions to import ${skipped.length === 1 ? "it" : "them"}.`,
103
108
  );
104
109
  }
105
110
  }
package/src/config.ts CHANGED
@@ -106,10 +106,12 @@ export const sqlProfileSchema = z.strictObject({
106
106
  defaultDatabase: z.string().optional(),
107
107
  /**
108
108
  * ClickHouse import (#3718): the SQL user-defined functions `chant import
109
- * --from <env>` adopts besides those the imported objects call, by name, or
109
+ * --from <env>` adopts besides those the imported objects use, by name, or
110
110
  * by prefix with a trailing `*` (`shop_*`). A function belongs to no
111
111
  * database, so without this only the functions the imported tables, views
112
- * and dictionaries call (and the functions those call) are imported.
112
+ * and dictionaries use (and the functions those call) are imported. A use
113
+ * is found by name or, since ClickHouse stores a call as the function's
114
+ * body, by that body (#3745).
113
115
  */
114
116
  importFunctions: z.array(z.string()).optional(),
115
117
  /**