@duckcodeailabs/dql-core 1.6.32 → 1.6.34

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 (43) hide show
  1. package/dist/ast/nodes.d.ts +9 -0
  2. package/dist/ast/nodes.d.ts.map +1 -1
  3. package/dist/ast/printer.js +20 -0
  4. package/dist/ast/printer.js.map +1 -1
  5. package/dist/contracts/types.d.ts +6 -0
  6. package/dist/contracts/types.d.ts.map +1 -1
  7. package/dist/contracts/types.js.map +1 -1
  8. package/dist/lineage/index.d.ts +1 -1
  9. package/dist/lineage/index.d.ts.map +1 -1
  10. package/dist/lineage/index.js.map +1 -1
  11. package/dist/lineage/sql-parser.d.ts +19 -0
  12. package/dist/lineage/sql-parser.d.ts.map +1 -1
  13. package/dist/lineage/sql-parser.js +90 -0
  14. package/dist/lineage/sql-parser.js.map +1 -1
  15. package/dist/manifest/builder.d.ts.map +1 -1
  16. package/dist/manifest/builder.js +44 -0
  17. package/dist/manifest/builder.js.map +1 -1
  18. package/dist/manifest/domain-writer.d.ts +14 -0
  19. package/dist/manifest/domain-writer.d.ts.map +1 -1
  20. package/dist/manifest/domain-writer.js +49 -3
  21. package/dist/manifest/domain-writer.js.map +1 -1
  22. package/dist/manifest/types.d.ts +8 -0
  23. package/dist/manifest/types.d.ts.map +1 -1
  24. package/dist/parser/parser.d.ts.map +1 -1
  25. package/dist/parser/parser.js +51 -3
  26. package/dist/parser/parser.js.map +1 -1
  27. package/dist/semantic/index.d.ts +1 -1
  28. package/dist/semantic/index.d.ts.map +1 -1
  29. package/dist/semantic/index.js +1 -1
  30. package/dist/semantic/index.js.map +1 -1
  31. package/dist/semantic/providers/dbt-provider.d.ts +63 -0
  32. package/dist/semantic/providers/dbt-provider.d.ts.map +1 -1
  33. package/dist/semantic/providers/dbt-provider.js +43 -16
  34. package/dist/semantic/providers/dbt-provider.js.map +1 -1
  35. package/dist/semantic/semantic-layer.d.ts +5 -0
  36. package/dist/semantic/semantic-layer.d.ts.map +1 -1
  37. package/dist/semantic/semantic-layer.js +310 -25
  38. package/dist/semantic/semantic-layer.js.map +1 -1
  39. package/dist/semantic/sql-dialect.d.ts +8 -0
  40. package/dist/semantic/sql-dialect.d.ts.map +1 -1
  41. package/dist/semantic/sql-dialect.js +22 -0
  42. package/dist/semantic/sql-dialect.js.map +1 -1
  43. package/package.json +1 -1
