@malloydata/malloy 0.0.412 → 0.0.414

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.
Files changed (45) hide show
  1. package/dist/api/foundation/core.d.ts +21 -0
  2. package/dist/api/foundation/core.js +30 -0
  3. package/dist/dialect/databricks/databricks.d.ts +2 -1
  4. package/dist/dialect/databricks/databricks.js +12 -6
  5. package/dist/dialect/dialect.d.ts +12 -1
  6. package/dist/dialect/dialect.js +25 -0
  7. package/dist/dialect/duckdb/duckdb.d.ts +2 -1
  8. package/dist/dialect/duckdb/duckdb.js +9 -2
  9. package/dist/dialect/mysql/mysql.d.ts +2 -1
  10. package/dist/dialect/mysql/mysql.js +14 -5
  11. package/dist/dialect/postgres/postgres.d.ts +2 -1
  12. package/dist/dialect/postgres/postgres.js +9 -2
  13. package/dist/dialect/snowflake/snowflake.d.ts +2 -1
  14. package/dist/dialect/snowflake/snowflake.js +11 -3
  15. package/dist/dialect/standardsql/standardsql.d.ts +2 -1
  16. package/dist/dialect/standardsql/standardsql.js +8 -2
  17. package/dist/dialect/trino/trino.d.ts +2 -1
  18. package/dist/dialect/trino/trino.js +8 -2
  19. package/dist/lang/ast/field-space/dynamic-space.js +14 -1
  20. package/dist/lang/ast/field-space/query-spaces.d.ts +1 -0
  21. package/dist/lang/ast/field-space/query-spaces.js +5 -0
  22. package/dist/lang/ast/query-items/field-references.d.ts +10 -1
  23. package/dist/lang/ast/query-items/field-references.js +67 -4
  24. package/dist/lang/ast/query-properties/nest.js +15 -0
  25. package/dist/lang/ast/source-elements/named-source.js +4 -1
  26. package/dist/lang/ast/statements/define-source.js +9 -2
  27. package/dist/lang/ast/types/malloy-element.js +3 -4
  28. package/dist/lang/parse-log.d.ts +10 -0
  29. package/dist/lang/parse-log.js +3 -0
  30. package/dist/model/expression_compiler.js +2 -2
  31. package/dist/model/field_instance.d.ts +1 -0
  32. package/dist/model/field_instance.js +14 -0
  33. package/dist/model/index.d.ts +2 -1
  34. package/dist/model/index.js +3 -1
  35. package/dist/model/malloy_types.d.ts +25 -6
  36. package/dist/model/nest-capability.d.ts +37 -0
  37. package/dist/model/nest-capability.js +31 -0
  38. package/dist/model/persist_utils.js +1 -1
  39. package/dist/model/query_query.d.ts +26 -5
  40. package/dist/model/query_query.js +256 -88
  41. package/dist/model/source_def_utils.d.ts +31 -7
  42. package/dist/model/source_def_utils.js +39 -9
  43. package/dist/version.d.ts +1 -1
  44. package/dist/version.js +1 -1
  45. package/package.json +4 -4
@@ -14,9 +14,31 @@ const field_instance_1 = require("./field_instance");
14
14
  const sql_compiled_1 = require("./sql_compiled");
15
15
  const source_def_utils_1 = require("./source_def_utils");
16
16
  const malloy_compile_error_1 = require("./malloy_compile_error");
17
+ const nest_capability_1 = require("./nest-capability");
17
18
  function pathToCol(path) {
18
19
  return path.map(el => encodeURIComponent(el)).join('/');
19
20
  }
