Skip to content

Commit 608be0c

Browse files
authored
automatically create indexes when a collection is queried (#292)
1 parent 360b0df commit 608be0c

15 files changed

+4092
-3346
lines changed

.changeset/petite-bars-lay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Added an auto-indexing system that creates indexes on collection eagerly when querying, this is a performance optimization that can be disabled by setting the autoIndex option to `off`.

packages/db/src/collection.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from "./query/builder/ref-proxy"
77
import { OrderedIndex } from "./indexes/ordered-index.js"
88
import { IndexProxy, LazyIndexWrapper } from "./indexes/lazy-index.js"
9+
import { ensureIndexForExpression } from "./indexes/auto-index.js"
910
import { createTransaction, getActiveTransaction } from "./transactions"
1011
import { createFilteredCallback, currentStateAsChanges } from "./change-events"
1112
import type { Transaction } from "./transactions"
@@ -426,7 +427,11 @@ export class CollectionImpl<
426427
a.compareCreatedAt(b)
427428
)
428429

429-
this.config = config
430+
// Set default values for optional config properties
431+
this.config = {
432+
...config,
433+
autoIndex: config.autoIndex ?? `eager`,
434+
}
430435

431436
// Store in global collections store
432437
collectionsStore.set(this.id, this)
@@ -1359,12 +1364,18 @@ export class CollectionImpl<
13591364

13601365
this.lazyIndexes.set(indexId, lazyIndex)
13611366

1362-
// For synchronous constructors (classes), resolve immediately
1363-
// For async loaders, wait for collection to be ready
1364-
if (typeof resolver === `function` && resolver.prototype) {
1365-
// This is a constructor - resolve immediately and synchronously
1367+
// For OrderedIndex, resolve immediately and synchronously
1368+
if ((resolver as unknown) === OrderedIndex) {
1369+
try {
1370+
const resolvedIndex = lazyIndex.getResolved()
1371+
this.resolvedIndexes.set(indexId, resolvedIndex)
1372+
} catch (error) {
1373+
console.warn(`Failed to resolve OrderedIndex:`, error)
1374+
}
1375+
} else if (typeof resolver === `function` && resolver.prototype) {
1376+
// Other synchronous constructors - resolve immediately
13661377
try {
1367-
const resolvedIndex = lazyIndex.getResolved() // This should work since constructor resolved it
1378+
const resolvedIndex = lazyIndex.getResolved()
13681379
this.resolvedIndexes.set(indexId, resolvedIndex)
13691380
} catch {
13701381
// Fallback to async resolution
@@ -2160,6 +2171,11 @@ export class CollectionImpl<
21602171
// Start sync and track subscriber
21612172
this.addSubscriber()
21622173

2174+
// Auto-index for where expressions if enabled
2175+
if (options.whereExpression) {
2176+
ensureIndexForExpression(options.whereExpression, this)
2177+
}
2178+
21632179
// Create a filtered callback if where clause is provided
21642180
const filteredCallback =
21652181
options.where || options.whereExpression

packages/db/src/indexes/auto-index.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { OrderedIndex } from "./ordered-index"
2+
import type { BasicExpression } from "../query/ir"
3+
import type { CollectionImpl } from "../collection"
4+
5+
export interface AutoIndexConfig {
6+
autoIndex?: `off` | `eager`
7+
}
8+
9+
/**
10+
* Analyzes a where expression and creates indexes for all simple operations on single fields
11+
*/
12+
export function ensureIndexForExpression<
13+
T extends Record<string, any>,
14+
TKey extends string | number,
15+
>(
16+
expression: BasicExpression,
17+
collection: CollectionImpl<T, TKey, any, any, any>
18+
): void {
19+
// Only proceed if auto-indexing is enabled
20+
if (collection.config.autoIndex !== `eager`) {
21+
return
22+
}
23+
24+
// Don't auto-index during sync operations
25+
if (
26+
collection.status === `loading` ||
27+
collection.status === `initialCommit`
28+
) {
29+
return
30+
}
31+
32+
// Extract all indexable expressions and create indexes for them
33+
const indexableExpressions = extractIndexableExpressions(expression)
34+
35+
for (const { fieldName, fieldPath } of indexableExpressions) {
36+
// Check if we already have an index for this field
37+
const existingIndex = Array.from(collection.indexes.values()).find(
38+
(index) => index.matchesField(fieldPath)
39+
)
40+
41+
if (existingIndex) {
42+
continue // Index already exists
43+
}
44+
45+
// Create a new index for this field using the collection's createIndex method
46+
try {
47+
collection.createIndex((row) => (row as any)[fieldName], {
48+
name: `auto_${fieldName}`,
49+
indexType: OrderedIndex,
50+
})
51+
} catch (error) {
52+
console.warn(
53+
`Failed to create auto-index for field "${fieldName}":`,
54+
error
55+
)
56+
}
57+
}
58+
}
59+
60+
/**
61+
* Extracts all indexable expressions from a where expression
62+
*/
63+
function extractIndexableExpressions(
64+
expression: BasicExpression
65+
): Array<{ fieldName: string; fieldPath: Array<string> }> {
66+
const results: Array<{ fieldName: string; fieldPath: Array<string> }> = []
67+
68+
function extractFromExpression(expr: BasicExpression): void {
69+
if (expr.type !== `func`) {
70+
return
71+
}
72+
73+
const func = expr as any
74+
75+
// Handle 'and' expressions by recursively processing all arguments
76+
if (func.name === `and`) {
77+
for (const arg of func.args) {
78+
extractFromExpression(arg)
79+
}
80+
return
81+
}
82+
83+
// Check if this is a supported operation
84+
const supportedOperations = [`eq`, `gt`, `gte`, `lt`, `lte`, `in`]
85+
if (!supportedOperations.includes(func.name)) {
86+
return
87+
}
88+
89+
// Check if the first argument is a property reference (single field)
90+
if (func.args.length < 1 || func.args[0].type !== `ref`) {
91+
return
92+
}
93+
94+
const fieldRef = func.args[0]
95+
const fieldPath = fieldRef.path
96+
97+
// Skip if it's not a simple field (e.g., nested properties or array access)
98+
if (fieldPath.length !== 1) {
99+
return
100+
}
101+
102+
const fieldName = fieldPath[0]
103+
results.push({ fieldName, fieldPath })
104+
}
105+
106+
extractFromExpression(expression)
107+
return results
108+
}

packages/db/src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,15 @@ export interface CollectionConfig<
377377
* Defaults to false for lazy loading. Set to true to immediately sync.
378378
*/
379379
startSync?: boolean
380+
/**
381+
* Auto-indexing mode for the collection.
382+
* When enabled, indexes will be automatically created for simple where expressions.
383+
* @default "eager"
384+
* @description
385+
* - "off": No automatic indexing
386+
* - "eager": Automatically create indexes for simple where expressions in subscribeChanges (default)
387+
*/
388+
autoIndex?: `off` | `eager`
380389
/**
381390
* Optional function to compare two items.
382391
* This is used to order the items in the collection.

0 commit comments

Comments
 (0)