Skip to content

Commit d27d32a

Browse files
samwilliskevin-dp
andauthored
asc/desc index bug (#623)
* add repo of asc/desc index bug * Fix bug with asc/desc indexes by tracking compare options in each index * Fix and add unit tests for ordering options * Modify BTreeIndex.take to properly handle null values * Fix unit test * Changeset --------- Co-authored-by: Kevin De Porre <kevin@electric-sql.com>
1 parent b38bd76 commit d27d32a

File tree

9 files changed

+375
-9
lines changed

9 files changed

+375
-9
lines changed

.changeset/quick-tigers-talk.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+
Fix bug where optimized queries would use the wrong index because the index is on the right column but was built using different comparison options (e.g. different direction, string sort, or null ordering).

packages/db/src/collection/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,10 @@ export class CollectionImpl<
428428
* // Create a ordered index with custom options
429429
* const ageIndex = collection.createIndex((row) => row.age, {
430430
* indexType: BTreeIndex,
431-
* options: { compareFn: customComparator },
431+
* options: {
432+
* compareFn: customComparator,
433+
* compareOptions: { direction: 'asc', nulls: 'first', stringSort: 'lexical' }
434+
* },
432435
* name: 'age_btree'
433436
* })
434437
*

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { DEFAULT_COMPARE_OPTIONS } from "../utils"
12
import { BTreeIndex } from "./btree-index"
3+
import type { CompareOptions } from "../query/builder/types"
24
import type { BasicExpression } from "../query/ir"
35
import type { CollectionImpl } from "../collection/index.js"
46

@@ -30,15 +32,18 @@ export function ensureIndexForField<
3032
fieldName: string,
3133
fieldPath: Array<string>,
3234
collection: CollectionImpl<T, TKey, any, any, any>,
35+
compareOptions: CompareOptions = DEFAULT_COMPARE_OPTIONS,
3336
compareFn?: (a: any, b: any) => number
3437
) {
3538
if (!shouldAutoIndex(collection)) {
3639
return
3740
}
3841

3942
// Check if we already have an index for this field
40-
const existingIndex = Array.from(collection.indexes.values()).find((index) =>
41-
index.matchesField(fieldPath)
43+
const existingIndex = Array.from(collection.indexes.values()).find(
44+
(index) =>
45+
index.matchesField(fieldPath) &&
46+
index.matchesCompareOptions(compareOptions)
4247
)
4348

4449
if (existingIndex) {
@@ -50,7 +55,7 @@ export function ensureIndexForField<
5055
collection.createIndex((row) => (row as any)[fieldName], {
5156
name: `auto_${fieldName}`,
5257
indexType: BTreeIndex,
53-
options: compareFn ? { compareFn } : {},
58+
options: compareFn ? { compareFn, compareOptions } : {},
5459
})
5560
} catch (error) {
5661
console.warn(`Failed to create auto-index for field "${fieldName}":`, error)

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { compileSingleRowExpression } from "../query/compiler/evaluators.js"
22
import { comparisonFunctions } from "../query/builder/functions.js"
3+
import { DEFAULT_COMPARE_OPTIONS, deepEquals } from "../utils.js"
4+
import type { CompareOptions } from "../query/builder/types.js"
35
import type { BasicExpression } from "../query/ir.js"
46

57
/**
@@ -36,6 +38,7 @@ export abstract class BaseIndex<
3638
protected lookupCount = 0
3739
protected totalLookupTime = 0
3840
protected lastUpdated = new Date()
41+
protected compareOptions: CompareOptions
3942

4043
constructor(
4144
id: number,
@@ -45,6 +48,7 @@ export abstract class BaseIndex<
4548
) {
4649
this.id = id
4750
this.expression = expression
51+
this.compareOptions = DEFAULT_COMPARE_OPTIONS
4852
this.name = name
4953
this.initialize(options)
5054
}
@@ -76,6 +80,10 @@ export abstract class BaseIndex<
7680
)
7781
}
7882

83+
matchesCompareOptions(compareOptions: CompareOptions): boolean {
84+
return deepEquals(this.compareOptions, compareOptions)
85+
}
86+
7987
getStats(): IndexStats {
8088
return {
8189
entryCount: this.keyCount,

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

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { BTree } from "../utils/btree.js"
22
import { defaultComparator, normalizeValue } from "../utils/comparison.js"
33
import { BaseIndex } from "./base-index.js"
4+
import type { CompareOptions } from "../query/builder/types.js"
45
import type { BasicExpression } from "../query/ir.js"
56
import type { IndexOperation } from "./base-index.js"
67

@@ -9,6 +10,7 @@ import type { IndexOperation } from "./base-index.js"
910
*/
1011
export interface BTreeIndexOptions {
1112
compareFn?: (a: any, b: any) => number
13+
compareOptions?: CompareOptions
1214
}
1315

1416
/**
@@ -53,6 +55,9 @@ export class BTreeIndex<
5355
) {
5456
super(id, expression, name, options)
5557
this.compareFn = options?.compareFn ?? defaultComparator
58+
if (options?.compareOptions) {
59+
this.compareOptions = options!.compareOptions
60+
}
5661
this.orderedEntries = new BTree(this.compareFn)
5762
}
5863

@@ -248,10 +253,12 @@ export class BTreeIndex<
248253
take(n: number, from?: any, filterFn?: (key: TKey) => boolean): Array<TKey> {
249254
const keysInResult: Set<TKey> = new Set()
250255
const result: Array<TKey> = []
251-
const nextKey = (k?: any) => this.orderedEntries.nextHigherKey(k)
256+
const nextPair = (k?: any) => this.orderedEntries.nextHigherPair(k)
257+
let pair: [any, any] | undefined
252258
let key = normalizeValue(from)
253259

254-
while ((key = nextKey(key)) && result.length < n) {
260+
while ((pair = nextPair(key)) !== undefined && result.length < n) {
261+
key = pair[0]
255262
const keys = this.valueMap.get(key)
256263
if (keys) {
257264
const it = keys.values()

packages/db/src/query/compiler/order-by.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ export function processOrderBy(
132132
fieldName,
133133
followRefResult.path,
134134
followRefCollection,
135+
clause.compareOptions,
135136
compare
136137
)
137138
}
@@ -152,7 +153,8 @@ export function processOrderBy(
152153

153154
const index: BaseIndex<string | number> | undefined = findIndexForField(
154155
followRefCollection.indexes,
155-
followRefResult.path
156+
followRefResult.path,
157+
clause.compareOptions
156158
)
157159

158160
if (index && index.supports(`gt`)) {

packages/db/src/utils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Generic utility functions
33
*/
44

5+
import type { CompareOptions } from "./query/builder/types"
6+
57
interface TypedArray {
68
length: number
79
[index: number]: number
@@ -209,3 +211,9 @@ export function isTemporal(a: any): boolean {
209211
const tag = getStringTag(a)
210212
return typeof tag === `string` && temporalTypes.includes(tag)
211213
}
214+
215+
export const DEFAULT_COMPARE_OPTIONS: CompareOptions = {
216+
direction: `asc`,
217+
nulls: `first`,
218+
stringSort: `locale`,
219+
}

packages/db/src/utils/index-optimization.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
* - Optimizes IN array expressions
1616
*/
1717

18+
import { DEFAULT_COMPARE_OPTIONS } from "../utils.js"
19+
import type { CompareOptions } from "../query/builder/types.js"
1820
import type { BaseIndex, IndexOperation } from "../indexes/base-index.js"
1921
import type { BasicExpression } from "../query/ir.js"
2022

@@ -31,10 +33,14 @@ export interface OptimizationResult<TKey> {
3133
*/
3234
export function findIndexForField<TKey extends string | number>(
3335
indexes: Map<number, BaseIndex<TKey>>,
34-
fieldPath: Array<string>
36+
fieldPath: Array<string>,
37+
compareOptions: CompareOptions = DEFAULT_COMPARE_OPTIONS
3538
): BaseIndex<TKey> | undefined {
3639
for (const index of indexes.values()) {
37-
if (index.matchesField(fieldPath)) {
40+
if (
41+
index.matchesField(fieldPath) &&
42+
index.matchesCompareOptions(compareOptions)
43+
) {
3844
return index
3945
}
4046
}

0 commit comments

Comments
 (0)