Skip to content

Commit 9a9e362

Browse files
committed
feat: support compound join conditions with and()
Allow joining on multiple fields simultaneously using and() to combine multiple eq() expressions in join conditions. Example: .join( { inventory: inventoriesCollection }, ({ product, inventory }) => and( eq(product.region, inventory.region), eq(product.sku, inventory.sku) ) ) - Add extractJoinConditions() helper to parse and() expressions - Extend JoinClause IR with additionalConditions field - Implement composite key extraction with JSON.stringify - Preserve fast path for single conditions (no serialization) - Disable lazy loading for compound joins Fixes #593
1 parent db764b7 commit 9a9e362

File tree

7 files changed

+239
-35
lines changed

7 files changed

+239
-35
lines changed

.changeset/cool-beers-attend.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@tanstack/db": minor
3+
---
4+
5+
Add support for compound join conditions using `and()`
6+
7+
Joins can now use multiple equality conditions combined with `and()`:
8+
9+
```
10+
.join(
11+
{ inventory: inventoriesCollection },
12+
({ product, inventory }) =>
13+
and(
14+
eq(product.region, inventory.region),
15+
eq(product.sku, inventory.sku)
16+
)
17+
)
18+
```

packages/db/src/errors.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,10 @@ export class InvalidSourceError extends QueryBuilderError {
362362

363363
export class JoinConditionMustBeEqualityError extends QueryBuilderError {
364364
constructor() {
365-
super(`Join condition must be an equality expression`)
365+
super(
366+
`Join condition must be an equality expression (eq) or compound equality (and(eq, eq, ...)). ` +
367+
`Only eq() expressions are allowed within and().`
368+
)
366369
}
367370
}
368371

packages/db/src/query/builder/index.ts

Lines changed: 82 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,31 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
135135
* query
136136
* .from({ u: usersCollection })
137137
* .join({ p: postsCollection }, ({u, p}) => eq(u.id, p.userId), 'inner')
138+
*
139+
* // Compound join on multiple fields
140+
* query
141+
* .from({ product: productsCollection })
142+
* .join(
143+
* { inventory: inventoryCollection },
144+
* ({ product, inventory }) =>
145+
* and(
146+
* eq(product.region, inventory.region),
147+
* eq(product.sku, inventory.sku)
148+
* )
149+
* )
150+
*
151+
* // Left join with compound condition
152+
* query
153+
* .from({ item: itemsCollection })
154+
* .join(
155+
* { details: detailsCollection },
156+
* ({ item, details }) =>
157+
* and(
158+
* eq(item.category, details.category),
159+
* eq(item.subcategory, details.subcategory)
160+
* ),
161+
* 'left'
162+
* )
138163
* ```
139164
*
140165
* // Join with a subquery
@@ -167,27 +192,15 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
167192
// Get the join condition expression
168193
const onExpression = onCallback(refProxy)
169194

170-
// Extract left and right from the expression
171-
// For now, we'll assume it's an eq function with two arguments
172-
let left: BasicExpression
173-
let right: BasicExpression
174-
175-
if (
176-
onExpression.type === `func` &&
177-
onExpression.name === `eq` &&
178-
onExpression.args.length === 2
179-
) {
180-
left = onExpression.args[0]!
181-
right = onExpression.args[1]!
182-
} else {
183-
throw new JoinConditionMustBeEqualityError()
184-
}
195+
// Extract join conditions (supports both eq() and and(eq(), ...))
196+
const { primary, additional } = extractJoinConditions(onExpression)
185197

186198
const joinClause: JoinClause = {
187199
from,
188200
type,
189-
left,
190-
right,
201+
left: primary.left,
202+
right: primary.right,
203+
additionalConditions: additional.length > 0 ? additional : undefined,
191204
}
192205

193206
const existingJoins = this.query.join || []
@@ -763,6 +776,58 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
763776
}
764777
}
765778

779+
/**
780+
* Extracts join conditions from an expression.
781+
* Accepts either:
782+
* - eq(left, right) - single condition
783+
* - and(eq(l1, r1), eq(l2, r2), ...) - compound condition
784+
*
785+
* Returns primary condition (first eq) and additional conditions (remaining eqs).
786+
*/
787+
function extractJoinConditions(expr: BasicExpression): {
788+
primary: { left: BasicExpression; right: BasicExpression }
789+
additional: Array<{ left: BasicExpression; right: BasicExpression }>
790+
} {
791+
// Case 1: Single eq() expression
792+
if (expr.type === `func` && expr.name === `eq` && expr.args.length === 2) {
793+
return {
794+
primary: {
795+
left: expr.args[0]!,
796+
right: expr.args[1]!,
797+
},
798+
additional: [],
799+
}
800+
}
801+
802+
// Case 2: and(eq(), eq(), ...) expression
803+
if (expr.type === `func` && expr.name === `and`) {
804+
const conditions: Array<{ left: BasicExpression; right: BasicExpression }> =
805+
[]
806+
807+
for (const arg of expr.args) {
808+
if (arg.type !== `func` || arg.name !== `eq` || arg.args.length !== 2) {
809+
throw new JoinConditionMustBeEqualityError()
810+
}
811+
conditions.push({
812+
left: arg.args[0]!,
813+
right: arg.args[1]!,
814+
})
815+
}
816+
817+
if (conditions.length === 0) {
818+
throw new JoinConditionMustBeEqualityError()
819+
}
820+
821+
return {
822+
primary: conditions[0]!,
823+
additional: conditions.slice(1),
824+
}
825+
}
826+
827+
// Case 3: Invalid expression
828+
throw new JoinConditionMustBeEqualityError()
829+
}
830+
766831
// Helper to ensure we have a BasicExpression/Aggregate for a value
767832
function toExpr(value: any): BasicExpression | Aggregate {
768833
if (value === undefined) return toExpression(null)

packages/db/src/query/compiler/joins.ts

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

packages/db/src/query/ir.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,12 @@ export type Join = Array<JoinClause>
3636
export interface JoinClause {
3737
from: CollectionRef | QueryRef
3838
type: `left` | `right` | `inner` | `outer` | `full` | `cross`
39-
left: BasicExpression
39+
left: BasicExpression // Primary join condition (always present)
4040
right: BasicExpression
41+
additionalConditions?: Array<{
42+
left: BasicExpression
43+
right: BasicExpression
44+
}>
4145
}
4246

4347
export type Where =

packages/db/src/query/optimizer.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,12 @@ function deepCopyQuery(query: QueryIR): QueryIR {
750750
type: joinClause.type,
751751
left: joinClause.left,
752752
right: joinClause.right,
753+
additionalConditions: joinClause.additionalConditions
754+
? joinClause.additionalConditions.map((cond) => ({
755+
left: cond.left,
756+
right: cond.right,
757+
}))
758+
: undefined,
753759
from:
754760
joinClause.from.type === `collectionRef`
755761
? new CollectionRefClass(

packages/db/tests/query/join.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { beforeEach, describe, expect, test } from "vitest"
22
import {
3+
and,
34
concat,
45
createLiveQueryCollection,
56
eq,
@@ -1726,6 +1727,46 @@ function createJoinTests(autoIndex: `off` | `eager`): void {
17261727
})
17271728
}
17281729

1730+
test(`should handle compound join conditions with and()`, () => {
1731+
// Tests issue #593: joining on multiple fields simultaneously
1732+
const productsCollection = createCollection(
1733+
mockSyncCollectionOptions({
1734+
id: `test-products-canary`,
1735+
getKey: (p: any) => p.productId,
1736+
initialData: [
1737+
{ productId: 1, region: `A`, sku: `sku1`, title: `A1` },
1738+
{ productId: 3, region: `C`, sku: `sku2`, title: `C2` },
1739+
],
1740+
})
1741+
)
1742+
1743+
const inventoriesCollection = createCollection(
1744+
mockSyncCollectionOptions({
1745+
id: `test-inventories-canary`,
1746+
getKey: (i: any) => i.inventoryId,
1747+
initialData: [
1748+
{ inventoryId: 1, region: `A`, sku: `sku1`, quantity: 10 },
1749+
{ inventoryId: 2, region: `C`, sku: `sku2`, quantity: 30 },
1750+
],
1751+
})
1752+
)
1753+
1754+
const joinQuery = createLiveQueryCollection({
1755+
startSync: true,
1756+
query: (q) =>
1757+
q
1758+
.from({ product: productsCollection })
1759+
.join({ inventory: inventoriesCollection }, ({ product, inventory }) =>
1760+
and(
1761+
eq(product.region, inventory.region),
1762+
eq(product.sku, inventory.sku)
1763+
)
1764+
),
1765+
})
1766+
1767+
expect(joinQuery.size).toBe(2)
1768+
})
1769+
17291770
describe(`Query JOIN Operations`, () => {
17301771
createJoinTests(`off`)
17311772
createJoinTests(`eager`)

0 commit comments

Comments
 (0)