@@ -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,70 @@ 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
+ composeAggregateIslands(options, metrics, dimensions, timeDimension, dialect) {
539
+ const grainKeys = [
540
+ ...dimensions.map((dimension) => dimension.name),
541
+ ...(timeDimension && options.timeDimension
542
+ ? [`${timeDimension.name}_${options.timeDimension.granularity}`]
543
+ : []),
544
+ ];
545
+ const islands = metrics.map((metric) => this.composeQuery({
546
+ ...options,
547
+ metrics: [metric.name],
548
+ // Ranking/limits apply to the consolidated result, never inside an island.
549
+ orderBy: undefined,
550
+ limit: undefined,
551
+ }));
552
+ if (islands.some((island) => !island))
553
+ return null;
554
+ const compiled = islands;
555
+ const cteNames = metrics.map((metric, index) => `metric_${index + 1}_${sanitizeSqlAlias(metric.name)}`);
556
+ const ctes = compiled.map((island, index) => `${cteNames[index]} AS (\n${indentSql(island.sql)}\n)`);
557
+ let body;
558
+ const joinClauses = [];
559
+ if (grainKeys.length === 0) {
560
+ const select = metrics.map((metric, index) => `${cteNames[index]}.${metric.name} AS ${metric.name}`);
561
+ body = `SELECT\n ${select.join(',\n ')}\nFROM ${cteNames[0]}`;
562
+ for (const cte of cteNames.slice(1)) {
563
+ const clause = `CROSS JOIN ${cte}`;
564
+ joinClauses.push(clause);
565
+ body += `\n${clause}`;
566
+ }
567
+ }
568
+ else {
569
+ const keysCte = `grain_keys AS (\n${cteNames.map((cte) => ` SELECT ${grainKeys.join(', ')} FROM ${cte}`).join('\n UNION\n')}\n)`;
570
+ ctes.push(keysCte);
571
+ const select = [
572
+ ...grainKeys.map((key) => `grain_keys.${key} AS ${key}`),
573
+ ...metrics.map((metric, index) => `${cteNames[index]}.${metric.name} AS ${metric.name}`),
574
+ ];
575
+ body = `SELECT\n ${select.join(',\n ')}\nFROM grain_keys`;
576
+ for (let index = 0; index < cteNames.length; index += 1) {
577
+ const cte = cteNames[index];
578
+ const predicate = grainKeys.map((key) => `grain_keys.${key} = ${cte}.${key}`).join(' AND ');
579
+ const clause = `LEFT JOIN ${cte} ON ${predicate}`;
580
+ joinClauses.push(clause);
581
+ body += `\n${clause}`;
582
+ }
583
+ }
584
+ if ((options.orderBy ?? []).length > 0) {
585
+ body += `\nORDER BY ${options.orderBy.map((order) => `${order.name} ${order.direction.toUpperCase()}`).join(', ')}`;
586
+ }
587
+ if (options.limit)
588
+ body += `\n${dialect.limitClause(options.limit)}`;
589
+ return {
590
+ sql: `WITH ${ctes.join(',\n')}\n${body}`,
591
+ joins: joinClauses,
592
+ tables: Array.from(new Set(compiled.flatMap((island) => island.tables))),
593
+ strategy: 'aggregate_islands',
594
+ grainKeys,
479
595
  };
480
596
  }
481
597
  addHierarchy(hierarchy) {
@@ -715,7 +831,6 @@ export class SemanticLayer {
715
831
  .filter((metric) => Boolean(metric));
716
832
  if (resolvedMetrics.length === 0)
717
833
  return [];
718
- const reachableTables = new Set();
719
834
  const resolveCubeNameForTable = (table) => {
720
835
  for (const cube of this.cubes.values()) {
721
836
  if (cube.table === table || cube.name === table)
@@ -723,28 +838,61 @@ export class SemanticLayer {
723
838
  }
724
839
  return undefined;
725
840
  };
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);
841
+ const metricTables = resolvedMetrics.map((metric) => {
842
+ const reachableTables = new Set();
843
+ const measureNames = new Set();
844
+ const measure = metric.typeParams?.measure;
845
+ if (measure && typeof measure === 'object' && !Array.isArray(measure) && typeof measure.name === 'string') {
846
+ measureNames.add(String(measure.name));
847
+ }
848
+ const inputs = metric.typeParams?.input_measures;
849
+ if (Array.isArray(inputs)) {
850
+ for (const input of inputs) {
851
+ if (input && typeof input === 'object' && typeof input.name === 'string') {
852
+ measureNames.add(String(input.name));
853
+ }
745
854
  }
746
855
  }
747
- }
856
+ if (metric.table)
857
+ reachableTables.add(metric.table);
858
+ for (const measureName of measureNames) {
859
+ const definition = this.measures.get(measureName);
860
+ if (definition?.table)
861
+ reachableTables.add(definition.table);
862
+ for (const model of this.semanticModels.values()) {
863
+ if (model.measures.includes(measureName) && model.table)
864
+ reachableTables.add(model.table);
865
+ }
866
+ }
867
+ for (const table of Array.from(reachableTables)) {
868
+ const cubeName = resolveCubeNameForTable(table);
869
+ if (!cubeName)
870
+ continue;
871
+ const queue = [cubeName];
872
+ const visited = new Set(queue);
873
+ while (queue.length > 0) {
874
+ const current = queue.shift();
875
+ const cube = this.cubes.get(current);
876
+ if (cube?.table)
877
+ reachableTables.add(cube.table);
878
+ const joins = this.joinGraph.get(current) ?? [];
879
+ for (const join of joins) {
880
+ const next = join.right;
881
+ if (visited.has(next))
882
+ continue;
883
+ visited.add(next);
884
+ queue.push(next);
885
+ }
886
+ }
887
+ }
888
+ return reachableTables;
889
+ });
890
+ const reachableTables = metricTables.slice(1).reduce((common, tables) => {
891
+ for (const table of common)
892
+ if (!tables.has(table))
893
+ common.delete(table);
894
+ return common;
895
+ }, new Set(metricTables[0]));
748
896
  return Array.from(this.dimensions.values())
749
897
  .filter((dimension) => reachableTables.has(dimension.table))
750
898
  .sort((a, b) => a.label.localeCompare(b.label));
@@ -825,6 +973,136 @@ function renderMetricExpression(metric) {
825
973
  return sql;
826
974
  }
827
975
  }
