linkgress-orm 0.4.75 → 0.4.78

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 (44) hide show
  1. package/dist/config/linkgress-config.d.ts +23 -0
  2. package/dist/config/linkgress-config.d.ts.map +1 -1
  3. package/dist/config/linkgress-config.js +29 -0
  4. package/dist/config/linkgress-config.js.map +1 -1
  5. package/dist/entity/db-context.d.ts +9 -0
  6. package/dist/entity/db-context.d.ts.map +1 -1
  7. package/dist/entity/db-context.js +76 -65
  8. package/dist/entity/db-context.js.map +1 -1
  9. package/dist/entity/entity-builder.d.ts.map +1 -1
  10. package/dist/entity/entity-builder.js +12 -1
  11. package/dist/entity/entity-builder.js.map +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +6 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/query/collection-strategy.interface.d.ts +14 -0
  17. package/dist/query/collection-strategy.interface.d.ts.map +1 -1
  18. package/dist/query/conditions.d.ts +36 -0
  19. package/dist/query/conditions.d.ts.map +1 -1
  20. package/dist/query/conditions.js +78 -10
  21. package/dist/query/conditions.js.map +1 -1
  22. package/dist/query/lateral-sql-cache.d.ts +53 -0
  23. package/dist/query/lateral-sql-cache.d.ts.map +1 -0
  24. package/dist/query/lateral-sql-cache.js +63 -0
  25. package/dist/query/lateral-sql-cache.js.map +1 -0
  26. package/dist/query/query-builder.d.ts +18 -1
  27. package/dist/query/query-builder.d.ts.map +1 -1
  28. package/dist/query/query-builder.js +209 -64
  29. package/dist/query/query-builder.js.map +1 -1
  30. package/dist/query/query-utils.d.ts +1 -0
  31. package/dist/query/query-utils.d.ts.map +1 -1
  32. package/dist/query/query-utils.js +20 -0
  33. package/dist/query/query-utils.js.map +1 -1
  34. package/dist/query/strategies/cte-collection-strategy.d.ts.map +1 -1
  35. package/dist/query/strategies/cte-collection-strategy.js +105 -104
  36. package/dist/query/strategies/cte-collection-strategy.js.map +1 -1
  37. package/dist/query/strategies/lateral-collection-strategy.d.ts +28 -3
  38. package/dist/query/strategies/lateral-collection-strategy.d.ts.map +1 -1
  39. package/dist/query/strategies/lateral-collection-strategy.js +227 -230
  40. package/dist/query/strategies/lateral-collection-strategy.js.map +1 -1
  41. package/dist/query/strategies/temptable-collection-strategy.d.ts.map +1 -1
  42. package/dist/query/strategies/temptable-collection-strategy.js +85 -84
  43. package/dist/query/strategies/temptable-collection-strategy.js.map +1 -1
  44. package/package.json +80 -80
@@ -1,7 +1,61 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LateralCollectionStrategy = void 0;
3
+ exports.LateralCollectionStrategy = exports.lateralShapeKey = void 0;
4
+ const query_utils_1 = require("../query-utils");
4
5
  const join_utils_1 = require("../join-utils");
