tplm-lang 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +357 -0
- package/dist/compiler/grid-spec-builder.d.ts +30 -0
- package/dist/compiler/grid-spec-builder.js +1836 -0
- package/dist/compiler/index.d.ts +11 -0
- package/dist/compiler/index.js +13 -0
- package/dist/compiler/malloy-generator.d.ts +36 -0
- package/dist/compiler/malloy-generator.js +141 -0
- package/dist/compiler/multi-query-utils.d.ts +42 -0
- package/dist/compiler/multi-query-utils.js +185 -0
- package/dist/compiler/query-plan-generator.d.ts +77 -0
- package/dist/compiler/query-plan-generator.js +1456 -0
- package/dist/compiler/table-spec-builder.d.ts +11 -0
- package/dist/compiler/table-spec-builder.js +588 -0
- package/dist/compiler/table-spec.d.ts +434 -0
- package/dist/compiler/table-spec.js +274 -0
- package/dist/executor/index.d.ts +71 -0
- package/dist/executor/index.js +232 -0
- package/dist/index.d.ts +214 -0
- package/dist/index.js +220 -0
- package/dist/parser/ast.d.ts +253 -0
- package/dist/parser/ast.js +164 -0
- package/dist/parser/chevrotain-parser.d.ts +118 -0
- package/dist/parser/chevrotain-parser.js +1266 -0
- package/dist/parser/index.d.ts +30 -0
- package/dist/parser/index.js +36 -0
- package/dist/parser/parser.d.ts +4 -0
- package/dist/parser/parser.js +4354 -0
- package/dist/parser/prettifier.d.ts +14 -0
- package/dist/parser/prettifier.js +380 -0
- package/dist/renderer/grid-renderer.d.ts +19 -0
- package/dist/renderer/grid-renderer.js +541 -0
- package/dist/renderer/index.d.ts +4 -0
- package/dist/renderer/index.js +4 -0
- package/package.json +67 -0
- package/packages/parser/tpl.pegjs +568 -0
- package/packages/renderer/tpl-table.css +182 -0
|
@@ -0,0 +1,1836 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grid Spec Builder
|
|
3
|
+
*
|
|
4
|
+
* Transforms TableSpec + query results into a GridSpec for rendering.
|
|
5
|
+
*
|
|
6
|
+
* Key responsibilities:
|
|
7
|
+
* 1. Build row header hierarchy from axis tree + actual dimension values
|
|
8
|
+
* 2. Build column header hierarchy from axis tree + actual dimension values
|
|
9
|
+
* 3. Create cell lookup function mapping (rowPath, colPath) → cell value
|
|
10
|
+
* 4. Handle totals, nested data, and cross-dimensional aggregation (ACROSS)
|
|
11
|
+
*/
|
|
12
|
+
import { collectBranches, } from './table-spec.js';
|
|
13
|
+
/**
|
|
14
|
+
* Build a GridSpec from a TableSpec and query results.
|
|
15
|
+
*
|
|
16
|
+
* @param spec The table specification
|
|
17
|
+
* @param plan The query plan
|
|
18
|
+
* @param results Query results indexed by query ID
|
|
19
|
+
* @param malloyQueries Optional Malloy query specs (for axis inversion info)
|
|
20
|
+
*/
|
|
21
|
+
export function buildGridSpec(spec, plan, results, malloyQueries) {
|
|
22
|
+
// Build maps of query ID to special handling flags
|
|
23
|
+
const invertedQueries = new Set();
|
|
24
|
+
const flatQueries = new Set();
|
|
25
|
+
if (malloyQueries) {
|
|
26
|
+
for (const mq of malloyQueries) {
|
|
27
|
+
if (mq.axesInverted) {
|
|
28
|
+
invertedQueries.add(mq.id);
|
|
29
|
+
}
|
|
30
|
+
if (mq.isFlatQuery) {
|
|
31
|
+
flatQueries.add(mq.id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Build header structures from axis trees
|
|
36
|
+
let rowHeaders = buildHeaderHierarchy(spec.rowAxis, plan, results, 'row');
|
|
37
|
+
const colHeaders = buildHeaderHierarchy(spec.colAxis, plan, results, 'col');
|
|
38
|
+
// Handle aggregate-only row axis: when rowHeaders is empty but we have aggregates,
|
|
39
|
+
// create a synthetic row header to represent the single aggregate row.
|
|
40
|
+
// This ensures the table body has at least one row to display data.
|
|
41
|
+
if (rowHeaders.length === 0 && spec.aggregates.length > 0) {
|
|
42
|
+
// Check if the row axis contains only aggregates (no dimensions)
|
|
43
|
+
const rowDimensions = collectDimensionsFromAxis(spec.rowAxis);
|
|
44
|
+
if (rowDimensions.length === 0) {
|
|
45
|
+
// Create synthetic row headers for each aggregate
|
|
46
|
+
// If there's only one aggregate, create a single row with the aggregate label
|
|
47
|
+
// If multiple aggregates, each gets its own row
|
|
48
|
+
if (spec.aggregates.length === 1) {
|
|
49
|
+
const agg = spec.aggregates[0];
|
|
50
|
+
rowHeaders = [{
|
|
51
|
+
type: 'dimension',
|
|
52
|
+
dimension: '_aggregate',
|
|
53
|
+
value: agg.label ?? formatAggregateName(agg.measure, agg.aggregation),
|
|
54
|
+
label: agg.label,
|
|
55
|
+
span: 1,
|
|
56
|
+
depth: 0,
|
|
57
|
+
path: [{ type: 'aggregate', name: agg.name }],
|
|
58
|
+
}];
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Multiple aggregates - each gets its own row
|
|
62
|
+
rowHeaders = spec.aggregates.map((agg, idx) => ({
|
|
63
|
+
type: 'dimension',
|
|
64
|
+
dimension: '_aggregate',
|
|
65
|
+
value: agg.label ?? formatAggregateName(agg.measure, agg.aggregation),
|
|
66
|
+
label: agg.label,
|
|
67
|
+
span: 1,
|
|
68
|
+
depth: 0,
|
|
69
|
+
path: [{ type: 'sibling', index: idx }, { type: 'aggregate', name: agg.name }],
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Build cell lookup (value-based)
|
|
75
|
+
const cellLookup = buildCellLookup(spec, plan, results, invertedQueries, flatQueries);
|
|
76
|
+
// Check for totals
|
|
77
|
+
const hasRowTotal = axisHasTotal(spec.rowAxis);
|
|
78
|
+
const hasColTotal = axisHasTotal(spec.colAxis);
|
|
79
|
+
// Determine if corner-style row headers should be used
|
|
80
|
+
// Only valid when rowHeaders:above is set AND row axis doesn't have siblings at root
|
|
81
|
+
const useCornerRowHeaders = shouldUseCornerRowHeaders(spec);
|
|
82
|
+
const cornerRowLabels = useCornerRowHeaders ? extractRowDimensionLabels(spec.rowAxis) : undefined;
|
|
83
|
+
// For left mode (when siblings exist), extract labels to show in corner
|
|
84
|
+
// Only show labels for dimensions that have custom labels
|
|
85
|
+
const leftModeRowLabels = !useCornerRowHeaders && rowHeaders.length > 0
|
|
86
|
+
? extractLeftModeRowLabels(rowHeaders)
|
|
87
|
+
: undefined;
|
|
88
|
+
return {
|
|
89
|
+
rowHeaders,
|
|
90
|
+
colHeaders,
|
|
91
|
+
getCell: (rowValues, colValues, aggregate) => cellLookup.get(rowValues, colValues, aggregate),
|
|
92
|
+
aggregates: spec.aggregates,
|
|
93
|
+
hasRowTotal,
|
|
94
|
+
hasColTotal,
|
|
95
|
+
options: spec.options,
|
|
96
|
+
useCornerRowHeaders,
|
|
97
|
+
cornerRowLabels,
|
|
98
|
+
leftModeRowLabels,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ---
|
|
102
|
+
// HEADER HIERARCHY BUILDER
|
|
103
|
+
// ---
|
|
104
|
+
/**
|
|
105
|
+
* Build header hierarchy from an axis tree and query results.
|
|
106
|
+
*
|
|
107
|
+
* This walks the axis tree structure and populates it with actual
|
|
108
|
+
* values from the query results.
|
|
109
|
+
*/
|
|
110
|
+
function buildHeaderHierarchy(tree, plan, results, axis) {
|
|
111
|
+
if (!tree)
|
|
112
|
+
return [];
|
|
113
|
+
// Collect all branches to understand the structure
|
|
114
|
+
const branches = collectBranches(tree);
|
|
115
|
+
// For each branch, find the corresponding query and extract values
|
|
116
|
+
const headers = [];
|
|
117
|
+
return buildHeaderNodes(tree, plan, results, axis, [], 0);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Recursively build header nodes from an axis tree.
|
|
121
|
+
*
|
|
122
|
+
* Key insight: Aggregates are NOT header levels - they define what value
|
|
123
|
+
* goes in cells. The header tree ends at the last dimension.
|
|
124
|
+
* Aggregate children mean "this dimension cell has these values".
|
|
125
|
+
*
|
|
126
|
+
* @param parentValues Context: dimension values of parent headers (e.g., state=CA)
|
|
127
|
+
* Used to filter child dimension values to only those that exist under this parent.
|
|
128
|
+
*/
|
|
129
|
+
function buildHeaderNodes(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
|
|
130
|
+
if (!node)
|
|
131
|
+
return [];
|
|
132
|
+
switch (node.nodeType) {
|
|
133
|
+
case 'dimension':
|
|
134
|
+
return buildDimensionHeaders(node, plan, results, axis, currentPath, depth, parentValues);
|
|
135
|
+
case 'total':
|
|
136
|
+
return buildTotalHeaders(node, plan, results, axis, currentPath, depth, parentValues);
|
|
137
|
+
case 'siblings':
|
|
138
|
+
return buildSiblingHeaders(node, plan, results, axis, currentPath, depth, parentValues);
|
|
139
|
+
case 'aggregate':
|
|
140
|
+
// Single aggregates (even with labels) should NOT create header entries.
|
|
141
|
+
// Header entries for aggregates are only appropriate when there are
|
|
142
|
+
// multiple sibling aggregates (e.g., income.sum "Sum" | income.mean "Average").
|
|
143
|
+
// The buildSiblingHeaders function handles that case.
|
|
144
|
+
//
|
|
145
|
+
// For a single aggregate like `gender * income.sum "Total Income"`,
|
|
146
|
+
// the label should be used for column headers, not create row entries.
|
|
147
|
+
return [];
|
|
148
|
+
case 'percentageAggregate':
|
|
149
|
+
// Single percentage aggregates (even with labels) should NOT create header entries.
|
|
150
|
+
// Header entries are only appropriate when there are multiple sibling aggregates.
|
|
151
|
+
// The buildSiblingHeaders function handles that case.
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Build headers for a dimension node.
|
|
157
|
+
*
|
|
158
|
+
* When a dimension has a custom label (non-empty) and is not already within a sibling group,
|
|
159
|
+
* we create a sibling-label wrapper to display the label above the dimension values.
|
|
160
|
+
*
|
|
161
|
+
* @param parentValues Context from parent dimensions for filtering child values
|
|
162
|
+
*/
|
|
163
|
+
function buildDimensionHeaders(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
|
|
164
|
+
// Check if this dimension has a custom non-empty label and we're not already in a sibling context
|
|
165
|
+
// If so, we should create a sibling-label wrapper to display the label above dimension values
|
|
166
|
+
const hasCustomLabel = node.label !== undefined && node.label !== '';
|
|
167
|
+
const alreadyInSiblingContext = currentPath.some(seg => seg.type === 'sibling');
|
|
168
|
+
if (hasCustomLabel && !alreadyInSiblingContext) {
|
|
169
|
+
// Create a sibling-label wrapper for this dimension's label
|
|
170
|
+
// Build value headers as children at depth+1
|
|
171
|
+
const valueHeaders = buildDimensionValueHeaders(node, plan, results, axis, currentPath, depth + 1, parentValues);
|
|
172
|
+
const span = valueHeaders.reduce((sum, c) => sum + c.span, 0);
|
|
173
|
+
return [{
|
|
174
|
+
type: 'sibling-label',
|
|
175
|
+
dimension: node.name,
|
|
176
|
+
value: node.label,
|
|
177
|
+
label: node.label,
|
|
178
|
+
span: span || 1,
|
|
179
|
+
depth,
|
|
180
|
+
children: valueHeaders.length > 0 ? valueHeaders : undefined,
|
|
181
|
+
path: currentPath,
|
|
182
|
+
}];
|
|
183
|
+
}
|
|
184
|
+
// No custom label or already in sibling context - build value headers directly
|
|
185
|
+
return buildDimensionValueHeaders(node, plan, results, axis, currentPath, depth, parentValues);
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Build value headers for a dimension node (the actual dimension values like "CA", "TX", etc.)
|
|
189
|
+
*/
|
|
190
|
+
function buildDimensionValueHeaders(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
|
|
191
|
+
// Find query results that contain this dimension
|
|
192
|
+
// Pass parentValues to filter values to only those that exist under parent context
|
|
193
|
+
const dimValues = extractDimensionValues(node.name, plan, results, axis, currentPath, parentValues);
|
|
194
|
+
const headers = [];
|
|
195
|
+
for (const value of dimValues) {
|
|
196
|
+
const valuePath = [
|
|
197
|
+
...currentPath,
|
|
198
|
+
{ type: 'dimension', name: node.name },
|
|
199
|
+
];
|
|
200
|
+
// Create updated parent values including this dimension's value
|
|
201
|
+
const childParentValues = new Map(parentValues);
|
|
202
|
+
childParentValues.set(node.name, value);
|
|
203
|
+
// Recursively build child headers with updated parent context
|
|
204
|
+
const children = node.child
|
|
205
|
+
? buildHeaderNodes(node.child, plan, results, axis, valuePath, depth + 1, childParentValues)
|
|
206
|
+
: [];
|
|
207
|
+
// Calculate span (how many leaf descendants)
|
|
208
|
+
const span = children.length > 0
|
|
209
|
+
? children.reduce((sum, c) => sum + c.span, 0)
|
|
210
|
+
: 1;
|
|
211
|
+
headers.push({
|
|
212
|
+
type: 'dimension',
|
|
213
|
+
dimension: node.name,
|
|
214
|
+
value: String(value),
|
|
215
|
+
label: node.label,
|
|
216
|
+
span,
|
|
217
|
+
depth,
|
|
218
|
+
children: children.length > 0 ? children : undefined,
|
|
219
|
+
path: valuePath,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return headers;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Build headers for a total node.
|
|
226
|
+
*/
|
|
227
|
+
function buildTotalHeaders(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
|
|
228
|
+
const totalPath = [
|
|
229
|
+
...currentPath,
|
|
230
|
+
{ type: 'total', label: node.label },
|
|
231
|
+
];
|
|
232
|
+
// Recursively build child headers if any
|
|
233
|
+
const children = node.child
|
|
234
|
+
? buildHeaderNodes(node.child, plan, results, axis, totalPath, depth + 1, parentValues)
|
|
235
|
+
: [];
|
|
236
|
+
const span = children.length > 0
|
|
237
|
+
? children.reduce((sum, c) => sum + c.span, 0)
|
|
238
|
+
: 1;
|
|
239
|
+
return [{
|
|
240
|
+
type: 'total',
|
|
241
|
+
value: node.label ?? 'Total',
|
|
242
|
+
label: node.label,
|
|
243
|
+
span,
|
|
244
|
+
depth,
|
|
245
|
+
children: children.length > 0 ? children : undefined,
|
|
246
|
+
path: totalPath,
|
|
247
|
+
}];
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Build headers for sibling nodes.
|
|
251
|
+
*
|
|
252
|
+
* For multiple dimension siblings (like `gender | state`), we add a sibling-label
|
|
253
|
+
* header to indicate which dimension the values belong to.
|
|
254
|
+
*
|
|
255
|
+
* For single dimension with totals (like `gender | ALL`), we DON'T add sibling-labels
|
|
256
|
+
* since the total is just an extension of the dimension, not a separate section.
|
|
257
|
+
*/
|
|
258
|
+
function buildSiblingHeaders(node, plan, results, axis, currentPath, depth, parentValues = new Map()) {
|
|
259
|
+
const allHeaders = [];
|
|
260
|
+
// Check if all children are aggregates (regular or percentage)
|
|
261
|
+
const allAggregates = node.children.every(child => child.nodeType === 'aggregate' || child.nodeType === 'percentageAggregate');
|
|
262
|
+
// Count dimension children - only use sibling-labels when there are 2+ dimensions
|
|
263
|
+
// A single dimension with totals (dim | ALL) doesn't need sibling-labels
|
|
264
|
+
const dimensionChildren = node.children.filter(child => child.nodeType === 'dimension');
|
|
265
|
+
const hasMultipleDimensionSiblings = dimensionChildren.length >= 2;
|
|
266
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
267
|
+
const child = node.children[i];
|
|
268
|
+
const siblingPath = [
|
|
269
|
+
...currentPath,
|
|
270
|
+
{ type: 'sibling', index: i },
|
|
271
|
+
];
|
|
272
|
+
// Special handling for aggregate siblings: always create headers
|
|
273
|
+
if (allAggregates && child.nodeType === 'aggregate') {
|
|
274
|
+
const aggNode = child;
|
|
275
|
+
const aggName = `${aggNode.measure}_${aggNode.aggregation}`;
|
|
276
|
+
const displayValue = aggNode.label ?? formatAggregateName(aggNode.measure, aggNode.aggregation);
|
|
277
|
+
allHeaders.push({
|
|
278
|
+
type: 'dimension',
|
|
279
|
+
dimension: '_aggregate',
|
|
280
|
+
value: displayValue,
|
|
281
|
+
label: aggNode.label,
|
|
282
|
+
span: 1,
|
|
283
|
+
depth,
|
|
284
|
+
path: [...siblingPath, { type: 'aggregate', name: aggName }],
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
else if (allAggregates && child.nodeType === 'percentageAggregate') {
|
|
288
|
+
// Handle percentage aggregate siblings
|
|
289
|
+
const pctNode = child;
|
|
290
|
+
const aggName = `${pctNode.measure ?? ''}_${pctNode.aggregation}_pct`;
|
|
291
|
+
const displayValue = pctNode.label ?? formatAggregateName(pctNode.measure ?? 'count', pctNode.aggregation);
|
|
292
|
+
allHeaders.push({
|
|
293
|
+
type: 'dimension',
|
|
294
|
+
dimension: '_aggregate',
|
|
295
|
+
value: displayValue,
|
|
296
|
+
label: pctNode.label,
|
|
297
|
+
span: 1,
|
|
298
|
+
depth,
|
|
299
|
+
path: [...siblingPath, { type: 'aggregate', name: aggName }],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
else if (child.nodeType === 'dimension') {
|
|
303
|
+
// Check if we need a sibling-label for this dimension
|
|
304
|
+
const dimNode = child;
|
|
305
|
+
const hasCustomLabel = dimNode.label !== undefined && dimNode.label !== '';
|
|
306
|
+
// Create sibling-label wrapper when:
|
|
307
|
+
// 1. Multiple dimension siblings (always need labels to distinguish them)
|
|
308
|
+
// 2. Single dimension with custom label (need to show the custom label)
|
|
309
|
+
// Skip sibling-label when:
|
|
310
|
+
// - suppressLabel is true (label explicitly set to "")
|
|
311
|
+
// - Single dimension without custom label (just show values directly)
|
|
312
|
+
const needsSiblingLabel = (hasMultipleDimensionSiblings || hasCustomLabel) && !dimNode.suppressLabel;
|
|
313
|
+
if (dimNode.suppressLabel || !needsSiblingLabel) {
|
|
314
|
+
// Build dimension value headers directly at current depth (no sibling-label wrapper)
|
|
315
|
+
const valueHeaders = buildDimensionValueHeaders(dimNode, plan, results, axis, siblingPath, depth, parentValues);
|
|
316
|
+
allHeaders.push(...valueHeaders);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
const dimensionLabel = dimNode.label ?? dimNode.name;
|
|
320
|
+
// Build child headers at depth+1 (under the sibling label)
|
|
321
|
+
const childHeaders = buildHeaderNodes(child, plan, results, axis, siblingPath, depth + 1, // Increased depth for children
|
|
322
|
+
parentValues);
|
|
323
|
+
// Calculate span from children
|
|
324
|
+
const span = childHeaders.length > 0
|
|
325
|
+
? childHeaders.reduce((sum, c) => sum + c.span, 0)
|
|
326
|
+
: 1;
|
|
327
|
+
// Create the sibling-label header with children
|
|
328
|
+
allHeaders.push({
|
|
329
|
+
type: 'sibling-label',
|
|
330
|
+
dimension: dimNode.name,
|
|
331
|
+
value: dimensionLabel,
|
|
332
|
+
label: dimNode.label,
|
|
333
|
+
span,
|
|
334
|
+
depth,
|
|
335
|
+
children: childHeaders.length > 0 ? childHeaders : undefined,
|
|
336
|
+
path: siblingPath,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// Other cases (totals, etc.) - pass through as before
|
|
342
|
+
const childHeaders = buildHeaderNodes(child, plan, results, axis, siblingPath, depth, parentValues);
|
|
343
|
+
allHeaders.push(...childHeaders);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return allHeaders;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Format an aggregate name for display (e.g., "births sum" from "births", "sum")
|
|
350
|
+
*/
|
|
351
|
+
function formatAggregateName(measure, aggregation) {
|
|
352
|
+
// For count/n without a measure, just return the aggregation name
|
|
353
|
+
// This handles cases like standalone "count" or "n"
|
|
354
|
+
if (!measure || measure === '__pending__') {
|
|
355
|
+
return aggregation === 'count' ? 'N' : aggregation;
|
|
356
|
+
}
|
|
357
|
+
// For count with a measure (e.g., income.count), just return "N" since
|
|
358
|
+
// count doesn't really bind to a measure in Malloy
|
|
359
|
+
if (aggregation === 'count') {
|
|
360
|
+
return 'N';
|
|
361
|
+
}
|
|
362
|
+
return `${measure} ${aggregation}`;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Extract unique dimension values from query results.
|
|
366
|
+
* Values are sorted alphabetically by default, unless:
|
|
367
|
+
* - The dimension has a limit (data order preserved from query)
|
|
368
|
+
* - The dimension has an explicit order (data order preserved from query)
|
|
369
|
+
*
|
|
370
|
+
* @param parentValues Optional context: parent dimension values to filter by.
|
|
371
|
+
* For example, when building name headers under state=CA, only return names
|
|
372
|
+
* that exist in the data where state=CA.
|
|
373
|
+
*/
|
|
374
|
+
function extractDimensionValues(dimension, plan, results, axis, currentPath, parentValues = new Map()) {
|
|
375
|
+
// Use an array to preserve order (for cases with explicit order/limit)
|
|
376
|
+
// and a Set to track uniqueness
|
|
377
|
+
const valuesArray = [];
|
|
378
|
+
const seenValues = new Set();
|
|
379
|
+
// Check if this dimension has an explicit order or limit
|
|
380
|
+
// If so, we should preserve the data order from the query
|
|
381
|
+
let hasExplicitOrder = false;
|
|
382
|
+
let hasLimit = false;
|
|
383
|
+
for (const query of plan.queries) {
|
|
384
|
+
// Collect all groupings to check (including additionalColVariants for merged queries)
|
|
385
|
+
const groupingsToCheck = [];
|
|
386
|
+
if (axis === 'row') {
|
|
387
|
+
groupingsToCheck.push(query.rowGroupings);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
groupingsToCheck.push(query.colGroupings);
|
|
391
|
+
if (query.additionalColVariants) {
|
|
392
|
+
for (const variant of query.additionalColVariants) {
|
|
393
|
+
groupingsToCheck.push(variant.colGroupings);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
for (const groupings of groupingsToCheck) {
|
|
398
|
+
const grouping = groupings.find(g => g.dimension === dimension);
|
|
399
|
+
if (grouping) {
|
|
400
|
+
if (grouping.order?.direction) {
|
|
401
|
+
hasExplicitOrder = true;
|
|
402
|
+
}
|
|
403
|
+
if (grouping.limit) {
|
|
404
|
+
hasLimit = true;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const preserveDataOrder = hasExplicitOrder || hasLimit;
|
|
410
|
+
// Look through all queries that have this dimension in the right axis
|
|
411
|
+
for (const query of plan.queries) {
|
|
412
|
+
const queryResults = results.get(query.id);
|
|
413
|
+
if (!queryResults)
|
|
414
|
+
continue;
|
|
415
|
+
if (axis === 'row') {
|
|
416
|
+
// For row dimensions, check rowGroupings
|
|
417
|
+
if (query.rowGroupings.some(g => g.dimension === dimension)) {
|
|
418
|
+
extractValuesFromDataOrdered(queryResults, dimension, valuesArray, seenValues, query.rowGroupings, 0, parentValues);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
else {
|
|
422
|
+
// For column dimensions, check primary colGroupings AND additionalColVariants
|
|
423
|
+
// This handles merged queries where some dimensions are in additionalColVariants
|
|
424
|
+
const allColGroupings = [query.colGroupings];
|
|
425
|
+
if (query.additionalColVariants) {
|
|
426
|
+
for (const variant of query.additionalColVariants) {
|
|
427
|
+
allColGroupings.push(variant.colGroupings);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const colGroupings of allColGroupings) {
|
|
431
|
+
if (colGroupings.some(g => g.dimension === dimension)) {
|
|
432
|
+
// For columns, we need to navigate through row nesting to find pivot structure
|
|
433
|
+
extractColValuesFromDataOrdered(queryResults, dimension, valuesArray, seenValues, colGroupings, query.rowGroupings, parentValues);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// If we should preserve data order (explicit order or limit), return as-is
|
|
439
|
+
if (preserveDataOrder) {
|
|
440
|
+
return valuesArray;
|
|
441
|
+
}
|
|
442
|
+
// Otherwise, sort for consistent display order
|
|
443
|
+
// Default is alphabetical/numerical ascending
|
|
444
|
+
valuesArray.sort((a, b) => {
|
|
445
|
+
// Handle mixed string/number sorting
|
|
446
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
447
|
+
return a - b; // Numeric ascending
|
|
448
|
+
}
|
|
449
|
+
return String(a).localeCompare(String(b)); // Alphabetic ascending
|
|
450
|
+
});
|
|
451
|
+
return valuesArray;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Extract dimension values from query result data (for row dimensions).
|
|
455
|
+
* Handles both flat structure (multiple dims in same group_by) and
|
|
456
|
+
* nested structure (dims in separate nested levels).
|
|
457
|
+
*
|
|
458
|
+
* Also handles inverted queries where row dimensions might be nested
|
|
459
|
+
* inside column structures (e.g., for global column limits).
|
|
460
|
+
*
|
|
461
|
+
* @param parentValues Context: only extract values from rows where parent dimensions match
|
|
462
|
+
*/
|
|
463
|
+
function extractValuesFromData(data, dimension, values, groupings, depth = 0, parentValues = new Map()) {
|
|
464
|
+
if (!data || data.length === 0)
|
|
465
|
+
return;
|
|
466
|
+
// Find the grouping for this dimension to get its label (if any)
|
|
467
|
+
const grouping = groupings.find(g => g.dimension === dimension);
|
|
468
|
+
// In Malloy output, labeled dimensions use the label as the column name
|
|
469
|
+
const dataKey = grouping?.label ?? dimension;
|
|
470
|
+
for (const row of data) {
|
|
471
|
+
// Check if this row matches all parent dimension constraints
|
|
472
|
+
let matchesParent = true;
|
|
473
|
+
for (const [parentDim, parentVal] of parentValues) {
|
|
474
|
+
// Find the grouping for parent dimension to get its label
|
|
475
|
+
const parentGrouping = groupings.find(g => g.dimension === parentDim);
|
|
476
|
+
const parentDataKey = parentGrouping?.label ?? parentDim;
|
|
477
|
+
const rowVal = row[parentDataKey] ?? row[parentDim];
|
|
478
|
+
// Handle "(null)" matching: if parentVal is "(null)", match against null in data
|
|
479
|
+
const matches = rowVal === undefined ||
|
|
480
|
+
rowVal === parentVal ||
|
|
481
|
+
(parentVal === '(null)' && rowVal === null);
|
|
482
|
+
if (!matches) {
|
|
483
|
+
matchesParent = false;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!matchesParent)
|
|
488
|
+
continue;
|
|
489
|
+
// First, check if the dimension exists directly on this row (flat structure)
|
|
490
|
+
// This handles cases where multiple dimensions are in the same group_by
|
|
491
|
+
// Check both the label (if present) and the original dimension name
|
|
492
|
+
let value = row[dataKey];
|
|
493
|
+
if (value === undefined) {
|
|
494
|
+
value = row[dimension];
|
|
495
|
+
}
|
|
496
|
+
// Include null values as "(null)" to ensure limit counts match displayed rows
|
|
497
|
+
if (value === null) {
|
|
498
|
+
value = '(null)';
|
|
499
|
+
}
|
|
500
|
+
if (value !== undefined) {
|
|
501
|
+
values.add(value);
|
|
502
|
+
}
|
|
503
|
+
// Check ALL nested structures, not just those in the expected groupings
|
|
504
|
+
// This handles inverted queries where row dims might be inside column nesting
|
|
505
|
+
for (const key of Object.keys(row)) {
|
|
506
|
+
if (key.startsWith('by_') && Array.isArray(row[key])) {
|
|
507
|
+
extractValuesFromData(row[key], dimension, values, groupings, depth + 1, parentValues);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Extract dimension values from column pivot structure.
|
|
514
|
+
* Must navigate through row nesting to find column pivots.
|
|
515
|
+
*
|
|
516
|
+
* Also handles flat queries where column dimensions are at the top level
|
|
517
|
+
* (no nesting) - same as row dimensions.
|
|
518
|
+
*
|
|
519
|
+
* @param parentValues Context: only extract values where parent dimensions match
|
|
520
|
+
*/
|
|
521
|
+
function extractColValuesFromData(data, dimension, values, colGroupings, rowGroupings = [], parentValues = new Map()) {
|
|
522
|
+
if (!data || data.length === 0 || colGroupings.length === 0)
|
|
523
|
+
return;
|
|
524
|
+
// Find the grouping for this dimension to get its label (if any)
|
|
525
|
+
const grouping = colGroupings.find(g => g.dimension === dimension);
|
|
526
|
+
const dataKey = grouping?.label ?? dimension;
|
|
527
|
+
// First, check if this is a flat query (column dims at top level)
|
|
528
|
+
// If the first row has the dimension directly, it's a flat query
|
|
529
|
+
if (data.length > 0 && (data[0][dataKey] !== undefined || data[0][dimension] !== undefined)) {
|
|
530
|
+
// Flat query - extract values directly from top level
|
|
531
|
+
for (const row of data) {
|
|
532
|
+
let value = row[dataKey];
|
|
533
|
+
if (value === undefined) {
|
|
534
|
+
value = row[dimension];
|
|
535
|
+
}
|
|
536
|
+
// Include null values as "(null)" to ensure limit counts match displayed rows
|
|
537
|
+
if (value === null) {
|
|
538
|
+
value = '(null)';
|
|
539
|
+
}
|
|
540
|
+
if (value !== undefined) {
|
|
541
|
+
values.add(value);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
// Navigate through row structure to find leaf rows, then extract column values
|
|
547
|
+
for (const row of data) {
|
|
548
|
+
navigateToColPivots(row, dimension, values, colGroupings, rowGroupings, 0, parentValues);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Navigate through row nesting to reach column pivot data.
|
|
553
|
+
*
|
|
554
|
+
* Handles both cases:
|
|
555
|
+
* 1. Row dims at top level: { state: 'CA', by_gender: [...] }
|
|
556
|
+
* 2. Wrapped in nests: { by_state: [{ state: 'CA', by_gender: [...] }] }
|
|
557
|
+
*
|
|
558
|
+
* @param parentValues Context: parent dimension values to filter by
|
|
559
|
+
*/
|
|
560
|
+
function navigateToColPivots(row, dimension, values, colGroupings, rowGroupings, rowDepth, parentValues = new Map()) {
|
|
561
|
+
// First, check if we need to navigate INTO the current row dimension's nest
|
|
562
|
+
// This handles the case where data is wrapped: { by_state: [...] }
|
|
563
|
+
if (rowDepth < rowGroupings.length) {
|
|
564
|
+
const currentRowDim = rowGroupings[rowDepth]?.dimension;
|
|
565
|
+
const currentNestedKey = `by_${currentRowDim}`;
|
|
566
|
+
// Check if this row dimension value exists directly on the row
|
|
567
|
+
const hasValueDirectly = row[currentRowDim] !== undefined;
|
|
568
|
+
// Or if we need to navigate into its nest first
|
|
569
|
+
if (!hasValueDirectly && row[currentNestedKey] && Array.isArray(row[currentNestedKey])) {
|
|
570
|
+
// Navigate into the current dimension's nest
|
|
571
|
+
for (const nestedRow of row[currentNestedKey]) {
|
|
572
|
+
navigateToColPivots(nestedRow, dimension, values, colGroupings, rowGroupings, rowDepth, parentValues);
|
|
573
|
+
}
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Check if there's more row nesting to navigate
|
|
578
|
+
if (rowDepth < rowGroupings.length - 1) {
|
|
579
|
+
const nextRowDim = rowGroupings[rowDepth + 1]?.dimension;
|
|
580
|
+
const nestedRowKey = `by_${nextRowDim}`;
|
|
581
|
+
const nestedRows = row[nestedRowKey];
|
|
582
|
+
if (nestedRows && Array.isArray(nestedRows)) {
|
|
583
|
+
for (const nestedRow of nestedRows) {
|
|
584
|
+
navigateToColPivots(nestedRow, dimension, values, colGroupings, rowGroupings, rowDepth + 1, parentValues);
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// At leaf of row structure - now extract column values
|
|
590
|
+
extractColValuesFromRow(row, dimension, values, colGroupings, 0, parentValues);
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* Extract column dimension values from a row at leaf level.
|
|
594
|
+
*
|
|
595
|
+
* Handles the case where the first column dimension is at the top level
|
|
596
|
+
* of the row (not nested), which occurs in inverted queries.
|
|
597
|
+
*
|
|
598
|
+
* @param parentValues Context: parent column dimension values to filter by
|
|
599
|
+
*/
|
|
600
|
+
function extractColValuesFromRow(row, dimension, values, colGroupings, depth, parentValues = new Map()) {
|
|
601
|
+
if (depth >= colGroupings.length)
|
|
602
|
+
return;
|
|
603
|
+
const currentDim = colGroupings[depth];
|
|
604
|
+
const nestedKey = `by_${currentDim.dimension}`;
|
|
605
|
+
const nested = row[nestedKey];
|
|
606
|
+
// Check if the current dimension is at the TOP LEVEL of the row (not nested)
|
|
607
|
+
// This happens when the first column dimension is at the outer level in inverted queries
|
|
608
|
+
const topLevelDataKey = currentDim.label ?? currentDim.dimension;
|
|
609
|
+
const topLevelValue = row[topLevelDataKey] ?? row[currentDim.dimension];
|
|
610
|
+
if (topLevelValue !== undefined) {
|
|
611
|
+
// First column dimension is at top level
|
|
612
|
+
// Include null values as "(null)" to ensure limit counts match displayed rows
|
|
613
|
+
let displayTopValue = topLevelValue;
|
|
614
|
+
if (displayTopValue === null) {
|
|
615
|
+
displayTopValue = '(null)';
|
|
616
|
+
}
|
|
617
|
+
// Check parent constraint
|
|
618
|
+
const parentVal = parentValues.get(currentDim.dimension);
|
|
619
|
+
if (parentVal !== undefined && displayTopValue !== parentVal) {
|
|
620
|
+
return; // Parent constraint not matched
|
|
621
|
+
}
|
|
622
|
+
if (currentDim.dimension === dimension) {
|
|
623
|
+
// This is the dimension we're extracting - add the top level value
|
|
624
|
+
values.add(displayTopValue);
|
|
625
|
+
}
|
|
626
|
+
// Look for the NEXT dimension's nested key
|
|
627
|
+
if (depth + 1 < colGroupings.length) {
|
|
628
|
+
const nextDim = colGroupings[depth + 1];
|
|
629
|
+
const nextNestedKey = `by_${nextDim.dimension}`;
|
|
630
|
+
const nextNested = row[nextNestedKey];
|
|
631
|
+
if (nextNested && Array.isArray(nextNested)) {
|
|
632
|
+
// Recurse into next dimension's nesting, but start at depth+1
|
|
633
|
+
for (const nestedRow of nextNested) {
|
|
634
|
+
extractColValuesFromRow(nestedRow, dimension, values, colGroupings, depth + 1, parentValues);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// Standard nested case: current dimension is in a by_X array
|
|
641
|
+
if (!nested || !Array.isArray(nested))
|
|
642
|
+
return;
|
|
643
|
+
for (const colRow of nested) {
|
|
644
|
+
// Check if parent column dimensions match
|
|
645
|
+
// For state * name, when extracting names for state=CA,
|
|
646
|
+
// we check if this row's state matches CA
|
|
647
|
+
let parentMatch = true;
|
|
648
|
+
const dataKey = currentDim.label ?? currentDim.dimension;
|
|
649
|
+
const colVal = colRow[dataKey] ?? colRow[currentDim.dimension];
|
|
650
|
+
// Check if this row's current dimension matches the parent value constraint
|
|
651
|
+
const parentVal = parentValues.get(currentDim.dimension);
|
|
652
|
+
if (parentVal !== undefined && colVal !== parentVal) {
|
|
653
|
+
parentMatch = false;
|
|
654
|
+
}
|
|
655
|
+
if (!parentMatch)
|
|
656
|
+
continue;
|
|
657
|
+
if (currentDim.dimension === dimension) {
|
|
658
|
+
// This is the dimension we're extracting values for
|
|
659
|
+
// Include null values as "(null)" to ensure limit counts match displayed rows
|
|
660
|
+
let displayVal = colVal;
|
|
661
|
+
if (displayVal === null) {
|
|
662
|
+
displayVal = '(null)';
|
|
663
|
+
}
|
|
664
|
+
if (displayVal !== undefined) {
|
|
665
|
+
values.add(displayVal);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Continue to deeper column levels
|
|
669
|
+
extractColValuesFromRow(colRow, dimension, values, colGroupings, depth + 1, parentValues);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// ---
|
|
673
|
+
// ORDER-PRESERVING VALUE EXTRACTION
|
|
674
|
+
// ---
|
|
675
|
+
/**
|
|
676
|
+
* Extract dimension values from query result data, preserving order.
|
|
677
|
+
* Same as extractValuesFromData but uses array + Set for order preservation.
|
|
678
|
+
*/
|
|
679
|
+
function extractValuesFromDataOrdered(data, dimension, valuesArray, seenValues, groupings, depth = 0, parentValues = new Map()) {
|
|
680
|
+
if (!data || data.length === 0)
|
|
681
|
+
return;
|
|
682
|
+
const grouping = groupings.find(g => g.dimension === dimension);
|
|
683
|
+
const dataKey = grouping?.label ?? dimension;
|
|
684
|
+
for (const row of data) {
|
|
685
|
+
let matchesParent = true;
|
|
686
|
+
for (const [parentDim, parentVal] of parentValues) {
|
|
687
|
+
const parentGrouping = groupings.find(g => g.dimension === parentDim);
|
|
688
|
+
const parentDataKey = parentGrouping?.label ?? parentDim;
|
|
689
|
+
const rowVal = row[parentDataKey] ?? row[parentDim];
|
|
690
|
+
// Handle "(null)" matching: if parentVal is "(null)", match against null in data
|
|
691
|
+
const matches = rowVal === undefined ||
|
|
692
|
+
rowVal === parentVal ||
|
|
693
|
+
(parentVal === '(null)' && rowVal === null);
|
|
694
|
+
if (!matches) {
|
|
695
|
+
matchesParent = false;
|
|
696
|
+
break;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (!matchesParent)
|
|
700
|
+
continue;
|
|
701
|
+
let value = row[dataKey];
|
|
702
|
+
if (value === undefined) {
|
|
703
|
+
value = row[dimension];
|
|
704
|
+
}
|
|
705
|
+
// Include null values as "(null)" to ensure limit counts match displayed rows
|
|
706
|
+
if (value === null) {
|
|
707
|
+
value = '(null)';
|
|
708
|
+
}
|
|
709
|
+
if (value !== undefined && !seenValues.has(value)) {
|
|
710
|
+
seenValues.add(value);
|
|
711
|
+
valuesArray.push(value);
|
|
712
|
+
}
|
|
713
|
+
for (const key of Object.keys(row)) {
|
|
714
|
+
if (key.startsWith('by_') && Array.isArray(row[key])) {
|
|
715
|
+
extractValuesFromDataOrdered(row[key], dimension, valuesArray, seenValues, groupings, depth + 1, parentValues);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Extract dimension values from column pivot structure, preserving order.
|
|
722
|
+
* Same as extractColValuesFromData but uses array + Set for order preservation.
|
|
723
|
+
*/
|
|
724
|
+
function extractColValuesFromDataOrdered(data, dimension, valuesArray, seenValues, colGroupings, rowGroupings = [], parentValues = new Map()) {
|
|
725
|
+
if (!data || data.length === 0 || colGroupings.length === 0)
|
|
726
|
+
return;
|
|
727
|
+
const grouping = colGroupings.find(g => g.dimension === dimension);
|
|
728
|
+
const dataKey = grouping?.label ?? dimension;
|
|
729
|
+
// First, check if this is a flat query (column dims at top level)
|
|
730
|
+
if (data.length > 0 && (data[0][dataKey] !== undefined || data[0][dimension] !== undefined)) {
|
|
731
|
+
for (const row of data) {
|
|
732
|
+
let value = row[dataKey];
|
|
733
|
+
if (value === undefined) {
|
|
734
|
+
value = row[dimension];
|
|
735
|
+
}
|
|
736
|
+
// Include null values as "(null)" to ensure limit counts match displayed rows
|
|
737
|
+
if (value === null) {
|
|
738
|
+
value = '(null)';
|
|
739
|
+
}
|
|
740
|
+
if (value !== undefined && !seenValues.has(value)) {
|
|
741
|
+
seenValues.add(value);
|
|
742
|
+
valuesArray.push(value);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// Navigate through row structure to find leaf rows, then extract column values
|
|
748
|
+
for (const row of data) {
|
|
749
|
+
navigateToColPivotsOrdered(row, dimension, valuesArray, seenValues, colGroupings, rowGroupings, 0, parentValues);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Navigate through row nesting to reach column pivot data, preserving order.
|
|
754
|
+
*/
|
|
755
|
+
function navigateToColPivotsOrdered(row, dimension, valuesArray, seenValues, colGroupings, rowGroupings, rowDepth, parentValues = new Map()) {
|
|
756
|
+
if (rowDepth < rowGroupings.length) {
|
|
757
|
+
const currentRowDim = rowGroupings[rowDepth]?.dimension;
|
|
758
|
+
const currentNestedKey = `by_${currentRowDim}`;
|
|
759
|
+
const hasValueDirectly = row[currentRowDim] !== undefined;
|
|
760
|
+
if (!hasValueDirectly && row[currentNestedKey] && Array.isArray(row[currentNestedKey])) {
|
|
761
|
+
for (const nestedRow of row[currentNestedKey]) {
|
|
762
|
+
navigateToColPivotsOrdered(nestedRow, dimension, valuesArray, seenValues, colGroupings, rowGroupings, rowDepth, parentValues);
|
|
763
|
+
}
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (rowDepth < rowGroupings.length - 1) {
|
|
768
|
+
const nextRowDim = rowGroupings[rowDepth + 1]?.dimension;
|
|
769
|
+
const nestedRowKey = `by_${nextRowDim}`;
|
|
770
|
+
const nestedRows = row[nestedRowKey];
|
|
771
|
+
if (nestedRows && Array.isArray(nestedRows)) {
|
|
772
|
+
for (const nestedRow of nestedRows) {
|
|
773
|
+
navigateToColPivotsOrdered(nestedRow, dimension, valuesArray, seenValues, colGroupings, rowGroupings, rowDepth + 1, parentValues);
|
|
774
|
+
}
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
extractColValuesFromRowOrdered(row, dimension, valuesArray, seenValues, colGroupings, 0, parentValues);
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Extract column dimension values from a row at leaf level, preserving order.
|
|
782
|
+
*
|
|
783
|
+
* Handles the case where the first column dimension is at the top level
|
|
784
|
+
* of the row (not nested), which occurs in inverted queries.
|
|
785
|
+
*/
|
|
786
|
+
function extractColValuesFromRowOrdered(row, dimension, valuesArray, seenValues, colGroupings, depth, parentValues = new Map()) {
|
|
787
|
+
if (depth >= colGroupings.length)
|
|
788
|
+
return;
|
|
789
|
+
const currentDim = colGroupings[depth];
|
|
790
|
+
const nestedKey = `by_${currentDim.dimension}`;
|
|
791
|
+
const nested = row[nestedKey];
|
|
792
|
+
// Check if the current dimension is at the TOP LEVEL of the row (not nested)
|
|
793
|
+
// This happens when the first column dimension is at the outer level in inverted queries
|
|
794
|
+
const topLevelDataKey = currentDim.label ?? currentDim.dimension;
|
|
795
|
+
const topLevelValue = row[topLevelDataKey] ?? row[currentDim.dimension];
|
|
796
|
+
if (topLevelValue !== undefined) {
|
|
797
|
+
// First column dimension is at top level
|
|
798
|
+
// Include null values as "(null)" to ensure limit counts match displayed rows
|
|
799
|
+
let displayTopValue = topLevelValue;
|
|
800
|
+
if (displayTopValue === null) {
|
|
801
|
+
displayTopValue = '(null)';
|
|
802
|
+
}
|
|
803
|
+
// Check parent constraint
|
|
804
|
+
const parentVal = parentValues.get(currentDim.dimension);
|
|
805
|
+
if (parentVal !== undefined && displayTopValue !== parentVal) {
|
|
806
|
+
return; // Parent constraint not matched
|
|
807
|
+
}
|
|
808
|
+
if (currentDim.dimension === dimension) {
|
|
809
|
+
// This is the dimension we're extracting - add the top level value
|
|
810
|
+
if (!seenValues.has(displayTopValue)) {
|
|
811
|
+
seenValues.add(displayTopValue);
|
|
812
|
+
valuesArray.push(displayTopValue);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// Look for the NEXT dimension's nested key
|
|
816
|
+
if (depth + 1 < colGroupings.length) {
|
|
817
|
+
const nextDim = colGroupings[depth + 1];
|
|
818
|
+
const nextNestedKey = `by_${nextDim.dimension}`;
|
|
819
|
+
const nextNested = row[nextNestedKey];
|
|
820
|
+
if (nextNested && Array.isArray(nextNested)) {
|
|
821
|
+
// Recurse into next dimension's nesting, but start at depth+1
|
|
822
|
+
for (const nestedRow of nextNested) {
|
|
823
|
+
extractColValuesFromRowOrdered(nestedRow, dimension, valuesArray, seenValues, colGroupings, depth + 1, parentValues);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
// Standard nested case: current dimension is in a by_X array
|
|
830
|
+
if (!nested || !Array.isArray(nested))
|
|
831
|
+
return;
|
|
832
|
+
for (const colRow of nested) {
|
|
833
|
+
let parentMatch = true;
|
|
834
|
+
const dataKey = currentDim.label ?? currentDim.dimension;
|
|
835
|
+
const colVal = colRow[dataKey] ?? colRow[currentDim.dimension];
|
|
836
|
+
const parentVal = parentValues.get(currentDim.dimension);
|
|
837
|
+
if (parentVal !== undefined && colVal !== parentVal) {
|
|
838
|
+
parentMatch = false;
|
|
839
|
+
}
|
|
840
|
+
if (!parentMatch)
|
|
841
|
+
continue;
|
|
842
|
+
if (currentDim.dimension === dimension) {
|
|
843
|
+
// Include null values as "(null)" to ensure limit counts match displayed rows
|
|
844
|
+
let displayColVal = colVal;
|
|
845
|
+
if (displayColVal === null) {
|
|
846
|
+
displayColVal = '(null)';
|
|
847
|
+
}
|
|
848
|
+
if (displayColVal !== undefined && !seenValues.has(displayColVal)) {
|
|
849
|
+
seenValues.add(displayColVal);
|
|
850
|
+
valuesArray.push(displayColVal);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
extractColValuesFromRowOrdered(colRow, dimension, valuesArray, seenValues, colGroupings, depth + 1, parentValues);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
// ---
|
|
857
|
+
// CELL LOOKUP BUILDER
|
|
858
|
+
// ---
|
|
859
|
+
/**
|
|
860
|
+
* A value-based key for cell lookup.
|
|
861
|
+
* Combines all dimensions into a single sorted key (axis-independent).
|
|
862
|
+
* This allows lookups to work regardless of whether a dimension is on rows or columns.
|
|
863
|
+
*/
|
|
864
|
+
function makeCellKey(rowValues, colValues) {
|
|
865
|
+
// Combine all dimension values, ignoring which axis they're on
|
|
866
|
+
const allEntries = [
|
|
867
|
+
...Array.from(rowValues.entries()),
|
|
868
|
+
...Array.from(colValues.entries()),
|
|
869
|
+
];
|
|
870
|
+
return allEntries
|
|
871
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
872
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
873
|
+
.join('|');
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Build a cell lookup function from query results.
|
|
877
|
+
*
|
|
878
|
+
* Key insight: The lookup uses dimension VALUES, not structural positions.
|
|
879
|
+
* We now use axis-independent keys so lookups work regardless of which axis
|
|
880
|
+
* a dimension is on.
|
|
881
|
+
*
|
|
882
|
+
* @param invertedQueries Set of query IDs that have inverted axes (column dim is outer in Malloy)
|
|
883
|
+
* @param flatQueries Set of query IDs that use flat structure (all dims in single group_by)
|
|
884
|
+
*/
|
|
885
|
+
function buildCellLookup(spec, plan, results, invertedQueries, flatQueries = new Set()) {
|
|
886
|
+
// Build an index: cellKey → aggregateName → value
|
|
887
|
+
const cellIndex = new Map();
|
|
888
|
+
// Index all query results by plan query ID
|
|
889
|
+
// Since we now use a unified QueryPlan system, IDs should match directly
|
|
890
|
+
const DEBUG = process.env.DEBUG_GRID === 'true';
|
|
891
|
+
for (const query of plan.queries) {
|
|
892
|
+
const queryData = results.get(query.id);
|
|
893
|
+
if (!queryData || queryData.length === 0) {
|
|
894
|
+
if (DEBUG) {
|
|
895
|
+
console.log(` No data for query ${query.id}`);
|
|
896
|
+
}
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
const isInverted = invertedQueries.has(query.id);
|
|
900
|
+
const isFlatQuery = flatQueries.has(query.id);
|
|
901
|
+
if (DEBUG) {
|
|
902
|
+
console.log(` Indexing ${query.id}: ${queryData.length} rows, inverted=${isInverted}, flat=${isFlatQuery}`);
|
|
903
|
+
}
|
|
904
|
+
indexQueryResults(queryData, query, cellIndex, spec.aggregates, isInverted, isFlatQuery);
|
|
905
|
+
}
|
|
906
|
+
// Debug: show all indexed keys
|
|
907
|
+
if (DEBUG) {
|
|
908
|
+
console.log(` Total cell keys indexed: ${cellIndex.size}`);
|
|
909
|
+
const keys = Array.from(cellIndex.keys()).slice(0, 5);
|
|
910
|
+
console.log(` Sample keys: ${keys.join(', ')}`);
|
|
911
|
+
}
|
|
912
|
+
return {
|
|
913
|
+
get(rowValues, colValues, aggregate) {
|
|
914
|
+
const cellKey = makeCellKey(rowValues, colValues);
|
|
915
|
+
const cellData = cellIndex.get(cellKey);
|
|
916
|
+
// Determine which aggregate to return
|
|
917
|
+
const aggName = aggregate ?? spec.aggregates[0]?.name ?? '';
|
|
918
|
+
const agg = spec.aggregates.find(a => a.name === aggName);
|
|
919
|
+
if (!cellData) {
|
|
920
|
+
if (DEBUG) {
|
|
921
|
+
console.log(` Cell miss: ${cellKey}`);
|
|
922
|
+
}
|
|
923
|
+
return {
|
|
924
|
+
raw: null,
|
|
925
|
+
formatted: '',
|
|
926
|
+
aggregate: aggName,
|
|
927
|
+
pathDescription: cellKey,
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
const value = cellData.get(aggName) ?? null;
|
|
931
|
+
return {
|
|
932
|
+
raw: value,
|
|
933
|
+
formatted: formatValue(value, agg),
|
|
934
|
+
aggregate: aggName,
|
|
935
|
+
pathDescription: cellKey,
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Index query results into the cell lookup structure.
|
|
942
|
+
*
|
|
943
|
+
* @param isInverted When true, the Malloy query has swapped axes:
|
|
944
|
+
* - Column dimension is outer (for global limit)
|
|
945
|
+
* - Row dimension is nested
|
|
946
|
+
* We swap the groupings interpretation to match.
|
|
947
|
+
* @param isFlatQuery When true, all dimensions are at the same level (no nesting)
|
|
948
|
+
*/
|
|
949
|
+
function indexQueryResults(data, query, cellIndex, aggregates, isInverted = false, isFlatQuery = false) {
|
|
950
|
+
// Handle flat queries: all dimensions are at top level, no nesting
|
|
951
|
+
if (isFlatQuery) {
|
|
952
|
+
indexFlatQueryResults(data, query, cellIndex, aggregates);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
// When inverted, the Malloy query structure is:
|
|
956
|
+
// - Outer: column dimension (appears in data as top-level property)
|
|
957
|
+
// - Nested: row dimension (appears as by_X nested array)
|
|
958
|
+
// But we still want to build keys based on LOGICAL row/col groupings.
|
|
959
|
+
// So we tell flattenAndIndex that what it sees as "row" data is actually "col".
|
|
960
|
+
const malloyRowGroupings = isInverted ? query.colGroupings : query.rowGroupings;
|
|
961
|
+
const malloyColGroupings = isInverted ? query.rowGroupings : query.colGroupings;
|
|
962
|
+
// Flatten nested data and build value-based keys for primary column variant
|
|
963
|
+
flattenAndIndex(data, malloyRowGroupings, malloyColGroupings, aggregates, new Map(), // values for malloy outer (will be mapped to correct axis)
|
|
964
|
+
new Map(), // values for malloy nested (will be mapped to correct axis)
|
|
965
|
+
cellIndex, 0, isInverted, query.rowGroupings, // logical row groupings
|
|
966
|
+
query.colGroupings // logical col groupings
|
|
967
|
+
);
|
|
968
|
+
// Handle merged queries with additional column variants
|
|
969
|
+
// Each variant has its own nests (e.g., by_gender vs by_sector_label)
|
|
970
|
+
// that need to be indexed separately
|
|
971
|
+
if (query.additionalColVariants) {
|
|
972
|
+
for (const variant of query.additionalColVariants) {
|
|
973
|
+
const variantColGroupings = isInverted ? query.rowGroupings : variant.colGroupings;
|
|
974
|
+
flattenAndIndex(data, malloyRowGroupings, variantColGroupings, aggregates, new Map(), new Map(), cellIndex, 0, isInverted, query.rowGroupings, variant.colGroupings);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Index flat query results where all dimensions are at the top level.
|
|
980
|
+
*
|
|
981
|
+
* Flat queries have structure: { dim1: val1, dim2: val2, ..., agg: value }
|
|
982
|
+
* No nested by_X arrays - all dimensions are directly on each row.
|
|
983
|
+
*/
|
|
984
|
+
function indexFlatQueryResults(data, query, cellIndex, aggregates) {
|
|
985
|
+
for (const row of data) {
|
|
986
|
+
// Extract all row dimension values
|
|
987
|
+
const rowValues = new Map();
|
|
988
|
+
for (const g of query.rowGroupings) {
|
|
989
|
+
const dataKey = g.label ?? g.dimension;
|
|
990
|
+
let value = row[dataKey];
|
|
991
|
+
if (value === undefined) {
|
|
992
|
+
value = row[g.dimension];
|
|
993
|
+
}
|
|
994
|
+
// Map null to "(null)" to match header extraction
|
|
995
|
+
if (value === null) {
|
|
996
|
+
value = '(null)';
|
|
997
|
+
}
|
|
998
|
+
if (value !== undefined) {
|
|
999
|
+
rowValues.set(g.dimension, value);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
// Extract all column dimension values
|
|
1003
|
+
const colValues = new Map();
|
|
1004
|
+
for (const g of query.colGroupings) {
|
|
1005
|
+
const dataKey = g.label ?? g.dimension;
|
|
1006
|
+
let value = row[dataKey];
|
|
1007
|
+
if (value === undefined) {
|
|
1008
|
+
value = row[g.dimension];
|
|
1009
|
+
}
|
|
1010
|
+
// Map null to "(null)" to match header extraction
|
|
1011
|
+
if (value === null) {
|
|
1012
|
+
value = '(null)';
|
|
1013
|
+
}
|
|
1014
|
+
if (value !== undefined) {
|
|
1015
|
+
colValues.set(g.dimension, value);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
// Index aggregate values using the combined row/col values
|
|
1019
|
+
indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Recursively flatten nested data and index by dimension values.
|
|
1024
|
+
*
|
|
1025
|
+
* @param malloyOuterGroupings The groupings that are at the outer level in Malloy data
|
|
1026
|
+
* @param malloyNestedGroupings The groupings that are nested (by_X) in Malloy data
|
|
1027
|
+
* @param isInverted When true, malloy outer = logical cols, malloy nested = logical rows
|
|
1028
|
+
* @param logicalRowGroupings The actual row groupings for cell key building
|
|
1029
|
+
* @param logicalColGroupings The actual col groupings for cell key building
|
|
1030
|
+
*/
|
|
1031
|
+
function flattenAndIndex(data, malloyOuterGroupings, malloyNestedGroupings, aggregates, baseOuterValues, baseNestedValues, cellIndex, outerDepth, isInverted = false, logicalRowGroupings, logicalColGroupings) {
|
|
1032
|
+
for (const row of data) {
|
|
1033
|
+
// Build outer values - collect ALL outer dimension values from the current row
|
|
1034
|
+
// This handles the case where multiple dimensions are in the same group_by
|
|
1035
|
+
const currentOuterValues = new Map(baseOuterValues);
|
|
1036
|
+
// Collect all outer dimension values that exist on this row
|
|
1037
|
+
for (let i = outerDepth; i < malloyOuterGroupings.length; i++) {
|
|
1038
|
+
const g = malloyOuterGroupings[i];
|
|
1039
|
+
const dim = g.dimension;
|
|
1040
|
+
// Use label as data key if present, otherwise use dimension name
|
|
1041
|
+
const dataKey = g.label ?? dim;
|
|
1042
|
+
let value = row[dataKey];
|
|
1043
|
+
if (value === undefined) {
|
|
1044
|
+
value = row[dim];
|
|
1045
|
+
}
|
|
1046
|
+
// Map null to "(null)" to match header extraction
|
|
1047
|
+
if (value === null) {
|
|
1048
|
+
value = '(null)';
|
|
1049
|
+
}
|
|
1050
|
+
if (value !== undefined) {
|
|
1051
|
+
currentOuterValues.set(dim, value);
|
|
1052
|
+
}
|
|
1053
|
+
else {
|
|
1054
|
+
// Check for nested structure
|
|
1055
|
+
const nestedKey = `by_${dim}`;
|
|
1056
|
+
if (row[nestedKey] && Array.isArray(row[nestedKey])) {
|
|
1057
|
+
// This dimension is nested - recurse
|
|
1058
|
+
flattenAndIndex(row[nestedKey], malloyOuterGroupings, malloyNestedGroupings, aggregates, currentOuterValues, baseNestedValues, cellIndex, i, isInverted, logicalRowGroupings, logicalColGroupings);
|
|
1059
|
+
break; // Don't continue with this row, we recursed
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
// Handle nested pivots or direct aggregates
|
|
1064
|
+
// Only if we collected all outer dimensions (didn't break out for nesting)
|
|
1065
|
+
const hasAllOuterDims = malloyOuterGroupings.every(g => currentOuterValues.has(g.dimension) ||
|
|
1066
|
+
row[`by_${g.dimension}`]);
|
|
1067
|
+
if (!hasAllOuterDims) {
|
|
1068
|
+
continue; // Skipped this row because we recursed into nested data
|
|
1069
|
+
}
|
|
1070
|
+
if (malloyNestedGroupings.length > 0) {
|
|
1071
|
+
indexColumnPivots(row, malloyNestedGroupings, aggregates, currentOuterValues, new Map(), cellIndex, 0, isInverted);
|
|
1072
|
+
}
|
|
1073
|
+
else {
|
|
1074
|
+
// Direct aggregate values
|
|
1075
|
+
// When inverted: currentOuterValues = col values, baseNestedValues = row values
|
|
1076
|
+
// When normal: currentOuterValues = row values, baseNestedValues = col values
|
|
1077
|
+
if (isInverted) {
|
|
1078
|
+
indexAggregateValues(row, aggregates, baseNestedValues, // These are actually the row values (empty when no nesting)
|
|
1079
|
+
currentOuterValues, // These are actually the col values
|
|
1080
|
+
cellIndex);
|
|
1081
|
+
}
|
|
1082
|
+
else {
|
|
1083
|
+
indexAggregateValues(row, aggregates, currentOuterValues, baseNestedValues, cellIndex);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
/**
|
|
1089
|
+
* Index column pivot values.
|
|
1090
|
+
*
|
|
1091
|
+
* @param isInverted When true, swap the interpretation:
|
|
1092
|
+
* - outerValues are actually column values (from outer malloy grouping)
|
|
1093
|
+
* - nestedValues are actually row values (from nested malloy grouping)
|
|
1094
|
+
*/
|
|
1095
|
+
function indexColumnPivots(row, nestedGroupings, aggregates, outerValues, baseNestedValues, cellIndex, nestedDepth, isInverted = false) {
|
|
1096
|
+
const currentDim = nestedGroupings[nestedDepth];
|
|
1097
|
+
const remainingDims = nestedGroupings.slice(nestedDepth);
|
|
1098
|
+
// First try the single dimension key
|
|
1099
|
+
let nestedKey = `by_${currentDim.dimension}`;
|
|
1100
|
+
let nested = row[nestedKey];
|
|
1101
|
+
// If not found, look for a combined key containing all remaining dimensions
|
|
1102
|
+
// This happens in inverted queries where row dims are grouped together
|
|
1103
|
+
if (!nested || !Array.isArray(nested)) {
|
|
1104
|
+
// Build combined key: by_dim1_dim2_...
|
|
1105
|
+
const combinedKey = 'by_' + remainingDims.map(g => g.dimension).join('_');
|
|
1106
|
+
nested = row[combinedKey];
|
|
1107
|
+
if (nested && Array.isArray(nested)) {
|
|
1108
|
+
// Found combined nest - extract ALL dimension values from each row
|
|
1109
|
+
for (const nestedRow of nested) {
|
|
1110
|
+
const currentNestedValues = new Map(baseNestedValues);
|
|
1111
|
+
// Extract all dimension values from this row
|
|
1112
|
+
for (const g of remainingDims) {
|
|
1113
|
+
const dataKey = g.label ?? g.dimension;
|
|
1114
|
+
let value = nestedRow[dataKey];
|
|
1115
|
+
if (value === undefined) {
|
|
1116
|
+
value = nestedRow[g.dimension];
|
|
1117
|
+
}
|
|
1118
|
+
// Map null to "(null)" to match header extraction
|
|
1119
|
+
if (value === null) {
|
|
1120
|
+
value = '(null)';
|
|
1121
|
+
}
|
|
1122
|
+
if (value !== undefined) {
|
|
1123
|
+
currentNestedValues.set(g.dimension, value);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
// This is the leaf level - index aggregate values
|
|
1127
|
+
if (isInverted) {
|
|
1128
|
+
indexAggregateValues(nestedRow, aggregates, currentNestedValues, // These are actually row values
|
|
1129
|
+
outerValues, // These are actually col values
|
|
1130
|
+
cellIndex);
|
|
1131
|
+
}
|
|
1132
|
+
else {
|
|
1133
|
+
indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
// No matching nested key found
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
// Standard case: single dimension per nest level
|
|
1142
|
+
for (const nestedRow of nested) {
|
|
1143
|
+
// Use label as data key if present, otherwise use dimension name
|
|
1144
|
+
const dataKey = currentDim.label ?? currentDim.dimension;
|
|
1145
|
+
let nestedValue = nestedRow[dataKey];
|
|
1146
|
+
if (nestedValue === undefined) {
|
|
1147
|
+
nestedValue = nestedRow[currentDim.dimension];
|
|
1148
|
+
}
|
|
1149
|
+
// Map null to "(null)" to match header extraction
|
|
1150
|
+
if (nestedValue === null) {
|
|
1151
|
+
nestedValue = '(null)';
|
|
1152
|
+
}
|
|
1153
|
+
const currentNestedValues = new Map(baseNestedValues);
|
|
1154
|
+
if (nestedValue !== undefined) {
|
|
1155
|
+
currentNestedValues.set(currentDim.dimension, nestedValue);
|
|
1156
|
+
}
|
|
1157
|
+
if (nestedDepth + 1 < nestedGroupings.length) {
|
|
1158
|
+
// More nesting
|
|
1159
|
+
indexColumnPivots(nestedRow, nestedGroupings, aggregates, outerValues, currentNestedValues, cellIndex, nestedDepth + 1, isInverted);
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
// Leaf - index aggregate values
|
|
1163
|
+
// When inverted: outer = cols, nested = rows
|
|
1164
|
+
// When normal: outer = rows, nested = cols
|
|
1165
|
+
if (isInverted) {
|
|
1166
|
+
indexAggregateValues(nestedRow, aggregates, currentNestedValues, // These are actually row values
|
|
1167
|
+
outerValues, // These are actually col values
|
|
1168
|
+
cellIndex);
|
|
1169
|
+
}
|
|
1170
|
+
else {
|
|
1171
|
+
indexAggregateValues(nestedRow, aggregates, outerValues, currentNestedValues, cellIndex);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* Index aggregate values at a specific row/col value combination.
|
|
1178
|
+
*/
|
|
1179
|
+
function indexAggregateValues(row, aggregates, rowValues, colValues, cellIndex) {
|
|
1180
|
+
const cellKey = makeCellKey(rowValues, colValues);
|
|
1181
|
+
let cellData = cellIndex.get(cellKey);
|
|
1182
|
+
if (!cellData) {
|
|
1183
|
+
cellData = new Map();
|
|
1184
|
+
cellIndex.set(cellKey, cellData);
|
|
1185
|
+
}
|
|
1186
|
+
for (const agg of aggregates) {
|
|
1187
|
+
const value = row[agg.name];
|
|
1188
|
+
if (value !== undefined) {
|
|
1189
|
+
cellData.set(agg.name, typeof value === 'number' ? value : null);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Parse a custom format pattern into prefix, precision, and suffix.
|
|
1195
|
+
* Pattern syntax: 'prefix #.precision suffix'
|
|
1196
|
+
* Examples:
|
|
1197
|
+
* '$ #.2' → prefix='$ ', precision=2, suffix=''
|
|
1198
|
+
* '#.0 units' → prefix='', precision=0, suffix=' units'
|
|
1199
|
+
* '€ #.2 M' → prefix='€ ', precision=2, suffix=' M'
|
|
1200
|
+
* '# %' → prefix='', precision=undefined, suffix=' %'
|
|
1201
|
+
*/
|
|
1202
|
+
function parseCustomFormatPattern(pattern) {
|
|
1203
|
+
// Find the # placeholder
|
|
1204
|
+
const hashIndex = pattern.indexOf('#');
|
|
1205
|
+
if (hashIndex === -1) {
|
|
1206
|
+
// No placeholder - treat entire pattern as suffix
|
|
1207
|
+
return { prefix: '', suffix: pattern, precision: undefined };
|
|
1208
|
+
}
|
|
1209
|
+
const prefix = pattern.substring(0, hashIndex);
|
|
1210
|
+
let remainder = pattern.substring(hashIndex + 1);
|
|
1211
|
+
// Check for precision specifier (.N)
|
|
1212
|
+
let precision;
|
|
1213
|
+
const precisionMatch = remainder.match(/^\.(\d+)/);
|
|
1214
|
+
if (precisionMatch) {
|
|
1215
|
+
precision = parseInt(precisionMatch[1], 10);
|
|
1216
|
+
remainder = remainder.substring(precisionMatch[0].length);
|
|
1217
|
+
}
|
|
1218
|
+
return { prefix, suffix: remainder, precision };
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Format a cell value according to its aggregate specification.
|
|
1222
|
+
*/
|
|
1223
|
+
function formatValue(value, agg) {
|
|
1224
|
+
if (value === null || value === undefined)
|
|
1225
|
+
return '';
|
|
1226
|
+
if (agg?.format) {
|
|
1227
|
+
switch (agg.format.type) {
|
|
1228
|
+
case 'percent':
|
|
1229
|
+
// Standard percent format: 0.5 → 50%
|
|
1230
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
1231
|
+
case 'rawPercent':
|
|
1232
|
+
// For percentage aggregates (ACROSS), Malloy already computes 100.0 * value / denominator
|
|
1233
|
+
// so the value is already in percentage form (59.44 = 59.44%), don't multiply again
|
|
1234
|
+
return `${value.toFixed(1)}%`;
|
|
1235
|
+
case 'integer':
|
|
1236
|
+
return Math.round(value).toLocaleString();
|
|
1237
|
+
case 'comma':
|
|
1238
|
+
return value.toLocaleString(undefined, {
|
|
1239
|
+
minimumFractionDigits: agg.format.precision,
|
|
1240
|
+
maximumFractionDigits: agg.format.precision,
|
|
1241
|
+
});
|
|
1242
|
+
case 'decimal':
|
|
1243
|
+
return value.toLocaleString(undefined, {
|
|
1244
|
+
minimumFractionDigits: agg.format.precision,
|
|
1245
|
+
maximumFractionDigits: agg.format.precision,
|
|
1246
|
+
});
|
|
1247
|
+
case 'currency':
|
|
1248
|
+
return `$${value.toLocaleString()}`;
|
|
1249
|
+
case 'custom': {
|
|
1250
|
+
// Parse custom format pattern: 'prefix #.precision suffix'
|
|
1251
|
+
const pattern = agg.format.pattern ?? '';
|
|
1252
|
+
const { prefix, suffix, precision } = parseCustomFormatPattern(pattern);
|
|
1253
|
+
const options = {};
|
|
1254
|
+
if (precision !== undefined) {
|
|
1255
|
+
options.minimumFractionDigits = precision;
|
|
1256
|
+
options.maximumFractionDigits = precision;
|
|
1257
|
+
}
|
|
1258
|
+
const formatted = value.toLocaleString(undefined, options);
|
|
1259
|
+
return `${prefix}${formatted}${suffix}`;
|
|
1260
|
+
}
|
|
1261
|
+
default:
|
|
1262
|
+
return String(value);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
// Default: locale-aware number formatting
|
|
1266
|
+
if (Number.isInteger(value)) {
|
|
1267
|
+
return value.toLocaleString();
|
|
1268
|
+
}
|
|
1269
|
+
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
|
|
1270
|
+
}
|
|
1271
|
+
// ---
|
|
1272
|
+
// UTILITIES
|
|
1273
|
+
// ---
|
|
1274
|
+
/**
|
|
1275
|
+
* Verify that a plan query matches the structure of the data.
|
|
1276
|
+
*/
|
|
1277
|
+
function verifyQueryMatch(query, data) {
|
|
1278
|
+
if (!data || data.length === 0)
|
|
1279
|
+
return false;
|
|
1280
|
+
const sample = data[0];
|
|
1281
|
+
const dataKeys = new Set(Object.keys(sample));
|
|
1282
|
+
// Check row groupings - data must have all row dimension keys
|
|
1283
|
+
for (const g of query.rowGroupings) {
|
|
1284
|
+
if (!dataKeys.has(g.dimension)) {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
// If query expects row dimensions but data has none (total query mismatch)
|
|
1289
|
+
if (query.rowGroupings.length > 0) {
|
|
1290
|
+
const hasRowDim = query.rowGroupings.some(g => dataKeys.has(g.dimension));
|
|
1291
|
+
if (!hasRowDim)
|
|
1292
|
+
return false;
|
|
1293
|
+
}
|
|
1294
|
+
// If query expects no row dimensions (isRowTotal) but data has row dims
|
|
1295
|
+
if (query.isRowTotal && query.rowGroupings.length === 0) {
|
|
1296
|
+
// Data should NOT have row dimension keys (only nested column keys)
|
|
1297
|
+
const hasAnyRowDim = Array.from(dataKeys).some(k => !k.startsWith('by_') && k !== 'births_sum' && k !== 'births_mean');
|
|
1298
|
+
// Allow if it's just nested keys
|
|
1299
|
+
const onlyNestedKeys = Array.from(dataKeys).every(k => k.startsWith('by_') || k === 'births_sum' || k === 'births_mean');
|
|
1300
|
+
if (!onlyNestedKeys)
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
// Check col groupings - verify full column path
|
|
1304
|
+
if (query.colGroupings.length > 0) {
|
|
1305
|
+
if (!hasFullColPath(sample, query.colGroupings, query.rowGroupings)) {
|
|
1306
|
+
return false;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return true;
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Find a query in the plan that matches the structure of the given data.
|
|
1313
|
+
* Checks if the data has the expected row/column groupings.
|
|
1314
|
+
*/
|
|
1315
|
+
function findMatchingQuery(plan, data) {
|
|
1316
|
+
if (!data || data.length === 0)
|
|
1317
|
+
return undefined;
|
|
1318
|
+
const sample = data[0];
|
|
1319
|
+
const dataKeys = new Set(Object.keys(sample));
|
|
1320
|
+
// Find a query whose groupings match the data structure
|
|
1321
|
+
for (const query of plan.queries) {
|
|
1322
|
+
let matches = true;
|
|
1323
|
+
// Check row groupings
|
|
1324
|
+
for (const g of query.rowGroupings) {
|
|
1325
|
+
if (!dataKeys.has(g.dimension)) {
|
|
1326
|
+
matches = false;
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
// Check col groupings (look for by_X nested keys - check FULL column path)
|
|
1331
|
+
if (matches && query.colGroupings.length > 0) {
|
|
1332
|
+
// Navigate to leaf of row structure and verify full column path
|
|
1333
|
+
if (!hasFullColPath(sample, query.colGroupings, query.rowGroupings)) {
|
|
1334
|
+
matches = false;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
if (matches) {
|
|
1338
|
+
return query;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return undefined;
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Check if a sample row has the full column path (all column dimensions).
|
|
1345
|
+
*/
|
|
1346
|
+
function hasFullColPath(row, colGroupings, rowGroupings) {
|
|
1347
|
+
// Navigate through row nesting to find leaf
|
|
1348
|
+
let leafRow = row;
|
|
1349
|
+
for (let i = 1; i < rowGroupings.length; i++) {
|
|
1350
|
+
const rowNestedKey = `by_${rowGroupings[i].dimension}`;
|
|
1351
|
+
if (leafRow[rowNestedKey] && Array.isArray(leafRow[rowNestedKey]) && leafRow[rowNestedKey].length > 0) {
|
|
1352
|
+
leafRow = leafRow[rowNestedKey][0];
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
// Now check full column path from leaf
|
|
1356
|
+
let current = leafRow;
|
|
1357
|
+
for (const colG of colGroupings) {
|
|
1358
|
+
const nestedKey = `by_${colG.dimension}`;
|
|
1359
|
+
if (!current[nestedKey] || !Array.isArray(current[nestedKey]) || current[nestedKey].length === 0) {
|
|
1360
|
+
return false;
|
|
1361
|
+
}
|
|
1362
|
+
current = current[nestedKey][0];
|
|
1363
|
+
}
|
|
1364
|
+
return true;
|
|
1365
|
+
}
|
|
1366
|
+
/**
|
|
1367
|
+
* Check if a sample row has a nested key (possibly through row nesting).
|
|
1368
|
+
*/
|
|
1369
|
+
function hasNestedKey(row, nestedKey, rowGroupings) {
|
|
1370
|
+
// Check direct key
|
|
1371
|
+
if (row[nestedKey] && Array.isArray(row[nestedKey])) {
|
|
1372
|
+
return true;
|
|
1373
|
+
}
|
|
1374
|
+
// Navigate through row nesting
|
|
1375
|
+
for (let i = 1; i < rowGroupings.length; i++) {
|
|
1376
|
+
const rowNestedKey = `by_${rowGroupings[i].dimension}`;
|
|
1377
|
+
if (row[rowNestedKey] && Array.isArray(row[rowNestedKey]) && row[rowNestedKey].length > 0) {
|
|
1378
|
+
if (hasNestedKey(row[rowNestedKey][0], nestedKey, rowGroupings.slice(i))) {
|
|
1379
|
+
return true;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
return false;
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Index query results when no matching plan query was found.
|
|
1387
|
+
* Auto-detects structure from the data itself.
|
|
1388
|
+
*/
|
|
1389
|
+
function indexQueryResultsAuto(data, cellIndex, aggregates) {
|
|
1390
|
+
if (!data || data.length === 0)
|
|
1391
|
+
return;
|
|
1392
|
+
// Auto-detect groupings from data structure
|
|
1393
|
+
const sample = data[0];
|
|
1394
|
+
const keys = Object.keys(sample);
|
|
1395
|
+
// Find dimension keys (non-aggregate, non-nested)
|
|
1396
|
+
const aggregateNames = new Set(aggregates.map(a => a.name));
|
|
1397
|
+
const dimKeys = keys.filter(k => !k.startsWith('by_') &&
|
|
1398
|
+
!aggregateNames.has(k) &&
|
|
1399
|
+
typeof sample[k] !== 'object');
|
|
1400
|
+
// Find nested keys (by_X patterns)
|
|
1401
|
+
const nestedKeys = keys.filter(k => k.startsWith('by_'));
|
|
1402
|
+
// Build auto-detected groupings
|
|
1403
|
+
const rowGroupings = dimKeys.map(k => ({
|
|
1404
|
+
dimension: k,
|
|
1405
|
+
label: undefined,
|
|
1406
|
+
sort: undefined,
|
|
1407
|
+
limit: undefined,
|
|
1408
|
+
}));
|
|
1409
|
+
// If there are nested keys, assume they're column groupings
|
|
1410
|
+
const colGroupings = nestedKeys.map(k => ({
|
|
1411
|
+
dimension: k.replace('by_', ''),
|
|
1412
|
+
label: undefined,
|
|
1413
|
+
sort: undefined,
|
|
1414
|
+
limit: undefined,
|
|
1415
|
+
}));
|
|
1416
|
+
// Now index using detected structure
|
|
1417
|
+
flattenAndIndexAuto(data, rowGroupings, colGroupings, aggregates, new Map(), new Map(), cellIndex);
|
|
1418
|
+
}
|
|
1419
|
+
/**
|
|
1420
|
+
* Recursively flatten and index with auto-detected structure.
|
|
1421
|
+
*/
|
|
1422
|
+
function flattenAndIndexAuto(data, rowGroupings, colGroupings, aggregates, baseRowValues, baseColValues, cellIndex) {
|
|
1423
|
+
for (const row of data) {
|
|
1424
|
+
const currentRowValues = new Map(baseRowValues);
|
|
1425
|
+
// Extract all dimension values from the row
|
|
1426
|
+
for (const g of rowGroupings) {
|
|
1427
|
+
const value = row[g.dimension];
|
|
1428
|
+
if (value !== undefined && value !== null) {
|
|
1429
|
+
currentRowValues.set(g.dimension, value);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
// Check for nested row data
|
|
1433
|
+
const nestedRowKeys = Object.keys(row).filter(k => k.startsWith('by_') &&
|
|
1434
|
+
!colGroupings.some(c => `by_${c.dimension}` === k));
|
|
1435
|
+
if (nestedRowKeys.length > 0) {
|
|
1436
|
+
// Has nested row data
|
|
1437
|
+
for (const nestedKey of nestedRowKeys) {
|
|
1438
|
+
const nestedData = row[nestedKey];
|
|
1439
|
+
if (Array.isArray(nestedData)) {
|
|
1440
|
+
flattenAndIndexAuto(nestedData, rowGroupings, colGroupings, aggregates, currentRowValues, baseColValues, cellIndex);
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
// Handle column pivots
|
|
1445
|
+
if (colGroupings.length > 0) {
|
|
1446
|
+
indexColumnPivotsAuto(row, colGroupings, aggregates, currentRowValues, new Map(), cellIndex, 0);
|
|
1447
|
+
}
|
|
1448
|
+
else {
|
|
1449
|
+
// Direct aggregate values
|
|
1450
|
+
indexAggregateValues(row, aggregates, currentRowValues, baseColValues, cellIndex);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Index column pivots with auto-detected structure.
|
|
1456
|
+
*/
|
|
1457
|
+
function indexColumnPivotsAuto(row, colGroupings, aggregates, rowValues, baseColValues, cellIndex, colDepth) {
|
|
1458
|
+
if (colDepth >= colGroupings.length) {
|
|
1459
|
+
indexAggregateValues(row, aggregates, rowValues, baseColValues, cellIndex);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
const currentDim = colGroupings[colDepth];
|
|
1463
|
+
const nestedKey = `by_${currentDim.dimension}`;
|
|
1464
|
+
const nested = row[nestedKey];
|
|
1465
|
+
if (!nested || !Array.isArray(nested)) {
|
|
1466
|
+
// No more nesting - index here
|
|
1467
|
+
indexAggregateValues(row, aggregates, rowValues, baseColValues, cellIndex);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
for (const colRow of nested) {
|
|
1471
|
+
const colValue = colRow[currentDim.dimension];
|
|
1472
|
+
const currentColValues = new Map(baseColValues);
|
|
1473
|
+
if (colValue !== undefined && colValue !== null) {
|
|
1474
|
+
currentColValues.set(currentDim.dimension, colValue);
|
|
1475
|
+
}
|
|
1476
|
+
indexColumnPivotsAuto(colRow, colGroupings, aggregates, rowValues, currentColValues, cellIndex, colDepth + 1);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
/**
|
|
1480
|
+
* Check if an axis tree contains a total node.
|
|
1481
|
+
*/
|
|
1482
|
+
function axisHasTotal(node) {
|
|
1483
|
+
if (!node)
|
|
1484
|
+
return false;
|
|
1485
|
+
switch (node.nodeType) {
|
|
1486
|
+
case 'total':
|
|
1487
|
+
return true;
|
|
1488
|
+
case 'dimension':
|
|
1489
|
+
return node.child ? axisHasTotal(node.child) : false;
|
|
1490
|
+
case 'siblings':
|
|
1491
|
+
return node.children.some(c => axisHasTotal(c));
|
|
1492
|
+
case 'aggregate':
|
|
1493
|
+
case 'percentageAggregate':
|
|
1494
|
+
return false;
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// ---
|
|
1498
|
+
// DEBUGGING
|
|
1499
|
+
// ---
|
|
1500
|
+
/**
|
|
1501
|
+
* Print a GridSpec for debugging.
|
|
1502
|
+
*/
|
|
1503
|
+
export function printGridSpec(grid) {
|
|
1504
|
+
const lines = [];
|
|
1505
|
+
lines.push('GridSpec:');
|
|
1506
|
+
lines.push('\n Row Headers:');
|
|
1507
|
+
printHeaderNodes(grid.rowHeaders, ' ', lines);
|
|
1508
|
+
lines.push('\n Column Headers:');
|
|
1509
|
+
printHeaderNodes(grid.colHeaders, ' ', lines);
|
|
1510
|
+
lines.push(`\n Aggregates: ${grid.aggregates.map(a => a.name).join(', ')}`);
|
|
1511
|
+
lines.push(` Has Row Total: ${grid.hasRowTotal}`);
|
|
1512
|
+
lines.push(` Has Col Total: ${grid.hasColTotal}`);
|
|
1513
|
+
return lines.join('\n');
|
|
1514
|
+
}
|
|
1515
|
+
function printHeaderNodes(nodes, indent, lines) {
|
|
1516
|
+
for (const node of nodes) {
|
|
1517
|
+
let line = `${indent}${node.type}: "${node.value}"`;
|
|
1518
|
+
if (node.dimension)
|
|
1519
|
+
line += ` (dim: ${node.dimension})`;
|
|
1520
|
+
line += ` span=${node.span} depth=${node.depth}`;
|
|
1521
|
+
lines.push(line);
|
|
1522
|
+
if (node.children) {
|
|
1523
|
+
printHeaderNodes(node.children, indent + ' ', lines);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
// ---
|
|
1528
|
+
// CORNER ROW HEADERS
|
|
1529
|
+
// ---
|
|
1530
|
+
/**
|
|
1531
|
+
* Determine if corner-style row headers should be used.
|
|
1532
|
+
*
|
|
1533
|
+
* Corner headers (labels above in thead) are used when:
|
|
1534
|
+
* 1. Row axis has NO siblings ANYWHERE in the tree
|
|
1535
|
+
* (siblings require left-style because each branch may have different dimensions)
|
|
1536
|
+
* 2. User hasn't explicitly set rowHeaders:left
|
|
1537
|
+
*
|
|
1538
|
+
* The default behavior is:
|
|
1539
|
+
* - rowHeaders:above (corner style) when no siblings exist
|
|
1540
|
+
* - rowHeaders:left when siblings exist (forced fallback)
|
|
1541
|
+
* - Explicit option overrides the default if compatible
|
|
1542
|
+
*
|
|
1543
|
+
* When siblings exist anywhere (e.g., state * (gender | region)), we can't
|
|
1544
|
+
* use corner headers because the sibling branches would need different labels
|
|
1545
|
+
* in the same corner cell, which doesn't make sense.
|
|
1546
|
+
*/
|
|
1547
|
+
function shouldUseCornerRowHeaders(spec) {
|
|
1548
|
+
// Check if row axis exists
|
|
1549
|
+
if (!spec.rowAxis) {
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1552
|
+
// Check for siblings anywhere in the tree - if found, must use left-style
|
|
1553
|
+
if (hasSiblingsAnywhere(spec.rowAxis)) {
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
// If user explicitly set rowHeaders:left, respect that
|
|
1557
|
+
if (spec.options.rowHeaders === 'left') {
|
|
1558
|
+
return false;
|
|
1559
|
+
}
|
|
1560
|
+
// Default to corner-style (above) when no siblings exist
|
|
1561
|
+
// This includes when rowHeaders is undefined or 'above'
|
|
1562
|
+
return true;
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Check if an axis tree contains "true" siblings anywhere (not just at root).
|
|
1566
|
+
*
|
|
1567
|
+
* "True siblings" means 2+ dimension children in a sibling group.
|
|
1568
|
+
* A single dimension with totals (dim | ALL) is NOT considered true siblings
|
|
1569
|
+
* because the structure is still linear - we can use corner-style headers.
|
|
1570
|
+
*
|
|
1571
|
+
* Note: The buildSiblingHeaders function separately decides whether to create
|
|
1572
|
+
* sibling-label wrappers (only for 2+ dimension siblings).
|
|
1573
|
+
*/
|
|
1574
|
+
function hasSiblingsAnywhere(node) {
|
|
1575
|
+
if (!node)
|
|
1576
|
+
return false;
|
|
1577
|
+
if (node.nodeType === 'siblings') {
|
|
1578
|
+
// Count dimension children - only consider "true siblings" if 2+ dimensions
|
|
1579
|
+
const dimensionChildCount = node.children.filter(c => c.nodeType === 'dimension').length;
|
|
1580
|
+
if (dimensionChildCount >= 2) {
|
|
1581
|
+
return true; // Multiple dimensions → true siblings
|
|
1582
|
+
}
|
|
1583
|
+
// Check for nested sibling groups that each contain different dimensions
|
|
1584
|
+
// e.g., ((occupation | ALL) | (education | ALL)) has 2 sibling children,
|
|
1585
|
+
// each containing a different root dimension
|
|
1586
|
+
const siblingChildrenWithDims = node.children.filter(c => {
|
|
1587
|
+
if (c.nodeType === 'siblings') {
|
|
1588
|
+
// This sibling child contains dimensions (possibly with totals)
|
|
1589
|
+
return c.children.some(gc => gc.nodeType === 'dimension');
|
|
1590
|
+
}
|
|
1591
|
+
return false;
|
|
1592
|
+
});
|
|
1593
|
+
if (siblingChildrenWithDims.length >= 2) {
|
|
1594
|
+
// Multiple sibling groups each with dimensions → true siblings
|
|
1595
|
+
return true;
|
|
1596
|
+
}
|
|
1597
|
+
// Single dim + totals: check if the dimension has nested true siblings
|
|
1598
|
+
for (const child of node.children) {
|
|
1599
|
+
if (hasSiblingsAnywhere(child)) {
|
|
1600
|
+
return true;
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
if (node.nodeType === 'dimension') {
|
|
1606
|
+
return hasSiblingsAnywhere(node.child ?? null);
|
|
1607
|
+
}
|
|
1608
|
+
if (node.nodeType === 'total') {
|
|
1609
|
+
return hasSiblingsAnywhere(node.child ?? null);
|
|
1610
|
+
}
|
|
1611
|
+
return false;
|
|
1612
|
+
}
|
|
1613
|
+
/**
|
|
1614
|
+
* Extract row dimension labels in nesting order for corner display.
|
|
1615
|
+
*
|
|
1616
|
+
* Walks the row axis tree and extracts dimension names and their labels.
|
|
1617
|
+
* For single-dimension sibling groups (dim | ALL), walks into the dimension
|
|
1618
|
+
* since the structure is still linear for header purposes.
|
|
1619
|
+
* Also handles aggregate siblings at the end of the chain.
|
|
1620
|
+
*/
|
|
1621
|
+
function extractRowDimensionLabels(node) {
|
|
1622
|
+
const labels = [];
|
|
1623
|
+
let current = node;
|
|
1624
|
+
while (current) {
|
|
1625
|
+
if (current.nodeType === 'dimension') {
|
|
1626
|
+
const dimNode = current;
|
|
1627
|
+
labels.push({
|
|
1628
|
+
dimension: dimNode.name,
|
|
1629
|
+
label: dimNode.label ?? dimNode.name,
|
|
1630
|
+
});
|
|
1631
|
+
current = dimNode.child ?? null;
|
|
1632
|
+
}
|
|
1633
|
+
else if (current.nodeType === 'siblings') {
|
|
1634
|
+
// Check what kind of sibling group this is
|
|
1635
|
+
const siblingNode = current;
|
|
1636
|
+
const dimensionChildren = siblingNode.children.filter(c => c.nodeType === 'dimension');
|
|
1637
|
+
const aggregateChildren = siblingNode.children.filter(c => c.nodeType === 'aggregate' || c.nodeType === 'percentageAggregate');
|
|
1638
|
+
if (dimensionChildren.length === 1) {
|
|
1639
|
+
// Single dimension + totals: walk into the dimension
|
|
1640
|
+
// This handles patterns like (gender | ALL) where we still want the label
|
|
1641
|
+
current = dimensionChildren[0];
|
|
1642
|
+
}
|
|
1643
|
+
else if (dimensionChildren.length === 0 && aggregateChildren.length > 0) {
|
|
1644
|
+
// Aggregate siblings (e.g., income.(sum | mean))
|
|
1645
|
+
// Add a synthetic label for the aggregate column
|
|
1646
|
+
// The actual aggregate names will be shown in row headers
|
|
1647
|
+
labels.push({
|
|
1648
|
+
dimension: '_aggregate',
|
|
1649
|
+
label: '', // Aggregates show their own labels in row headers
|
|
1650
|
+
});
|
|
1651
|
+
break; // Aggregates are always leaves
|
|
1652
|
+
}
|
|
1653
|
+
else {
|
|
1654
|
+
// Multiple dimensions - stop collecting (true siblings need left-mode)
|
|
1655
|
+
break;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
else if (current.nodeType === 'total') {
|
|
1659
|
+
// Total node - check if it has a child to continue
|
|
1660
|
+
const totalNode = current;
|
|
1661
|
+
current = totalNode.child ?? null;
|
|
1662
|
+
}
|
|
1663
|
+
else if (current.nodeType === 'aggregate' || current.nodeType === 'percentageAggregate') {
|
|
1664
|
+
// Single aggregate - no header column needed
|
|
1665
|
+
break;
|
|
1666
|
+
}
|
|
1667
|
+
else {
|
|
1668
|
+
break;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return labels;
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Extract row dimension labels for left-mode display (when siblings exist).
|
|
1675
|
+
*
|
|
1676
|
+
* In left mode, we show labels in the corner only when:
|
|
1677
|
+
* - The dimension has a custom label (user explicitly provided one)
|
|
1678
|
+
* - The dimension hasn't already been labeled at a different depth
|
|
1679
|
+
* - There are NO sibling-labels in the row headers (sibling-labels show labels in body)
|
|
1680
|
+
*
|
|
1681
|
+
* When the same dimension appears at different depths in different branches
|
|
1682
|
+
* (e.g., gender at depth 1 under ALL, but depth 2 under occupation), we show
|
|
1683
|
+
* the label at the DEEPEST depth where the dimension appears. This ensures
|
|
1684
|
+
* the label appears in the column that has the most values for that dimension.
|
|
1685
|
+
*
|
|
1686
|
+
* This walks all branches and finds dimensions with their labels,
|
|
1687
|
+
* returning one entry per header column depth.
|
|
1688
|
+
*/
|
|
1689
|
+
function extractLeftModeRowLabels(rowHeaders) {
|
|
1690
|
+
// Get max depth of row headers
|
|
1691
|
+
function getMaxDepth(nodes) {
|
|
1692
|
+
let max = 0;
|
|
1693
|
+
for (const node of nodes) {
|
|
1694
|
+
max = Math.max(max, node.depth);
|
|
1695
|
+
if (node.children) {
|
|
1696
|
+
max = Math.max(max, getMaxDepth(node.children));
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
return max;
|
|
1700
|
+
}
|
|
1701
|
+
// Check if row headers contain sibling-labels
|
|
1702
|
+
// If so, those labels are already displayed in body row headers, don't duplicate in corner
|
|
1703
|
+
function hasSiblingLabels(nodes) {
|
|
1704
|
+
for (const node of nodes) {
|
|
1705
|
+
if (node.type === 'sibling-label')
|
|
1706
|
+
return true;
|
|
1707
|
+
if (node.children && hasSiblingLabels(node.children))
|
|
1708
|
+
return true;
|
|
1709
|
+
}
|
|
1710
|
+
return false;
|
|
1711
|
+
}
|
|
1712
|
+
const maxDepth = getMaxDepth(rowHeaders);
|
|
1713
|
+
// If sibling-labels exist, they handle showing labels in body - corner should be empty
|
|
1714
|
+
if (hasSiblingLabels(rowHeaders)) {
|
|
1715
|
+
const result = [];
|
|
1716
|
+
for (let depth = 0; depth <= maxDepth; depth++) {
|
|
1717
|
+
result.push({ label: '', hasCustomLabel: false });
|
|
1718
|
+
}
|
|
1719
|
+
return result;
|
|
1720
|
+
}
|
|
1721
|
+
// First pass: find the deepest depth where each dimension with a custom label appears
|
|
1722
|
+
const dimensionToDeepestDepth = new Map();
|
|
1723
|
+
for (let depth = 0; depth <= maxDepth; depth++) {
|
|
1724
|
+
const labelsAtDepth = collectLabelsAtDepth(rowHeaders, depth);
|
|
1725
|
+
for (const info of labelsAtDepth) {
|
|
1726
|
+
if (info.hasCustomLabel && info.dimension) {
|
|
1727
|
+
// Always update to track the deepest occurrence
|
|
1728
|
+
const existing = dimensionToDeepestDepth.get(info.dimension);
|
|
1729
|
+
if (!existing || depth > existing.depth) {
|
|
1730
|
+
dimensionToDeepestDepth.set(info.dimension, { depth, label: info.label });
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
// Build result array
|
|
1736
|
+
const result = [];
|
|
1737
|
+
for (let depth = 0; depth <= maxDepth; depth++) {
|
|
1738
|
+
// Check if any dimension should have its label at this depth
|
|
1739
|
+
let hasCustom = false;
|
|
1740
|
+
let customLabel = '';
|
|
1741
|
+
let dimension;
|
|
1742
|
+
for (const [dim, info] of dimensionToDeepestDepth) {
|
|
1743
|
+
if (info.depth === depth) {
|
|
1744
|
+
hasCustom = true;
|
|
1745
|
+
customLabel = info.label;
|
|
1746
|
+
dimension = dim;
|
|
1747
|
+
break;
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
result.push({
|
|
1751
|
+
dimension,
|
|
1752
|
+
label: customLabel,
|
|
1753
|
+
hasCustomLabel: hasCustom,
|
|
1754
|
+
});
|
|
1755
|
+
}
|
|
1756
|
+
return result;
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Collect label information from all header nodes at a specific depth.
|
|
1760
|
+
*
|
|
1761
|
+
* Note: sibling-label nodes are NOT marked as having custom labels for corner display
|
|
1762
|
+
* because they already display their labels in body row headers. This prevents
|
|
1763
|
+
* duplicate labels appearing in both corner and body.
|
|
1764
|
+
*/
|
|
1765
|
+
function collectLabelsAtDepth(nodes, targetDepth) {
|
|
1766
|
+
const labels = [];
|
|
1767
|
+
function collect(node) {
|
|
1768
|
+
if (node.depth === targetDepth) {
|
|
1769
|
+
// Check if this node has a custom label
|
|
1770
|
+
// For dimension types: label is custom if node.label exists and differs from value
|
|
1771
|
+
// For sibling-label types: DON'T mark as custom - they show labels in body, not corner
|
|
1772
|
+
let hasCustomLabel = false;
|
|
1773
|
+
if (node.type === 'dimension') {
|
|
1774
|
+
// A dimension node has a custom label if node.label is set and non-empty
|
|
1775
|
+
// The node.label comes from the AST when user writes: gender "mf"
|
|
1776
|
+
hasCustomLabel = node.label !== undefined && node.label !== '' && node.label !== node.dimension;
|
|
1777
|
+
}
|
|
1778
|
+
else if (node.type === 'sibling-label') {
|
|
1779
|
+
// Sibling-labels display their labels in body row headers, not in corner cells.
|
|
1780
|
+
// Don't mark as custom to prevent duplicate label display.
|
|
1781
|
+
hasCustomLabel = false;
|
|
1782
|
+
}
|
|
1783
|
+
labels.push({
|
|
1784
|
+
dimension: node.dimension,
|
|
1785
|
+
label: node.label ?? node.value,
|
|
1786
|
+
hasCustomLabel,
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
if (node.children) {
|
|
1790
|
+
for (const child of node.children) {
|
|
1791
|
+
collect(child);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
for (const node of nodes) {
|
|
1796
|
+
collect(node);
|
|
1797
|
+
}
|
|
1798
|
+
return labels;
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Collect all dimension names from an axis tree.
|
|
1802
|
+
* Returns an empty array if the axis contains only aggregates (no dimensions).
|
|
1803
|
+
*/
|
|
1804
|
+
function collectDimensionsFromAxis(node) {
|
|
1805
|
+
const dimensions = [];
|
|
1806
|
+
const seen = new Set();
|
|
1807
|
+
function walk(n) {
|
|
1808
|
+
if (!n)
|
|
1809
|
+
return;
|
|
1810
|
+
switch (n.nodeType) {
|
|
1811
|
+
case 'dimension':
|
|
1812
|
+
if (!seen.has(n.name)) {
|
|
1813
|
+
seen.add(n.name);
|
|
1814
|
+
dimensions.push(n.name);
|
|
1815
|
+
}
|
|
1816
|
+
if (n.child)
|
|
1817
|
+
walk(n.child);
|
|
1818
|
+
break;
|
|
1819
|
+
case 'total':
|
|
1820
|
+
if (n.child)
|
|
1821
|
+
walk(n.child);
|
|
1822
|
+
break;
|
|
1823
|
+
case 'siblings':
|
|
1824
|
+
for (const child of n.children) {
|
|
1825
|
+
walk(child);
|
|
1826
|
+
}
|
|
1827
|
+
break;
|
|
1828
|
+
case 'aggregate':
|
|
1829
|
+
case 'percentageAggregate':
|
|
1830
|
+
// Leaf nodes, no dimensions to collect
|
|
1831
|
+
break;
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
walk(node);
|
|
1835
|
+
return dimensions;
|
|
1836
|
+
}
|