tplm-lang 0.3.6 → 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);
@@ -1023,6 +1025,37 @@ function makeCellKey(rowValues, colValues) {
1023
1025
  .map(([k, v]) => `${k}=${v}`)
1024
1026
  .join("|");
1025
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
+ }
1026
1059
  /**
1027
1060
  * Build a cell lookup function from query results.
1028
1061
  *
@@ -1032,9 +1065,10 @@ function makeCellKey(rowValues, colValues) {
1032
1065
  *
1033
1066
  * @param invertedQueries Set of query IDs that have inverted axes (column dim is outer in Malloy)
1034
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
1035
1069
  */
1036
- function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = new Set()) {
1037
- // 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)
1038
1072
  const cellIndex = new Map();
1039
1073
  // Index all query results by plan query ID
1040
1074
  // Since we now use a unified QueryPlan system, IDs should match directly
@@ -1078,12 +1112,24 @@ function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = new
1078
1112
  pathDescription: cellKey,
1079
1113
  };
1080
1114
  }
1081
- 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
+ }
1082
1126
  return {
1083
1127
  raw: value,
1084
1128
  formatted: formatValue(value, agg),
1085
1129
  aggregate: aggName,
1086
1130
  pathDescription: cellKey,
1131
+ sql,
1132
+ cellSQL,
1087
1133
  };
1088
1134
  },
1089
1135
  };
@@ -1129,7 +1175,7 @@ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = fals
1129
1175
  new Map(), // values for malloy nested (will be mapped to correct axis)
1130
1176
  cellIndex, 0, isInverted, query.rowGroupings, // logical row groupings
1131
1177
  query.colGroupings, // logical col groupings
1132
- primarySuffix);
1178
+ primarySuffix, query.id);
1133
1179
  // Handle merged queries with additional column variants
1134
1180
  // Each variant has its own nests (e.g., by_gender vs by_sector_label)
1135
1181
  // that need to be indexed separately
@@ -1147,7 +1193,7 @@ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = fals
1147
1193
  seenFirstDims.set(firstDim, count + 1);
1148
1194
  nestNameSuffix = count > 0 ? `_${count}` : "";
1149
1195
  }
1150
- 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);
1151
1197
  }
1152
1198
  }
1153
1199
  }
@@ -1192,7 +1238,7 @@ function indexFlatQueryResults(data, query, cellIndex, aggregates) {
1192
1238
  }
1193
1239
  }
1194
1240
  // Index aggregate values using the combined row/col values
1195
- indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex);
1241
+ indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex, query.id);
1196
1242
  }
1197
1243
  }
1198
1244
  /**
@@ -1205,7 +1251,7 @@ function indexFlatQueryResults(data, query, cellIndex, aggregates) {
1205
1251
  * @param logicalColGroupings The actual col groupings for cell key building
1206
1252
  * @param nestNameSuffix Suffix for nest names (e.g., '_1' for merged query variants)
1207
1253
  */
1208
- 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) {
1209
1255
  for (const row of data) {
1210
1256
  // Build outer values - collect ALL outer dimension values from the current row
1211
1257
  // This handles the case where multiple dimensions are in the same group_by
@@ -1234,7 +1280,7 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
1234
1280
  const nestedKey = `by_${dim}${nestNameSuffix}`;
1235
1281
  if (row[nestedKey] && Array.isArray(row[nestedKey])) {
1236
1282
  // This dimension is nested - recurse
1237
- 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);
1238
1284
  break; // Don't continue with this row, we recursed
1239
1285
  }
1240
1286
  }
@@ -1247,7 +1293,7 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
1247
1293
  continue; // Skipped this row because we recursed into nested data
1248
1294
  }
1249
1295
  if (malloyNestedGroupings.length > 0) {
1250
- indexColumnPivots(row, malloyNestedGroupings, aggregates, currentOuterValues, new Map(), cellIndex, 0, isInverted, nestNameSuffix);
1296
+ indexColumnPivots(row, malloyNestedGroupings, aggregates, currentOuterValues, new Map(), cellIndex, 0, isInverted, nestNameSuffix, queryId);
1251
1297
  }
