diff --git a/.changeset/fix-or-partial-union.md b/.changeset/fix-or-partial-union.md new file mode 100644 index 0000000000..ebbf80d5ae --- /dev/null +++ b/.changeset/fix-or-partial-union.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +fix(db): require all or() branches to be index-optimizable before using index result diff --git a/packages/db/src/utils/index-optimization.ts b/packages/db/src/utils/index-optimization.ts index 81b111af56..30c87c4830 100644 --- a/packages/db/src/utils/index-optimization.ts +++ b/packages/db/src/utils/index-optimization.ts @@ -469,22 +469,17 @@ function optimizeOrExpression( const results: Array> = [] - // Try to optimize each part, keep the optimizable ones for (const arg of expression.args) { const result = optimizeQueryRecursive(arg, collection) - if (result.canOptimize) { - results.push(result) + if (!result.canOptimize) { + return { canOptimize: false, matchingKeys: new Set() } } + results.push(result) } - if (results.length > 0) { - // Use unionSets utility for OR logic - const allMatchingSets = results.map((r) => r.matchingKeys) - const unionedKeys = unionSets(allMatchingSets) - return { canOptimize: true, matchingKeys: unionedKeys } - } - - return { canOptimize: false, matchingKeys: new Set() } + const allMatchingSets = results.map((r) => r.matchingKeys) + const unionedKeys = unionSets(allMatchingSets) + return { canOptimize: true, matchingKeys: unionedKeys } } /** @@ -498,8 +493,8 @@ function canOptimizeOrExpression< return false } - // If any argument can be optimized, we can gain some speedup - return expression.args.some((arg) => canOptimizeExpression(arg, collection)) + // All branches must be optimizable — partial OR optimization is unsound + return expression.args.every((arg) => canOptimizeExpression(arg, collection)) } /** diff --git a/packages/db/tests/query/or-partial-optimization.test.ts b/packages/db/tests/query/or-partial-optimization.test.ts new file mode 100644 index 0000000000..56d89990a3 --- /dev/null +++ b/packages/db/tests/query/or-partial-optimization.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { BasicIndex } from '../../src/indexes/basic-index.js' +import { createLiveQueryCollection } from '../../src/query/live-query-collection' +import { eq, or } from '../../src/query/builder/functions' +import { mockSyncCollectionOptions } from '../utils' + +interface TestItem { + id: string + category: string + tag: string +} + +const testData: Array = [ + { id: `1`, category: `A`, tag: `x` }, + { id: `2`, category: `B`, tag: `y` }, + { id: `3`, category: `A`, tag: `z` }, + { id: `4`, category: `C`, tag: `x` }, +] + +describe(`or() with partially indexed branches`, () => { + it(`returns all matching rows when only one or() branch is indexed`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `or-partial-index`, + getKey: (item) => item.id, + initialData: testData, + autoIndex: `off`, + }), + ) + + await collection.stateWhenReady() + + collection.createIndex((row) => row.category, { + indexType: BasicIndex, + }) + + const liveQuery = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => + or(eq(item.category, `A`), eq(item.tag, `x`)), + ) + .select(({ item }) => ({ + id: item.id, + category: item.category, + tag: item.tag, + })), + startSync: true, + }) + + await liveQuery.stateWhenReady() + + const ids = liveQuery.toArray.map((r) => r.id).sort() + expect(ids).toEqual([`1`, `3`, `4`]) + }) +})