linkgress-orm 0.2.7 → 0.2.9

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.
@@ -1768,11 +1768,11 @@ class SelectQueryBuilder {
1768
1768
  deleteSql += ` ${usingClause}`;
1769
1769
  }
1770
1770
  deleteSql += ` WHERE ${fullWhereClause}`;
1771
- const { sql, params } = queryBuilder.buildReturningWithNavigation(deleteSql, whereParams, returning, navigationInfo);
1771
+ const { sql, params, nestedPaths } = queryBuilder.buildReturningWithNavigation(deleteSql, whereParams, returning, navigationInfo);
1772
1772
  const result = queryBuilder.executor
1773
1773
  ? await queryBuilder.executor.query(sql, params)
1774
1774
  : await queryBuilder.client.query(sql, params);
1775
- return queryBuilder.mapReturningResultsWithNavigation(result.rows, returning, navigationInfo.navigationFields);
1775
+ return queryBuilder.mapReturningResultsWithNavigation(result.rows, returning, navigationInfo.navigationFields, nestedPaths);
1776
1776
  }
1777
1777
  // Standard RETURNING (no navigation properties)
1778
1778
  // Qualify columns with table name when using USING clause to avoid ambiguity
@@ -1907,11 +1907,11 @@ class SelectQueryBuilder {
1907
1907
  updateSql += ` ${fromClause}`;
1908
1908
  }
1909
1909
  updateSql += ` WHERE ${fullWhereClause}`;
1910
- const { sql, params } = queryBuilder.buildReturningWithNavigation(updateSql, values, returning, navigationInfo);
1910
+ const { sql, params, nestedPaths } = queryBuilder.buildReturningWithNavigation(updateSql, values, returning, navigationInfo);
1911
1911
  const result = queryBuilder.executor
1912
1912
  ? await queryBuilder.executor.query(sql, params)
1913
1913
  : await queryBuilder.client.query(sql, params);
1914
- return queryBuilder.mapReturningResultsWithNavigation(result.rows, returning, navigationInfo.navigationFields);
1914
+ return queryBuilder.mapReturningResultsWithNavigation(result.rows, returning, navigationInfo.navigationFields, nestedPaths);
1915
1915
  }
1916
1916
  // Standard RETURNING (no navigation properties)
1917
1917
  // Qualify columns with table name when using FROM clause to avoid ambiguity
@@ -2054,35 +2054,68 @@ class SelectQueryBuilder {
2054
2054
  }
2055
2055
  const navigationFields = new Map();
2056
2056
  const allTableAliases = new Set();
