linkgress-orm 0.4.79 → 0.4.81

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.
@@ -121,11 +121,22 @@ const MOCK_ROW_CHAIN_ID = Symbol('linkgressMockChainId');
121
121
  const navigationPathSignature = (path) => path
122
122
  .map(step => `${step.alias}:${step.targetTable}:${(step.foreignKeys ?? []).join('+')}:${(step.matches ?? []).join('+')}:${step.isMandatory ? 1 : 0}:${step.sourceAlias ?? ''}`)
123
123
  .join('>');
124
- /** A reference mock row: `Object.create(prototype)` plus its two own state slots. */
125
- const mintReferenceMockRow = (prototype) => {
124
+ /**
125
+ * A reference mock row: `Object.create(prototype)` plus its own state slots.
126
+ *
127
+ * `chainId` is the identity of the row this navigation hangs off, and it is propagated so the
128
+ * field refs minted from the nav row answer "which query do I belong to?" the same way a plain
129
+ * column ref does. Without it a navigation ref is anonymous, and an outer correlation written
130
+ * THROUGH a navigation (`l.city!.name`) is indistinguishable from the inner table's own
131
+ * navigation of the same name — which is precisely the misbinding isForeignChainRef exists to
132
+ * catch. Undefined stays undefined: rows minted outside a chain (collection mocks) keep the
133
+ * anonymity their own callers rely on.
134
+ */
135
+ const mintReferenceMockRow = (prototype, chainId) => {
126
136
  const mock = Object.create(prototype);
127
137
  mock[MOCK_ROW_FIELD_REFS] = {};
128
138
  mock[MOCK_ROW_NAV_CACHE] = {};
139
+ mock[MOCK_ROW_CHAIN_ID] = chainId;
129
140
  return mock;
130
141
  };
131
142
  /**
@@ -272,11 +283,19 @@ const NUMERIC_REGEX = /^-?\d+(\.\d+)?$/;
272
283
  * Query builder for a table
273
284
  */
274
285
  /**
275
- * Monotonic sequence for query-chain identities. Every root builder gets a
276
- * fresh id; derived builders inherit it. Field refs bake the id so a
277
- * standalone subquery can detect same-table refs leaking in from an OUTER
278
- * chain (alias collision → the correlation would silently bind to the inner
279
- * row — see extractOuterFieldRefs).
286
+ * Monotonic sequence for query-chain identities. Every root builder gets a fresh id and
287
+ * derived builders inherit it, so every field ref can say which query it belongs to — which is
288
+ * what lets a subquery tell its OWN refs from ones leaking in from an OUTER chain (see
289
+ * isForeignChainRef).
290
+ *
291
+ * Navigation rows inherit the id of the row they hang off (`mintReferenceMockRow`), so
292
+ * `outer.nav.col` carries the OUTER chain while `inner.nav.col` carries the inner one even
293
+ * when both render under the same alias. That propagation is load-bearing: without it a
294
+ * correlation written through a navigation is anonymous and indistinguishable from the inner
295
+ * table's own navigation of the same name.
296
+ *
297
+ * `CollectionQueryBuilder` deliberately stamps nothing — its refs are anonymous by design, so
298
+ * "carries an id at all" is what marks a correlation on that path.
280
299
  */
281
300
  let chainIdSeq = 0;
282
301
  class QueryBuilder {
@@ -507,13 +526,13 @@ class QueryBuilder {
507
526
  return cachedRow;
508
527
  }
509
528
  if (holder.prototype !== undefined && mock_row_cache_1.MockRowCache.isEnabled()) {
510
- return (navCache[relName] = mintReferenceMockRow(holder.prototype));
529
+ return (navCache[relName] = mintReferenceMockRow(holder.prototype, slots[MOCK_ROW_CHAIN_ID]));
511
530
  }
512
531
  const refBuilder = new ReferenceQueryBuilder(relName, relConfig.targetTable, relConfig.foreignKeys || [relConfig.foreignKey || ''], relConfig.matches || [], relConfig.isMandatory ?? false, targetSchema, schemaRegistry, // Pass schema registry for nested navigation resolution
513
532
  [], // Empty navigation path for first level navigation
514
533
  sourceTableName // Pass source table name for lateral join correlation
515
534
  );
516
- return (navCache[relName] = refBuilder.createMockTargetRow(holder));
535
+ return (navCache[relName] = refBuilder.createMockTargetRow(holder, slots[MOCK_ROW_CHAIN_ID]));
517
536
  },
518
537
  enumerable: false,
519
538
  configurable: true,
@@ -682,7 +701,7 @@ class QueryBuilder {
682
701
  [], // Empty navigation path for first level navigation
683
702
  schema.name // Pass source table name for lateral join correlation
684
703
  );
685
- return (navCache[relName] = refBuilder.createMockTargetRow());
704
+ return (navCache[relName] = refBuilder.createMockTargetRow(undefined, slots[MOCK_ROW_CHAIN_ID]));
686
705
  },
