@@ -95,6 +95,40 @@ export function processJoins(
9595 return resultPipeline
9696}
9797
98+ /**
99+ * Creates a join key extractor function that handles both single and composite conditions.
100+ *
101+ * Fast path: Single condition returns primitive value (no serialization overhead)
102+ * Composite path: Multiple conditions serialize to JSON string for consistent hashing
103+ */
104+ function createJoinKeyExtractor (
105+ compiledConditions : Array < ( namespacedRow : NamespacedRow ) => any >
106+ ) : ( namespacedRow : NamespacedRow ) => any {
107+ // Fast path: single condition, return primitive value directly
108+ if ( compiledConditions . length === 1 ) {
109+ return compiledConditions [ 0 ] !
110+ }
111+
112+ // Composite path: extract all values and serialize
113+ return ( namespacedRow : NamespacedRow ) => {
114+ const parts : Array < any > = [ ]
115+
116+ for ( const extractor of compiledConditions ) {
117+ const value = extractor ( namespacedRow )
118+
119+ // If any value is null/undefined, entire composite key is null
120+ if ( value == null ) {
121+ return null
122+ }
123+
124+ parts . push ( value )
125+ }
126+
127+ // Serialize to string for consistent hashing in IVM operator
128+ return JSON . stringify ( parts )
129+ }
130+ }
131+
98132/**
99133 * Processes a single join clause with lazy loading optimization.
100134 * For LEFT/RIGHT/INNER joins, marks one side as "lazy" (loads on-demand based on join keys).
@@ -167,24 +201,52 @@ function processJoin(
167201 joinedCollection
168202 )
169203
170- // Analyze which source each expression refers to and swap if necessary
204+ // Collect all condition pairs (primary + additional)
205+ const conditionPairs : Array < {
206+ left : BasicExpression
207+ right : BasicExpression
208+ } > = [
209+ { left : joinClause . left , right : joinClause . right } ,
210+ ...( joinClause . additionalConditions || [ ] ) ,
211+ ]
212+
213+ // Analyze and compile each condition pair
171214 const availableSources = Object . keys ( sources )
172- const { mainExpr, joinedExpr } = analyzeJoinExpressions (
173- joinClause . left ,
174- joinClause . right ,
175- availableSources ,
176- joinedSource
177- )
215+ const compiledMainExprs : Array < ( row : NamespacedRow ) => any > = [ ]
216+ const compiledJoinedExprs : Array < ( row : NamespacedRow ) => any > = [ ]
217+
218+ // Store analyzed expressions for primary condition (used for lazy loading check)
219+ let primaryMainExpr : BasicExpression | null = null
220+ let primaryJoinedExpr : BasicExpression | null = null
221+
222+ for ( let i = 0 ; i < conditionPairs . length ; i ++ ) {
223+ const { left, right } = conditionPairs [ i ] !
224+ const { mainExpr, joinedExpr } = analyzeJoinExpressions (
225+ left ,
226+ right ,
227+ availableSources ,
228+ joinedSource
229+ )
178230
179- // Pre-compile the join expressions
180- const compiledMainExpr = compileExpression ( mainExpr )
181- const compiledJoinedExpr = compileExpression ( joinedExpr )
231+ // Save the analyzed primary expressions for lazy loading optimization
232+ if ( i === 0 ) {
233+ primaryMainExpr = mainExpr
234+ primaryJoinedExpr = joinedExpr
235+ }
236+
237+ compiledMainExprs . push ( compileExpression ( mainExpr ) )
238+ compiledJoinedExprs . push ( compileExpression ( joinedExpr ) )
239+ }
240+
241+ // Create composite key extractors (fast path for single condition)
242+ const mainKeyExtractor = createJoinKeyExtractor ( compiledMainExprs )
243+ const joinedKeyExtractor = createJoinKeyExtractor ( compiledJoinedExprs )
182244
183245 // Prepare the main pipeline for joining
184246 let mainPipeline = pipeline . pipe (
185247 map ( ( [ currentKey , namespacedRow ] ) => {
186248 // Extract the join key from the main source expression
187- const mainKey = compiledMainExpr ( namespacedRow )
249+ const mainKey = mainKeyExtractor ( namespacedRow )
188250
189251 // Return [joinKey, [originalKey, namespacedRow]]
190252 return [ mainKey , [ currentKey , namespacedRow ] ] as [
@@ -201,7 +263,7 @@ function processJoin(
201263 const namespacedRow : NamespacedRow = { [ joinedSource ] : row }
202264
203265 // Extract the join key from the joined source expression
204- const joinedKey = compiledJoinedExpr ( namespacedRow )
266+ const joinedKey = joinedKeyExtractor ( namespacedRow )
205267
206268 // Return [joinKey, [originalKey, namespacedRow]]
207269 return [ joinedKey , [ currentKey , namespacedRow ] ] as [
@@ -226,12 +288,16 @@ function processJoin(
226288 lazyFrom . type === `queryRef` &&
227289 ( lazyFrom . query . limit || lazyFrom . query . offset )
228290
291+ // Use analyzed primary expressions (potentially swapped by analyzeJoinExpressions)
229292 // If join expressions contain computed values (like concat functions)
230293 // we don't optimize the join because we don't have an index over the computed values
231294 const hasComputedJoinExpr =
232- mainExpr . type === `func` || joinedExpr . type === `func`
295+ primaryMainExpr ! . type === `func` || primaryJoinedExpr ! . type === `func`
296+
297+ // Disable lazy loading for compound joins (multiple conditions)
298+ const hasCompoundJoin = conditionPairs . length > 1
233299
234- if ( ! limitedSubquery && ! hasComputedJoinExpr ) {
300+ if ( ! limitedSubquery && ! hasComputedJoinExpr && ! hasCompoundJoin ) {
235301 // This join can be optimized by having the active collection
236302 // dynamically load keys into the lazy collection
237303 // based on the value of the joinKey and by looking up
@@ -247,10 +313,11 @@ function processJoin(
247313 const activePipeline =
248314 activeSource === `main` ? mainPipeline : joinedPipeline
249315
316+ // Use primaryJoinedExpr for lazy loading index lookup
250317 const lazySourceJoinExpr =
251318 activeSource === `main`
252- ? ( joinedExpr as PropRef )
253- : ( mainExpr as PropRef )
319+ ? ( primaryJoinedExpr as PropRef )
320+ : ( primaryMainExpr as PropRef )
254321
255322 const followRefResult = followRef (
256323 rawQuery ,
0 commit comments