@duckcodeailabs/dql-core 1.6.33 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/dist/apps/app-document.d.ts +3 -1
  2. package/dist/apps/app-document.d.ts.map +1 -1
  3. package/dist/apps/app-document.js +26 -11
  4. package/dist/apps/app-document.js.map +1 -1
  5. package/dist/apps/dashboard-document.d.ts +5 -0
  6. package/dist/apps/dashboard-document.d.ts.map +1 -1
  7. package/dist/apps/dashboard-document.js +4 -0
  8. package/dist/apps/dashboard-document.js.map +1 -1
  9. package/dist/apps/index.d.ts +2 -0
  10. package/dist/apps/index.d.ts.map +1 -1
  11. package/dist/apps/index.js +1 -0
  12. package/dist/apps/index.js.map +1 -1
  13. package/dist/apps/product-domain-context.d.ts +23 -0
  14. package/dist/apps/product-domain-context.d.ts.map +1 -0
  15. package/dist/apps/product-domain-context.js +25 -0
  16. package/dist/apps/product-domain-context.js.map +1 -0
  17. package/dist/artifacts/dql-artifact.d.ts +13 -0
  18. package/dist/artifacts/dql-artifact.d.ts.map +1 -1
  19. package/dist/artifacts/dql-artifact.js +69 -0
  20. package/dist/artifacts/dql-artifact.js.map +1 -1
  21. package/dist/artifacts/index.d.ts +1 -1
  22. package/dist/artifacts/index.d.ts.map +1 -1
  23. package/dist/ast/index.d.ts +1 -1
  24. package/dist/ast/index.d.ts.map +1 -1
  25. package/dist/ast/index.js.map +1 -1
  26. package/dist/ast/nodes.d.ts +19 -1
  27. package/dist/ast/nodes.d.ts.map +1 -1
  28. package/dist/ast/printer.js +24 -0
  29. package/dist/ast/printer.js.map +1 -1
  30. package/dist/blocks/index.d.ts +2 -0
  31. package/dist/blocks/index.d.ts.map +1 -0
  32. package/dist/blocks/index.js +2 -0
  33. package/dist/blocks/index.js.map +1 -0
  34. package/dist/blocks/parameters.d.ts +55 -0
  35. package/dist/blocks/parameters.d.ts.map +1 -0
  36. package/dist/blocks/parameters.js +160 -0
  37. package/dist/blocks/parameters.js.map +1 -0
  38. package/dist/contracts/types.d.ts +6 -0
  39. package/dist/contracts/types.d.ts.map +1 -1
  40. package/dist/contracts/types.js.map +1 -1
  41. package/dist/format/diff.d.ts.map +1 -1
  42. package/dist/format/diff.js +19 -11
  43. package/dist/format/diff.js.map +1 -1
  44. package/dist/format/notebook.d.ts +1 -1
  45. package/dist/format/notebook.js +2 -2
  46. package/dist/formatter/formatter.js +23 -1
  47. package/dist/formatter/formatter.js.map +1 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/lineage/index.d.ts +1 -1
  53. package/dist/lineage/index.d.ts.map +1 -1
  54. package/dist/lineage/index.js.map +1 -1
  55. package/dist/lineage/sql-parser.d.ts +19 -0
  56. package/dist/lineage/sql-parser.d.ts.map +1 -1
  57. package/dist/lineage/sql-parser.js +90 -0
  58. package/dist/lineage/sql-parser.js.map +1 -1
  59. package/dist/manifest/builder.d.ts +5 -0
  60. package/dist/manifest/builder.d.ts.map +1 -1
  61. package/dist/manifest/builder.js +186 -8
  62. package/dist/manifest/builder.js.map +1 -1
  63. package/dist/manifest/datalex-migration.d.ts +68 -0
  64. package/dist/manifest/datalex-migration.d.ts.map +1 -0
  65. package/dist/manifest/datalex-migration.js +369 -0
  66. package/dist/manifest/datalex-migration.js.map +1 -0
  67. package/dist/manifest/dbt-first-authoring.d.ts +210 -0
  68. package/dist/manifest/dbt-first-authoring.d.ts.map +1 -0
  69. package/dist/manifest/dbt-first-authoring.js +510 -0
  70. package/dist/manifest/dbt-first-authoring.js.map +1 -0
  71. package/dist/manifest/dbt-first-modeling.d.ts +34 -0
  72. package/dist/manifest/dbt-first-modeling.d.ts.map +1 -0
  73. package/dist/manifest/dbt-first-modeling.js +931 -0
  74. package/dist/manifest/dbt-first-modeling.js.map +1 -0
  75. package/dist/manifest/domain-discovery.d.ts +98 -0
  76. package/dist/manifest/domain-discovery.d.ts.map +1 -0
  77. package/dist/manifest/domain-discovery.js +503 -0
  78. package/dist/manifest/domain-discovery.js.map +1 -0
  79. package/dist/manifest/domain-package-registry.d.ts +20 -0
  80. package/dist/manifest/domain-package-registry.d.ts.map +1 -0
  81. package/dist/manifest/domain-package-registry.js +265 -0
  82. package/dist/manifest/domain-package-registry.js.map +1 -0
  83. package/dist/manifest/domain-writer.d.ts +18 -0
  84. package/dist/manifest/domain-writer.d.ts.map +1 -1
  85. package/dist/manifest/domain-writer.js +52 -3
  86. package/dist/manifest/domain-writer.js.map +1 -1
  87. package/dist/manifest/index.d.ts +7 -1
  88. package/dist/manifest/index.d.ts.map +1 -1
  89. package/dist/manifest/index.js +6 -0
  90. package/dist/manifest/index.js.map +1 -1
  91. package/dist/manifest/project-snapshot.d.ts +30 -0
  92. package/dist/manifest/project-snapshot.d.ts.map +1 -0
  93. package/dist/manifest/project-snapshot.js +68 -0
  94. package/dist/manifest/project-snapshot.js.map +1 -0
  95. package/dist/manifest/types.d.ts +297 -3
  96. package/dist/manifest/types.d.ts.map +1 -1
  97. package/dist/parser/parser.d.ts.map +1 -1
  98. package/dist/parser/parser.js +88 -6
  99. package/dist/parser/parser.js.map +1 -1
  100. package/dist/semantic/analyzer.js +1 -1
  101. package/dist/semantic/analyzer.js.map +1 -1
  102. package/dist/semantic/index.d.ts +1 -1
  103. package/dist/semantic/index.d.ts.map +1 -1
  104. package/dist/semantic/index.js +1 -1
  105. package/dist/semantic/index.js.map +1 -1
  106. package/dist/semantic/providers/dbt-provider.d.ts +63 -0
  107. package/dist/semantic/providers/dbt-provider.d.ts.map +1 -1
  108. package/dist/semantic/providers/dbt-provider.js +43 -16
  109. package/dist/semantic/providers/dbt-provider.js.map +1 -1
  110. package/dist/semantic/semantic-layer.d.ts +11 -0
  111. package/dist/semantic/semantic-layer.d.ts.map +1 -1
  112. package/dist/semantic/semantic-layer.js +378 -27
  113. package/dist/semantic/semantic-layer.js.map +1 -1
  114. package/dist/semantic/sql-dialect.d.ts +8 -0
  115. package/dist/semantic/sql-dialect.d.ts.map +1 -1
  116. package/dist/semantic/sql-dialect.js +22 -0
  117. package/dist/semantic/sql-dialect.js.map +1 -1
  118. package/package.json +1 -1
