linkgress-orm 0.2.7 → 0.2.8

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,54 @@ 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
+ }
2089
+ else if (!Array.isArray(field)) {
2090
+ // Nested plain object - recurse into it
2091
+ nestedObjects.set(fieldPath, field);
2092
+ collectFieldRefs(field, fieldPath);
2093
+ }
2076
2094
  }
2077
2095
  }
2078
- }
2079
- if (navigationFields.size === 0) {
2096
+ };
2097
+ collectFieldRefs(selection);
2098
+ if (navigationFields.size === 0 && nestedObjects.size === 0 && collectionFields.size === 0) {
2080
2099
  return null;
2081
2100
  }
2082
2101
  // Resolve navigation joins
2083
2102
  const joins = [];
2084
2103
  this.resolveJoinsForTableAliases(allTableAliases, joins);
2085
- return { hasNavigation: true, selection, joins, navigationFields };
2104
+ return { hasNavigation: true, selection, joins, navigationFields, nestedObjects, collectionFields };
2086
2105
  }
2087
2106
  /**
2088
2107
  * Build RETURNING clause with navigation property support using CTE
@@ -2093,6 +2112,7 @@ class SelectQueryBuilder {
2093
2112
  // First, collect all columns needed from the main table in the mutation's RETURNING
2094
2113
  const mainTableColumns = new Set();
2095
2114
  const selectParts = [];
2115
+ const nestedPaths = new Set();
2096
2116
  const mockRow = this._createMockRow();
2097
2117
  const selectedMock = this.selector(mockRow);
2098
2118
  const selection = returning(selectedMock);
@@ -2111,22 +2131,75 @@ class SelectQueryBuilder {
2111
2131
  for (const join of navigationInfo.joins) {
2112
2132
  aliasToSourceTable.set(join.alias, join.targetTable);
2113
2133
  }
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}"`);
2134
+ // Track collection subqueries for LATERAL JOINs
2135
+ const collectionSubqueries = [];
2136
+ let lateralCounter = 0;
2137
+ // Track all params including those from collection subqueries
2138
+ const allParams = [...mutationParams];
2139
+ let currentParamCounter = mutationParams.length + 1;
2140
+ // Build a QueryContext for collection subquery building
2141
+ const buildCollectionContext = () => ({
2142
+ ctes: new Map(),
2143
+ cteCounter: lateralCounter,
2144
+ paramCounter: currentParamCounter,
2145
+ allParams: allParams,
2146
+ collectionStrategy: 'lateral',
2147
+ });
2148
+ // Recursively process selection to build SELECT parts
2149
+ const processSelection = (obj, path = '') => {
2150
+ for (const [key, field] of Object.entries(obj)) {
2151
+ const fieldPath = path ? `${path}.${key}` : key;
2152
+ if (field && typeof field === 'object') {
2153
+ if ('__dbColumnName' in field) {
2154
+ // Direct field reference
2155
+ const tableAlias = field.__tableAlias;
2156
+ const dbColumnName = field.__dbColumnName;
2157
+ if (!tableAlias || tableAlias === this.schema.name) {
2158
+ mainTableColumns.add(dbColumnName);
2159
+ selectParts.push(`"__mutation__"."${dbColumnName}" AS "${fieldPath}"`);
2160
+ }
2161
+ else {
2162
+ selectParts.push(`"${tableAlias}"."${dbColumnName}" AS "${fieldPath}"`);
2163
+ }
2164
+ }
2165
+ else if (field instanceof CollectionQueryBuilder) {
2166
+ // CollectionQueryBuilder (.toList(), .firstOrDefault())
2167
+ // Build a correlated subquery that references __mutation__ instead of the source table
2168
+ const collectionBuilder = field;
2169
+ const context = buildCollectionContext();
2170
+ // Build the CTE/subquery using lateral strategy
2171
+ const cteResult = collectionBuilder.buildCTE(context);
2172
+ lateralCounter = context.cteCounter;
2173
+ currentParamCounter = context.paramCounter; // Track new param index after collection subquery
2174
+ // The lateral strategy returns either:
2175
+ // 1. A correlated subquery in selectExpression (no join needed)
2176
+ // 2. A LATERAL JOIN with joinClause and selectExpression
2177
+ if (cteResult.joinClause && cteResult.joinClause.trim()) {
2178
+ // LATERAL JOIN needed - rewrite to reference __mutation__ instead of source table
2179
+ const rewrittenJoinClause = this.rewriteCollectionJoinForMutation(cteResult.joinClause, this.schema.name, '__mutation__');
2180
+ collectionSubqueries.push({
2181
+ fieldPath,
2182
+ lateralAlias: cteResult.tableName || `lateral_${lateralCounter - 1}`,
2183
+ joinClause: rewrittenJoinClause,
2184
+ selectExpression: cteResult.selectExpression || `"${cteResult.tableName}".data`,
2185
+ });
2186
+ selectParts.push(`${cteResult.selectExpression || `"${cteResult.tableName}".data`} AS "${fieldPath}"`);
2187
+ }
2188
+ else if (cteResult.selectExpression) {
2189
+ // Correlated subquery in SELECT - rewrite source table references
2190
+ const rewrittenExpr = this.rewriteCollectionExprForMutation(cteResult.selectExpression, this.schema.name, '__mutation__');
2191
+ selectParts.push(`${rewrittenExpr} AS "${fieldPath}"`);
2192
+ }
2193
+ }
2194
+ else if (!Array.isArray(field)) {
2195
+ // Nested plain object - recurse into it and mark as nested path
2196
+ nestedPaths.add(fieldPath);
2197
+ processSelection(field, fieldPath);
2198
+ }
2127
2199
  }
2128
2200
  }
2129
- }
2201
+ };
2202
+ processSelection(selection);
2130
2203
  // Include foreign keys needed for joins - only for joins from main table
2131
2204
  for (const join of navigationInfo.joins) {
2132
2205
  // Only add FK to mainTableColumns if the join source is the main table
@@ -2137,6 +2210,19 @@ class SelectQueryBuilder {
2137
2210
  }
2138
2211
  }
2139
2212
  }
2213
+ // Include 'id' column if there are collection subqueries (needed for correlation)
2214
+ if (collectionSubqueries.length > 0 || (navigationInfo.collectionFields && navigationInfo.collectionFields.size > 0)) {
2215
+ // Find the 'id' column db name
2216
+ const idColEntry = Object.entries(this.schema.columns).find(([propName, _]) => propName === 'id');
2217
+ if (idColEntry) {
2218
+ const idDbCol = idColEntry[1].build().name;
2219
+ mainTableColumns.add(idDbCol);
2220
+ }
2221
+ else {
2222
+ // Fallback to 'id' if not found in schema
2223
+ mainTableColumns.add('id');
2224
+ }
2225
+ }
2140
2226
  // Build RETURNING clause for CTE with all needed columns from main table
2141
2227
  const cteReturningCols = Array.from(mainTableColumns).map(col => `"${col}"`).join(', ');
2142
2228
  // Add RETURNING to the mutation SQL for CTE
@@ -2166,6 +2252,10 @@ class SelectQueryBuilder {
2166
2252
  const joinType = join.isMandatory ? 'INNER JOIN' : 'LEFT JOIN';
2167
2253
  joinClauses.push(`${joinType} ${qualifiedJoinTable} AS "${join.alias}" ON ${joinConditions.join(' AND ')}`);
2168
2254
  }
2255
+ // Add LATERAL JOINs for collections
2256
+ for (const collection of collectionSubqueries) {
2257
+ joinClauses.push(collection.joinClause);
2258
+ }
2169
2259
  // Build the final CTE query
2170
2260
  const sql = `WITH "__mutation__" AS (
2171
2261
  ${mutationWithReturning}
@@ -2173,47 +2263,95 @@ class SelectQueryBuilder {
2173
2263
  SELECT ${selectParts.join(', ')}
2174
2264
  FROM "__mutation__"
2175
2265
  ${joinClauses.join('\n')}`;
2176
- return { sql, params: mutationParams };
2266
+ return { sql, params: allParams, nestedPaths };
2267
+ }
2268
+ /**
2269
+ * Rewrite a LATERAL JOIN clause to reference __mutation__ instead of the source table
2270
+ * @internal
2271
+ */
2272
+ rewriteCollectionJoinForMutation(joinClause, sourceTable, mutationAlias) {
2273
+ const sourcePattern = new RegExp(`"${sourceTable}"\\."`, 'g');
2274
+ return joinClause.replace(sourcePattern, `"${mutationAlias}"."`);
2275
+ }
2276
+ /**
2277
+ * Rewrite a correlated subquery expression to reference __mutation__ instead of the source table
2278
+ * @internal
2279
+ */
2280
+ rewriteCollectionExprForMutation(expression, sourceTable, mutationAlias) {
2281
+ const sourcePattern = new RegExp(`"${sourceTable}"\\."`, 'g');
2282
+ return expression.replace(sourcePattern, `"${mutationAlias}"."`);
2177
2283
  }
