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 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 joins, pre-defined measures, or multiple sources, you can use full Malloy models. This is an advanced approach for users already familiar with Malloy.
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 the aggregation name
465
- // This handles cases like standalone "count" or "n"
464
+ // For count/n without a measure, just return "N"
466
465
  if (!measure || measure === "__pending__") {
467
466
  return aggregation === "count" ? "N" : aggregation;
468
467
  }
469
- // For count with a measure (e.g., income.count), just return "N" since
470
- // count doesn't really bind to a measure in Malloy
468
+ // For field-bound count (distinct count), show "field N"
471
469
  if (aggregation === "count") {
472
- return "N";
470
+ return `${measure} N`;
473
471
  }
474
472
  return `${measure} ${aggregation}`;
475
473
  }
@@ -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';
@@ -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}.${a.aggregation}()`;
113
+ return `${a.name} is ${buildAggExpr(a.measure, a.aggregation)}`;
114
114
  });
115
115
  return `${pad}aggregate: ${aggs.join(', ')}`;
116
116
  }
117
+ /** Build a Malloy aggregate expression (mirrors multi-query-utils buildAggExpression) */
118
+ function buildAggExpr(measure, aggregation) {
119
+ if (aggregation === 'count') {
120
+ if (measure && measure !== '__pending__' && measure !== '') {
121
+ return `count(${escapeForMalloy(measure)})`;
122
+ }
123
+ return 'count()';
124
+ }
125
+ if (!measure || measure === '__pending__')
126
+ return 'count()';
127
+ const methodMap = { mean: 'avg', stdev: 'stddev', pct: 'sum', pctn: 'count', pctsum: 'sum' };
128
+ return `${measure}.${methodMap[aggregation] ?? aggregation}()`;
129
+ }
130
+ const MALLOY_RESERVED = new Set(['all', 'and', 'as', 'asc', 'avg', 'by', 'case', 'cast', 'count', 'day', 'desc', 'dimension', 'else', 'end', 'exclude', 'extend', 'false', 'from', 'group', 'having', 'hour', 'import', 'is', 'join', 'limit', 'max', 'measure', 'min', 'minute', 'month', 'nest', 'not', 'now', 'null', 'number', 'on', 'or', 'order', 'pick', 'quarter', 'run', 'second', 'source', 'sum', 'then', 'true', 'week', 'when', 'where', 'year']);
131
+ function escapeForMalloy(name) {
132
+ return MALLOY_RESERVED.has(name.toLowerCase()) ? `\`${name}\`` : name;
133
+ }
117
134
  /**
118
135
  * Build total aggregate for column totals.
119
136
  */
