tplm-lang 0.3.5 → 0.3.7
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/compiler/grid-spec-builder.d.ts +5 -0
- package/dist/compiler/grid-spec-builder.js +72 -25
- package/dist/compiler/malloy-generator.js +19 -2
- package/dist/compiler/multi-query-utils.d.ts +6 -1
- package/dist/compiler/multi-query-utils.js +11 -6
- package/dist/compiler/table-spec.d.ts +16 -0
- package/dist/executor/index.d.ts +2 -0
- package/dist/executor/index.js +10 -1
- package/dist/index.cjs +123 -43
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -3
- package/dist/renderer/grid-renderer.js +32 -21
- package/package.json +1 -1
|
@@ -23,6 +23,11 @@ export interface BuildGridSpecOptions {
|
|
|
23
23
|
malloyQueries?: MalloyQuerySpec[];
|
|
24
24
|
/** Ordering provider for definition-order sorting */
|
|
25
25
|
orderingProvider?: DimensionOrderingProvider;
|
|
26
|
+
/**
|
|
27
|
+
* Raw SQL queries by query ID, captured from the Malloy runtime.
|
|
28
|
+
* When provided, each CellValue will include `sql` and `cellSQL` fields.
|
|
29
|
+
*/
|
|
30
|
+
sqlQueries?: Map<string, string>;
|
|
26
31
|
}
|
|
27
32
|
/**
|
|
28
33
|
* Build a GridSpec from a TableSpec and query results.
|
|
@@ -102,12 +102,14 @@ export function buildGridSpec(spec, plan, results, malloyQueriesOrOptions) {
|
|
|
102
102
|
// Handle both old signature (array) and new signature (options object)
|
|
103
103
|
let malloyQueries;
|
|
104
104
|
let orderingProvider;
|
|
105
|
+
let sqlQueries;
|
|
105
106
|
if (Array.isArray(malloyQueriesOrOptions)) {
|
|
106
107
|
malloyQueries = malloyQueriesOrOptions;
|
|
107
108
|
}
|
|
108
109
|
else if (malloyQueriesOrOptions) {
|
|
109
110
|
malloyQueries = malloyQueriesOrOptions.malloyQueries;
|
|
110
111
|
orderingProvider = malloyQueriesOrOptions.orderingProvider;
|
|
112
|
+
sqlQueries = malloyQueriesOrOptions.sqlQueries;
|
|
111
113
|
}
|
|
112
114
|
// Set module-level ordering provider for definition-order sorting
|
|
113
115
|
currentOrderingProvider = orderingProvider;
|
|
@@ -174,7 +176,7 @@ export function buildGridSpec(spec, plan, results, malloyQueriesOrOptions) {
|
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
178
|
// Build cell lookup (value-based)
|
|
177
|
-
const cellLookup = buildCellLookup(spec, plan, results, invertedQueries, flatQueries);
|
|
179
|
+
const cellLookup = buildCellLookup(spec, plan, results, invertedQueries, flatQueries, sqlQueries);
|
|
178
180
|
// Check for totals
|
|
179
181
|
const hasRowTotal = axisHasTotal(spec.rowAxis);
|
|
180
182
|
const hasColTotal = axisHasTotal(spec.colAxis);
|
|
@@ -461,15 +463,13 @@ function buildSiblingHeaders(node, plan, results, axis, currentPath, depth, pare
|
|
|
461
463
|
* Format an aggregate name for display (e.g., "births sum" from "births", "sum")
|
|
462
464
|
*/
|
|
463
465
|
function formatAggregateName(measure, aggregation) {
|
|
464
|
-
// For count/n without a measure, just return
|
|
465
|
-
// This handles cases like standalone "count" or "n"
|
|
466
|
+
// For count/n without a measure, just return "N"
|
|
466
467
|
if (!measure || measure === "__pending__") {
|
|
467
468
|
return aggregation === "count" ? "N" : aggregation;
|
|
468
469
|
}
|
|
469
|
-
// For count
|
|
470
|
-
// count doesn't really bind to a measure in Malloy
|
|
470
|
+
// For field-bound count (distinct count), show "field N"
|
|
471
471
|
if (aggregation === "count") {
|
|
472
|
-
return
|
|
472
|
+
return `${measure} N`;
|
|
473
473
|
}
|
|
474
474
|
return `${measure} ${aggregation}`;
|
|
475
475
|
}
|
|
@@ -1025,6 +1025,37 @@ function makeCellKey(rowValues, colValues) {
|
|
|
1025
1025
|
.map(([k, v]) => `${k}=${v}`)
|
|
1026
1026
|
.join("|");
|
|
1027
1027
|
}
|
|
1028
|
+
/**
|
|
1029
|
+
* Build a cell-specific SQL query by wrapping the base SQL with a WHERE clause
|
|
1030
|
+
* that narrows to the specific dimension values for this cell.
|
|
1031
|
+
*/
|
|
1032
|
+
function buildCellSQL(baseSQL, rowValues, colValues) {
|
|
1033
|
+
const conditions = [];
|
|
1034
|
+
const addCondition = (dim, val) => {
|
|
1035
|
+
if (typeof val === "number") {
|
|
1036
|
+
conditions.push(`${quoteIdentifier(dim)} = ${val}`);
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
conditions.push(`${quoteIdentifier(dim)} = '${val.replace(/'/g, "''")}'`);
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
for (const [dim, val] of rowValues) {
|
|
1043
|
+
addCondition(dim, val);
|
|
1044
|
+
}
|
|
1045
|
+
for (const [dim, val] of colValues) {
|
|
1046
|
+
addCondition(dim, val);
|
|
1047
|
+
}
|
|
1048
|
+
if (conditions.length === 0)
|
|
1049
|
+
return baseSQL;
|
|
1050
|
+
// Wrap: SELECT * FROM (baseSQL) AS _tpl WHERE conditions
|
|
1051
|
+
return `SELECT * FROM (\n${baseSQL}\n) AS _tpl_base\nWHERE ${conditions.join(" AND ")}`;
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Quote a SQL identifier with double quotes.
|
|
1055
|
+
*/
|
|
1056
|
+
function quoteIdentifier(name) {
|
|
1057
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
1058
|
+
}
|
|
1028
1059
|
/**
|
|
1029
1060
|
* Build a cell lookup function from query results.
|
|
1030
1061
|
*
|
|
@@ -1034,9 +1065,10 @@ function makeCellKey(rowValues, colValues) {
|
|
|
1034
1065
|
*
|
|
1035
1066
|
* @param invertedQueries Set of query IDs that have inverted axes (column dim is outer in Malloy)
|
|
1036
1067
|
* @param flatQueries Set of query IDs that use flat structure (all dims in single group_by)
|
|
1068
|
+
* @param sqlQueries Optional map of query ID → SQL string for attribution
|
|
1037
1069
|
*/
|
|
1038
|
-
function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = new Set()) {
|
|
1039
|
-
// Build an index: cellKey → aggregateName → value
|
|
1070
|
+
function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = new Set(), sqlQueries) {
|
|
1071
|
+
// Build an index: cellKey → aggregateName → CellEntry (value + queryId)
|
|
1040
1072
|
const cellIndex = new Map();
|
|
1041
1073
|
// Index all query results by plan query ID
|
|
1042
1074
|
// Since we now use a unified QueryPlan system, IDs should match directly
|
|
@@ -1080,12 +1112,24 @@ function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = new
|
|
|
1080
1112
|
pathDescription: cellKey,
|
|
1081
1113
|
};
|
|
1082
1114
|
}
|
|
1083
|
-
const
|
|
1115
|
+
const entry = cellData.get(aggName);
|
|
1116
|
+
const value = entry?.value ?? null;
|
|
1117
|
+
// Build SQL attribution if available
|
|
1118
|
+
let sql;
|
|
1119
|
+
let cellSQL;
|
|
1120
|
+
if (sqlQueries && entry?.queryId) {
|
|
1121
|
+
sql = sqlQueries.get(entry.queryId);
|
|
1122
|
+
if (sql) {
|
|
1123
|
+
cellSQL = buildCellSQL(sql, rowValues, colValues);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1084
1126
|
return {
|
|
1085
1127
|
raw: value,
|
|
1086
1128
|
formatted: formatValue(value, agg),
|
|
1087
1129
|
aggregate: aggName,
|
|
1088
1130
|
pathDescription: cellKey,
|
|
1131
|
+
sql,
|
|
1132
|
+
cellSQL,
|
|
1089
1133
|
};
|
|
1090
1134
|
},
|
|
1091
1135
|
};
|
|
@@ -1131,7 +1175,7 @@ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = fals
|
|
|
1131
1175
|
new Map(), // values for malloy nested (will be mapped to correct axis)
|
|
1132
1176
|
cellIndex, 0, isInverted, query.rowGroupings, // logical row groupings
|
|
1133
1177
|
query.colGroupings, // logical col groupings
|
|
1134
|
-
primarySuffix);
|
|
1178
|
+
primarySuffix, query.id);
|
|
1135
1179
|
// Handle merged queries with additional column variants
|
|
1136
1180
|
// Each variant has its own nests (e.g., by_gender vs by_sector_label)
|
|
1137
1181
|
// that need to be indexed separately
|
|
@@ -1149,7 +1193,7 @@ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = fals
|
|
|
1149
1193
|
seenFirstDims.set(firstDim, count + 1);
|
|
1150
1194
|
nestNameSuffix = count > 0 ? `_${count}` : "";
|
|
1151
1195
|
}
|
|
1152
|
-
flattenAndIndex(data, malloyRowGroupings, variantColGroupings, aggregates, new Map(), new Map(), cellIndex, 0, isInverted, query.rowGroupings, variant.colGroupings, nestNameSuffix);
|
|
1196
|
+
flattenAndIndex(data, malloyRowGroupings, variantColGroupings, aggregates, new Map(), new Map(), cellIndex, 0, isInverted, query.rowGroupings, variant.colGroupings, nestNameSuffix, query.id);
|
|
1153
1197
|
}
|
|
1154
1198
|
}
|
|
1155
1199
|
}
|
|
@@ -1194,7 +1238,7 @@ function indexFlatQueryResults(data, query, cellIndex, aggregates) {
|
|
|
1194
1238
|
}
|
|
1195
1239
|
}
|
|
1196
1240
|
// Index aggregate values using the combined row/col values
|
|
1197
|
-
indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex);
|
|
1241
|
+
indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex, query.id);
|
|
1198
1242
|
}
|
|
1199
1243
|
}
|
|
1200
1244
|
/**
|
|
@@ -1207,7 +1251,7 @@ function indexFlatQueryResults(data, query, cellIndex, aggregates) {
|
|
|
1207
1251
|
* @param logicalColGroupings The actual col groupings for cell key building
|
|
1208
1252
|
* @param nestNameSuffix Suffix for nest names (e.g., '_1' for merged query variants)
|
|
1209
1253
|
*/
|
|
1210
|
-
function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggregates, baseOuterValues, baseNestedValues, cellIndex, outerDepth, isInverted = false, logicalRowGroupings, logicalColGroupings, nestNameSuffix = "") {
|
|
1254
|
+
function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggregates, baseOuterValues, baseNestedValues, cellIndex, outerDepth, isInverted = false, logicalRowGroupings, logicalColGroupings, nestNameSuffix = "", queryId) {
|
|
1211
1255
|
for (const row of data) {
|
|
1212
1256
|
// Build outer values - collect ALL outer dimension values from the current row
|
|
1213
1257
|
// This handles the case where multiple dimensions are in the same group_by
|
|
@@ -1236,7 +1280,7 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
|
|
|
1236
1280
|
const nestedKey = `by_${dim}${nestNameSuffix}`;
|
|
1237
1281
|
if (row[nestedKey] && Array.isArray(row[nestedKey])) {
|
|
1238
1282
|
// This dimension is nested - recurse
|
|
1239
|
-
flattenAndIndex(row[nestedKey], malloyOuterGroupings, malloyNestedGroupings, aggregates, currentOuterValues, baseNestedValues, cellIndex, i, isInverted, logicalRowGroupings, logicalColGroupings, nestNameSuffix);
|
|
1283
|
+
flattenAndIndex(row[nestedKey], malloyOuterGroupings, malloyNestedGroupings, aggregates, currentOuterValues, baseNestedValues, cellIndex, i, isInverted, logicalRowGroupings, logicalColGroupings, nestNameSuffix, queryId);
|
|
1240
1284
|
break; // Don't continue with this row, we recursed
|
|
1241
1285
|
}
|
|
1242
1286
|
}
|
|
@@ -1249,7 +1293,7 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
|
|
|
1249
1293
|
continue; // Skipped this row because we recursed into nested data
|
|
1250
1294
|
}
|
|
1251
1295
|
if (malloyNestedGroupings.length > 0) {
|
|
1252
|
-
indexColumnPivots(row, malloyNestedGroupings, aggregates, currentOuterValues, new Map(), cellIndex, 0, isInverted, nestNameSuffix);
|
|
1296
|
+
indexColumnPivots(row, malloyNestedGroupings, aggregates, currentOuterValues, new Map(), cellIndex, 0, isInverted, nestNameSuffix, queryId);
|
|
1253
1297
|
}
|
|
1254
1298
|
else {
|
|
1255
1299
|
// Direct aggregate values
|
|
@@ -1258,10 +1302,10 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
|
|
|
1258
1302
|
if (isInverted) {
|
|
1259
1303
|
indexAggregateValues(row, aggregates, baseNestedValues, // These are actually the row values (empty when no nesting)
|
|
1260
1304
|
currentOuterValues, // These are actually the col values
|
|
1261
|
-
cellIndex);
|
|
1305
|
+
cellIndex, queryId);
|
|
1262
1306
|
}
|
|
1263
1307
|
else {
|
|
1264
|
-
indexAggregateValues(row, aggregates, currentOuterValues, baseNestedValues, cellIndex);
|
|
1308
|
+
indexAggregateValues(row, aggregates, currentOuterValues, baseNestedValues, cellIndex, queryId);
|
|
1265
1309
|
}
|
|
1266
1310
|
}
|
|
1267
1311
|
}
|
|
@@ -1275,7 +1319,7 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
|
|
|
1275
1319
|
* @param nestNameSuffix Suffix for nest names (e.g., '_1' for merged query variants).
|
|
1276
1320
|
* Only applied to the outermost nest (nestedDepth === 0) to match Malloy query structure.
|
|
1277
1321
|
*/
|
|
1278
|
-
function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNestedValues, cellIndex, nestedDepth, isInverted = false, nestNameSuffix = "") {
|
|
1322
|
+
function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNestedValues, cellIndex, nestedDepth, isInverted = false, nestNameSuffix = "", queryId) {
|
|
1279
1323
|
const currentDim = nestedGroupings[nestedDepth];
|
|
1280
1324
|
const remainingDims = nestedGroupings.slice(nestedDepth);
|
|
1281
1325
|
// First try the single dimension key
|
|
@@ -1313,10 +1357,10 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
1313
1357
|
if (isInverted) {
|
|
1314
1358
|
indexAggregateValues(nestedRow, aggregates, currentNestedValues, // These are actually row values
|
|
1315
1359
|
outerValues, // These are actually col values
|
|
1316
|
-
cellIndex);
|
|
1360
|
+
cellIndex, queryId);
|
|
1317
1361
|
}
|
|
1318
1362
|
else {
|
|
1319
|
-
indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex);
|
|
1363
|
+
indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex, queryId);
|
|
1320
1364
|
}
|
|
1321
1365
|
}
|
|
1322
1366
|
return;
|
|
@@ -1342,7 +1386,7 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
1342
1386
|
}
|
|
1343
1387
|
if (nestedDepth + 1 < nestedGroupings.length) {
|
|
1344
1388
|
// More nesting
|
|
1345
|
-
indexColumnPivots(nestedRow, nestedGroupings, aggregates, outerValues, currentNestedValues, cellIndex, nestedDepth + 1, isInverted, nestNameSuffix);
|
|
1389
|
+
indexColumnPivots(nestedRow, nestedGroupings, aggregates, outerValues, currentNestedValues, cellIndex, nestedDepth + 1, isInverted, nestNameSuffix, queryId);
|
|
1346
1390
|
}
|
|
1347
1391
|
else {
|
|
1348
1392
|
// Leaf - index aggregate values
|
|
@@ -1351,10 +1395,10 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
1351
1395
|
if (isInverted) {
|
|
1352
1396
|
indexAggregateValues(nestedRow, aggregates, currentNestedValues, // These are actually row values
|
|
1353
1397
|
outerValues, // These are actually col values
|
|
1354
|
-
cellIndex);
|
|
1398
|
+
cellIndex, queryId);
|
|
1355
1399
|
}
|
|
1356
1400
|
else {
|
|
1357
|
-
indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex);
|
|
1401
|
+
indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex, queryId);
|
|
1358
1402
|
}
|
|
1359
1403
|
}
|
|
1360
1404
|
}
|
|
@@ -1362,7 +1406,7 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
1362
1406
|
/**
|
|
1363
1407
|
* Index aggregate values at a specific row/col value combination.
|
|
1364
1408
|
*/
|
|
1365
|
-
function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex) {
|
|
1409
|
+
function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex, queryId) {
|
|
1366
1410
|
const cellKey = makeCellKey(rowValues, colValues);
|
|
1367
1411
|
let cellData = cellIndex.get(cellKey);
|
|
1368
1412
|
if (!cellData) {
|
|
@@ -1372,7 +1416,10 @@ function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex)
|
|
|
1372
1416
|
for (const agg of aggregates) {
|
|
1373
1417
|
const value = row[agg.name];
|
|
1374
1418
|
if (value !== undefined) {
|
|
1375
|
-
cellData.set(agg.name,
|
|
1419
|
+
cellData.set(agg.name, {
|
|
1420
|
+
value: typeof value === "number" ? value : null,
|
|
1421
|
+
queryId,
|
|
1422
|
+
});
|
|
1376
1423
|
}
|
|
1377
1424
|
}
|
|
1378
1425
|
}
|
|
@@ -110,16 +110,33 @@ function buildNestedPivot(colGroupings, aggregates, indent) {
|
|
|
110
110
|
function buildAggregates(aggregates, indent = 2) {
|
|
111
111
|
const pad = ' '.repeat(indent);
|
|
112
112
|
const aggs = aggregates.map(a => {
|
|
113
|
-
return `${a.name} is ${a.measure
|
|
113
|
+
return `${a.name} is ${buildAggExpr(a.measure, a.aggregation)}`;
|
|
114
114
|
});
|
|
115
115
|
return `${pad}aggregate: ${aggs.join(', ')}`;
|
|
116
116
|
}
|
|
117
|
+
/** Build a Malloy aggregate expression (mirrors multi-query-utils buildAggExpression) */
|
|
118
|
+
function buildAggExpr(measure, aggregation) {
|
|
119
|
+
if (aggregation === 'count') {
|
|
120
|
+
if (measure && measure !== '__pending__' && measure !== '') {
|
|
121
|
+
return `count(${escapeForMalloy(measure)})`;
|
|
122
|
+
}
|
|
123
|
+
return 'count()';
|
|
124
|
+
}
|
|
125
|
+
if (!measure || measure === '__pending__')
|
|
126
|
+
return 'count()';
|
|
127
|
+
const methodMap = { mean: 'avg', stdev: 'stddev', pct: 'sum', pctn: 'count', pctsum: 'sum' };
|
|
128
|
+
return `${measure}.${methodMap[aggregation] ?? aggregation}()`;
|
|
129
|
+
}
|
|
130
|
+
const MALLOY_RESERVED = new Set(['all', 'and', 'as', 'asc', 'avg', 'by', 'case', 'cast', 'count', 'day', 'desc', 'dimension', 'else', 'end', 'exclude', 'extend', 'false', 'from', 'group', 'having', 'hour', 'import', 'is', 'join', 'limit', 'max', 'measure', 'min', 'minute', 'month', 'nest', 'not', 'now', 'null', 'number', 'on', 'or', 'order', 'pick', 'quarter', 'run', 'second', 'source', 'sum', 'then', 'true', 'week', 'when', 'where', 'year']);
|
|
131
|
+
function escapeForMalloy(name) {
|
|
132
|
+
return MALLOY_RESERVED.has(name.toLowerCase()) ? `\`${name}\`` : name;
|
|
133
|
+
}
|
|
117
134
|
/**
|
|
118
135
|
* Build total aggregate for column totals.
|
|
119
136
|
*/
|
|
120
137
|
function buildTotalAggregate(aggregates, label) {
|
|
121
138
|
const aggs = aggregates.map(a => {
|
|
122
|
-
return `${a.name} is ${a.measure
|
|
139
|
+
return `${a.name} is ${buildAggExpr(a.measure, a.aggregation)}`;
|
|
123
140
|
});
|
|
124
141
|
const nestName = label ? `"${label}"` : '"total"';
|
|
125
142
|
return ` nest: total is { aggregate: ${aggs.join(', ')} }`;
|
|
@@ -16,7 +16,12 @@ export declare function escapeFieldName(name: string): string;
|
|
|
16
16
|
*/
|
|
17
17
|
export declare function escapeWhereExpression(expr: string): string;
|
|
18
18
|
/**
|
|
19
|
-
* Build a Malloy aggregate expression from measure and aggregation
|
|
19
|
+
* Build a Malloy aggregate expression from measure and aggregation.
|
|
20
|
+
*
|
|
21
|
+
* Count semantics:
|
|
22
|
+
* - Standalone `count` or `n` (no measure) → `count()` — counts all rows
|
|
23
|
+
* - Field-bound `user_id.count` (with measure) → `count(user_id)` — counts distinct values
|
|
24
|
+
* This matches Malloy's native semantics where count(field) = COUNT(DISTINCT field).
|
|
20
25
|
*/
|
|
21
26
|
export declare function buildAggExpression(measure: string, aggregation: string): string;
|
|
22
27
|
/**
|
|
@@ -86,16 +86,21 @@ const AGG_METHOD_MAP = {
|
|
|
86
86
|
pctsum: 'sum',
|
|
87
87
|
};
|
|
88
88
|
/**
|
|
89
|
-
* Build a Malloy aggregate expression from measure and aggregation
|
|
89
|
+
* Build a Malloy aggregate expression from measure and aggregation.
|
|
90
|
+
*
|
|
91
|
+
* Count semantics:
|
|
92
|
+
* - Standalone `count` or `n` (no measure) → `count()` — counts all rows
|
|
93
|
+
* - Field-bound `user_id.count` (with measure) → `count(user_id)` — counts distinct values
|
|
94
|
+
* This matches Malloy's native semantics where count(field) = COUNT(DISTINCT field).
|
|
90
95
|
*/
|
|
91
96
|
export function buildAggExpression(measure, aggregation) {
|
|
92
97
|
const malloyMethod = AGG_METHOD_MAP[aggregation] ?? aggregation;
|
|
93
|
-
// Handle count
|
|
94
|
-
// count() counts all rows, not a specific field
|
|
95
|
-
// When users write "income.count" or "income.n", they semantically mean "count"
|
|
96
|
-
// since you can't "count" a measure - you can only count rows
|
|
98
|
+
// Handle count: standalone = row count, field-bound = distinct count
|
|
97
99
|
if (aggregation === 'count') {
|
|
98
|
-
|
|
100
|
+
if (measure && measure !== '__pending__' && measure !== '') {
|
|
101
|
+
return `count(${escapeFieldName(measure)})`; // distinct count of field
|
|
102
|
+
}
|
|
103
|
+
return 'count()'; // standalone row count
|
|
99
104
|
}
|
|
100
105
|
// Handle other aggregations without a measure (use placeholder)
|
|
101
106
|
// Also handle the __pending__ placeholder used for standalone count
|
|
@@ -399,6 +399,22 @@ export interface CellValue {
|
|
|
399
399
|
readonly aggregate: string;
|
|
400
400
|
/** Path description for tooltips */
|
|
401
401
|
readonly pathDescription: string;
|
|
402
|
+
/**
|
|
403
|
+
* The SQL query that produced this cell's data.
|
|
404
|
+
* Only populated when `trackSQL: true` is set.
|
|
405
|
+
* This is the full SQL for the query — multiple cells may share the same SQL
|
|
406
|
+
* when they come from the same underlying query.
|
|
407
|
+
*/
|
|
408
|
+
readonly sql?: string;
|
|
409
|
+
/**
|
|
410
|
+
* A cell-specific SQL query narrowed with WHERE conditions for this cell's
|
|
411
|
+
* exact dimension values. This would return only the row(s) relevant to
|
|
412
|
+
* this specific cell if executed independently.
|
|
413
|
+
* Only populated when `trackSQL: true` is set.
|
|
414
|
+
* Note: For percentage/ACROSS aggregates, the narrowed SQL may not reproduce
|
|
415
|
+
* the same value because window functions depend on the full result set.
|
|
416
|
+
*/
|
|
417
|
+
readonly cellSQL?: string;
|
|
402
418
|
}
|
|
403
419
|
/**
|
|
404
420
|
* Walk the axis tree and call a visitor for each node.
|
package/dist/executor/index.d.ts
CHANGED
package/dist/executor/index.js
CHANGED
|
@@ -273,7 +273,13 @@ export async function executeMalloy(malloySource, options = {}) {
|
|
|
273
273
|
try {
|
|
274
274
|
// Parse and run the Malloy query
|
|
275
275
|
// Note: Malloy has a default rowLimit of 10 - we use a high limit to get all data
|
|
276
|
-
const
|
|
276
|
+
const materializer = runtime.loadQuery(malloySource);
|
|
277
|
+
// Capture SQL before running if requested
|
|
278
|
+
let sql;
|
|
279
|
+
if (options.captureSQL) {
|
|
280
|
+
sql = await materializer.getSQL();
|
|
281
|
+
}
|
|
282
|
+
const result = await materializer.run({ rowLimit: 100000 });
|
|
277
283
|
// Get the data
|
|
278
284
|
const data = result.data.toObject();
|
|
279
285
|
// Write to file if requested
|
|
@@ -281,6 +287,9 @@ export async function executeMalloy(malloySource, options = {}) {
|
|
|
281
287
|
fs.writeFileSync(options.outputPath, JSON.stringify(data, null, 2), 'utf-8');
|
|
282
288
|
console.log(`Results written to: ${options.outputPath}`);
|
|
283
289
|
}
|
|
290
|
+
if (options.captureSQL) {
|
|
291
|
+
return { data: options.raw ? result : data, sql };
|
|
292
|
+
}
|
|
284
293
|
return options.raw ? result : data;
|
|
285
294
|
}
|
|
286
295
|
catch (error) {
|
package/dist/index.cjs
CHANGED
|
@@ -6536,6 +6536,9 @@ var AGG_METHOD_MAP = {
|
|
|
6536
6536
|
function buildAggExpression(measure, aggregation) {
|
|
6537
6537
|
const malloyMethod = AGG_METHOD_MAP[aggregation] ?? aggregation;
|
|
6538
6538
|
if (aggregation === "count") {
|
|
6539
|
+
if (measure && measure !== "__pending__" && measure !== "") {
|
|
6540
|
+
return `count(${escapeFieldName(measure)})`;
|
|
6541
|
+
}
|
|
6539
6542
|
return "count()";
|
|
6540
6543
|
}
|
|
6541
6544
|
if (!measure || measure === "__pending__") {
|
|
@@ -8426,11 +8429,13 @@ function normalizeQueryResults(results) {
|
|
|
8426
8429
|
function buildGridSpec(spec, plan, results, malloyQueriesOrOptions) {
|
|
8427
8430
|
let malloyQueries;
|
|
8428
8431
|
let orderingProvider;
|
|
8432
|
+
let sqlQueries;
|
|
8429
8433
|
if (Array.isArray(malloyQueriesOrOptions)) {
|
|
8430
8434
|
malloyQueries = malloyQueriesOrOptions;
|
|
8431
8435
|
} else if (malloyQueriesOrOptions) {
|
|
8432
8436
|
malloyQueries = malloyQueriesOrOptions.malloyQueries;
|
|
8433
8437
|
orderingProvider = malloyQueriesOrOptions.orderingProvider;
|
|
8438
|
+
sqlQueries = malloyQueriesOrOptions.sqlQueries;
|
|
8434
8439
|
}
|
|
8435
8440
|
currentOrderingProvider2 = orderingProvider;
|
|
8436
8441
|
results = normalizeQueryResults(results);
|
|
@@ -8486,7 +8491,8 @@ function buildGridSpec(spec, plan, results, malloyQueriesOrOptions) {
|
|
|
8486
8491
|
plan,
|
|
8487
8492
|
results,
|
|
8488
8493
|
invertedQueries,
|
|
8489
|
-
flatQueries
|
|
8494
|
+
flatQueries,
|
|
8495
|
+
sqlQueries
|
|
8490
8496
|
);
|
|
8491
8497
|
const hasRowTotal = axisHasTotal(spec.rowAxis);
|
|
8492
8498
|
const hasColTotal = axisHasTotal(spec.colAxis);
|
|
@@ -8760,7 +8766,7 @@ function formatAggregateName(measure, aggregation) {
|
|
|
8760
8766
|
return aggregation === "count" ? "N" : aggregation;
|
|
8761
8767
|
}
|
|
8762
8768
|
if (aggregation === "count") {
|
|
8763
|
-
return
|
|
8769
|
+
return `${measure} N`;
|
|
8764
8770
|
}
|
|
8765
8771
|
return `${measure} ${aggregation}`;
|
|
8766
8772
|
}
|
|
@@ -9078,7 +9084,31 @@ function makeCellKey(rowValues, colValues) {
|
|
|
9078
9084
|
];
|
|
9079
9085
|
return allEntries.sort((a, b) => a[0].localeCompare(b[0])).map(([k, v]) => `${k}=${v}`).join("|");
|
|
9080
9086
|
}
|
|
9081
|
-
function
|
|
9087
|
+
function buildCellSQL(baseSQL, rowValues, colValues) {
|
|
9088
|
+
const conditions = [];
|
|
9089
|
+
const addCondition = (dim, val) => {
|
|
9090
|
+
if (typeof val === "number") {
|
|
9091
|
+
conditions.push(`${quoteIdentifier(dim)} = ${val}`);
|
|
9092
|
+
} else {
|
|
9093
|
+
conditions.push(`${quoteIdentifier(dim)} = '${val.replace(/'/g, "''")}'`);
|
|
9094
|
+
}
|
|
9095
|
+
};
|
|
9096
|
+
for (const [dim, val] of rowValues) {
|
|
9097
|
+
addCondition(dim, val);
|
|
9098
|
+
}
|
|
9099
|
+
for (const [dim, val] of colValues) {
|
|
9100
|
+
addCondition(dim, val);
|
|
9101
|
+
}
|
|
9102
|
+
if (conditions.length === 0) return baseSQL;
|
|
9103
|
+
return `SELECT * FROM (
|
|
9104
|
+
${baseSQL}
|
|
9105
|
+
) AS _tpl_base
|
|
9106
|
+
WHERE ${conditions.join(" AND ")}`;
|
|
9107
|
+
}
|
|
9108
|
+
function quoteIdentifier(name) {
|
|
9109
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
9110
|
+
}
|
|
9111
|
+
function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = /* @__PURE__ */ new Set(), sqlQueries) {
|
|
9082
9112
|
const cellIndex = /* @__PURE__ */ new Map();
|
|
9083
9113
|
const DEBUG = process.env.DEBUG_GRID === "true";
|
|
9084
9114
|
for (const query of plan.queries) {
|
|
@@ -9127,12 +9157,23 @@ function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = /*
|
|
|
9127
9157
|
pathDescription: cellKey
|
|
9128
9158
|
};
|
|
9129
9159
|
}
|
|
9130
|
-
const
|
|
9160
|
+
const entry = cellData.get(aggName);
|
|
9161
|
+
const value = entry?.value ?? null;
|
|
9162
|
+
let sql;
|
|
9163
|
+
let cellSQL;
|
|
9164
|
+
if (sqlQueries && entry?.queryId) {
|
|
9165
|
+
sql = sqlQueries.get(entry.queryId);
|
|
9166
|
+
if (sql) {
|
|
9167
|
+
cellSQL = buildCellSQL(sql, rowValues, colValues);
|
|
9168
|
+
}
|
|
9169
|
+
}
|
|
9131
9170
|
return {
|
|
9132
9171
|
raw: value,
|
|
9133
9172
|
formatted: formatValue(value, agg),
|
|
9134
9173
|
aggregate: aggName,
|
|
9135
|
-
pathDescription: cellKey
|
|
9174
|
+
pathDescription: cellKey,
|
|
9175
|
+
sql,
|
|
9176
|
+
cellSQL
|
|
9136
9177
|
};
|
|
9137
9178
|
}
|
|
9138
9179
|
};
|
|
@@ -9168,7 +9209,8 @@ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = fals
|
|
|
9168
9209
|
// logical row groupings
|
|
9169
9210
|
query.colGroupings,
|
|
9170
9211
|
// logical col groupings
|
|
9171
|
-
primarySuffix
|
|
9212
|
+
primarySuffix,
|
|
9213
|
+
query.id
|
|
9172
9214
|
);
|
|
9173
9215
|
if (query.additionalColVariants) {
|
|
9174
9216
|
for (const variant of query.additionalColVariants) {
|
|
@@ -9192,7 +9234,8 @@ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = fals
|
|
|
9192
9234
|
isInverted,
|
|
9193
9235
|
query.rowGroupings,
|
|
9194
9236
|
variant.colGroupings,
|
|
9195
|
-
nestNameSuffix
|
|
9237
|
+
nestNameSuffix,
|
|
9238
|
+
query.id
|
|
9196
9239
|
);
|
|
9197
9240
|
}
|
|
9198
9241
|
}
|
|
@@ -9227,10 +9270,10 @@ function indexFlatQueryResults(data, query, cellIndex, aggregates) {
|
|
|
9227
9270
|
colValues.set(g.dimension, value);
|
|
9228
9271
|
}
|
|
9229
9272
|
}
|
|
9230
|
-
indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex);
|
|
9273
|
+
indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex, query.id);
|
|
9231
9274
|
}
|
|
9232
9275
|
}
|
|
9233
|
-
function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggregates, baseOuterValues, baseNestedValues, cellIndex, outerDepth, isInverted = false, logicalRowGroupings, logicalColGroupings, nestNameSuffix = "") {
|
|
9276
|
+
function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggregates, baseOuterValues, baseNestedValues, cellIndex, outerDepth, isInverted = false, logicalRowGroupings, logicalColGroupings, nestNameSuffix = "", queryId) {
|
|
9234
9277
|
for (const row of data) {
|
|
9235
9278
|
const currentOuterValues = new Map(baseOuterValues);
|
|
9236
9279
|
for (let i = outerDepth; i < malloyOuterGroupings.length; i++) {
|
|
@@ -9261,7 +9304,8 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
|
|
|
9261
9304
|
isInverted,
|
|
9262
9305
|
logicalRowGroupings,
|
|
9263
9306
|
logicalColGroupings,
|
|
9264
|
-
nestNameSuffix
|
|
9307
|
+
nestNameSuffix,
|
|
9308
|
+
queryId
|
|
9265
9309
|
);
|
|
9266
9310
|
break;
|
|
9267
9311
|
}
|
|
@@ -9283,7 +9327,8 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
|
|
|
9283
9327
|
cellIndex,
|
|
9284
9328
|
0,
|
|
9285
9329
|
isInverted,
|
|
9286
|
-
nestNameSuffix
|
|
9330
|
+
nestNameSuffix,
|
|
9331
|
+
queryId
|
|
9287
9332
|
);
|
|
9288
9333
|
} else {
|
|
9289
9334
|
if (isInverted) {
|
|
@@ -9294,7 +9339,8 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
|
|
|
9294
9339
|
// These are actually the row values (empty when no nesting)
|
|
9295
9340
|
currentOuterValues,
|
|
9296
9341
|
// These are actually the col values
|
|
9297
|
-
cellIndex
|
|
9342
|
+
cellIndex,
|
|
9343
|
+
queryId
|
|
9298
9344
|
);
|
|
9299
9345
|
} else {
|
|
9300
9346
|
indexAggregateValues(
|
|
@@ -9302,13 +9348,14 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
|
|
|
9302
9348
|
aggregates,
|
|
9303
9349
|
currentOuterValues,
|
|
9304
9350
|
baseNestedValues,
|
|
9305
|
-
cellIndex
|
|
9351
|
+
cellIndex,
|
|
9352
|
+
queryId
|
|
9306
9353
|
);
|
|
9307
9354
|
}
|
|
9308
9355
|
}
|
|
9309
9356
|
}
|
|
9310
9357
|
}
|
|
9311
|
-
function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNestedValues, cellIndex, nestedDepth, isInverted = false, nestNameSuffix = "") {
|
|
9358
|
+
function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNestedValues, cellIndex, nestedDepth, isInverted = false, nestNameSuffix = "", queryId) {
|
|
9312
9359
|
const currentDim = nestedGroupings[nestedDepth];
|
|
9313
9360
|
const remainingDims = nestedGroupings.slice(nestedDepth);
|
|
9314
9361
|
let nestedKey = nestedDepth === 0 ? `by_${currentDim.dimension}${nestNameSuffix}` : `by_${currentDim.dimension}`;
|
|
@@ -9340,7 +9387,8 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
9340
9387
|
// These are actually row values
|
|
9341
9388
|
outerValues,
|
|
9342
9389
|
// These are actually col values
|
|
9343
|
-
cellIndex
|
|
9390
|
+
cellIndex,
|
|
9391
|
+
queryId
|
|
9344
9392
|
);
|
|
9345
9393
|
} else {
|
|
9346
9394
|
indexAggregateValues(
|
|
@@ -9348,7 +9396,8 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
9348
9396
|
aggregates,
|
|
9349
9397
|
outerValues,
|
|
9350
9398
|
currentNestedValues,
|
|
9351
|
-
cellIndex
|
|
9399
|
+
cellIndex,
|
|
9400
|
+
queryId
|
|
9352
9401
|
);
|
|
9353
9402
|
}
|
|
9354
9403
|
}
|
|
@@ -9379,7 +9428,8 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
9379
9428
|
cellIndex,
|
|
9380
9429
|
nestedDepth + 1,
|
|
9381
9430
|
isInverted,
|
|
9382
|
-
nestNameSuffix
|
|
9431
|
+
nestNameSuffix,
|
|
9432
|
+
queryId
|
|
9383
9433
|
);
|
|
9384
9434
|
} else {
|
|
9385
9435
|
if (isInverted) {
|
|
@@ -9390,7 +9440,8 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
9390
9440
|
// These are actually row values
|
|
9391
9441
|
outerValues,
|
|
9392
9442
|
// These are actually col values
|
|
9393
|
-
cellIndex
|
|
9443
|
+
cellIndex,
|
|
9444
|
+
queryId
|
|
9394
9445
|
);
|
|
9395
9446
|
} else {
|
|
9396
9447
|
indexAggregateValues(
|
|
@@ -9398,13 +9449,14 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
|
|
|
9398
9449
|
aggregates,
|
|
9399
9450
|
outerValues,
|
|
9400
9451
|
currentNestedValues,
|
|
9401
|
-
cellIndex
|
|
9452
|
+
cellIndex,
|
|
9453
|
+
queryId
|
|
9402
9454
|
);
|
|
9403
9455
|
}
|
|
9404
9456
|
}
|
|
9405
9457
|
}
|
|
9406
9458
|
}
|
|
9407
|
-
function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex) {
|
|
9459
|
+
function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex, queryId) {
|
|
9408
9460
|
const cellKey = makeCellKey(rowValues, colValues);
|
|
9409
9461
|
let cellData = cellIndex.get(cellKey);
|
|
9410
9462
|
if (!cellData) {
|
|
@@ -9414,7 +9466,10 @@ function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex)
|
|
|
9414
9466
|
for (const agg of aggregates) {
|
|
9415
9467
|
const value = row[agg.name];
|
|
9416
9468
|
if (value !== void 0) {
|
|
9417
|
-
cellData.set(agg.name,
|
|
9469
|
+
cellData.set(agg.name, {
|
|
9470
|
+
value: typeof value === "number" ? value : null,
|
|
9471
|
+
queryId
|
|
9472
|
+
});
|
|
9418
9473
|
}
|
|
9419
9474
|
}
|
|
9420
9475
|
}
|
|
@@ -10206,6 +10261,17 @@ ${ordinalPick}`);
|
|
|
10206
10261
|
}
|
|
10207
10262
|
|
|
10208
10263
|
// packages/renderer/grid-renderer.ts
|
|
10264
|
+
function formatAggDisplayName(agg) {
|
|
10265
|
+
if (agg.label) return agg.label;
|
|
10266
|
+
if (agg.aggregation === "count") {
|
|
10267
|
+
if (agg.measure && agg.measure !== "__pending__" && agg.measure !== "") {
|
|
10268
|
+
return `${agg.measure} N`;
|
|
10269
|
+
}
|
|
10270
|
+
return "N";
|
|
10271
|
+
}
|
|
10272
|
+
if (!agg.measure || agg.measure === "__pending__") return agg.aggregation;
|
|
10273
|
+
return `${agg.measure} ${agg.aggregation}`;
|
|
10274
|
+
}
|
|
10209
10275
|
function renderGridToHTML(grid, options = {}) {
|
|
10210
10276
|
const {
|
|
10211
10277
|
tableClass = "tpl-table",
|
|
@@ -10288,7 +10354,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
|
10288
10354
|
lines.push('<th class="tpl-corner"></th>');
|
|
10289
10355
|
}
|
|
10290
10356
|
}
|
|
10291
|
-
lines.push(`<th>${escapeHTML(grid.aggregates[0]
|
|
10357
|
+
lines.push(`<th>${escapeHTML(grid.aggregates[0] ? formatAggDisplayName(grid.aggregates[0]) : "Value")}</th>`);
|
|
10292
10358
|
lines.push("</tr>");
|
|
10293
10359
|
lines.push("</thead>");
|
|
10294
10360
|
return;
|
|
@@ -10320,7 +10386,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
|
10320
10386
|
lines.push("<th>Value</th>");
|
|
10321
10387
|
} else {
|
|
10322
10388
|
for (const agg of grid.aggregates) {
|
|
10323
|
-
lines.push(`<th>${escapeHTML(agg
|
|
10389
|
+
lines.push(`<th>${escapeHTML(formatAggDisplayName(agg))}</th>`);
|
|
10324
10390
|
}
|
|
10325
10391
|
}
|
|
10326
10392
|
lines.push("</tr>");
|
|
@@ -10537,23 +10603,13 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
|
|
|
10537
10603
|
if (aggInfo?.label) {
|
|
10538
10604
|
displayAgg = aggInfo.label;
|
|
10539
10605
|
} else if (aggInfo) {
|
|
10540
|
-
|
|
10541
|
-
displayAgg = "count";
|
|
10542
|
-
} else {
|
|
10543
|
-
displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
|
|
10544
|
-
}
|
|
10606
|
+
displayAgg = formatAggDisplayName(aggInfo);
|
|
10545
10607
|
} else {
|
|
10546
10608
|
displayAgg = aggregateName.replace(/^__pending___/, "").replace(/_/g, ".");
|
|
10547
10609
|
}
|
|
10548
10610
|
} else if (aggregates.length === 1) {
|
|
10549
10611
|
const aggInfo = aggregates[0];
|
|
10550
|
-
|
|
10551
|
-
displayAgg = aggInfo.label;
|
|
10552
|
-
} else if (aggInfo.aggregation === "count") {
|
|
10553
|
-
displayAgg = "count";
|
|
10554
|
-
} else {
|
|
10555
|
-
displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
|
|
10556
|
-
}
|
|
10612
|
+
displayAgg = formatAggDisplayName(aggInfo);
|
|
10557
10613
|
}
|
|
10558
10614
|
if (displayAgg) {
|
|
10559
10615
|
return dimPath ? `${dimPath} \u2192 ${displayAgg}` : displayAgg;
|
|
@@ -10578,7 +10634,9 @@ function renderDataCell(grid, rowValues, colValues, rowLeaf, colLeaf, rowRoots,
|
|
|
10578
10634
|
const humanPath = buildHumanPath(rowValues, colValues, aggregateName, grid.aggregates);
|
|
10579
10635
|
const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : "";
|
|
10580
10636
|
const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : "";
|
|
10581
|
-
|
|
10637
|
+
const sqlAttr = cell.sql ? ` data-sql="${escapeHTML(cell.sql)}"` : "";
|
|
10638
|
+
const cellSQLAttr = cell.cellSQL ? ` data-cell-sql="${escapeHTML(cell.cellSQL)}"` : "";
|
|
10639
|
+
lines.push(`<td${classAttr}${titleAttr}${dataAttr}${sqlAttr}${cellSQLAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
10582
10640
|
}
|
|
10583
10641
|
function renderDataCells(grid, rowValues, rowLeaf, lines) {
|
|
10584
10642
|
const colValues = /* @__PURE__ */ new Map();
|
|
@@ -10590,14 +10648,18 @@ function renderDataCells(grid, rowValues, rowLeaf, lines) {
|
|
|
10590
10648
|
const humanPath = buildHumanPath(rowValues, colValues, rowAgg, grid.aggregates);
|
|
10591
10649
|
const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : "";
|
|
10592
10650
|
const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : "";
|
|
10593
|
-
|
|
10651
|
+
const sqlAttr = cell.sql ? ` data-sql="${escapeHTML(cell.sql)}"` : "";
|
|
10652
|
+
const cellSQLAttr = cell.cellSQL ? ` data-cell-sql="${escapeHTML(cell.cellSQL)}"` : "";
|
|
10653
|
+
lines.push(`<td${classAttr}${titleAttr}${dataAttr}${sqlAttr}${cellSQLAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
10594
10654
|
} else {
|
|
10595
10655
|
for (const agg of grid.aggregates) {
|
|
10596
10656
|
const cell = grid.getCell(rowValues, colValues, agg.name);
|
|
10597
10657
|
const humanPath = buildHumanPath(rowValues, colValues, agg.name, grid.aggregates);
|
|
10598
10658
|
const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : "";
|
|
10599
10659
|
const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : "";
|
|
10600
|
-
|
|
10660
|
+
const sqlAttr = cell.sql ? ` data-sql="${escapeHTML(cell.sql)}"` : "";
|
|
10661
|
+
const cellSQLAttr = cell.cellSQL ? ` data-cell-sql="${escapeHTML(cell.cellSQL)}"` : "";
|
|
10662
|
+
lines.push(`<td${classAttr}${titleAttr}${dataAttr}${sqlAttr}${cellSQLAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
10601
10663
|
}
|
|
10602
10664
|
}
|
|
10603
10665
|
}
|
|
@@ -10765,7 +10827,12 @@ async function executeMalloy(malloySource, options = {}) {
|
|
|
10765
10827
|
connection: conn
|
|
10766
10828
|
});
|
|
10767
10829
|
try {
|
|
10768
|
-
const
|
|
10830
|
+
const materializer = runtime.loadQuery(malloySource);
|
|
10831
|
+
let sql;
|
|
10832
|
+
if (options.captureSQL) {
|
|
10833
|
+
sql = await materializer.getSQL();
|
|
10834
|
+
}
|
|
10835
|
+
const result = await materializer.run({ rowLimit: 1e5 });
|
|
10769
10836
|
const data = result.data.toObject();
|
|
10770
10837
|
if (options.outputPath) {
|
|
10771
10838
|
fs.writeFileSync(
|
|
@@ -10775,6 +10842,9 @@ async function executeMalloy(malloySource, options = {}) {
|
|
|
10775
10842
|
);
|
|
10776
10843
|
console.log(`Results written to: ${options.outputPath}`);
|
|
10777
10844
|
}
|
|
10845
|
+
if (options.captureSQL) {
|
|
10846
|
+
return { data: options.raw ? result : data, sql };
|
|
10847
|
+
}
|
|
10778
10848
|
return options.raw ? result : data;
|
|
10779
10849
|
} catch (error) {
|
|
10780
10850
|
console.error("Malloy execution error:", error);
|
|
@@ -10823,18 +10893,28 @@ var TPL = class {
|
|
|
10823
10893
|
orderingProvider: options.orderingProvider
|
|
10824
10894
|
});
|
|
10825
10895
|
const rawResults = /* @__PURE__ */ new Map();
|
|
10896
|
+
const sqlQueries = this.options.trackSQL ? /* @__PURE__ */ new Map() : void 0;
|
|
10826
10897
|
for (const queryInfo of queries) {
|
|
10827
10898
|
const fullMalloy = `${effectiveModel}
|
|
10828
10899
|
${queryInfo.malloy}`;
|
|
10829
|
-
|
|
10830
|
-
|
|
10900
|
+
if (this.options.trackSQL) {
|
|
10901
|
+
const result = await executeMalloy(fullMalloy, { captureSQL: true });
|
|
10902
|
+
rawResults.set(queryInfo.id, result.data);
|
|
10903
|
+
if (result.sql) {
|
|
10904
|
+
sqlQueries.set(queryInfo.id, result.sql);
|
|
10905
|
+
}
|
|
10906
|
+
} else {
|
|
10907
|
+
const data = await executeMalloy(fullMalloy);
|
|
10908
|
+
rawResults.set(queryInfo.id, data);
|
|
10909
|
+
}
|
|
10831
10910
|
}
|
|
10832
10911
|
const grid = buildGridSpec(spec, plan, rawResults, {
|
|
10833
10912
|
malloyQueries: queries,
|
|
10834
|
-
orderingProvider: options.orderingProvider
|
|
10913
|
+
orderingProvider: options.orderingProvider,
|
|
10914
|
+
sqlQueries
|
|
10835
10915
|
});
|
|
10836
10916
|
const html = renderGridToHTML(grid);
|
|
10837
|
-
return { html, grid, malloy, rawResults };
|
|
10917
|
+
return { html, grid, malloy, rawResults, sqlQueries };
|
|
10838
10918
|
}
|
|
10839
10919
|
};
|
|
10840
10920
|
function createTPL(options = {}) {
|
package/dist/index.d.ts
CHANGED
|
@@ -53,6 +53,13 @@ export interface TPLOptions {
|
|
|
53
53
|
sourceName?: string;
|
|
54
54
|
/** @deprecated Use `sourceName` instead */
|
|
55
55
|
source?: string;
|
|
56
|
+
/**
|
|
57
|
+
* When true, captures the raw SQL generated by Malloy for each query
|
|
58
|
+
* and makes it available on each cell via `CellValue.sql` and `CellValue.cellSQL`.
|
|
59
|
+
* Useful for audit environments where consumers need to verify how each
|
|
60
|
+
* number was produced.
|
|
61
|
+
*/
|
|
62
|
+
trackSQL?: boolean;
|
|
56
63
|
}
|
|
57
64
|
/**
|
|
58
65
|
* Result of compiling TPL to Malloy
|
|
@@ -115,6 +122,13 @@ export interface ExecuteResult {
|
|
|
115
122
|
malloy: string;
|
|
116
123
|
/** raw query results by query ID */
|
|
117
124
|
rawResults: Map<string, any[]>;
|
|
125
|
+
/**
|
|
126
|
+
* Raw SQL queries by query ID.
|
|
127
|
+
* Only populated when `trackSQL: true` is set.
|
|
128
|
+
* Each entry maps a query ID (e.g., 'q0') to the SQL string
|
|
129
|
+
* that was sent to the database.
|
|
130
|
+
*/
|
|
131
|
+
sqlQueries?: Map<string, string>;
|
|
118
132
|
}
|
|
119
133
|
/**
|
|
120
134
|
* High-level TPL API for parsing, compiling, executing, and rendering.
|
package/dist/index.js
CHANGED
|
@@ -86,18 +86,29 @@ export class TPL {
|
|
|
86
86
|
});
|
|
87
87
|
// execute all queries
|
|
88
88
|
const rawResults = new Map();
|
|
89
|
+
const sqlQueries = this.options.trackSQL ? new Map() : undefined;
|
|
89
90
|
for (const queryInfo of queries) {
|
|
90
91
|
const fullMalloy = `${effectiveModel}\n${queryInfo.malloy}`;
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
if (this.options.trackSQL) {
|
|
93
|
+
const result = await executeMalloy(fullMalloy, { captureSQL: true });
|
|
94
|
+
rawResults.set(queryInfo.id, result.data);
|
|
95
|
+
if (result.sql) {
|
|
96
|
+
sqlQueries.set(queryInfo.id, result.sql);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
const data = await executeMalloy(fullMalloy);
|
|
101
|
+
rawResults.set(queryInfo.id, data);
|
|
102
|
+
}
|
|
93
103
|
}
|
|
94
104
|
// build grid and render
|
|
95
105
|
const grid = buildGridSpec(spec, plan, rawResults, {
|
|
96
106
|
malloyQueries: queries,
|
|
97
107
|
orderingProvider: options.orderingProvider,
|
|
108
|
+
sqlQueries,
|
|
98
109
|
});
|
|
99
110
|
const html = renderGridToHTML(grid);
|
|
100
|
-
return { html, grid, malloy, rawResults };
|
|
111
|
+
return { html, grid, malloy, rawResults, sqlQueries };
|
|
101
112
|
}
|
|
102
113
|
}
|
|
103
114
|
/**
|
|
@@ -6,6 +6,25 @@
|
|
|
6
6
|
* Key simplification: GridSpec already has the complete header hierarchy
|
|
7
7
|
* with pre-computed spans, so we don't need to reconstruct structure.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Format an aggregate's display name.
|
|
11
|
+
* - Standalone count/n → "N"
|
|
12
|
+
* - field.count (distinct count) → "field N"
|
|
13
|
+
* - field.sum → "field sum"
|
|
14
|
+
*/
|
|
15
|
+
function formatAggDisplayName(agg) {
|
|
16
|
+
if (agg.label)
|
|
17
|
+
return agg.label;
|
|
18
|
+
if (agg.aggregation === 'count') {
|
|
19
|
+
if (agg.measure && agg.measure !== '__pending__' && agg.measure !== '') {
|
|
20
|
+
return `${agg.measure} N`;
|
|
21
|
+
}
|
|
22
|
+
return 'N';
|
|
23
|
+
}
|
|
24
|
+
if (!agg.measure || agg.measure === '__pending__')
|
|
25
|
+
return agg.aggregation;
|
|
26
|
+
return `${agg.measure} ${agg.aggregation}`;
|
|
27
|
+
}
|
|
9
28
|
/**
|
|
10
29
|
* Render a GridSpec to HTML.
|
|
11
30
|
*/
|
|
@@ -118,7 +137,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
// Single aggregate header
|
|
121
|
-
lines.push(`<th>${escapeHTML(grid.aggregates[0]
|
|
140
|
+
lines.push(`<th>${escapeHTML(grid.aggregates[0] ? formatAggDisplayName(grid.aggregates[0]) : 'Value')}</th>`);
|
|
122
141
|
lines.push('</tr>');
|
|
123
142
|
lines.push('</thead>');
|
|
124
143
|
return;
|
|
@@ -159,7 +178,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
|
159
178
|
else {
|
|
160
179
|
// Aggregates are not on row axis, render each aggregate as a column header
|
|
161
180
|
for (const agg of grid.aggregates) {
|
|
162
|
-
lines.push(`<th>${escapeHTML(agg
|
|
181
|
+
lines.push(`<th>${escapeHTML(formatAggDisplayName(agg))}</th>`);
|
|
163
182
|
}
|
|
164
183
|
}
|
|
165
184
|
lines.push('</tr>');
|
|
@@ -505,13 +524,7 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
|
|
|
505
524
|
displayAgg = aggInfo.label;
|
|
506
525
|
}
|
|
507
526
|
else if (aggInfo) {
|
|
508
|
-
|
|
509
|
-
if (aggInfo.aggregation === 'count') {
|
|
510
|
-
displayAgg = 'count';
|
|
511
|
-
}
|
|
512
|
-
else {
|
|
513
|
-
displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
|
|
514
|
-
}
|
|
527
|
+
displayAgg = formatAggDisplayName(aggInfo);
|
|
515
528
|
}
|
|
516
529
|
else {
|
|
517
530
|
// Fallback: clean up the internal name
|
|
@@ -523,15 +536,7 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
|
|
|
523
536
|
else if (aggregates.length === 1) {
|
|
524
537
|
// Single aggregate - still show it in tooltip
|
|
525
538
|
const aggInfo = aggregates[0];
|
|
526
|
-
|
|
527
|
-
displayAgg = aggInfo.label;
|
|
528
|
-
}
|
|
529
|
-
else if (aggInfo.aggregation === 'count') {
|
|
530
|
-
displayAgg = 'count';
|
|
531
|
-
}
|
|
532
|
-
else {
|
|
533
|
-
displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
|
|
534
|
-
}
|
|
539
|
+
displayAgg = formatAggDisplayName(aggInfo);
|
|
535
540
|
}
|
|
536
541
|
if (displayAgg) {
|
|
537
542
|
return dimPath ? `${dimPath} → ${displayAgg}` : displayAgg;
|
|
@@ -564,7 +569,9 @@ function renderDataCell(grid, rowValues, colValues, rowLeaf, colLeaf, rowRoots,
|
|
|
564
569
|
const humanPath = buildHumanPath(rowValues, colValues, aggregateName, grid.aggregates);
|
|
565
570
|
const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : '';
|
|
566
571
|
const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : '';
|
|
567
|
-
|
|
572
|
+
const sqlAttr = cell.sql ? ` data-sql="${escapeHTML(cell.sql)}"` : '';
|
|
573
|
+
const cellSQLAttr = cell.cellSQL ? ` data-cell-sql="${escapeHTML(cell.cellSQL)}"` : '';
|
|
574
|
+
lines.push(`<td${classAttr}${titleAttr}${dataAttr}${sqlAttr}${cellSQLAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
568
575
|
}
|
|
569
576
|
/**
|
|
570
577
|
* Render data cells when there are no column pivots.
|
|
@@ -582,7 +589,9 @@ function renderDataCells(grid, rowValues, rowLeaf, lines) {
|
|
|
582
589
|
const humanPath = buildHumanPath(rowValues, colValues, rowAgg, grid.aggregates);
|
|
583
590
|
const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : '';
|
|
584
591
|
const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : '';
|
|
585
|
-
|
|
592
|
+
const sqlAttr = cell.sql ? ` data-sql="${escapeHTML(cell.sql)}"` : '';
|
|
593
|
+
const cellSQLAttr = cell.cellSQL ? ` data-cell-sql="${escapeHTML(cell.cellSQL)}"` : '';
|
|
594
|
+
lines.push(`<td${classAttr}${titleAttr}${dataAttr}${sqlAttr}${cellSQLAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
586
595
|
}
|
|
587
596
|
else {
|
|
588
597
|
// Multiple aggregate cells
|
|
@@ -591,7 +600,9 @@ function renderDataCells(grid, rowValues, rowLeaf, lines) {
|
|
|
591
600
|
const humanPath = buildHumanPath(rowValues, colValues, agg.name, grid.aggregates);
|
|
592
601
|
const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : '';
|
|
593
602
|
const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : '';
|
|
594
|
-
|
|
603
|
+
const sqlAttr = cell.sql ? ` data-sql="${escapeHTML(cell.sql)}"` : '';
|
|
604
|
+
const cellSQLAttr = cell.cellSQL ? ` data-cell-sql="${escapeHTML(cell.cellSQL)}"` : '';
|
|
605
|
+
lines.push(`<td${classAttr}${titleAttr}${dataAttr}${sqlAttr}${cellSQLAttr}>${escapeHTML(cell.formatted)}</td>`);
|
|
595
606
|
}
|
|
596
607
|
}
|
|
597
608
|
}
|