2178
2284
  /**
2179
2285
  * Map RETURNING results with navigation properties
2180
2286
  * Applies type mappers based on source table schemas
2287
+ * Reconstructs nested objects from flat column paths
2181
2288
  * @internal
2182
2289
  */
2183
- mapReturningResultsWithNavigation(rows, returning, navigationFields) {
2290
+ mapReturningResultsWithNavigation(rows, returning, navigationFields, nestedPaths) {
2184
2291
  return rows.map(row => {
2185
2292
  const mapped = {};
2186
2293
  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;
2294
+ // Handle nested paths (e.g., "invoicingPartner.id" -> { invoicingPartner: { id: value } })
2295
+ if (key.includes('.')) {
2296
+ const parts = key.split('.');
2297
+ let current = mapped;
2298
+ for (let i = 0; i < parts.length - 1; i++) {
2299
+ if (!current[parts[i]]) {
2300
+ current[parts[i]] = {};
2202
2301
  }
2302
+ current = current[parts[i]];
2203
2303
  }
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;
2304
+ const finalKey = parts[parts.length - 1];
2305
+ // Check if this is a navigation field
2306
+ const navInfo = navigationFields.get(key);
2307
+ if (navInfo && navInfo.schemaTable && this.schemaRegistry) {
2308
+ const targetSchema = this.schemaRegistry.get(navInfo.schemaTable);
2309
+ if (targetSchema) {
2310
+ const colEntry = Object.entries(targetSchema.columns).find(([_, col]) => {
2311
+ const config = col.build();
2312
+ return config.name === navInfo.dbColumnName;
2313
+ });
2314
+ if (colEntry) {
2315
+ const config = colEntry[1].build();
2316
+ current[finalKey] = config.mapper ? config.mapper.fromDriver(value) : value;
2317
+ continue;
2318
+ }
2319
+ }
2320
+ }
2321
+ current[finalKey] = value;
2214
2322
  }
2215
2323
  else {
2216
- mapped[key] = value;
2324
+ // Check if this is a navigation field
2325
+ const navInfo = navigationFields.get(key);
2326
+ if (navInfo && navInfo.schemaTable && this.schemaRegistry) {
2327
+ // Try to get mapper from the navigation target's schema
2328
+ const targetSchema = this.schemaRegistry.get(navInfo.schemaTable);
2329
+ if (targetSchema) {
2330
+ // Find the column and its mapper
2331
+ const colEntry = Object.entries(targetSchema.columns).find(([_, col]) => {
2332
+ const config = col.build();
2333
+ return config.name === navInfo.dbColumnName;
2334
+ });
2335
+ if (colEntry) {
2336
+ const config = colEntry[1].build();
2337
+ mapped[key] = config.mapper ? config.mapper.fromDriver(value) : value;
2338
+ continue;
2339
+ }
2340
+ }
2341
+ }
2342
+ // Try to find column by alias or name in main schema
2343
+ const colEntry = Object.entries(this.schema.columns).find(([propName, col]) => {
2344
+ const config = col.build();
2345
+ return propName === key || config.name === key;
2346
+ });
2347
+ if (colEntry) {
2348
+ const [, col] = colEntry;
2349
+ const config = col.build();
2350
+ mapped[key] = config.mapper ? config.mapper.fromDriver(value) : value;
2351
+ }
2352
+ else {
2353
+ mapped[key] = value;
2354
+ }
2217
2355
  }
2218
2356
  }
2219
2357
  return mapped;