Skip to content

Commit fe42591

Browse files
authored
add leftJoin, rightJoin, innerJoin and fullJoin aliases of the main join method (#269)
1 parent 11215d9 commit fe42591

File tree

4 files changed

+529
-0
lines changed

4 files changed

+529
-0
lines changed

.changeset/loud-cycles-spend.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Add `leftJoin`, `rightJoin`, `innerJoin` and `fullJoin` aliases of the main `join` method on the query builder.

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

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,110 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
184184
}) as any
185185
}
186186

187+
/**
188+
* Perform a LEFT JOIN with another table or subquery
189+
*
190+
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
191+
* @param onCallback - A function that receives table references and returns the join condition
192+
* @returns A QueryBuilder with the left joined table available
193+
*
194+
* @example
195+
* ```ts
196+
* // Left join users with posts
197+
* query
198+
* .from({ users: usersCollection })
199+
* .leftJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
200+
* ```
201+
*/
202+
leftJoin<TSource extends Source>(
203+
source: TSource,
204+
onCallback: JoinOnCallback<
205+
MergeContext<TContext, SchemaFromSource<TSource>>
206+
>
207+
): QueryBuilder<
208+
MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, `left`>
209+
> {
210+
return this.join(source, onCallback, `left`)
211+
}
212+
213+
/**
214+
* Perform a RIGHT JOIN with another table or subquery
215+
*
216+
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
217+
* @param onCallback - A function that receives table references and returns the join condition
218+
* @returns A QueryBuilder with the right joined table available
219+
*
220+
* @example
221+
* ```ts
222+
* // Right join users with posts
223+
* query
224+
* .from({ users: usersCollection })
225+
* .rightJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
226+
* ```
227+
*/
228+
rightJoin<TSource extends Source>(
229+
source: TSource,
230+
onCallback: JoinOnCallback<
231+
MergeContext<TContext, SchemaFromSource<TSource>>
232+
>
233+
): QueryBuilder<
234+
MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, `right`>
235+
> {
236+
return this.join(source, onCallback, `right`)
237+
}
238+
239+
/**
240+
* Perform an INNER JOIN with another table or subquery
241+
*
242+
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
243+
* @param onCallback - A function that receives table references and returns the join condition
244+
* @returns A QueryBuilder with the inner joined table available
245+
*
246+
* @example
247+
* ```ts
248+
* // Inner join users with posts
249+
* query
250+
* .from({ users: usersCollection })
251+
* .innerJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
252+
* ```
253+
*/
254+
innerJoin<TSource extends Source>(
255+
source: TSource,
256+
onCallback: JoinOnCallback<
257+
MergeContext<TContext, SchemaFromSource<TSource>>
258+
>
259+
): QueryBuilder<
260+
MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, `inner`>
261+
> {
262+
return this.join(source, onCallback, `inner`)
263+
}
264+
265+
/**
266+
* Perform a FULL JOIN with another table or subquery
267+
*
268+
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
269+
* @param onCallback - A function that receives table references and returns the join condition
270+
* @returns A QueryBuilder with the full joined table available
271+
*
272+
* @example
273+
* ```ts
274+
* // Full join users with posts
275+
* query
276+
* .from({ users: usersCollection })
277+
* .fullJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
278+
* ```
279+
*/
280+
fullJoin<TSource extends Source>(
281+
source: TSource,
282+
onCallback: JoinOnCallback<
283+
MergeContext<TContext, SchemaFromSource<TSource>>
284+
>
285+
): QueryBuilder<
286+
MergeContextWithJoinType<TContext, SchemaFromSource<TSource>, `full`>
287+
> {
288+
return this.join(source, onCallback, `full`)
289+
}
290+
187291
/**
188292
* Filter rows based on a condition
189293
*

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

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,173 @@ describe(`QueryBuilder.join`, () => {
232232
expect(builtQuery.select).toHaveProperty(`employee`)
233233
expect(builtQuery.select).toHaveProperty(`department`)
234234
})
235+
236+
describe(`join alias methods`, () => {
237+
it(`leftJoin produces same result as join with 'left' type`, () => {
238+
const builder = new Query()
239+
const explicitQuery = builder
240+
.from({ employees: employeesCollection })
241+
.join(
242+
{ departments: departmentsCollection },
243+
({ employees, departments }) =>
244+
eq(employees.department_id, departments.id),
245+
`left`
246+
)
247+
248+
const aliasQuery = builder
249+
.from({ employees: employeesCollection })
250+
.leftJoin(
251+
{ departments: departmentsCollection },
252+
({ employees, departments }) =>
253+
eq(employees.department_id, departments.id)
254+
)
255+
256+
const explicitQueryIR = getQueryIR(explicitQuery)
257+
const aliasQueryIR = getQueryIR(aliasQuery)
258+
259+
expect(aliasQueryIR.join).toEqual(explicitQueryIR.join)
260+
expect(aliasQueryIR.join![0]!.type).toBe(`left`)
261+
})
262+
263+
it(`rightJoin produces same result as join with 'right' type`, () => {
264+
const builder = new Query()
265+
const explicitQuery = builder
266+
.from({ employees: employeesCollection })
267+
.join(
268+
{ departments: departmentsCollection },
269+
({ employees, departments }) =>
270+
eq(employees.department_id, departments.id),
271+
`right`
272+
)
273+
274+
const aliasQuery = builder
275+
.from({ employees: employeesCollection })
276+
.rightJoin(
277+
{ departments: departmentsCollection },
278+
({ employees, departments }) =>
279+
eq(employees.department_id, departments.id)
280+
)
281+
282+
const explicitQueryIR = getQueryIR(explicitQuery)
283+
const aliasQueryIR = getQueryIR(aliasQuery)
284+
285+
expect(aliasQueryIR.join).toEqual(explicitQueryIR.join)
286+
expect(aliasQueryIR.join![0]!.type).toBe(`right`)
287+
})
288+
289+
it(`innerJoin produces same result as join with 'inner' type`, () => {
290+
const builder = new Query()
291+
const explicitQuery = builder
292+
.from({ employees: employeesCollection })
293+
.join(
294+
{ departments: departmentsCollection },
295+
({ employees, departments }) =>
296+
eq(employees.department_id, departments.id),
297+
`inner`
298+
)
299+
300+
const aliasQuery = builder
301+
.from({ employees: employeesCollection })
302+
.innerJoin(
303+
{ departments: departmentsCollection },
304+
({ employees, departments }) =>
305+
eq(employees.department_id, departments.id)
306+
)
307+
308+
const explicitQueryIR = getQueryIR(explicitQuery)
309+
const aliasQueryIR = getQueryIR(aliasQuery)
310+
311+
expect(aliasQueryIR.join).toEqual(explicitQueryIR.join)
312+
expect(aliasQueryIR.join![0]!.type).toBe(`inner`)
313+
})
314+
315+
it(`fullJoin produces same result as join with 'full' type`, () => {
316+
const builder = new Query()
317+
const explicitQuery = builder
318+
.from({ employees: employeesCollection })
319+
.join(
320+
{ departments: departmentsCollection },
321+
({ employees, departments }) =>
322+
eq(employees.department_id, departments.id),
323+
`full`
324+
)
325+
326+
const aliasQuery = builder
327+
.from({ employees: employeesCollection })
328+
.fullJoin(
329+
{ departments: departmentsCollection },
330+
({ employees, departments }) =>
331+
eq(employees.department_id, departments.id)
332+
)
333+
334+
const explicitQueryIR = getQueryIR(explicitQuery)
335+
const aliasQueryIR = getQueryIR(aliasQuery)
336+
337+
expect(aliasQueryIR.join).toEqual(explicitQueryIR.join)
338+
expect(aliasQueryIR.join![0]!.type).toBe(`full`)
339+
})
340+
341+
it(`supports chaining join aliases with different types`, () => {
342+
const projectsCollection = new CollectionImpl<{
343+
id: number
344+
name: string
345+
department_id: number
346+
}>({
347+
id: `projects`,
348+
getKey: (item) => item.id,
349+
sync: { sync: () => {} },
350+
})
351+
352+
const builder = new Query()
353+
const query = builder
354+
.from({ employees: employeesCollection })
355+
.leftJoin(
356+
{ departments: departmentsCollection },
357+
({ employees, departments }) =>
358+
eq(employees.department_id, departments.id)
359+
)
360+
.innerJoin(
361+
{ projects: projectsCollection },
362+
({ departments, projects }) =>
363+
eq(departments.id, projects.department_id)
364+
)
365+
366+
const builtQuery = getQueryIR(query)
367+
expect(builtQuery.join).toBeDefined()
368+
expect(builtQuery.join).toHaveLength(2)
369+
370+
const firstJoin = builtQuery.join![0]!
371+
const secondJoin = builtQuery.join![1]!
372+
373+
expect(firstJoin.type).toBe(`left`)
374+
expect(firstJoin.from.alias).toBe(`departments`)
375+
expect(secondJoin.type).toBe(`inner`)
376+
expect(secondJoin.from.alias).toBe(`projects`)
377+
})
378+
379+
it(`join aliases work in select and where clauses`, () => {
380+
const builder = new Query()
381+
const query = builder
382+
.from({ employees: employeesCollection })
383+
.innerJoin(
384+
{ departments: departmentsCollection },
385+
({ employees, departments }) =>
386+
eq(employees.department_id, departments.id)
387+
)
388+
.where(({ departments }) => gt(departments.budget, 1000000))
389+
.select(({ employees, departments }) => ({
390+
id: employees.id,
391+
name: employees.name,
392+
department_name: departments.name,
393+
department_budget: departments.budget,
394+
}))
395+
396+
const builtQuery = getQueryIR(query)
397+
expect(builtQuery.join).toBeDefined()
398+
expect(builtQuery.join![0]!.type).toBe(`inner`)
399+
expect(builtQuery.where).toBeDefined()
400+
expect(builtQuery.select).toBeDefined()
401+
expect(builtQuery.select).toHaveProperty(`department_name`)
402+
})
403+
})
235404
})

0 commit comments

Comments
 (0)