diff --git a/.changeset/pass-join-info-to-queryfn.md b/.changeset/pass-join-info-to-queryfn.md new file mode 100644 index 000000000..c71a9abc5 --- /dev/null +++ b/.changeset/pass-join-info-to-queryfn.md @@ -0,0 +1,6 @@ +--- +'@tanstack/db': patch +'@tanstack/query-db-collection': patch +--- + +Pass join information to queryFn in on-demand mode. This enables server-side joins before pagination, fixing inconsistent page sizes when queries combine pagination with filters on joined collections. The new `joins` array in `LoadSubsetOptions` contains collection ID, alias, join type, key expressions, and associated filters. diff --git a/INVESTIGATION-join-pagination-queryFn.md b/INVESTIGATION-join-pagination-queryFn.md new file mode 100644 index 000000000..e46e5e1f0 --- /dev/null +++ b/INVESTIGATION-join-pagination-queryFn.md @@ -0,0 +1,155 @@ +# Investigation: Passing Join Information to queryFn in On-Demand Mode + +**Status: IMPLEMENTED** + +## Problem Statement + +When using `queryCollection` in on-demand mode with joins and pagination, the pagination was applied **before** join filters were evaluated, leading to inconsistent page sizes or empty results. + +**User's scenario:** + +- `tasksCollection` (queryCollection, on-demand) with `limit: 10` +- Joined with `accountsCollection` +- Filter on `account.name = 'example'` + +**What was happening:** + +1. `tasksCollection.queryFn` received `{ limit: 10, where: }` +2. Backend returned 10 tasks +3. Client-side join with `accountsCollection` +4. Account filter applied client-side +5. Result: Only 6 tasks match (page size inconsistent) + +## Solution Implemented + +We extended `LoadSubsetOptions` to include join information, allowing the sync layer to construct server-side join queries that filter before pagination. + +### New `JoinInfo` Type + +```typescript +// packages/db/src/types.ts +export type JoinInfo = { + /** The ID of the collection being joined */ + collectionId: string + /** The alias used for the joined collection in the query */ + alias: string + /** The type of join to perform */ + type: `inner` | `left` | `right` | `full` | `cross` + /** The join key expression from the main collection (e.g., task.account_id) */ + localKey: BasicExpression + /** The join key expression from the joined collection (e.g., account.id) */ + foreignKey: BasicExpression + /** Filters that apply to the joined collection */ + where?: BasicExpression + /** OrderBy expressions that reference the joined collection */ + orderBy?: OrderBy +} +``` + +### Extended `LoadSubsetOptions` + +```typescript +export type LoadSubsetOptions = { + where?: BasicExpression + orderBy?: OrderBy + limit?: number + cursor?: CursorExpressions + offset?: number + subscription?: Subscription + // NEW: Join information for server-side query construction + joins?: Array +} +``` + +## Implementation Details + +### 1. Join Info Extraction (`packages/db/src/query/optimizer.ts`) + +Added `extractJoinInfo()` function that: + +- Analyzes query IR to extract join clause information +- Associates filters from `sourceWhereClauses` with their respective joins +- Associates orderBy expressions with their respective joins +- Returns a map of main source alias → Array + +### 2. Compilation Pipeline (`packages/db/src/query/compiler/index.ts`) + +- `CompilationResult` now includes `joinInfoBySource` +- `compileQuery()` passes through join info from optimizer + +### 3. Live Query Builder (`packages/db/src/query/live/collection-config-builder.ts`) + +- Added `joinInfoBySourceCache` to store join info +- Populated during compilation + +### 4. Collection Subscriber (`packages/db/src/query/live/collection-subscriber.ts`) + +- Added `getJoinInfoForAlias()` method +- Passes join info when creating subscriptions + +### 5. Collection Subscription (`packages/db/src/collection/subscription.ts`) + +- `CollectionSubscriptionOptions` now accepts `joinInfo` +- `requestLimitedSnapshot()` includes `joins` in `LoadSubsetOptions` + +### 6. Serialization (`packages/query-db-collection/src/serialization.ts`) + +- `serializeLoadSubsetOptions()` now serializes join info for query key generation + +## Usage in queryFn + +Now `queryFn` can access join information: + +```typescript +queryFn: async (context) => { + const opts = context.meta.loadSubsetOptions + + if (opts.joins?.length) { + // Construct a query with server-side joins + // Example with Drizzle: + let query = db.select().from(tasks) + + for (const join of opts.joins) { + // Add join based on join.type, join.localKey, join.foreignKey + query = query.innerJoin(accounts, eq(tasks.accountId, accounts.id)) + + // Apply joined collection's filter + if (join.where) { + query = query.where(/* translate join.where to Drizzle */) + } + } + + // Apply main collection's filter + if (opts.where) { + query = query.where(/* translate opts.where to Drizzle */) + } + + return query.limit(opts.limit).offset(opts.offset) + } + + // Simple query without joins (existing behavior) + return db.select().from(tasks).where(/* opts.where */).limit(opts.limit) +} +``` + +## Files Changed + +- `packages/db/src/types.ts` - Added `JoinInfo` type, extended `LoadSubsetOptions` and `SubscribeChangesOptions` +- `packages/db/src/query/optimizer.ts` - Added `extractJoinInfo()`, updated `OptimizationResult` +- `packages/db/src/query/compiler/index.ts` - Added `joinInfoBySource` to `CompilationResult` +- `packages/db/src/query/live/collection-config-builder.ts` - Added `joinInfoBySourceCache` +- `packages/db/src/query/live/collection-subscriber.ts` - Added `getJoinInfoForAlias()` +- `packages/db/src/collection/subscription.ts` - Pass join info in `loadSubset` calls +- `packages/query-db-collection/src/serialization.ts` - Serialize joins in query keys +- `packages/db/tests/query/optimizer.test.ts` - Added tests for join info extraction + +## Test Coverage + +Added tests in `packages/db/tests/query/optimizer.test.ts`: + +- Empty map for queries without joins +- Basic inner join extraction +- WHERE clause inclusion for joined collections +- OrderBy inclusion for joined collections +- Multiple joins handling +- Swapped key expression handling diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 44c9af49f..73bb4a085 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -12,6 +12,7 @@ import type { BasicExpression, OrderBy } from '../query/ir.js' import type { IndexInterface } from '../indexes/base-index.js' import type { ChangeMessage, + JoinInfo, LoadSubsetOptions, Subscription, SubscriptionEvents, @@ -45,6 +46,12 @@ type CollectionSubscriptionOptions = { whereExpression?: BasicExpression /** Callback to call when the subscription is unsubscribed */ onUnsubscribe?: (event: SubscriptionUnsubscribedEvent) => void + /** + * Join information for server-side query construction. + * When present, this is included in loadSubset calls so the sync layer + * can perform joins before pagination. + */ + joinInfo?: Array } export class CollectionSubscription @@ -591,6 +598,8 @@ export class CollectionSubscription cursor: cursorExpressions, // Cursor expressions passed separately offset: offset ?? currentOffset, // Use provided offset, or auto-tracked offset subscription: this, + // Include join info for server-side query construction + joins: this.options.joinInfo, } const syncResult = this.collection._sync.loadSubset(loadOptions) diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 30513ba85..3ff161bec 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -25,6 +25,7 @@ import type { import type { LazyCollectionCallbacks } from './joins.js' import type { Collection } from '../../collection/index.js' import type { + JoinInfo, KeyedStream, NamespacedAndKeyedStream, ResultStream, @@ -67,6 +68,13 @@ export interface CompilationResult { * the inner aliases where collection subscriptions were created. */ aliasRemapping: Record + + /** + * Map of main source aliases to their join information for server-side query construction. + * This enables the sync layer to perform joins before pagination, ensuring consistent page sizes + * when filtering by joined collection properties. + */ + joinInfoBySource: Map> } /** @@ -106,7 +114,11 @@ export function compileQuery( validateQueryStructure(rawQuery) // Optimize the query before compilation - const { optimizedQuery: query, sourceWhereClauses } = optimizeQuery(rawQuery) + const { + optimizedQuery: query, + sourceWhereClauses, + joinInfoBySource, + } = optimizeQuery(rawQuery) // Create mapping from optimized query to original for caching queryMapping.set(query, rawQuery) @@ -345,6 +357,7 @@ export function compileQuery( sourceWhereClauses, aliasToCollectionId, aliasRemapping, + joinInfoBySource, } cache.set(rawQuery, compilationResult) @@ -375,6 +388,7 @@ export function compileQuery( sourceWhereClauses, aliasToCollectionId, aliasRemapping, + joinInfoBySource, } cache.set(rawQuery, compilationResult) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 53f6d13a9..34c600cf7 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -19,6 +19,7 @@ import type { OrderByOptimizationInfo } from '../compiler/order-by.js' import type { Collection } from '../../collection/index.js' import type { CollectionConfigSingleRowOption, + JoinInfo, KeyedStream, ResultStream, StringCollationConfig, @@ -136,6 +137,9 @@ export class CollectionConfigBuilder< | Map> | undefined + // Map of source aliases to their join info for server-side query construction + public joinInfoBySourceCache: Map> | undefined + // Map of source alias to subscription readonly subscriptions: Record = {} // Map of source aliases to functions that load keys for that lazy source @@ -617,6 +621,7 @@ export class CollectionConfigBuilder< this.inputsCache = undefined this.pipelineCache = undefined this.sourceWhereClausesCache = undefined + this.joinInfoBySourceCache = undefined // Reset lazy source alias state this.lazySources.clear() @@ -664,6 +669,7 @@ export class CollectionConfigBuilder< this.pipelineCache = compilation.pipeline this.sourceWhereClausesCache = compilation.sourceWhereClauses + this.joinInfoBySourceCache = compilation.joinInfoBySource this.compiledAliasToCollectionId = compilation.aliasToCollectionId // Defensive check: verify all compiled aliases have corresponding inputs diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index ec4876b74..db6d49e92 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -7,6 +7,7 @@ import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm' import type { Collection } from '../../collection/index.js' import type { ChangeMessage, + JoinInfo, SubscriptionStatusChangeEvent, } from '../../types.js' import type { Context, GetResult } from '../builder/types.js' @@ -197,6 +198,9 @@ export class CollectionSubscriber< this.sendChangesToPipeline(changes) } + // Get join info if this is the main source in a join query + const joinInfo = this.getJoinInfoForAlias() + // Create subscription with onStatusChange - listener is registered before snapshot // Note: For non-ordered queries (no limit/offset), we use trackLoadSubsetPromise: false // which is the default behavior in subscribeChanges @@ -204,6 +208,7 @@ export class CollectionSubscriber< ...(includeInitialState && { includeInitialState }), whereExpression, onStatusChange, + joinInfo, }) return subscription @@ -230,11 +235,15 @@ export class CollectionSubscriber< ) } + // Get join info if this is the main source in a join query + const joinInfo = this.getJoinInfoForAlias() + // Subscribe to changes with onStatusChange - listener is registered before any snapshot // values bigger than what we've sent don't need to be sent because they can't affect the topK const subscription = this.collection.subscribeChanges(sendChangesInRange, { whereExpression, onStatusChange, + joinInfo, }) subscriptionHolder.current = subscription @@ -386,6 +395,21 @@ export class CollectionSubscriber< return sourceWhereClausesCache.get(this.alias) } + /** + * Gets join info for this alias if it's the main source in a join query. + * Join info is only returned for the main collection (FROM clause), + * not for joined collections, since only the main collection needs + * to know about joins for server-side query construction. + */ + private getJoinInfoForAlias(): Array | undefined { + const joinInfoBySourceCache = + this.collectionConfigBuilder.joinInfoBySourceCache + if (!joinInfoBySourceCache) { + return undefined + } + return joinInfoBySourceCache.get(this.alias) + } + private getOrderByInfo(): OrderByOptimizationInfo | undefined { const info = this.collectionConfigBuilder.optimizableOrderByCollections[ diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index e1020c284..1142171ef 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -131,7 +131,15 @@ import { getWhereExpression, isResidualWhere, } from './ir.js' -import type { BasicExpression, From, QueryIR, Select, Where } from './ir.js' +import type { + BasicExpression, + From, + OrderBy, + QueryIR, + Select, + Where, +} from './ir.js' +import type { JoinInfo } from '../types.js' /** * Represents a WHERE clause after source analysis @@ -163,6 +171,11 @@ export interface OptimizationResult { optimizedQuery: QueryIR /** Map of source aliases to their extracted WHERE clauses for index optimization */ sourceWhereClauses: Map> + /** + * Map of main source aliases to their join information for server-side query construction. + * This enables the sync layer to perform joins before pagination, ensuring consistent page sizes. + */ + joinInfoBySource: Map> } /** @@ -192,6 +205,10 @@ export function optimizeQuery(query: QueryIR): OptimizationResult { // First, extract source WHERE clauses before optimization const sourceWhereClauses = extractSourceWhereClauses(query) + // Extract join info for server-side query construction + // This must be done on the original query before optimization + const joinInfoBySource = extractJoinInfo(query, sourceWhereClauses) + // Apply multi-level predicate pushdown with iterative convergence let optimized = query let previousOptimized: QueryIR | undefined @@ -214,6 +231,7 @@ export function optimizeQuery(query: QueryIR): OptimizationResult { return { optimizedQuery: cleaned, sourceWhereClauses, + joinInfoBySource, } } @@ -257,6 +275,145 @@ function extractSourceWhereClauses( return sourceWhereClauses } +/** + * Extracts join information from a query for server-side query construction. + * This allows the sync layer to perform joins before pagination, + * ensuring consistent page sizes when filtering by joined collection properties. + * + * @param query - The original QueryIR to analyze + * @param sourceWhereClauses - Pre-computed WHERE clauses by source alias + * @returns Map of main source alias to array of JoinInfo for that source's joins + */ +export function extractJoinInfo( + query: QueryIR, + sourceWhereClauses: Map>, +): Map> { + const joinInfoBySource = new Map>() + + // No joins means no join info to extract + if (!query.join || query.join.length === 0) { + return joinInfoBySource + } + + const mainAlias = query.from.alias + + // Extract orderBy expressions that reference each alias + const orderByByAlias = extractOrderByByAlias(query.orderBy) + + for (const joinClause of query.join) { + const joinedFrom = joinClause.from + const joinedAlias = joinedFrom.alias + + // Get the collection ID - for queryRef, we need to traverse to find the innermost collection + let collectionId: string + if (joinedFrom.type === `collectionRef`) { + collectionId = joinedFrom.collection.id + } else { + // For subqueries, get the innermost collection ID + collectionId = getInnermostCollectionId(joinedFrom.query) + } + + // Determine which expression belongs to which side of the join + const { localKey, foreignKey } = analyzeJoinKeys( + joinClause.left, + joinClause.right, + mainAlias, + joinedAlias, + ) + + // Map join type to JoinInfo type (handle 'outer' as 'full') + const joinType = joinClause.type === `outer` ? `full` : joinClause.type + + const joinInfo: JoinInfo = { + collectionId, + alias: joinedAlias, + type: joinType, + localKey, + foreignKey, + // Include any WHERE clause that applies to the joined collection + where: sourceWhereClauses.get(joinedAlias), + // Include any orderBy that references the joined collection + orderBy: orderByByAlias.get(joinedAlias), + } + + // Store join info keyed by the main source alias + if (!joinInfoBySource.has(mainAlias)) { + joinInfoBySource.set(mainAlias, []) + } + joinInfoBySource.get(mainAlias)!.push(joinInfo) + } + + return joinInfoBySource +} + +/** + * Extracts orderBy expressions grouped by the alias they reference. + */ +function extractOrderByByAlias( + orderBy: OrderBy | undefined, +): Map { + const result = new Map() + + if (!orderBy || orderBy.length === 0) { + return result + } + + for (const clause of orderBy) { + const alias = getExpressionAlias(clause.expression) + if (alias) { + if (!result.has(alias)) { + result.set(alias, []) + } + result.get(alias)!.push(clause) + } + } + + return result +} + +/** + * Gets the alias (first path element) from an expression if it's a PropRef. + */ +function getExpressionAlias(expr: BasicExpression): string | undefined { + if (expr.type === `ref` && expr.path.length > 0) { + return expr.path[0] + } + return undefined +} + +/** + * Analyzes join key expressions to determine which is local (main) and which is foreign (joined). + */ +function analyzeJoinKeys( + left: BasicExpression, + right: BasicExpression, + mainAlias: string, + joinedAlias: string, +): { localKey: BasicExpression; foreignKey: BasicExpression } { + const leftAlias = getExpressionAlias(left) + const rightAlias = getExpressionAlias(right) + + // If left references the joined alias, swap them + if (leftAlias === joinedAlias || rightAlias === mainAlias) { + return { localKey: right, foreignKey: left } + } + + // Default: left is local, right is foreign + return { localKey: left, foreignKey: right } +} + +/** + * Gets the collection ID from the innermost FROM clause of a query. + * Used for subqueries to find the actual collection being queried. + */ +function getInnermostCollectionId(query: QueryIR): string { + if (query.from.type === `collectionRef`) { + return query.from.collection.id + } + // Recurse into subquery + return getInnermostCollectionId(query.from.query) +} + /** * Determines if a source alias refers to a collection reference (not a subquery). * This is used to identify WHERE clauses that can be pushed down to collection subscriptions. diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 29bfce622..fd50fe7ac 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -282,6 +282,48 @@ export type CursorExpressions = { lastKey?: string | number } +/** + * Information about a join that should be performed server-side. + * This allows the sync layer to construct queries that join and filter + * before pagination, ensuring consistent page sizes. + */ +export type JoinInfo = { + /** The ID of the collection being joined */ + collectionId: string + + /** The alias used for the joined collection in the query */ + alias: string + + /** The type of join to perform */ + type: `inner` | `left` | `right` | `full` | `cross` + + /** + * The join key expression from the main collection. + * For example, in `eq(task.account_id, account.id)`, this would be `task.account_id`. + */ + localKey: BasicExpression + + /** + * The join key expression from the joined collection. + * For example, in `eq(task.account_id, account.id)`, this would be `account.id`. + */ + foreignKey: BasicExpression + + /** + * Filters that apply to the joined collection. + * These should be applied as part of the join condition or WHERE clause + * on the server side. + */ + where?: BasicExpression + + /** + * OrderBy expressions that reference the joined collection. + * If the query orders by a field from the joined collection, + * this information is needed for the server-side query. + */ + orderBy?: OrderBy +} + export type LoadSubsetOptions = { /** The where expression to filter the data (does NOT include cursor expressions) */ where?: BasicExpression @@ -309,6 +351,16 @@ export type LoadSubsetOptions = { * @optional Available when called from CollectionSubscription, may be undefined for direct calls */ subscription?: Subscription + /** + * Information about joins that should be performed server-side. + * When present, the sync layer can construct queries that join and filter + * before applying pagination, ensuring consistent page sizes. + * + * This is particularly important for on-demand mode where pagination + * would otherwise be applied before join filters, leading to + * inconsistent result counts. + */ + joins?: Array } export type LoadSubsetFn = (options: LoadSubsetOptions) => true | Promise @@ -804,6 +856,13 @@ export interface SubscribeChangesOptions< * @internal */ onStatusChange?: (event: SubscriptionStatusChangeEvent) => void + /** + * Join information for server-side query construction. + * When present, this is included in loadSubset calls so the sync layer + * can perform joins before pagination. + * @internal + */ + joinInfo?: Array } export interface SubscribeChangesSnapshotOptions< diff --git a/packages/db/tests/query/optimizer.test.ts b/packages/db/tests/query/optimizer.test.ts index 77ff8cbed..c33cfad4d 100644 --- a/packages/db/tests/query/optimizer.test.ts +++ b/packages/db/tests/query/optimizer.test.ts @@ -1856,4 +1856,205 @@ describe(`Query Optimizer`, () => { } }) }) + + describe(`Join Info Extraction`, () => { + test(`should return empty map for queries without joins`, () => { + const query: QueryIR = { + from: new CollectionRef(mockCollection, `u`), + where: [createEq(createPropRef(`u`, `status`), createValue(`active`))], + } + + const { joinInfoBySource } = optimizeQuery(query) + + expect(joinInfoBySource.size).toBe(0) + }) + + test(`should extract join info for simple inner join`, () => { + const usersCollection = { id: `users-collection` } as any + const postsCollection = { id: `posts-collection` } as any + + const query: QueryIR = { + from: new CollectionRef(usersCollection, `user`), + join: [ + { + from: new CollectionRef(postsCollection, `post`), + type: `inner`, + left: createPropRef(`user`, `id`), + right: createPropRef(`post`, `user_id`), + }, + ], + } + + const { joinInfoBySource } = optimizeQuery(query) + + // Join info should be keyed by main source alias + expect(joinInfoBySource.has(`user`)).toBe(true) + const userJoins = joinInfoBySource.get(`user`)! + expect(userJoins).toHaveLength(1) + + const joinInfo = userJoins[0]! + expect(joinInfo.collectionId).toBe(`posts-collection`) + expect(joinInfo.alias).toBe(`post`) + expect(joinInfo.type).toBe(`inner`) + expect(joinInfo.localKey).toEqual(createPropRef(`user`, `id`)) + expect(joinInfo.foreignKey).toEqual(createPropRef(`post`, `user_id`)) + expect(joinInfo.where).toBeUndefined() + expect(joinInfo.orderBy).toBeUndefined() + }) + + test(`should include WHERE clause for joined collection in join info`, () => { + const tasksCollection = { id: `tasks-collection` } as any + const accountsCollection = { id: `accounts-collection` } as any + + const query: QueryIR = { + from: new CollectionRef(tasksCollection, `task`), + join: [ + { + from: new CollectionRef(accountsCollection, `account`), + type: `inner`, + left: createPropRef(`task`, `account_id`), + right: createPropRef(`account`, `id`), + }, + ], + where: [ + // Filter on task (main collection) + createEq(createPropRef(`task`, `status`), createValue(`active`)), + // Filter on account (joined collection) + createEq(createPropRef(`account`, `name`), createValue(`Acme Corp`)), + ], + } + + const { joinInfoBySource, sourceWhereClauses } = optimizeQuery(query) + + // sourceWhereClauses should have the account filter + expect(sourceWhereClauses.has(`account`)).toBe(true) + expect(sourceWhereClauses.get(`account`)).toEqual( + createEq(createPropRef(`account`, `name`), createValue(`Acme Corp`)), + ) + + // joinInfoBySource should include the account filter in the join info + expect(joinInfoBySource.has(`task`)).toBe(true) + const taskJoins = joinInfoBySource.get(`task`)! + expect(taskJoins).toHaveLength(1) + + const joinInfo = taskJoins[0]! + expect(joinInfo.where).toEqual( + createEq(createPropRef(`account`, `name`), createValue(`Acme Corp`)), + ) + }) + + test(`should include orderBy for joined collection in join info`, () => { + const tasksCollection = { id: `tasks-collection` } as any + const accountsCollection = { id: `accounts-collection` } as any + + const query: QueryIR = { + from: new CollectionRef(tasksCollection, `task`), + join: [ + { + from: new CollectionRef(accountsCollection, `account`), + type: `inner`, + left: createPropRef(`task`, `account_id`), + right: createPropRef(`account`, `id`), + }, + ], + orderBy: [ + { + expression: createPropRef(`account`, `name`), + compareOptions: { direction: `asc`, nulls: `last` }, + }, + { + expression: createPropRef(`task`, `created_at`), + compareOptions: { direction: `desc`, nulls: `last` }, + }, + ], + } + + const { joinInfoBySource } = optimizeQuery(query) + + expect(joinInfoBySource.has(`task`)).toBe(true) + const taskJoins = joinInfoBySource.get(`task`)! + const joinInfo = taskJoins[0]! + + // Only the account orderBy should be in the join info + expect(joinInfo.orderBy).toHaveLength(1) + expect(joinInfo.orderBy![0]!.expression).toEqual( + createPropRef(`account`, `name`), + ) + }) + + test(`should handle multiple joins`, () => { + const ordersCollection = { id: `orders-collection` } as any + const customersCollection = { id: `customers-collection` } as any + const productsCollection = { id: `products-collection` } as any + + const query: QueryIR = { + from: new CollectionRef(ordersCollection, `order`), + join: [ + { + from: new CollectionRef(customersCollection, `customer`), + type: `inner`, + left: createPropRef(`order`, `customer_id`), + right: createPropRef(`customer`, `id`), + }, + { + from: new CollectionRef(productsCollection, `product`), + type: `left`, + left: createPropRef(`order`, `product_id`), + right: createPropRef(`product`, `id`), + }, + ], + where: [ + createEq(createPropRef(`customer`, `tier`), createValue(`premium`)), + ], + } + + const { joinInfoBySource } = optimizeQuery(query) + + expect(joinInfoBySource.has(`order`)).toBe(true) + const orderJoins = joinInfoBySource.get(`order`)! + expect(orderJoins).toHaveLength(2) + + // First join: customer + const customerJoin = orderJoins.find((j) => j.alias === `customer`)! + expect(customerJoin.collectionId).toBe(`customers-collection`) + expect(customerJoin.type).toBe(`inner`) + expect(customerJoin.where).toEqual( + createEq(createPropRef(`customer`, `tier`), createValue(`premium`)), + ) + + // Second join: product + const productJoin = orderJoins.find((j) => j.alias === `product`)! + expect(productJoin.collectionId).toBe(`products-collection`) + expect(productJoin.type).toBe(`left`) + expect(productJoin.where).toBeUndefined() + }) + + test(`should handle join with swapped key expressions`, () => { + const usersCollection = { id: `users-collection` } as any + const postsCollection = { id: `posts-collection` } as any + + // Join with right side referencing main, left side referencing joined + const query: QueryIR = { + from: new CollectionRef(usersCollection, `user`), + join: [ + { + from: new CollectionRef(postsCollection, `post`), + type: `inner`, + // Swapped: left is post (joined), right is user (main) + left: createPropRef(`post`, `user_id`), + right: createPropRef(`user`, `id`), + }, + ], + } + + const { joinInfoBySource } = optimizeQuery(query) + + const userJoins = joinInfoBySource.get(`user`)! + const joinInfo = userJoins[0]! + + // The extractor should correctly identify which is local vs foreign + expect(joinInfo.localKey).toEqual(createPropRef(`user`, `id`)) + expect(joinInfo.foreignKey).toEqual(createPropRef(`post`, `user_id`)) + }) + }) }) diff --git a/packages/query-db-collection/src/serialization.ts b/packages/query-db-collection/src/serialization.ts index 9849c4bd3..3d6e9cc1d 100644 --- a/packages/query-db-collection/src/serialization.ts +++ b/packages/query-db-collection/src/serialization.ts @@ -50,6 +50,28 @@ export function serializeLoadSubsetOptions( result.offset = options.offset } + // Include joins for server-side query construction + if (options.joins?.length) { + result.joins = options.joins.map((join) => ({ + collectionId: join.collectionId, + alias: join.alias, + type: join.type, + localKey: serializeExpression(join.localKey), + foreignKey: serializeExpression(join.foreignKey), + where: join.where ? serializeExpression(join.where) : undefined, + orderBy: join.orderBy?.map((clause) => ({ + expression: serializeExpression(clause.expression), + direction: clause.compareOptions.direction, + nulls: clause.compareOptions.nulls, + stringSort: clause.compareOptions.stringSort, + ...(clause.compareOptions.stringSort === `locale` && { + locale: clause.compareOptions.locale, + localeOptions: clause.compareOptions.localeOptions, + }), + })), + })) + } + return Object.keys(result).length === 0 ? undefined : JSON.stringify(result) }