6
+ const lateral_sql_cache_1 = require("../lateral-sql-cache");
7
+ /** Key-part separator: a control character no alias, column name or SQL text contains. */
8
+ const KEY_SEP = String.fromCharCode(1);
9
+ /** A column list as one key part; the one-column case (nearly every FK) needs no join allocation. */
10
+ const listKey = (list) => (list === undefined ? '' : list.length === 1 ? list[0] : list.join(','));
11
+ /** Appends what `buildNavigationJoinsWithAlias` reads from each join, including the alias-map resolution of its source. */
12
+ const appendNavigationJoinsKey = (key, joins, aliasMap) => {
13
+ if (!joins) {
14
+ return key + '-' + KEY_SEP;
15
+ }
16
+ key += joins.length + KEY_SEP;
17
+ for (const join of joins) {
18
+ key += join.alias + KEY_SEP + join.targetTable + KEY_SEP + (join.targetSchema ?? '') + KEY_SEP
19
+ + listKey(join.foreignKeys) + KEY_SEP + listKey(join.matches) + KEY_SEP + (join.isMandatory ? 'I' : 'L') + KEY_SEP
20
+ + join.sourceAlias + KEY_SEP + (aliasMap?.get(join.sourceAlias) ?? '') + KEY_SEP;
21
+ }
22
+ return key;
23
+ };
24
+ /** Appends the projection: aliases, expressions, nesting, and nested laterals by memo id (or by text when not memoised). */
25
+ const appendFieldsKey = (key, fields) => {
26
+ key += '[' + fields.length + KEY_SEP;
27
+ for (const field of fields) {
28
+ key += field.alias + KEY_SEP + (field.expression ?? '') + KEY_SEP + (field.isColumn === true ? 'c' : 'e') + KEY_SEP;
29
+ if (field.nestedCteJoin) {
30
+ key += field.nestedCteJoin.cteName + KEY_SEP + (field.nestedCteJoin.memoId ? '#' + field.nestedCteJoin.memoId : field.nestedCteJoin.joinClause) + KEY_SEP;
31
+ }
32
+ key = field.nested ? appendFieldsKey(key, field.nested) : key + '-' + KEY_SEP;
33
+ }
34
+ return key + ']' + KEY_SEP;
35
+ };
36
+ /**
37
+ * The LateralSqlCache key of an aggregation: every input `LateralCollectionStrategy.render` and
38
+ * its helpers read, in order — the alias inputs (counter, relation, tables), the correlation
39
+ * columns, the clause TEXT (which already carries the `$n` placeholder numbering), the scalar
40
+ * flags and limits, both navigation-join lists with their alias-map resolution, and the
41
+ * projection. Parameter VALUES never enter the strategy and never enter the key. Built by plain
42
+ * concatenation (a rope V8 flattens once, on the lookup) — cheaper than an array join.
43
+ */
44
+ const lateralShapeKey = (config, context) => {
45
+ const aliasMap = context.lateralTableAliasMap;
46
+ let key = config.counter + KEY_SEP + config.relationName + KEY_SEP + config.targetTable + KEY_SEP + config.foreignKey + KEY_SEP
47
+ + listKey(config.foreignKeys) + KEY_SEP + listKey(config.matches) + KEY_SEP + (config.foreignKeyTableAlias ?? '') + KEY_SEP
48
+ + config.sourceTable + KEY_SEP + (aliasMap?.get(config.sourceTable) ?? '') + KEY_SEP
49
+ + (config.whereClause ?? '') + KEY_SEP + (config.orderByClause ?? '') + KEY_SEP + (config.orderByClauseAlias ?? '') + KEY_SEP
50
+ + (config.limitValue ?? '') + KEY_SEP + (config.offsetValue ?? '') + KEY_SEP
51
+ + (config.isDistinct === true ? 'D' : '') + (config.isSingleResult === true ? 'S' : '') + (config.useJsonArrayAggregation === true ? 'J' : '') + KEY_SEP
52
+ + config.aggregationType + KEY_SEP + (config.aggregateField ?? '') + KEY_SEP + (config.aggregateExpression ?? '') + KEY_SEP
53
+ + (config.arrayField ?? '') + KEY_SEP + config.defaultValue + KEY_SEP;
54
+ key = appendNavigationJoinsKey(key, config.navigationJoins, aliasMap);
55
+ key = appendNavigationJoinsKey(key, config.selectorNavigationJoins, aliasMap);
56
+ return appendFieldsKey(key, config.selectedFields);
57
+ };
58
+ exports.lateralShapeKey = lateralShapeKey;
5
59
  /**
6
60
  * LATERAL JOIN-based collection strategy
7
61
  *
@@ -70,6 +124,38 @@ class LateralCollectionStrategy {
70
124
  return `"${fkTableAlias}"."${foreignKey}" = "${sourceAlias}"."id"`;
71
125
  }
72
126
  buildAggregation(config, context) {
127
+ // Memoised per shape (see LateralSqlCache): the rendering below reads nothing but the
128
+ // config and the enclosing alias map, and mutates neither, so a hit is a pure lookup.
129
+ if (!lateral_sql_cache_1.LateralSqlCache.isEnabled()) {
130
+ return this.render(config, context);
131
+ }
132
+ const key = (0, exports.lateralShapeKey)(config, context);
133
+ const hit = lateral_sql_cache_1.LateralSqlCache.get(key);
134
+ if (hit) {
135
+ return {
136
+ sql: hit.sql,
137
+ params: context.allParams,
138
+ tableName: hit.tableName,
139
+ joinClause: hit.joinClause,
140
+ selectExpression: hit.selectExpression,
141
+ isCTE: false,
142
+ memoId: hit.id,
143
+ };
144
+ }
145
+ const rendered = this.render(config, context);
146
+ const entry = lateral_sql_cache_1.LateralSqlCache.store(key, {
147
+ sql: rendered.sql,
148
+ joinClause: rendered.joinClause,
149
+ selectExpression: rendered.selectExpression,
150
+ tableName: rendered.tableName,
151
+ });
152
+ if (entry.id > 0) {
153
+ rendered.memoId = entry.id;
154
+ }
155
+ return rendered;
156
+ }
157
+ /** The uncached rendering behind {@link buildAggregation}. */
158
+ render(config, context) {
73
159
  const lateralAlias = `lateral_${config.counter}`;
74
160
  // Optimization: For simple aggregations without LIMIT/OFFSET/ORDER BY,
75
161
  // use a correlated subquery in SELECT instead of LATERAL JOIN.
@@ -164,7 +250,10 @@ class LateralCollectionStrategy {
164
250
  // Helper to rewrite expressions that reference the collection's table to use inner alias
165
251
  const rewriteTableReference = (expression) => {
166
252
  // Replace the special marker alias `"__collection_tableName__".` with `"innerTableAlias".`
167
- const markerPattern = new RegExp(`"__collection_${targetTable}__"\\.`, 'g');
253
+ if (!expression.includes('"__collection_')) {
254
+ return expression; // nothing to rewrite — skip the regex pass entirely
255
+ }
256
+ const markerPattern = (0, query_utils_1.collectionMarkerPattern)(targetTable, true);
168
257
  return expression.replace(markerPattern, `"${innerTableAlias}".`);
169
258
  };
170
259
  // Build navigation JOINs for multi-level navigation (selector joins only)
@@ -198,24 +287,24 @@ class LateralCollectionStrategy {
198
287
  // Pattern: (SELECT array_agg(x) FROM (SELECT DISTINCT x FROM ...) sub)
199
288
  // This is more efficient than array_agg(DISTINCT x) which forces a sort
200
289
  if (isDistinct) {
201
- subquerySQL = `(SELECT COALESCE(${arrayAggFn}("${arrayField}"), ${defaultValue})
202
- FROM (SELECT DISTINCT ${fieldExpression} as "${arrayField}"
203
- FROM "${targetTable}" "${innerTableAlias}"
204
- ${navJoinsSQL}
290
+ subquerySQL = `(SELECT COALESCE(${arrayAggFn}("${arrayField}"), ${defaultValue})
291
+ FROM (SELECT DISTINCT ${fieldExpression} as "${arrayField}"
292
+ FROM "${targetTable}" "${innerTableAlias}"
293
+ ${navJoinsSQL}
205
294
  WHERE ${whereSQL}) "sq")`;
206
295
  }
207
296
  else {
208
- subquerySQL = `(SELECT COALESCE(${arrayAggFn}(${fieldExpression}), ${defaultValue})
209
- FROM "${targetTable}" "${innerTableAlias}"
210
- ${navJoinsSQL}
297
+ subquerySQL = `(SELECT COALESCE(${arrayAggFn}(${fieldExpression}), ${defaultValue})
298
+ FROM "${targetTable}" "${innerTableAlias}"
299
+ ${navJoinsSQL}
211
300
  WHERE ${whereSQL})`;
212
301
  }
213
302
  }
214
303
  else if (aggregationType === 'exists') {
215
304
  // EXISTS as correlated subquery: (SELECT EXISTS(SELECT 1 FROM ... WHERE ...))
216
- subquerySQL = `(SELECT EXISTS(SELECT 1
217
- FROM "${targetTable}" "${innerTableAlias}"
218
- ${navJoinsSQL}
305
+ subquerySQL = `(SELECT EXISTS(SELECT 1
306
+ FROM "${targetTable}" "${innerTableAlias}"
307
+ ${navJoinsSQL}
219
308
  WHERE ${whereSQL}))`;
220
309
  }
221
310
  else {
@@ -243,9 +332,9 @@ WHERE ${whereSQL}))`;
243
332
  throw new Error(`Unknown aggregation type: ${aggregationType}`);
244
333
  }
245
334
  // Build correlated subquery for scalar aggregation
246
- subquerySQL = `(SELECT COALESCE(${aggregateSql}, ${defaultValue})
247
- FROM "${targetTable}" "${innerTableAlias}"
248
- ${navJoinsSQL}
335
+ subquerySQL = `(SELECT COALESCE(${aggregateSql}, ${defaultValue})
336
+ FROM "${targetTable}" "${innerTableAlias}"
337
+ ${navJoinsSQL}
249
338
  WHERE ${whereSQL})`;
250
339
  }
251
340
  // For correlated subquery, the select expression IS the subquery
@@ -260,20 +349,60 @@ WHERE ${whereSQL})`;
260
349
  };
261
350
  }
262
351
  /**
263
- * Helper to collect nested CTE/LATERAL joins from selected fields
264
- * These are joins to CTEs or LATERAL subqueries created for nested collections
352
+ * Rewrites the collection marker alias (`"__collection_<table>__".`) to the lateral's inner
353
+ * alias. The regex pass runs only when the marker is present at all.
354
+ */
355
+ rewriteMarker(expression, targetTable, innerTableAlias) {
356
+ if (!expression.includes('"__collection_')) {
357
+ return expression;
358
+ }
359
+ return expression.replace((0, query_utils_1.collectionMarkerPattern)(targetTable, true), '"' + innerTableAlias + '".');
360
+ }
361
+ /**
362
+ * One select-list entry for a leaf field. A bare quoted column (`"col"`, no dot, no inner
363
+ * quote) is qualified with the inner alias; anything else has the marker rewritten and is
364
+ * aliased unless it already renders as exactly `"<alias>"`.
265
365
  */
266
- collectNestedCteJoins(fields) {
267
- const joins = [];
366
+ renderLeafSelect(expression, fullAlias, targetTable, innerTableAlias) {
367
+ const length = expression.length;
368
+ const isSimpleColumn = length > 2
369
+ && expression.charCodeAt(0) === 34
370
+ && expression.charCodeAt(length - 1) === 34
371
+ && expression.indexOf('"', 1) === length - 1
372
+ && expression.indexOf('.') === -1;
373
+ if (isSimpleColumn) {
374
+ return '"' + innerTableAlias + '".' + expression + ' as "' + fullAlias + '"';
375
+ }
376
+ const rewritten = this.rewriteMarker(expression, targetTable, innerTableAlias);
377
+ return rewritten === '"' + fullAlias + '"' ? rewritten : rewritten + ' as "' + fullAlias + '"';
378
+ }
379
+ /**
380
+ * Renders the selected fields in ONE recursive pass: returns the `json_build_object(...)` text
381
+ * of this level and appends the flattened inner select list and the nested lateral joins to
382
+ * `out` (same order as the three separate walks this replaces — depth first, own join before
383
+ * the nested ones). No intermediate arrays: the select list of a wide collection projection was
384
+ * three allocations per field and three joins per statement.
385
+ */
386
+ renderFields(fields, prefix, targetTable, innerTableAlias, out) {
387
+ let json = '';
268
388
  for (const field of fields) {
389
+ const alias = field.alias;
390
+ const fullAlias = prefix ? prefix + '__' + alias : alias;
269
391
  if (field.nestedCteJoin) {
270
- joins.push(field.nestedCteJoin.joinClause);
392
+ out.joins += (out.joins ? '\n ' : '') + field.nestedCteJoin.joinClause;
271
393
  }
272
394
  if (field.nested) {
273
- joins.push(...this.collectNestedCteJoins(field.nested));
395
+ const nestedJson = this.renderFields(field.nested, fullAlias, targetTable, innerTableAlias, out);
396
+ json += (json ? ', ' : '') + "'" + alias + "', " + nestedJson;
397
+ }
398
+ else {
399
+ json += (json ? ', ' : '') + "'" + alias + "', \"" + fullAlias + '"';
400
+ if (field.expression) {
401
+ out.select += (out.select ? ', ' : '') + this.renderLeafSelect(field.expression, fullAlias, targetTable, innerTableAlias);
402
+ }
274
403
  }
275
404
  }
276
- return joins;
405
+ return 'json_build_object(' + json + ')';
277
406
  }
278
407
  /**
279
408
  * Build navigation JOINs SQL for multi-level navigation in collection queries
@@ -309,14 +438,15 @@ WHERE ${whereSQL})`;
309
438
  * @param targetTable - Optional: the original target table name (e.g., "posts") to map to innerTableAlias
310
439
  * @param context - Optional: QueryContext containing lateralTableAliasMap for nested lateral references
311
440
  */
312
- buildNavigationJoinsWithAlias(navigationJoins, innerTableAlias, targetTable, context) {
441
+ buildNavigationJoinsWithAlias(navigationJoins, innerTableAlias, targetTable, context, relationName) {
313
442
  if (!navigationJoins || navigationJoins.length === 0) {
314
443
  return '';
315
444
  }
316
- // Extract the relation name from innerTableAlias (e.g., "lateral_0_posts" -> "posts")
317
- // This is needed to know which source aliases should be remapped
318
- const parts = innerTableAlias.split('_');
319
- const relationName = parts.length >= 3 ? parts.slice(2).join('_') : innerTableAlias;
445
+ if (relationName === undefined) {
446
+ // `lateral_<n>_<relation>` the relation name is everything after the second `_`
447
+ const parts = innerTableAlias.split('_');
448
+ relationName = parts.length >= 3 ? parts.slice(2).join('_') : innerTableAlias;
449
+ }
320
450
  // Get the lateral table alias map from context (for nested lateral references)
321
451
  // This is used when a nested collection's selector navigation references a parent collection's table
322
452
  const lateralAliasMap = context?.lateralTableAliasMap;
@@ -359,98 +489,24 @@ WHERE ${whereSQL})`;
359
489
  */
360
490
  buildJsonbAggregation(config, lateralAlias, context) {
361
491
  const { selectedFields, targetTable, foreignKey, sourceTable, whereClause, orderByClause, limitValue, offsetValue, isDistinct, navigationJoins, relationName } = config;
362
- // Use a unique table alias to avoid conflicts with outer query tables
363
- // This is important when the collection targets the same table as the outer query
364
- // (e.g., post.user.posts where both outer and inner are "posts" table)
365
492
  const innerTableAlias = `${lateralAlias}_${relationName}`;
366
- // Helper to collect all leaf fields from a potentially nested structure
367
- const collectLeafFields = (fields, prefix = '') => {
368
- const result = [];
369
- for (const field of fields) {
370
- const fullAlias = prefix ? `${prefix}__${field.alias}` : field.alias;
371
- if (field.nested) {
372
- result.push(...collectLeafFields(field.nested, fullAlias));
373
- }
374
- else if (field.expression) {
375
- result.push({ alias: fullAlias, expression: field.expression });
376
- }
377
- }
378
- return result;
379
- };
380
- // Helper to build json_build_object expression (handles nested structures)
381
- const buildJsonbObject = (fields, prefix = '') => {
382
- const parts = [];
383
- for (const field of fields) {
384
- if (field.nested) {
385
- const nestedJsonb = buildJsonbObject(field.nested, prefix ? `${prefix}__${field.alias}` : field.alias);
386
- parts.push(`'${field.alias}', ${nestedJsonb}`);
387
- }
388
- else {
389
- const fullAlias = prefix ? `${prefix}__${field.alias}` : field.alias;
390
- parts.push(`'${field.alias}', "${fullAlias}"`);
391
- }
392
- }
393
- return `json_build_object(${parts.join(', ')})`;
394
- };
395
- // Collect all leaf fields for the SELECT clause
396
- const leafFields = collectLeafFields(selectedFields);
397
- // When there are navigation joins, we need to qualify unqualified field expressions
398
- // with the inner table alias to avoid ambiguous column references
399
- const hasNavigationJoins = navigationJoins && navigationJoins.length > 0;
400
- // Helper to rewrite expressions that reference the collection's table to use inner alias
401
- const rewriteTableReference = (expression) => {
402
- // Replace the special marker alias `"__collection_tableName__".` with `"innerTableAlias".`
403
- // This marker is set in CollectionQueryBuilder.createMockItem() to distinguish
404
- // collection references from outer table references when both target the same table
405
- const markerPattern = new RegExp(`"__collection_${targetTable}__"\\.`, 'g');
406
- return expression.replace(markerPattern, `"${innerTableAlias}".`);
407
- };
408
- // Build the subquery SELECT fields (no foreign key needed since we correlate with parent)
409
- const allSelectFields = leafFields.map(f => {
410
- // If expression is just a quoted column name (e.g., `"id"`), qualify it with inner table alias
411
- // But if it's already qualified (e.g., `"user"."username"`), rewrite if it references target table
412
- const isSimpleColumn = /^"[^".]+"$/.test(f.expression);
413
- if (isSimpleColumn) {
414
- // Unqualified column - qualify with inner table alias
415
- const columnName = f.expression.slice(1, -1); // Remove quotes
416
- return `"${innerTableAlias}"."${columnName}" as "${f.alias}"`;
417
- }
418
- // Already qualified - rewrite target table references
419
- const rewritten = rewriteTableReference(f.expression);
420
- if (rewritten !== `"${f.alias}"`) {
421
- return `${rewritten} as "${f.alias}"`;
422
- }
423
- return rewritten;
424
- });
425
- // Build the JSONB fields for json_build_object
426
- const jsonbObjectExpr = buildJsonbObject(selectedFields);
427
- // Build navigation JOINs for multi-level navigation
428
- // Pass innerTableAlias so navigation joins can reference it properly
429
- const navJoinsSQL = this.buildNavigationJoinsWithAlias(navigationJoins, innerTableAlias, targetTable, context);
430
- // Collect nested CTE/LATERAL joins (for collections within collections)
431
- const nestedCteJoins = this.collectNestedCteJoins(selectedFields);
432
- const nestedCteJoinsSQL = nestedCteJoins.length > 0 ? nestedCteJoins.join('\n ') : '';
433
- // For nested collections, the source table may be aliased in a parent LATERAL
434
- // Check the lateralTableAliasMap to get the correct alias
493
+ // Inner select list + json_build_object + nested lateral joins in one pass
494
+ const rendered = { select: '', joins: '' };
495
+ const jsonbObjectExpr = this.renderFields(selectedFields, '', targetTable, innerTableAlias, rendered);
496
+ const navJoinsSQL = this.buildNavigationJoinsWithAlias(navigationJoins, innerTableAlias, targetTable, context, relationName);
497
+ // Check if the source table has been aliased by a parent LATERAL (for nested collections)
435
498
  const effectiveSourceTable = context.lateralTableAliasMap?.get(sourceTable) || sourceTable;
436
- // Build WHERE clause - LATERAL correlates with parent via foreign key
437
- // The correlation is: innerAlias.foreignKey = source.id (plus any literal
438
- // predicates carried by `config.foreignKeys`/`matches`).
439
- // For selectMany, the FK is on the intermediate table (joined via navJoins)
499
+ // Build WHERE clause with correlation to parent
500
+ // For selectMany, the FK is on the intermediate table (joined via navJoins), not the target table
440
501
  const fkTableAlias = config.foreignKeyTableAlias || innerTableAlias;
441
502
  let whereSQL = `WHERE ${this.buildParentCorrelation(config, fkTableAlias, effectiveSourceTable, foreignKey)}`;
442
503
  if (whereClause) {
443
- // Rewrite the user's WHERE clause to use inner alias for the collection's table
444
- const rewrittenWhereClause = rewriteTableReference(whereClause);
445
- whereSQL += ` AND ${rewrittenWhereClause}`;
504
+ whereSQL += ` AND ${this.rewriteMarker(whereClause, targetTable, innerTableAlias)}`;
446
505
  }
447
- // Build ORDER BY clause - also rewrite table references
448
506
  let orderBySQL = '';
449
507
  if (orderByClause) {
450
- const rewrittenOrderBy = rewriteTableReference(orderByClause);
451
- orderBySQL = `ORDER BY ${rewrittenOrderBy}`;
508
+ orderBySQL = `ORDER BY ${this.rewriteMarker(orderByClause, targetTable, innerTableAlias)}`;
452
509
  }
453
- // Build LIMIT/OFFSET
454
510
  let limitOffsetClause = '';
455
511
  if (limitValue !== undefined) {
456
512
  limitOffsetClause = `LIMIT ${limitValue}`;
@@ -458,25 +514,22 @@ WHERE ${whereSQL})`;
458
514
  if (offsetValue !== undefined) {
459
515
  limitOffsetClause += ` OFFSET ${offsetValue}`;
460
516
  }
461
- // Build DISTINCT clause
462
517
  const distinctClause = isDistinct ? 'DISTINCT ' : '';
463
- // Note: We don't add ORDER BY inside json_agg because:
464
- // 1. The inner subquery already applies ORDER BY before LIMIT/OFFSET
465
- // 2. Column aliases in the subquery may differ from original column names
466
- // The order is preserved from the inner query's ORDER BY
467
- const lateralSQL = `
468
- SELECT json_agg(
469
- ${jsonbObjectExpr}
470
- ) as data
471
- FROM (
472
- SELECT ${distinctClause}${allSelectFields.join(', ')}
473
- FROM "${targetTable}" "${innerTableAlias}"
474
- ${navJoinsSQL}
475
- ${nestedCteJoinsSQL}
476
- ${whereSQL}
477
- ${orderBySQL}
478
- ${limitOffsetClause}
479
- ) sub
518
+ // Build LATERAL subquery
519
+ // Structure: SELECT json_agg(json_build_object(...)) FROM (SELECT ... LIMIT/OFFSET) sub
520
+ const lateralSQL = `
521
+ SELECT json_agg(
522
+ ${jsonbObjectExpr}
523
+ ) as data
524
+ FROM (
525
+ SELECT ${distinctClause}${rendered.select}
526
+ FROM "${targetTable}" "${innerTableAlias}"
527
+ ${navJoinsSQL}
528
+ ${rendered.joins}
529
+ ${whereSQL}
530
+ ${orderBySQL}
531
+ ${limitOffsetClause}
532
+ ) sub
480
533
  `.trim();
481
534
  return lateralSQL;
482
535
  }
@@ -486,96 +539,34 @@ FROM (
486
539
  */
487
540
  buildSingleJsonAggregation(config, lateralAlias, context) {
488
541
  const { selectedFields, targetTable, foreignKey, sourceTable, whereClause, orderByClause, isDistinct, navigationJoins, relationName } = config;
489
- // Use a unique table alias to avoid conflicts with outer query tables
490
542
  const innerTableAlias = `${lateralAlias}_${relationName}`;
491
- // Helper to collect all leaf fields from a potentially nested structure
492
- const collectLeafFields = (fields, prefix = '') => {
493
- const result = [];
494
- for (const field of fields) {
495
- const fullAlias = prefix ? `${prefix}__${field.alias}` : field.alias;
496
- if (field.nested) {
497
- result.push(...collectLeafFields(field.nested, fullAlias));
498
- }
499
- else if (field.expression) {
500
- result.push({ alias: fullAlias, expression: field.expression });
501
- }
502
- }
503
- return result;
504
- };
505
- // Helper to build json_build_object expression (handles nested structures)
506
- const buildJsonbObject = (fields, prefix = '') => {
507
- const parts = [];
508
- for (const field of fields) {
509
- if (field.nested) {
510
- const nestedJsonb = buildJsonbObject(field.nested, prefix ? `${prefix}__${field.alias}` : field.alias);
511
- parts.push(`'${field.alias}', ${nestedJsonb}`);
512
- }
513
- else {
514
- const fullAlias = prefix ? `${prefix}__${field.alias}` : field.alias;
515
- parts.push(`'${field.alias}', "${fullAlias}"`);
516
- }
517
- }
518
- return `json_build_object(${parts.join(', ')})`;
519
- };
520
- // Collect all leaf fields for the SELECT clause
521
- const leafFields = collectLeafFields(selectedFields);
522
- // Helper to rewrite expressions that reference the collection's table to use inner alias
523
- const rewriteTableReference = (expression) => {
524
- // Replace the special marker alias `"__collection_tableName__".` with `"innerTableAlias".`
525
- const markerPattern = new RegExp(`"__collection_${targetTable}__"\\.`, 'g');
526
- return expression.replace(markerPattern, `"${innerTableAlias}".`);
527
- };
528
- // Build the subquery SELECT fields using inner table alias
529
- const allSelectFields = leafFields.map(f => {
530
- const isSimpleColumn = /^"[^".]+"$/.test(f.expression);
531
- if (isSimpleColumn) {
532
- const columnName = f.expression.slice(1, -1);
533
- return `"${innerTableAlias}"."${columnName}" as "${f.alias}"`;
534
- }
535
- const rewritten = rewriteTableReference(f.expression);
536
- if (rewritten !== `"${f.alias}"`) {
537
- return `${rewritten} as "${f.alias}"`;
538
- }
539
- return rewritten;
540
- });
541
- // Build the JSONB fields for json_build_object
542
- const jsonbObjectExpr = buildJsonbObject(selectedFields);
543
- // Build navigation JOINs for multi-level navigation
544
- const navJoinsSQL = this.buildNavigationJoinsWithAlias(navigationJoins, innerTableAlias, targetTable, context);
545
- // Collect nested CTE/LATERAL joins (for collections within collections)
546
- const nestedCteJoins = this.collectNestedCteJoins(selectedFields);
547
- const nestedCteJoinsSQL = nestedCteJoins.length > 0 ? nestedCteJoins.join('\n ') : '';
548
- // For nested collections, the source table may be aliased in a parent LATERAL
543
+ const rendered = { select: '', joins: '' };
544
+ const jsonbObjectExpr = this.renderFields(selectedFields, '', targetTable, innerTableAlias, rendered);
545
+ const navJoinsSQL = this.buildNavigationJoinsWithAlias(navigationJoins, innerTableAlias, targetTable, context, relationName);
549
546
  const effectiveSourceTable = context.lateralTableAliasMap?.get(sourceTable) || sourceTable;
550
- // Build WHERE clause - LATERAL correlates with parent via foreign key
551
- // For selectMany, the FK is on the intermediate table (joined via navJoins)
552
547
  const fkTableAlias2 = config.foreignKeyTableAlias || innerTableAlias;
553
548
  let whereSQL = `WHERE ${this.buildParentCorrelation(config, fkTableAlias2, effectiveSourceTable, foreignKey)}`;
554
549
  if (whereClause) {
555
- const rewrittenWhereClause = rewriteTableReference(whereClause);
556
- whereSQL += ` AND ${rewrittenWhereClause}`;
550
+ whereSQL += ` AND ${this.rewriteMarker(whereClause, targetTable, innerTableAlias)}`;
557
551
  }
558
- // Build ORDER BY clause
559
552
  let orderBySQL = '';
560
553
  if (orderByClause) {
561
- const rewrittenOrderBy = rewriteTableReference(orderByClause);
562
- orderBySQL = `ORDER BY ${rewrittenOrderBy}`;
554
+ orderBySQL = `ORDER BY ${this.rewriteMarker(orderByClause, targetTable, innerTableAlias)}`;
563
555
  }
564
- // Build DISTINCT clause
565
556
  const distinctClause = isDistinct ? 'DISTINCT ' : '';
566
- // For single result, use row_to_json on the first row
567
- // LIMIT 1 is applied inside the subquery
568
- const lateralSQL = `
569
- SELECT ${jsonbObjectExpr} as data
570
- FROM (
571
- SELECT ${distinctClause}${allSelectFields.join(', ')}
572
- FROM "${targetTable}" "${innerTableAlias}"
573
- ${navJoinsSQL}
574
- ${nestedCteJoinsSQL}
575
- ${whereSQL}
576
- ${orderBySQL}
577
- LIMIT 1
578
- ) sub
557
+ // Structure: SELECT json_build_object(...) FROM (SELECT ... LIMIT 1) sub
558
+ // Returns null if no rows (LEFT JOIN LATERAL handles this)
559
+ const lateralSQL = `
560
+ SELECT ${jsonbObjectExpr} as data
561
+ FROM (
562
+ SELECT ${distinctClause}${rendered.select}
563
+ FROM "${targetTable}" "${innerTableAlias}"
564
+ ${navJoinsSQL}
565
+ ${rendered.joins}
566
+ ${whereSQL}
567
+ ${orderBySQL}
568
+ LIMIT 1
569
+ ) sub
579
570
  `.trim();
580
571
  return lateralSQL;
581
572
  }
@@ -592,7 +583,10 @@ FROM (
592
583
  // Helper to rewrite expressions that reference the collection's table to use inner alias
593
584
  const rewriteTableReference = (expression) => {
594
585
  // Replace the special marker alias `"__collection_tableName__".` with `"innerTableAlias".`
595
- const markerPattern = new RegExp(`"__collection_${targetTable}__"\\.`, 'g');
586
+ if (!expression.includes('"__collection_')) {
587
+ return expression; // nothing to rewrite — skip the regex pass entirely
588
+ }
589
+ const markerPattern = (0, query_utils_1.collectionMarkerPattern)(targetTable, true);
596
590
  return expression.replace(markerPattern, `"${innerTableAlias}".`);
597
591
  };
598
592
  // Get the actual field expression from selectedFields (if available)
@@ -634,18 +628,18 @@ FROM (
634
628
  // Build DISTINCT clause
635
629
  const distinctClause = isDistinct ? 'DISTINCT ' : '';
636
630
  // Note: We don't add ORDER BY inside array_agg because the inner subquery already sorts
637
- const lateralSQL = `
638
- SELECT ${config.useJsonArrayAggregation ? 'json_agg' : 'array_agg'}(
639
- "${arrayField}"
640
- ) as data
641
- FROM (
642
- SELECT ${distinctClause}${fieldExpression} as "${arrayField}"
643
- FROM "${targetTable}" "${innerTableAlias}"
644
- ${navJoinsSQL}
645
- ${whereSQL}
646
- ${orderBySQL}
647
- ${limitOffsetClause}
648
- ) sub
631
+ const lateralSQL = `
632
+ SELECT ${config.useJsonArrayAggregation ? 'json_agg' : 'array_agg'}(
633
+ "${arrayField}"
634
+ ) as data
635
+ FROM (
636
+ SELECT ${distinctClause}${fieldExpression} as "${arrayField}"
637
+ FROM "${targetTable}" "${innerTableAlias}"
638
+ ${navJoinsSQL}
639
+ ${whereSQL}
640
+ ${orderBySQL}
641
+ ${limitOffsetClause}
642
+ ) sub
649
643
  `.trim();
650
644
  return lateralSQL;
651
645
  }
@@ -659,7 +653,10 @@ FROM (
659
653
  // Helper to rewrite expressions that reference the collection's table to use inner alias
660
654
  const rewriteTableReference = (expression) => {
661
655
  // Replace the special marker alias `"__collection_tableName__".` with `"innerTableAlias".`
662
- const markerPattern = new RegExp(`"__collection_${targetTable}__"\\.`, 'g');
656
+ if (!expression.includes('"__collection_')) {
657
+ return expression; // nothing to rewrite — skip the regex pass entirely
658
+ }
659
+ const markerPattern = (0, query_utils_1.collectionMarkerPattern)(targetTable, true);
663
660
  return expression.replace(markerPattern, `"${innerTableAlias}".`);
664
661
  };
665
662
  // For nested collections, the source table may be aliased in a parent LATERAL
@@ -694,20 +691,20 @@ FROM (
694
691
  break;
695
692
  case 'exists': {
696
693
  // EXISTS as LATERAL: SELECT EXISTS(SELECT 1 FROM ... WHERE ...)
697
- const lateralSQL = `
698
- SELECT EXISTS(SELECT 1
699
- FROM "${targetTable}" "${innerTableAlias}"
700
- ${whereSQL}) as data
694
+ const lateralSQL = `
695
+ SELECT EXISTS(SELECT 1
696
+ FROM "${targetTable}" "${innerTableAlias}"
697
+ ${whereSQL}) as data
701
698
  `.trim();
702
699
  return lateralSQL;
703
700
  }
704
701
  default:
705
702
  throw new Error(`Unknown aggregation type: ${aggregationType}`);
706
703
  }
707
- const lateralSQL = `
708
- SELECT ${aggregateExpression} as data
709
- FROM "${targetTable}" "${innerTableAlias}"
710
- ${whereSQL}
704
+ const lateralSQL = `
705
+ SELECT ${aggregateExpression} as data
706
+ FROM "${targetTable}" "${innerTableAlias}"
707
+ ${whereSQL}
711
708
  `.trim();
712
709
  return lateralSQL;
713
710
  }