@duckcodeailabs/dql-core 1.6.33 → 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.
- package/dist/ast/nodes.d.ts +9 -0
- package/dist/ast/nodes.d.ts.map +1 -1
- package/dist/ast/printer.js +20 -0
- package/dist/ast/printer.js.map +1 -1
- package/dist/contracts/types.d.ts +6 -0
- package/dist/contracts/types.d.ts.map +1 -1
- package/dist/contracts/types.js.map +1 -1
- package/dist/lineage/index.d.ts +1 -1
- package/dist/lineage/index.d.ts.map +1 -1
- package/dist/lineage/index.js.map +1 -1
- package/dist/lineage/sql-parser.d.ts +19 -0
- package/dist/lineage/sql-parser.d.ts.map +1 -1
- package/dist/lineage/sql-parser.js +90 -0
- package/dist/lineage/sql-parser.js.map +1 -1
- package/dist/manifest/builder.d.ts.map +1 -1
- package/dist/manifest/builder.js +44 -0
- package/dist/manifest/builder.js.map +1 -1
- package/dist/manifest/domain-writer.d.ts +14 -0
- package/dist/manifest/domain-writer.d.ts.map +1 -1
- package/dist/manifest/domain-writer.js +49 -3
- package/dist/manifest/domain-writer.js.map +1 -1
- package/dist/manifest/types.d.ts +8 -0
- package/dist/manifest/types.d.ts.map +1 -1
- package/dist/parser/parser.d.ts.map +1 -1
- package/dist/parser/parser.js +51 -3
- package/dist/parser/parser.js.map +1 -1
- package/dist/semantic/index.d.ts +1 -1
- package/dist/semantic/index.d.ts.map +1 -1
- package/dist/semantic/index.js +1 -1
- package/dist/semantic/index.js.map +1 -1
- package/dist/semantic/providers/dbt-provider.d.ts +63 -0
- package/dist/semantic/providers/dbt-provider.d.ts.map +1 -1
- package/dist/semantic/providers/dbt-provider.js +43 -16
- package/dist/semantic/providers/dbt-provider.js.map +1 -1
- package/dist/semantic/semantic-layer.d.ts +5 -0
- package/dist/semantic/semantic-layer.d.ts.map +1 -1
- package/dist/semantic/semantic-layer.js +310 -25
- package/dist/semantic/semantic-layer.js.map +1 -1
- package/dist/semantic/sql-dialect.d.ts +8 -0
- package/dist/semantic/sql-dialect.d.ts.map +1 -1
- package/dist/semantic/sql-dialect.js +22 -0
- package/dist/semantic/sql-dialect.js.map +1 -1
- 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 (
|
|
365
|
-
|
|
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
|
|
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
|
-
|
|
727
|
-
reachableTables
|
|
728
|
-
const
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
const
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
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';
|