687
706
  enumerable: false,
688
707
  configurable: true,
@@ -724,6 +743,11 @@ class SelectQueryBuilder {
724
743
  return schema ? `"${schema}"."${tableName}"` : `"${tableName}"`;
725
744
  }
726
745
  constructor(schema, client, selector, whereCond, limit, offset, orderBy, executor, manualJoins, joinCounter, isDistinct, schemaRegistry, ctes, collectionStrategy, chainId) {
746
+ /**
747
+ * @internal Aliases the WHERE correlated on, recorded by `detectAndAddJoinsFromCondition`
748
+ * so the shadow check can be re-run after the SELECT list has contributed its joins.
749
+ */
750
+ this.correlatedAliasesFromCondition = new Set();
727
751
  this.orderByFields = [];
728
752
  this.manualJoins = [];
729
753
  this.joinCounter = 0;
@@ -1000,7 +1024,7 @@ class SelectQueryBuilder {
1000
1024
  * .select(g => ({ street: g.key.street, count: g.count() }))
1001
1025
  */
1002
1026
  groupBy(selector) {
1003
- return new grouped_query_1.GroupedQueryBuilder(this.schema, this.client, this.selector, selector, this.whereCond, this.executor, this.manualJoins, this.joinCounter, this.schemaRegistry);
1027
+ return new grouped_query_1.GroupedQueryBuilder(this.schema, this.client, this.selector, selector, this.whereCond, this.executor, this.manualJoins, this.joinCounter, this.schemaRegistry, this.chainId);
1004
1028
  }
1005
1029
  /**
1006
1030
  * Add a LEFT JOIN with a subquery
@@ -1246,7 +1270,7 @@ class SelectQueryBuilder {
1246
1270
  [], // Empty navigation path for first level navigation
1247
1271
  schema.name // Pass source table name for lateral join correlation
1248
1272
  );
1249
- return (navCache[relName] = refBuilder.createMockTargetRow());
1273
+ return (navCache[relName] = refBuilder.createMockTargetRow(undefined, slots[MOCK_ROW_CHAIN_ID]));
1250
1274
  },
1251
1275
  enumerable: false,
1252
1276
  configurable: true,
@@ -3711,7 +3735,7 @@ ${joinClauses.join('\n')}`;
3711
3735
  return cachedRow;
3712
3736
  }
3713
3737
  if (holder.prototype !== undefined && mock_row_cache_1.MockRowCache.isEnabled()) {
3714
- return (navCache[relName] = mintReferenceMockRow(holder.prototype));
3738
+ return (navCache[relName] = mintReferenceMockRow(holder.prototype, slots[MOCK_ROW_CHAIN_ID]));
3715
3739
  }
3716
3740
  const refBuilder = new ReferenceQueryBuilder(relName, relConfig.targetTable, relConfig.foreignKeys || [relConfig.foreignKey || ''], relConfig.matches || [], relConfig.isMandatory ?? false, targetSchema, // Pass the target schema directly
3717
3741
  schemaRegistry, // Pass schema registry for nested resolution
@@ -3719,7 +3743,7 @@ ${joinClauses.join('\n')}`;
3719
3743
  sourceTableName // Pass source table name for lateral join correlation
3720
3744
  );
3721
3745
  // Return a mock object that exposes the target table's columns
3722
- return (navCache[relName] = refBuilder.createMockTargetRow(holder));
3746
+ return (navCache[relName] = refBuilder.createMockTargetRow(holder, slots[MOCK_ROW_CHAIN_ID]));
3723
3747
  },
3724
3748
  enumerable: false,
3725
3749
  configurable: true,
@@ -3870,7 +3894,7 @@ ${joinClauses.join('\n')}`;
3870
3894
  const columnName = nestedValue.__dbColumnName;
3871
3895
  // Add JOIN if needed for navigation fields
3872
3896
  if (tableAlias !== this.schema.name) {
3873
- const relConfig = this.schema.relations[tableAlias];
3897
+ const relConfig = this.relationForRef(nestedValue, tableAlias);
3874
3898
  if (relConfig && !joins.find(j => j.alias === tableAlias)) {
3875
3899
  let targetSchema;
3876
3900
  if (relConfig.targetTableBuilder) {
@@ -4014,7 +4038,7 @@ ${joinClauses.join('\n')}`;
4014
4038
  const columnName = nestedValue.__dbColumnName;
4015
4039
  // Add JOIN if needed for navigation fields
4016
4040
  if (tableAlias !== this.schema.name) {
4017
- const relConfig = this.schema.relations[tableAlias];
4041
+ const relConfig = this.relationForRef(nestedValue, tableAlias);
4018
4042
  if (relConfig && !joins.find(j => j.alias === tableAlias)) {
4019
4043
  let targetSchema;
4020
4044
  if (relConfig.targetTableBuilder) {
@@ -4065,6 +4089,12 @@ ${joinClauses.join('\n')}`;
4065
4089
  return;
4066
4090
  }
4067
4091
  for (const [_key, value] of Object.entries(selection)) {
4092
+ // A ref from another chain is a correlation to an enclosing query, which already has
4093
+ // that table in scope — resolving it here would join a second copy of it into this
4094
+ // subquery. Same rule as the WHERE path; see isForeignChainRef.
4095
+ if ((0, query_utils_2.isForeignChainRef)(value, this.chainId)) {
4096
+ continue;
4097
+ }
4068
4098
  if (value && typeof value === 'object' && '__tableAlias' in value && '__dbColumnName' in value) {
4069
4099
  // This is a FieldRef with a table alias
4070
4100
  const tableAlias = value.__tableAlias;
@@ -4084,6 +4114,9 @@ ${joinClauses.join('\n')}`;
4084
4114
  // SqlFragment may contain navigation property references
4085
4115
  const fieldRefs = value.getFieldRefs();
4086
4116
  for (const fieldRef of fieldRefs) {
4117
+ if ((0, query_utils_2.isForeignChainRef)(fieldRef, this.chainId)) {
4118
+ continue;
4119
+ }
4087
4120
  if ('__tableAlias' in fieldRef && fieldRef.__tableAlias) {
4088
4121
  const tableAlias = fieldRef.__tableAlias;
4089
4122
  if (tableAlias && tableAlias !== this.schema.name) {
@@ -4106,6 +4139,25 @@ ${joinClauses.join('\n')}`;
4106
4139
  }
4107
4140
  }
4108
4141
  }
4142
+ /**
4143
+ * The relation `ref` navigates, or `undefined` when `ref` does not belong to this query.
4144
+ *
4145
+ * Every inline "field ref -> relation -> JOIN" site routes through here instead of reading
4146
+ * `this.schema.relations[alias]` directly, because the alias alone cannot tell a navigation
4147
+ * of OURS from a correlation to an enclosing query: a relation name may equal an outer
4148
+ * table's alias (a child's `library` navigation against a parent table also called
4149
+ * `library`). Joining on that name pulls a second copy of the outer table into this query
4150
+ * and rebinds the correlation to it — the predicate then compares the inner row with
4151
+ * itself and is true for every row, with no SQL error and no type error to show for it.
4152
+ *
4153
+ * See isForeignChainRef for how the two are told apart.
4154
+ */
4155
+ relationForRef(ref, tableAlias) {
4156
+ if ((0, query_utils_2.isForeignChainRef)(ref, this.chainId)) {
4157
+ return undefined;
4158
+ }
4159
+ return this.schema.relations[tableAlias];
4160
+ }
4109
4161
  /**
4110
4162
  * Resolve all navigation joins by finding the correct path through the schema graph
4111
4163
  * This handles multi-level navigation like task.level.createdBy
@@ -4180,7 +4232,7 @@ ${joinClauses.join('\n')}`;
4180
4232
  const tableAlias = fieldRef.__tableAlias;
4181
4233
  if (tableAlias && tableAlias !== this.schema.name && !joins.some(j => j.alias === tableAlias)) {
4182
4234
  // This references a related table - find the relation and add a JOIN
4183
- const relation = this.schema.relations[tableAlias];
4235
+ const relation = this.relationForRef(fieldRef, tableAlias);
4184
4236
  if (relation && relation.type === 'one') {
4185
4237
  // Get target schema from targetTableBuilder if available
4186
4238
  let targetSchema;
@@ -4209,8 +4261,19 @@ ${joinClauses.join('\n')}`;
4209
4261
  }
4210
4262
  // Collect all table aliases from the condition
4211
4263
  const allTableAliases = new Set();
4264
+ const correlatedAliases = new Set();
4212
4265
  const fieldRefs = condition.getFieldRefs();
4213
4266
  for (const fieldRef of fieldRefs) {
4267
+ // A ref minted by a DIFFERENT chain belongs to an enclosing query: it is a
4268
+ // CORRELATION, not one of our navigations, and the outer query already has that
4269
+ // table in scope. Joining it here would resolve the alias against OUR relations and
4270
+ // pull in a second, inner copy of the outer table — see isForeignChainRef.
4271
+ if ((0, query_utils_2.isForeignChainRef)(fieldRef, this.chainId)) {
4272
+ if ('__tableAlias' in fieldRef && fieldRef.__tableAlias) {
4273
+ correlatedAliases.add(fieldRef.__tableAlias);
4274
+ }
4275
+ continue;
4276
+ }
4214
4277
  if ('__tableAlias' in fieldRef && fieldRef.__tableAlias) {
4215
4278
  const tableAlias = fieldRef.__tableAlias;
4216
4279
  if (tableAlias !== this.schema.name) {
@@ -4227,6 +4290,11 @@ ${joinClauses.join('\n')}`;
4227
4290
  }
4228
4291
  }
4229
4292
  }
4293
+ // Kept for the second, wider check once the SELECT list has added its own joins: the
4294
+ // colliding navigation can be named ONLY in the projection, which this method never sees.
4295
+ this.correlatedAliasesFromCondition = correlatedAliases;
4296
+ // Refuse the one shape that cannot be rendered — see assertNoCorrelatedAliasShadowing.
4297
+ (0, query_utils_2.assertNoCorrelatedAliasShadowing)(this.schema.name, correlatedAliases, allTableAliases);
4230
4298
  // Resolve all joins through the schema graph
4231
4299
  this.resolveJoinsForTableAliases(allTableAliases, joins);
4232
4300
  }
@@ -4247,6 +4315,10 @@ ${joinClauses.join('\n')}`;
4247
4315
  this.detectAndAddJoinsFromSelection(selection, joins);
4248
4316
  // Scan WHERE condition for navigation property references and add JOINs
4249
4317
  this.detectAndAddJoinsFromCondition(this.whereCond, joins);
4318
+ // Repeat the shadow check now that the SELECT list has contributed its joins: the colliding
4319
+ // navigation can be named ONLY in the projection, where the WHERE-time check cannot see it.
4320
+ // EXISTS ignores the select list, so nothing else would catch that shape.
4321
+ (0, query_utils_2.assertNoCorrelatedAliasShadowing)(this.schema.name, this.correlatedAliasesFromCondition, new Set(joins.map(join => join.alias)));
4250
4322
  // Handle case where selection is a single value (not an object with properties)
4251
4323
  if (selection instanceof conditions_1.SqlFragment) {
4252
4324
  // Single SQL fragment - just build it directly
@@ -4317,7 +4389,7 @@ ${joinClauses.join('\n')}`;
4317
4389
  const tableAlias = value.__tableAlias;
4318
4390
  const columnName = value.__dbColumnName;
4319
4391
  // Find the relation config for this navigation
4320
- const relConfig = this.schema.relations[tableAlias];
4392
+ const relConfig = this.relationForRef(value, tableAlias);
4321
4393
  if (relConfig) {
4322
4394
  // Add JOIN if not already added
4323
4395
  if (!joins.find(j => j.alias === tableAlias)) {
@@ -4419,7 +4491,7 @@ ${joinClauses.join('\n')}`;
4419
4491
  const firstValue = value[tableAlias];
4420
4492
  if (firstValue && typeof firstValue === 'object' && '__tableAlias' in firstValue) {
4421
4493
  const alias = firstValue.__tableAlias;
4422
- const relConfig = this.schema.relations[alias];
4494
+ const relConfig = this.relationForRef(firstValue, alias);
4423
4495
  if (relConfig && relConfig.type === 'one') {
4424
4496
  // This is a reference navigation - select all fields from the target table
4425
4497
  // Performance: Use cached target schema
@@ -4717,6 +4789,10 @@ ${joinClauses.join('\n')}`;
4717
4789
  this.detectAndAddJoinsFromSelection(selection, joins);
4718
4790
  // Scan WHERE condition for navigation property references and add JOINs
4719
4791
  this.detectAndAddJoinsFromCondition(this.whereCond, joins);
4792
+ // Repeat the shadow check now that the SELECT list has contributed its joins: the colliding
4793
+ // navigation can be named ONLY in the projection, where the WHERE-time check cannot see it.
4794
+ // EXISTS ignores the select list, so nothing else would catch that shape.
4795
+ (0, query_utils_2.assertNoCorrelatedAliasShadowing)(this.schema.name, this.correlatedAliasesFromCondition, new Set(joins.map(join => join.alias)));
4720
4796
  // Handle case where selection is a single value (not an object with properties)
4721
4797
  if (selection instanceof conditions_1.SqlFragment) {
4722
4798
  const sqlBuildContext = {
@@ -4791,7 +4867,7 @@ ${joinClauses.join('\n')}`;
4791
4867
  if ('__tableAlias' in value && value.__tableAlias && typeof value.__tableAlias === 'string') {
4792
4868
  const tableAlias = value.__tableAlias;
4793
4869
  const columnName = value.__dbColumnName;
4794
- const relConfig = this.schema.relations[tableAlias];
4870
+ const relConfig = this.relationForRef(value, tableAlias);
4795
4871
  if (relConfig && !joins.find(j => j.alias === tableAlias)) {
4796
4872
  let targetSchema;
4797
4873
  if (relConfig.targetTableBuilder) {
@@ -5816,7 +5892,15 @@ ${joinClauses.join('\n')}`;
5816
5892
  // If the table alias doesn't match our current schema, it's from an outer query
5817
5893
  // Also check if it's not a navigation property of this table (which would be in schema.relations)
5818
5894
  // and not one of our own manual-join aliases (filter-joins qualify refs by join alias).
5819
- if (tableAlias !== currentTableName && !this.schema.relations[tableAlias]) {
5895
+ //
5896
+ // Chain identity outranks both name checks: a ref stamped by another chain is a
5897
+ // correlation whatever it is called, and reading it as our own navigation (because a
5898
+ // relation happens to carry the same name) is what silently misbinds the predicate.
5899
+ // See isForeignChainRef.
5900
+ if (tableAlias !== currentTableName && (0, query_utils_2.isForeignChainRef)(ref, this.chainId)) {
5901
+ outerRefs.push(ref);
5902
+ }
5903
+ else if (tableAlias !== currentTableName && !this.schema.relations[tableAlias]) {
5820
5904
  if (!this.manualJoins.some(j => j.alias === tableAlias)) {
5821
5905
  outerRefs.push(ref);
5822
5906
  }
@@ -5901,7 +5985,7 @@ class ReferenceQueryBuilder {
5901
5985
  * Create a mock object that exposes the target table's columns
5902
5986
  * This allows accessing related fields like: p.user.username
5903
5987
  */
5904
- createMockTargetRow(holder) {
5988
+ createMockTargetRow(holder, chainId) {
5905
5989
  if (this.targetTableSchema) {
5906
5990
  // Prototype-level cache — see MockRowCache's doc. Everything the getters close over
5907
5991
  // is fully determined by (target schema object identity, relationName, sourceAlias,
@@ -5928,7 +6012,7 @@ class ReferenceQueryBuilder {
5928
6012
  holder.prototype = prototype;
5929
6013
  }
5930
6014
  }
5931
- return mintReferenceMockRow(prototype);
6015
+ return mintReferenceMockRow(prototype, chainId);
5932
6016
  }
5933
6017
  else {
5934
6018
  // Fallback: use the shared nested proxy that supports deep property access
@@ -5972,6 +6056,8 @@ class ReferenceQueryBuilder {
5972
6056
  __fieldName: colName,
5973
6057
  __dbColumnName: dbColumnName,
5974
6058
  __tableAlias: tableAlias, // Alias for SQL generation
6059
+ // Identity of the query this navigation hangs off — see mintReferenceMockRow.
6060
+ __chainId: slots[MOCK_ROW_CHAIN_ID],
5975
6061
  __sourceTable: sourceTable, // Actual table name for mapper lookup
5976
6062
  __mapper: mapper, // Include mapper for toDriver transformation in conditions
5977
6063
  __sqlType: columnSqlTypes[colName], // Column SQL type — lets flag* emit width-exact mask casts
@@ -6055,7 +6141,7 @@ class ReferenceQueryBuilder {
6055
6141
  let cached = navCache[relName];
6056
6142
  if (cached === undefined) {
6057
6143
  if (holder.prototype !== undefined && mock_row_cache_1.MockRowCache.isEnabled()) {
6058
- cached = navCache[relName] = mintReferenceMockRow(holder.prototype);
6144
+ cached = navCache[relName] = mintReferenceMockRow(holder.prototype, slots[MOCK_ROW_CHAIN_ID]);
6059
6145
  }
6060
6146
  else {
6061
6147
  const refBuilder = new ReferenceQueryBuilder(relName, relConfig.targetTable, relConfig.foreignKeys || [relConfig.foreignKey || ''], relConfig.matches || [], relConfig.isMandatory ?? false, nestedTargetSchema, // Pass the target schema directly
@@ -6063,7 +6149,7 @@ class ReferenceQueryBuilder {
6063
6149
  extendedNavPath, // Pass navigation path for nested collections
6064
6150
  parentSourceAlias ? tableAlias : '' // Only set source if tracking path
6065
6151
  );
6066
- cached = navCache[relName] = refBuilder.createMockTargetRow(holder);
6152
+ cached = navCache[relName] = refBuilder.createMockTargetRow(holder, slots[MOCK_ROW_CHAIN_ID]);
6067
6153
  }
6068
6154
  }
6069
6155
  return cached;
@@ -6258,14 +6344,14 @@ class CollectionQueryBuilder {
6258
6344
  return cachedRow;
6259
6345
  }
6260
6346
  if (holder.prototype !== undefined && mock_row_cache_1.MockRowCache.isEnabled()) {
6261
- return (navCache[relName] = mintReferenceMockRow(holder.prototype));
6347
+ return (navCache[relName] = mintReferenceMockRow(holder.prototype, slots[MOCK_ROW_CHAIN_ID]));
6262
6348
  }
6263
6349
  const refBuilder = new ReferenceQueryBuilder(relName, relConfig.targetTable, relConfig.foreignKeys || [relConfig.foreignKey || ''], relConfig.matches || [], relConfig.isMandatory ?? false, undefined, // Don't pass schema, force registry lookup
6264
6350
  schemaRegistry, // Pass schema registry for nested resolution
6265
6351
  [], // Empty navigation path - this is the first reference in the chain
6266
6352
  targetTable // Source alias is this collection's target table
6267
6353
  );
6268
- return (navCache[relName] = refBuilder.createMockTargetRow(holder));
6354
+ return (navCache[relName] = refBuilder.createMockTargetRow(holder, slots[MOCK_ROW_CHAIN_ID]));
6269
6355
  },
6270
6356
  enumerable: false,
6271
6357
  configurable: true,
@@ -6497,11 +6583,22 @@ class CollectionQueryBuilder {
6497
6583
  }
6498
6584
  /**
6499
6585
  * Get field references from this condition.
6500
- * Returns empty since EXISTS subqueries are self-contained correlated subqueries.
6501
- * Required for duck-typing compatibility with Condition interface when used in WHERE clauses.
6586
+ *
6587
+ * Only the ones belonging to an ENCLOSING query are surfaced. Those are correlations, and
6588
+ * the outer query has to have their tables in scope for the subquery to reference them: a
6589
+ * lambda saying `l.city!.name` needs the OUTER query to join `city`, or the emitted
6590
+ * `"city"."name"` has no FROM-clause entry to bind to. Reporting them here is what lets the
6591
+ * parent's join detection see the requirement.
6592
+ *
6593
+ * Our OWN refs stay hidden, as they always were — they are emitted inside this subquery and
6594
+ * must not drag joins into the parent. Required for duck-typing compatibility with the
6595
+ * Condition interface when used in WHERE clauses.
6502
6596
  */
6503
6597
  getFieldRefs() {
6504
- return [];
6598
+ if (!this.whereCond) {
6599
+ return [];
6600
+ }
6601
+ return this.whereCond.getFieldRefs().filter(ref => (0, query_utils_2.isForeignChainRef)(ref, undefined));
6505
6602
  }
6506
6603
  /**
6507
6604
  * Build SQL for this collection as a correlated EXISTS subquery.
@@ -6548,38 +6645,11 @@ class CollectionQueryBuilder {
6548
6645
  // the statement failed with `missing FROM-clause entry for table "<alias>"`.
6549
6646
  // Reuses the selector path's machinery: seed aliases + chains from the
6550
6647
  // condition's FieldRefs, then resolve multi-hop paths against the target schema.
6551
- if (this.whereCond) {
6552
- const joinedAliases = new Set([
6553
- ...this.navigationPath.map(nav => nav.alias),
6554
- ...this.selectManyJoins.map(nav => nav.alias),
6555
- ]);
6556
- const targetSchema = this.schemaRegistry?.get(targetTable);
6557
- if (targetSchema) {
6558
- const whereJoins = [];
6559
- const whereAliases = new Set();
6560
- for (const ref of this.whereCond.getFieldRefs()) {
6561
- const refAlias = ref?.__tableAlias;
6562
- // Direct columns of the target arrive under the `__collection_<table>__`
6563
- // marker (rewritten below) or the bare table name — neither needs a join.
6564
- if (!refAlias || refAlias === targetTable || refAlias.startsWith('__collection_') || joinedAliases.has(refAlias)) {
6565
- continue;
6566
- }
6567
- this.addNavigationJoinForFieldRef(ref, whereJoins, targetTable, targetSchema, whereAliases);
6568
- }
6569
- if (whereAliases.size > 0) {
6570
- this.resolveNavigationJoins(whereAliases, whereJoins, targetSchema);
6571
- }
6572
- for (const nav of whereJoins) {
6573
- if (joinedAliases.has(nav.alias)) {
6574
- continue;
6575
- }
6576
- joinedAliases.add(nav.alias);
6577
- const joinType = nav.isMandatory ? 'JOIN' : 'LEFT JOIN';
6578
- const fk = nav.foreignKeys[0];
6579
- const pk = (nav.matches && nav.matches.length > 0) ? nav.matches[0] : 'id';
6580
- allJoins.push(`${joinType} "${nav.targetTable}" "${nav.alias}" ON "${nav.sourceAlias}"."${fk}" = "${nav.alias}"."${pk}"`);
6581
- }
6582
- }
6648
+ for (const nav of this.resolveWhereNavigationJoins(sourceTable)) {
6649
+ const joinType = nav.isMandatory ? 'JOIN' : 'LEFT JOIN';
6650
+ const fk = nav.foreignKeys[0];
6651
+ const pk = (nav.matches && nav.matches.length > 0) ? nav.matches[0] : 'id';
6652
+ allJoins.push(`${joinType} "${nav.targetTable}" "${nav.alias}" ON "${nav.sourceAlias}"."${fk}" = "${nav.alias}"."${pk}"`);
6583
6653
  }
6584
6654
  const navJoinsSQL = allJoins.join('\n');
6585
6655
  // Build WHERE clause: correlation + additional conditions
@@ -6693,10 +6763,73 @@ class CollectionQueryBuilder {
6693
6763
  * Add a navigation JOIN for a FieldRef if it references a related table
6694
6764
  * Handles multi-level navigation by recursively resolving the join chain
6695
6765
  */
6766
+ /**
6767
+ * The joins required by REFERENCE navigations inside this collection's OWN where-condition,
6768
+ * e.g. `shelves.where(s => eq(s.city!.name, 'Rural'))`.
6769
+ *
6770
+ * Navigation joins used to be derived from SELECTORS only, which left a where-navigated
6771
+ * alias unjoined. Both collection render paths need this and neither can rely on the other:
6772
+ * an `exists()` aggregation has no selector at all, and a collection in a PROJECTION has one
6773
+ * that says nothing about the where-clause. Emitting it in one place is what keeps the two
6774
+ * paths answering the same question — the projection path previously bound such an alias to
6775
+ * whatever the OUTER query happened to have joined under that name (silently wrong under
6776
+ * `lateral`) or failed with `missing FROM-clause entry` when it had not.
6777
+ *
6778
+ * `correlationAlias` is the parent's alias in the collection's implicit correlation; a
6779
+ * navigation of ours named the same would shadow it, which cannot be rendered.
6780
+ */
6781
+ resolveWhereNavigationJoins(correlationAlias) {
6782
+ if (!this.whereCond) {
6783
+ return [];
6784
+ }
6785
+ const targetSchema = this.schemaRegistry?.get(this.targetTable);
6786
+ if (!targetSchema) {
6787
+ return [];
6788
+ }
6789
+ const alreadyJoined = new Set([
6790
+ ...this.navigationPath.map(nav => nav.alias),
6791
+ ...this.selectManyJoins.map(nav => nav.alias),
6792
+ ]);
6793
+ const whereJoins = [];
6794
+ const whereAliases = new Set();
6795
+ for (const ref of this.whereCond.getFieldRefs()) {
6796
+ const refAlias = ref?.__tableAlias;
6797
+ // Correlations to the enclosing row are filtered inside `addNavigationJoinForFieldRef` —
6798
+ // the choke point shared with the selector loops. Direct columns of the target arrive
6799
+ // under the `__collection_<table>__` marker or the bare table name; neither needs a join.
6800
+ if (!refAlias || refAlias === this.targetTable || refAlias.startsWith('__collection_') || alreadyJoined.has(refAlias)) {
6801
+ continue;
6802
+ }
6803
+ this.addNavigationJoinForFieldRef(ref, whereJoins, this.targetTable, targetSchema, whereAliases);
6804
+ }
6805
+ if (whereAliases.size > 0) {
6806
+ this.resolveNavigationJoins(whereAliases, whereJoins, targetSchema);
6807
+ }
6808
+ // A collection's correlation to its parent is implicit and always present, so an own
6809
+ // navigation named like the parent shadows it every time. Refuse it for the same reason
6810
+ // the standalone path does, or the identical logical query throws on one path and
6811
+ // misbinds on the other.
6812
+ (0, query_utils_2.assertNoCorrelatedAliasShadowing)(this.targetTable, [correlationAlias], new Set(whereJoins.map(nav => nav.alias)));
6813
+ return whereJoins.filter(nav => !alreadyJoined.has(nav.alias));
6814
+ }
6696
6815
  addNavigationJoinForFieldRef(fieldRef, joins, sourceAlias, sourceSchema, allTableAliases, joinedAliases) {
6697
6816
  if (!fieldRef || typeof fieldRef !== 'object' || !('__tableAlias' in fieldRef)) {
6698
6817
  return;
6699
6818
  }
6819
+ // A ref carrying a chain id was minted by the ENCLOSING query, not by this collection:
6820
+ // it is a correlation to the outer row, which the outer query already has in scope.
6821
+ // Refs this builder mints carry none — marker-aliased columns AND navigation traversals
6822
+ // alike — so this separates `s.library.name` (ours: join it) from `l.name` (outer: leave
6823
+ // it) even though both render under the alias `library`. Joining the latter pulls a
6824
+ // second copy of the outer table into the subquery and binds the correlation to it,
6825
+ // which silently makes the predicate compare the inner row with itself.
6826
+ //
6827
+ // Guarded HERE rather than at each caller because this method is the single choke point
6828
+ // through which the WHERE loop and all three selector loops resolve a ref into a join.
6829
+ // See isForeignChainRef.
6830
+ if ((0, query_utils_2.isForeignChainRef)(fieldRef, undefined)) {
6831
+ return;
6832
+ }
6700
6833
  const tableAlias = fieldRef.__tableAlias;
6701
6834
  // If this references the target table directly, no join needed
6702
6835
  if (!tableAlias || tableAlias === this.targetTable) {
@@ -7279,6 +7412,14 @@ class CollectionQueryBuilder {
7279
7412
  this.detectNavigationJoins(selectorResult, navigationJoins, this.targetTable, this.targetTableSchema);
7280
7413
  }
7281
7414
  }
7415
+ // The selector says nothing about the collection's own WHERE, so a navigation used only
7416
+ // there would render unjoined — and then bind to whatever the OUTER query has under that
7417
+ // alias instead of failing. Same resolution the inline EXISTS path uses.
7418
+ for (const nav of this.resolveWhereNavigationJoins(this.sourceTable)) {
7419
+ if (!navigationJoins.some(existing => existing.alias === nav.alias)) {
7420
+ navigationJoins.push(nav);
7421
+ }
7422
+ }
7282
7423
  // Step 5b: Merge navigation path joins (for intermediate tables in navigation chains)
7283
7424
  // These joins are needed when accessing a collection through a chain like:
7284
7425
  // ln.edition.book.category.formats