linkgress-orm 0.1.1 → 0.1.4

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 (30) hide show
  1. package/dist/entity/db-context.d.ts.map +1 -1
  2. package/dist/entity/db-context.js +7 -5
  3. package/dist/entity/db-context.js.map +1 -1
  4. package/dist/migration/db-schema-manager.d.ts +5 -0
  5. package/dist/migration/db-schema-manager.d.ts.map +1 -1
  6. package/dist/migration/db-schema-manager.js +70 -2
  7. package/dist/migration/db-schema-manager.js.map +1 -1
  8. package/dist/query/collection-strategy.interface.d.ts +38 -0
  9. package/dist/query/collection-strategy.interface.d.ts.map +1 -1
  10. package/dist/query/cte-builder.d.ts +15 -1
  11. package/dist/query/cte-builder.d.ts.map +1 -1
  12. package/dist/query/cte-builder.js +121 -11
  13. package/dist/query/cte-builder.js.map +1 -1
  14. package/dist/query/grouped-query.d.ts +13 -1
  15. package/dist/query/grouped-query.d.ts.map +1 -1
  16. package/dist/query/grouped-query.js +147 -4
  17. package/dist/query/grouped-query.js.map +1 -1
  18. package/dist/query/query-builder.d.ts +36 -1
  19. package/dist/query/query-builder.d.ts.map +1 -1
  20. package/dist/query/query-builder.js +359 -42
  21. package/dist/query/query-builder.js.map +1 -1
  22. package/dist/query/strategies/cte-collection-strategy.d.ts +4 -0
  23. package/dist/query/strategies/cte-collection-strategy.d.ts.map +1 -1
  24. package/dist/query/strategies/cte-collection-strategy.js +103 -12
  25. package/dist/query/strategies/cte-collection-strategy.js.map +1 -1
  26. package/dist/query/strategies/lateral-collection-strategy.d.ts +4 -0
  27. package/dist/query/strategies/lateral-collection-strategy.d.ts.map +1 -1
  28. package/dist/query/strategies/lateral-collection-strategy.js +61 -3
  29. package/dist/query/strategies/lateral-collection-strategy.js.map +1 -1
  30. package/package.json +1 -1
@@ -121,7 +121,7 @@ const NUMERIC_REGEX = /^-?\d+(\.\d+)?$/;
121
121
  * Query builder for a table
122
122
  */