21
+ /**
22
+ * Is this result's row limit applied by the ROW_NUMBER "shave" in
23
+ * generateSQLHavingLimit? A nested projection's is not: its limit slices its
24
+ * array-agg (see sqlAggregateTurtle), because a projection has no group-by
25
+ * columns for the shave to partition and order on.
26
+ */
27
+ function usesShaveLimit(result) {
28
+ if (result.getLimit() === undefined)
29
+ return false;
30
+ const isNestedProjection = result.parent !== undefined && result.firstSegment.type === 'project';
31
+ return !isNestedProjection;
32
+ }
33
+ /** Is this an analytic (window) field -- a `calculate:`? */
34
+ function fieldIsAnalytic(fi) {
35
+ return ((0, malloy_types_1.hasExpression)(fi.f.fieldDef) &&
36
+ (0, malloy_types_1.expressionIsAnalytic)(fi.f.fieldDef.expressionType));
37
+ }
38
+ // 1-based SELECT positions of the dimension columns, for `GROUP BY 1,2,...`.
39
+ function groupByPositions(columns) {
40
+ return columns.flatMap((c, i) => (c.isDimension ? [i + 1] : []));
41
+ }
20
42
  function pushDialectField(dl, f) {
21
43
  const { sqlExpression, sqlOutputName, rawName } = f;
22
44
  if ((0, malloy_types_1.isAtomic)(f.fieldDef)) {
@@ -886,15 +908,31 @@ class QueryQuery extends query_node_1.QueryField {
886
908
  }
887
909
  return s;
888
910
  }
911
+ /**
912
+ * The single-scan form, with no `group_set` fan-out: one `SELECT` over the
913
+ * scan, grouped by the dimensions. Used both for a query with no nests and --
914
+ * via canUseSingleGroupSetSQL -- for one whose only nests are single-stage
915
+ * grain-preserving projections, which become an array-agg in the same SELECT.
916
+ * That array-agg needs no `FILTER (WHERE group_set=N)` (every row is in the
917
+ * one group), and aggregate/window expressions must not gate themselves by
918
+ * group_set either, so emitsGroupSet is cleared (see FieldInstanceResultRoot).
919
+ */
889
920
  generateSimpleSQL(stageWriter) {
921
+ this.rootResult.emitsGroupSet = false;
890
922
  let s = '';
891
923
  s += 'SELECT \n';
892
924
  const fields = [];
893
- for (const [name, field] of this.rootResult.allFields) {
894
- const fi = field;
925
+ const outputPipelinedSQL = [];
926
+ for (const [name, fi] of this.rootResult.allFields) {
895
927
  const sqlName = this.parent.dialect.sqlQuoteIdentifier(name);
896
- if (fi.fieldUsage.type === 'result') {
897
- fields.push(` ${fi.generateExpression()} as ${sqlName}`);
928
+ if (fi instanceof field_instance_1.FieldInstanceField) {
929
+ if (fi.fieldUsage.type === 'result') {
930
+ fields.push(` ${fi.generateExpression()} as ${sqlName}`);
931
+ }
932
+ }
933
+ else if (fi instanceof field_instance_1.FieldInstanceResult) {
934
+ const turtle = this.generateTurtleSQL(fi, stageWriter, sqlName, outputPipelinedSQL, true);
935
+ fields.push(` ${turtle} as ${sqlName}`);
898
936
  }
899
937
  }
900
938
  s += (0, utils_1.indent)(fields.join(',\n')) + '\n';
@@ -921,29 +959,77 @@ class QueryQuery extends query_node_1.QueryField {
921
959
  s += `LIMIT ${this.firstSegment.limit}\n`;
922
960
  }
923
961
  this.resultStage = stageWriter.addStage(s);
962
+ // Consumes any pipelined turtle output. The nests that reach here are
963
+ // single-stage (canUseSingleGroupSetSQL), so outputPipelinedSQL is empty and
964
+ // this is a no-op; kept so the function stays correct if that guard widens.
965
+ this.resultStage = this.generatePipelinedStages(outputPipelinedSQL, this.resultStage, stageWriter, []);
924
966
  return this.resultStage;
925
967
  }
968
+ /**
969
+ * Can this query take the single-group-set fast path? True when the query
970
+ * needs no group-set fan-out: it has nests (so it's "complex") but every one
971
+ * is a single-stage grain-preserving projection riding group_set 0, and there
972
+ * are no reduce nests, ungroupings, or totals (all of which would mint a
973
+ * second group set and push maxGroupSet above 0). Anything outside that shape
974
+ * falls back to the general demux path, which is correct for every case.
975
+ */
976
+ canUseSingleGroupSetSQL() {
977
+ if (this.maxGroupSet !== 0)
978
+ return false;
979
+ if (this.firstSegment.type !== 'reduce')
980
+ return false;
981
+ for (const [, fi] of this.rootResult.allFields) {
982
+ if (fi instanceof field_instance_1.FieldInstanceField) {
983
+ // A root-level `calculate:` (window) needs the general path's analytic
984
+ // handling: on dialects that can't partition a window on an expression
985
+ // (BigQuery) it rides a lateral-join bag this path doesn't build.
986
+ if (fieldIsAnalytic(fi))
987
+ return false;
988
+ }
989
+ else if (fi instanceof field_instance_1.FieldInstanceResult) {
990
+ if (fi.firstSegment.type !== 'project')
991
+ return false;
992
+ if (fi.turtleDef.pipeline.length !== 1)
993
+ return false;
994
+ if (fi.getRepeatedResultType() !== 'nested')
995
+ return false;
996
+ // A `calculate:` inside a `select:` would put a window function inside
997
+ // the array-agg, which no dialect allows; leave it on the general path.
998
+ for (const [, nestField] of fi.allFields) {
999
+ if (nestField instanceof field_instance_1.FieldInstanceField &&
1000
+ fieldIsAnalytic(nestField)) {
1001
+ return false;
1002
+ }
1003
+ }
1004
+ }
1005
+ }
1006
+ return true;
1007
+ }
926
1008
  // This probably should be generated in a dialect independat way.
927
1009
  // but for now, it is just googleSQL.
928
- generatePipelinedStages(outputPipelinedSQL, lastStageName, stageWriter) {
1010
+ generatePipelinedStages(outputPipelinedSQL, lastStageName, stageWriter,
1011
+ // Every column `lastStageName` produces. The replaced turtle columns (in
1012
+ // outputPipelinedSQL) are dropped and re-added by the pipeline expressions;
1013
+ // everything else is carried through unchanged by its emitted name -- for a
1014
+ // grouped stage that is the group-set-suffixed form (e.g. `f1__0`), not the
1015
+ // final output name.
1016
+ priorStageColumns) {
929
1017
  if (outputPipelinedSQL.length === 0) {
930
1018
  return lastStageName;
931
1019
  }
1020
+ const pipelinesSQL = outputPipelinedSQL
1021
+ .map(o => `${o.pipelineSQL} as ${o.sqlFieldName}`)
1022
+ .join(',\n');
932
1023
  let retSQL;
933
1024
  if (this.parent.dialect.supportsSelectReplace) {
934
- const pipelinesSQL = outputPipelinedSQL
935
- .map(o => `${o.pipelineSQL} as ${o.sqlFieldName}`)
936
- .join(',\n');
937
1025
  retSQL = `SELECT * replace (${pipelinesSQL}) FROM ${lastStageName}
938
1026
  `;
939
1027
  }
940
1028
  else {
941
- const pipelinesSQL = outputPipelinedSQL
942
- .map(o => `${o.pipelineSQL} as ${o.sqlFieldName}`)
943
- .join(',\n');
944
1029
  const outputFields = outputPipelinedSQL.map(f => f.sqlFieldName);
945
- const allFields = Array.from(this.rootResult.allFields.keys()).map(f => this.parent.dialect.sqlQuoteIdentifier(f));
946
- const fields = allFields.filter(f => outputFields.indexOf(f) === -1);
1030
+ const fields = priorStageColumns
1031
+ .map(c => c.name)
1032
+ .filter(name => outputFields.indexOf(name) === -1);
947
1033
  retSQL = `SELECT ${fields.length > 0 ? fields.join(', ') + ',' : ''} ${pipelinesSQL} FROM ${lastStageName}`;
948
1034
  }
949
1035
  return stageWriter.addStage(retSQL);
@@ -978,12 +1064,19 @@ class QueryQuery extends query_node_1.QueryField {
978
1064
  sql: exp,
979
1065
  name: outputName,
980
1066
  });
981
- output.sql.push(outputFieldName);
1067
+ output.columns.push({
1068
+ sql: outputFieldName,
1069
+ name: outputName,
1070
+ isDimension: true,
1071
+ });
982
1072
  if (fi.f.fieldDef.type === 'number') {
983
1073
  const outputNameString = this.parent.dialect.sqlQuoteIdentifier(`${name}__${resultSet.groupSet}_string`);
984
1074
  const outputFieldNameString = `__lateral_join_bag.${outputNameString}`;
985
- output.sql.push(outputFieldNameString);
986
- output.dimensionIndexes.push(output.fieldIndex++);
1075
+ output.columns.push({
1076
+ sql: outputFieldNameString,
1077
+ name: outputNameString,
1078
+ isDimension: true,
1079
+ });
987
1080
  output.lateralJoinSQLExpressions.push({
988
1081
  sql: `CAST(${exp} as STRING)`,
989
1082
  name: outputNameString,
@@ -993,13 +1086,19 @@ class QueryQuery extends query_node_1.QueryField {
993
1086
  }
994
1087
  else {
995
1088
  // just treat it like a regular field.
996
- output.sql.push(`${exp} as ${outputName}`);
1089
+ output.columns.push({
1090
+ sql: `${exp} as ${outputName}`,
1091
+ name: outputName,
1092
+ isDimension: true,
1093
+ });
997
1094
  }
998
- output.dimensionIndexes.push(output.fieldIndex++);
999
1095
  }
1000
1096
  else if ((0, query_node_1.isBasicCalculation)(fi.f)) {
1001
- output.sql.push(`${exp} as ${outputName}`);
1002
- output.fieldIndex++;
1097
+ output.columns.push({
1098
+ sql: `${exp} as ${outputName}`,
1099
+ name: outputName,
1100
+ isDimension: false,
1101
+ });
1003
1102
  }
1004
1103
  }
1005
1104
  }
@@ -1009,8 +1108,11 @@ class QueryQuery extends query_node_1.QueryField {
1009
1108
  }
1010
1109
  else if (fi.firstSegment.type === 'project') {
1011
1110
  const s = this.generateTurtleSQL(fi, stageWriter, outputName, output.outputPipelinedSQL);
1012
- output.sql.push(`${s} as ${outputName}`);
1013
- output.fieldIndex++;
1111
+ output.columns.push({
1112
+ sql: `${s} as ${outputName}`,
1113
+ name: outputName,
1114
+ isDimension: false,
1115
+ });
1014
1116
  }
1015
1117
  }
1016
1118
  }
@@ -1028,8 +1130,11 @@ class QueryQuery extends query_node_1.QueryField {
1028
1130
  }
1029
1131
  else {
1030
1132
  resultSet.hasHaving = true;
1031
- output.sql.push(`CASE WHEN group_set=${resultSet.groupSet} THEN CASE WHEN ${having.sql()} THEN 0 ELSE 1 END END as __delete__${resultSet.groupSet}`);
1032
- output.fieldIndex++;
1133
+ output.columns.push({
1134
+ sql: `CASE WHEN group_set=${resultSet.groupSet} THEN CASE WHEN ${having.sql()} THEN 0 ELSE 1 END END as __delete__${resultSet.groupSet}`,
1135
+ name: `__delete__${resultSet.groupSet}`,
1136
+ isDimension: false,
1137
+ });
1033
1138
  }
1034
1139
  }
1035
1140
  }