1252
1298
  else {
1253
1299
  // Direct aggregate values
@@ -1256,10 +1302,10 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
1256
1302
  if (isInverted) {
1257
1303
  indexAggregateValues(row, aggregates, baseNestedValues, // These are actually the row values (empty when no nesting)
1258
1304
  currentOuterValues, // These are actually the col values
1259
- cellIndex);
1305
+ cellIndex, queryId);
1260
1306
  }
1261
1307
  else {
1262
- indexAggregateValues(row, aggregates, currentOuterValues, baseNestedValues, cellIndex);
1308
+ indexAggregateValues(row, aggregates, currentOuterValues, baseNestedValues, cellIndex, queryId);
1263
1309
  }
1264
1310
  }
1265
1311
  }
@@ -1273,7 +1319,7 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
1273
1319
  * @param nestNameSuffix Suffix for nest names (e.g., '_1' for merged query variants).
1274
1320
  * Only applied to the outermost nest (nestedDepth === 0) to match Malloy query structure.
1275
1321
  */
1276
- 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) {
1277
1323
  const currentDim = nestedGroupings[nestedDepth];
1278
1324
  const remainingDims = nestedGroupings.slice(nestedDepth);
1279
1325
  // First try the single dimension key
@@ -1311,10 +1357,10 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
1311
1357
  if (isInverted) {
1312
1358
  indexAggregateValues(nestedRow, aggregates, currentNestedValues, // These are actually row values
1313
1359
  outerValues, // These are actually col values
1314
- cellIndex);
1360
+ cellIndex, queryId);
1315
1361
  }
1316
1362
  else {
1317
- indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex);
1363
+ indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex, queryId);
1318
1364
  }
1319
1365
  }
1320
1366
  return;
@@ -1340,7 +1386,7 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
1340
1386
  }
1341
1387
  if (nestedDepth + 1 < nestedGroupings.length) {
1342
1388
  // More nesting
1343
- indexColumnPivots(nestedRow, nestedGroupings, aggregates, outerValues, currentNestedValues, cellIndex, nestedDepth + 1, isInverted, nestNameSuffix);
1389
+ indexColumnPivots(nestedRow, nestedGroupings, aggregates, outerValues, currentNestedValues, cellIndex, nestedDepth + 1, isInverted, nestNameSuffix, queryId);
1344
1390
  }
1345
1391
  else {
1346
1392
  // Leaf - index aggregate values
@@ -1349,10 +1395,10 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
1349
1395
  if (isInverted) {
1350
1396
  indexAggregateValues(nestedRow, aggregates, currentNestedValues, // These are actually row values
1351
1397
  outerValues, // These are actually col values
1352
- cellIndex);
1398
+ cellIndex, queryId);
1353
1399
  }
1354
1400
  else {
1355
- indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex);
1401
+ indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex, queryId);
1356
1402
  }
1357
1403
  }
1358
1404
  }
@@ -1360,7 +1406,7 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
1360
1406
  /**
1361
1407
  * Index aggregate values at a specific row/col value combination.
1362
1408
  */
1363
- function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex) {
1409
+ function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex, queryId) {
1364
1410
  const cellKey = makeCellKey(rowValues, colValues);
1365
1411
  let cellData = cellIndex.get(cellKey);
1366
1412
  if (!cellData) {
@@ -1370,7 +1416,10 @@ function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex)
1370
1416
  for (const agg of aggregates) {
1371
1417
  const value = row[agg.name];
1372
1418
  if (value !== undefined) {
1373
- cellData.set(agg.name, typeof value === "number" ? value : null);
1419
+ cellData.set(agg.name, {
1420
+ value: typeof value === "number" ? value : null,
1421
+ queryId,
1422
+ });
1374
1423
  }
1375
1424
  }
1376
1425
  }