976
+ /** Quote a scalar value for SQL, matching composeQuery's WHERE conventions. */
977
+ function quoteSqlValue(v) {
978
+ return /^-?\d+(\.\d+)?$/.test(v.trim()) ? v : `'${v.replace(/'/g, "''")}'`;
979
+ }
980
+ /**
981
+ * Substitute MetricFlow-style `{{ Dimension('entity__dim') }}` /
982
+ * `{{ TimeDimension('entity__dim', 'day') }}` references in a raw SQL filter
983
+ * string. Plain SQL predicates (no Jinja) are trusted and returned as-is.
984
+ * Returns null when any non-Dimension Jinja tag remains, so the caller fails safe.
985
+ */
986
+ function resolveFilterJinja(raw, resolveColumn) {
987
+ if (!raw.includes('{{'))
988
+ return raw;
989
+ const out = raw.replace(/\{\{\s*(?:Time)?Dimension\(\s*['"]([^'"]+)['"](?:\s*,\s*['"][^'"]+['"])?\s*\)\s*\}\}/g, (_full, ref) => resolveColumn(String(ref)));
990
+ return out.includes('{{') ? null : out;
991
+ }
992
+ /** Render a single object-shaped metric filter (`{ field, operator, value(s) }`). */
993
+ function renderObjectFilter(raw, resolveColumn) {
994
+ const field = raw.field ?? raw.dimension ?? raw.column ?? raw.name;
995
+ if (field == null || String(field).trim() === '') {
996
+ if (typeof raw.sql === 'string' && raw.sql.trim())
997
+ return resolveFilterJinja(raw.sql.trim(), resolveColumn);
998
+ return null;
999
+ }
1000
+ const col = resolveColumn(String(field));
1001
+ const rawValues = raw.values ?? (raw.value !== undefined ? [raw.value] : []);
1002
+ const values = (Array.isArray(rawValues) ? rawValues : [rawValues]).map((v) => String(v));
1003
+ const op = String(raw.operator ?? raw.op ?? (values.length > 1 ? 'in' : 'equals')).toLowerCase();
1004
+ const q = quoteSqlValue;
1005
+ switch (op) {
1006
+ case 'equals':
1007
+ case '=':
1008
+ case 'eq':
1009
+ return values.length > 1 ? `${col} IN (${values.map(q).join(', ')})` : `${col} = ${q(values[0] ?? '')}`;
1010
+ case 'not_equals':
1011
+ case '!=':
1012
+ case 'neq':
1013
+ return `${col} != ${q(values[0] ?? '')}`;
1014
+ case 'in':
1015
+ return values.length ? `${col} IN (${values.map(q).join(', ')})` : null;
1016
+ case 'not_in':
1017
+ return values.length ? `${col} NOT IN (${values.map(q).join(', ')})` : null;
1018
+ case 'gt':
1019
+ case '>': return `${col} > ${q(values[0] ?? '')}`;
1020
+ case 'gte':
1021
+ case '>=': return `${col} >= ${q(values[0] ?? '')}`;
1022
+ case 'lt':
1023
+ case '<': return `${col} < ${q(values[0] ?? '')}`;
1024
+ case 'lte':
1025
+ case '<=': return `${col} <= ${q(values[0] ?? '')}`;
1026
+ case 'contains': return `${col} LIKE '%${String(values[0] ?? '').replace(/'/g, "''")}%'`;
1027
+ case 'is_null': return `${col} IS NULL`;
1028
+ case 'is_not_null': return `${col} IS NOT NULL`;
1029
+ default: return null;
1030
+ }
1031
+ }
1032
+ /** Render a metric `filter` spec (string | object | array-of-objects) to SQL, or null if unrenderable. */
1033
+ function renderFilterSpec(spec, resolveColumn) {
1034
+ if (typeof spec === 'string') {
1035
+ const trimmed = spec.trim();
1036
+ return trimmed ? resolveFilterJinja(trimmed, resolveColumn) : '';
1037
+ }
1038
+ if (Array.isArray(spec)) {
1039
+ const parts = [];
1040
+ for (const item of spec) {
1041
+ if (!item || typeof item !== 'object')
1042
+ return null;
1043
+ const r = renderObjectFilter(item, resolveColumn);
1044
+ if (r === null)
1045
+ return null;
1046
+ if (r)
1047
+ parts.push(`(${r})`);
1048
+ }
1049
+ return parts.length ? parts.join(' AND ') : '';
1050
+ }
1051
+ if (spec && typeof spec === 'object')
1052
+ return renderObjectFilter(spec, resolveColumn);
1053
+ return null;
1054
+ }
1055
+ /** Compute a metric's scoped-filter predicate, distinguishing none / ok / unrenderable. */
1056
+ function computeMetricFilterPredicate(metric, resolveColumn) {
1057
+ const f = metric.filter;
1058
+ if (f === undefined || f === null)
1059
+ return { kind: 'none' };
1060
+ const rendered = renderFilterSpec(f, resolveColumn);
1061
+ if (rendered === null)
1062
+ return { kind: 'fail' };
1063
+ if (!rendered)
1064
+ return { kind: 'none' };
1065
+ return { kind: 'ok', sql: rendered };
1066
+ }
1067
+ /**
1068
+ * Inject a metric's scoped filter INSIDE its aggregate via CASE WHEN so that a
1069
+ * multi-metric query with differing per-metric filters stays correct, e.g.
1070
+ * `SUM(amount)` + `status='completed'` → `SUM(CASE WHEN status='completed' THEN amount END)`.
1071
+ * Returns null when the expression is not a single recognizable aggregate call
1072
+ * (e.g. a ratio `SUM(a)/SUM(b)`), so the caller fails safe rather than emit
1073
+ * a filter that only partially applies.
1074
+ */
1075
+ function wrapAggregateWithFilter(expr, predicate) {
1076
+ const head = expr.match(/^\s*(sum|avg|min|max|count)\s*\(/i);
1077
+ if (!head)
1078
+ return null;
1079
+ const fn = head[1].toUpperCase();
1080
+ const openIdx = expr.indexOf('(', head.index ?? 0);
1081
+ // Find the close paren that matches the aggregate's opening paren. If it is
1082
+ // not the end of the expression, this is NOT a single aggregate call (e.g. a
1083
+ // ratio `SUM(a) / SUM(b)`) — refuse rather than inject a half-applied filter.
1084
+ let depth = 0;
1085
+ let closeIdx = -1;
1086
+ for (let i = openIdx; i < expr.length; i++) {
1087
+ if (expr[i] === '(')
1088
+ depth++;
1089
+ else if (expr[i] === ')' && --depth === 0) {
1090
+ closeIdx = i;
1091
+ break;
1092
+ }
1093
+ }
1094
+ if (closeIdx === -1 || expr.slice(closeIdx + 1).trim() !== '')
1095
+ return null;
1096
+ let inner = expr.slice(openIdx + 1, closeIdx).trim();
1097
+ const distinctMatch = inner.match(/^distinct\s+/i);
1098
+ const distinct = Boolean(distinctMatch);
1099
+ if (distinct)
1100
+ inner = inner.slice(distinctMatch[0].length).trim();
1101
+ if (fn === 'COUNT' && !distinct && inner === '*')
1102
+ return `COUNT(CASE WHEN ${predicate} THEN 1 END)`;
1103
+ const distinctKw = distinct ? 'DISTINCT ' : '';
1104
+ return `${fn}(${distinctKw}CASE WHEN ${predicate} THEN ${inner} END)`;
1105
+ }
828
1106
  export function parseCubeDefinition(raw) {
829
1107
  const measuresRaw = Array.isArray(raw.measures) ? raw.measures : [];
830
1108
  const dimensionsRaw = Array.isArray(raw.dimensions) ? raw.dimensions : [];
@@ -887,6 +1165,13 @@ export function parseCubeDefinition(raw) {
887
1165
  source: cubeSource,
888
1166
  };
889
1167
  }
1168
+ function sanitizeSqlAlias(value) {
1169
+ const normalized = value.replace(/[^a-zA-Z0-9_]+/g, '_').replace(/^_+|_+$/g, '');
1170
+ return normalized || 'metric';
1171
+ }
1172
+ function indentSql(sql) {
1173
+ return sql.split(/\r?\n/).map((line) => ` ${line}`).join('\n');
1174
+ }
890
1175
  function validateMetricType(t) {
891
1176
  const valid = ['sum', 'count', 'count_distinct', 'avg', 'min', 'max', 'custom'];
892
1177
  return valid.includes(t) ? t : 'sum';