@@ -1038,11 +1143,18 @@ class QueryQuery extends query_node_1.QueryField {
1038
1143
  for (const [, field] of resultStruct.allFields) {
1039
1144
  if (field.type === 'query') {
1040
1145
  const fir = field;
1041
- const turtleWhere = this.generateSQLFilters(fir, 'where');
1042
- if (turtleWhere.present()) {
1043
- const groupSets = fir.childGroups.join(',');
1044
- wheres.add(`(group_set NOT IN (${groupSets})` +
1045
- ` OR (group_set IN (${groupSets}) AND ${turtleWhere.sql()}))`);
1146
+ // A projection first stage's `where:` folds into its array-agg
1147
+ // condition (see generateTurtleSQL / sqlAggregateTurtle), not a
1148
+ // scan-level WHERE: it rides its enclosing group_set, so a WHERE here
1149
+ // can't isolate it from the parent, and its childGroups is empty (no
1150
+ // group_set of its own) which would emit a broken `group_set IN ()`.
1151
+ if (fir.firstSegment.type !== 'project') {
1152
+ const turtleWhere = this.generateSQLFilters(fir, 'where');
1153
+ if (turtleWhere.present()) {
1154
+ const groupSets = fir.childGroups.join(',');
1155
+ wheres.add(`(group_set NOT IN (${groupSets})` +
1156
+ ` OR (group_set IN (${groupSets}) AND ${turtleWhere.sql()}))`);
1157
+ }
1046
1158
  }
1047
1159
  wheres.addChain(this.generateSQLWhereChildren(fir));
1048
1160
  }
@@ -1079,11 +1191,11 @@ class QueryQuery extends query_node_1.QueryField {
1079
1191
  const partitionSQL = [];
1080
1192
  let hasAnyLimits = false;
1081
1193
  let hasResultsWithChildren = false;
1082
- const resultsWithHavingOrLimit = this.rootResult.selectStructs([], (result) => result.hasHaving || result.getLimit() !== undefined);
1194
+ const resultsWithHavingOrLimit = this.rootResult.selectStructs([], (result) => result.hasHaving || usesShaveLimit(result));
1083
1195
  if (resultsWithHavingOrLimit.length > 0) {
1084
1196
  // loop through an generate the partitions
1085
1197
  for (const result of this.rootResult.selectStructs([], (_result) => true)) {
1086
- const hasLimit = result.getLimit() !== undefined;
1198
+ const hasLimit = usesShaveLimit(result);
1087
1199
  hasResultsWithChildren || (hasResultsWithChildren = result.childGroups.length > 1 && (hasLimit || result.hasHaving));
1088
1200
  hasAnyLimits || (hasAnyLimits = hasLimit);
1089
1201
  // find all the parent dimension names.
@@ -1201,9 +1313,7 @@ class QueryQuery extends query_node_1.QueryField {
1201
1313
  let from = this.generateSQLJoins(stageWriter);
1202
1314
  const wheres = this.generateSQLWhereTurtled();
1203
1315
  const f = {
1204
- dimensionIndexes: [1],
1205
- fieldIndex: 2,
1206
- sql: ['group_set'],
1316
+ columns: [{ sql: 'group_set', name: 'group_set', isDimension: true }],
1207
1317
  lateralJoinSQLExpressions: [],
1208
1318
  groupsAggregated: [],
1209
1319
  outputPipelinedSQL: [],
@@ -1213,9 +1323,9 @@ class QueryQuery extends query_node_1.QueryField {
1213
1323
  throw new malloy_compile_error_1.MalloyCompileError("Cannot use 'select:' in a stage that contains nested views. " +
1214
1324
  'Use `group_by:` or restructure the pipeline.', 'compiler-project-with-turtles', this.fieldDef.location);
1215
1325
  }
1216
- const groupBy = 'GROUP BY ' + f.dimensionIndexes.join(',') + '\n';
1326
+ const groupBy = 'GROUP BY ' + groupByPositions(f.columns).join(',') + '\n';
1217
1327
  from += this.parent.dialect.sqlGroupSetTable(this.maxGroupSet) + '\n';
1218
- s += (0, utils_1.indent)(f.sql.join(',\n')) + '\n';
1328
+ s += (0, utils_1.indent)(f.columns.map(c => c.sql).join(',\n')) + '\n';
1219
1329
  if (f.lateralJoinSQLExpressions.length > 0) {
1220
1330
  from += this.parent.dialect.sqlLateralJoinBag(f.lateralJoinSQLExpressions);
1221
1331
  }
@@ -1224,7 +1334,7 @@ class QueryQuery extends query_node_1.QueryField {
1224
1334
  const resultStage = stageWriter.addStage(s);
1225
1335
  // generate stages for havings and limits
1226
1336
  this.resultStage = this.generateSQLHavingLimit(stageWriter, resultStage);
1227
- this.resultStage = this.generatePipelinedStages(f.outputPipelinedSQL, this.resultStage, stageWriter);
1337
+ this.resultStage = this.generatePipelinedStages(f.outputPipelinedSQL, this.resultStage, stageWriter, f.columns);
1228
1338
  return this.resultStage;
1229
1339
  }
1230
1340
  generateDepthNFields(depth, resultSet, output, stageWriter) {
@@ -1235,13 +1345,19 @@ class QueryQuery extends query_node_1.QueryField {
1235
1345
  if (fi.fieldUsage.type === 'result') {
1236
1346
  if ((0, query_node_1.isScalarField)(fi.f)) {
1237
1347
  const exp = (0, utils_1.caseGroup)(resultSet.groupSet > 0 ? resultSet.childGroups : [], sqlFieldName);
1238
- output.sql.push(`${exp} as ${sqlFieldName}`);
1239
- output.dimensionIndexes.push(output.fieldIndex++);
1348
+ output.columns.push({
1349
+ sql: `${exp} as ${sqlFieldName}`,
1350
+ name: sqlFieldName,
1351
+ isDimension: true,
1352
+ });
1240
1353
  }
1241
1354
  else if ((0, query_node_1.isBasicCalculation)(fi.f)) {
1242
1355
  const exp = this.parent.dialect.sqlAnyValue(resultSet.groupSet, sqlFieldName);
1243
- output.sql.push(`${exp} as ${sqlFieldName}`);
1244
- output.fieldIndex++;
1356
+ output.columns.push({
1357
+ sql: `${exp} as ${sqlFieldName}`,
1358
+ name: sqlFieldName,
1359
+ isDimension: false,
1360
+ });
1245
1361
  }
1246
1362
  }
1247
1363
  }
@@ -1256,8 +1372,11 @@ class QueryQuery extends query_node_1.QueryField {
1256
1372
  toGroup: resultSet.groupSet,
1257
1373
  });
1258
1374
  groupsToMap.push(fi.groupSet);
1259
- output.sql.push(`${s} as ${sqlFieldName}`);
1260
- output.fieldIndex++;
1375
+ output.columns.push({
1376
+ sql: `${s} as ${sqlFieldName}`,
1377
+ name: sqlFieldName,
1378
+ isDimension: false,
1379
+ });
1261
1380
  }
1262
1381
  else {
1263
1382
  this.generateDepthNFields(depth, fi, output, stageWriter);
@@ -1267,14 +1386,19 @@ class QueryQuery extends query_node_1.QueryField {
1267
1386
  if (output.groupsAggregated.length > 0) {
1268
1387
  if (!this.parent.dialect.hasLateralColumnAliasInSelect) {
1269
1388
  // Safe to remap in-place; aggregate expressions won't see the alias.
1270
- // This runs on every recursive return but sql[0] replacement is
1389
+ // This runs on every recursive return but column[0] replacement is
1271
1390
  // idempotent — last call builds the full CASE from all accumulated
1272
- // groupsAggregated entries.
1273
- output.sql[0] = 'CASE ';
1391
+ // groupsAggregated entries. The output is still named group_set.
1392
+ let groupSetSQL = 'CASE ';
1274
1393
  for (const m of output.groupsAggregated) {
1275
- output.sql[0] += `WHEN group_set=${m.fromGroup} THEN ${m.toGroup} `;
1394
+ groupSetSQL += `WHEN group_set=${m.fromGroup} THEN ${m.toGroup} `;
1276
1395
  }
1277
- output.sql[0] += 'ELSE group_set END as group_set';
1396
+ groupSetSQL += 'ELSE group_set END as group_set';
1397
+ output.columns[0] = {
1398
+ sql: groupSetSQL,
1399
+ name: 'group_set',
1400
+ isDimension: true,
1401
+ };
1278
1402
  }
1279
1403
  // For hasLateralColumnAliasInSelect, the remap is handled in
1280
1404
  // generateSQLDepthN to avoid adding it multiple times from recursion.
@@ -1283,88 +1407,100 @@ class QueryQuery extends query_node_1.QueryField {
1283
1407
  generateSQLDepthN(depth, stageWriter, stageName) {
1284
1408
  let s = 'SELECT \n';
1285
1409
  const f = {
1286
- dimensionIndexes: [1],
1287
- fieldIndex: 2,
1288
- sql: ['group_set'],
1410
+ columns: [{ sql: 'group_set', name: 'group_set', isDimension: true }],
1289
1411
  lateralJoinSQLExpressions: [],
1290
1412
  groupsAggregated: [],
1291
1413
  outputPipelinedSQL: [],
1292
1414
  };
1293
1415
  this.generateDepthNFields(depth, this.rootResult, f, stageWriter);
1294
1416
  // When column aliases are visible in the same SELECT (e.g. Databricks),
1295
- // replace sql[0] with the remap CASE aliased as __remapped_group_set
1296
- // (not group_set). This avoids shadowing the input group_set column
1297
- // that aggregate expressions reference. GROUP BY still uses position 1
1298
- // (the remapped value), so rows with different original group_sets
1299
- // collapse correctly. A follow-up CTE renames it to group_set.
1417
+ // replace column[0]'s SQL with the remap CASE aliased as
1418
+ // __remapped_group_set (not group_set). This avoids shadowing the input
1419
+ // group_set column that aggregate expressions reference. GROUP BY still
1420
+ // uses position 1 (the remapped value), so rows with different original
1421
+ // group_sets collapse correctly. A follow-up CTE renames it back, so the
1422
+ // column's carried-forward name stays group_set.
1300
1423
  const needsRemapStage = f.groupsAggregated.length > 0 &&
1301
1424
  this.parent.dialect.hasLateralColumnAliasInSelect;
1302
1425
  if (needsRemapStage) {
1303
- f.sql[0] = 'CASE ';
1426
+ let groupSetSQL = 'CASE ';
1304
1427
  for (const m of f.groupsAggregated) {
1305
- f.sql[0] += `WHEN group_set=${m.fromGroup} THEN ${m.toGroup} `;
1428
+ groupSetSQL += `WHEN group_set=${m.fromGroup} THEN ${m.toGroup} `;
1306
1429
  }
1307
- f.sql[0] += 'ELSE group_set END as __remapped_group_set';
1430
+ groupSetSQL += 'ELSE group_set END as __remapped_group_set';
1431
+ f.columns[0] = { sql: groupSetSQL, name: 'group_set', isDimension: true };
1308
1432
  }
1309
- s += (0, utils_1.indent)(f.sql.join(',\n')) + '\n';
1433
+ s += (0, utils_1.indent)(f.columns.map(c => c.sql).join(',\n')) + '\n';
1310
1434
  s += `FROM ${stageName}\n`;
1311
1435
  const where = this.rootResult.eliminateComputeGroupsSQL();
1312
1436
  if (where.length > 0) {
1313
1437
  s += `WHERE ${where}\n`;
1314
1438
  }
1315
- if (f.dimensionIndexes.length > 0) {
1316
- s += `GROUP BY ${f.dimensionIndexes.join(',')}\n`;
1439
+ const dimensionPositions = groupByPositions(f.columns);
1440
+ if (dimensionPositions.length > 0) {
1441
+ s += `GROUP BY ${dimensionPositions.join(',')}\n`;
1317
1442
  }
1318
1443
  this.resultStage = stageWriter.addStage(s);
1319
1444
  if (needsRemapStage) {
1320
- const cols = f.sql.slice(1).map(col => {
1321
- const asMatch = col.match(/as\s+(\S+)\s*$/i);
1322
- return asMatch ? asMatch[1] : '*';
1323
- });
1445
+ const cols = f.columns.slice(1).map(c => c.name);
1324
1446
  const remapSQL = `SELECT \n${(0, utils_1.indent)(['__remapped_group_set as group_set', ...cols].join(',\n'))}\nFROM ${this.resultStage}\n`;
1325
1447
  this.resultStage = stageWriter.addStage(remapSQL);
1326
1448
  }
1327
- this.resultStage = this.generatePipelinedStages(f.outputPipelinedSQL, this.resultStage, stageWriter);
1449
+ this.resultStage = this.generatePipelinedStages(f.outputPipelinedSQL, this.resultStage, stageWriter, f.columns);
1328
1450
  return this.resultStage;
1329
1451
  }
1330
1452
  genereateSQLCombineTurtles(stageWriter, stage0Name) {
1331
1453
  let s = 'SELECT\n';
1332
- const fieldsSQL = [];
1333
- let fieldIndex = 1;
1454
+ // Columns this combine stage emits. Unlike the grouped stages above, these
1455
+ // are the final output names (unsuffixed), not the group-set-suffixed form.
1456
+ // A following pipelined stage carries them forward by name.
1457
+ const columns = [];
1334
1458
  const outputPipelinedSQL = [];
1335
- const dimensionIndexes = [];
1336
1459
  for (const [name, fi] of this.rootResult.allFields) {
1337
1460
  const sqlName = this.parent.dialect.sqlQuoteIdentifier(name);
1338
1461
  if (fi instanceof field_instance_1.FieldInstanceField) {
1339
1462
  if (fi.fieldUsage.type === 'result') {
1340
1463
  if ((0, query_node_1.isScalarField)(fi.f)) {
1341
- fieldsSQL.push(this.parent.dialect.sqlQuoteIdentifier(`${name}__${this.rootResult.groupSet}`) + ` as ${sqlName}`);
1342
- dimensionIndexes.push(fieldIndex++);
1464
+ columns.push({
1465
+ sql: this.parent.dialect.sqlQuoteIdentifier(`${name}__${this.rootResult.groupSet}`) + ` as ${sqlName}`,
1466
+ name: sqlName,
1467
+ isDimension: true,
1468
+ });
1343
1469
  }
1344
1470
  else if ((0, query_node_1.isBasicCalculation)(fi.f)) {
1345
- fieldsSQL.push(this.parent.dialect.sqlAnyValueLastTurtle(this.parent.dialect.sqlQuoteIdentifier(`${name}__${this.rootResult.groupSet}`), this.rootResult.groupSet, sqlName));
1346
- fieldIndex++;
1471
+ columns.push({
1472
+ sql: this.parent.dialect.sqlAnyValueLastTurtle(this.parent.dialect.sqlQuoteIdentifier(`${name}__${this.rootResult.groupSet}`), this.rootResult.groupSet, sqlName),
1473
+ name: sqlName,
1474
+ isDimension: false,
1475
+ });
1347
1476
  }
1348
1477
  }
1349
1478
  }
1350
1479
  else if (fi instanceof field_instance_1.FieldInstanceResult) {
1351
1480
  if (fi.firstSegment.type === 'reduce') {
1352
- fieldsSQL.push(`${this.generateTurtleSQL(fi, stageWriter, sqlName, outputPipelinedSQL)} as ${sqlName}`);
1353
- fieldIndex++;
1481
+ columns.push({
1482
+ sql: `${this.generateTurtleSQL(fi, stageWriter, sqlName, outputPipelinedSQL)} as ${sqlName}`,
1483
+ name: sqlName,
1484
+ isDimension: false,
1485
+ });
1354
1486
  }
1355
1487
  else if (fi.firstSegment.type === 'project') {
1356
- fieldsSQL.push(this.parent.dialect.sqlAnyValueLastTurtle(this.parent.dialect.sqlQuoteIdentifier(`${name}__${this.rootResult.groupSet}`), this.rootResult.groupSet, sqlName));
1357
- fieldIndex++;
1488
+ columns.push({
1489
+ sql: this.parent.dialect.sqlAnyValueLastTurtle(this.parent.dialect.sqlQuoteIdentifier(`${name}__${this.rootResult.groupSet}`), this.rootResult.groupSet, sqlName),
1490
+ name: sqlName,
1491
+ isDimension: false,
1492
+ });
1358
1493
  }
1359
1494
  }
1360
1495
  }
1361
- s += (0, utils_1.indent)(fieldsSQL.join(',\n')) + `\nFROM ${stage0Name}\n`;
1496
+ s += (0, utils_1.indent)(columns.map(c => c.sql).join(',\n')) + `\nFROM ${stage0Name}\n`;
1362
1497
  const where = this.rootResult.eliminateComputeGroupsSQL();
1363
1498
  if (where.length > 0) {
1364
1499
  s += `WHERE ${where}\n`;
1365
1500
  }
1366
- if (dimensionIndexes.length > 0) {
1367
- s += `GROUP BY ${dimensionIndexes.join(',')}\n`;
1501
+ const dimensionPositions = groupByPositions(columns);
1502
+ if (dimensionPositions.length > 0) {
1503
+ s += `GROUP BY ${dimensionPositions.join(',')}\n`;
1368
1504
  }
1369
1505
  // order by
1370
1506
  s += this.genereateSQLOrderBy(this.firstSegment, this.rootResult);
@@ -1373,7 +1509,7 @@ class QueryQuery extends query_node_1.QueryField {
1373
1509
  s += `LIMIT ${this.firstSegment.limit}\n`;
1374
1510
  }
1375
1511
  this.resultStage = stageWriter.addStage(s);
1376
- this.resultStage = this.generatePipelinedStages(outputPipelinedSQL, this.resultStage, stageWriter);
1512
+ this.resultStage = this.generatePipelinedStages(outputPipelinedSQL, this.resultStage, stageWriter, columns);
1377
1513
  return this.resultStage;
1378
1514
  }
1379
1515
  // create a simplified version of the StructDef for dialects.
@@ -1438,7 +1574,21 @@ class QueryQuery extends query_node_1.QueryField {
1438
1574
  }
1439
1575
  return dialectFieldList;
1440
1576
  }
1441
- generateTurtleSQL(resultStruct, stageWriter, sqlFieldName, outputPipelinedSQL) {
1577
+ generateTurtleSQL(resultStruct, stageWriter, sqlFieldName, outputPipelinedSQL,
1578
+ // The single-group-set fast path (generateSingleGroupSetSQL) has no
1579
+ // group_set column to filter on: every row is in the one group. The
1580
+ // array-agg drops its `FILTER (WHERE group_set=N)` and keeps only a
1581
+ // projection's own `where:`.
1582
+ omitGroupSetFilter = false) {
1583
+ // How to emit this nest's first stage — and the last line of defense before
1584
+ // broken SQL for IR that didn't pass through the translator (cached,
1585
+ // deserialized, or query-builder IR). Same `nestStrategy` the translator
1586
+ // uses, so the two can't disagree.
1587
+ const pipeline = resultStruct.turtleDef.pipeline;
1588
+ const strategy = (0, nest_capability_1.nestStrategy)(pipeline[0], this.parent.dialect, pipeline.length > 1);
1589
+ if (strategy.kind === 'unsupported') {
1590
+ throw new malloy_compile_error_1.MalloyCompileError(`dialect '${this.parent.dialect.name}' cannot emit this nested query (${strategy.reason})`, strategy.reason, resultStruct.turtleDef.location);
1591
+ }
1442
1592
  // calculate the ordering.
1443
1593
  const compiledOrderBy = [];
1444
1594
  let orderingField;
@@ -1483,7 +1633,24 @@ class QueryQuery extends query_node_1.QueryField {
1483
1633
  }
1484
1634
  }
1485
1635
  else {
1486
- ret = this.parent.dialect.sqlAggregateTurtle(resultStruct.groupSet, dialectFieldList, orderBy);
1636
+ // A projection first stage applies its own `limit:` by slicing the
1637
+ // array-agg (it has no group-by keys to ROW_NUMBER-shave on) and its
1638
+ // `where:` by folding into the array-agg's group_set condition (it rides
1639
+ // the enclosing group_set, so a scan-level WHERE can't isolate it). This
1640
+ // is a first-stage question: if more stages follow, they consume this
1641
+ // (limited/filtered) array via the recursion in generateTurtlePipelineSQL.
1642
+ // A reduce first stage keeps the shave and the scan-level WHERE.
1643
+ let limit;
1644
+ let filterSQL;
1645
+ if (strategy.kind === 'project') {
1646
+ limit = strategy.limit;
1647
+ const where = this.generateSQLFilters(resultStruct, 'where');
1648
+ if (where.present()) {
1649
+ filterSQL = where.sql();
1650
+ }
1651
+ }
1652
+ // (capability is guarded by nestStrategy at the top of this method)
1653
+ ret = this.parent.dialect.sqlAggregateTurtle(omitGroupSetFilter ? undefined : resultStruct.groupSet, dialectFieldList, orderBy, limit, filterSQL);
1487
1654
  }
1488
1655
  // If the turtle is a pipeline, generate a UDF to compute it.
1489
1656
  const newStageWriter = new stage_writer_1.StageWriter(this.parent.dialect.supportsCTEinCoorelatedSubQueries, stageWriter);
@@ -1561,12 +1728,13 @@ class QueryQuery extends query_node_1.QueryField {
1561
1728
  this.maxGroupSet = r.nextGroupSetNumber - 1;
1562
1729
  this.rootResult.assignFieldsToGroups();
1563
1730
  (_a = this.rootResult).isComplexQuery || (_a.isComplexQuery = this.maxDepth > 0 || r.isComplex);
1564
- if (this.rootResult.isComplexQuery) {
1731
+ // A complex query needs the group-set fan-out unless it has just one group
1732
+ // set (only single-stage projection nests), in which case generateSimpleSQL
1733
+ // emits the same no-fan-out form a non-nested query gets.
1734
+ if (this.rootResult.isComplexQuery && !this.canUseSingleGroupSetSQL()) {
1565
1735
  return this.generateComplexSQL(stageWriter);
1566
1736
  }
1567
- else {
1568
- return this.generateSimpleSQL(stageWriter);
1569
- }
1737
+ return this.generateSimpleSQL(stageWriter);
1570
1738
  }
1571
1739
  generateSQLFromPipeline(stageWriter) {
1572
1740
  this.parent.maybeEmitParameterizedSourceUsage();
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * SourceDef Utilities for Persistence
3
3
  *
4
- * Key invariant: sourceID is ONLY assigned in DefineSource when a source
5
- * gets a name. Factory functions explicitly copy only the fields they need,
6
- * never using spread, to prevent accidental propagation of sourceID/extends.
7
- *
8
- * The `extends` property is set by callers when processing extend blocks.
4
+ * Key invariant: a source's identity fields (sourceID, referenceID, extends)
5
+ * are assigned in the translator (DefineSource / the reference path), never by
6
+ * these compiler factories. The factory functions explicitly copy only the
7
+ * fields they need, never using spread, so identity is not propagated onto a
8
+ * freshly built source.
9
9
  */
10
10
  import type { BuildID, FieldDef, GivenID, ModelDef, PersistableSourceDef, Query, QuerySourceDef, SourceDef, SourceID, SourceRegistryEntry, SourceRegistryValue, SQLPhraseSegment, SQLSourceDef, TableSourceDef } from './malloy_types';
11
11
  export declare function mkSourceID(name: string, url: string | undefined): SourceID;
@@ -30,13 +30,37 @@ export declare function mkSQLSourceDef(base: SourceDef, selectStr: string, selec
30
30
  */
31
31
  export declare function mkTableSourceDef(name: string, connection: string, tablePath: string, dialect: string, fields: FieldDef[]): TableSourceDef;
32
32
  /**
33
- * Resolve a sourceID to a SourceDef using the sourceRegistry.
33
+ * Resolve a SourceID (a source's own `sourceID`, or a `referenceID` pointing at
34
+ * one) to its SourceDef using the sourceRegistry, returning any kind of source.
34
35
  *
35
36
  * @param modelDef The model definition containing the registry
36
- * @param sourceID The sourceID to resolve
37
+ * @param sourceID The SourceID to resolve
37
38
  * @returns The SourceDef if found, undefined otherwise
38
39
  */
40
+ export declare function resolveSourceRef(modelDef: ModelDef, sourceID: SourceID): SourceDef | undefined;
41
+ /**
42
+ * Resolve a sourceID to a persistable SourceDef using the sourceRegistry.
43
+ *
44
+ * @param modelDef The model definition containing the registry
45
+ * @param sourceID The sourceID to resolve
46
+ * @returns The PersistableSourceDef if found, undefined otherwise
47
+ */
39
48
  export declare function resolveSourceID(modelDef: ModelDef, sourceID: SourceID): PersistableSourceDef | undefined;
49
+ /** The namespace entry a source refers to (see `sourceNamespaceReference`). */
50
+ export interface NamespaceReference {
51
+ /** The name this source is bound to in `modelDef.contents`. */
52
+ name: string;
53
+ /** The referenced source. */
54
+ source: SourceDef;
55
+ }
56
+ /**
57
+ * If `sd` was created as an unmodified reference to another source
58
+ * (`sd.referenceID` is set) and that source is present in this model's
59
+ * namespace, return it together with the name it goes by. Returns undefined
60
+ * when `sd` defines its own shape, or when the referenced source is not in this
61
+ * model's namespace (e.g. an imported source whose own target wasn't imported).
62
+ */
63
+ export declare function sourceNamespaceReference(modelDef: ModelDef, sd: SourceDef): NamespaceReference | undefined;
40
64
  /**
41
65
  * Add an entry to the sourceRegistry.
42
66
  *