@@ -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
@@ -8429,11 +8429,13 @@ function normalizeQueryResults(results) {
8429
8429
  function buildGridSpec(spec, plan, results, malloyQueriesOrOptions) {
8430
8430
  let malloyQueries;
8431
8431
  let orderingProvider;
8432
+ let sqlQueries;
8432
8433
  if (Array.isArray(malloyQueriesOrOptions)) {
8433
8434
  malloyQueries = malloyQueriesOrOptions;
8434
8435
  } else if (malloyQueriesOrOptions) {
8435
8436
  malloyQueries = malloyQueriesOrOptions.malloyQueries;
8436
8437
  orderingProvider = malloyQueriesOrOptions.orderingProvider;
8438
+ sqlQueries = malloyQueriesOrOptions.sqlQueries;
8437
8439
  }
8438
8440
  currentOrderingProvider2 = orderingProvider;
8439
8441
  results = normalizeQueryResults(results);
@@ -8489,7 +8491,8 @@ function buildGridSpec(spec, plan, results, malloyQueriesOrOptions) {
8489
8491
  plan,
8490
8492
  results,
8491
8493
  invertedQueries,
8492
- flatQueries
8494
+ flatQueries,
8495
+ sqlQueries
8493
8496
  );
8494
8497
  const hasRowTotal = axisHasTotal(spec.rowAxis);
8495
8498
  const hasColTotal = axisHasTotal(spec.colAxis);
@@ -9081,7 +9084,31 @@ function makeCellKey(rowValues, colValues) {
9081
9084
  ];
9082
9085
  return allEntries.sort((a, b) => a[0].localeCompare(b[0])).map(([k, v]) => `${k}=${v}`).join("|");
9083
9086
  }
9084
- 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) {
9085
9112
  const cellIndex = /* @__PURE__ */ new Map();
9086
9113
  const DEBUG = process.env.DEBUG_GRID === "true";
9087
9114
  for (const query of plan.queries) {
@@ -9130,12 +9157,23 @@ function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = /*
9130
9157
  pathDescription: cellKey
9131
9158
  };
9132
9159
  }
9133
- 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
+ }
9134
9170
  return {
9135
9171
  raw: value,
9136
9172
  formatted: formatValue(value, agg),
9137
9173
  aggregate: aggName,
9138
- pathDescription: cellKey
9174
+ pathDescription: cellKey,
9175
+ sql,
9176
+ cellSQL
9139
9177
  };
9140
9178
  }
9141
9179
  };
@@ -9171,7 +9209,8 @@ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = fals
9171
9209
  // logical row groupings
9172
9210
  query.colGroupings,
9173
9211
  // logical col groupings
9174
- primarySuffix
9212
+ primarySuffix,
9213
+ query.id
9175
9214
  );
9176
9215
  if (query.additionalColVariants) {
9177
9216
  for (const variant of query.additionalColVariants) {
@@ -9195,7 +9234,8 @@ function indexQueryResults(data, query, cellIndex, aggregates, isInverted = fals
9195
9234
  isInverted,
9196
9235
  query.rowGroupings,
9197
9236
  variant.colGroupings,
9198
- nestNameSuffix
9237
+ nestNameSuffix,
9238
+ query.id
9199
9239
  );
9200
9240
  }
9201
9241
  }
@@ -9230,10 +9270,10 @@ function indexFlatQueryResults(data, query, cellIndex, aggregates) {
9230
9270
  colValues.set(g.dimension, value);
9231
9271
  }
9232
9272
  }
9233
- indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex);
9273
+ indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex, query.id);
9234
9274
  }
9235
9275
  }
9236
- 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) {
9237
9277
  for (const row of data) {
9238
9278
  const currentOuterValues = new Map(baseOuterValues);
9239
9279
  for (let i = outerDepth; i < malloyOuterGroupings.length; i++) {
@@ -9264,7 +9304,8 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
9264
9304
  isInverted,
9265
9305
  logicalRowGroupings,
9266
9306
  logicalColGroupings,
9267
- nestNameSuffix
9307
+ nestNameSuffix,
9308
+ queryId
9268
9309
  );
9269
9310
  break;
9270
9311
  }
@@ -9286,7 +9327,8 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
9286
9327
  cellIndex,
9287
9328
  0,
9288
9329
  isInverted,
9289
- nestNameSuffix
9330
+ nestNameSuffix,
9331
+ queryId
9290
9332
  );
9291
9333
  } else {
9292
9334
  if (isInverted) {
@@ -9297,7 +9339,8 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
9297
9339
  // These are actually the row values (empty when no nesting)
9298
9340
  currentOuterValues,
9299
9341
  // These are actually the col values
9300
- cellIndex
9342
+ cellIndex,
9343
+ queryId
9301
9344
  );
9302
9345
  } else {
9303
9346
  indexAggregateValues(
@@ -9305,13 +9348,14 @@ function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggr
9305
9348
  aggregates,
9306
9349
  currentOuterValues,
9307
9350
  baseNestedValues,
9308
- cellIndex
9351
+ cellIndex,
9352
+ queryId
9309
9353
  );
9310
9354
  }
9311
9355
  }
9312
9356
  }
9313
9357
  }
9314
- 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) {
9315
9359
  const currentDim = nestedGroupings[nestedDepth];
9316
9360
  const remainingDims = nestedGroupings.slice(nestedDepth);
9317
9361
  let nestedKey = nestedDepth === 0 ? `by_${currentDim.dimension}${nestNameSuffix}` : `by_${currentDim.dimension}`;
@@ -9343,7 +9387,8 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
9343
9387
  // These are actually row values
9344
9388
  outerValues,
9345
9389
  // These are actually col values
9346
- cellIndex
9390
+ cellIndex,
9391
+ queryId
9347
9392
  );
9348
9393
  } else {
9349
9394
  indexAggregateValues(
@@ -9351,7 +9396,8 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
9351
9396
  aggregates,
9352
9397
  outerValues,
9353
9398
  currentNestedValues,
9354
- cellIndex
9399
+ cellIndex,
9400
+ queryId
9355
9401
  );
9356
9402
  }
9357
9403
  }
@@ -9382,7 +9428,8 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
9382
9428
  cellIndex,
9383
9429
  nestedDepth + 1,
9384
9430
  isInverted,
9385
- nestNameSuffix
9431
+ nestNameSuffix,
9432
+ queryId
9386
9433
  );
9387
9434
  } else {
9388
9435
  if (isInverted) {
@@ -9393,7 +9440,8 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
9393
9440
  // These are actually row values
9394
9441
  outerValues,
9395
9442
  // These are actually col values
9396
- cellIndex
9443
+ cellIndex,
9444
+ queryId
9397
9445
  );
9398
9446
  } else {
9399
9447
  indexAggregateValues(
@@ -9401,13 +9449,14 @@ function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNe
9401
9449
  aggregates,
9402
9450
  outerValues,
9403
9451
  currentNestedValues,
9404
- cellIndex
9452
+ cellIndex,
9453
+ queryId
9405
9454
  );
9406
9455
  }
9407
9456
  }
9408
9457
  }
9409
9458
  }
9410
- function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex) {
9459
+ function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex, queryId) {
9411
9460
  const cellKey = makeCellKey(rowValues, colValues);
9412
9461
  let cellData = cellIndex.get(cellKey);
9413
9462
  if (!cellData) {
@@ -9417,7 +9466,10 @@ function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex)
9417
9466
  for (const agg of aggregates) {
9418
9467
  const value = row[agg.name];
9419
9468
  if (value !== void 0) {
9420
- cellData.set(agg.name, typeof value === "number" ? value : null);
9469
+ cellData.set(agg.name, {
9470
+ value: typeof value === "number" ? value : null,
9471
+ queryId
9472
+ });
9421
9473
  }
9422
9474
  }
9423
9475
  }
@@ -10582,7 +10634,9 @@ function renderDataCell(grid, rowValues, colValues, rowLeaf, colLeaf, rowRoots,
10582
10634
  const humanPath = buildHumanPath(rowValues, colValues, aggregateName, grid.aggregates);
10583
10635
  const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : "";
10584
10636
  const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : "";
10585
- 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>`);
10586
10640
  }
10587
10641
  function renderDataCells(grid, rowValues, rowLeaf, lines) {
10588
10642
  const colValues = /* @__PURE__ */ new Map();
@@ -10594,14 +10648,18 @@ function renderDataCells(grid, rowValues, rowLeaf, lines) {
10594
10648
  const humanPath = buildHumanPath(rowValues, colValues, rowAgg, grid.aggregates);
10595
10649
  const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : "";
10596
10650
  const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : "";
10597
- 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>`);
10598
10654
  } else {
10599
10655
  for (const agg of grid.aggregates) {
10600
10656
  const cell = grid.getCell(rowValues, colValues, agg.name);
10601
10657
  const humanPath = buildHumanPath(rowValues, colValues, agg.name, grid.aggregates);
10602
10658
  const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : "";
10603
10659
  const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : "";
10604
- 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>`);
10605
10663
  }
10606
10664
  }