123
123
  class QueryBuilder {
124
- constructor(schema, client, whereCond, limit, offset, orderBy, executor, manualJoins, joinCounter, collectionStrategy) {
124
+ constructor(schema, client, whereCond, limit, offset, orderBy, executor, manualJoins, joinCounter, collectionStrategy, schemaRegistry) {
125
125
  this.orderByFields = [];
126
126
  this.manualJoins = [];
127
127
  this.joinCounter = 0;
@@ -135,6 +135,7 @@ class QueryBuilder {
135
135
  this.manualJoins = manualJoins || [];
136
136
  this.joinCounter = joinCounter || 0;
137
137
  this.collectionStrategy = collectionStrategy;
138
+ this.schemaRegistry = schemaRegistry;
138
139
  }
139
140
  /**
140
141
  * Get qualified table name with schema prefix if specified
@@ -148,7 +149,7 @@ class QueryBuilder {
148
149
  */
149
150
  select(selector) {
150
151
  return new SelectQueryBuilder(this.schema, this.client, selector, this.whereCond, this.limitValue, this.offsetValue, this.orderByFields, this.executor, this.manualJoins, this.joinCounter, false, // isDistinct defaults to false
151
- undefined, // schemaRegistry
152
+ this.schemaRegistry, // Pass schema registry for nested navigation resolution
152
153
  [], // ctes - start with empty array
153
154
  this.collectionStrategy);
154
155
  }
@@ -171,7 +172,8 @@ class QueryBuilder {
171
172
  * Add CTEs (Common Table Expressions) to the query
172
173
  */
173
174
  with(...ctes) {
174
- return new SelectQueryBuilder(this.schema, this.client, (row) => row, this.whereCond, this.limitValue, this.offsetValue, this.orderByFields, this.executor, this.manualJoins, this.joinCounter, false, undefined, ctes, this.collectionStrategy);
175
+ return new SelectQueryBuilder(this.schema, this.client, (row) => row, this.whereCond, this.limitValue, this.offsetValue, this.orderByFields, this.executor, this.manualJoins, this.joinCounter, false, this.schemaRegistry, // Pass schema registry for nested navigation resolution
176
+ ctes, this.collectionStrategy);
175
177
  }
176
178
  /**
177
179
  * Create mock row for analysis
@@ -200,12 +202,16 @@ class QueryBuilder {
200
202
  const relationEntries = getRelationEntriesForSchema(this.schema);
201
203
  // Add relations (both collections and single references)
202
204
  for (const [relName, relConfig] of relationEntries) {
203
- // Performance: Use cached target schema
204
- const targetSchema = getTargetSchemaForRelation(this.schema, relName, relConfig);
205
+ // Performance: Use cached target schema, but prefer registry lookup for full relations
206
+ let targetSchema = this.schemaRegistry?.get(relConfig.targetTable);
207
+ if (!targetSchema) {
208
+ targetSchema = getTargetSchemaForRelation(this.schema, relName, relConfig);
209
+ }
205
210
  if (relConfig.type === 'many') {
206
211
  Object.defineProperty(mock, relName, {
207
212
  get: () => {
208
- return new CollectionQueryBuilder(relName, relConfig.targetTable, relConfig.foreignKey || relConfig.foreignKeys?.[0] || '', this.schema.name, targetSchema);
213
+ return new CollectionQueryBuilder(relName, relConfig.targetTable, relConfig.foreignKey || relConfig.foreignKeys?.[0] || '', this.schema.name, targetSchema, this.schemaRegistry // Pass schema registry for nested navigation resolution
214
+ );
209
215
  },
210
216
  enumerable: true,
211
217
  configurable: true,
@@ -215,7 +221,8 @@ class QueryBuilder {
215
221
  // Single reference navigation (many-to-one, one-to-one)
216
222
  Object.defineProperty(mock, relName, {
217
223
  get: () => {
218
- const refBuilder = new ReferenceQueryBuilder(relName, relConfig.targetTable, relConfig.foreignKeys || [relConfig.foreignKey || ''], relConfig.matches || [], relConfig.isMandatory ?? false, targetSchema);
224
+ const refBuilder = new ReferenceQueryBuilder(relName, relConfig.targetTable, relConfig.foreignKeys || [relConfig.foreignKey || ''], relConfig.matches || [], relConfig.isMandatory ?? false, targetSchema, this.schemaRegistry // Pass schema registry for nested navigation resolution
225
+ );
219
226
  return refBuilder.createMockTargetRow();
220
227
  },
221
228
  enumerable: true,
@@ -806,7 +813,7 @@ class SelectQueryBuilder {
806
813
  get(target, prop) {
807
814
  if (typeof prop === 'symbol')
808
815
  return undefined;
809
- // If we have selection metadata, check if this property has a mapper
816
+ // If we have selection metadata, check if this property has a mapper or is an aggregation array
810
817
  if (cte.selectionMetadata && prop in cte.selectionMetadata) {
811
818
  const value = cte.selectionMetadata[prop];
812
819
  // If it's a SqlFragment with a mapper, preserve it
@@ -819,6 +826,16 @@ class SelectQueryBuilder {
819
826
  getMapper: () => value.getMapper(),
820
827
  };
821
828
  }
829
+ // If it's a CTE aggregation array marker, preserve it with inner metadata
830
+ if (typeof value === 'object' && value !== null && '__isAggregationArray' in value && value.__isAggregationArray) {
831
+ return {
832
+ __fieldName: prop,
833
+ __dbColumnName: prop,
834
+ __tableAlias: cte.name,
835
+ __isAggregationArray: true,
836
+ __innerSelectionMetadata: value.__innerSelectionMetadata,
837
+ };
838
+ }
822
839
  }
823
840
  // Return a regular FieldRef for any property accessed
824
841
  return {
@@ -1462,31 +1479,117 @@ class SelectQueryBuilder {
1462
1479
  }
1463
1480
  /**
1464
1481
  * Detect navigation property references in selection and add necessary JOINs
1482
+ * Supports multi-level navigation like task.level.createdBy
1465
1483
  */
1466
1484
  detectAndAddJoinsFromSelection(selection, joins) {
1467
1485
  if (!selection || typeof selection !== 'object') {
1468
1486
  return;
1469
1487
  }
1470
- for (const [key, value] of Object.entries(selection)) {
1488
+ // First pass: collect all table aliases
1489
+ const allTableAliases = new Set();
1490
+ this.collectTableAliasesFromSelection(selection, allTableAliases);
1491
+ // Second pass: resolve all joins through the schema graph
1492
+ this.resolveJoinsForTableAliases(allTableAliases, joins);
1493
+ }
1494
+ /**
1495
+ * Collect all table aliases from a selection
1496
+ */
1497
+ collectTableAliasesFromSelection(selection, allTableAliases) {
1498
+ if (!selection || typeof selection !== 'object') {
1499
+ return;
1500
+ }
1501
+ for (const [_key, value] of Object.entries(selection)) {
1471
1502
  if (value && typeof value === 'object' && '__tableAlias' in value && '__dbColumnName' in value) {
1472
- // This is a FieldRef with a table alias - check if it's from a related table
1473
- this.addJoinForFieldRef(value, joins);
1503
+ // This is a FieldRef with a table alias
1504
+ const tableAlias = value.__tableAlias;
1505
+ if (tableAlias && tableAlias !== this.schema.name) {
1506
+ allTableAliases.add(tableAlias);
1507
+ }
1474
1508
  }
1475
1509
  else if (value instanceof conditions_1.SqlFragment) {
1476
- // SqlFragment may contain navigation property references - extract them
1510
+ // SqlFragment may contain navigation property references
1477
1511
  const fieldRefs = value.getFieldRefs();
1478
1512
  for (const fieldRef of fieldRefs) {
1479
- this.addJoinForFieldRef(fieldRef, joins);
1513
+ if ('__tableAlias' in fieldRef && fieldRef.__tableAlias) {
1514
+ const tableAlias = fieldRef.__tableAlias;
1515
+ if (tableAlias && tableAlias !== this.schema.name) {
1516
+ allTableAliases.add(tableAlias);
1517
+ }
1518
+ }
1480
1519
  }
1481
1520
  }
1482
- else if (value && typeof value === 'object' && !Array.isArray(value)) {
1521
+ else if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof CollectionQueryBuilder)) {
1483
1522
  // Recursively check nested objects
1484
- this.detectAndAddJoinsFromSelection(value, joins);
1523
+ this.collectTableAliasesFromSelection(value, allTableAliases);
1524
+ }
1525
+ }
1526
+ }
1527
+ /**
1528
+ * Resolve all navigation joins by finding the correct path through the schema graph
1529
+ * This handles multi-level navigation like task.level.createdBy
1530
+ */
1531
+ resolveJoinsForTableAliases(allTableAliases, joins) {
1532
+ if (allTableAliases.size === 0) {
1533
+ return;
1534
+ }
1535
+ // Keep resolving until we've resolved all aliases or can't make progress
1536
+ const resolved = new Set();
1537
+ let maxIterations = allTableAliases.size * 3; // Prevent infinite loops
1538
+ while (resolved.size < allTableAliases.size && maxIterations-- > 0) {
1539
+ // Build a map of already joined schemas for path resolution
1540
+ const joinedSchemas = new Map();
1541
+ joinedSchemas.set(this.schema.name, this.schema);
1542
+ for (const join of joins) {
1543
+ let schema;
1544
+ if (this.schemaRegistry) {
1545
+ schema = this.schemaRegistry.get(join.targetTable);
1546
+ }
1547
+ if (schema) {
1548
+ joinedSchemas.set(join.alias, schema);
1549
+ }
1550
+ }
1551
+ // Try to resolve each unresolved alias
1552
+ for (const alias of allTableAliases) {
1553
+ if (resolved.has(alias) || joins.some(j => j.alias === alias)) {
1554
+ resolved.add(alias);
1555
+ continue;
1556
+ }
1557
+ // Look for this alias in any of the already joined schemas
1558
+ for (const [sourceAlias, schema] of joinedSchemas) {
1559
+ if (schema.relations && schema.relations[alias]) {
1560
+ const relation = schema.relations[alias];
1561
+ if (relation.type === 'one') {
1562
+ // Get target schema
1563
+ let targetSchema;
1564
+ let targetSchemaName;
1565
+ if (this.schemaRegistry) {
1566
+ targetSchema = this.schemaRegistry.get(relation.targetTable);
1567
+ targetSchemaName = targetSchema?.schema;
1568
+ }
1569
+ if (!targetSchema && relation.targetTableBuilder) {
1570
+ targetSchema = relation.targetTableBuilder.build();
1571
+ targetSchemaName = targetSchema?.schema;
1572
+ }
1573
+ joins.push({
1574
+ alias,
1575
+ targetTable: relation.targetTable,
1576
+ targetSchema: targetSchemaName,
1577
+ foreignKeys: relation.foreignKeys || [relation.foreignKey || ''],
1578
+ matches: relation.matches || ['id'],
1579
+ isMandatory: relation.isMandatory ?? false,
1580
+ sourceAlias, // Track where this join comes from
1581
+ });
1582
+ resolved.add(alias);
1583
+ break;
1584
+ }
1585
+ }
1586
+ }
1485
1587
  }
1486
1588
  }
1487
1589
  }
1488
1590
  /**
1489
1591
  * Add a JOIN for a FieldRef if it references a related table
1592
+ * @deprecated Use detectAndAddJoinsFromSelection with multi-level resolution instead
1490
1593
  */
1491
1594
  addJoinForFieldRef(fieldRef, joins) {
1492
1595
  if (!fieldRef || typeof fieldRef !== 'object' || !('__tableAlias' in fieldRef) || !('__dbColumnName' in fieldRef)) {
@@ -1522,35 +1625,19 @@ class SelectQueryBuilder {
1522
1625
  if (!condition) {
1523
1626
  return;
1524
1627
  }
1525
- // Get all field references from the condition
1628
+ // Collect all table aliases from the condition
1629
+ const allTableAliases = new Set();
1526
1630
  const fieldRefs = condition.getFieldRefs();
1527
1631
  for (const fieldRef of fieldRefs) {
1528
1632
  if ('__tableAlias' in fieldRef && fieldRef.__tableAlias) {
1529
1633
  const tableAlias = fieldRef.__tableAlias;
1530
- // Check if this references a related table that isn't already joined
1531
- if (tableAlias !== this.schema.name && !joins.some(j => j.alias === tableAlias)) {
1532
- // Find the relation config for this navigation
1533
- const relation = this.schema.relations[tableAlias];
1534
- if (relation && relation.type === 'one') {
1535
- // Get target schema from targetTableBuilder if available
1536
- let targetSchema;
1537
- if (relation.targetTableBuilder) {
1538
- const targetTableSchema = relation.targetTableBuilder.build();
1539
- targetSchema = targetTableSchema.schema;
1540
- }
1541
- // Add a JOIN for this reference
1542
- joins.push({
1543
- alias: tableAlias,
1544
- targetTable: relation.targetTable,
1545
- targetSchema,
1546
- foreignKeys: relation.foreignKeys || [relation.foreignKey || ''],
1547
- matches: relation.matches || [],
1548
- isMandatory: relation.isMandatory ?? false,
1549
- });
1550
- }
1634
+ if (tableAlias !== this.schema.name) {
1635
+ allTableAliases.add(tableAlias);
1551
1636
  }
1552
1637
  }
1553
1638
  }
1639
+ // Resolve all joins through the schema graph
1640
+ this.resolveJoinsForTableAliases(allTableAliases, joins);
1554
1641
  }
1555
1642
  /**
1556
1643
  * Build SQL query
@@ -1632,6 +1719,7 @@ class SelectQueryBuilder {
1632
1719
  if ('__tableAlias' in value && value.__tableAlias && typeof value.__tableAlias === 'string') {
1633
1720
  // This is a field from a joined table
1634
1721
  const tableAlias = value.__tableAlias;
1722
+ const columnName = value.__dbColumnName;
1635
1723
  // Find the relation config for this navigation
1636
1724
  const relConfig = this.schema.relations[tableAlias];
1637
1725
  if (relConfig) {
@@ -1653,7 +1741,15 @@ class SelectQueryBuilder {
1653
1741
  });
1654
1742
  }
1655
1743
  }
1656
- selectParts.push(`"${tableAlias}"."${value.__dbColumnName}" as "${key}"`);
1744
+ // Check if this is a CTE aggregation column that needs COALESCE
1745
+ const cteJoin = this.manualJoins.find(j => j.cte && j.cte.name === tableAlias);
1746
+ if (cteJoin && cteJoin.cte && cteJoin.cte.isAggregationColumn(columnName)) {
1747
+ // CTE aggregation column - wrap with COALESCE to return empty array instead of null
1748
+ selectParts.push(`COALESCE("${tableAlias}"."${columnName}", '[]'::jsonb) as "${key}"`);
1749
+ }
1750
+ else {
1751
+ selectParts.push(`"${tableAlias}"."${columnName}" as "${key}"`);
1752
+ }
1657
1753
  }
1658
1754
  else {
1659
1755
  // Regular field from the main table
@@ -1912,11 +2008,14 @@ class SelectQueryBuilder {
1912
2008
  for (const join of joins) {
1913
2009
  const joinType = join.isMandatory ? 'INNER JOIN' : 'LEFT JOIN';
1914
2010
  // Build ON clause for the join
2011
+ // For multi-level navigation, use the sourceAlias (the intermediate table)
2012
+ // For direct navigation, use the main table name
2013
+ const sourceTable = join.sourceAlias || this.schema.name;
1915
2014
  const onConditions = [];
1916
2015
  for (let i = 0; i < join.foreignKeys.length; i++) {
1917
2016
  const fk = join.foreignKeys[i];
1918
2017
  const match = join.matches[i];
1919
- onConditions.push(`"${this.schema.name}"."${fk}" = "${join.alias}"."${match}"`);
2018
+ onConditions.push(`"${sourceTable}"."${fk}" = "${join.alias}"."${match}"`);
1920
2019
  }
1921
2020
  // Use schema-qualified table name if schema is specified
1922
2021
  const joinTableName = this.getQualifiedTableName(join.targetTable, join.targetSchema);
@@ -2048,6 +2147,17 @@ class SelectQueryBuilder {
2048
2147
  }
2049
2148
  }
2050
2149
  }
2150
+ else if (typeof value === 'object' && value !== null && '__isAggregationArray' in value && value.__isAggregationArray) {
2151
+ // CTE withAggregation array - apply mappers to items inside
2152
+ const collectionItems = row[key] || [];
2153
+ const innerMetadata = value.__innerSelectionMetadata;
2154
+ if (innerMetadata && !disableMappers) {
2155
+ result[key] = this.transformCteAggregationItems(collectionItems, innerMetadata);
2156
+ }
2157
+ else {
2158
+ result[key] = collectionItems;
2159
+ }
2160
+ }
2051
2161
  else if (typeof value === 'object' && value !== null && typeof value.getMapper === 'function') {
2052
2162
  // SqlFragment with custom mapper (check this BEFORE FieldRef to handle subquery/CTE fields with mappers)
2053
2163
  const rawValue = row[key];
@@ -2160,6 +2270,54 @@ class SelectQueryBuilder {
2160
2270
  return transformedItem;
2161
2271
  });
2162
2272
  }
2273
+ /**
2274
+ * Transform CTE aggregation items applying fromDriver mappers from selection metadata
2275
+ */
2276
+ transformCteAggregationItems(items, selectionMetadata) {
2277
+ if (!items || items.length === 0) {
2278
+ return [];
2279
+ }
2280
+ // Build mapper cache from selection metadata
2281
+ const mapperCache = {};
2282
+ for (const [key, value] of Object.entries(selectionMetadata)) {
2283
+ // Check if value has getMapper (SqlFragment or field with mapper)
2284
+ if (typeof value === 'object' && value !== null && typeof value.getMapper === 'function') {
2285
+ let mapper = value.getMapper();
2286
+ // If mapper is a CustomTypeBuilder, get the actual type
2287
+ if (mapper && typeof mapper.getType === 'function') {
2288
+ mapper = mapper.getType();
2289
+ }
2290
+ if (mapper && typeof mapper.fromDriver === 'function') {
2291
+ mapperCache[key] = mapper;
2292
+ }
2293
+ }
2294
+ // Check if it's a FieldRef with schema column mapper
2295
+ else if (typeof value === 'object' && value !== null && '__fieldName' in value) {
2296
+ const fieldName = value.__fieldName;
2297
+ const column = this.schema.columns[fieldName];
2298
+ if (column) {
2299
+ const config = column.build();
2300
+ if (config.mapper && typeof config.mapper.fromDriver === 'function') {
2301
+ mapperCache[key] = config.mapper;
2302
+ }
2303
+ }
2304
+ }
2305
+ }
2306
+ // Transform items
2307
+ return items.map(item => {
2308
+ const transformedItem = {};
2309
+ for (const [key, value] of Object.entries(item)) {
2310
+ const mapper = mapperCache[key];
2311
+ if (mapper && value !== null && value !== undefined) {
2312
+ transformedItem[key] = mapper.fromDriver(value);
2313
+ }
2314
+ else {
2315
+ transformedItem[key] = value;
2316
+ }
2317
+ }
2318
+ return transformedItem;
2319
+ });
2320
+ }
2163
2321
  /**
2164
2322
  * Build aggregation query (MIN, MAX, SUM)
2165
2323
  */
@@ -2493,7 +2651,8 @@ class CollectionQueryBuilder {
2493
2651
  * Select specific fields from collection items
2494
2652
  */
2495
2653
  select(selector) {
2496
- const newBuilder = new CollectionQueryBuilder(this.relationName, this.targetTable, this.foreignKey, this.sourceTable, this.targetTableSchema);
2654
+ const newBuilder = new CollectionQueryBuilder(this.relationName, this.targetTable, this.foreignKey, this.sourceTable, this.targetTableSchema, this.schemaRegistry // Pass schema registry for nested navigation resolution
2655
+ );
2497
2656
  newBuilder.selector = selector;
2498
2657
  newBuilder.whereCond = this.whereCond;
2499
2658
  newBuilder.limitValue = this.limitValue;
@@ -2726,6 +2885,151 @@ class CollectionQueryBuilder {
2726
2885
  getFlattenResultType() {
2727
2886
  return this.flattenResultType;
2728
2887
  }
2888
+ /**
2889
+ * Detect navigation property references in the selected fields and add necessary JOINs
2890
+ * This supports multi-level navigation like p.task.level.createdBy.username
2891
+ */
2892
+ detectNavigationJoins(selection, joins, currentSourceAlias, currentSchema) {
2893
+ if (!selection || typeof selection !== 'object') {
2894
+ return;
2895
+ }
2896
+ // Collect all table aliases referenced in the selection
2897
+ const allTableAliases = new Set();
2898
+ // Helper to collect from a single selection
2899
+ const collectFromSelection = (sel) => {
2900
+ if (!sel || typeof sel !== 'object') {
2901
+ return;
2902
+ }
2903
+ // Handle single FieldRef
2904
+ if ('__tableAlias' in sel && '__dbColumnName' in sel) {
2905
+ this.addNavigationJoinForFieldRef(sel, joins, currentSourceAlias, currentSchema, allTableAliases);
2906
+ return;
2907
+ }
2908
+ // Handle object with multiple fields
2909
+ for (const [_key, value] of Object.entries(sel)) {
2910
+ if (value && typeof value === 'object' && '__tableAlias' in value && '__dbColumnName' in value) {
2911
+ // This is a FieldRef with a table alias
2912
+ this.addNavigationJoinForFieldRef(value, joins, currentSourceAlias, currentSchema, allTableAliases);
2913
+ }
2914
+ else if (value instanceof conditions_1.SqlFragment) {
2915
+ // SqlFragment may contain navigation property references
2916
+ const fieldRefs = value.getFieldRefs();
2917
+ for (const fieldRef of fieldRefs) {
2918
+ this.addNavigationJoinForFieldRef(fieldRef, joins, currentSourceAlias, currentSchema, allTableAliases);
2919
+ }
2920
+ }
2921
+ else if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof CollectionQueryBuilder)) {
2922
+ // Recursively check nested objects
2923
+ collectFromSelection(value);
2924
+ }
2925
+ }
2926
+ };
2927
+ // First pass: collect all table aliases
2928
+ collectFromSelection(selection);
2929
+ // Second pass: resolve all navigation joins by finding the correct path through schemas
2930
+ if (allTableAliases.size > 0) {
2931
+ this.resolveNavigationJoins(allTableAliases, joins, currentSchema);
2932
+ }
2933
+ }
2934
+ /**
2935
+ * Add a navigation JOIN for a FieldRef if it references a related table
2936
+ * Handles multi-level navigation by recursively resolving the join chain
2937
+ */
2938
+ addNavigationJoinForFieldRef(fieldRef, joins, sourceAlias, sourceSchema, allTableAliases) {
2939
+ if (!fieldRef || typeof fieldRef !== 'object' || !('__tableAlias' in fieldRef)) {
2940
+ return;
2941
+ }
2942
+ const tableAlias = fieldRef.__tableAlias;
2943
+ // If this references the target table directly, no join needed
2944
+ if (!tableAlias || tableAlias === this.targetTable) {
2945
+ return;
2946
+ }
2947
+ // Collect this table alias for later resolution
2948
+ allTableAliases.add(tableAlias);
2949
+ // Check if we already have this join
2950
+ if (joins.some(j => j.alias === tableAlias)) {
2951
+ return;
2952
+ }
2953
+ // Find the relation in the current schema
2954
+ const relation = sourceSchema.relations?.[tableAlias];
2955
+ if (relation && relation.type === 'one') {
2956
+ this.addNavigationJoin(tableAlias, relation, joins, sourceAlias);
2957
+ }
2958
+ }
2959
+ /**
2960
+ * Add a navigation join and return the target schema
2961
+ */
2962
+ addNavigationJoin(alias, relation, joins, sourceAlias) {
2963
+ // Check if already added
2964
+ if (joins.some(j => j.alias === alias)) {
2965
+ return undefined;
2966
+ }
2967
+ // Get the target table schema
2968
+ let targetSchema;
2969
+ let targetSchemaName;
2970
+ if (this.schemaRegistry) {
2971
+ targetSchema = this.schemaRegistry.get(relation.targetTable);
2972
+ targetSchemaName = targetSchema?.schema;
2973
+ }
2974
+ if (!targetSchema && relation.targetTableBuilder) {
2975
+ targetSchema = relation.targetTableBuilder.build();
2976
+ targetSchemaName = targetSchema?.schema;
2977
+ }
2978
+ // Build the join info
2979
+ const foreignKeys = relation.foreignKeys || [relation.foreignKey || ''];
2980
+ const matches = relation.matches || ['id']; // Default to 'id' as the PK
2981
+ joins.push({
2982
+ alias,
2983
+ targetTable: relation.targetTable,
2984
+ targetSchema: targetSchemaName,
2985
+ foreignKeys,
2986
+ matches,
2987
+ isMandatory: relation.isMandatory ?? false,
2988
+ sourceAlias,
2989
+ });
2990
+ return targetSchema;
2991
+ }
2992
+ /**
2993
+ * Resolve all navigation joins by finding the correct path through the schema graph
2994
+ * This handles multi-level navigation like task.level.createdBy
2995
+ */
2996
+ resolveNavigationJoins(allTableAliases, joins, startSchema) {
2997
+ // Keep resolving until we've resolved all aliases or can't make progress
2998
+ let resolved = new Set();
2999
+ let maxIterations = allTableAliases.size * 2; // Prevent infinite loops
3000
+ while (resolved.size < allTableAliases.size && maxIterations-- > 0) {
3001
+ // Build a map of already joined schemas for path resolution
3002
+ const joinedSchemas = new Map();
3003
+ joinedSchemas.set(this.targetTable, startSchema);
3004
+ for (const join of joins) {
3005
+ let schema;
3006
+ if (this.schemaRegistry) {
3007
+ schema = this.schemaRegistry.get(join.targetTable);
3008
+ }
3009
+ if (schema) {
3010
+ joinedSchemas.set(join.alias, schema);
3011
+ }
3012
+ }
3013
+ // Try to resolve each unresolved alias
3014
+ for (const alias of allTableAliases) {
3015
+ if (resolved.has(alias) || joins.some(j => j.alias === alias)) {
3016
+ resolved.add(alias);
3017
+ continue;
3018
+ }
3019
+ // Look for this alias in any of the already joined schemas
3020
+ for (const [schemaAlias, schema] of joinedSchemas) {
3021
+ if (schema.relations && schema.relations[alias]) {
3022
+ const relation = schema.relations[alias];
3023
+ if (relation.type === 'one') {
3024
+ this.addNavigationJoin(alias, relation, joins, schemaAlias);
3025
+ resolved.add(alias);
3026
+ break;
3027
+ }
3028
+ }
3029
+ }
3030
+ }
3031
+ }
3032
+ }
2729
3033
  /**
2730
3034
  * Build CTE for this collection query
2731
3035
  * Now delegates to collection strategy pattern
@@ -2760,8 +3064,13 @@ class CollectionQueryBuilder {
2760
3064
  return { alias, expression: fragmentSql };
2761
3065
  }
2762
3066
  else if (typeof field === 'object' && field !== null && '__dbColumnName' in field) {
2763
- // FieldRef object - use database column name
3067
+ // FieldRef object - use database column name with optional table alias
2764
3068
  const dbColumnName = field.__dbColumnName;
3069
+ const tableAlias = field.__tableAlias;
3070
+ // If tableAlias differs from the target table, it's a navigation property reference
3071
+ if (tableAlias && tableAlias !== this.targetTable) {
3072
+ return { alias, expression: `"${tableAlias}"."${dbColumnName}"` };
3073
+ }
2765
3074
  return { alias, expression: `"${dbColumnName}"` };
2766
3075
  }
2767
3076
  else if (typeof field === 'string') {
@@ -2884,7 +3193,14 @@ class CollectionQueryBuilder {
2884
3193
  aggregationType = 'jsonb';
2885
3194
  defaultValue = "'[]'::jsonb";
2886
3195
  }
2887
- // Step 5: Build CollectionAggregationConfig object
3196
+ // Step 5: Detect navigation joins from the selected fields
3197
+ const navigationJoins = [];
3198
+ if (this.selector && this.targetTableSchema) {
3199
+ const mockItem = this.createMockItem();
3200
+ const selectedFields = this.selector(mockItem);
3201
+ this.detectNavigationJoins(selectedFields, navigationJoins, this.targetTable, this.targetTableSchema);
3202
+ }
3203
+ // Step 6: Build CollectionAggregationConfig object
2888
3204
  const config = {
2889
3205
  relationName: this.relationName,
2890
3206
  targetTable: this.targetTable,
@@ -2903,6 +3219,7 @@ class CollectionQueryBuilder {
2903
3219
  arrayField,
2904
3220
  defaultValue,
2905
3221
  counter: context.cteCounter++,
3222
+ navigationJoins: navigationJoins.length > 0 ? navigationJoins : undefined,
2906
3223
  };
2907
3224
  // Step 6: Call the strategy
2908
3225
  const result = strategy.buildAggregation(config, context, client);