@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.
Files changed (32) hide show
  1. package/dist/app/api-doc.yaml +20 -3
  2. package/dist/app/assets/{EnvironmentPage-BRMCY9d8.js → EnvironmentPage-CuO6sty5.js} +1 -1
  3. package/dist/app/assets/HomePage-u3vxROIF.js +1 -0
  4. package/dist/app/assets/{MainPage-sZdUjUcu.js → MainPage-Bt2sYLbr.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-C_jQQUCJ.js → MaterializationsPage-B1rj61zs.js} +1 -1
  6. package/dist/app/assets/{ModelPage-Bh62OIEq.js → ModelPage-BpvONvSR.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DJW4xjLp.js → PackagePage-DHNcwboW.js} +1 -1
  8. package/dist/app/assets/{RouteError-Vi5yGs_F.js → RouteError-D1vhLJvr.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-BvJMi21d.js → WorkbookPage-CZmud-yI.js} +1 -1
  10. package/dist/app/assets/{core-BbW0t3RQ.es-L-mZcOk3.js → core-XtBSnW2U.es-DE88sVsZ.js} +1 -1
  11. package/dist/app/assets/{index-CCcHdeew.js → index-BQdOB6m9.js} +1 -1
  12. package/dist/app/assets/{index-DuqTjxM_.js → index-BlnQtDZj.js} +137 -137
  13. package/dist/app/assets/{index-CNFX-CGL.js → index-CYf2akGH.js} +1 -1
  14. package/dist/app/assets/{index.umd-GgEb4WfT.js → index.umd-BdQ0R4hx.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/package_load_worker.mjs +3 -0
  17. package/dist/server.mjs +150 -140
  18. package/package.json +1 -1
  19. package/src/materialization_metrics.spec.ts +84 -0
  20. package/src/materialization_metrics.ts +78 -119
  21. package/src/service/build_plan.spec.ts +84 -0
  22. package/src/service/build_plan.ts +53 -2
  23. package/src/service/materialization_service.spec.ts +126 -8
  24. package/src/service/materialization_service.ts +96 -83
  25. package/src/service/materialization_test_fixtures.ts +2 -0
  26. package/src/service/package.ts +14 -2
  27. package/src/service/persist_annotation_validation.spec.ts +77 -0
  28. package/src/service/persist_annotation_validation.ts +54 -0
  29. package/src/service/quoting.spec.ts +50 -0
  30. package/src/service/quoting.ts +37 -16
  31. package/src/utils.ts +5 -0
  32. 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 { splitTablePath } from "./quoting";
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
- try {
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 new MaterializationConflictError(
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 new MaterializationConflictError(
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 level of graph.nodes) {
422
- for (const node of level) {
423
- const persistSource = compiled.sources[node.sourceID];
424
- if (!persistSource) continue;
425
- if (include && !include.has(persistSource.name)) continue;
426
-
427
- const buildId = computeBuildId(
428
- persistSource,
429
- compiled.connectionDigests,
430
- );
431
- if (seen.has(buildId)) continue;
432
- seen.add(buildId);
433
-
434
- const prior = priorEntries[buildId];
435
- if (prior && prior.physicalTableName) {
436
- carried[buildId] = prior;
437
- continue;
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 level of graph.nodes) {
593
- for (const node of level) {
594
- if (signal.aborted) throw new Error("Build cancelled");
595
- const persistSource = sources[node.sourceID];
596
- if (!persistSource) continue;
597
-
598
- const buildId = computeBuildId(persistSource, connectionDigests);
599
- const instruction = byBuildId.get(buildId);
600
- if (!instruction) continue;
601
-
602
- const entry = await this.buildOneSource(
603
- persistSource,
604
- instruction,
605
- connection,
606
- connectionDigests,
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 { bareName } = splitTablePath(physicalTableName);
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 ${stagingTableName}`);
639
+ await connection.runSQL(`DROP TABLE IF EXISTS ${quotedStaging}`);
641
640
  try {
642
641
  await connection.runSQL(
643
- `CREATE TABLE ${stagingTableName} AS (${buildSQL})`,
642
+ `CREATE TABLE ${quotedStaging} AS (${buildSQL})`,
644
643
  );
645
- await connection.runSQL(`DROP TABLE IF EXISTS ${physicalTableName}`);
644
+ await connection.runSQL(`DROP TABLE IF EXISTS ${quotedPhysical}`);
646
645
  await connection.runSQL(
647
- `ALTER TABLE ${stagingTableName} RENAME TO ${bareName}`,
646
+ `ALTER TABLE ${quotedStaging} RENAME TO ${quotedBareName}`,
648
647
  );
649
648
  } catch (err) {
650
649
  try {
651
- await connection.runSQL(`DROP TABLE IF EXISTS ${stagingTableName}`);
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 ${physicalTableName}`,
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 ${physicalTableName}${stagingSuffix(
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: err instanceof Error ? err.message : String(err),
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 = err instanceof Error ? err.message : String(err);
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: {
@@ -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: err instanceof Error ? err.message : String(err),
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
+ });
@@ -1,21 +1,42 @@
1
1
  /**
2
- * Split a possibly schema-qualified table name into its schema prefix
3
- * (including the trailing dot) and the bare table name.
4
- *
5
- * Examples:
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 splitTablePath(tableName: string): {
10
- schemaPrefix: string;
11
- bareName: string;
12
- } {
7
+ export function bareTableName(tableName: string): string {
13
8
  const lastDot = tableName.lastIndexOf(".");
14
- if (lastDot >= 0) {
15
- return {
16
- schemaPrefix: tableName.substring(0, lastDot + 1),
17
- bareName: tableName.substring(lastDot + 1),
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 { schemaPrefix: "", bareName: tableName };
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};