10607
10665
  }
@@ -10769,7 +10827,12 @@ async function executeMalloy(malloySource, options = {}) {
10769
10827
  connection: conn
10770
10828
  });
10771
10829
  try {
10772
- 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 });
10773
10836
  const data = result.data.toObject();
10774
10837
  if (options.outputPath) {
10775
10838
  fs.writeFileSync(
@@ -10779,6 +10842,9 @@ async function executeMalloy(malloySource, options = {}) {
10779
10842
  );
10780
10843
  console.log(`Results written to: ${options.outputPath}`);
10781
10844
  }
10845
+ if (options.captureSQL) {
10846
+ return { data: options.raw ? result : data, sql };
10847
+ }
10782
10848
  return options.raw ? result : data;
10783
10849
  } catch (error) {
10784
10850
  console.error("Malloy execution error:", error);
@@ -10827,18 +10893,28 @@ var TPL = class {
10827
10893
  orderingProvider: options.orderingProvider
10828
10894
  });
10829
10895
  const rawResults = /* @__PURE__ */ new Map();
10896
+ const sqlQueries = this.options.trackSQL ? /* @__PURE__ */ new Map() : void 0;
10830
10897
  for (const queryInfo of queries) {
10831
10898
  const fullMalloy = `${effectiveModel}
10832
10899
  ${queryInfo.malloy}`;
10833
- const data = await executeMalloy(fullMalloy);
10834
- 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
+ }
10835
10910
  }
