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.
@@ -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 the aggregation name
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 with a measure (e.g., income.count), just return "N" since
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 "N";
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 value = cellData.get(aggName) ?? null;
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, typeof value === "number" ? value : null);
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}.${a.aggregation}()`;
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}.${a.aggregation}()`;
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 - in Malloy, count() doesn't take a measure argument
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
- return 'count()';
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.
@@ -76,6 +76,8 @@ export interface ExecuteOptions {
76
76
  outputPath?: string;
77
77
  /** Return raw Malloy result object */
78
78
  raw?: boolean;
79
+ /** When true, also capture the generated SQL string */
80
+ captureSQL?: boolean;
79
81
  }
80
82
  /**
81
83
  * Execute a Malloy query string
@@ -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 result = await runtime.loadQuery(malloySource).run({ rowLimit: 100000 });
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 "N";
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 buildCellLookup(spec, plan, results, invertedQueries, flatQueries = /* @__PURE__ */ new Set()) {
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 value = cellData.get(aggName) ?? null;
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, typeof value === "number" ? value : null);
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]?.label ?? grid.aggregates[0]?.name ?? "Value")}</th>`);
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.label ?? agg.name)}</th>`);
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
- if (aggInfo.aggregation === "count") {
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
- if (aggInfo.label) {
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
- lines.push(`<td${classAttr}${titleAttr}${dataAttr}>${escapeHTML(cell.formatted)}</td>`);
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
- lines.push(`<td${classAttr}${titleAttr}${dataAttr}>${escapeHTML(cell.formatted)}</td>`);
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
- lines.push(`<td${classAttr}${titleAttr}${dataAttr}>${escapeHTML(cell.formatted)}</td>`);
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 result = await runtime.loadQuery(malloySource).run({ rowLimit: 1e5 });
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
- const data = await executeMalloy(fullMalloy);
10830
- rawResults.set(queryInfo.id, data);
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
- const data = await executeMalloy(fullMalloy);
92
- rawResults.set(queryInfo.id, data);
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]?.label ?? grid.aggregates[0]?.name ?? 'Value')}</th>`);
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.label ?? agg.name)}</th>`);
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
- // Build display from measure + aggregation
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
- if (aggInfo.label) {
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
- lines.push(`<td${classAttr}${titleAttr}${dataAttr}>${escapeHTML(cell.formatted)}</td>`);
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
- lines.push(`<td${classAttr}${titleAttr}${dataAttr}>${escapeHTML(cell.formatted)}</td>`);
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
- lines.push(`<td${classAttr}${titleAttr}${dataAttr}>${escapeHTML(cell.formatted)}</td>`);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tplm-lang",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "TPLm - Table Producing Language backed by Malloy.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",