diff --git a/.changeset/slimy-lizards-kiss.md b/.changeset/slimy-lizards-kiss.md new file mode 100644 index 000000000..eb657a5b6 --- /dev/null +++ b/.changeset/slimy-lizards-kiss.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Fix query optimizer to preserve outer join semantics by keeping residual WHERE clauses when pushing predicates to subqueries. diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index 94a3a94c6..8366eb009 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -1,5 +1,5 @@ import { filter, groupBy, groupByOperators, map } from "@tanstack/db-ivm" -import { Func, PropRef } from "../ir.js" +import { Func, PropRef, getHavingExpression } from "../ir.js" import { AggregateFunctionNotInSelectError, NonAggregateExpressionNotInGroupByError, @@ -129,8 +129,9 @@ export function processGroupBy( // Apply HAVING clauses if present if (havingClauses && havingClauses.length > 0) { for (const havingClause of havingClauses) { + const havingExpression = getHavingExpression(havingClause) const transformedHavingClause = transformHavingClause( - havingClause, + havingExpression, selectClause || {} ) const compiledHaving = compileExpression(transformedHavingClause) @@ -263,8 +264,9 @@ export function processGroupBy( // Apply HAVING clauses if present if (havingClauses && havingClauses.length > 0) { for (const havingClause of havingClauses) { + const havingExpression = getHavingExpression(havingClause) const transformedHavingClause = transformHavingClause( - havingClause, + havingExpression, selectClause || {} ) const compiledHaving = compileExpression(transformedHavingClause) diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 0ee3bc167..e3a1beb53 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -7,7 +7,7 @@ import { LimitOffsetRequireOrderByError, UnsupportedFromTypeError, } from "../../errors.js" -import { PropRef } from "../ir.js" +import { PropRef, getWhereExpression } from "../ir.js" import { compileExpression } from "./evaluators.js" import { processJoins } from "./joins.js" import { processGroupBy } from "./group-by.js" @@ -131,7 +131,8 @@ export function compileQuery( if (query.where && query.where.length > 0) { // Apply each WHERE condition as a filter (they are ANDed together) for (const where of query.where) { - const compiledWhere = compileExpression(where) + const whereExpression = getWhereExpression(where) + const compiledWhere = compileExpression(whereExpression) pipeline = pipeline.pipe( filter(([_key, namespacedRow]) => { return compiledWhere(namespacedRow) diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index d015ff4b9..863f3f554 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -39,7 +39,9 @@ export interface JoinClause { right: BasicExpression } -export type Where = BasicExpression +export type Where = + | BasicExpression + | { expression: BasicExpression; residual?: boolean } export type GroupBy = Array @@ -128,3 +130,48 @@ export class Aggregate extends BaseExpression { super() } } + +/** + * Helper functions for working with Where clauses + */ + +/** + * Extract the expression from a Where clause + */ +export function getWhereExpression(where: Where): BasicExpression { + return typeof where === `object` && `expression` in where + ? where.expression + : where +} + +/** + * Extract the expression from a HAVING clause + * HAVING clauses can contain aggregates, unlike regular WHERE clauses + */ +export function getHavingExpression( + having: Having +): BasicExpression | Aggregate { + return typeof having === `object` && `expression` in having + ? having.expression + : having +} + +/** + * Check if a Where clause is marked as residual + */ +export function isResidualWhere(where: Where): boolean { + return ( + typeof where === `object` && + `expression` in where && + where.residual === true + ) +} + +/** + * Create a residual Where clause from an expression + */ +export function createResidualWhere( + expression: BasicExpression +): Where { + return { expression, residual: true } +} diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index e701a05c8..895be7be1 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -45,6 +45,11 @@ * - **Ordering + Limits**: ORDER BY combined with LIMIT/OFFSET (would change result set) * - **Functional Operations**: fnSelect, fnWhere, fnHaving (potential side effects) * + * ### Residual WHERE Clauses + * For outer joins (LEFT, RIGHT, FULL), WHERE clauses are copied to subqueries for optimization + * but also kept as "residual" clauses in the main query to preserve semantics. This ensures + * that NULL values from outer joins are properly filtered according to SQL standards. + * * The optimizer tracks which clauses were actually optimized and only removes those from the * main query. Subquery reuse is handled safely through immutable query copies. * @@ -121,9 +126,12 @@ import { CollectionRef as CollectionRefClass, Func, QueryRef as QueryRefClass, + createResidualWhere, + getWhereExpression, + isResidualWhere, } from "./ir.js" import { isConvertibleToCollectionFilter } from "./compiler/expressions.js" -import type { BasicExpression, From, QueryIR } from "./ir.js" +import type { BasicExpression, From, QueryIR, Where } from "./ir.js" /** * Represents a WHERE clause after source analysis @@ -325,8 +333,13 @@ function applySingleLevelOptimization(query: QueryIR): QueryIR { return query } + // Filter out residual WHERE clauses to prevent them from being optimized again + const nonResidualWhereClauses = query.where.filter( + (where) => !isResidualWhere(where) + ) + // Step 1: Split all AND clauses at the root level for granular optimization - const splitWhereClauses = splitAndClauses(query.where) + const splitWhereClauses = splitAndClauses(nonResidualWhereClauses) // Step 2: Analyze each WHERE clause to determine which sources it touches const analyzedClauses = splitWhereClauses.map((clause) => @@ -337,7 +350,20 @@ function applySingleLevelOptimization(query: QueryIR): QueryIR { const groupedClauses = groupWhereClauses(analyzedClauses) // Step 4: Apply optimizations by lifting single-source clauses into subqueries - return applyOptimizations(query, groupedClauses) + const optimizedQuery = applyOptimizations(query, groupedClauses) + + // Add back any residual WHERE clauses that were filtered out + const residualWhereClauses = query.where.filter((where) => + isResidualWhere(where) + ) + if (residualWhereClauses.length > 0) { + optimizedQuery.where = [ + ...(optimizedQuery.where || []), + ...residualWhereClauses, + ] + } + + return optimizedQuery } /** @@ -424,26 +450,35 @@ function isRedundantSubquery(query: QueryIR): boolean { * ``` */ function splitAndClauses( - whereClauses: Array> + whereClauses: Array ): Array> { const result: Array> = [] - for (const clause of whereClauses) { - if (clause.type === `func` && clause.name === `and`) { - // Recursively split nested AND clauses to handle complex expressions - const splitArgs = splitAndClauses( - clause.args as Array> - ) - result.push(...splitArgs) - } else { - // Preserve non-AND clauses as-is (including OR clauses) - result.push(clause) - } + for (const whereClause of whereClauses) { + const clause = getWhereExpression(whereClause) + result.push(...splitAndClausesRecursive(clause)) } return result } +// Helper function for recursive splitting of BasicExpression arrays +function splitAndClausesRecursive( + clause: BasicExpression +): Array> { + if (clause.type === `func` && clause.name === `and`) { + // Recursively split nested AND clauses to handle complex expressions + const result: Array> = [] + for (const arg of clause.args as Array>) { + result.push(...splitAndClausesRecursive(arg)) + } + return result + } else { + // Preserve non-AND clauses as-is (including OR clauses) + return [clause] + } +} + /** * Step 2: Analyze which table sources a WHERE clause touches. * @@ -588,19 +623,32 @@ function applyOptimizations( })) : undefined - // Build the remaining WHERE clauses: multi-source + any single-source that weren't optimized - const remainingWhereClauses: Array> = [] + // Build the remaining WHERE clauses: multi-source + residual single-source clauses + const remainingWhereClauses: Array = [] // Add multi-source clauses if (groupedClauses.multiSource) { remainingWhereClauses.push(groupedClauses.multiSource) } - // Add single-source clauses that weren't actually optimized + // Determine if we need residual clauses (when query has outer JOINs) + const hasOuterJoins = + query.join && + query.join.some( + (join) => + join.type === `left` || join.type === `right` || join.type === `full` + ) + + // Add single-source clauses for (const [source, clause] of groupedClauses.singleSource) { if (!actuallyOptimized.has(source)) { + // Wasn't optimized at all - keep as regular WHERE clause remainingWhereClauses.push(clause) + } else if (hasOuterJoins) { + // Was optimized AND query has outer JOINs - keep as residual WHERE clause + remainingWhereClauses.push(createResidualWhere(clause)) } + // If optimized and no outer JOINs - don't keep (original behavior) } // Create a completely new query object to ensure immutability diff --git a/packages/db/tests/query/indexes.test.ts b/packages/db/tests/query/indexes.test.ts index b306362b2..0083c6832 100644 --- a/packages/db/tests/query/indexes.test.ts +++ b/packages/db/tests/query/indexes.test.ts @@ -631,7 +631,7 @@ describe(`Query Index Optimization`, () => { write({ type: `insert`, value: { - id: `other1`, + id: `1`, // Matches Alice from main collection name: `Other Active Item`, age: 40, status: `active`, @@ -641,7 +641,7 @@ describe(`Query Index Optimization`, () => { write({ type: `insert`, value: { - id: `other2`, + id: `2`, // Matches Bob from main collection name: `Other Inactive Item`, age: 35, status: `inactive`, @@ -970,11 +970,11 @@ describe(`Query Index Optimization`, () => { await liveQuery.stateWhenReady() - // Should include all results from the first collection + // Should only include results where both sides match the WHERE condition + // Charlie and Eve are filtered out because they have no matching 'other' records + // and the WHERE clause requires other.status = 'active' (can't be NULL) expect(liveQuery.toArray).toEqual([ { id: `1`, name: `Alice`, otherName: `Other Active Item` }, - { id: `3`, name: `Charlie` }, - { id: `5`, name: `Eve` }, ]) // Combine stats from both collections @@ -1100,11 +1100,11 @@ describe(`Query Index Optimization`, () => { await liveQuery.stateWhenReady() - // Should have found results where both items are active + // Should only include results where both sides match the WHERE condition + // Charlie and Eve are filtered out because they have no matching 'other' records + // and the WHERE clause requires other.status = 'active' (can't be NULL) expect(liveQuery.toArray).toEqual([ { id: `1`, name: `Alice`, otherName: `Other Active Item` }, - { id: `3`, name: `Charlie` }, - { id: `5`, name: `Eve` }, ]) // We should have done an index lookup on the left collection to find active items diff --git a/packages/db/tests/query/join.test.ts b/packages/db/tests/query/join.test.ts index df1b5f016..7268b9025 100644 --- a/packages/db/tests/query/join.test.ts +++ b/packages/db/tests/query/join.test.ts @@ -455,6 +455,197 @@ function testJoinType(joinType: JoinType, autoIndex: `off` | `eager`) { }) }) } + + test(`should handle WHERE clauses on nullable side of ${joinType} join`, () => { + // This test checks the behavior of WHERE clauses that filter on the side of the join + // that can produce NULL values. The behavior should differ based on join type. + + // Create a specific scenario for testing nullable WHERE clauses + // We'll use a team/member scenario where teams may have no members + type Team = { + id: number + name: string + active: boolean + } + + type TeamMember = { + id: number + team_id: number + user_id: number + role: string + } + + const teams: Array = [ + { id: 1, name: `Team Alpha`, active: true }, + { id: 2, name: `Team Beta`, active: true }, + { id: 3, name: `Team Gamma`, active: false }, // This team has no members + ] + + const teamMembers: Array = [ + { id: 1, team_id: 1, user_id: 100, role: `admin` }, + { id: 2, team_id: 1, user_id: 200, role: `member` }, + { id: 3, team_id: 2, user_id: 100, role: `admin` }, + // Note: Team Gamma (id: 3) has no members + ] + + const teamsCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-teams-where`, + getKey: (team) => team.id, + initialData: teams, + autoIndex, + }) + ) + + const teamMembersCollection = createCollection( + mockSyncCollectionOptions({ + id: `test-members-where`, + getKey: (member) => member.id, + initialData: teamMembers, + autoIndex, + }) + ) + + // Test WHERE clause that filters on the nullable side based on join type + let joinQuery: any + + if (joinType === `left`) { + // LEFT JOIN: teams LEFT JOIN members WHERE member.user_id = 100 + // Should return only teams where user 100 is a member + joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ team: teamsCollection }) + .leftJoin({ member: teamMembersCollection }, ({ team, member }) => + eq(team.id, member.team_id) + ) + .where(({ member }) => eq(member.user_id, 100)) + .select(({ team, member }) => ({ + team_id: team.id, + team_name: team.name, + user_id: member.user_id, + role: member.role, + })), + }) + } else if (joinType === `right`) { + // RIGHT JOIN: teams RIGHT JOIN members WHERE team.active = true + // Should return only members whose teams are active + joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ team: teamsCollection }) + .rightJoin( + { member: teamMembersCollection }, + ({ team, member }) => eq(team.id, member.team_id) + ) + .where(({ team }) => eq(team.active, true)) + .select(({ team, member }) => ({ + team_id: team.id, + team_name: team.name, + user_id: member.user_id, + role: member.role, + })), + }) + } else if (joinType === `full`) { + // FULL JOIN: teams FULL JOIN members WHERE member.role = 'admin' + // Should return all teams with admin members, plus orphaned admins + joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ team: teamsCollection }) + .fullJoin({ member: teamMembersCollection }, ({ team, member }) => + eq(team.id, member.team_id) + ) + .where(({ member }) => eq(member.role, `admin`)) + .select(({ team, member }) => ({ + team_id: team.id, + team_name: team.name, + user_id: member.user_id, + role: member.role, + })), + }) + } else { + // INNER JOIN: teams INNER JOIN members WHERE member.user_id = 100 + // Should return only teams where user 100 is a member (same as LEFT but no nulls) + joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ team: teamsCollection }) + .innerJoin( + { member: teamMembersCollection }, + ({ team, member }) => eq(team.id, member.team_id) + ) + .where(({ member }) => eq(member.user_id, 100)) + .select(({ team, member }) => ({ + team_id: team.id, + team_name: team.name, + user_id: member.user_id, + role: member.role, + })), + }) + } + + const results = joinQuery.toArray + + // Type the results properly based on our expected structure + type JoinResult = { + team_id: number | null + team_name: string | null + user_id: number | null + role: string | null + } + + const typedResults = results as Array + + // Verify expected behavior based on join type + switch (joinType) { + case `inner`: + // INNER JOIN with WHERE member.user_id = 100 + // Should return 2 results: Team Alpha and Team Beta (both have user 100) + expect(typedResults).toHaveLength(2) + expect(typedResults.every((r) => r.user_id === 100)).toBe(true) + expect(typedResults.map((r) => r.team_name).sort()).toEqual([ + `Team Alpha`, + `Team Beta`, + ]) + break + + case `left`: + // LEFT JOIN with WHERE member.user_id = 100 + // Should return 2 results: only teams where user 100 is actually a member + // Team Gamma should be filtered out because member.user_id would be null + expect(typedResults).toHaveLength(2) + expect(typedResults.every((r) => r.user_id === 100)).toBe(true) + expect(typedResults.map((r) => r.team_name).sort()).toEqual([ + `Team Alpha`, + `Team Beta`, + ]) + break + + case `right`: + // RIGHT JOIN with WHERE team.active = true + // Should return 3 results: all members whose teams are active + // (All existing teams are active) + expect(typedResults).toHaveLength(3) + expect(typedResults.every((r) => r.team_id !== null)).toBe(true) + expect(typedResults.map((r) => r.user_id).sort()).toEqual([ + 100, 100, 200, + ]) + break + + case `full`: + // FULL JOIN with WHERE member.role = 'admin' + // Should return 2 results: Team Alpha + user 100, Team Beta + user 100 + expect(typedResults).toHaveLength(2) + expect(typedResults.every((r) => r.role === `admin`)).toBe(true) + expect(typedResults.map((r) => r.user_id).sort()).toEqual([100, 100]) + break + } + }) }) } diff --git a/packages/db/tests/query/optimizer.test.ts b/packages/db/tests/query/optimizer.test.ts index 73530a6f2..9e25bb2d4 100644 --- a/packages/db/tests/query/optimizer.test.ts +++ b/packages/db/tests/query/optimizer.test.ts @@ -1420,4 +1420,244 @@ describe(`Query Optimizer`, () => { } }) }) + + describe(`JOIN semantics preservation`, () => { + test(`should preserve WHERE clause semantics when pushing down to LEFT JOIN`, () => { + // This test reproduces the bug where pushing WHERE clauses into LEFT JOIN subqueries + // changes the semantics by filtering out null values that should remain + + const teamsCollection = { id: `teams` } as any + const teamMembersCollection = { id: `team-members` } as any + + // Original query: LEFT JOIN with WHERE clause that should filter final results + const query: QueryIR = { + from: new CollectionRef(teamsCollection, `team`), + join: [ + { + type: `left`, + from: new CollectionRef(teamMembersCollection, `teamMember`), + left: createPropRef(`team`, `id`), + right: createPropRef(`teamMember`, `team_id`), + }, + ], + where: [ + // This WHERE clause should filter the final result, not pre-filter the teamMember collection + createEq(createPropRef(`teamMember`, `user_id`), createValue(100)), + ], + select: { + id: createPropRef(`team`, `id`), + name: createPropRef(`team`, `name`), + }, + } + + const { optimizedQuery } = optimizeQuery(query) + + // The WHERE clause should remain in the main query to preserve LEFT JOIN semantics + // It should NOT be completely moved to the subquery + expect(optimizedQuery.where).toHaveLength(1) + expect(optimizedQuery.where![0]).toEqual({ + expression: createEq( + createPropRef(`teamMember`, `user_id`), + createValue(100) + ), + residual: true, + }) + + // If the optimizer creates a subquery for teamMember, the WHERE clause should also be copied there + // but a residual copy must remain in the main query + if ( + optimizedQuery.join && + optimizedQuery.join[0]?.from.type === `queryRef` + ) { + const teamMemberSubquery = optimizedQuery.join[0].from.query + // The subquery may have the WHERE clause for optimization + if (teamMemberSubquery.where && teamMemberSubquery.where.length > 0) { + // But the main query MUST still have it to preserve semantics + expect(optimizedQuery.where).toContainEqual({ + expression: createEq( + createPropRef(`teamMember`, `user_id`), + createValue(100) + ), + residual: true, + }) + } + } + }) + + test(`should preserve WHERE clause semantics when pushing down to RIGHT JOIN`, () => { + // This test reproduces the bug where pushing WHERE clauses into RIGHT JOIN subqueries + // changes the semantics by filtering out null values that should remain + + const usersCollection = { id: `users` } as any + const profilesCollection = { id: `profiles` } as any + + // Original query: RIGHT JOIN with WHERE clause that should filter final results + // This should include all profiles, but only those where user.department_id = 1 OR user is null + const query: QueryIR = { + from: new CollectionRef(usersCollection, `user`), + join: [ + { + type: `right`, + from: new CollectionRef(profilesCollection, `profile`), + left: createPropRef(`user`, `id`), + right: createPropRef(`profile`, `user_id`), + }, + ], + where: [ + // This WHERE clause should filter the final result, not pre-filter the users collection + // In a RIGHT JOIN, this should keep profiles where either: + // 1. user.department_id = 1, OR + // 2. user is null (profile has no matching user) + createEq(createPropRef(`user`, `department_id`), createValue(1)), + ], + select: { + profile_id: createPropRef(`profile`, `id`), + user_name: createPropRef(`user`, `name`), + }, + } + + const { optimizedQuery } = optimizeQuery(query) + + // The WHERE clause should remain in the main query to preserve RIGHT JOIN semantics + // It should NOT be completely moved to the subquery + expect(optimizedQuery.where).toHaveLength(1) + expect(optimizedQuery.where![0]).toEqual({ + expression: createEq( + createPropRef(`user`, `department_id`), + createValue(1) + ), + residual: true, + }) + + // If the optimizer creates a subquery for users, the WHERE clause should also be copied there + // but a residual copy must remain in the main query + if (optimizedQuery.from.type === `queryRef`) { + const userSubquery = optimizedQuery.from.query + // The subquery may have the WHERE clause for optimization + if (userSubquery.where && userSubquery.where.length > 0) { + // But the main query MUST still have it to preserve semantics + expect(optimizedQuery.where).toContainEqual({ + expression: createEq( + createPropRef(`user`, `department_id`), + createValue(1) + ), + residual: true, + }) + } + } + }) + + test(`should preserve WHERE clause semantics when pushing down to FULL JOIN`, () => { + // This test reproduces the bug where pushing WHERE clauses into FULL JOIN subqueries + // changes the semantics by filtering out null values that should remain + + const ordersCollection = { id: `orders` } as any + const paymentsCollection = { id: `payments` } as any + + // Original query: FULL JOIN with WHERE clause that should filter final results + // This should include: + // 1. Orders with payments where payment.amount > 100 + // 2. Orders without payments (WHERE would be false for null payment.amount, so filtered out) + // 3. Payments without orders where payment.amount > 100 + const query: QueryIR = { + from: new CollectionRef(ordersCollection, `order`), + join: [ + { + type: `full`, + from: new CollectionRef(paymentsCollection, `payment`), + left: createPropRef(`order`, `id`), + right: createPropRef(`payment`, `order_id`), + }, + ], + where: [ + // This WHERE clause should filter the final result, not pre-filter either collection + createGt(createPropRef(`payment`, `amount`), createValue(100)), + ], + select: { + order_id: createPropRef(`order`, `id`), + payment_amount: createPropRef(`payment`, `amount`), + }, + } + + const { optimizedQuery } = optimizeQuery(query) + + // The WHERE clause should remain in the main query to preserve FULL JOIN semantics + // It should NOT be completely moved to the subquery + expect(optimizedQuery.where).toHaveLength(1) + expect(optimizedQuery.where![0]).toEqual({ + expression: createGt( + createPropRef(`payment`, `amount`), + createValue(100) + ), + residual: true, + }) + + // If the optimizer creates a subquery for payments, the WHERE clause should also be copied there + // but a residual copy must remain in the main query + if ( + optimizedQuery.join && + optimizedQuery.join[0]?.from.type === `queryRef` + ) { + const paymentSubquery = optimizedQuery.join[0].from.query + // The subquery may have the WHERE clause for optimization + if (paymentSubquery.where && paymentSubquery.where.length > 0) { + // But the main query MUST still have it to preserve semantics + expect(optimizedQuery.where).toContainEqual({ + expression: createGt( + createPropRef(`payment`, `amount`), + createValue(100) + ), + residual: true, + }) + } + } + }) + + test(`should allow WHERE clause pushdown for INNER JOIN (semantics preserved)`, () => { + // This test confirms that INNER JOIN optimization is still safe + // Because INNER JOINs don't produce NULL values, moving WHERE clauses to subqueries + // doesn't change the semantics + + const usersCollection = { id: `users` } as any + const departmentsCollection = { id: `departments` } as any + + // Original query: INNER JOIN with WHERE clause - optimization should be allowed + const query: QueryIR = { + from: new CollectionRef(usersCollection, `user`), + join: [ + { + type: `inner`, + from: new CollectionRef(departmentsCollection, `dept`), + left: createPropRef(`user`, `department_id`), + right: createPropRef(`dept`, `id`), + }, + ], + where: [ + // This WHERE clause CAN be moved to subquery for INNER JOIN without changing semantics + createEq(createPropRef(`dept`, `budget`), createValue(100000)), + ], + select: { + user_name: createPropRef(`user`, `name`), + dept_name: createPropRef(`dept`, `name`), + }, + } + + const { optimizedQuery } = optimizeQuery(query) + + // For INNER JOIN, the WHERE clause CAN be completely moved to the subquery + // This is safe because INNER JOIN doesn't produce NULL values that need residual filtering + expect(optimizedQuery.where).toHaveLength(0) + + // The WHERE clause should be pushed into the department subquery for optimization + expect(optimizedQuery.join).toHaveLength(1) + expect(optimizedQuery.join![0]?.from.type).toBe(`queryRef`) + + if (optimizedQuery.join![0]?.from.type === `queryRef`) { + const deptSubquery = optimizedQuery.join![0].from.query + expect(deptSubquery.where).toContainEqual( + createEq(createPropRef(`dept`, `budget`), createValue(100000)) + ) + } + }) + }) })