From 08b140bfcf03b653afa5d1668ffc723ff73d8a81 Mon Sep 17 00:00:00 2001 From: Anton Vozghrin Date: Tue, 2 Jun 2026 15:13:12 +0300 Subject: [PATCH] fix(db): canonicalize inArray value order in normalizeExpressionPaths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inArray is set membership, but normalizeExpressionPaths only canonicalized ref paths, not value order, so the same value set in a different order produced a distinct serialized predicate (and loadSubset queryKey/cache key for adapters that key by it) and refetched identical data. normalizeExpressionPaths now also sorts in value arrays, and the join lazy-load predicate — previously built and combined without normalization — is routed through it so its keys are canonicalized too. --- .changeset/fix-canonicalize-in-values.md | 13 ++ packages/db/src/query/compiler/expressions.ts | 25 ++-- packages/db/src/query/compiler/joins.ts | 6 +- .../query/canonicalize-in-values.test.ts | 116 ++++++++++++++++++ 4 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-canonicalize-in-values.md create mode 100644 packages/db/tests/query/canonicalize-in-values.test.ts diff --git a/.changeset/fix-canonicalize-in-values.md b/.changeset/fix-canonicalize-in-values.md new file mode 100644 index 000000000..ccd900aff --- /dev/null +++ b/.changeset/fix-canonicalize-in-values.md @@ -0,0 +1,13 @@ +--- +'@tanstack/db': patch +--- + +fix(db): canonicalize `inArray` value order in `normalizeExpressionPaths` + +`inArray` is set membership, but `normalizeExpressionPaths` only canonicalized +ref paths, not value order — so the same value set in a different order produced +a distinct serialized predicate (and `loadSubset` queryKey / cache key for +adapters that key by it) and refetched identical data. `normalizeExpressionPaths` +now also sorts `in` value arrays, and the join lazy-load predicate (previously +built and combined without normalization) is routed through it so its keys are +canonicalized too. diff --git a/packages/db/src/query/compiler/expressions.ts b/packages/db/src/query/compiler/expressions.ts index f2856ed7e..c5dbd5965 100644 --- a/packages/db/src/query/compiler/expressions.ts +++ b/packages/db/src/query/compiler/expressions.ts @@ -1,18 +1,21 @@ import { Func, PropRef, Value } from '../ir.js' +import { defaultComparator } from '../../utils/comparison.js' import type { BasicExpression, OrderBy } from '../ir.js' /** - * Normalizes a WHERE clause expression by removing table aliases from property references. + * Normalizes a WHERE clause expression into a canonical form. * - * This function recursively traverses an expression tree and creates new BasicExpression - * instances with normalized paths. The main transformation is removing the collection alias - * from property reference paths (e.g., `['user', 'id']` becomes `['id']` when `collectionAlias` - * is `'user'`), which is needed when converting query-level expressions to collection-level - * expressions for subscriptions. + * Recursively traverses an expression tree and creates new BasicExpression instances with: + * - **Normalized paths** — the collection alias is removed from property reference paths + * (e.g. `['user', 'id']` becomes `['id']` when `collectionAlias` is `'user'`), needed when + * converting query-level expressions to collection-level expressions for subscriptions. + * - **Canonical set-membership order** — `in` value arrays are sorted. `in` is unordered, so + * without this the same value set in a different order produces a distinct serialized + * predicate (and `loadSubset` queryKey / cache key) and refetches identical data. * * @param whereClause - The WHERE clause expression to normalize * @param collectionAlias - The alias of the collection being filtered (to strip from paths) - * @returns A new BasicExpression with normalized paths + * @returns A new, canonicalized BasicExpression * * @example * // Input: ref with path ['user', 'id'] where collectionAlias is 'user' @@ -48,6 +51,14 @@ export function normalizeExpressionPaths( ) args.push(convertedArg) } + if ( + whereClause.name === `in` && + args.length === 2 && + args[1]?.type === `val` && + Array.isArray(args[1].value) + ) { + args[1] = new Value([...args[1].value].sort(defaultComparator)) + } return new Func(whereClause.name, args) } } diff --git a/packages/db/src/query/compiler/joins.ts b/packages/db/src/query/compiler/joins.ts index 0c37e05f4..1c0f91338 100644 --- a/packages/db/src/query/compiler/joins.ts +++ b/packages/db/src/query/compiler/joins.ts @@ -16,6 +16,7 @@ import { ensureIndexForField } from '../../indexes/auto-index.js' import { PropRef } from '../ir.js' import { inArray } from '../builder/functions.js' import { compileExpression } from './evaluators.js' +import { normalizeExpressionPaths } from './expressions.js' import { getLazyLoadTargets } from './lazy-targets.js' import type { CompileQueryFn } from './index.js' import type { OrderByOptimizationInfo } from './order-by.js' @@ -310,7 +311,10 @@ function processJoin( const lazyJoinRef = new PropRef(target.path) const loaded = lazySourceSubscription.requestSnapshot({ - where: inArray(lazyJoinRef, joinKeys), + where: normalizeExpressionPaths( + inArray(lazyJoinRef, joinKeys), + target.alias, + ), optimizedOnly: true, }) diff --git a/packages/db/tests/query/canonicalize-in-values.test.ts b/packages/db/tests/query/canonicalize-in-values.test.ts new file mode 100644 index 000000000..a97cf702d --- /dev/null +++ b/packages/db/tests/query/canonicalize-in-values.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { createLiveQueryCollection, inArray } from '../../src/query/index.js' +import { createCollection } from '../../src/collection/index.js' +import { BasicIndex } from '../../src/indexes/basic-index.js' +import { extractSimpleComparisons } from '../../src/query/expression-helpers.js' +import type { LoadSubsetOptions } from '../../src/types.js' + +type Child = { id: number; parentId: number; title: string } + +const sampleChildren: Array = [ + { id: 10, parentId: 1, title: `Child A1` }, + { id: 20, parentId: 2, title: `Child B1` }, + { id: 30, parentId: 3, title: `Child C1` }, +] + +function createChildrenCollectionWithTracking() { + const loadSubsetCalls: Array = [] + + const collection = createCollection({ + id: `canon-children`, + getKey: (child) => child.id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BasicIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const child of sampleChildren) { + write({ type: `insert`, value: child }) + } + commit() + markReady() + return { + loadSubset: vi.fn((options: LoadSubsetOptions) => { + loadSubsetCalls.push(options) + return Promise.resolve() + }), + } + }, + }, + }) + + return { collection, loadSubsetCalls } +} + +function firstInArrayValues(loadSubsetCalls: Array) { + expect(loadSubsetCalls.length).toBeGreaterThan(0) + const filters = extractSimpleComparisons(loadSubsetCalls[0]!.where) + const inFilter = filters.find((f) => f.operator === `in`) + expect(inFilter).toBeDefined() + return inFilter!.value +} + +describe(`canonicalize inArray value order`, () => { + it(`sorts inArray values in the predicate passed to loadSubset`, async () => { + const { collection: children, loadSubsetCalls } = + createChildrenCollectionWithTracking() + + // Out-of-order literal — survives normalizeExpressionPaths unsorted, so + // without canonicalization loadSubset would receive [3, 1, 2]. + const liveQuery = createLiveQueryCollection((q) => + q.from({ c: children }).where(({ c }) => inArray(c.parentId, [3, 1, 2])), + ) + + await liveQuery.preload() + + expect(firstInArrayValues(loadSubsetCalls)).toEqual([1, 2, 3]) + }) + + it(`sorts multi-digit numbers numerically, not lexicographically`, async () => { + const { collection: children, loadSubsetCalls } = + createChildrenCollectionWithTracking() + + // A lexicographic `.sort()` would yield [1, 10, 2]; the value comparator + // must sort these numerically to [1, 2, 10]. + const liveQuery = createLiveQueryCollection((q) => + q.from({ c: children }).where(({ c }) => inArray(c.parentId, [10, 2, 1])), + ) + + await liveQuery.preload() + + expect(firstInArrayValues(loadSubsetCalls)).toEqual([1, 2, 10]) + }) + + it(`leaves a single-element inArray unchanged`, async () => { + const { collection: children, loadSubsetCalls } = + createChildrenCollectionWithTracking() + + const liveQuery = createLiveQueryCollection((q) => + q.from({ c: children }).where(({ c }) => inArray(c.parentId, [7])), + ) + + await liveQuery.preload() + + expect(firstInArrayValues(loadSubsetCalls)).toEqual([7]) + }) + + it(`sorts inArray values for ordered/limited queries too`, async () => { + const { collection: children, loadSubsetCalls } = + createChildrenCollectionWithTracking() + + // orderBy + limit takes the requestLimitedSnapshot path; the where is still + // canonicalized because it is normalized at subscription creation. + const liveQuery = createLiveQueryCollection((q) => + q + .from({ c: children }) + .where(({ c }) => inArray(c.parentId, [3, 1, 2])) + .orderBy(({ c }) => c.id) + .limit(2), + ) + + await liveQuery.preload() + + expect(firstInArrayValues(loadSubsetCalls)).toEqual([1, 2, 3]) + }) +})