@@ -285,9 +285,9 @@ export class SemanticLayer {
285
285
  return null;
286
286
  // Resolve metric definitions
287
287
  const resolvedMetrics = metrics
288
- .map((m) => this.metrics.get(m))
288
+ .map((m) => this.resolveComposableMetric(m))
289
289
  .filter(Boolean);
290
- if (resolvedMetrics.length === 0)
290
+ if (resolvedMetrics.length !== metrics.length)
291
291
  return null;
292
292
  // Resolve dimension definitions
293
293
  const resolvedDimensions = dimensions
@@ -298,6 +298,18 @@ export class SemanticLayer {
298
298
  if (timeDimension) {
299
299
  timeDimDef = this.dimensions.get(timeDimension.name);
300
300
  }
301
+ const resolvedFilterDimensions = (filters ?? [])
302
+ .map((filter) => this.dimensions.get(filter.dimension))
303
+ .filter(Boolean);
304
+ // Multiple metrics from different fact tables must never be aggregated after
305
+ // one raw fact-to-fact join: that multiplies rows and silently inflates sums.
306
+ // Compile one aggregate island per metric at the requested conformed grain,
307
+ // then join the small aggregate results. Each island still uses the semantic
308
+ // graph and refuses independently when a dimension/filter is unjoinable.
309
+ const metricTables = new Set(resolvedMetrics.map((metric) => metric.table));
310
+ if (metricTables.size > 1) {
311
+ return this.composeAggregateIslands(options, resolvedMetrics, resolvedDimensions, timeDimDef, dialect);
312
+ }
301
313
  // Find the primary table (from first metric)
302
314
  const primaryTable = resolvedMetrics[0].table;
303
315
  // Collect all tables referenced
@@ -306,6 +318,8 @@ export class SemanticLayer {
306
318
  allTables.add(m.table);
307
319
  for (const d of resolvedDimensions)
308
320
  allTables.add(d.table);
321
+ for (const d of resolvedFilterDimensions)
322
+ allTables.add(d.table);
309
323
  if (timeDimDef)
310
324
  allTables.add(timeDimDef.table);
311
325
  // Find which cube corresponds to a table
@@ -345,6 +359,27 @@ export class SemanticLayer {
345
359
  }
346
360
  if (unjoinedTables.size > 0)
347
361
  return null;
362
+ // Metric-scoped filters (G1 correctness). A governed metric may declare a
363
+ // `filter` that scopes its aggregate to a subset of rows (e.g.
364
+ // completed_revenue = SUM(amount) WHERE status='completed'). Dropping it
365
+ // silently returns a WRONG number at the highest-trust tier, so we either
366
+ // apply it or refuse to compose (null) — the caller then falls through to
367
+ // generated SQL rather than surfacing a governed-but-wrong answer.
368
+ const resolveFilterColumn = (ref) => {
369
+ const dim = this.dimensions.get(ref);
370
+ if (dim?.sql)
371
+ return dim.sql;
372
+ return ref.includes('__') ? ref.split('__').pop() ?? ref : ref;
373
+ };
374
+ const metricFilters = resolvedMetrics.map((m) => computeMetricFilterPredicate(m, resolveFilterColumn));
375
+ // A metric that declares a filter we cannot render fails the whole compose.
376
+ if (metricFilters.some((r) => r.kind === 'fail'))
377
+ return null;
378
+ const okFilters = metricFilters.filter((r) => r.kind === 'ok');
379
+ // When EVERY metric carries the SAME filter, hoist it to a single WHERE
380
+ // predicate (cleaner and equivalent); otherwise apply per-metric via CASE WHEN.
381
+ const distinctPredicates = new Set(okFilters.map((r) => r.sql));
382
+ const hoistFilterToWhere = okFilters.length === resolvedMetrics.length && resolvedMetrics.length > 0 && distinctPredicates.size === 1;
348
383
  // Build SELECT
349
384
  const selectParts = [];
350
385
  // Add dimensions
@@ -360,9 +395,20 @@ export class SemanticLayer {
360
395
  const truncated = dialect.dateTrunc(grain, tdSql);
361
396
  selectParts.push(`${truncated} AS ${timeDimDef.name}_${grain}`);
362
397
  }
363
- // Add metrics
364
- for (const m of resolvedMetrics) {
365
- selectParts.push(`${renderMetricExpression(m)} AS ${m.name}`);
398
+ // Add metrics (applying per-metric scoped filters via CASE WHEN unless hoisted)
399
+ for (let i = 0; i < resolvedMetrics.length; i++) {
400
+ const m = resolvedMetrics[i];
401
+ const fr = metricFilters[i];
402
+ let expr = renderMetricExpression(m);
403
+ if (fr.kind === 'ok' && !hoistFilterToWhere) {
404
+ const wrapped = wrapAggregateWithFilter(expr, fr.sql);
405
+ // Unparseable aggregate + a per-metric filter → refuse rather than emit
406
+ // a partially-applied filter.
407
+ if (wrapped === null)
408
+ return null;
409
+ expr = wrapped;
410
+ }
411
+ selectParts.push(`${expr} AS ${m.name}`);
366
412
  }
367
413
  // Build FROM + JOINs (apply tableMapping for actual DB table names)
368
414
  let fromClause = `FROM ${resolveTable(primaryTable)}`;
@@ -393,7 +439,9 @@ export class SemanticLayer {
393
439
  if (!f.dimension)
394
440
  continue;
395
441
  const dimDef = this.dimensions.get(f.dimension);
396
- const dimSql = dimDef?.sql ?? f.dimension;
442
+ const dimSql = dimDef
443
+ ? (dimDef.sql.includes('.') || dimDef.table === primaryTable ? dimDef.sql : `${dimDef.table}.${dimDef.sql}`)
444
+ : f.dimension;
397
445
  const v0 = f.values?.[0] ?? '';
398
446
  const v1 = f.values?.[1] ?? '';
399
447
  // Detect if value looks numeric (no quoting needed)
@@ -455,6 +503,10 @@ export class SemanticLayer {
455
503
  break;
456
504
  }
457
505
  }
506
+ // Governed metric filter shared by every metric: applied once as WHERE.
507
+ if (hoistFilterToWhere && okFilters.length > 0) {
508
+ whereParts.push(okFilters[0].sql);
509
+ }
458
510
  // Build ORDER BY
459
511
  const orderByParts = [];
460
512
  for (const o of orderBy ?? []) {
@@ -476,6 +528,121 @@ export class SemanticLayer {
476
528
  sql,
477
529
  joins: joinClauses,
478
530
  tables: Array.from(allTables),
531
+ strategy: 'direct_join',
532
+ grainKeys: [
533
+ ...resolvedDimensions.map((dimension) => dimension.name),
534
+ ...(timeDimDef && timeDimension ? [`${timeDimDef.name}_${timeDimension.granularity}`] : []),
535
+ ],
536
+ };
537
+ }
538
+ /**
539
+ * dbt simple metrics may store physical ownership on their input measure
540
+ * instead of duplicating it on the metric node. Materialize that contract for
541
+ * native composition; derived/ratio/cumulative metrics remain MetricFlow-only.
542
+ */
543
+ resolveComposableMetric(name) {
544
+ const metric = this.metrics.get(name);
545
+ if (!metric)
546
+ return undefined;
547
+ if (metric.table)
548
+ return metric;
549
+ if (metric.metricType && metric.metricType !== 'simple')
550
+ return undefined;
551
+ const measureNames = [];
552
+ const direct = metric.typeParams?.measure;
553
+ if (typeof direct === 'string')
554
+ measureNames.push(direct);
555
+ else if (direct && typeof direct === 'object' && !Array.isArray(direct)) {
556
+ const directName = direct.name;
557
+ if (typeof directName === 'string')
558
+ measureNames.push(directName);
559
+ }
560
+ const inputs = metric.typeParams?.input_measures;
561
+ if (Array.isArray(inputs)) {
562
+ for (const input of inputs) {
563
+ if (typeof input === 'string')
564
+ measureNames.push(input);
565
+ else if (input && typeof input === 'object' && !Array.isArray(input)) {
566
+ const inputName = input.name;
567
+ if (typeof inputName === 'string')
568
+ measureNames.push(inputName);
569
+ }
570
+ }
571
+ }
572
+ measureNames.push(metric.name);
573
+ const measure = measureNames
574
+ .map((measureName) => this.measures.get(measureName))
575
+ .find((candidate) => Boolean(candidate?.table));
576
+ if (!measure || measure.nonAdditiveDimension)
577
+ return undefined;
578
+ const type = normalizeMeasureMetricType(measure.agg);
579
+ if (!type)
580
+ return undefined;
581
+ return {
582
+ ...metric,
583
+ table: measure.table,
584
+ cube: metric.cube ?? measure.cube,
585
+ sql: measure.expr?.trim() || (type === 'count' ? '*' : measure.name),
586
+ type,
587
+ };
588
+ }
589
+ composeAggregateIslands(options, metrics, dimensions, timeDimension, dialect) {
590
+ const grainKeys = [
591
+ ...dimensions.map((dimension) => dimension.name),
592
+ ...(timeDimension && options.timeDimension
593
+ ? [`${timeDimension.name}_${options.timeDimension.granularity}`]
594
+ : []),
595
+ ];
596
+ const islands = metrics.map((metric) => this.composeQuery({
597
+ ...options,
598
+ metrics: [metric.name],
599
+ // Ranking/limits apply to the consolidated result, never inside an island.
600
+ orderBy: undefined,
601
+ limit: undefined,
602
+ }));
603
+ if (islands.some((island) => !island))
604
+ return null;
605
+ const compiled = islands;
606
+ const cteNames = metrics.map((metric, index) => `metric_${index + 1}_${sanitizeSqlAlias(metric.name)}`);
607
+ const ctes = compiled.map((island, index) => `${cteNames[index]} AS (\n${indentSql(island.sql)}\n)`);
608
+ let body;
609
+ const joinClauses = [];
610
+ if (grainKeys.length === 0) {
611
+ const select = metrics.map((metric, index) => `${cteNames[index]}.${metric.name} AS ${metric.name}`);
612
+ body = `SELECT\n ${select.join(',\n ')}\nFROM ${cteNames[0]}`;
613
+ for (const cte of cteNames.slice(1)) {
614
+ const clause = `CROSS JOIN ${cte}`;
615
+ joinClauses.push(clause);
616
+ body += `\n${clause}`;
617
+ }
618
+ }
619
+ else {
620
+ const keysCte = `grain_keys AS (\n${cteNames.map((cte) => ` SELECT ${grainKeys.join(', ')} FROM ${cte}`).join('\n UNION\n')}\n)`;
621
+ ctes.push(keysCte);
622
+ const select = [
623
+ ...grainKeys.map((key) => `grain_keys.${key} AS ${key}`),
624
+ ...metrics.map((metric, index) => `${cteNames[index]}.${metric.name} AS ${metric.name}`),
625
+ ];
626
+ body = `SELECT\n ${select.join(',\n ')}\nFROM grain_keys`;
627
+ for (let index = 0; index < cteNames.length; index += 1) {
628
+ const cte = cteNames[index];
629
+ const predicate = grainKeys.map((key) => `grain_keys.${key} = ${cte}.${key}`).join(' AND ');
630
+ const clause = `LEFT JOIN ${cte} ON ${predicate}`;
631
+ joinClauses.push(clause);
632
+ body += `\n${clause}`;
633
+ }
634
+ }
635
+ if ((options.orderBy ?? []).length > 0) {
636
+ body += `\nORDER BY ${options.orderBy.map((order) => `${order.name} ${order.direction.toUpperCase()}`).join(', ')}`;
637
+ }
638
+ if (options.limit)
639
+ body += `\n${dialect.limitClause(options.limit)}`;
640
+ return {
641
+ sql: `WITH ${ctes.join(',\n')}\n${body}`,
642
+ joins: joinClauses,
643
+ tables: Array.from(new Set(compiled.flatMap((island) => island.tables))),
644
+ strategy: 'aggregate_islands',
645
+ grainKeys,
479
646
  };
480
647
  }
481
648
  addHierarchy(hierarchy) {
@@ -715,7 +882,6 @@ export class SemanticLayer {
715
882
  .filter((metric) => Boolean(metric));
716
883
  if (resolvedMetrics.length === 0)
717
884
  return [];
718
- const reachableTables = new Set();
719
885
  const resolveCubeNameForTable = (table) => {
720
886
  for (const cube of this.cubes.values()) {
721
887
  if (cube.table === table || cube.name === table)
@@ -723,28 +889,61 @@ export class SemanticLayer {
723
889
  }
724
890
  return undefined;
725
891
  };
726
- for (const metric of resolvedMetrics) {
727
- reachableTables.add(metric.table);
728
- const cubeName = resolveCubeNameForTable(metric.table);
729
- if (!cubeName)
730
- continue;
731
- const queue = [cubeName];
732
- const visited = new Set(queue);
733
- while (queue.length > 0) {
734
- const current = queue.shift();
735
- const cube = this.cubes.get(current);
736
- if (cube?.table)
737
- reachableTables.add(cube.table);
738
- const joins = this.joinGraph.get(current) ?? [];
739
- for (const join of joins) {
740
- const next = join.right;
741
- if (visited.has(next))
742
- continue;
743
- visited.add(next);
744
- queue.push(next);
892
+ const metricTables = resolvedMetrics.map((metric) => {
893
+ const reachableTables = new Set();
894
+ const measureNames = new Set();
895
+ const measure = metric.typeParams?.measure;
896
+ if (measure && typeof measure === 'object' && !Array.isArray(measure) && typeof measure.name === 'string') {
897
+ measureNames.add(String(measure.name));
898
+ }
899
+ const inputs = metric.typeParams?.input_measures;
900
+ if (Array.isArray(inputs)) {
901
+ for (const input of inputs) {
902
+ if (input && typeof input === 'object' && typeof input.name === 'string') {
903
+ measureNames.add(String(input.name));
904
+ }
745
905
  }
746
906
  }
747
- }
907
+ if (metric.table)
908
+ reachableTables.add(metric.table);
909
+ for (const measureName of measureNames) {
910
+ const definition = this.measures.get(measureName);
911
+ if (definition?.table)
912
+ reachableTables.add(definition.table);
913
+ for (const model of this.semanticModels.values()) {
914
+ if (model.measures.includes(measureName) && model.table)
915
+ reachableTables.add(model.table);
916
+ }
917
+ }
918
+ for (const table of Array.from(reachableTables)) {
919
+ const cubeName = resolveCubeNameForTable(table);
920
+ if (!cubeName)
921
+ continue;
922
+ const queue = [cubeName];
923
+ const visited = new Set(queue);
924
+ while (queue.length > 0) {
925
+ const current = queue.shift();
926
+ const cube = this.cubes.get(current);
927
+ if (cube?.table)
928
+ reachableTables.add(cube.table);
929
+ const joins = this.joinGraph.get(current) ?? [];
930
+ for (const join of joins) {
931
+ const next = join.right;
932
+ if (visited.has(next))
933
+ continue;
934
+ visited.add(next);
935
+ queue.push(next);
936
+ }
937
+ }
938
+ }
939
+ return reachableTables;
940
+ });
941
+ const reachableTables = metricTables.slice(1).reduce((common, tables) => {
942
+ for (const table of common)
943
+ if (!tables.has(table))
944
+ common.delete(table);
945
+ return common;
946
+ }, new Set(metricTables[0]));
748
947
  return Array.from(this.dimensions.values())
749
948
  .filter((dimension) => reachableTables.has(dimension.table))
750
949
  .sort((a, b) => a.label.localeCompare(b.label));
@@ -825,6 +1024,151 @@ function renderMetricExpression(metric) {
825
1024
  return sql;
826
1025
  }
827
1026
  }
1027
+ function normalizeMeasureMetricType(agg) {
1028
+ switch (agg.toLowerCase()) {
1029
+ case 'sum':
1030
+ case 'count':
1031
+ case 'count_distinct':
1032
+ case 'avg':
1033
+ case 'min':
1034
+ case 'max':
1035
+ return agg.toLowerCase();
1036
+ case 'average':
1037
+ return 'avg';
1038
+ default:
1039
+ return null;
1040
+ }
1041
+ }
1042
+ /** Quote a scalar value for SQL, matching composeQuery's WHERE conventions. */
1043
+ function quoteSqlValue(v) {
1044
+ return /^-?\d+(\.\d+)?$/.test(v.trim()) ? v : `'${v.replace(/'/g, "''")}'`;
1045
+ }
1046
+ /**
1047
+ * Substitute MetricFlow-style `{{ Dimension('entity__dim') }}` /
1048
+ * `{{ TimeDimension('entity__dim', 'day') }}` references in a raw SQL filter
1049
+ * string. Plain SQL predicates (no Jinja) are trusted and returned as-is.
1050
+ * Returns null when any non-Dimension Jinja tag remains, so the caller fails safe.
1051
+ */
1052
+ function resolveFilterJinja(raw, resolveColumn) {
1053
+ if (!raw.includes('{{'))
1054
+ return raw;
1055
+ const out = raw.replace(/\{\{\s*(?:Time)?Dimension\(\s*['"]([^'"]+)['"](?:\s*,\s*['"][^'"]+['"])?\s*\)\s*\}\}/g, (_full, ref) => resolveColumn(String(ref)));
1056
+ return out.includes('{{') ? null : out;
1057
+ }
1058
+ /** Render a single object-shaped metric filter (`{ field, operator, value(s) }`). */
1059
+ function renderObjectFilter(raw, resolveColumn) {
1060
+ const field = raw.field ?? raw.dimension ?? raw.column ?? raw.name;
1061
+ if (field == null || String(field).trim() === '') {
1062
+ if (typeof raw.sql === 'string' && raw.sql.trim())
1063
+ return resolveFilterJinja(raw.sql.trim(), resolveColumn);
1064
+ return null;
1065
+ }
1066
+ const col = resolveColumn(String(field));
1067
+ const rawValues = raw.values ?? (raw.value !== undefined ? [raw.value] : []);
1068
+ const values = (Array.isArray(rawValues) ? rawValues : [rawValues]).map((v) => String(v));
1069
+ const op = String(raw.operator ?? raw.op ?? (values.length > 1 ? 'in' : 'equals')).toLowerCase();
1070
+ const q = quoteSqlValue;
1071
+ switch (op) {
1072
+ case 'equals':
1073
+ case '=':
1074
+ case 'eq':
1075
+ return values.length > 1 ? `${col} IN (${values.map(q).join(', ')})` : `${col} = ${q(values[0] ?? '')}`;
1076
+ case 'not_equals':
1077
+ case '!=':
1078
+ case 'neq':
1079
+ return `${col} != ${q(values[0] ?? '')}`;
1080
+ case 'in':
1081
+ return values.length ? `${col} IN (${values.map(q).join(', ')})` : null;
1082
+ case 'not_in':
1083
+ return values.length ? `${col} NOT IN (${values.map(q).join(', ')})` : null;
1084
+ case 'gt':
1085
+ case '>': return `${col} > ${q(values[0] ?? '')}`;
1086
+ case 'gte':
1087
+ case '>=': return `${col} >= ${q(values[0] ?? '')}`;
1088
+ case 'lt':
1089
+ case '<': return `${col} < ${q(values[0] ?? '')}`;
1090
+ case 'lte':
1091
+ case '<=': return `${col} <= ${q(values[0] ?? '')}`;
1092
+ case 'contains': return `${col} LIKE '%${String(values[0] ?? '').replace(/'/g, "''")}%'`;
1093
+ case 'is_null': return `${col} IS NULL`;
1094
+ case 'is_not_null': return `${col} IS NOT NULL`;
1095
+ default: return null;
1096
+ }
1097
+ }
1098
+ /** Render a metric `filter` spec (string | object | array-of-objects) to SQL, or null if unrenderable. */
1099
+ function renderFilterSpec(spec, resolveColumn) {
1100
+ if (typeof spec === 'string') {
1101
+ const trimmed = spec.trim();
1102
+ return trimmed ? resolveFilterJinja(trimmed, resolveColumn) : '';
1103
+ }
1104
+ if (Array.isArray(spec)) {
1105
+ const parts = [];
1106
+ for (const item of spec) {
1107
+ if (!item || typeof item !== 'object')
1108
+ return null;
1109
+ const r = renderObjectFilter(item, resolveColumn);
1110
+ if (r === null)
1111
+ return null;
1112
+ if (r)
1113
+ parts.push(`(${r})`);
1114
+ }
1115
+ return parts.length ? parts.join(' AND ') : '';
1116
+ }
1117
+ if (spec && typeof spec === 'object')
1118
+ return renderObjectFilter(spec, resolveColumn);
1119
+ return null;
1120
+ }
1121
+ /** Compute a metric's scoped-filter predicate, distinguishing none / ok / unrenderable. */
1122
+ function computeMetricFilterPredicate(metric, resolveColumn) {
1123
+ const f = metric.filter;
1124
+ if (f === undefined || f === null)
1125
+ return { kind: 'none' };
1126
+ const rendered = renderFilterSpec(f, resolveColumn);
1127
+ if (rendered === null)
1128
+ return { kind: 'fail' };
1129
+ if (!rendered)
1130
+ return { kind: 'none' };
1131
+ return { kind: 'ok', sql: rendered };
1132
+ }
1133
+ /**
1134
+ * Inject a metric's scoped filter INSIDE its aggregate via CASE WHEN so that a
1135
+ * multi-metric query with differing per-metric filters stays correct, e.g.
1136
+ * `SUM(amount)` + `status='completed'` → `SUM(CASE WHEN status='completed' THEN amount END)`.
1137
+ * Returns null when the expression is not a single recognizable aggregate call
1138
+ * (e.g. a ratio `SUM(a)/SUM(b)`), so the caller fails safe rather than emit
1139
+ * a filter that only partially applies.
1140
+ */
1141
+ function wrapAggregateWithFilter(expr, predicate) {
1142
+ const head = expr.match(/^\s*(sum|avg|min|max|count)\s*\(/i);
1143
+ if (!head)
1144
+ return null;
1145
+ const fn = head[1].toUpperCase();
1146
+ const openIdx = expr.indexOf('(', head.index ?? 0);
1147
+ // Find the close paren that matches the aggregate's opening paren. If it is
1148
+ // not the end of the expression, this is NOT a single aggregate call (e.g. a
1149
+ // ratio `SUM(a) / SUM(b)`) — refuse rather than inject a half-applied filter.
1150
+ let depth = 0;
1151
+ let closeIdx = -1;
1152
+ for (let i = openIdx; i < expr.length; i++) {
1153
+ if (expr[i] === '(')
1154
+ depth++;
1155
+ else if (expr[i] === ')' && --depth === 0) {
1156
+ closeIdx = i;
1157
+ break;
1158
+ }
1159
+ }
1160
+ if (closeIdx === -1 || expr.slice(closeIdx + 1).trim() !== '')
1161
+ return null;
1162
+ let inner = expr.slice(openIdx + 1, closeIdx).trim();
1163
+ const distinctMatch = inner.match(/^distinct\s+/i);
1164
+ const distinct = Boolean(distinctMatch);
1165
+ if (distinct)
1166
+ inner = inner.slice(distinctMatch[0].length).trim();
1167
+ if (fn === 'COUNT' && !distinct && inner === '*')
1168
+ return `COUNT(CASE WHEN ${predicate} THEN 1 END)`;
1169
+ const distinctKw = distinct ? 'DISTINCT ' : '';
1170
+ return `${fn}(${distinctKw}CASE WHEN ${predicate} THEN ${inner} END)`;
1171
+ }
828
1172
  export function parseCubeDefinition(raw) {
829
1173
  const measuresRaw = Array.isArray(raw.measures) ? raw.measures : [];
830
1174
  const dimensionsRaw = Array.isArray(raw.dimensions) ? raw.dimensions : [];
@@ -887,6 +1231,13 @@ export function parseCubeDefinition(raw) {
887
1231
  source: cubeSource,
888
1232
  };
889
1233
  }
1234
+ function sanitizeSqlAlias(value) {
1235
+ const normalized = value.replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
1236
+ return normalized || 'metric';
1237
+ }
1238
+ function indentSql(sql) {
1239
+ return sql.split(/\r?\n/).map((line) => ` ${line}`).join('\n');
1240
+ }
890
1241
  function validateMetricType(t) {
891
1242
  const valid = ['sum', 'count', 'count_distinct', 'avg', 'min', 'max', 'custom'];
892
1243
  return valid.includes(t) ? t : 'sum';