Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-load-subset-order-by-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Fix `loadSubset` orderBy hints for subqueries with computed selected fields. We now stop ref-following on non-ref select expressions and only pass `orderBy`/`limit` hints when each `orderBy` ref resolves to a real source field.
14 changes: 8 additions & 6 deletions packages/db/src/query/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1107,15 +1107,17 @@ export function followRef(
// is it part of the select clause?
if (query.select) {
const selectedField = query.select[field]
if (selectedField && selectedField.type === `ref`) {
return followRef(query, selectedField, collection)
if (selectedField) {
// Only pass-through refs can be followed to a raw collection field.
// Computed select expressions do not map to a source column.
if (selectedField.type === `ref`) {
return followRef(query, selectedField, collection)
}
return
}
}

// Either this field is not part of the select clause
// and thus it must be part of the collection itself
// or it is part of the select but is not a reference
// so we can stop here and don't have to follow it
// Field is not projected by select, so it belongs to the collection itself.
return { collection, path: [field] }
}

Expand Down
14 changes: 8 additions & 6 deletions packages/db/src/query/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,15 +246,17 @@ export function followRef(
// is it part of the select clause?
if (query.select) {
const selectedField = query.select[field]
if (selectedField && selectedField.type === `ref`) {
return followRef(query, selectedField, collection)
if (selectedField) {
// Only pass-through refs can be followed to a raw collection field.
// Computed select expressions do not map to a source column.
if (selectedField.type === `ref`) {
return followRef(query, selectedField, collection)
}
return
}
}

// Either this field is not part of the select clause
// and thus it must be part of the collection itself
// or it is part of the select but is not a reference
// so we can stop here and don't have to follow it
// Field is not projected by select, so it belongs to the collection itself.
return { collection, path: [field] }
}

Expand Down
19 changes: 13 additions & 6 deletions packages/db/src/query/live/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { MultiSet, serializeValue } from '@tanstack/db-ivm'
import { UnsupportedRootScalarSelectError } from '../../errors.js'
import { normalizeOrderByPaths } from '../compiler/expressions.js'
import { buildQuery, getQueryIR } from '../builder/index.js'
import { IncludesSubquery } from '../ir.js'
import { IncludesSubquery, followRef } from '../ir.js'
import type { MultiSetArray, RootStreamBuilder } from '@tanstack/db-ivm'
import type { Collection } from '../../collection/index.js'
import type { ChangeMessage } from '../../types.js'
Expand Down Expand Up @@ -334,7 +334,7 @@ export function trackBiggestSentValue(
* be scoped to the given alias (e.g. cross-collection refs or aggregates).
*/
export function computeSubscriptionOrderByHints(
query: { orderBy?: OrderBy; limit?: number; offset?: number },
query: QueryIR,
alias: string,
): { orderBy: OrderBy | undefined; limit: number | undefined } {
const { orderBy, limit, offset } = query
Expand All @@ -345,14 +345,21 @@ export function computeSubscriptionOrderByHints(
? normalizeOrderByPaths(orderBy, alias)
: undefined

// Only pass orderBy when it is scoped to this alias and uses simple refs,
// to avoid leaking cross-collection paths into backend-specific compilers.
// Only pass orderBy when:
// 1) it is scoped to this alias and uses simple refs, and
// 2) each ref can be traced to a raw source field (not a computed alias).
const rootCollection = extractCollectionFromSource(query)
const canPassOrderBy =
normalizedOrderBy?.every((clause) => {
normalizedOrderBy?.every((clause, index) => {
const exp = clause.expression
if (exp.type !== `ref`) return false
const path = exp.path
return Array.isArray(path) && path.length === 1
if (!(Array.isArray(path) && path.length === 1)) return false

const originalExp = orderBy?.[index]?.expression
if (!originalExp || originalExp.type !== `ref`) return false

return !!followRef(query, originalExp, rootCollection)
}) ?? false

return {
Expand Down
31 changes: 31 additions & 0 deletions packages/db/tests/query/load-subset-subquery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createCollection } from '../../src/collection/index.js'
import {
and,
coalesce,
createLiveQueryCollection,
eq,
gte,
Expand Down Expand Up @@ -265,4 +266,34 @@ describe(`loadSubset with subqueries`, () => {

expect(lastCall!.orderBy).toEqual(expectedOrderBy)
})

it(`does not pass orderBy/limit to loadSubset for computed subquery orderBy`, async () => {
const { collection: ordersCollection, loadSubsetCalls } =
createOrdersCollectionWithTracking()

const subqueryQuery = createLiveQueryCollection((q) => {
const prepaidOrderQ = q
.from({ prepaidOrder: ordersCollection })
.select(({ prepaidOrder }) => ({
address_id: prepaidOrder.address_id,
sortKey: coalesce(prepaidOrder.scheduled_at, `1970-01-01`),
}))
.orderBy(({ $selected }) => $selected.sortKey, `desc`)
.limit(2)

return q
.from({ charge: chargesCollection })
.fullJoin({ prepaidOrder: prepaidOrderQ }, ({ charge, prepaidOrder }) =>
eq(charge.address_id, prepaidOrder.address_id),
)
})

await subqueryQuery.preload()

expect(loadSubsetCalls.length).toBeGreaterThan(0)
const lastCall = loadSubsetCalls[loadSubsetCalls.length - 1]
expect(lastCall).toBeDefined()
expect(lastCall!.orderBy).toBeUndefined()
expect(lastCall!.limit).toBeUndefined()
})
})