120
137
  function buildTotalAggregate(aggregates, label) {
121
138
  const aggs = aggregates.map(a => {
122
- return `${a.name} is ${a.measure}.${a.aggregation}()`;
139
+ return `${a.name} is ${buildAggExpr(a.measure, a.aggregation)}`;
123
140
  });
124
141
  const nestName = label ? `"${label}"` : '"total"';
125
142
  return ` nest: total is { aggregate: ${aggs.join(', ')} }`;
@@ -16,7 +16,12 @@ export declare function escapeFieldName(name: string): string;
16
16
  */
17
17
  export declare function escapeWhereExpression(expr: string): string;
18
18
  /**
19
- * Build a Malloy aggregate expression from measure and aggregation
19
+ * Build a Malloy aggregate expression from measure and aggregation.
20
+ *
21
+ * Count semantics:
22
+ * - Standalone `count` or `n` (no measure) → `count()` — counts all rows
23
+ * - Field-bound `user_id.count` (with measure) → `count(user_id)` — counts distinct values
24
+ * This matches Malloy's native semantics where count(field) = COUNT(DISTINCT field).
20
25
  */
21
26
  export declare function buildAggExpression(measure: string, aggregation: string): string;
22
27
  /**
@@ -86,16 +86,21 @@ const AGG_METHOD_MAP = {
86
86
  pctsum: 'sum',
87
87
  };
88
88
  /**
89
- * Build a Malloy aggregate expression from measure and aggregation
89
+ * Build a Malloy aggregate expression from measure and aggregation.
90
+ *
91
+ * Count semantics:
92
+ * - Standalone `count` or `n` (no measure) → `count()` — counts all rows
93
+ * - Field-bound `user_id.count` (with measure) → `count(user_id)` — counts distinct values
94
+ * This matches Malloy's native semantics where count(field) = COUNT(DISTINCT field).
90
95
  */
91
96
  export function buildAggExpression(measure, aggregation) {
92
97
  const malloyMethod = AGG_METHOD_MAP[aggregation] ?? aggregation;
93
- // Handle count - in Malloy, count() doesn't take a measure argument
94
- // count() counts all rows, not a specific field
95
- // When users write "income.count" or "income.n", they semantically mean "count"
96
- // since you can't "count" a measure - you can only count rows
98
+ // Handle count: standalone = row count, field-bound = distinct count
97
99
  if (aggregation === 'count') {
98
- return 'count()';
100
+ if (measure && measure !== '__pending__' && measure !== '') {
101
+ return `count(${escapeFieldName(measure)})`; // distinct count of field
102
+ }
103
+ return 'count()'; // standalone row count
99
104
  }
100
105
  // Handle other aggregations without a measure (use placeholder)
101
106
  // Also handle the __pending__ placeholder used for standalone count
@@ -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 tablePath The path to the source table (e.g., 'data/file.csv')
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(tablePath: string, percentiles: PercentileInfo[], partitionColumns: string[], dialect: SqlDialect, whereClause?: string): string;
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 tablePath Path to the source table
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, tablePath: string, sourceName: string, dialect: SqlDialect, originalTPL: string): PercentileConfig;
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(tablePath: string, percentiles: PercentileInfo[], partitionLevels: PartitionLevel[], dialect: SqlDialect, whereClause?: string): string;
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 tablePath The path to the source table (e.g., 'data/file.csv')
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(tablePath, percentiles, partitionColumns, dialect, whereClause) {
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
- // Generate the SQL
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 tablePath Path to the source table
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, tablePath, sourceName, dialect, originalTPL) {
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(tablePath, percentiles, partitionLevels, dialect, whereClause);
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(tablePath, percentiles, partitionLevels, dialect, whereClause) {
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
- if (dialect === 'duckdb') {
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
  }
@@ -67,13 +67,24 @@ export async function createConnection(options) {
67
67
  }
68
68
  async function createBigQueryConnection(options) {
69
69
  const { BigQueryConnection } = await loadBigQuery();
70
- const credentialsPath = options.credentialsPath ?? './config/dev-credentials.json';
71
- // Read credentials to get project ID if not provided
72
- const credentials = JSON.parse(fs.readFileSync(credentialsPath, 'utf-8'));
73
- const projectId = options.projectId ?? credentials.project_id;
74
- // Set the environment variable for Google Cloud authentication
75
- process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve(credentialsPath);
76
- const connection = new BigQueryConnection('bigquery', {}, { projectId, location: options.location ?? 'US' });
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('slite-development.tpl_test.test_usa_names') extend {
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 "N";
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, tablePath, sourceName, dialect, originalTPL) {
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
- tablePath,
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(tablePath, percentiles, partitionLevels, dialect, whereClause) {
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
- if (dialect === "duckdb") {
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]?.label ?? grid.aggregates[0]?.name ?? "Value")}</th>`);
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.label ?? agg.name)}</th>`);
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
- if (aggInfo.aggregation === "count") {
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
- if (aggInfo.label) {
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
- const credentials = JSON.parse(fs.readFileSync(credentialsPath, "utf-8"));
10646
- const projectId = options.projectId ?? credentials.project_id;
10647
- process.env.GOOGLE_APPLICATION_CREDENTIALS = path.resolve(credentialsPath);
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
- { projectId, location: options.location ?? "US" }
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('slite-development.tpl_test.test_usa_names') extend {
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
- if (this.tablePath && this.dialect) {
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
- this.tablePath,
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
- this.tablePath,
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 extendedModel = this.model.replace(
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, or fromBigQueryTable.
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, or fromBigQueryTable.
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
- // Check if we can handle percentiles (need tablePath and dialect)
266
- if (this.tablePath && this.dialect) {
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, this.tablePath, this.sourceName, this.dialect, tplSource);
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(this.tablePath, percentileConfig.percentiles, mappedPartitionLevels, this.dialect, whereClause || undefined);
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
- // Remove the closing source and add extend block
378
- const extendedModel = this.model.replace(/^(source: \w+ is [^)]+\))$/, `$1 extend {\n${malloyExtend}\n}`);
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]?.label ?? grid.aggregates[0]?.name ?? 'Value')}</th>`);
140
+ lines.push(`<th>${escapeHTML(grid.aggregates[0] ? formatAggDisplayName(grid.aggregates[0]) : 'Value')}</th>`);
122
141
  lines.push('</tr>');
123
142
  lines.push('</thead>');
124
143
  return;
@@ -159,7 +178,7 @@ function renderColumnHeaders(grid, lines, showDimensionLabels) {
159
178
  else {
160
179
  // Aggregates are not on row axis, render each aggregate as a column header
161
180
  for (const agg of grid.aggregates) {
162
- lines.push(`<th>${escapeHTML(agg.label ?? agg.name)}</th>`);
181
+ lines.push(`<th>${escapeHTML(formatAggDisplayName(agg))}</th>`);
163
182
  }
164
183
  }
165
184
  lines.push('</tr>');
@@ -505,13 +524,7 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
505
524
  displayAgg = aggInfo.label;
506
525
  }
507
526
  else if (aggInfo) {
508
- // Build display from measure + aggregation
509
- if (aggInfo.aggregation === 'count') {
510
- displayAgg = 'count';
511
- }
512
- else {
513
- displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
514
- }
527
+ displayAgg = formatAggDisplayName(aggInfo);
515
528
  }
516
529
  else {
517
530
  // Fallback: clean up the internal name
@@ -523,15 +536,7 @@ function buildHumanPath(rowValues, colValues, aggregateName, aggregates) {
523
536
  else if (aggregates.length === 1) {
524
537
  // Single aggregate - still show it in tooltip
525
538
  const aggInfo = aggregates[0];
526
- if (aggInfo.label) {
527
- displayAgg = aggInfo.label;
528
- }
529
- else if (aggInfo.aggregation === 'count') {
530
- displayAgg = 'count';
531
- }
532
- else {
533
- displayAgg = `${aggInfo.measure}.${aggInfo.aggregation}`;
534
- }
539
+ displayAgg = formatAggDisplayName(aggInfo);
535
540
  }
536
541
  if (displayAgg) {
537
542
  return dimPath ? `${dimPath} → ${displayAgg}` : displayAgg;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tplm-lang",
3
- "version": "0.3.4",
3
+ "version": "0.3.6",
4
4
  "description": "TPLm - Table Producing Language backed by Malloy.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",