@malloy-publisher/server 0.0.210 → 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 +150 -140
- 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/package.ts +14 -2
- 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
|
@@ -30,13 +30,16 @@ import {
|
|
|
30
30
|
ResourceRepository,
|
|
31
31
|
} from "../storage/DatabaseInterface";
|
|
32
32
|
import { DuplicateActiveMaterializationError } from "../storage/duckdb/MaterializationRepository";
|
|
33
|
+
import { errMessage } from "../utils";
|
|
33
34
|
import {
|
|
34
35
|
CompiledBuildPlan,
|
|
35
36
|
compilePackageBuildPlan,
|
|
36
37
|
computeBuildId,
|
|
38
|
+
deriveAnnotationFields,
|
|
39
|
+
iterGraphSources,
|
|
37
40
|
} from "./build_plan";
|
|
38
41
|
import { EnvironmentStore } from "./environment_store";
|
|
39
|
-
import {
|
|
42
|
+
import { bareTableName, quoteIdentifier, quoteTablePath } from "./quoting";
|
|
40
43
|
import { resolveEnvironmentId } from "./resolve_environment";
|
|
41
44
|
|
|
42
45
|
/**
|
|
@@ -58,14 +61,7 @@ export function stagingSuffix(buildId: string): string {
|
|
|
58
61
|
* The author owns quoting the `name=` value for the dialect.
|
|
59
62
|
*/
|
|
60
63
|
function selfAssignTableName(persistSource: PersistSource): string {
|
|
61
|
-
|
|
62
|
-
return (
|
|
63
|
-
persistSource.annotations.parseAsTag("@").tag.text("name") ||
|
|
64
|
-
persistSource.name
|
|
65
|
-
);
|
|
66
|
-
} catch {
|
|
67
|
-
return persistSource.name;
|
|
68
|
-
}
|
|
64
|
+
return deriveAnnotationFields(persistSource).name || persistSource.name;
|
|
69
65
|
}
|
|
70
66
|
|
|
71
67
|
/** Classify a thrown build error as cancelled (cooperative abort) or failed. */
|
|
@@ -239,9 +235,7 @@ export class MaterializationService {
|
|
|
239
235
|
packageName,
|
|
240
236
|
);
|
|
241
237
|
if (active) {
|
|
242
|
-
throw
|
|
243
|
-
`Package ${packageName} already has an active materialization (${active.id})`,
|
|
244
|
-
);
|
|
238
|
+
throw this.activeConflict(packageName, active.id);
|
|
245
239
|
}
|
|
246
240
|
|
|
247
241
|
const forceRefresh = options.forceRefresh ?? false;
|
|
@@ -265,11 +259,7 @@ export class MaterializationService {
|
|
|
265
259
|
environmentId,
|
|
266
260
|
packageName,
|
|
267
261
|
);
|
|
268
|
-
throw
|
|
269
|
-
winner
|
|
270
|
-
? `Package ${packageName} already has an active materialization (${winner.id})`
|
|
271
|
-
: `Package ${packageName} already has an active materialization`,
|
|
272
|
-
);
|
|
262
|
+
throw this.activeConflict(packageName, winner?.id);
|
|
273
263
|
}
|
|
274
264
|
throw err;
|
|
275
265
|
}
|
|
@@ -320,6 +310,12 @@ export class MaterializationService {
|
|
|
320
310
|
const startedAt = Date.now();
|
|
321
311
|
|
|
322
312
|
try {
|
|
313
|
+
// Persist the run's start time so the UI can compute a duration
|
|
314
|
+
// (start -> now while in-flight, start -> completed once terminal).
|
|
315
|
+
await this.repository.updateMaterialization(id, {
|
|
316
|
+
startedAt: new Date(startedAt),
|
|
317
|
+
});
|
|
318
|
+
|
|
323
319
|
const environmentId = await this.resolveEnvironmentId(environmentName);
|
|
324
320
|
const environment = await this.environmentStore.getEnvironment(
|
|
325
321
|
environmentName,
|
|
@@ -418,35 +414,34 @@ export class MaterializationService {
|
|
|
418
414
|
const seen = new Set<string>();
|
|
419
415
|
|
|
420
416
|
for (const graph of compiled.graphs) {
|
|
421
|
-
for (const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
instructions.push({
|
|
441
|
-
buildId,
|
|
442
|
-
materializedTableId: `local-${buildId.substring(
|
|
443
|
-
0,
|
|
444
|
-
STAGING_BUILD_ID_LEN,
|
|
445
|
-
)}`,
|
|
446
|
-
physicalTableName: selfAssignTableName(persistSource),
|
|
447
|
-
realization: "COPY",
|
|
448
|
-
});
|
|
417
|
+
for (const persistSource of iterGraphSources(
|
|
418
|
+
graph,
|
|
419
|
+
compiled.sources,
|
|
420
|
+
)) {
|
|
421
|
+
if (include && !include.has(persistSource.name)) continue;
|
|
422
|
+
|
|
423
|
+
const buildId = computeBuildId(
|
|
424
|
+
persistSource,
|
|
425
|
+
compiled.connectionDigests,
|
|
426
|
+
);
|
|
427
|
+
if (seen.has(buildId)) continue;
|
|
428
|
+
seen.add(buildId);
|
|
429
|
+
|
|
430
|
+
const prior = priorEntries[buildId];
|
|
431
|
+
if (prior && prior.physicalTableName) {
|
|
432
|
+
carried[buildId] = prior;
|
|
433
|
+
continue;
|
|
449
434
|
}
|
|
435
|
+
|
|
436
|
+
instructions.push({
|
|
437
|
+
buildId,
|
|
438
|
+
materializedTableId: `local-${buildId.substring(
|
|
439
|
+
0,
|
|
440
|
+
STAGING_BUILD_ID_LEN,
|
|
441
|
+
)}`,
|
|
442
|
+
physicalTableName: selfAssignTableName(persistSource),
|
|
443
|
+
realization: "COPY",
|
|
444
|
+
});
|
|
450
445
|
}
|
|
451
446
|
}
|
|
452
447
|
|
|
@@ -589,25 +584,21 @@ export class MaterializationService {
|
|
|
589
584
|
`Connection '${graph.connectionName}' not found`,
|
|
590
585
|
);
|
|
591
586
|
}
|
|
592
|
-
for (const
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
manifest,
|
|
608
|
-
);
|
|
609
|
-
entries[buildId] = entry;
|
|
610
|
-
}
|
|
587
|
+
for (const persistSource of iterGraphSources(graph, sources)) {
|
|
588
|
+
if (signal.aborted) throw new Error("Build cancelled");
|
|
589
|
+
|
|
590
|
+
const buildId = computeBuildId(persistSource, connectionDigests);
|
|
591
|
+
const instruction = byBuildId.get(buildId);
|
|
592
|
+
if (!instruction) continue;
|
|
593
|
+
|
|
594
|
+
const entry = await this.buildOneSource(
|
|
595
|
+
persistSource,
|
|
596
|
+
instruction,
|
|
597
|
+
connection,
|
|
598
|
+
connectionDigests,
|
|
599
|
+
manifest,
|
|
600
|
+
);
|
|
601
|
+
entries[buildId] = entry;
|
|
611
602
|
}
|
|
612
603
|
}
|
|
613
604
|
|
|
@@ -633,32 +624,37 @@ export class MaterializationService {
|
|
|
633
624
|
connectionDigests,
|
|
634
625
|
});
|
|
635
626
|
|
|
636
|
-
const
|
|
627
|
+
const bareName = bareTableName(physicalTableName);
|
|
637
628
|
const stagingTableName = `${physicalTableName}${stagingSuffix(buildId)}`;
|
|
629
|
+
// The control plane sends the logical (unquoted) physical name; dialect-
|
|
630
|
+
// quote each identifier here so a container path or quote-requiring name
|
|
631
|
+
// (e.g. a hyphenated BigQuery project id) produces valid DDL. The manifest
|
|
632
|
+
// echoes the logical name (below) so the CP stays in logical-name space.
|
|
633
|
+
const dialect = persistSource.dialectName;
|
|
634
|
+
const quotedStaging = quoteTablePath(stagingTableName, dialect);
|
|
635
|
+
const quotedPhysical = quoteTablePath(physicalTableName, dialect);
|
|
636
|
+
const quotedBareName = quoteIdentifier(bareName, dialect);
|
|
638
637
|
|
|
639
638
|
const startTime = performance.now();
|
|
640
|
-
await connection.runSQL(`DROP TABLE IF EXISTS ${
|
|
639
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${quotedStaging}`);
|
|
641
640
|
try {
|
|
642
641
|
await connection.runSQL(
|
|
643
|
-
`CREATE TABLE ${
|
|
642
|
+
`CREATE TABLE ${quotedStaging} AS (${buildSQL})`,
|
|
644
643
|
);
|
|
645
|
-
await connection.runSQL(`DROP TABLE IF EXISTS ${
|
|
644
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${quotedPhysical}`);
|
|
646
645
|
await connection.runSQL(
|
|
647
|
-
`ALTER TABLE ${
|
|
646
|
+
`ALTER TABLE ${quotedStaging} RENAME TO ${quotedBareName}`,
|
|
648
647
|
);
|
|
649
648
|
} catch (err) {
|
|
650
649
|
try {
|
|
651
|
-
await connection.runSQL(`DROP TABLE IF EXISTS ${
|
|
650
|
+
await connection.runSQL(`DROP TABLE IF EXISTS ${quotedStaging}`);
|
|
652
651
|
} catch (cleanupErr) {
|
|
653
652
|
logger.warn(
|
|
654
653
|
"Failed to clean up staging table after a failed build; physical leak",
|
|
655
654
|
{
|
|
656
655
|
stagingTableName,
|
|
657
656
|
connectionName: persistSource.connectionName,
|
|
658
|
-
cleanupError:
|
|
659
|
-
cleanupErr instanceof Error
|
|
660
|
-
? cleanupErr.message
|
|
661
|
-
: String(cleanupErr),
|
|
657
|
+
cleanupError: errMessage(cleanupErr),
|
|
662
658
|
},
|
|
663
659
|
);
|
|
664
660
|
}
|
|
@@ -795,14 +791,23 @@ export class MaterializationService {
|
|
|
795
791
|
connection = await pkg.getMalloyConnection(connectionName);
|
|
796
792
|
connectionCache.set(connectionName, connection);
|
|
797
793
|
}
|
|
794
|
+
// Dialect-quote from the live connection, the same way
|
|
795
|
+
// buildOneSource quoted at build time, so a name that built
|
|
796
|
+
// successfully also drops successfully (container paths, hyphenated
|
|
797
|
+
// BigQuery project ids, etc.).
|
|
798
|
+
const dialect = connection.dialectName;
|
|
798
799
|
await connection.runSQL(
|
|
799
|
-
`DROP TABLE IF EXISTS ${
|
|
800
|
+
`DROP TABLE IF EXISTS ${quoteTablePath(
|
|
801
|
+
physicalTableName,
|
|
802
|
+
dialect,
|
|
803
|
+
)}`,
|
|
800
804
|
);
|
|
801
805
|
// A crash between staging-create and rename can leave the staging
|
|
802
806
|
// table behind; clean it up too while we hold the connection.
|
|
803
807
|
await connection.runSQL(
|
|
804
|
-
`DROP TABLE IF EXISTS ${
|
|
805
|
-
entry.buildId
|
|
808
|
+
`DROP TABLE IF EXISTS ${quoteTablePath(
|
|
809
|
+
`${physicalTableName}${stagingSuffix(entry.buildId)}`,
|
|
810
|
+
dialect,
|
|
806
811
|
)}`,
|
|
807
812
|
);
|
|
808
813
|
recordDropTables("success");
|
|
@@ -817,7 +822,7 @@ export class MaterializationService {
|
|
|
817
822
|
materializationId: m.id,
|
|
818
823
|
physicalTableName,
|
|
819
824
|
connectionName,
|
|
820
|
-
error:
|
|
825
|
+
error: errMessage(err),
|
|
821
826
|
});
|
|
822
827
|
}
|
|
823
828
|
}
|
|
@@ -848,6 +853,17 @@ export class MaterializationService {
|
|
|
848
853
|
|
|
849
854
|
// ==================== HELPERS ====================
|
|
850
855
|
|
|
856
|
+
/** The single-active conflict error, with the winning run's id when known. */
|
|
857
|
+
private activeConflict(
|
|
858
|
+
packageName: string,
|
|
859
|
+
activeId?: string,
|
|
860
|
+
): MaterializationConflictError {
|
|
861
|
+
const suffix = activeId ? ` (${activeId})` : "";
|
|
862
|
+
return new MaterializationConflictError(
|
|
863
|
+
`Package ${packageName} already has an active materialization${suffix}`,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
851
867
|
private recordRun(
|
|
852
868
|
mode: MaterializationMode,
|
|
853
869
|
outcome: "success" | "failed" | "cancelled",
|
|
@@ -865,7 +881,7 @@ export class MaterializationService {
|
|
|
865
881
|
|
|
866
882
|
run(abortController.signal)
|
|
867
883
|
.catch(async (err) => {
|
|
868
|
-
const message =
|
|
884
|
+
const message = errMessage(err);
|
|
869
885
|
const next = abortController.signal.aborted
|
|
870
886
|
? "CANCELLED"
|
|
871
887
|
: "FAILED";
|
|
@@ -879,10 +895,7 @@ export class MaterializationService {
|
|
|
879
895
|
logger.error("Failed to record materialization failure", {
|
|
880
896
|
materializationId: id,
|
|
881
897
|
originalError: message,
|
|
882
|
-
transitionError:
|
|
883
|
-
transitionErr instanceof Error
|
|
884
|
-
? transitionErr.message
|
|
885
|
-
: String(transitionErr),
|
|
898
|
+
transitionError: errMessage(transitionErr),
|
|
886
899
|
});
|
|
887
900
|
}
|
|
888
901
|
})
|
|
@@ -80,11 +80,13 @@ export function fakeSource(opts: {
|
|
|
80
80
|
buildId: string;
|
|
81
81
|
sql?: string;
|
|
82
82
|
connectionName?: string;
|
|
83
|
+
dialectName?: string;
|
|
83
84
|
}): PersistSource {
|
|
84
85
|
return {
|
|
85
86
|
name: opts.name,
|
|
86
87
|
sourceID: opts.name,
|
|
87
88
|
connectionName: opts.connectionName ?? "duckdb",
|
|
89
|
+
dialectName: opts.dialectName ?? "duckdb",
|
|
88
90
|
makeBuildId: () => opts.buildId,
|
|
89
91
|
getSQL: () => opts.sql ?? "SELECT 1",
|
|
90
92
|
annotations: {
|
package/src/service/package.ts
CHANGED
|
@@ -31,9 +31,10 @@ import { formatDuration, logger } from "../logger";
|
|
|
31
31
|
import { recordBuildPlanComputeDuration } from "../materialization_metrics";
|
|
32
32
|
import { assertSafeEnvironmentPath, safeJoinUnderRoot } from "../path_safety";
|
|
33
33
|
import { BuildManifest, BuildPlan } from "../storage/DatabaseInterface";
|
|
34
|
-
import { ignoreDotfiles } from "../utils";
|
|
34
|
+
import { errMessage, ignoreDotfiles } from "../utils";
|
|
35
35
|
import { computePackageBuildPlan } from "./build_plan";
|
|
36
36
|
import { Model } from "./model";
|
|
37
|
+
import { assertPersistNamesQuoted } from "./persist_annotation_validation";
|
|
37
38
|
|
|
38
39
|
type ApiDatabase = components["schemas"]["Database"];
|
|
39
40
|
type ApiModel = components["schemas"]["Model"];
|
|
@@ -361,6 +362,17 @@ export class Package {
|
|
|
361
362
|
// to load inside the pure-CPU package-load worker). A misconfigured tag
|
|
362
363
|
// is logged as a warning naming the target; it does not fail the load.
|
|
363
364
|
await model.validateRenderTags();
|
|
365
|
+
// Reject unquoted `#@ persist name=` annotations the same way: an
|
|
366
|
+
// unquoted name is dropped from the build plan, so the source would
|
|
367
|
+
// publish but never materialize. Scan the raw `.malloy` source (the
|
|
368
|
+
// ground truth for quoting); throws a ModelCompilationError (424).
|
|
369
|
+
if (sm.modelPath.endsWith(MODEL_FILE_SUFFIX)) {
|
|
370
|
+
const modelSource = await fs.readFile(
|
|
371
|
+
path.join(packagePath, sm.modelPath),
|
|
372
|
+
"utf-8",
|
|
373
|
+
);
|
|
374
|
+
assertPersistNamesQuoted(modelSource, sm.modelPath);
|
|
375
|
+
}
|
|
364
376
|
models.set(sm.modelPath, model);
|
|
365
377
|
}
|
|
366
378
|
|
|
@@ -400,7 +412,7 @@ export class Package {
|
|
|
400
412
|
`Failed to compute build plan for package ${packageName}`,
|
|
401
413
|
{
|
|
402
414
|
packageName,
|
|
403
|
-
error:
|
|
415
|
+
error: errMessage(err),
|
|
404
416
|
},
|
|
405
417
|
);
|
|
406
418
|
}
|
|
@@ -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};
|