tplm-lang 0.3.4 → 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.
- package/README.md +34 -4
- package/dist/compiler/grid-spec-builder.js +3 -5
- package/dist/compiler/index.d.ts +1 -1
- package/dist/compiler/index.js +1 -1
- package/dist/compiler/malloy-generator.js +19 -2
- package/dist/compiler/multi-query-utils.d.ts +6 -1
- package/dist/compiler/multi-query-utils.js +11 -6
- package/dist/compiler/percentile-utils.d.ts +25 -5
- package/dist/compiler/percentile-utils.js +35 -21
- package/dist/executor/index.js +22 -9
- package/dist/index.cjs +151 -41
- package/dist/index.d.ts +69 -1
- package/dist/index.js +139 -12
- package/dist/renderer/grid-renderer.js +23 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -94,6 +94,25 @@ const tpl = fromConnection({
|
|
|
94
94
|
dialect: "bigquery",
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
// SQL source (JOINs, CTEs, subqueries)
|
|
98
|
+
import { fromConnectionSQL } from "tplm-lang";
|
|
99
|
+
const tpl = fromConnectionSQL({
|
|
100
|
+
connection: conn,
|
|
101
|
+
sql: `
|
|
102
|
+
SELECT a.revenue, a.product, b.region
|
|
103
|
+
FROM \`p.d.sales\` a
|
|
104
|
+
JOIN \`p.d.regions\` b ON a.region_id = b.id
|
|
105
|
+
`,
|
|
106
|
+
dialect: "bigquery",
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// DuckDB SQL source (no connection needed)
|
|
110
|
+
import { fromDuckDBSQL } from "tplm-lang";
|
|
111
|
+
const tpl = fromDuckDBSQL(`
|
|
112
|
+
SELECT a.*, b.region
|
|
113
|
+
FROM 'sales.csv' a JOIN 'regions.csv' b ON a.region_id = b.id
|
|
114
|
+
`);
|
|
115
|
+
|
|
97
116
|
// Then query the same way
|
|
98
117
|
const { html } = await tpl.query("TABLE ROWS region * revenue.sum;");
|
|
99
118
|
```
|
|
@@ -195,7 +214,8 @@ COLS education * (revenue.sum ACROSS COLS | revenue.mean)
|
|
|
195
214
|
|
|
196
215
|
```typescript
|
|
197
216
|
import {
|
|
198
|
-
fromCSV, fromDuckDBTable, fromBigQueryTable, fromConnection
|
|
217
|
+
fromCSV, fromDuckDBTable, fromBigQueryTable, fromConnection,
|
|
218
|
+
fromConnectionSQL, fromDuckDBSQL,
|
|
199
219
|
} from "tplm-lang";
|
|
200
220
|
|
|
201
221
|
// CSV or Parquet files
|
|
@@ -217,6 +237,16 @@ const tpl = fromConnection({
|
|
|
217
237
|
dialect: "bigquery", // or "duckdb"
|
|
218
238
|
});
|
|
219
239
|
|
|
240
|
+
// SQL source — query JOINs, CTEs, or any SQL result
|
|
241
|
+
const tpl = fromConnectionSQL({
|
|
242
|
+
connection: conn,
|
|
243
|
+
sql: `SELECT a.*, b.region FROM \`p.d.sales\` a JOIN \`p.d.regions\` b ON a.rid = b.id`,
|
|
244
|
+
dialect: "bigquery",
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// DuckDB SQL source (no connection needed)
|
|
248
|
+
const tpl = fromDuckDBSQL(`SELECT a.*, b.region FROM 'sales.csv' a JOIN 'regions.csv' b ON a.rid = b.id`);
|
|
249
|
+
|
|
220
250
|
// Add computed dimensions with .extend()
|
|
221
251
|
const tplWithDims = tpl.extend(`
|
|
222
252
|
dimension: region is pick 'North' when region_code = 1 else 'South'
|
|
@@ -345,7 +375,7 @@ The renderer produces HTML tables with:
|
|
|
345
375
|
|
|
346
376
|
## Advanced: Using Full Malloy Models
|
|
347
377
|
|
|
348
|
-
For complex scenarios requiring
|
|
378
|
+
For complex scenarios requiring pre-defined measures, multiple sources, or advanced Malloy patterns, you can use full Malloy models. This is an advanced approach for users already familiar with Malloy. (For simple joins, consider `fromConnectionSQL` or `fromDuckDBSQL` instead.)
|
|
349
379
|
|
|
350
380
|
```typescript
|
|
351
381
|
import { createTPL } from "tplm-lang";
|
|
@@ -365,12 +395,12 @@ const { html } = await tpl.execute(
|
|
|
365
395
|
);
|
|
366
396
|
```
|
|
367
397
|
|
|
368
|
-
> **Note:** When using full Malloy models, **percentile aggregations are not supported** (`p25`, `p50`, `p75`, `p90`, `p95`, `p99`, `median`). This is because percentiles require pre-computing values via SQL window functions against the raw table, and TPL cannot introspect complex Malloy models to determine the underlying table structure. Use the Easy Connectors (fromCSV, fromDuckDBTable, fromBigQueryTable) for percentile support.
|
|
398
|
+
> **Note:** When using full Malloy models, **percentile aggregations are not supported** (`p25`, `p50`, `p75`, `p90`, `p95`, `p99`, `median`). This is because percentiles require pre-computing values via SQL window functions against the raw table, and TPL cannot introspect complex Malloy models to determine the underlying table structure. Use the Easy Connectors (fromCSV, fromDuckDBTable, fromBigQueryTable, fromConnectionSQL, fromDuckDBSQL) for percentile support.
|
|
369
399
|
|
|
370
400
|
**What belongs in your Malloy model:**
|
|
371
401
|
|
|
372
|
-
- Joins between tables
|
|
373
402
|
- Complex calculated measures TPL cannot express
|
|
403
|
+
- Advanced Malloy patterns (views, refinements, etc.)
|
|
374
404
|
|
|
375
405
|
**What TPL computes at query time:**
|
|
376
406
|
|
|
@@ -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
|
|
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
|
|
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
|
|
470
|
+
return `${measure} N`;
|
|
473
471
|
}
|
|
474
472
|
return `${measure} ${aggregation}`;
|
|
475
473
|
}
|
package/dist/compiler/index.d.ts
CHANGED
|
@@ -9,5 +9,5 @@ export { buildTableSpec, printTableSpec, } from './table-spec-builder.js';
|
|
|
9
9
|
export { generateQueryPlan, generateMalloyQueries, printQueryPlan, countRawQueries, } from './query-plan-generator.js';
|
|
10
10
|
export type { MalloyQuerySpec, GenerateMalloyOptions } from './query-plan-generator.js';
|
|
11
11
|
export { buildGridSpec, printGridSpec, type QueryResults, type BuildGridSpecOptions, } from './grid-spec-builder.js';
|
|
12
|
-
export { analyzeAndGeneratePercentileConfig, findPercentileAggregations, findDimensions, findPartitionLevels, findCollapsedDimensions, generatePercentileSourceSQL, generateMultiLevelPercentileSQL, generatePercentileMalloySource, transformTPLForPercentiles, postProcessMalloyForPercentiles, isPercentileMethod, PERCENTILE_METHODS, PERCENTILE_VALUES, PERCENTILE_LABELS, type PercentileInfo, type PercentileConfig, type PartitionLevel, type SqlDialect, } from './percentile-utils.js';
|
|
12
|
+
export { analyzeAndGeneratePercentileConfig, findPercentileAggregations, findDimensions, findPartitionLevels, findCollapsedDimensions, generatePercentileSourceSQL, generateMultiLevelPercentileSQL, generatePercentileMalloySource, transformTPLForPercentiles, postProcessMalloyForPercentiles, isPercentileMethod, PERCENTILE_METHODS, PERCENTILE_VALUES, PERCENTILE_LABELS, tableSource, sqlSource, type PercentileInfo, type PercentileConfig, type PartitionLevel, type SqlDialect, type SourceRef, } from './percentile-utils.js';
|
|
13
13
|
export { malloyPickToSqlCase, parseDimensionMappings, detectDimensionOrdering, type DimensionInfo, type DimensionOrderingProvider, } from './dimension-utils.js';
|
package/dist/compiler/index.js
CHANGED
|
@@ -12,6 +12,6 @@ export { generateQueryPlan, generateMalloyQueries, printQueryPlan, countRawQueri
|
|
|
12
12
|
// gridspec builder
|
|
13
13
|
export { buildGridSpec, printGridSpec, } from './grid-spec-builder.js';
|
|
14
14
|
// percentile support
|
|
15
|
-
export { analyzeAndGeneratePercentileConfig, findPercentileAggregations, findDimensions, findPartitionLevels, findCollapsedDimensions, generatePercentileSourceSQL, generateMultiLevelPercentileSQL, generatePercentileMalloySource, transformTPLForPercentiles, postProcessMalloyForPercentiles, isPercentileMethod, PERCENTILE_METHODS, PERCENTILE_VALUES, PERCENTILE_LABELS, } from './percentile-utils.js';
|
|
15
|
+
export { analyzeAndGeneratePercentileConfig, findPercentileAggregations, findDimensions, findPartitionLevels, findCollapsedDimensions, generatePercentileSourceSQL, generateMultiLevelPercentileSQL, generatePercentileMalloySource, transformTPLForPercentiles, postProcessMalloyForPercentiles, isPercentileMethod, PERCENTILE_METHODS, PERCENTILE_VALUES, PERCENTILE_LABELS, tableSource, sqlSource, } from './percentile-utils.js';
|
|
16
16
|
// dimension utilities (for percentile partitioning and ordering)
|
|
17
17
|
export { malloyPickToSqlCase, parseDimensionMappings, detectDimensionOrdering, } from './dimension-utils.js';
|
|
@@ -110,16 +110,33 @@ function buildNestedPivot(colGroupings, aggregates, indent) {
|
|
|
110
110
|
function buildAggregates(aggregates, indent = 2) {
|
|
111
111
|
const pad = ' '.repeat(indent);
|
|
112
112
|
const aggs = aggregates.map(a => {
|
|
113
|
-
return `${a.name} is ${a.measure
|
|
113
|
+
return `${a.name} is ${buildAggExpr(a.measure, a.aggregation)}`;
|
|
114
114
|
});
|
|
115
115
|
return `${pad}aggregate: ${aggs.join(', ')}`;
|
|
116
116
|
}
|
|
117
|
+
/** Build a Malloy aggregate expression (mirrors multi-query-utils buildAggExpression) */
|
|
118
|
+
function buildAggExpr(measure, aggregation) {
|
|
119
|
+
if (aggregation === 'count') {
|
|
120
|
+
if (measure && measure !== '__pending__' && measure !== '') {
|
|
121
|
+
return `count(${escapeForMalloy(measure)})`;
|
|
122
|
+
}
|
|
123
|
+
return 'count()';
|
|
124
|
+
}
|
|
125
|
+
if (!measure || measure === '__pending__')
|
|
126
|
+
return 'count()';
|
|
127
|
+
const methodMap = { mean: 'avg', stdev: 'stddev', pct: 'sum', pctn: 'count', pctsum: 'sum' };
|
|
128
|
+
return `${measure}.${methodMap[aggregation] ?? aggregation}()`;
|
|
129
|
+
}
|
|
130
|
+
const MALLOY_RESERVED = new Set(['all', 'and', 'as', 'asc', 'avg', 'by', 'case', 'cast', 'count', 'day', 'desc', 'dimension', 'else', 'end', 'exclude', 'extend', 'false', 'from', 'group', 'having', 'hour', 'import', 'is', 'join', 'limit', 'max', 'measure', 'min', 'minute', 'month', 'nest', 'not', 'now', 'null', 'number', 'on', 'or', 'order', 'pick', 'quarter', 'run', 'second', 'source', 'sum', 'then', 'true', 'week', 'when', 'where', 'year']);
|
|
131
|
+
function escapeForMalloy(name) {
|
|
132
|
+
return MALLOY_RESERVED.has(name.toLowerCase()) ? `\`${name}\`` : name;
|
|
133
|
+
}
|
|
117
134
|
/**
|
|
118
135
|
* Build total aggregate for column totals.
|
|
119
136
|
*/
|
|
120
137
|
function buildTotalAggregate(aggregates, label) {
|
|
121
138
|
const aggs = aggregates.map(a => {
|
|
122
|
-
return `${a.name} is ${a.measure
|
|
139
|
+
return `${a.name} is ${buildAggExpr(a.measure, a.aggregation)}`;
|
|
123
140
|
});
|
|
124
141
|
const nestName = label ? `"${label}"` : '"total"';
|
|
125
142
|
return ` nest: total is { aggregate: ${aggs.join(', ')} }`;
|
|
@@ -16,7 +16,12 @@ export declare function escapeFieldName(name: string): string;
|
|
|
16
16
|
*/
|
|
17
17
|
export declare function escapeWhereExpression(expr: string): string;
|
|
18
18
|
/**
|
|
19
|
-
* Build a Malloy aggregate expression from measure and aggregation
|
|
19
|
+
* Build a Malloy aggregate expression from measure and aggregation.
|
|
20
|
+
*
|
|
21
|
+
* Count semantics:
|
|
22
|
+
* - Standalone `count` or `n` (no measure) → `count()` — counts all rows
|
|
23
|
+
* - Field-bound `user_id.count` (with measure) → `count(user_id)` — counts distinct values
|
|
24
|
+
* This matches Malloy's native semantics where count(field) = COUNT(DISTINCT field).
|
|
20
25
|
*/
|
|
21
26
|
export declare function buildAggExpression(measure: string, aggregation: string): string;
|
|
22
27
|
/**
|
|
@@ -86,16 +86,21 @@ const AGG_METHOD_MAP = {
|
|
|
86
86
|
pctsum: 'sum',
|
|
87
87
|
};
|
|
88
88
|
/**
|
|
89
|
-
* Build a Malloy aggregate expression from measure and aggregation
|
|
89
|
+
* Build a Malloy aggregate expression from measure and aggregation.
|
|
90
|
+
*
|
|
91
|
+
* Count semantics:
|
|
92
|
+
* - Standalone `count` or `n` (no measure) → `count()` — counts all rows
|
|
93
|
+
* - Field-bound `user_id.count` (with measure) → `count(user_id)` — counts distinct values
|
|
94
|
+
* This matches Malloy's native semantics where count(field) = COUNT(DISTINCT field).
|
|
90
95
|
*/
|
|
91
96
|
export function buildAggExpression(measure, aggregation) {
|
|
92
97
|
const malloyMethod = AGG_METHOD_MAP[aggregation] ?? aggregation;
|
|
93
|
-
// Handle count
|
|
94
|
-
// count() counts all rows, not a specific field
|
|
95
|
-
// When users write "income.count" or "income.n", they semantically mean "count"
|
|
96
|
-
// since you can't "count" a measure - you can only count rows
|
|
98
|
+
// Handle count: standalone = row count, field-bound = distinct count
|
|
97
99
|
if (aggregation === 'count') {
|
|
98
|
-
|
|
100
|
+
if (measure && measure !== '__pending__' && measure !== '') {
|
|
101
|
+
return `count(${escapeFieldName(measure)})`; // distinct count of field
|
|
102
|
+
}
|
|
103
|
+
return 'count()'; // standalone row count
|
|
99
104
|
}
|
|
100
105
|
// Handle other aggregations without a measure (use placeholder)
|
|
101
106
|
// Also handle the __pending__ placeholder used for standalone count
|
|
@@ -61,6 +61,26 @@ export declare function findPercentileAggregations(stmt: TPLStatement): Percenti
|
|
|
61
61
|
*/
|
|
62
62
|
export declare function findDimensions(stmt: TPLStatement): string[];
|
|
63
63
|
export type SqlDialect = 'duckdb' | 'bigquery';
|
|
64
|
+
/**
|
|
65
|
+
* Describes the data source for SQL generation.
|
|
66
|
+
* - 'table': a table path (file or fully-qualified table name)
|
|
67
|
+
* - 'sql': a raw SQL query to wrap as a subquery
|
|
68
|
+
*/
|
|
69
|
+
export type SourceRef = {
|
|
70
|
+
type: 'table';
|
|
71
|
+
path: string;
|
|
72
|
+
} | {
|
|
73
|
+
type: 'sql';
|
|
74
|
+
query: string;
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Create a SourceRef from a table path string (backwards compatibility).
|
|
78
|
+
*/
|
|
79
|
+
export declare function tableSource(path: string): SourceRef;
|
|
80
|
+
/**
|
|
81
|
+
* Create a SourceRef from a raw SQL query.
|
|
82
|
+
*/
|
|
83
|
+
export declare function sqlSource(query: string): SourceRef;
|
|
64
84
|
/**
|
|
65
85
|
* Generate the SQL window function for a percentile computation.
|
|
66
86
|
*
|
|
@@ -71,14 +91,14 @@ export declare function generatePercentileWindowFunction(measure: string, quanti
|
|
|
71
91
|
/**
|
|
72
92
|
* Generate a derived SQL source that pre-computes percentiles.
|
|
73
93
|
*
|
|
74
|
-
* @param
|
|
94
|
+
* @param source The data source (table path or SQL query) — accepts string for backwards compat or SourceRef
|
|
75
95
|
* @param percentiles The percentiles to compute
|
|
76
96
|
* @param partitionColumns Columns to partition by (dimensions)
|
|
77
97
|
* @param dialect SQL dialect
|
|
78
98
|
* @param whereClause Optional WHERE clause to filter data before computing percentiles
|
|
79
99
|
* @returns SQL query string for the derived source
|
|
80
100
|
*/
|
|
81
|
-
export declare function generatePercentileSourceSQL(
|
|
101
|
+
export declare function generatePercentileSourceSQL(source: string | SourceRef, percentiles: PercentileInfo[], partitionColumns: string[], dialect: SqlDialect, whereClause?: string): string;
|
|
82
102
|
/**
|
|
83
103
|
* Generate a Malloy source definition that uses the derived SQL.
|
|
84
104
|
*
|
|
@@ -179,13 +199,13 @@ export interface PercentileConfig {
|
|
|
179
199
|
* Analyze a TPL statement and generate percentile configuration if needed.
|
|
180
200
|
*
|
|
181
201
|
* @param stmt Parsed TPL statement
|
|
182
|
-
* @param
|
|
202
|
+
* @param source Path to the source table (string) or a SourceRef
|
|
183
203
|
* @param sourceName Malloy source name
|
|
184
204
|
* @param dialect SQL dialect
|
|
185
205
|
* @param originalTPL Original TPL query string
|
|
186
206
|
* @returns Configuration for percentile support
|
|
187
207
|
*/
|
|
188
|
-
export declare function analyzeAndGeneratePercentileConfig(stmt: TPLStatement,
|
|
208
|
+
export declare function analyzeAndGeneratePercentileConfig(stmt: TPLStatement, source: string | SourceRef, sourceName: string, dialect: SqlDialect, originalTPL: string): PercentileConfig;
|
|
189
209
|
/**
|
|
190
210
|
* Generate SQL with window functions for multiple partition levels.
|
|
191
211
|
*
|
|
@@ -196,4 +216,4 @@ export declare function analyzeAndGeneratePercentileConfig(stmt: TPLStatement, t
|
|
|
196
216
|
* - __income_p50__gender_state: PARTITION BY gender, state (for detailed cells)
|
|
197
217
|
* - __income_p50__state: PARTITION BY state (for ALL gender cells)
|
|
198
218
|
*/
|
|
199
|
-
export declare function generateMultiLevelPercentileSQL(
|
|
219
|
+
export declare function generateMultiLevelPercentileSQL(source: string | SourceRef, percentiles: PercentileInfo[], partitionLevels: PartitionLevel[], dialect: SqlDialect, whereClause?: string): string;
|
|
@@ -179,6 +179,31 @@ export function findDimensions(stmt) {
|
|
|
179
179
|
collectFromAxis(stmt.colAxis);
|
|
180
180
|
return Array.from(dims);
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Build a FROM clause for the given source reference and dialect.
|
|
184
|
+
*/
|
|
185
|
+
function buildFromClause(source, dialect) {
|
|
186
|
+
if (source.type === 'sql') {
|
|
187
|
+
return `(${source.query}) _src`;
|
|
188
|
+
}
|
|
189
|
+
if (dialect === 'duckdb') {
|
|
190
|
+
return `'${source.path}'`;
|
|
191
|
+
}
|
|
192
|
+
// BigQuery uses backtick-quoted table names
|
|
193
|
+
return `\`${source.path}\``;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Create a SourceRef from a table path string (backwards compatibility).
|
|
197
|
+
*/
|
|
198
|
+
export function tableSource(path) {
|
|
199
|
+
return { type: 'table', path };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Create a SourceRef from a raw SQL query.
|
|
203
|
+
*/
|
|
204
|
+
export function sqlSource(query) {
|
|
205
|
+
return { type: 'sql', query };
|
|
206
|
+
}
|
|
182
207
|
/**
|
|
183
208
|
* Generate the SQL window function for a percentile computation.
|
|
184
209
|
*
|
|
@@ -200,14 +225,15 @@ export function generatePercentileWindowFunction(measure, quantile, partitionCol
|
|
|
200
225
|
/**
|
|
201
226
|
* Generate a derived SQL source that pre-computes percentiles.
|
|
202
227
|
*
|
|
203
|
-
* @param
|
|
228
|
+
* @param source The data source (table path or SQL query) — accepts string for backwards compat or SourceRef
|
|
204
229
|
* @param percentiles The percentiles to compute
|
|
205
230
|
* @param partitionColumns Columns to partition by (dimensions)
|
|
206
231
|
* @param dialect SQL dialect
|
|
207
232
|
* @param whereClause Optional WHERE clause to filter data before computing percentiles
|
|
208
233
|
* @returns SQL query string for the derived source
|
|
209
234
|
*/
|
|
210
|
-
export function generatePercentileSourceSQL(
|
|
235
|
+
export function generatePercentileSourceSQL(source, percentiles, partitionColumns, dialect, whereClause) {
|
|
236
|
+
const sourceRef = typeof source === 'string' ? tableSource(source) : source;
|
|
211
237
|
// Build SELECT clause with window functions
|
|
212
238
|
const windowFunctions = percentiles.map(p => {
|
|
213
239
|
const windowFunc = generatePercentileWindowFunction(p.measure, p.quantile, partitionColumns, dialect);
|
|
@@ -215,15 +241,7 @@ export function generatePercentileSourceSQL(tablePath, percentiles, partitionCol
|
|
|
215
241
|
});
|
|
216
242
|
// Build WHERE clause if provided
|
|
217
243
|
const wherePart = whereClause ? ` WHERE ${whereClause}` : '';
|
|
218
|
-
|
|
219
|
-
if (dialect === 'duckdb') {
|
|
220
|
-
// DuckDB can read files directly
|
|
221
|
-
return `SELECT *, ${windowFunctions.join(', ')} FROM '${tablePath}'${wherePart}`;
|
|
222
|
-
}
|
|
223
|
-
else {
|
|
224
|
-
// BigQuery uses fully qualified table names
|
|
225
|
-
return `SELECT *, ${windowFunctions.join(', ')} FROM \`${tablePath}\`${wherePart}`;
|
|
226
|
-
}
|
|
244
|
+
return `SELECT *, ${windowFunctions.join(', ')} FROM ${buildFromClause(sourceRef, dialect)}${wherePart}`;
|
|
227
245
|
}
|
|
228
246
|
/**
|
|
229
247
|
* Generate a Malloy source definition that uses the derived SQL.
|
|
@@ -487,13 +505,13 @@ function collectDimensionsFromGroup(group, dims) {
|
|
|
487
505
|
* Analyze a TPL statement and generate percentile configuration if needed.
|
|
488
506
|
*
|
|
489
507
|
* @param stmt Parsed TPL statement
|
|
490
|
-
* @param
|
|
508
|
+
* @param source Path to the source table (string) or a SourceRef
|
|
491
509
|
* @param sourceName Malloy source name
|
|
492
510
|
* @param dialect SQL dialect
|
|
493
511
|
* @param originalTPL Original TPL query string
|
|
494
512
|
* @returns Configuration for percentile support
|
|
495
513
|
*/
|
|
496
|
-
export function analyzeAndGeneratePercentileConfig(stmt,
|
|
514
|
+
export function analyzeAndGeneratePercentileConfig(stmt, source, sourceName, dialect, originalTPL) {
|
|
497
515
|
const percentiles = findPercentileAggregations(stmt);
|
|
498
516
|
if (percentiles.length === 0) {
|
|
499
517
|
return {
|
|
@@ -513,7 +531,7 @@ export function analyzeAndGeneratePercentileConfig(stmt, tablePath, sourceName,
|
|
|
513
531
|
// Find the full partition level (most dimensions) for TPL transformation
|
|
514
532
|
const fullLevel = partitionLevels.reduce((max, level) => level.dimensions.length > max.dimensions.length ? level : max);
|
|
515
533
|
// Generate SQL with all needed partition levels
|
|
516
|
-
const derivedSQL = generateMultiLevelPercentileSQL(
|
|
534
|
+
const derivedSQL = generateMultiLevelPercentileSQL(source, percentiles, partitionLevels, dialect, whereClause);
|
|
517
535
|
const derivedMalloySource = generatePercentileMalloySource(sourceName, derivedSQL, percentiles, dialect);
|
|
518
536
|
// Transform TPL to use full partition column names (post-processing will fix outer aggregates)
|
|
519
537
|
const transformedTPL = transformTPLForPercentiles(originalTPL, percentiles, fullLevel.suffix);
|
|
@@ -538,7 +556,8 @@ export function analyzeAndGeneratePercentileConfig(stmt, tablePath, sourceName,
|
|
|
538
556
|
* - __income_p50__gender_state: PARTITION BY gender, state (for detailed cells)
|
|
539
557
|
* - __income_p50__state: PARTITION BY state (for ALL gender cells)
|
|
540
558
|
*/
|
|
541
|
-
export function generateMultiLevelPercentileSQL(
|
|
559
|
+
export function generateMultiLevelPercentileSQL(source, percentiles, partitionLevels, dialect, whereClause) {
|
|
560
|
+
const sourceRef = typeof source === 'string' ? tableSource(source) : source;
|
|
542
561
|
const windowFunctions = [];
|
|
543
562
|
for (const level of partitionLevels) {
|
|
544
563
|
for (const p of percentiles) {
|
|
@@ -548,10 +567,5 @@ export function generateMultiLevelPercentileSQL(tablePath, percentiles, partitio
|
|
|
548
567
|
}
|
|
549
568
|
}
|
|
550
569
|
const wherePart = whereClause ? ` WHERE ${whereClause}` : '';
|
|
551
|
-
|
|
552
|
-
return `SELECT *, ${windowFunctions.join(', ')} FROM '${tablePath}'${wherePart}`;
|
|
553
|
-
}
|
|
554
|
-
else {
|
|
555
|
-
return `SELECT *, ${windowFunctions.join(', ')} FROM \`${tablePath}\`${wherePart}`;
|
|
556
|
-
}
|
|
570
|
+
return `SELECT *, ${windowFunctions.join(', ')} FROM ${buildFromClause(sourceRef, dialect)}${wherePart}`;
|
|
557
571
|
}
|
package/dist/executor/index.js
CHANGED
|
@@ -67,13 +67,24 @@ export async function createConnection(options) {
|
|
|
67
67
|
}
|
|
68
68
|
async function createBigQueryConnection(options) {
|
|
69
69
|
const { BigQueryConnection } = await loadBigQuery();
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
// Resolve credentials file: explicit path > default dev path > none (ADC)
|
|
71
|
+
const credentialsPath = options.credentialsPath
|
|
72
|
+
?? (fs.existsSync('./config/dev-credentials.json') ? './config/dev-credentials.json' : undefined);
|
|
73
|
+
let projectId = options.projectId;
|
|
74
|
+
if (credentialsPath) {
|
|
75
|
+
const credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf-8'));
|
|
76
|
+
projectId = projectId ?? credentials.project_id;
|
|
77
|
+
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve(credentialsPath);
|
|
78
|
+
}
|
|
79
|
+
// Fall back to standard env vars for project ID (gcloud config doesn't propagate to client libraries)
|
|
80
|
+
projectId = projectId
|
|
81
|
+
?? process.env.BIGQUERY_PROJECT_ID
|
|
82
|
+
?? process.env.GOOGLE_CLOUD_PROJECT
|
|
83
|
+
?? process.env.GCLOUD_PROJECT;
|
|
84
|
+
const config = { location: options.location ?? 'US' };
|
|
85
|
+
if (projectId)
|
|
86
|
+
config.projectId = projectId;
|
|
87
|
+
const connection = new BigQueryConnection('bigquery', {}, config);
|
|
77
88
|
connectionInstance = connection;
|
|
78
89
|
currentConnectionType = 'bigquery';
|
|
79
90
|
return connection;
|
|
@@ -228,10 +239,12 @@ source: names is duckdb.table('${csvPath}') extend {
|
|
|
228
239
|
}
|
|
229
240
|
`;
|
|
230
241
|
}
|
|
231
|
-
// BigQuery source
|
|
242
|
+
// BigQuery source — public dataset uses 'number' instead of 'births'
|
|
243
|
+
const bqTable = process.env.BIGQUERY_TEST_TABLE || 'bigquery-public-data.usa_names.usa_1910_current';
|
|
232
244
|
return `
|
|
233
|
-
source: names is bigquery.table('
|
|
245
|
+
source: names is bigquery.table('${bqTable}') extend {
|
|
234
246
|
dimension:
|
|
247
|
+
births is number
|
|
235
248
|
population is floor(births * 37.5)
|
|
236
249
|
measure:
|
|
237
250
|
total_births is births.sum()
|
package/dist/index.cjs
CHANGED
|
@@ -44,6 +44,8 @@ __export(index_exports, {
|
|
|
44
44
|
fromBigQueryTable: () => fromBigQueryTable,
|
|
45
45
|
fromCSV: () => fromCSV,
|
|
46
46
|
fromConnection: () => fromConnection,
|
|
47
|
+
fromConnectionSQL: () => fromConnectionSQL,
|
|
48
|
+
fromDuckDBSQL: () => fromDuckDBSQL,
|
|
47
49
|
fromDuckDBTable: () => fromDuckDBTable,
|
|
48
50
|
generateMalloyQueries: () => generateMalloyQueries,
|
|
49
51
|
generateQueryPlan: () => generateQueryPlan,
|
|
@@ -6534,6 +6536,9 @@ var AGG_METHOD_MAP = {
|
|
|
6534
6536
|
function buildAggExpression(measure, aggregation) {
|
|
6535
6537
|
const malloyMethod = AGG_METHOD_MAP[aggregation] ?? aggregation;
|
|
6536
6538
|
if (aggregation === "count") {
|
|
6539
|
+
if (measure && measure !== "__pending__" && measure !== "") {
|
|
6540
|
+
return `count(${escapeFieldName(measure)})`;
|
|
6541
|
+
}
|
|
6537
6542
|
return "count()";
|
|
6538
6543
|
}
|
|
6539
6544
|
if (!measure || measure === "__pending__") {
|
|
@@ -8758,7 +8763,7 @@ function formatAggregateName(measure, aggregation) {
|
|
|
8758
8763
|
return aggregation === "count" ? "N" : aggregation;
|
|
8759
8764
|
}
|
|
8760
8765
|
if (aggregation === "count") {
|
|
8761
|
-
return
|
|
8766
|
+
return `${measure} N`;
|
|
8762
8767
|
}
|
|
8763
8768
|
return `${measure} ${aggregation}`;
|
|
8764
8769
|
}
|
|
@@ -9842,6 +9847,21 @@ function findDimensions(stmt) {
|
|
|
9842
9847
|
collectFromAxis(stmt.colAxis);
|
|
9843
9848
|
return Array.from(dims);
|
|
9844
9849
|
}
|
|
9850
|
+
function buildFromClause(source, dialect) {
|
|
9851
|
+
if (source.type === "sql") {
|
|
9852
|
+
return `(${source.query}) _src`;
|
|
9853
|
+
}
|
|
9854
|
+
if (dialect === "duckdb") {
|
|
9855
|
+
return `'${source.path}'`;
|
|
9856
|
+
}
|
|
9857
|
+
return `\`${source.path}\``;
|
|
9858
|
+
}
|
|
9859
|
+
function tableSource(path2) {
|
|
9860
|
+
return { type: "table", path: path2 };
|
|
9861
|
+
}
|
|
9862
|
+
function sqlSource(query) {
|
|
9863
|
+
return { type: "sql", query };
|
|
9864
|
+
}
|
|
9845
9865
|
function generatePercentileWindowFunction(measure, quantile, partitionColumns, dialect) {
|
|
9846
9866
|
const partitionClause = partitionColumns.length > 0 ? `PARTITION BY ${partitionColumns.join(", ")}` : "";
|
|
9847
9867
|
if (dialect === "duckdb") {
|
|
@@ -10020,7 +10040,7 @@ function collectDimensionsFromGroup(group, dims) {
|
|
|
10020
10040
|
}
|
|
10021
10041
|
}
|
|
10022
10042
|
}
|
|
10023
|
-
function analyzeAndGeneratePercentileConfig(stmt,
|
|
10043
|
+
function analyzeAndGeneratePercentileConfig(stmt, source, sourceName, dialect, originalTPL) {
|
|
10024
10044
|
const percentiles = findPercentileAggregations(stmt);
|
|
10025
10045
|
if (percentiles.length === 0) {
|
|
10026
10046
|
return {
|
|
@@ -10039,7 +10059,7 @@ function analyzeAndGeneratePercentileConfig(stmt, tablePath, sourceName, dialect
|
|
|
10039
10059
|
(max, level) => level.dimensions.length > max.dimensions.length ? level : max
|
|
10040
10060
|
);
|
|
10041
10061
|
const derivedSQL = generateMultiLevelPercentileSQL(
|
|
10042
|
-
|
|
10062
|
+
source,
|
|
10043
10063
|
percentiles,
|
|
10044
10064
|
partitionLevels,
|
|
10045
10065
|
dialect,
|
|
@@ -10063,7 +10083,8 @@ function analyzeAndGeneratePercentileConfig(stmt, tablePath, sourceName, dialect
|
|
|
10063
10083
|
transformedTPL
|
|
10064
10084
|
};
|
|
10065
10085
|
}
|
|
10066
|
-
function generateMultiLevelPercentileSQL(
|
|
10086
|
+
function generateMultiLevelPercentileSQL(source, percentiles, partitionLevels, dialect, whereClause) {
|
|
10087
|
+
const sourceRef = typeof source === "string" ? tableSource(source) : source;
|
|
10067
10088
|
const windowFunctions = [];
|
|
10068
10089
|
for (const level of partitionLevels) {
|
|
10069
10090
|
for (const p of percentiles) {
|
|
@@ -10078,11 +10099,7 @@ function generateMultiLevelPercentileSQL(tablePath, percentiles, partitionLevels
|
|
|
10078
10099
|
}
|
|
10079
10100
|
}
|
|
10080
10101
|
const wherePart = whereClause ? ` WHERE ${whereClause}` : "";
|
|
10081
|
-
|
|
10082
|
-
return `SELECT *, ${windowFunctions.join(", ")} FROM '${tablePath}'${wherePart}`;
|
|
10083
|
-
} else {
|
|
10084
|
-
return `SELECT *, ${windowFunctions.join(", ")} FROM \`${tablePath}\`${wherePart}`;
|
|
10085
|
-
}
|
|
10102
|
+
return `SELECT *, ${windowFunctions.join(", ")} FROM ${buildFromClause(sourceRef, dialect)}${wherePart}`;
|
|
10086
10103
|
}
|
|
10087
10104
|
|
|
10088
10105
|
// packages/compiler/dimension-utils.ts
|
|
@@ -10192,6 +10209,17 @@ ${ordinalPick}`);
|
|
|
10192
10209
|
}
|
|
10193
10210
|
|
|
10194
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
|
+
}
|
|
10195
10223
|
function renderGridToHTML(grid, options = {}) {
|
|
10196
10224
|
const {
|
|
10197
10225
|
tableClass = "tpl-table",
|
|
@@ -10274,7 +10302,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
|
10274
10302
|
lines.push('<th class="tpl-corner"></th>');
|
|
10275
10303
|
}
|
|
10276
10304
|
}
|
|
10277
|
-
lines.push(`<th>${escapeHTML(grid.aggregates[0]
|
|
10305
|
+
lines.push(`<th>${escapeHTML(grid.aggregates[0] ? formatAggDisplayName(grid.aggregates[0]) : "Value")}</th>`);
|
|
10278
10306
|
lines.push("</tr>");
|
|
10279
10307
|
lines.push("</thead>");
|
|
10280
10308
|
return;
|
|
@@ -10306,7 +10334,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
|
10306
10334
|
lines.push("<th>Value</th>");
|
|
10307
10335
|
} else {
|
|
10308
10336
|
for (const agg of grid.aggregates) {
|
|
10309
|
-
lines.push(`<th>${escapeHTML(agg
|
|
10337
|
+
lines.push(`<th>${escapeHTML(formatAggDisplayName(agg))}</th>`);
|
|
10310
10338
|
}
|
|
10311
10339
|
}
|
|
10312
10340
|
lines.push("</tr>");
|
|
@@ -10523,23 +10551,13 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
|
|
|
10523
10551
|
if (aggInfo?.label) {
|
|
10524
10552
|
displayAgg = aggInfo.label;
|
|
10525
10553
|
} else if (aggInfo) {
|
|
10526
|
-
|
|
10527
|
-
displayAgg = "count";
|
|
10528
|
-
} else {
|
|
10529
|
-
displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
|
|
10530
|
-
}
|
|
10554
|
+
displayAgg = formatAggDisplayName(aggInfo);
|
|
10531
10555
|
} else {
|
|
10532
10556
|
displayAgg = aggregateName.replace(/^__pending___/, "").replace(/_/g, ".");
|
|
10533
10557
|
}
|
|
10534
10558
|
} else if (aggregates.length === 1) {
|
|
10535
10559
|
const aggInfo = aggregates[0];
|
|
10536
|
-
|
|
10537
|
-
displayAgg = aggInfo.label;
|
|
10538
|
-
} else if (aggInfo.aggregation === "count") {
|
|
10539
|
-
displayAgg = "count";
|
|
10540
|
-
} else {
|
|
10541
|
-
displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
|
|
10542
|
-
}
|
|
10560
|
+
displayAgg = formatAggDisplayName(aggInfo);
|
|
10543
10561
|
}
|
|
10544
10562
|
if (displayAgg) {
|
|
10545
10563
|
return dimPath ? `${dimPath} \u2192 ${displayAgg}` : displayAgg;
|
|
@@ -10641,14 +10659,20 @@ async function createConnection(options) {
|
|
|
10641
10659
|
}
|
|
10642
10660
|
async function createBigQueryConnection(options) {
|
|
10643
10661
|
const { BigQueryConnection } = await loadBigQuery();
|
|
10644
|
-
const credentialsPath = options.credentialsPath ?? "./config/dev-credentials.json";
|
|
10645
|
-
|
|
10646
|
-
|
|
10647
|
-
|
|
10662
|
+
const credentialsPath = options.credentialsPath ?? (fs.existsSync("./config/dev-credentials.json") ? "./config/dev-credentials.json" : void 0);
|
|
10663
|
+
let projectId = options.projectId;
|
|
10664
|
+
if (credentialsPath) {
|
|
10665
|
+
const credentials = JSON.parse(fs.readFileSync(credentialsPath, "utf-8"));
|
|
10666
|
+
projectId = projectId ?? credentials.project_id;
|
|
10667
|
+
process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve(credentialsPath);
|
|
10668
|
+
}
|
|
10669
|
+
projectId = projectId ?? process.env.BIGQUERY_PROJECT_ID ?? process.env.GOOGLE_CLOUD_PROJECT ?? process.env.GCLOUD_PROJECT;
|
|
10670
|
+
const config = { location: options.location ?? "US" };
|
|
10671
|
+
if (projectId) config.projectId = projectId;
|
|
10648
10672
|
const connection = new BigQueryConnection(
|
|
10649
10673
|
"bigquery",
|
|
10650
10674
|
{},
|
|
10651
|
-
|
|
10675
|
+
config
|
|
10652
10676
|
);
|
|
10653
10677
|
connectionInstance = connection;
|
|
10654
10678
|
currentConnectionType = "bigquery";
|
|
@@ -10719,9 +10743,11 @@ source: names is duckdb.table('${csvPath}') extend {
|
|
|
10719
10743
|
}
|
|
10720
10744
|
`;
|
|
10721
10745
|
}
|
|
10746
|
+
const bqTable = process.env.BIGQUERY_TEST_TABLE || "bigquery-public-data.usa_names.usa_1910_current";
|
|
10722
10747
|
return `
|
|
10723
|
-
source: names is bigquery.table('
|
|
10748
|
+
source: names is bigquery.table('${bqTable}') extend {
|
|
10724
10749
|
dimension:
|
|
10750
|
+
births is number
|
|
10725
10751
|
population is floor(births * 37.5)
|
|
10726
10752
|
measure:
|
|
10727
10753
|
total_births is births.sum()
|
|
@@ -10867,11 +10893,44 @@ function fromConnection(options) {
|
|
|
10867
10893
|
dialect
|
|
10868
10894
|
});
|
|
10869
10895
|
}
|
|
10896
|
+
function fromConnectionSQL(options) {
|
|
10897
|
+
validateSQL(options.sql);
|
|
10898
|
+
const dialect = options.dialect ?? "bigquery";
|
|
10899
|
+
const connType = dialect === "duckdb" ? "duckdb" : "bigquery";
|
|
10900
|
+
setConnection(options.connection, connType);
|
|
10901
|
+
const sourceName = "data";
|
|
10902
|
+
const prefix = dialect === "duckdb" ? "duckdb" : "bigquery";
|
|
10903
|
+
const model = `source: ${sourceName} is ${prefix}.sql("""${options.sql}""")`;
|
|
10904
|
+
return new EasyTPL(model, sourceName, {
|
|
10905
|
+
...options,
|
|
10906
|
+
sourceSQL: options.sql,
|
|
10907
|
+
dialect
|
|
10908
|
+
});
|
|
10909
|
+
}
|
|
10910
|
+
function fromDuckDBSQL(sql, options = {}) {
|
|
10911
|
+
validateSQL(sql);
|
|
10912
|
+
setPendingConnection({ type: "duckdb" });
|
|
10913
|
+
const sourceName = "data";
|
|
10914
|
+
const model = `source: ${sourceName} is duckdb.sql("""${sql}""")`;
|
|
10915
|
+
return new EasyTPL(model, sourceName, {
|
|
10916
|
+
...options,
|
|
10917
|
+
sourceSQL: sql,
|
|
10918
|
+
dialect: "duckdb"
|
|
10919
|
+
});
|
|
10920
|
+
}
|
|
10921
|
+
function validateSQL(sql) {
|
|
10922
|
+
if (sql.includes('"""')) {
|
|
10923
|
+
throw new Error(
|
|
10924
|
+
`SQL source cannot contain triple-quotes (""") as they conflict with Malloy's SQL embedding syntax.`
|
|
10925
|
+
);
|
|
10926
|
+
}
|
|
10927
|
+
}
|
|
10870
10928
|
var EasyTPL = class _EasyTPL {
|
|
10871
10929
|
tpl;
|
|
10872
10930
|
model;
|
|
10873
10931
|
sourceName;
|
|
10874
10932
|
tablePath;
|
|
10933
|
+
sourceSQL;
|
|
10875
10934
|
dialect;
|
|
10876
10935
|
dimensionMap;
|
|
10877
10936
|
orderingProvider;
|
|
@@ -10880,6 +10939,7 @@ var EasyTPL = class _EasyTPL {
|
|
|
10880
10939
|
this.model = model;
|
|
10881
10940
|
this.sourceName = sourceName;
|
|
10882
10941
|
this.tablePath = options.tablePath;
|
|
10942
|
+
this.sourceSQL = options.sourceSQL;
|
|
10883
10943
|
this.dialect = options.dialect;
|
|
10884
10944
|
this.dimensionMap = options.dimensionMap || /* @__PURE__ */ new Map();
|
|
10885
10945
|
this.orderingProvider = options.orderingProvider;
|
|
@@ -10900,11 +10960,12 @@ var EasyTPL = class _EasyTPL {
|
|
|
10900
10960
|
* ```
|
|
10901
10961
|
*/
|
|
10902
10962
|
async query(tplSource) {
|
|
10903
|
-
|
|
10963
|
+
const sourceRef = this.getSourceRef();
|
|
10964
|
+
if (sourceRef && this.dialect) {
|
|
10904
10965
|
const stmt = parse2(tplSource);
|
|
10905
10966
|
const percentileConfig = analyzeAndGeneratePercentileConfig(
|
|
10906
10967
|
stmt,
|
|
10907
|
-
|
|
10968
|
+
sourceRef,
|
|
10908
10969
|
this.sourceName,
|
|
10909
10970
|
this.dialect,
|
|
10910
10971
|
tplSource
|
|
@@ -10926,7 +10987,7 @@ var EasyTPL = class _EasyTPL {
|
|
|
10926
10987
|
whereClause = rawWhere;
|
|
10927
10988
|
}
|
|
10928
10989
|
const derivedSQL = generateMultiLevelPercentileSQL(
|
|
10929
|
-
|
|
10990
|
+
sourceRef,
|
|
10930
10991
|
percentileConfig.percentiles,
|
|
10931
10992
|
mappedPartitionLevels,
|
|
10932
10993
|
this.dialect,
|
|
@@ -11011,15 +11072,7 @@ ${processedMalloy}`;
|
|
|
11011
11072
|
* ```
|
|
11012
11073
|
*/
|
|
11013
11074
|
extend(malloyExtend) {
|
|
11014
|
-
const
|
|
11015
|
-
/^(source: \w+ is [^)]+\))$/,
|
|
11016
|
-
`$1 extend {
|
|
11017
|
-
${malloyExtend}
|
|
11018
|
-
}`
|
|
11019
|
-
);
|
|
11020
|
-
const finalModel = extendedModel === this.model ? this.model.replace(/}$/, `
|
|
11021
|
-
${malloyExtend}
|
|
11022
|
-
}`) : extendedModel;
|
|
11075
|
+
const finalModel = this.addExtendBlock(malloyExtend);
|
|
11023
11076
|
const newMappings = parseDimensionMappings(malloyExtend);
|
|
11024
11077
|
const mergedMap = new Map(this.dimensionMap);
|
|
11025
11078
|
for (const [dim, col] of newMappings) {
|
|
@@ -11042,6 +11095,7 @@ ${malloyExtend}
|
|
|
11042
11095
|
}
|
|
11043
11096
|
return new _EasyTPL(modelWithAutoDims, this.sourceName, {
|
|
11044
11097
|
tablePath: this.tablePath,
|
|
11098
|
+
sourceSQL: this.sourceSQL,
|
|
11045
11099
|
dialect: this.dialect,
|
|
11046
11100
|
dimensionMap: mergedMap,
|
|
11047
11101
|
orderingProvider
|
|
@@ -11063,6 +11117,10 @@ ${malloyExtend}
|
|
|
11063
11117
|
getDimensionMap() {
|
|
11064
11118
|
return new Map(this.dimensionMap);
|
|
11065
11119
|
}
|
|
11120
|
+
/** Get the source SQL (if this is a SQL-backed source) */
|
|
11121
|
+
getSourceSQL() {
|
|
11122
|
+
return this.sourceSQL;
|
|
11123
|
+
}
|
|
11066
11124
|
/** Get dimension→raw column mapping (for backward compatibility) */
|
|
11067
11125
|
getDimensionToColumnMap() {
|
|
11068
11126
|
const result = /* @__PURE__ */ new Map();
|
|
@@ -11071,6 +11129,56 @@ ${malloyExtend}
|
|
|
11071
11129
|
}
|
|
11072
11130
|
return result;
|
|
11073
11131
|
}
|
|
11132
|
+
/**
|
|
11133
|
+
* Build a SourceRef for percentile SQL generation.
|
|
11134
|
+
* Prefers sourceSQL (wrapped as subquery) over tablePath.
|
|
11135
|
+
*/
|
|
11136
|
+
getSourceRef() {
|
|
11137
|
+
if (this.sourceSQL) {
|
|
11138
|
+
return sqlSource(this.sourceSQL);
|
|
11139
|
+
}
|
|
11140
|
+
if (this.tablePath) {
|
|
11141
|
+
return tableSource(this.tablePath);
|
|
11142
|
+
}
|
|
11143
|
+
return void 0;
|
|
11144
|
+
}
|
|
11145
|
+
/**
|
|
11146
|
+
* Add an extend block to the model string.
|
|
11147
|
+
* Handles both table-based models (single closing paren) and
|
|
11148
|
+
* SQL-based models (triple-quoted SQL with embedded parentheses).
|
|
11149
|
+
*/
|
|
11150
|
+
addExtendBlock(malloyExtend) {
|
|
11151
|
+
if (this.model.includes(" extend {")) {
|
|
11152
|
+
return this.model.replace(/}\s*$/, `
|
|
11153
|
+
${malloyExtend}
|
|
11154
|
+
}`);
|
|
11155
|
+
}
|
|
11156
|
+
const sqlSourceMatch = this.model.match(
|
|
11157
|
+
/^(source:\s+\w+\s+is\s+\w+\.sql\("""[\s\S]*?"""\))$/
|
|
11158
|
+
);
|
|
11159
|
+
if (sqlSourceMatch) {
|
|
11160
|
+
return `${sqlSourceMatch[1]} extend {
|
|
11161
|
+
${malloyExtend}
|
|
11162
|
+
}`;
|
|
11163
|
+
}
|
|
11164
|
+
const tableSourceMatch = this.model.match(
|
|
11165
|
+
/^(source:\s+\w+\s+is\s+\w+\.table\('[^']*'\))$/
|
|
11166
|
+
);
|
|
11167
|
+
if (tableSourceMatch) {
|
|
11168
|
+
return `${tableSourceMatch[1]} extend {
|
|
11169
|
+
${malloyExtend}
|
|
11170
|
+
}`;
|
|
11171
|
+
}
|
|
11172
|
+
const genericMatch = this.model.match(/^([\s\S]*\))$/);
|
|
11173
|
+
if (genericMatch) {
|
|
11174
|
+
return `${genericMatch[1]} extend {
|
|
11175
|
+
${malloyExtend}
|
|
11176
|
+
}`;
|
|
11177
|
+
}
|
|
11178
|
+
return `${this.model} extend {
|
|
11179
|
+
${malloyExtend}
|
|
11180
|
+
}`;
|
|
11181
|
+
}
|
|
11074
11182
|
};
|
|
11075
11183
|
// Annotate the CommonJS export names for ESM import in node:
|
|
11076
11184
|
0 && (module.exports = {
|
|
@@ -11088,6 +11196,8 @@ ${malloyExtend}
|
|
|
11088
11196
|
fromBigQueryTable,
|
|
11089
11197
|
fromCSV,
|
|
11090
11198
|
fromConnection,
|
|
11199
|
+
fromConnectionSQL,
|
|
11200
|
+
fromDuckDBSQL,
|
|
11091
11201
|
fromDuckDBTable,
|
|
11092
11202
|
generateMalloyQueries,
|
|
11093
11203
|
generateQueryPlan,
|
package/dist/index.d.ts
CHANGED
|
@@ -214,12 +214,65 @@ export declare function fromConnection(options: {
|
|
|
214
214
|
table: string;
|
|
215
215
|
dialect?: SqlDialect;
|
|
216
216
|
} & TPLOptions): EasyTPL;
|
|
217
|
+
/**
|
|
218
|
+
* Query any SQL query result using a pre-built Malloy connection.
|
|
219
|
+
* Use this when your data comes from a SQL query (e.g., a JOIN) rather than a single table.
|
|
220
|
+
*
|
|
221
|
+
* The SQL is used as Malloy's `bigquery.sql("""...""")` or `duckdb.sql("""...""")` source,
|
|
222
|
+
* which supports schema introspection and querying without creating views or temp tables.
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```typescript
|
|
226
|
+
* import { BigQueryConnection } from '@malloydata/db-bigquery';
|
|
227
|
+
* import { fromConnectionSQL } from 'tplm-lang';
|
|
228
|
+
*
|
|
229
|
+
* const connection = new BigQueryConnection('bigquery', undefined, {
|
|
230
|
+
* projectId: 'my-project',
|
|
231
|
+
* credentials: { client_email, private_key },
|
|
232
|
+
* });
|
|
233
|
+
*
|
|
234
|
+
* const tpl = fromConnectionSQL({
|
|
235
|
+
* connection,
|
|
236
|
+
* sql: `
|
|
237
|
+
* SELECT a.revenue, a.product_category, b.region
|
|
238
|
+
* FROM \`p.d.sales\` a
|
|
239
|
+
* JOIN \`p.d.customers\` b ON a.customer_id = b.customer_id
|
|
240
|
+
* `,
|
|
241
|
+
* dialect: 'bigquery',
|
|
242
|
+
* });
|
|
243
|
+
* const { html } = await tpl.query('TABLE ROWS region * revenue.sum COLS product_category;');
|
|
244
|
+
* ```
|
|
245
|
+
*/
|
|
246
|
+
export declare function fromConnectionSQL(options: {
|
|
247
|
+
connection: Connection;
|
|
248
|
+
sql: string;
|
|
249
|
+
dialect?: SqlDialect;
|
|
250
|
+
} & TPLOptions): EasyTPL;
|
|
251
|
+
/**
|
|
252
|
+
* Query a DuckDB SQL result directly.
|
|
253
|
+
* Use this when your data comes from a SQL query rather than a file.
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```typescript
|
|
257
|
+
* import { fromDuckDBSQL } from 'tplm-lang';
|
|
258
|
+
*
|
|
259
|
+
* const tpl = fromDuckDBSQL(`
|
|
260
|
+
* SELECT a.*, b.region
|
|
261
|
+
* FROM 'sales.csv' a
|
|
262
|
+
* JOIN 'regions.csv' b ON a.region_id = b.id
|
|
263
|
+
* `);
|
|
264
|
+
* const { html } = await tpl.query('TABLE ROWS region * revenue.sum;');
|
|
265
|
+
* ```
|
|
266
|
+
*/
|
|
267
|
+
export declare function fromDuckDBSQL(sql: string, options?: TPLOptions): EasyTPL;
|
|
217
268
|
/**
|
|
218
269
|
* Extended options for EasyTPL that include percentile support metadata.
|
|
219
270
|
*/
|
|
220
271
|
interface EasyTPLOptions extends TPLOptions {
|
|
221
272
|
/** Path to the source table (for percentile support) */
|
|
222
273
|
tablePath?: string;
|
|
274
|
+
/** Raw SQL source (alternative to tablePath, for SQL-backed sources) */
|
|
275
|
+
sourceSQL?: string;
|
|
223
276
|
/** SQL dialect (for percentile support) */
|
|
224
277
|
dialect?: SqlDialect;
|
|
225
278
|
/** Mapping from computed dimension names to dimension info (for percentile partitioning) */
|
|
@@ -230,7 +283,8 @@ interface EasyTPLOptions extends TPLOptions {
|
|
|
230
283
|
import { type DimensionInfo } from './compiler/dimension-utils.js';
|
|
231
284
|
/**
|
|
232
285
|
* Simplified TPL API for direct table queries.
|
|
233
|
-
* Created by fromDuckDBTable, fromCSV,
|
|
286
|
+
* Created by fromDuckDBTable, fromCSV, fromBigQueryTable, fromConnection,
|
|
287
|
+
* fromConnectionSQL, or fromDuckDBSQL.
|
|
234
288
|
*
|
|
235
289
|
* Supports percentile aggregations (p25, p50/median, p75, p90, p95, p99)
|
|
236
290
|
* by automatically generating derived SQL sources with window functions.
|
|
@@ -240,6 +294,7 @@ export declare class EasyTPL {
|
|
|
240
294
|
private model;
|
|
241
295
|
private sourceName;
|
|
242
296
|
private tablePath?;
|
|
297
|
+
private sourceSQL?;
|
|
243
298
|
private dialect?;
|
|
244
299
|
private dimensionMap;
|
|
245
300
|
private orderingProvider?;
|
|
@@ -290,6 +345,19 @@ export declare class EasyTPL {
|
|
|
290
345
|
getDialect(): SqlDialect | undefined;
|
|
291
346
|
/** Get the dimension info map (for percentile partitioning) */
|
|
292
347
|
getDimensionMap(): Map<string, DimensionInfo>;
|
|
348
|
+
/** Get the source SQL (if this is a SQL-backed source) */
|
|
349
|
+
getSourceSQL(): string | undefined;
|
|
293
350
|
/** Get dimension→raw column mapping (for backward compatibility) */
|
|
294
351
|
getDimensionToColumnMap(): Map<string, string>;
|
|
352
|
+
/**
|
|
353
|
+
* Build a SourceRef for percentile SQL generation.
|
|
354
|
+
* Prefers sourceSQL (wrapped as subquery) over tablePath.
|
|
355
|
+
*/
|
|
356
|
+
private getSourceRef;
|
|
357
|
+
/**
|
|
358
|
+
* Add an extend block to the model string.
|
|
359
|
+
* Handles both table-based models (single closing paren) and
|
|
360
|
+
* SQL-based models (triple-quoted SQL with embedded parentheses).
|
|
361
|
+
*/
|
|
362
|
+
private addExtendBlock;
|
|
295
363
|
}
|
package/dist/index.js
CHANGED
|
@@ -124,7 +124,7 @@ export function createBigQueryTPL(options = {}) {
|
|
|
124
124
|
// EASY CONNECTORS - Skip Malloy, just query your data
|
|
125
125
|
// ============================================================================
|
|
126
126
|
// Import percentile utilities for EasyTPL
|
|
127
|
-
import { analyzeAndGeneratePercentileConfig, postProcessMalloyForPercentiles, generateMultiLevelPercentileSQL, } from './compiler/percentile-utils.js';
|
|
127
|
+
import { analyzeAndGeneratePercentileConfig, postProcessMalloyForPercentiles, generateMultiLevelPercentileSQL, sqlSource, tableSource, } from './compiler/percentile-utils.js';
|
|
128
128
|
/**
|
|
129
129
|
* Query a DuckDB-compatible file (CSV, Parquet) directly.
|
|
130
130
|
* No Malloy knowledge required - just point to your file and query.
|
|
@@ -220,11 +220,90 @@ export function fromConnection(options) {
|
|
|
220
220
|
dialect,
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Query any SQL query result using a pre-built Malloy connection.
|
|
225
|
+
* Use this when your data comes from a SQL query (e.g., a JOIN) rather than a single table.
|
|
226
|
+
*
|
|
227
|
+
* The SQL is used as Malloy's `bigquery.sql("""...""")` or `duckdb.sql("""...""")` source,
|
|
228
|
+
* which supports schema introspection and querying without creating views or temp tables.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```typescript
|
|
232
|
+
* import { BigQueryConnection } from '@malloydata/db-bigquery';
|
|
233
|
+
* import { fromConnectionSQL } from 'tplm-lang';
|
|
234
|
+
*
|
|
235
|
+
* const connection = new BigQueryConnection('bigquery', undefined, {
|
|
236
|
+
* projectId: 'my-project',
|
|
237
|
+
* credentials: { client_email, private_key },
|
|
238
|
+
* });
|
|
239
|
+
*
|
|
240
|
+
* const tpl = fromConnectionSQL({
|
|
241
|
+
* connection,
|
|
242
|
+
* sql: `
|
|
243
|
+
* SELECT a.revenue, a.product_category, b.region
|
|
244
|
+
* FROM \`p.d.sales\` a
|
|
245
|
+
* JOIN \`p.d.customers\` b ON a.customer_id = b.customer_id
|
|
246
|
+
* `,
|
|
247
|
+
* dialect: 'bigquery',
|
|
248
|
+
* });
|
|
249
|
+
* const { html } = await tpl.query('TABLE ROWS region * revenue.sum COLS product_category;');
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
export function fromConnectionSQL(options) {
|
|
253
|
+
validateSQL(options.sql);
|
|
254
|
+
const dialect = options.dialect ?? 'bigquery';
|
|
255
|
+
const connType = dialect === 'duckdb' ? 'duckdb' : 'bigquery';
|
|
256
|
+
setConnection(options.connection, connType);
|
|
257
|
+
const sourceName = 'data';
|
|
258
|
+
const prefix = dialect === 'duckdb' ? 'duckdb' : 'bigquery';
|
|
259
|
+
const model = `source: ${sourceName} is ${prefix}.sql("""${options.sql}""")`;
|
|
260
|
+
return new EasyTPL(model, sourceName, {
|
|
261
|
+
...options,
|
|
262
|
+
sourceSQL: options.sql,
|
|
263
|
+
dialect,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Query a DuckDB SQL result directly.
|
|
268
|
+
* Use this when your data comes from a SQL query rather than a file.
|
|
269
|
+
*
|
|
270
|
+
* @example
|
|
271
|
+
* ```typescript
|
|
272
|
+
* import { fromDuckDBSQL } from 'tplm-lang';
|
|
273
|
+
*
|
|
274
|
+
* const tpl = fromDuckDBSQL(`
|
|
275
|
+
* SELECT a.*, b.region
|
|
276
|
+
* FROM 'sales.csv' a
|
|
277
|
+
* JOIN 'regions.csv' b ON a.region_id = b.id
|
|
278
|
+
* `);
|
|
279
|
+
* const { html } = await tpl.query('TABLE ROWS region * revenue.sum;');
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
export function fromDuckDBSQL(sql, options = {}) {
|
|
283
|
+
validateSQL(sql);
|
|
284
|
+
setPendingConnection({ type: 'duckdb' });
|
|
285
|
+
const sourceName = 'data';
|
|
286
|
+
const model = `source: ${sourceName} is duckdb.sql("""${sql}""")`;
|
|
287
|
+
return new EasyTPL(model, sourceName, {
|
|
288
|
+
...options,
|
|
289
|
+
sourceSQL: sql,
|
|
290
|
+
dialect: 'duckdb',
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Validate that SQL doesn't contain triple-quotes which would break Malloy's SQL embedding.
|
|
295
|
+
*/
|
|
296
|
+
function validateSQL(sql) {
|
|
297
|
+
if (sql.includes('"""')) {
|
|
298
|
+
throw new Error('SQL source cannot contain triple-quotes (""") as they conflict with Malloy\'s SQL embedding syntax.');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
223
301
|
// Import dimension utilities from compiler
|
|
224
302
|
import { parseDimensionMappings, detectDimensionOrdering, } from './compiler/dimension-utils.js';
|
|
225
303
|
/**
|
|
226
304
|
* Simplified TPL API for direct table queries.
|
|
227
|
-
* Created by fromDuckDBTable, fromCSV,
|
|
305
|
+
* Created by fromDuckDBTable, fromCSV, fromBigQueryTable, fromConnection,
|
|
306
|
+
* fromConnectionSQL, or fromDuckDBSQL.
|
|
228
307
|
*
|
|
229
308
|
* Supports percentile aggregations (p25, p50/median, p75, p90, p95, p99)
|
|
230
309
|
* by automatically generating derived SQL sources with window functions.
|
|
@@ -234,6 +313,7 @@ export class EasyTPL {
|
|
|
234
313
|
model;
|
|
235
314
|
sourceName;
|
|
236
315
|
tablePath;
|
|
316
|
+
sourceSQL;
|
|
237
317
|
dialect;
|
|
238
318
|
dimensionMap;
|
|
239
319
|
orderingProvider;
|
|
@@ -242,6 +322,7 @@ export class EasyTPL {
|
|
|
242
322
|
this.model = model;
|
|
243
323
|
this.sourceName = sourceName;
|
|
244
324
|
this.tablePath = options.tablePath;
|
|
325
|
+
this.sourceSQL = options.sourceSQL;
|
|
245
326
|
this.dialect = options.dialect;
|
|
246
327
|
this.dimensionMap = options.dimensionMap || new Map();
|
|
247
328
|
this.orderingProvider = options.orderingProvider;
|
|
@@ -262,11 +343,13 @@ export class EasyTPL {
|
|
|
262
343
|
* ```
|
|
263
344
|
*/
|
|
264
345
|
async query(tplSource) {
|
|
265
|
-
//
|
|
266
|
-
|
|
346
|
+
// Resolve the source reference for percentile support
|
|
347
|
+
const sourceRef = this.getSourceRef();
|
|
348
|
+
// Check if we can handle percentiles (need a source and dialect)
|
|
349
|
+
if (sourceRef && this.dialect) {
|
|
267
350
|
// Parse the TPL to detect percentiles
|
|
268
351
|
const stmt = parse(tplSource);
|
|
269
|
-
const percentileConfig = analyzeAndGeneratePercentileConfig(stmt,
|
|
352
|
+
const percentileConfig = analyzeAndGeneratePercentileConfig(stmt, sourceRef, this.sourceName, this.dialect, tplSource);
|
|
270
353
|
if (percentileConfig.hasPercentiles && percentileConfig.transformedTPL) {
|
|
271
354
|
// Map partition levels to use SQL expressions (CASE statements for computed dimensions)
|
|
272
355
|
// This ensures percentiles are computed per computed dimension value, not per raw column value
|
|
@@ -287,7 +370,7 @@ export class EasyTPL {
|
|
|
287
370
|
}
|
|
288
371
|
whereClause = rawWhere;
|
|
289
372
|
}
|
|
290
|
-
const derivedSQL = generateMultiLevelPercentileSQL(
|
|
373
|
+
const derivedSQL = generateMultiLevelPercentileSQL(sourceRef, percentileConfig.percentiles, mappedPartitionLevels, this.dialect, whereClause || undefined);
|
|
291
374
|
// Generate Malloy source with derived SQL
|
|
292
375
|
const connectionPrefix = this.dialect === 'bigquery' ? 'bigquery' : 'duckdb';
|
|
293
376
|
let derivedMalloySource = `source: ${this.sourceName} is ${connectionPrefix}.sql("""${derivedSQL}""")`;
|
|
@@ -374,12 +457,8 @@ export class EasyTPL {
|
|
|
374
457
|
* ```
|
|
375
458
|
*/
|
|
376
459
|
extend(malloyExtend) {
|
|
377
|
-
//
|
|
378
|
-
const
|
|
379
|
-
// If no match (already has extend), append to existing extend block
|
|
380
|
-
const finalModel = extendedModel === this.model
|
|
381
|
-
? this.model.replace(/}$/, `\n${malloyExtend}\n}`)
|
|
382
|
-
: extendedModel;
|
|
460
|
+
// Add extend block to the model, handling both table and SQL source patterns
|
|
461
|
+
const finalModel = this.addExtendBlock(malloyExtend);
|
|
383
462
|
// Parse the extend text to extract dimension→column mappings
|
|
384
463
|
const newMappings = parseDimensionMappings(malloyExtend);
|
|
385
464
|
// Merge with existing mappings (new mappings take precedence)
|
|
@@ -409,6 +488,7 @@ export class EasyTPL {
|
|
|
409
488
|
}
|
|
410
489
|
return new EasyTPL(modelWithAutoDims, this.sourceName, {
|
|
411
490
|
tablePath: this.tablePath,
|
|
491
|
+
sourceSQL: this.sourceSQL,
|
|
412
492
|
dialect: this.dialect,
|
|
413
493
|
dimensionMap: mergedMap,
|
|
414
494
|
orderingProvider,
|
|
@@ -430,6 +510,10 @@ export class EasyTPL {
|
|
|
430
510
|
getDimensionMap() {
|
|
431
511
|
return new Map(this.dimensionMap);
|
|
432
512
|
}
|
|
513
|
+
/** Get the source SQL (if this is a SQL-backed source) */
|
|
514
|
+
getSourceSQL() {
|
|
515
|
+
return this.sourceSQL;
|
|
516
|
+
}
|
|
433
517
|
/** Get dimension→raw column mapping (for backward compatibility) */
|
|
434
518
|
getDimensionToColumnMap() {
|
|
435
519
|
const result = new Map();
|
|
@@ -438,4 +522,47 @@ export class EasyTPL {
|
|
|
438
522
|
}
|
|
439
523
|
return result;
|
|
440
524
|
}
|
|
525
|
+
/**
|
|
526
|
+
* Build a SourceRef for percentile SQL generation.
|
|
527
|
+
* Prefers sourceSQL (wrapped as subquery) over tablePath.
|
|
528
|
+
*/
|
|
529
|
+
getSourceRef() {
|
|
530
|
+
if (this.sourceSQL) {
|
|
531
|
+
return sqlSource(this.sourceSQL);
|
|
532
|
+
}
|
|
533
|
+
if (this.tablePath) {
|
|
534
|
+
return tableSource(this.tablePath);
|
|
535
|
+
}
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Add an extend block to the model string.
|
|
540
|
+
* Handles both table-based models (single closing paren) and
|
|
541
|
+
* SQL-based models (triple-quoted SQL with embedded parentheses).
|
|
542
|
+
*/
|
|
543
|
+
addExtendBlock(malloyExtend) {
|
|
544
|
+
// If model already has an extend block, append to it
|
|
545
|
+
if (this.model.includes(' extend {')) {
|
|
546
|
+
return this.model.replace(/}\s*$/, `\n${malloyExtend}\n}`);
|
|
547
|
+
}
|
|
548
|
+
// For SQL sources: match triple-quoted pattern which may contain parens
|
|
549
|
+
// e.g. source: data is bigquery.sql("""SELECT ... FROM (subq) ...""")
|
|
550
|
+
const sqlSourceMatch = this.model.match(/^(source:\s+\w+\s+is\s+\w+\.sql\("""[\s\S]*?"""\))$/);
|
|
551
|
+
if (sqlSourceMatch) {
|
|
552
|
+
return `${sqlSourceMatch[1]} extend {\n${malloyExtend}\n}`;
|
|
553
|
+
}
|
|
554
|
+
// For table sources: match up to closing paren
|
|
555
|
+
// e.g. source: data is duckdb.table('file.csv')
|
|
556
|
+
const tableSourceMatch = this.model.match(/^(source:\s+\w+\s+is\s+\w+\.table\('[^']*'\))$/);
|
|
557
|
+
if (tableSourceMatch) {
|
|
558
|
+
return `${tableSourceMatch[1]} extend {\n${malloyExtend}\n}`;
|
|
559
|
+
}
|
|
560
|
+
// Fallback: try the original generic pattern (for any model ending with ")")
|
|
561
|
+
const genericMatch = this.model.match(/^([\s\S]*\))$/);
|
|
562
|
+
if (genericMatch) {
|
|
563
|
+
return `${genericMatch[1]} extend {\n${malloyExtend}\n}`;
|
|
564
|
+
}
|
|
565
|
+
// If nothing matched, just append
|
|
566
|
+
return `${this.model} extend {\n${malloyExtend}\n}`;
|
|
567
|
+
}
|
|
441
568
|
}
|
|
@@ -6,6 +6,25 @@
|
|
|
6
6
|
* Key simplification: GridSpec already has the complete header hierarchy
|
|
7
7
|
* with pre-computed spans, so we don't need to reconstruct structure.
|
|
8
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* Format an aggregate's display name.
|
|
11
|
+
* - Standalone count/n → "N"
|
|
12
|
+
* - field.count (distinct count) → "field N"
|
|
13
|
+
* - field.sum → "field sum"
|
|
14
|
+
*/
|
|
15
|
+
function formatAggDisplayName(agg) {
|
|
16
|
+
if (agg.label)
|
|
17
|
+
return agg.label;
|
|
18
|
+
if (agg.aggregation === 'count') {
|
|
19
|
+
if (agg.measure && agg.measure !== '__pending__' && agg.measure !== '') {
|
|
20
|
+
return `${agg.measure} N`;
|
|
21
|
+
}
|
|
22
|
+
return 'N';
|
|
23
|
+
}
|
|
24
|
+
if (!agg.measure || agg.measure === '__pending__')
|
|
25
|
+
return agg.aggregation;
|
|
26
|
+
return `${agg.measure} ${agg.aggregation}`;
|
|
27
|
+
}
|
|
9
28
|
/**
|
|
10
29
|
* Render a GridSpec to HTML.
|
|
11
30
|
*/
|
|
@@ -118,7 +137,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
|
118
137
|
}
|
|
119
138
|
}
|
|
120
139
|
// Single aggregate header
|
|
121
|
-
lines.push(`<th>${escapeHTML(grid.aggregates[0]
|
|
140
|
+
lines.push(`<th>${escapeHTML(grid.aggregates[0] ? formatAggDisplayName(grid.aggregates[0]) : 'Value')}</th>`);
|
|
122
141
|
lines.push('</tr>');
|
|
123
142
|
lines.push('</thead>');
|
|
124
143
|
return;
|
|
@@ -159,7 +178,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
|
|
|
159
178
|
else {
|
|
160
179
|
// Aggregates are not on row axis, render each aggregate as a column header
|
|
161
180
|
for (const agg of grid.aggregates) {
|
|
162
|
-
lines.push(`<th>${escapeHTML(agg
|
|
181
|
+
lines.push(`<th>${escapeHTML(formatAggDisplayName(agg))}</th>`);
|
|
163
182
|
}
|
|
164
183
|
}
|
|
165
184
|
lines.push('</tr>');
|
|
@@ -505,13 +524,7 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
|
|
|
505
524
|
displayAgg = aggInfo.label;
|
|
506
525
|
}
|
|
507
526
|
else if (aggInfo) {
|
|
508
|
-
|
|
509
|
-
if (aggInfo.aggregation === 'count') {
|
|
510
|
-
displayAgg = 'count';
|
|
511
|
-
}
|
|
512
|
-
else {
|
|
513
|
-
displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
|
|
514
|
-
}
|
|
527
|
+
displayAgg = formatAggDisplayName(aggInfo);
|
|
515
528
|
}
|
|
516
529
|
else {
|
|
517
530
|
// Fallback: clean up the internal name
|
|
@@ -523,15 +536,7 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
|
|
|
523
536
|
else if (aggregates.length === 1) {
|
|
524
537
|
// Single aggregate - still show it in tooltip
|
|
525
538
|
const aggInfo = aggregates[0];
|
|
526
|
-
|
|
527
|
-
displayAgg = aggInfo.label;
|
|
528
|
-
}
|
|
529
|
-
else if (aggInfo.aggregation === 'count') {
|
|
530
|
-
displayAgg = 'count';
|
|
531
|
-
}
|
|
532
|
-
else {
|
|
533
|
-
displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
|
|
534
|
-
}
|
|
539
|
+
displayAgg = formatAggDisplayName(aggInfo);
|
|
535
540
|
}
|
|
536
541
|
if (displayAgg) {
|
|
537
542
|
return dimPath ? `${dimPath} → ${displayAgg}` : displayAgg;
|