10836
10911
  const grid = buildGridSpec(spec, plan, rawResults, {
10837
10912
  malloyQueries: queries,
10838
- orderingProvider: options.orderingProvider
10913
+ orderingProvider: options.orderingProvider,
10914
+ sqlQueries
10839
10915
  });
10840
10916
  const html = renderGridToHTML(grid);
10841
- return { html, grid, malloy, rawResults };
10917
+ return { html, grid, malloy, rawResults, sqlQueries };
10842
10918
  }
10843
10919
  };
10844
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
  /**
@@ -569,7 +569,9 @@ function renderDataCell(grid, rowValues, colValues, rowLeaf, colLeaf, rowRoots,
569
569
  const humanPath = buildHumanPath(rowValues, colValues, aggregateName, grid.aggregates);
570
570
  const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : '';
571
571
  const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : '';
572
- 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>`);
573
575
  }
574
576
  /**
575
577
  * Render data cells when there are no column pivots.
@@ -587,7 +589,9 @@ function renderDataCells(grid, rowValues, rowLeaf, lines) {
587
589
  const humanPath = buildHumanPath(rowValues, colValues, rowAgg, grid.aggregates);
588
590
  const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : '';
589
591
  const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : '';
590
- 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>`);
591
595
  }
592
596
  else {
593
597
  // Multiple aggregate cells
@@ -596,7 +600,9 @@ function renderDataCells(grid, rowValues, rowLeaf, lines) {
596
600
  const humanPath = buildHumanPath(rowValues, colValues, agg.name, grid.aggregates);
597
601
  const titleAttr = humanPath ? ` title="${escapeHTML(humanPath)}"` : '';
598
602
  const dataAttr = cell.pathDescription ? ` data-cell="${escapeHTML(cell.pathDescription)}"` : '';
599
- 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>`);
600
606
  }
601
607
  }
602
608
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tplm-lang",
3
- "version": "0.3.6",
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",