2057
- // Check each field in the selection for navigation properties
2058
- for (const [alias, field] of Object.entries(selection)) {
2059
- if (field && typeof field === 'object' && '__dbColumnName' in field && '__tableAlias' in field) {
2060
- const tableAlias = field.__tableAlias;
2061
- if (tableAlias && tableAlias !== this.schema.name) {
2062
- allTableAliases.add(tableAlias);
2063
- navigationFields.set(alias, {
2064
- tableAlias,
2065
- dbColumnName: field.__dbColumnName,
2066
- schemaTable: field.__sourceTable,
2067
- });
2068
- }
2069
- // Also collect intermediate navigation aliases for multi-level navigation
2070
- if ('__navigationAliases' in field && Array.isArray(field.__navigationAliases)) {
2071
- for (const navAlias of field.__navigationAliases) {
2072
- if (navAlias && navAlias !== this.schema.name) {
2073
- allTableAliases.add(navAlias);
2057
+ const nestedObjects = new Map();
2058
+ const collectionFields = new Map();
2059
+ // Recursively collect field refs and table aliases from selection
2060
+ const collectFieldRefs = (obj, path = '') => {
2061
+ for (const [key, field] of Object.entries(obj)) {
2062
+ const fieldPath = path ? `${path}.${key}` : key;
2063
+ if (field && typeof field === 'object') {
2064
+ if ('__dbColumnName' in field) {
2065
+ // Direct field reference (either main table or navigation)
2066
+ const tableAlias = field.__tableAlias;
2067
+ if (tableAlias && tableAlias !== this.schema.name) {
2068
+ // Navigation field
2069
+ allTableAliases.add(tableAlias);
2070
+ navigationFields.set(fieldPath, {
2071
+ tableAlias,
2072
+ dbColumnName: field.__dbColumnName,
2073
+ schemaTable: field.__sourceTable,
2074
+ });
2075
+ }
2076
+ // Also collect intermediate navigation aliases for multi-level navigation
2077
+ if ('__navigationAliases' in field && Array.isArray(field.__navigationAliases)) {
2078
+ for (const navAlias of field.__navigationAliases) {
2079
+ if (navAlias && navAlias !== this.schema.name) {
2080
+ allTableAliases.add(navAlias);
2081
+ }
2082
+ }
2074
2083
  }
2075
2084
  }
2085
+ else if (field instanceof CollectionQueryBuilder) {
2086
+ // CollectionQueryBuilder (.toList(), .firstOrDefault())
2087
+ collectionFields.set(fieldPath, field);
2088
+ // Also extract the navigation path from the collection builder
2089
+ // so we can add the necessary joins to reach the collection's source table
2090
+ const collectionBuilder = field;
2091
+ if (collectionBuilder.navigationPath && Array.isArray(collectionBuilder.navigationPath)) {
2092
+ for (const navJoin of collectionBuilder.navigationPath) {
2093
+ if (navJoin.alias && navJoin.alias !== this.schema.name) {
2094
+ allTableAliases.add(navJoin.alias);
2095
+ }
2096
+ }
2097
+ }
2098
+ // Also add the source table alias
2099
+ if (collectionBuilder.sourceTable && collectionBuilder.sourceTable !== this.schema.name) {
2100
+ allTableAliases.add(collectionBuilder.sourceTable);
2101
+ }
2102
+ }
2103
+ else if (!Array.isArray(field)) {
2104
+ // Nested plain object - recurse into it
2105
+ nestedObjects.set(fieldPath, field);
2106
+ collectFieldRefs(field, fieldPath);
2107
+ }
2076
2108
  }
2077
2109
  }
2078
- }
2079
- if (navigationFields.size === 0) {
2110
+ };
2111
+ collectFieldRefs(selection);
2112
+ if (navigationFields.size === 0 && nestedObjects.size === 0 && collectionFields.size === 0) {
2080
2113
  return null;
2081
2114
  }
2082
2115
  // Resolve navigation joins
2083
2116
  const joins = [];
2084
2117
  this.resolveJoinsForTableAliases(allTableAliases, joins);
2085
- return { hasNavigation: true, selection, joins, navigationFields };
2118
+ return { hasNavigation: true, selection, joins, navigationFields, nestedObjects, collectionFields };
2086
2119
  }
2087
2120
  /**
2088
2121
  * Build RETURNING clause with navigation property support using CTE
@@ -2093,6 +2126,7 @@ class SelectQueryBuilder {
2093
2126
  // First, collect all columns needed from the main table in the mutation's RETURNING
2094
2127
  const mainTableColumns = new Set();
2095
2128
  const selectParts = [];
2129
+ const nestedPaths = new Set();
2096
2130
  const mockRow = this._createMockRow();
2097
2131
  const selectedMock = this.selector(mockRow);
2098
2132
  const selection = returning(selectedMock);
@@ -2111,22 +2145,82 @@ class SelectQueryBuilder {
2111
2145
  for (const join of navigationInfo.joins) {
2112
2146
  aliasToSourceTable.set(join.alias, join.targetTable);
2113
2147
  }
2114
- // For each selected field, determine if it's from main table or navigation
2115
- for (const [alias, field] of Object.entries(selection)) {
2116
- if (field && typeof field === 'object' && '__dbColumnName' in field) {
2117
- const tableAlias = field.__tableAlias;
2118
- const dbColumnName = field.__dbColumnName;
2119
- if (!tableAlias || tableAlias === this.schema.name) {
2120
- // Main table column - add to CTE RETURNING and final SELECT
2121
- mainTableColumns.add(dbColumnName);
2122
- selectParts.push(`"__mutation__"."${dbColumnName}" AS "${alias}"`);
2123
- }
2124
- else {
2125
- // Navigation column - will be joined in outer query
2126
- selectParts.push(`"${tableAlias}"."${dbColumnName}" AS "${alias}"`);
2148
+ // Build a map of table name -> alias for collection subquery rewriting
2149
+ // This allows us to rewrite collection subqueries to use the correct joined aliases
2150
+ const tableToAlias = new Map();
2151
+ tableToAlias.set(this.schema.name, '__mutation__'); // Main table uses mutation CTE
2152
+ for (const join of navigationInfo.joins) {
2153
+ tableToAlias.set(join.targetTable, join.alias);
2154
+ }
2155
+ // Track collection subqueries for LATERAL JOINs
2156
+ const collectionSubqueries = [];
2157
+ let lateralCounter = 0;
2158
+ // Track all params including those from collection subqueries
2159
+ const allParams = [...mutationParams];
2160
+ let currentParamCounter = mutationParams.length + 1;
2161
+ // Build a QueryContext for collection subquery building
2162
+ const buildCollectionContext = () => ({
2163
+ ctes: new Map(),
2164
+ cteCounter: lateralCounter,
2165
+ paramCounter: currentParamCounter,
2166
+ allParams: allParams,
2167
+ collectionStrategy: 'lateral',
2168
+ });
2169
+ // Recursively process selection to build SELECT parts
2170
+ const processSelection = (obj, path = '') => {
2171
+ for (const [key, field] of Object.entries(obj)) {
2172
+ const fieldPath = path ? `${path}.${key}` : key;
2173
+ if (field && typeof field === 'object') {
2174
+ if ('__dbColumnName' in field) {
2175
+ // Direct field reference
2176
+ const tableAlias = field.__tableAlias;
2177
+ const dbColumnName = field.__dbColumnName;
2178
+ if (!tableAlias || tableAlias === this.schema.name) {
2179
+ mainTableColumns.add(dbColumnName);
2180
+ selectParts.push(`"__mutation__"."${dbColumnName}" AS "${fieldPath}"`);
2181
+ }
2182
+ else {
2183
+ selectParts.push(`"${tableAlias}"."${dbColumnName}" AS "${fieldPath}"`);
2184
+ }
2185
+ }
2186
+ else if (field instanceof CollectionQueryBuilder) {
2187
+ // CollectionQueryBuilder (.toList(), .firstOrDefault())
2188
+ // Build a correlated subquery that references joined tables from the main query
2189
+ const collectionBuilder = field;
2190
+ const context = buildCollectionContext();
2191
+ // Build the CTE/subquery using lateral strategy
2192
+ const cteResult = collectionBuilder.buildCTE(context);
2193
+ lateralCounter = context.cteCounter;
2194
+ currentParamCounter = context.paramCounter; // Track new param index after collection subquery
2195
+ // The lateral strategy returns either:
2196
+ // 1. A correlated subquery in selectExpression (no join needed)
2197
+ // 2. A LATERAL JOIN with joinClause and selectExpression
2198
+ if (cteResult.joinClause && cteResult.joinClause.trim()) {
2199
+ // LATERAL JOIN needed - rewrite all table references to use correct aliases
2200
+ const rewrittenJoinClause = this.rewriteCollectionTableReferences(cteResult.joinClause, tableToAlias);
2201
+ collectionSubqueries.push({
2202
+ fieldPath,
2203
+ lateralAlias: cteResult.tableName || `lateral_${lateralCounter - 1}`,
2204
+ joinClause: rewrittenJoinClause,
2205
+ selectExpression: cteResult.selectExpression || `"${cteResult.tableName}".data`,
2206
+ });
2207
+ selectParts.push(`${cteResult.selectExpression || `"${cteResult.tableName}".data`} AS "${fieldPath}"`);
2208
+ }
2209
+ else if (cteResult.selectExpression) {
2210
+ // Correlated subquery in SELECT - rewrite all table references
2211
+ const rewrittenExpr = this.rewriteCollectionTableReferences(cteResult.selectExpression, tableToAlias);
2212
+ selectParts.push(`${rewrittenExpr} AS "${fieldPath}"`);
2213
+ }
2214
+ }
2215
+ else if (!Array.isArray(field)) {
2216
+ // Nested plain object - recurse into it and mark as nested path
2217
+ nestedPaths.add(fieldPath);
2218
+ processSelection(field, fieldPath);
2219
+ }
2127
2220
  }
2128
2221
  }
2129
- }
2222
+ };
2223
+ processSelection(selection);
2130
2224
  // Include foreign keys needed for joins - only for joins from main table
2131
2225
  for (const join of navigationInfo.joins) {
2132
2226
  // Only add FK to mainTableColumns if the join source is the main table
@@ -2137,6 +2231,19 @@ class SelectQueryBuilder {
2137
2231
  }
2138
2232
  }
2139
2233
  }
2234
+ // Include 'id' column if there are collection subqueries (needed for correlation)
2235
+ if (collectionSubqueries.length > 0 || (navigationInfo.collectionFields && navigationInfo.collectionFields.size > 0)) {
2236
+ // Find the 'id' column db name
2237
+ const idColEntry = Object.entries(this.schema.columns).find(([propName, _]) => propName === 'id');
2238
+ if (idColEntry) {
2239
+ const idDbCol = idColEntry[1].build().name;
2240
+ mainTableColumns.add(idDbCol);
2241
+ }
2242
+ else {
2243
+ // Fallback to 'id' if not found in schema
2244
+ mainTableColumns.add('id');
2245
+ }
2246
+ }
2140
2247
  // Build RETURNING clause for CTE with all needed columns from main table
2141
2248
  const cteReturningCols = Array.from(mainTableColumns).map(col => `"${col}"`).join(', ');
2142
2249
  // Add RETURNING to the mutation SQL for CTE
@@ -2166,6 +2273,10 @@ class SelectQueryBuilder {
2166
2273
  const joinType = join.isMandatory ? 'INNER JOIN' : 'LEFT JOIN';
2167
2274
  joinClauses.push(`${joinType} ${qualifiedJoinTable} AS "${join.alias}" ON ${joinConditions.join(' AND ')}`);
2168
2275
  }
2276
+ // Add LATERAL JOINs for collections
2277
+ for (const collection of collectionSubqueries) {
2278
+ joinClauses.push(collection.joinClause);
2279
+ }
2169
2280
  // Build the final CTE query
2170
2281
  const sql = `WITH "__mutation__" AS (
2171
2282
  ${mutationWithReturning}
@@ -2173,47 +2284,99 @@ class SelectQueryBuilder {
2173
2284
  SELECT ${selectParts.join(', ')}
2174
2285
  FROM "__mutation__"
2175
2286
  ${joinClauses.join('\n')}`;
2176
- return { sql, params: mutationParams };
2287
+ return { sql, params: allParams, nestedPaths };
2288
+ }
2289
+ /**
2290
+ * Rewrite table references in a collection subquery to use the correct aliases
2291
+ * from the main query's JOINs. This handles multi-level navigation where the
2292
+ * collection is accessed through intermediate joined tables.
2293
+ *
2294
+ * @param expression - The SQL expression (join clause or select expression) to rewrite
2295
+ * @param tableToAlias - Map of table names to their aliases in the main query
2296
+ * @returns The rewritten expression with all table references updated
2297
+ * @internal
2298
+ */
2299
+ rewriteCollectionTableReferences(expression, tableToAlias) {
2300
+ let result = expression;
2301
+ // Rewrite each table reference to use the correct alias
2302
+ // Pattern: "tableName"."columnName" -> "alias"."columnName"
2303
+ for (const [tableName, alias] of tableToAlias) {
2304
+ const pattern = new RegExp(`"${tableName}"\\."`, 'g');
2305
+ result = result.replace(pattern, `"${alias}"."`);
2306
+ }
2307
+ return result;
2177
2308
  }
2178
2309
  /**
2179
2310
  * Map RETURNING results with navigation properties
2180
2311
  * Applies type mappers based on source table schemas
2312
+ * Reconstructs nested objects from flat column paths
2181
2313
  * @internal
2182
2314
  */
2183
- mapReturningResultsWithNavigation(rows, returning, navigationFields) {
2315
+ mapReturningResultsWithNavigation(rows, returning, navigationFields, nestedPaths) {
2184
2316
  return rows.map(row => {
2185
2317
  const mapped = {};
2186
2318
  for (const [key, value] of Object.entries(row)) {
2187
- // Check if this is a navigation field
2188
- const navInfo = navigationFields.get(key);
2189
- if (navInfo && navInfo.schemaTable && this.schemaRegistry) {
2190
- // Try to get mapper from the navigation target's schema
2191
- const targetSchema = this.schemaRegistry.get(navInfo.schemaTable);
2192
- if (targetSchema) {
2193
- // Find the column and its mapper
2194
- const colEntry = Object.entries(targetSchema.columns).find(([_, col]) => {
2195
- const config = col.build();
2196
- return config.name === navInfo.dbColumnName;
2197
- });
2198
- if (colEntry) {
2199
- const config = colEntry[1].build();
2200
- mapped[key] = config.mapper ? config.mapper.fromDriver(value) : value;
2201
- continue;
2319
+ // Handle nested paths (e.g., "invoicingPartner.id" -> { invoicingPartner: { id: value } })
2320
+ if (key.includes('.')) {
2321
+ const parts = key.split('.');
2322
+ let current = mapped;
2323
+ for (let i = 0; i < parts.length - 1; i++) {
2324
+ if (!current[parts[i]]) {
2325
+ current[parts[i]] = {};
2202
2326
  }
2327
+ current = current[parts[i]];
2203
2328
  }
2204
- }
2205
- // Try to find column by alias or name in main schema
2206
- const colEntry = Object.entries(this.schema.columns).find(([propName, col]) => {
2207
- const config = col.build();
2208
- return propName === key || config.name === key;
2209
- });
2210
- if (colEntry) {
2211
- const [, col] = colEntry;
2212
- const config = col.build();
2213
- mapped[key] = config.mapper ? config.mapper.fromDriver(value) : value;
2329
+ const finalKey = parts[parts.length - 1];
2330
+ // Check if this is a navigation field
2331
+ const navInfo = navigationFields.get(key);
2332
+ if (navInfo && navInfo.schemaTable && this.schemaRegistry) {
2333
+ const targetSchema = this.schemaRegistry.get(navInfo.schemaTable);
2334
+ if (targetSchema) {
2335
+ const colEntry = Object.entries(targetSchema.columns).find(([_, col]) => {
2336
+ const config = col.build();
2337
+ return config.name === navInfo.dbColumnName;
2338
+ });
2339
+ if (colEntry) {
2340
+ const config = colEntry[1].build();
2341
+ current[finalKey] = config.mapper ? config.mapper.fromDriver(value) : value;
2342
+ continue;
2343
+ }
2344
+ }
2345
+ }
2346
+ current[finalKey] = value;
2214
2347
  }
2215
2348
  else {
2216
- mapped[key] = value;
2349
+ // Check if this is a navigation field
2350
+ const navInfo = navigationFields.get(key);
2351
+ if (navInfo && navInfo.schemaTable && this.schemaRegistry) {
2352
+ // Try to get mapper from the navigation target's schema
2353
+ const targetSchema = this.schemaRegistry.get(navInfo.schemaTable);
2354
+ if (targetSchema) {
2355
+ // Find the column and its mapper
2356
+ const colEntry = Object.entries(targetSchema.columns).find(([_, col]) => {
2357
+ const config = col.build();
2358
+ return config.name === navInfo.dbColumnName;
2359
+ });
2360
+ if (colEntry) {
2361
+ const config = colEntry[1].build();
2362
+ mapped[key] = config.mapper ? config.mapper.fromDriver(value) : value;
2363
+ continue;
2364
+ }
2365
+ }
2366
+ }
2367
+ // Try to find column by alias or name in main schema
2368
+ const colEntry = Object.entries(this.schema.columns).find(([propName, col]) => {
2369
+ const config = col.build();
2370
+ return propName === key || config.name === key;
2371
+ });
2372
+ if (colEntry) {
2373
+ const [, col] = colEntry;
2374
+ const config = col.build();
2375
+ mapped[key] = config.mapper ? config.mapper.fromDriver(value) : value;
2376
+ }
2377
+ else {
2378
+ mapped[key] = value;
2379
+ }
2217
2380
  }
2218
2381
  }
2219
2382
  return mapped;