diff --git a/.changeset/orderby-index-compare-options-warning.md b/.changeset/orderby-index-compare-options-warning.md new file mode 100644 index 0000000000..905e70d37a --- /dev/null +++ b/.changeset/orderby-index-compare-options-warning.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Report a `compareOptions` mismatch in the `orderBy` with `limit` lazy-loading warning. When an index on the ordered field exists but was built with different `compareOptions` (for example `nulls: 'last'` against an index with the default `nulls: 'first'`), the warning now says so and shows the `createIndex` call with matching `compareOptions`, instead of claiming that no index exists. diff --git a/packages/db/src/query/compiler/order-by.ts b/packages/db/src/query/compiler/order-by.ts index 9a616b506f..e4d24a6439 100644 --- a/packages/db/src/query/compiler/order-by.ts +++ b/packages/db/src/query/compiler/order-by.ts @@ -211,12 +211,31 @@ export function processOrderBy( if (devBuild() && process.env.NODE_ENV !== `production` && !index) { const collectionId = followRefCollection.id const fieldPath = followRefResult.path.join(`.`) - console.warn( - `[TanStack DB]${collectionId ? ` [${collectionId}]` : ``} orderBy with limit requires an index on "${fieldPath}" for efficient lazy loading. ` + - `Falling back to loading all data. ` + - `Consider creating an index on the collection with collection.createIndex((row) => row.${fieldPath}) ` + - `or enable auto-indexing with autoIndex: 'eager' and a defaultIndexType.`, - ) + // An index on the field may exist but be sorted with other + // compareOptions, which findIndexForField rejects. + const fieldIndexes = Array.from( + followRefCollection.indexes.values(), + ).filter((candidate) => candidate.matchesField(followRefResult.path)) + const compareOptionsMismatch = + fieldIndexes.length > 0 && + !fieldIndexes.some((candidate) => + candidate.matchesCompareOptions(compareOpts), + ) + if (compareOptionsMismatch) { + const queryCompareOptions = JSON.stringify(compareOpts) + console.warn( + `[TanStack DB]${collectionId ? ` [${collectionId}]` : ``} orderBy with limit found an index on "${fieldPath}", but its compareOptions don't match the query's (${queryCompareOptions}), so it cannot be used for efficient lazy loading. ` + + `Falling back to loading all data. ` + + `Consider creating an index with matching compareOptions with collection.createIndex((row) => row.${fieldPath}, { options: { compareOptions: ${queryCompareOptions} } }).`, + ) + } else { + console.warn( + `[TanStack DB]${collectionId ? ` [${collectionId}]` : ``} orderBy with limit requires an index on "${fieldPath}" for efficient lazy loading. ` + + `Falling back to loading all data. ` + + `Consider creating an index on the collection with collection.createIndex((row) => row.${fieldPath}) ` + + `or enable auto-indexing with autoIndex: 'eager' and a defaultIndexType.`, + ) + } } orderByAlias = diff --git a/packages/db/tests/fixtures/development-only-messages.json b/packages/db/tests/fixtures/development-only-messages.json index 6624b4b3a3..444e4d8b40 100644 --- a/packages/db/tests/fixtures/development-only-messages.json +++ b/packages/db/tests/fixtures/development-only-messages.json @@ -52,6 +52,12 @@ { "file": "query/compiler/order-by.ts", "literals": [ + "[TanStack DB]", + " orderBy with limit found an index on \"", + "\", but its compareOptions don't match the query's (", + "), so it cannot be used for efficient lazy loading. ", + "Falling back to loading all data. ", + "Consider creating an index with matching compareOptions with collection.createIndex((row) => row.", "[TanStack DB]", " orderBy with limit requires an index on \"", "\" for efficient lazy loading. ", diff --git a/packages/db/tests/query/order-by.test.ts b/packages/db/tests/query/order-by.test.ts index ba55fbc209..96fc14b679 100644 --- a/packages/db/tests/query/order-by.test.ts +++ b/packages/db/tests/query/order-by.test.ts @@ -1,5 +1,6 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' +import { BasicIndex } from '../../src/indexes/basic-index.js' import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' import { @@ -3366,3 +3367,117 @@ describe(`OrderBy with Date values and precision differences`, () => { expect(ltValue.getTime() - gteValue.getTime()).toBe(1) // 1ms difference }) }) + +describe(`orderBy with limit index warning`, () => { + type Client = { id: number; name: string } + + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, `warn`).mockImplementation(() => {}) + }) + + afterEach(() => { + warnSpy.mockRestore() + }) + + // An index on the ordered field whose compareOptions differ from the + // query's cannot serve the ordered prefix, so the query falls back to a full + // load. The warning must say the index was skipped for its compareOptions, + // not that no index exists. + it(`reports a compareOptions mismatch when an index on the field exists`, async () => { + const clients = createCollection( + mockSyncCollectionOptions({ + id: `order-by-mismatched-index-clients`, + getKey: (row) => row.id, + autoIndex: `off`, + initialData: [ + { id: 1, name: `Charlie` }, + { id: 2, name: `Alice` }, + { id: 3, name: `Bob` }, + ], + }), + ) + clients.createIndex((row) => row.name, { indexType: BasicIndex }) + + const query = createLiveQueryCollection((q) => + q + .from({ client: clients }) + .orderBy(({ client }) => client.name, { + direction: `asc`, + nulls: `last`, + }) + .limit(2), + ) + await query.preload() + + expect(query.toArray.map((row) => row.name)).toEqual([`Alice`, `Bob`]) + + const messages = warnSpy.mock.calls.map((call) => String(call[0])) + expect(messages.some((m) => m.includes(`requires an index`))).toBe(false) + expect(messages).toContainEqual( + expect.stringContaining( + `[order-by-mismatched-index-clients] orderBy with limit found an index on "name", but its compareOptions don't match the query's`, + ), + ) + expect(messages).toContainEqual( + expect.stringContaining( + `collection.createIndex((row) => row.name, { options: { compareOptions: {"stringSort":"locale","direction":"asc","nulls":"last"} } })`, + ), + ) + + // The suggested index serves the same query, so it no longer warns. + warnSpy.mockClear() + clients.createIndex((row) => row.name, { + indexType: BasicIndex, + options: { + compareOptions: { + stringSort: `locale`, + direction: `asc`, + nulls: `last`, + }, + }, + }) + const indexedQuery = createLiveQueryCollection((q) => + q + .from({ client: clients }) + .orderBy(({ client }) => client.name, { + direction: `asc`, + nulls: `last`, + }) + .limit(2), + ) + await indexedQuery.preload() + + expect(indexedQuery.toArray.map((row) => row.name)).toEqual([ + `Alice`, + `Bob`, + ]) + expect(warnSpy).not.toHaveBeenCalled() + }) + + it(`keeps the missing-index warning when no index on the field exists`, async () => { + const clients = createCollection( + mockSyncCollectionOptions({ + id: `order-by-missing-index-clients`, + getKey: (row) => row.id, + autoIndex: `off`, + initialData: [{ id: 1, name: `Alice` }], + }), + ) + + const query = createLiveQueryCollection((q) => + q + .from({ client: clients }) + .orderBy(({ client }) => client.name) + .limit(2), + ) + await query.preload() + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining( + `[order-by-missing-index-clients] orderBy with limit requires an index on "name"`, + ), + ) + }) +})