@dbsp/nql 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -427,8 +427,13 @@ interface ColumnValidatorRelation {
427
427
  readonly target: string;
428
428
  readonly type?: 'hasOne' | 'hasMany' | 'belongsTo' | 'belongsToMany';
429
429
  readonly foreignKey?: string | readonly string[] | undefined;
430
- readonly sourceKey?: string | undefined;
431
- readonly targetKey?: string | undefined;
430
+ readonly through?: string | undefined;
431
+ readonly otherKey?: string | undefined;
432
+ readonly throughSourceKey?: string | undefined;
433
+ readonly throughTargetKey?: string | undefined;
434
+ readonly recursive?: unknown;
435
+ readonly sourceKey?: string | readonly string[] | undefined;
436
+ readonly targetKey?: string | readonly string[] | undefined;
432
437
  }
433
438
  /**
434
439
  * Options for the NQL compiler.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { NQL_INTERNAL_COMPILER_OPTIONS, markNqlTrustedRelationFilter, createNqlBindingRef, isNqlBindingRef, getNqlBindingRefName } from '@dbsp/types/internal';
2
- import { NQL_SELECT_AGGREGATE_FUNCTIONS, NQL_SELECT_VALUE_FUNCTIONS, NQL_SELECT_JSON_FUNCTIONS, NQL_SELECT_SCALAR_FUNCTIONS, isNqlSelectWindowFunctionAllowed, isRankingWindowFunction, isParamIntent } from '@dbsp/types';
1
+ import { NQL_INTERNAL_COMPILER_OPTIONS, explainUnsupportedNqlBindingIncludeHop, markNqlTrustedRelationFilter, createNqlBindingRef, isNqlBindingRef, getNqlBindingRefName } from '@dbsp/types/internal';
2
+ import { NQL_SELECT_AGGREGATE_FUNCTIONS, NQL_SELECT_VALUE_FUNCTIONS, NQL_SELECT_JSON_FUNCTIONS, NQL_SELECT_SCALAR_FUNCTIONS, toColumnList, isNqlSelectWindowFunctionAllowed, isRankingWindowFunction, isParamIntent } from '@dbsp/types';
3
3
  import { createToken, Lexer, CstParser } from 'chevrotain';
4
4
 
5
5
  // src/compiler/index.ts
@@ -43,8 +43,6 @@ var NqlSemanticException = class extends Error {
43
43
  this.relatedSymbol = relatedSymbol;
44
44
  }
45
45
  };
46
-
47
- // src/compiler/column-validator.ts
48
46
  var ColumnValidator = class _ColumnValidator {
49
47
  constructor(schema) {
50
48
  this.schema = schema;
@@ -110,6 +108,88 @@ var ColumnValidator = class _ColumnValidator {
110
108
  getVirtualBindingScalarRelation(bindingName, relationName) {
111
109
  return this.virtualBindingRelationFilters.get(bindingName)?.scalarRelations?.find((relation) => relation.relation === relationName);
112
110
  }
111
+ static virtualRelationIncludeShape(relation) {
112
+ const relationType = relation.relationType;
113
+ const foreignKey = relationType === "belongsTo" ? relation.sourceColumn : relation.targetColumn;
114
+ return {
115
+ type: relationType,
116
+ foreignKey,
117
+ source: relation.sourceTable,
118
+ target: relation.targetTable
119
+ };
120
+ }
121
+ resolveVirtualBindingScalarRelationForInclude(bindingName, relationPath) {
122
+ const relationName = relationPath.join(".");
123
+ if (relationPath.length < 1) {
124
+ throw new NqlSemanticException(
125
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
126
+ `Query '${bindingName}' reads from an NQL binding and cannot use relation include '${relationName}' (ref-#192): the relation path must name a source-table relation.`
127
+ );
128
+ }
129
+ const relation = relationPath[0];
130
+ if (!relation) {
131
+ throw new NqlSemanticException(
132
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
133
+ `Query '${bindingName}' reads from an NQL binding and cannot use relation include '${relationName}' (ref-#192): the relation path must name a source-table relation.`
134
+ );
135
+ }
136
+ const virtualRelation = this.getVirtualBindingScalarRelation(
137
+ bindingName,
138
+ relation
139
+ );
140
+ if (virtualRelation) {
141
+ const resolvedFirstHop = this.getRelation(virtualRelation.sourceTable, relation) ?? _ColumnValidator.virtualRelationIncludeShape(virtualRelation);
142
+ const unsupportedFirstHopReason = explainUnsupportedNqlBindingIncludeHop(
143
+ relation,
144
+ resolvedFirstHop,
145
+ { relation }
146
+ );
147
+ if (unsupportedFirstHopReason) {
148
+ throw new NqlSemanticException(
149
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
150
+ `Query '${bindingName}' reads from an NQL binding and cannot use relation include '${relationName}' (ref-#192): ${unsupportedFirstHopReason}.`
151
+ );
152
+ }
153
+ let sourceTable = virtualRelation.targetTable;
154
+ for (let i = 1; i < relationPath.length; i++) {
155
+ const tailRelation = relationPath[i];
156
+ if (!tailRelation) {
157
+ throw new NqlSemanticException(
158
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
159
+ `Query '${bindingName}' reads from an NQL binding and cannot use relation include '${relationName}' (ref-#192): relation path segment ${i + 1} is empty.`
160
+ );
161
+ }
162
+ const resolvedTail = this.getRelation(sourceTable, tailRelation);
163
+ if (!resolvedTail) {
164
+ throw new NqlSemanticException(
165
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
166
+ `Query '${bindingName}' reads from an NQL binding and cannot use relation include '${relationName}' (ref-#192): tail relation '${tailRelation}' is not declared on table '${sourceTable}'.`
167
+ );
168
+ }
169
+ const unsupportedReason = explainUnsupportedNqlBindingIncludeHop(
170
+ tailRelation,
171
+ resolvedTail,
172
+ { relation: tailRelation }
173
+ );
174
+ if (unsupportedReason) {
175
+ throw new NqlSemanticException(
176
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
177
+ `Query '${bindingName}' reads from an NQL binding and cannot use relation include '${relationName}' (ref-#192): tail ${unsupportedReason}.`
178
+ );
179
+ }
180
+ sourceTable = resolvedTail.target;
181
+ }
182
+ return virtualRelation;
183
+ }
184
+ const reason = this.explainVirtualBindingScalarRelationRejection(
185
+ bindingName,
186
+ relation
187
+ );
188
+ throw new NqlSemanticException(
189
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
190
+ `Query '${bindingName}' reads from an NQL binding and cannot use relation include '${relation}' (ref-#192): ${reason}.`
191
+ );
192
+ }
113
193
  explainVirtualBindingRelationRejection(bindingName, relationName) {
114
194
  const metadata = this.virtualBindingRelationFilters.get(bindingName);
115
195
  if (!metadata) {
@@ -124,27 +204,24 @@ var ColumnValidator = class _ColumnValidator {
124
204
  if (!relation) {
125
205
  return `relation '${relationName}' is not declared on source table '${sourceTable}'`;
126
206
  }
127
- const fk = relation.foreignKey;
128
- const fkColumns = typeof fk === "string" ? [fk] : Array.isArray(fk) ? [...fk] : [];
207
+ const fkColumns = toColumnList(relation.foreignKey);
129
208
  if (relation.type !== "belongsTo") {
130
209
  return `relation '${relationName}' is '${relation.type ?? "unknown"}'; A-lite only supports relations whose FK column is on the binding source table`;
131
210
  }
132
- if (fkColumns.length !== 1) {
133
- return `relation '${relationName}' must have exactly one FK column for A-lite binding relation filters`;
211
+ if (fkColumns.length === 0) {
212
+ return `relation '${relationName}' must have at least one FK column for A-lite binding relation filters`;
134
213
  }
135
- const fkColumn = fkColumns[0];
136
- if (fkColumn === void 0) {
137
- return `relation '${relationName}' must have exactly one FK column for A-lite binding relation filters`;
138
- }
139
- const directProjection = metadata.directProjectionLineage?.find(
140
- (projection) => projection.sourceTable === sourceTable && _ColumnValidator.columnsMatch(projection.sourceColumn, fkColumn)
141
- );
142
- if (!directProjection) {
143
- const available2 = this.virtualBindingTables.get(bindingName)?.join(", ");
144
- return `relation '${relationName}' FK column '${fkColumn}' is not projected as a direct source-column projection by binding '${bindingName}'${available2 ? ` (available columns: ${available2})` : ""}`;
214
+ for (const fkColumn of fkColumns) {
215
+ const directProjection = metadata.directProjectionLineage?.find(
216
+ (projection) => projection.sourceTable === sourceTable && _ColumnValidator.columnsMatch(projection.sourceColumn, fkColumn)
217
+ );
218
+ if (!directProjection) {
219
+ const available2 = this.virtualBindingTables.get(bindingName)?.join(", ");
220
+ return `relation '${relationName}' FK column '${fkColumn}' is not projected as a direct source-column projection by binding '${bindingName}'${available2 ? ` (available columns: ${available2})` : ""}`;
221
+ }
145
222
  }
146
223
  const available = this.virtualBindingTables.get(bindingName)?.join(", ");
147
- return `relation '${relationName}' FK column '${fkColumn}' is not available through binding '${bindingName}'${available ? ` (available columns: ${available})` : ""}`;
224
+ return `relation '${relationName}' FK columns '${fkColumns.join(", ")}' are not available through binding '${bindingName}'${available ? ` (available columns: ${available})` : ""}`;
148
225
  }
149
226
  explainVirtualBindingScalarRelationRejection(bindingName, relationName) {
150
227
  const metadata = this.virtualBindingRelationFilters.get(bindingName);
@@ -160,29 +237,25 @@ var ColumnValidator = class _ColumnValidator {
160
237
  if (!relation) {
161
238
  return `relation '${relationName}' is not declared on source table '${sourceTable}'`;
162
239
  }
163
- const fk = relation.foreignKey;
164
- const fkColumns = typeof fk === "string" ? [fk] : Array.isArray(fk) ? [...fk] : [];
165
- if (relation.type !== "belongsTo" && relation.type !== "hasOne") {
166
- return `relation '${relationName}' is '${relation.type ?? "unknown"}'; binding relation columns require a scalar belongsTo/hasOne relation`;
167
- }
168
- if (fkColumns.length !== 1) {
169
- return `relation '${relationName}' must have exactly one FK column for binding relation columns`;
170
- }
171
- const fkColumn = fkColumns[0];
172
- if (fkColumn === void 0) {
173
- return `relation '${relationName}' must have exactly one FK column for binding relation columns`;
174
- }
175
- const sourceColumn = relation.type === "belongsTo" ? fkColumn : relation.sourceKey ?? "id";
176
- const directProjection = metadata.directProjectionLineage?.find(
177
- (projection) => projection.sourceTable === sourceTable && _ColumnValidator.columnsMatch(projection.sourceColumn, sourceColumn)
240
+ const unsupportedReason = explainUnsupportedNqlBindingIncludeHop(
241
+ relationName,
242
+ relation
178
243
  );
179
- if (!directProjection) {
180
- const available2 = this.virtualBindingTables.get(bindingName)?.join(", ");
181
- const sourceColumnLabel = relation.type === "belongsTo" ? "FK column" : "source key column";
182
- return `relation '${relationName}' ${sourceColumnLabel} '${sourceColumn}' is not projected as a direct source-column projection by binding '${bindingName}'${available2 ? ` (available columns: ${available2})` : ""}`;
244
+ if (unsupportedReason) return unsupportedReason;
245
+ const fkColumns = toColumnList(relation.foreignKey);
246
+ const sourceColumns = relation.type === "belongsTo" ? fkColumns : toColumnList(relation.sourceKey).length > 0 ? toColumnList(relation.sourceKey) : ["id"];
247
+ for (const sourceColumn of sourceColumns) {
248
+ const directProjection = metadata.directProjectionLineage?.find(
249
+ (projection) => projection.sourceTable === sourceTable && _ColumnValidator.columnsMatch(projection.sourceColumn, sourceColumn)
250
+ );
251
+ if (!directProjection) {
252
+ const available2 = this.virtualBindingTables.get(bindingName)?.join(", ");
253
+ const sourceColumnLabel = relation.type === "belongsTo" ? "FK column" : "source key column";
254
+ return `relation '${relationName}' ${sourceColumnLabel} '${sourceColumn}' is not projected as a direct source-column projection by binding '${bindingName}'${available2 ? ` (available columns: ${available2})` : ""}`;
255
+ }
183
256
  }
184
257
  const available = this.virtualBindingTables.get(bindingName)?.join(", ");
185
- return `relation '${relationName}' source column '${sourceColumn}' is not available through binding '${bindingName}'${available ? ` (available columns: ${available})` : ""}`;
258
+ return `relation '${relationName}' source columns '${sourceColumns.join(", ")}' are not available through binding '${bindingName}'${available ? ` (available columns: ${available})` : ""}`;
186
259
  }
187
260
  /**
188
261
  * Convert camelCase to snake_case for column name matching.
@@ -281,6 +354,28 @@ var ColumnValidator = class _ColumnValidator {
281
354
  );
282
355
  }
283
356
  };
357
+ var DEFAULT_RELATION_TARGET_COLUMN = "id";
358
+ function relationForeignKeys(relation) {
359
+ if (!relation) return void 0;
360
+ const columns = toColumnList(relation.foreignKey);
361
+ return columns.length > 0 ? columns : void 0;
362
+ }
363
+ function relationCardinality(relation) {
364
+ return relation?.type === "hasMany" || relation?.type === "belongsToMany" ? "many" : "one";
365
+ }
366
+ function scalarRelationJoinColumns(relation) {
367
+ if (!relation) return void 0;
368
+ if (relation.type !== "belongsTo" && relation.type !== "hasOne" && relation.type !== "hasMany") {
369
+ return void 0;
370
+ }
371
+ const fkColumns = relationForeignKeys(relation);
372
+ if (!fkColumns) return void 0;
373
+ const sourceKeys = toColumnList(relation.sourceKey);
374
+ const targetKeys = toColumnList(relation.targetKey);
375
+ const sourceJoinColumn = relation.type === "belongsTo" ? fkColumns : sourceKeys.length > 0 ? sourceKeys : [DEFAULT_RELATION_TARGET_COLUMN];
376
+ const targetJoinColumn = relation.type === "belongsTo" ? targetKeys.length > 0 ? targetKeys : [DEFAULT_RELATION_TARGET_COLUMN] : fkColumns;
377
+ return { sourceJoinColumn, targetJoinColumn };
378
+ }
284
379
  var FORBIDDEN_PARAM_NAMES = /* @__PURE__ */ new Set([
285
380
  "__proto__",
286
381
  "constructor",
@@ -401,6 +496,17 @@ function throwBindingRelationColumn182(bindingName, relationName, reason) {
401
496
  `Query '${bindingName}' reads from an NQL binding and cannot select relation column '${relationName}' under A-full (ref-#182): ${reason}.`
402
497
  );
403
498
  }
499
+ function bindingRelationColumnUnsupportedReason(relationName, relation) {
500
+ const unsupportedReason = explainUnsupportedNqlBindingIncludeHop(
501
+ relationName,
502
+ relation
503
+ );
504
+ if (unsupportedReason) return unsupportedReason;
505
+ if (relationCardinality(relation) !== "one") {
506
+ return `relation '${relationName}' is '${relation.type ?? "unknown"}'; scalar binding relation columns require every hop to be to-one (belongsTo/hasOne) (ref-#192)`;
507
+ }
508
+ return void 0;
509
+ }
404
510
  function resolveBindingRelationFilter(ctx, bindingName, relationPath) {
405
511
  if (!isBindingTable(ctx, bindingName)) return void 0;
406
512
  const actualBindingName = bindingName;
@@ -435,19 +541,19 @@ function resolveBindingRelationColumn(ctx, bindingName, relationPath, selectedCo
435
541
  if (!isBindingTable(ctx, bindingName)) return void 0;
436
542
  const actualBindingName = bindingName;
437
543
  const relationName = relationPath.join(".");
438
- if (relationPath.length !== 1) {
544
+ const relation = relationPath[0];
545
+ if (!relation) {
439
546
  throwBindingRelationColumn182(
440
547
  actualBindingName,
441
548
  relationName,
442
- "multi-hop binding relation columns are not supported; the relation path must be a single source-table relation"
549
+ "the relation path must name a source-table relation"
443
550
  );
444
551
  }
445
- const relation = relationPath[0];
446
- if (!relation) {
552
+ if (!ctx.validator) {
447
553
  throwBindingRelationColumn182(
448
554
  actualBindingName,
449
555
  relationName,
450
- "the relation path must name a source-table relation"
556
+ "model metadata is not available"
451
557
  );
452
558
  }
453
559
  const virtualRelation = ctx.validator?.getVirtualBindingScalarRelation(
@@ -459,10 +565,92 @@ function resolveBindingRelationColumn(ctx, bindingName, relationPath, selectedCo
459
565
  actualBindingName,
460
566
  relation
461
567
  ) ?? "model metadata is not available";
462
- throwBindingRelationColumn182(actualBindingName, relation, reason);
568
+ throwBindingRelationColumn182(actualBindingName, relationName, reason);
569
+ }
570
+ if (relationPath.length === 1) {
571
+ ctx.validator.validateColumn(virtualRelation.targetTable, selectedColumn);
572
+ return virtualRelation;
573
+ }
574
+ const firstHop = ctx.validator.getRelation(
575
+ virtualRelation.sourceTable,
576
+ relation
577
+ );
578
+ if (!firstHop) {
579
+ throwBindingRelationColumn182(
580
+ actualBindingName,
581
+ relationName,
582
+ `relation '${relation}' is not declared on source table '${virtualRelation.sourceTable}'`
583
+ );
463
584
  }
464
- ctx.validator?.validateColumn(virtualRelation.targetTable, selectedColumn);
465
- return virtualRelation;
585
+ const firstHopReason = bindingRelationColumnUnsupportedReason(
586
+ relation,
587
+ firstHop
588
+ );
589
+ if (firstHopReason) {
590
+ throwBindingRelationColumn182(
591
+ actualBindingName,
592
+ relationName,
593
+ firstHopReason
594
+ );
595
+ }
596
+ let sourceTable = virtualRelation.targetTable;
597
+ const hops = [];
598
+ for (let i = 1; i < relationPath.length; i++) {
599
+ const tailRelation = relationPath[i];
600
+ if (!tailRelation) {
601
+ throwBindingRelationColumn182(
602
+ actualBindingName,
603
+ relationName,
604
+ `relation path segment ${i + 1} is empty (ref-#192)`
605
+ );
606
+ }
607
+ const resolvedTail = ctx.validator.getRelation(sourceTable, tailRelation);
608
+ if (!resolvedTail) {
609
+ throwBindingRelationColumn182(
610
+ actualBindingName,
611
+ relationName,
612
+ `tail relation '${tailRelation}' is not declared on table '${sourceTable}' (ref-#192)`
613
+ );
614
+ }
615
+ const tailReason = bindingRelationColumnUnsupportedReason(
616
+ tailRelation,
617
+ resolvedTail
618
+ );
619
+ if (tailReason) {
620
+ throwBindingRelationColumn182(
621
+ actualBindingName,
622
+ relationName,
623
+ `tail ${tailReason}`
624
+ );
625
+ }
626
+ const joinColumns = scalarRelationJoinColumns(resolvedTail);
627
+ if (!joinColumns) {
628
+ throwBindingRelationColumn182(
629
+ actualBindingName,
630
+ relationName,
631
+ `tail relation '${tailRelation}' cannot be resolved to a scalar join (ref-#192)`
632
+ );
633
+ }
634
+ if (joinColumns.sourceJoinColumn.length !== joinColumns.targetJoinColumn.length) {
635
+ throwBindingRelationColumn182(
636
+ actualBindingName,
637
+ relationName,
638
+ `tail relation '${tailRelation}' has mismatched join column counts (${joinColumns.sourceJoinColumn.length} source, ${joinColumns.targetJoinColumn.length} target) (ref-#179)`
639
+ );
640
+ }
641
+ hops.push({
642
+ target: resolvedTail.target,
643
+ fkColumn: joinColumns.sourceJoinColumn,
644
+ joinColumn: joinColumns.targetJoinColumn
645
+ });
646
+ sourceTable = resolvedTail.target;
647
+ }
648
+ ctx.validator.validateColumn(sourceTable, selectedColumn);
649
+ return {
650
+ ...virtualRelation,
651
+ relation: relationName,
652
+ hops
653
+ };
466
654
  }
467
655
  function assertNoBindingRelationPath(ctx, bindingName, path) {
468
656
  if (!isBindingTable(ctx, bindingName)) return;
@@ -1099,7 +1287,6 @@ var PORTABLE_BINDING_FINAL_FUNCTIONS = /* @__PURE__ */ new Set([
1099
1287
  ...NQL_SELECT_AGGREGATE_FUNCTIONS,
1100
1288
  ...NQL_SELECT_VALUE_FUNCTIONS
1101
1289
  ]);
1102
- var DEFAULT_RELATION_TARGET_COLUMN = "id";
1103
1290
  function compileNestedQuery(query, ctx, fns, bindings) {
1104
1291
  const savedContext = {
1105
1292
  currentFromTable: ctx.currentFromTable,
@@ -1182,19 +1369,9 @@ function selectContainsAggregate(select) {
1182
1369
  );
1183
1370
  }
1184
1371
  function relationForeignKeysOnSource(relation) {
1185
- if (!relation || relation.type !== "belongsTo") return void 0;
1186
- if (typeof relation.foreignKey === "string") return [relation.foreignKey];
1187
- if (Array.isArray(relation.foreignKey)) return relation.foreignKey;
1188
- return void 0;
1189
- }
1190
- function relationForeignKeys(relation) {
1191
- if (!relation) return void 0;
1192
- if (typeof relation.foreignKey === "string") return [relation.foreignKey];
1193
- if (Array.isArray(relation.foreignKey)) return relation.foreignKey;
1194
- return void 0;
1195
- }
1196
- function relationCardinality(relation) {
1197
- return relation?.type === "hasMany" || relation?.type === "belongsToMany" ? "many" : "one";
1372
+ if (relation?.type !== "belongsTo") return void 0;
1373
+ const fkColumns = toColumnList(relation.foreignKey);
1374
+ return fkColumns.length > 0 ? fkColumns : void 0;
1198
1375
  }
1199
1376
  function unsafeBindingRelationReason(intent, ctx, bindingDependencies) {
1200
1377
  if (!ctx.validator) return "model metadata is not available";
@@ -1229,48 +1406,70 @@ function findDirectSourceProjection(sourceTable, sourceColumn, directProjectionL
1229
1406
  (projection) => projection.sourceTable === sourceTable && columnsMatch(projection.sourceColumn, sourceColumn)
1230
1407
  );
1231
1408
  }
1409
+ function findDirectSourceProjections(sourceTable, sourceColumns, directProjectionLineage) {
1410
+ const projections = [];
1411
+ for (const sourceColumn of sourceColumns) {
1412
+ const projection = findDirectSourceProjection(
1413
+ sourceTable,
1414
+ sourceColumn,
1415
+ directProjectionLineage
1416
+ );
1417
+ if (!projection) return void 0;
1418
+ projections.push(projection);
1419
+ }
1420
+ return projections;
1421
+ }
1232
1422
  function virtualRelationForBinding(relation, sourceTable, directProjectionLineage) {
1233
1423
  const fkColumns = relationForeignKeysOnSource(relation);
1234
- if (!relation || !fkColumns || fkColumns.length !== 1) return void 0;
1235
- const fkColumn = fkColumns[0];
1236
- const sourceProjection = findDirectSourceProjection(
1424
+ if (!relation || !fkColumns) return void 0;
1425
+ const sourceProjections = findDirectSourceProjections(
1237
1426
  sourceTable,
1238
- fkColumn,
1427
+ fkColumns,
1239
1428
  directProjectionLineage
1240
1429
  );
1241
- if (!sourceProjection) return void 0;
1430
+ if (!sourceProjections) return void 0;
1431
+ const targetKey = toColumnList(relation.targetKey);
1432
+ const targetColumns = targetKey.length > 0 ? targetKey : [DEFAULT_RELATION_TARGET_COLUMN];
1433
+ if (targetColumns.length !== fkColumns.length) return void 0;
1242
1434
  return {
1243
1435
  relation: relation.name,
1244
1436
  sourceTable,
1245
1437
  targetTable: relation.target,
1246
- sourceColumn: sourceProjection.outputColumn,
1247
- targetColumn: relation.targetKey ?? DEFAULT_RELATION_TARGET_COLUMN,
1438
+ sourceColumn: sourceProjections.map(
1439
+ (projection) => projection.outputColumn
1440
+ ),
1441
+ targetColumn: targetColumns,
1442
+ hops: [],
1248
1443
  cardinality: "one"
1249
1444
  };
1250
1445
  }
1251
1446
  function scalarVirtualRelationForBinding(relation, sourceTable, directProjectionLineage) {
1252
1447
  if (!relation) return void 0;
1253
- if (relation.type !== "belongsTo" && relation.type !== "hasOne") {
1448
+ if (relation.type !== "belongsTo" && relation.type !== "hasOne" && relation.type !== "hasMany") {
1254
1449
  return void 0;
1255
1450
  }
1256
- const fkColumns = relationForeignKeys(relation);
1257
- if (!fkColumns || fkColumns.length !== 1) return void 0;
1258
- const fkColumn = fkColumns[0];
1259
- const sourceJoinColumn = relation.type === "belongsTo" ? fkColumn : relation.sourceKey ?? DEFAULT_RELATION_TARGET_COLUMN;
1260
- const targetJoinColumn = relation.type === "belongsTo" ? relation.targetKey ?? DEFAULT_RELATION_TARGET_COLUMN : fkColumn;
1261
- const sourceProjection = findDirectSourceProjection(
1451
+ const joinColumns = scalarRelationJoinColumns(relation);
1452
+ if (!joinColumns) return void 0;
1453
+ if (joinColumns.sourceJoinColumn.length !== joinColumns.targetJoinColumn.length) {
1454
+ return void 0;
1455
+ }
1456
+ const sourceProjections = findDirectSourceProjections(
1262
1457
  sourceTable,
1263
- sourceJoinColumn,
1458
+ joinColumns.sourceJoinColumn,
1264
1459
  directProjectionLineage
1265
1460
  );
1266
- if (!sourceProjection) return void 0;
1461
+ if (!sourceProjections) return void 0;
1267
1462
  return {
1268
1463
  relation: relation.name,
1269
1464
  sourceTable,
1270
1465
  targetTable: relation.target,
1271
- sourceColumn: sourceProjection.outputColumn,
1272
- targetColumn: targetJoinColumn,
1273
- cardinality: relationCardinality(relation)
1466
+ sourceColumn: sourceProjections.map(
1467
+ (projection) => projection.outputColumn
1468
+ ),
1469
+ targetColumn: joinColumns.targetJoinColumn,
1470
+ hops: [],
1471
+ cardinality: relationCardinality(relation),
1472
+ relationType: relation.type
1274
1473
  };
1275
1474
  }
1276
1475
  function getBindingRelationFilterMetadata(intent, ctx, outputSchema, bindingDependencies) {
@@ -1338,6 +1537,19 @@ function validateBindingFinalPath(expr, ctx, bindingName) {
1338
1537
  }
1339
1538
  assertNoBindingRelationPath(ctx, bindingName, segments.join("."));
1340
1539
  }
1540
+ function resolveBindingRelationInclude(ctx, bindingName, relationPath) {
1541
+ if (!isBindingTable(ctx, bindingName)) return void 0;
1542
+ if (!bindingName || !ctx.validator) {
1543
+ throw new NqlSemanticException(
1544
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
1545
+ `Query '${bindingName ?? "<unknown>"}' reads from an NQL binding and cannot use relation include '${relationPath.join(".")}' (ref-#192): model metadata is not available.`
1546
+ );
1547
+ }
1548
+ return ctx.validator.resolveVirtualBindingScalarRelationForInclude(
1549
+ bindingName,
1550
+ relationPath
1551
+ );
1552
+ }
1341
1553
  function validateBindingFinalFunction(expr, ctx, bindingName, allowRelationFilters) {
1342
1554
  const fn = expr.name.toLowerCase();
1343
1555
  if (!PORTABLE_BINDING_FINAL_FUNCTIONS.has(fn)) {
@@ -1538,12 +1750,7 @@ function validateBindingFinalSelectClause(clause, ctx, bindingName) {
1538
1750
  case "star":
1539
1751
  break;
1540
1752
  case "relationStar":
1541
- assertNoBindingRelationConstruct(
1542
- ctx,
1543
- bindingName,
1544
- "select relation columns",
1545
- item.relation.join(".")
1546
- );
1753
+ resolveBindingRelationInclude(ctx, bindingName, item.relation);
1547
1754
  break;
1548
1755
  case "expression":
1549
1756
  if (item.expression.type === "path" && item.expression.segments.length > 1) {
@@ -1779,21 +1986,38 @@ function compileQueryInternal(query, ctx, fns, bindings) {
1779
1986
  }
1780
1987
  }
1781
1988
  }
1782
- if (select && select.type === "expressions" && !isBindingSource) {
1989
+ if (select && select.type === "expressions") {
1783
1990
  const relationPaths = /* @__PURE__ */ new Set();
1784
1991
  for (const expr of select.columns) {
1785
1992
  if (expr.kind === "relationColumn") {
1786
- relationPaths.add(expr.relation);
1993
+ if (!isBindingSource || expr.column === "*") {
1994
+ relationPaths.add(expr.relation);
1995
+ }
1787
1996
  }
1788
1997
  }
1789
1998
  if (relationPaths.size > 0) {
1790
- const nestedIncludes = buildNestedIncludes(relationPaths, flatMode);
1791
- for (const inc of nestedIncludes) {
1792
- const exists = allIncludes.some(
1793
- (existing) => existing.relation === inc.relation
1794
- );
1795
- if (!exists) {
1796
- allIncludes.push(inc);
1999
+ if (isBindingSource) {
2000
+ for (const relation of relationPaths) {
2001
+ resolveBindingRelationInclude(ctx, query.table, relation.split("."));
2002
+ }
2003
+ const nestedIncludes = buildNestedIncludes(relationPaths, flatMode);
2004
+ for (const inc of nestedIncludes) {
2005
+ const exists = allIncludes.some(
2006
+ (existing) => existing.relation === inc.relation
2007
+ );
2008
+ if (!exists) {
2009
+ allIncludes.push(inc);
2010
+ }
2011
+ }
2012
+ } else {
2013
+ const nestedIncludes = buildNestedIncludes(relationPaths, flatMode);
2014
+ for (const inc of nestedIncludes) {
2015
+ const exists = allIncludes.some(
2016
+ (existing) => existing.relation === inc.relation
2017
+ );
2018
+ if (!exists) {
2019
+ allIncludes.push(inc);
2020
+ }
1797
2021
  }
1798
2022
  }
1799
2023
  }
@@ -2858,6 +3082,7 @@ function compileRelationFilter(expr, ctx, fns, aliasContext, outerAliases) {
2858
3082
  targetTable: bindingRelation.targetTable,
2859
3083
  sourceColumn: bindingRelation.sourceColumn,
2860
3084
  targetColumn: bindingRelation.targetColumn,
3085
+ hops: bindingRelation.hops,
2861
3086
  ...bindingRelation.cardinality !== void 0 && {
2862
3087
  cardinality: bindingRelation.cardinality
2863
3088
  }
@@ -2967,6 +3192,20 @@ var SELECT_SCALAR_FUNCTION_NAMES = /* @__PURE__ */ new Set([
2967
3192
  ...NQL_SELECT_JSON_FUNCTIONS,
2968
3193
  ...NQL_SELECT_SCALAR_FUNCTIONS
2969
3194
  ]);
3195
+ function resolveBindingRelationInclude2(ctx, bindingName, relationPath) {
3196
+ if (!isBindingTable(ctx, bindingName)) return false;
3197
+ if (!bindingName || !ctx.validator) {
3198
+ throw new NqlSemanticException(
3199
+ NqlErrorCodes.SEM_INVALID_SYNTAX,
3200
+ `Query '${bindingName ?? "<unknown>"}' reads from an NQL binding and cannot use relation include '${relationPath.join(".")}' (ref-#192): model metadata is not available.`
3201
+ );
3202
+ }
3203
+ ctx.validator.resolveVirtualBindingScalarRelationForInclude(
3204
+ bindingName,
3205
+ relationPath
3206
+ );
3207
+ return true;
3208
+ }
2970
3209
  function validateSelectPathExpression(expr, ctx) {
2971
3210
  if (!ctx.currentFromTable) return;
2972
3211
  const { segments } = expr;
@@ -3022,12 +3261,14 @@ function compileSelectClause(clause, ctx, fns) {
3022
3261
  } else if (item.type === "relationStar") {
3023
3262
  hasExpressions = true;
3024
3263
  const relation = item.relation.join(".");
3025
- assertNoBindingRelationConstruct(
3026
- ctx,
3027
- ctx.currentFromTable,
3028
- "select relation columns",
3029
- relation
3030
- );
3264
+ if (!resolveBindingRelationInclude2(ctx, ctx.currentFromTable, item.relation)) {
3265
+ assertNoBindingRelationConstruct(
3266
+ ctx,
3267
+ ctx.currentFromTable,
3268
+ "select relation columns",
3269
+ relation
3270
+ );
3271
+ }
3031
3272
  expressions.push({
3032
3273
  kind: "relationColumn",
3033
3274
  relation,
@@ -3368,7 +3609,8 @@ function compileMultiSegmentPath(expr, item, ctx) {
3368
3609
  );
3369
3610
  }
3370
3611
  if (ctx.currentFromTable && ctx.validator) {
3371
- const targetTable = bindingRelation?.targetTable ?? ctx.validator.resolveRelationTarget(ctx.currentFromTable, segments[0]);
3612
+ const lastBindingHop = bindingRelation?.hops[bindingRelation.hops.length - 1];
3613
+ const targetTable = lastBindingHop?.target ?? bindingRelation?.targetTable ?? ctx.validator.resolveRelationTarget(ctx.currentFromTable, segments[0]);
3372
3614
  if (targetTable) {
3373
3615
  ctx.validator.validateColumn(targetTable, column);
3374
3616
  }
@@ -3384,9 +3626,13 @@ function compileMultiSegmentPath(expr, item, ctx) {
3384
3626
  targetTable: bindingRelation.targetTable,
3385
3627
  sourceColumn: bindingRelation.sourceColumn,
3386
3628
  targetColumn: bindingRelation.targetColumn,
3629
+ hops: bindingRelation.hops,
3387
3630
  selectedColumn: column,
3388
3631
  ...bindingRelation.cardinality !== void 0 && {
3389
3632
  cardinality: bindingRelation.cardinality
3633
+ },
3634
+ ...bindingRelation.relationType !== void 0 && {
3635
+ relationType: bindingRelation.relationType
3390
3636
  }
3391
3637
  }) : relationColumnIntent;
3392
3638
  }