@malloy-publisher/server 0.0.209 → 0.0.211
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.
- package/dist/app/api-doc.yaml +20 -3
- package/dist/app/assets/{EnvironmentPage-BRMCY9d8.js → EnvironmentPage-CuO6sty5.js} +1 -1
- package/dist/app/assets/HomePage-u3vxROIF.js +1 -0
- package/dist/app/assets/{MainPage-sZdUjUcu.js → MainPage-Bt2sYLbr.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-C_jQQUCJ.js → MaterializationsPage-B1rj61zs.js} +1 -1
- package/dist/app/assets/{ModelPage-Bh62OIEq.js → ModelPage-BpvONvSR.js} +1 -1
- package/dist/app/assets/{PackagePage-DJW4xjLp.js → PackagePage-DHNcwboW.js} +1 -1
- package/dist/app/assets/{RouteError-Vi5yGs_F.js → RouteError-D1vhLJvr.js} +1 -1
- package/dist/app/assets/{WorkbookPage-BvJMi21d.js → WorkbookPage-CZmud-yI.js} +1 -1
- package/dist/app/assets/{core-BbW0t3RQ.es-L-mZcOk3.js → core-XtBSnW2U.es-DE88sVsZ.js} +1 -1
- package/dist/app/assets/{index-CCcHdeew.js → index-BQdOB6m9.js} +1 -1
- package/dist/app/assets/{index-DuqTjxM_.js → index-BlnQtDZj.js} +137 -137
- package/dist/app/assets/{index-CNFX-CGL.js → index-CYf2akGH.js} +1 -1
- package/dist/app/assets/{index.umd-GgEb4WfT.js → index.umd-BdQ0R4hx.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +3 -0
- package/dist/server.mjs +314 -306
- package/package.json +1 -1
- package/src/materialization_metrics.spec.ts +84 -0
- package/src/materialization_metrics.ts +78 -119
- package/src/service/build_plan.spec.ts +84 -0
- package/src/service/build_plan.ts +53 -2
- package/src/service/materialization_service.spec.ts +126 -8
- package/src/service/materialization_service.ts +96 -83
- package/src/service/materialization_test_fixtures.ts +2 -0
- package/src/service/model.ts +25 -25
- package/src/service/package.ts +19 -7
- package/src/service/package_worker_path.spec.ts +65 -55
- package/src/service/persist_annotation_validation.spec.ts +77 -0
- package/src/service/persist_annotation_validation.ts +54 -0
- package/src/service/quoting.spec.ts +50 -0
- package/src/service/quoting.ts +37 -16
- package/src/utils.ts +5 -0
- package/dist/app/assets/HomePage-DQzgkiMI.js +0 -1
|
@@ -28,10 +28,12 @@ import {
|
|
|
28
28
|
describe,
|
|
29
29
|
expect,
|
|
30
30
|
it,
|
|
31
|
+
spyOn,
|
|
31
32
|
} from "bun:test";
|
|
32
33
|
import * as fs from "fs";
|
|
33
34
|
import * as os from "os";
|
|
34
35
|
import * as path from "path";
|
|
36
|
+
import { logger } from "../logger";
|
|
35
37
|
import {
|
|
36
38
|
PackageLoadPool,
|
|
37
39
|
__setPackageLoadPoolForTests,
|
|
@@ -280,14 +282,13 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
280
282
|
}
|
|
281
283
|
});
|
|
282
284
|
|
|
283
|
-
it("
|
|
285
|
+
it("logs a warning for a package whose view carries an invalid renderer tag", async () => {
|
|
284
286
|
writeManifest();
|
|
285
287
|
// `# big_value { sparkline=... }` is a child-only renderer config placed on
|
|
286
288
|
// the view itself, with no activating big_value. The renderer declines to
|
|
287
|
-
// match it
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// ModelCompilationError naming the offending view.
|
|
289
|
+
// match it. Render-tag validation runs on the main thread after the worker
|
|
290
|
+
// hydrates the model. The misconfiguration is logged as a warning naming
|
|
291
|
+
// the offending view; it does not fail the package load.
|
|
291
292
|
fs.writeFileSync(
|
|
292
293
|
path.join(tempDir, "bad_render.malloy"),
|
|
293
294
|
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
@@ -302,21 +303,21 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
302
303
|
}`,
|
|
303
304
|
);
|
|
304
305
|
|
|
305
|
-
const
|
|
306
|
+
const warnSpy = spyOn(logger, "warn");
|
|
306
307
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
307
308
|
try {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
);
|
|
318
|
-
expect((caught as Error).message).toContain("nums -> card");
|
|
309
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
310
|
+
expect(pkg.getModelPaths()).toEqual(["bad_render.malloy"]);
|
|
311
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
312
|
+
expect(
|
|
313
|
+
warnings.some(
|
|
314
|
+
(m) =>
|
|
315
|
+
m.includes("Invalid renderer configuration") &&
|
|
316
|
+
m.includes("nums -> card"),
|
|
317
|
+
),
|
|
318
|
+
).toBe(true);
|
|
319
319
|
} finally {
|
|
320
|
+
warnSpy.mockRestore();
|
|
320
321
|
await duckdb.close();
|
|
321
322
|
}
|
|
322
323
|
});
|
|
@@ -341,7 +342,7 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
341
342
|
}
|
|
342
343
|
});
|
|
343
344
|
|
|
344
|
-
it("
|
|
345
|
+
it("logs a warning for a backtick-quoted (hyphenated) source with a bad view render tag", async () => {
|
|
345
346
|
writeManifest();
|
|
346
347
|
// A source whose name needs Malloy backtick-quoting (here, a hyphen). The
|
|
347
348
|
// validation target must quote the identifier; otherwise `run: bad-source
|
|
@@ -363,26 +364,26 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
363
364
|
}`,
|
|
364
365
|
);
|
|
365
366
|
|
|
366
|
-
const
|
|
367
|
+
const warnSpy = spyOn(logger, "warn");
|
|
367
368
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
368
369
|
try {
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
);
|
|
379
|
-
expect((caught as Error).message).toContain("bad-source -> card");
|
|
370
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
371
|
+
expect(pkg.getModelPaths()).toEqual(["hyphen_render.malloy"]);
|
|
372
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
373
|
+
expect(
|
|
374
|
+
warnings.some(
|
|
375
|
+
(m) =>
|
|
376
|
+
m.includes("Invalid renderer configuration") &&
|
|
377
|
+
m.includes("bad-source -> card"),
|
|
378
|
+
),
|
|
379
|
+
).toBe(true);
|
|
380
380
|
} finally {
|
|
381
|
+
warnSpy.mockRestore();
|
|
381
382
|
await duckdb.close();
|
|
382
383
|
}
|
|
383
384
|
});
|
|
384
385
|
|
|
385
|
-
it("
|
|
386
|
+
it("logs a warning for a source name containing an escaped backtick with a bad view render tag", async () => {
|
|
386
387
|
writeManifest();
|
|
387
388
|
// Source name with a literal backtick (written escaped in Malloy as
|
|
388
389
|
// `wei\`rd`). The validation target must re-escape it; a bare wrap would
|
|
@@ -403,26 +404,26 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
403
404
|
}`,
|
|
404
405
|
);
|
|
405
406
|
|
|
406
|
-
const
|
|
407
|
+
const warnSpy = spyOn(logger, "warn");
|
|
407
408
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
408
409
|
try {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
);
|
|
419
|
-
expect((caught as Error).message).toContain("card");
|
|
410
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
411
|
+
expect(pkg.getModelPaths()).toEqual(["backtick_render.malloy"]);
|
|
412
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
413
|
+
expect(
|
|
414
|
+
warnings.some(
|
|
415
|
+
(m) =>
|
|
416
|
+
m.includes("Invalid renderer configuration") &&
|
|
417
|
+
m.includes("card"),
|
|
418
|
+
),
|
|
419
|
+
).toBe(true);
|
|
420
420
|
} finally {
|
|
421
|
+
warnSpy.mockRestore();
|
|
421
422
|
await duckdb.close();
|
|
422
423
|
}
|
|
423
424
|
});
|
|
424
425
|
|
|
425
|
-
it("
|
|
426
|
+
it("logs a warning for a .malloynb notebook whose source view carries an invalid renderer tag", async () => {
|
|
426
427
|
writeManifest();
|
|
427
428
|
fs.writeFileSync(
|
|
428
429
|
path.join(tempDir, "bad_render.malloynb"),
|
|
@@ -439,18 +440,22 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
439
440
|
}`,
|
|
440
441
|
);
|
|
441
442
|
|
|
442
|
-
const
|
|
443
|
+
const warnSpy = spyOn(logger, "warn");
|
|
443
444
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
444
445
|
try {
|
|
445
|
-
await
|
|
446
|
-
|
|
447
|
-
|
|
446
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
447
|
+
expect(pkg.getModelPaths()).toEqual(["bad_render.malloynb"]);
|
|
448
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
449
|
+
expect(
|
|
450
|
+
warnings.some((m) => m.includes("Invalid renderer configuration")),
|
|
451
|
+
).toBe(true);
|
|
448
452
|
} finally {
|
|
453
|
+
warnSpy.mockRestore();
|
|
449
454
|
await duckdb.close();
|
|
450
455
|
}
|
|
451
456
|
});
|
|
452
457
|
|
|
453
|
-
it("re-validates renderer tags on reload,
|
|
458
|
+
it("re-validates renderer tags on reload, logging a warning for a model that develops a bad tag", async () => {
|
|
454
459
|
writeManifest();
|
|
455
460
|
// Start valid so Package.create succeeds.
|
|
456
461
|
fs.writeFileSync(
|
|
@@ -463,13 +468,15 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
463
468
|
);
|
|
464
469
|
|
|
465
470
|
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
471
|
+
const warnSpy = spyOn(logger, "warn");
|
|
466
472
|
try {
|
|
467
473
|
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
468
474
|
expect(pkg.getModel("m.malloy")).toBeDefined();
|
|
475
|
+
warnSpy.mockClear();
|
|
469
476
|
|
|
470
|
-
// The model now develops a misconfigured render tag. Reload
|
|
471
|
-
//
|
|
472
|
-
//
|
|
477
|
+
// The model now develops a misconfigured render tag. Reload re-validates
|
|
478
|
+
// (not just Package.create) and logs a warning for it, without aborting
|
|
479
|
+
// the reload or failing the model.
|
|
473
480
|
fs.writeFileSync(
|
|
474
481
|
path.join(tempDir, "m.malloy"),
|
|
475
482
|
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
@@ -487,10 +494,13 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
487
494
|
|
|
488
495
|
const reloaded = pkg.getModel("m.malloy");
|
|
489
496
|
expect(reloaded).toBeDefined();
|
|
490
|
-
await expect(reloaded!.getModel()).
|
|
491
|
-
|
|
492
|
-
|
|
497
|
+
await expect(reloaded!.getModel()).resolves.toBeDefined();
|
|
498
|
+
const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
|
|
499
|
+
expect(
|
|
500
|
+
warnings.some((m) => m.includes("Invalid renderer configuration")),
|
|
501
|
+
).toBe(true);
|
|
493
502
|
} finally {
|
|
503
|
+
warnSpy.mockRestore();
|
|
494
504
|
await duckdb.close();
|
|
495
505
|
}
|
|
496
506
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { ModelCompilationError } from "../errors";
|
|
3
|
+
import { assertPersistNamesQuoted } from "./persist_annotation_validation";
|
|
4
|
+
|
|
5
|
+
describe("assertPersistNamesQuoted", () => {
|
|
6
|
+
it("throws ModelCompilationError on a bare (unquoted) persist name", () => {
|
|
7
|
+
expect(() =>
|
|
8
|
+
assertPersistNamesQuoted(
|
|
9
|
+
`#@ persist name=engaged_events\nsource: engaged_events is x -> { select: * }`,
|
|
10
|
+
"m.malloy",
|
|
11
|
+
),
|
|
12
|
+
).toThrow(ModelCompilationError);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("tolerates whitespace around = but still flags an unquoted value", () => {
|
|
16
|
+
expect(() =>
|
|
17
|
+
assertPersistNamesQuoted(
|
|
18
|
+
`#@ persist name = engaged_events`,
|
|
19
|
+
"m.malloy",
|
|
20
|
+
),
|
|
21
|
+
).toThrow(/must be quoted/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("accepts a double-quoted name", () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
assertPersistNamesQuoted(
|
|
27
|
+
`#@ persist name="engaged_events"`,
|
|
28
|
+
"m.malloy",
|
|
29
|
+
),
|
|
30
|
+
).not.toThrow();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("accepts a single-quoted name", () => {
|
|
34
|
+
expect(() =>
|
|
35
|
+
assertPersistNamesQuoted(
|
|
36
|
+
`#@ persist name='engaged_events'`,
|
|
37
|
+
"m.malloy",
|
|
38
|
+
),
|
|
39
|
+
).not.toThrow();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("accepts a dotted, quoted dialect path", () => {
|
|
43
|
+
expect(() =>
|
|
44
|
+
assertPersistNamesQuoted(
|
|
45
|
+
`#@ persist name="my_dataset.engaged_events"`,
|
|
46
|
+
"m.malloy",
|
|
47
|
+
),
|
|
48
|
+
).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("ignores non-persist annotations and persist lines with no name field", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
assertPersistNamesQuoted(
|
|
54
|
+
`# number=2\n#@ persist realization="COPY"\nsource: x is y -> { select: * }`,
|
|
55
|
+
"m.malloy",
|
|
56
|
+
),
|
|
57
|
+
).not.toThrow();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("does not mistake a neighbouring key for the name field", () => {
|
|
61
|
+
expect(() =>
|
|
62
|
+
assertPersistNamesQuoted(
|
|
63
|
+
`#@ persist tablename=foo name="bar"`,
|
|
64
|
+
"m.malloy",
|
|
65
|
+
),
|
|
66
|
+
).not.toThrow();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reports every offending annotation in the message", () => {
|
|
70
|
+
expect(() =>
|
|
71
|
+
assertPersistNamesQuoted(
|
|
72
|
+
`#@ persist name=a\nsource: a is x -> { select: * }\n#@ persist name=b\nsource: b is x -> { select: * }`,
|
|
73
|
+
"m.malloy",
|
|
74
|
+
),
|
|
75
|
+
).toThrow(/name=a.*name=b/s);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { ModelCompilationError } from "../errors";
|
|
2
|
+
|
|
3
|
+
/** A line whose first non-whitespace content is a `#@ persist` directive. */
|
|
4
|
+
const PERSIST_LINE_PATTERN = /^\s*#@\s+persist\b/;
|
|
5
|
+
/**
|
|
6
|
+
* A `name=` key whose value is NOT immediately opened by a single or double
|
|
7
|
+
* quote — i.e. a bare `name=engaged_events` (whitespace around `=` tolerated).
|
|
8
|
+
* `name="..."` and `name='...'` pass. The `\bname` word boundary requires a
|
|
9
|
+
* standalone key, so neighbours like `tablename=` / `realization_name=` are
|
|
10
|
+
* never mistaken for it.
|
|
11
|
+
*/
|
|
12
|
+
const UNQUOTED_NAME_PATTERN = /\bname\s*=\s*(?!["'])/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Reject `#@ persist name=<value>` annotations whose name is unquoted.
|
|
16
|
+
*
|
|
17
|
+
* The persist build plan requires a quoted name — a dialect-style table path
|
|
18
|
+
* (`name="engaged_events"`, or `name="my_dataset.engaged_events"`). A bare
|
|
19
|
+
* value is dropped from the build plan, so the source publishes and serves but
|
|
20
|
+
* is never materialized, with no error anywhere. Throwing a
|
|
21
|
+
* `ModelCompilationError` (HTTP 424) fails the publish/load with a clear,
|
|
22
|
+
* actionable message — the same hard-stop `Model.validateRenderTags` applies to
|
|
23
|
+
* a misconfigured render tag.
|
|
24
|
+
*
|
|
25
|
+
* Scans the raw model source line-by-line rather than the compiled annotation
|
|
26
|
+
* objects: the check must fire regardless of how the compiler attaches the
|
|
27
|
+
* annotation, and the raw text is the ground truth for whether the author
|
|
28
|
+
* quoted the value (the tag parser discards quote information once parsed).
|
|
29
|
+
*
|
|
30
|
+
* @throws {ModelCompilationError} listing every offending annotation.
|
|
31
|
+
*/
|
|
32
|
+
export function assertPersistNamesQuoted(
|
|
33
|
+
modelSource: string,
|
|
34
|
+
modelPath: string,
|
|
35
|
+
): void {
|
|
36
|
+
const offenders: string[] = [];
|
|
37
|
+
for (const rawLine of modelSource.split("\n")) {
|
|
38
|
+
if (!PERSIST_LINE_PATTERN.test(rawLine)) continue;
|
|
39
|
+
if (UNQUOTED_NAME_PATTERN.test(rawLine)) {
|
|
40
|
+
offenders.push(rawLine.trim());
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (offenders.length > 0) {
|
|
44
|
+
throw new ModelCompilationError({
|
|
45
|
+
message:
|
|
46
|
+
`${modelPath}: persist annotation name must be quoted. Write a quoted ` +
|
|
47
|
+
`value like name="engaged_events" (or a dialect table path such as ` +
|
|
48
|
+
`name="my_dataset.engaged_events"), not a bare value — an unquoted ` +
|
|
49
|
+
`persist name is dropped from the build plan, so the source would ` +
|
|
50
|
+
`publish but never materialize. Offending annotation(s): ` +
|
|
51
|
+
`${offenders.join("; ")}.`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { bareTableName, quoteIdentifier, quoteTablePath } from "./quoting";
|
|
3
|
+
|
|
4
|
+
describe("bareTableName", () => {
|
|
5
|
+
it("returns the segment after the last dot for a qualified name", () => {
|
|
6
|
+
expect(bareTableName("my_schema.my_table")).toBe("my_table");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns the name unchanged when it is unqualified", () => {
|
|
10
|
+
expect(bareTableName("my_table")).toBe("my_table");
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe("quoteIdentifier", () => {
|
|
15
|
+
it("backticks for backtick dialects (BigQuery / MySQL / Databricks)", () => {
|
|
16
|
+
expect(quoteIdentifier("my-table", "standardsql")).toBe("`my-table`");
|
|
17
|
+
expect(quoteIdentifier("t", "mysql")).toBe("`t`");
|
|
18
|
+
expect(quoteIdentifier("t", "databricks")).toBe("`t`");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("double-quotes for everything else (Postgres / DuckDB / Snowflake)", () => {
|
|
22
|
+
expect(quoteIdentifier("my-table", "postgres")).toBe('"my-table"');
|
|
23
|
+
expect(quoteIdentifier("t", "duckdb")).toBe('"t"');
|
|
24
|
+
expect(quoteIdentifier("t", "snowflake")).toBe('"t"');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("escapes an embedded quote char by doubling it", () => {
|
|
28
|
+
expect(quoteIdentifier("a`b", "standardsql")).toBe("`a``b`");
|
|
29
|
+
expect(quoteIdentifier('a"b', "postgres")).toBe('"a""b"');
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("quoteTablePath", () => {
|
|
34
|
+
it("quotes each segment of a container path independently", () => {
|
|
35
|
+
expect(
|
|
36
|
+
quoteTablePath(
|
|
37
|
+
"my-proj.mydataset.engaged_events_ab12_v0",
|
|
38
|
+
"standardsql",
|
|
39
|
+
),
|
|
40
|
+
).toBe("`my-proj`.`mydataset`.`engaged_events_ab12_v0`");
|
|
41
|
+
expect(quoteTablePath("mydataset.t_ab12_v0", "postgres")).toBe(
|
|
42
|
+
'"mydataset"."t_ab12_v0"',
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("quotes a bare (unqualified) name", () => {
|
|
47
|
+
expect(quoteTablePath("t_ab12_v0", "standardsql")).toBe("`t_ab12_v0`");
|
|
48
|
+
expect(quoteTablePath("t_ab12_v0", "postgres")).toBe('"t_ab12_v0"');
|
|
49
|
+
});
|
|
50
|
+
});
|
package/src/service/quoting.ts
CHANGED
|
@@ -1,21 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* "my_schema.my_table" -> { schemaPrefix: "my_schema.", bareName: "my_table" }
|
|
7
|
-
* "my_table" -> { schemaPrefix: "", bareName: "my_table" }
|
|
2
|
+
* The bare (unqualified) name of a possibly container-qualified table path:
|
|
3
|
+
* the segment after the last dot, e.g. `my_schema.my_table` -> `my_table` and
|
|
4
|
+
* `my_table` -> `my_table`. Used as the RENAME target, which names a table
|
|
5
|
+
* within its existing schema rather than re-stating the full path.
|
|
8
6
|
*/
|
|
9
|
-
export function
|
|
10
|
-
schemaPrefix: string;
|
|
11
|
-
bareName: string;
|
|
12
|
-
} {
|
|
7
|
+
export function bareTableName(tableName: string): string {
|
|
13
8
|
const lastDot = tableName.lastIndexOf(".");
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
return lastDot >= 0 ? tableName.substring(lastDot + 1) : tableName;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Dialects whose identifier quote character is a backtick; everything else uses
|
|
13
|
+
// the SQL-standard double quote. Keyed by Malloy `dialectName`.
|
|
14
|
+
const BACKTICK_DIALECTS = new Set(["standardsql", "mysql", "databricks"]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Quote a single SQL identifier for {@code dialect}, escaping any embedded quote
|
|
18
|
+
* character by doubling it.
|
|
19
|
+
*/
|
|
20
|
+
export function quoteIdentifier(identifier: string, dialect: string): string {
|
|
21
|
+
if (BACKTICK_DIALECTS.has(dialect)) {
|
|
22
|
+
return "`" + identifier.replace(/`/g, "``") + "`";
|
|
19
23
|
}
|
|
20
|
-
return
|
|
24
|
+
return '"' + identifier.replace(/"/g, '""') + '"';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Dialect-quote a (possibly container-qualified) table path so it can be inlined
|
|
29
|
+
* into DDL. Each dot segment is quoted independently and rejoined with dots, so
|
|
30
|
+
* a path like {@code my-proj.mydataset.engaged_events_v0} becomes
|
|
31
|
+
* `` `my-proj`.`mydataset`.`engaged_events_v0` `` on BigQuery or
|
|
32
|
+
* {@code "my-proj"."mydataset"."engaged_events_v0"} on Postgres — handling
|
|
33
|
+
* container hierarchies and quote-requiring names (e.g. hyphenated BigQuery
|
|
34
|
+
* project ids) uniformly. The control plane provides the logical (unquoted) path
|
|
35
|
+
* in `physicalTableName`; quoting for the warehouse is the publisher's job.
|
|
36
|
+
*/
|
|
37
|
+
export function quoteTablePath(tableName: string, dialect: string): string {
|
|
38
|
+
return tableName
|
|
39
|
+
.split(".")
|
|
40
|
+
.map((segment) => quoteIdentifier(segment, dialect))
|
|
41
|
+
.join(".");
|
|
21
42
|
}
|
package/src/utils.ts
CHANGED
|
@@ -22,3 +22,8 @@ export const URL_READER: URLReader = {
|
|
|
22
22
|
export function ignoreDotfiles(file: string): boolean {
|
|
23
23
|
return path.basename(file).startsWith(".");
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
/** The message of a thrown value, stringifying non-Error throws. */
|
|
27
|
+
export function errMessage(err: unknown): string {
|
|
28
|
+
return err instanceof Error ? err.message : String(err);
|
|
29
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{$ as s,j as t,s as o}from"./index-DuqTjxM_.js";function e(){const n=s();return t.jsx(o,{onClickEnvironment:n})}export{e as default};
|