tplm-lang 0.3.5 → 0.3.6

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.
@@ -461,15 +461,13 @@ function buildSiblingHeaders(node, plan, results, axis, currentPath, depth, pare
461
461
  * Format an aggregate name for display (e.g., "births sum" from "births", "sum")
462
462
  */
463
463
  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"
464
+ // For count/n without a measure, just return "N"
466
465
  if (!measure || measure === "__pending__") {
467
466
  return aggregation === "count" ? "N" : aggregation;
468
467
  }
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
468
+ // For field-bound count (distinct count), show "field N"
471
469
  if (aggregation === "count") {
472
- return "N";
470
+ return `${measure} N`;
473
471
  }
474
472
  return `${measure} ${aggregation}`;
475
473
  }
@@ -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
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__") {
@@ -8760,7 +8763,7 @@ function formatAggregateName(measure, aggregation) {
8760
8763
  return aggregation === "count" ? "N" : aggregation;
8761
8764
  }
8762
8765
  if (aggregation === "count") {
8763
- return "N";
8766
+ return `${measure} N`;
8764
8767
  }
8765
8768
  return `${measure} ${aggregation}`;
8766
8769
  }
@@ -10206,6 +10209,17 @@ ${ordinalPick}`);
10206
10209
  }
10207
10210
 
10208
10211
  // packages/renderer/grid-renderer.ts
10212
+ function formatAggDisplayName(agg) {
10213
+ if (agg.label) return agg.label;
10214
+ if (agg.aggregation === "count") {
10215
+ if (agg.measure && agg.measure !== "__pending__" && agg.measure !== "") {
10216
+ return `${agg.measure} N`;
10217
+ }
10218
+ return "N";
10219
+ }
10220
+ if (!agg.measure || agg.measure === "__pending__") return agg.aggregation;
10221
+ return `${agg.measure} ${agg.aggregation}`;
10222
+ }
10209
10223
  function renderGridToHTML(grid, options = {}) {
10210
10224
  const {
10211
10225
  tableClass = "tpl-table",
@@ -10288,7 +10302,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
10288
10302
  lines.push('<th class="tpl-corner"></th>');
10289
10303
  }
10290
10304
  }
10291
- lines.push(`<th>${escapeHTML(grid.aggregates[0]?.label ?? grid.aggregates[0]?.name ?? "Value")}</th>`);
10305
+ lines.push(`<th>${escapeHTML(grid.aggregates[0] ? formatAggDisplayName(grid.aggregates[0]) : "Value")}</th>`);
10292
10306
  lines.push("</tr>");
10293
10307
  lines.push("</thead>");
10294
10308
  return;
@@ -10320,7 +10334,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
10320
10334
  lines.push("<th>Value</th>");
10321
10335
  } else {
10322
10336
  for (const agg of grid.aggregates) {
10323
- lines.push(`<th>${escapeHTML(agg.label ?? agg.name)}</th>`);
10337
+ lines.push(`<th>${escapeHTML(formatAggDisplayName(agg))}</th>`);
10324
10338
  }
10325
10339
  }
10326
10340
  lines.push("</tr>");
@@ -10537,23 +10551,13 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
10537
10551
  if (aggInfo?.label) {
10538
10552
  displayAgg = aggInfo.label;
10539
10553
  } else if (aggInfo) {
10540
- if (aggInfo.aggregation === "count") {
10541
- displayAgg = "count";
10542
- } else {
10543
- displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
10544
- }
10554
+ displayAgg = formatAggDisplayName(aggInfo);
10545
10555
  } else {
10546
10556
  displayAgg = aggregateName.replace(/^__pending___/, "").replace(/_/g, ".");
10547
10557
  }
10548
10558
  } else if (aggregates.length === 1) {
10549
10559
  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
- }
10560
+ displayAgg = formatAggDisplayName(aggInfo);
10557
10561
  }
10558
10562
  if (displayAgg) {
10559
10563
  return dimPath ? `${dimPath} \u2192 ${displayAgg}` : displayAgg;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tplm-lang",
3
- "version": "0.3.5",
3
+ "version": "0.3.6",
4
4
  "description": "TPLm - Table Producing Language backed by Malloy.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",