Skip to content

Commit 97b595e

Browse files
authored
Configuration options for sortBy (#314)
1 parent 7d4fd14 commit 97b595e

File tree

10 files changed

+633
-49
lines changed

10 files changed

+633
-49
lines changed

.changeset/late-icons-fly.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+
Add option to configure how orderBy compares values. This includes ascending/descending order, ordering of null values, and lexical vs locale comparison for strings.

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { ascComparator } from "../utils/comparison.js"
21
import { BTree } from "../utils/btree.js"
2+
import { defaultComparator } from "../utils/comparison.js"
33
import { BaseIndex } from "./base-index.js"
44
import type { BasicExpression } from "../query/ir.js"
55
import type { IndexOperation } from "./base-index.js"
@@ -43,7 +43,7 @@ export class BTreeIndex<
4343
private orderedEntries: BTree<any, undefined> // we don't associate values with the keys of the B+ tree (the keys are indexed values)
4444
private valueMap = new Map<any, Set<TKey>>() // instead we store a mapping of indexed values to a set of PKs
4545
private indexedKeys = new Set<TKey>()
46-
private compareFn: (a: any, b: any) => number = ascComparator
46+
private compareFn: (a: any, b: any) => number = defaultComparator
4747

4848
constructor(
4949
id: number,
@@ -52,7 +52,7 @@ export class BTreeIndex<
5252
options?: any
5353
) {
5454
super(id, expression, name, options)
55-
this.compareFn = options?.compareFn ?? ascComparator
55+
this.compareFn = options?.compareFn ?? defaultComparator
5656
this.orderedEntries = new BTree(this.compareFn)
5757
}
5858

packages/db/src/query/builder/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,14 @@ import type {
1919
QueryIR,
2020
} from "../ir.js"
2121
import type {
22+
CompareOptions,
2223
Context,
2324
GroupByCallback,
2425
JoinOnCallback,
2526
MergeContext,
2627
MergeContextWithJoinType,
2728
OrderByCallback,
29+
OrderByOptions,
2830
RefProxyForContext,
2931
ResultTypeFromSelect,
3032
SchemaFromSource,
@@ -478,16 +480,31 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
478480
*/
479481
orderBy(
480482
callback: OrderByCallback<TContext>,
481-
direction: OrderByDirection = `asc`
483+
options: OrderByDirection | OrderByOptions = `asc`
482484
): QueryBuilder<TContext> {
483485
const aliases = this._getCurrentAliases()
484486
const refProxy = createRefProxy(aliases) as RefProxyForContext<TContext>
485487
const result = callback(refProxy)
486488

489+
const opts: CompareOptions =
490+
typeof options === `string`
491+
? { direction: options, nulls: `first`, stringSort: `locale` }
492+
: {
493+
direction: options.direction ?? `asc`,
494+
nulls: options.nulls ?? `first`,
495+
stringSort: options.stringSort ?? `locale`,
496+
locale:
497+
options.stringSort === `locale` ? options.locale : undefined,
498+
localeOptions:
499+
options.stringSort === `locale`
500+
? options.localeOptions
501+
: undefined,
502+
}
503+
487504
// Create the new OrderBy structure with expression and direction
488505
const orderByClause: OrderByClause = {
489506
expression: toExpression(result),
490-
direction,
507+
compareOptions: opts,
491508
}
492509

493510
const existingOrderBy: OrderBy = this.query.orderBy || []

packages/db/src/query/builder/types.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CollectionImpl } from "../../collection.js"
2-
import type { Aggregate, BasicExpression } from "../ir.js"
2+
import type { Aggregate, BasicExpression, OrderByDirection } from "../ir.js"
33
import type { QueryBuilder } from "./index.js"
44

55
export interface Context {
@@ -75,6 +75,29 @@ export type OrderByCallback<TContext extends Context> = (
7575
refs: RefProxyForContext<TContext>
7676
) => any
7777

78+
export type OrderByOptions = {
79+
direction?: OrderByDirection
80+
nulls?: `first` | `last`
81+
} & StringSortOpts
82+
83+
export type StringSortOpts =
84+
| {
85+
stringSort?: `lexical`
86+
}
87+
| {
88+
stringSort?: `locale`
89+
locale?: string
90+
localeOptions?: object
91+
}
92+
93+
export type CompareOptions = {
94+
direction: OrderByDirection
95+
nulls: `first` | `last`
96+
stringSort: `lexical` | `locale`
97+
locale?: string
98+
localeOptions?: object
99+
}
100+
78101
// Callback type for groupBy clauses
79102
export type GroupByCallback<TContext extends Context> = (
80103
refs: RefProxyForContext<TContext>

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

Lines changed: 22 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { orderByWithFractionalIndex } from "@tanstack/db-ivm"
2-
import { ascComparator, descComparator } from "../../utils/comparison.js"
2+
import { defaultComparator, makeComparator } from "../../utils/comparison.js"
33
import { compileExpression } from "./evaluators.js"
44
import type { OrderByClause } from "../ir.js"
55
import type { NamespacedAndKeyedStream, NamespacedRow } from "../../types.js"
@@ -19,7 +19,7 @@ export function processOrderBy(
1919
// Pre-compile all order by expressions
2020
const compiledOrderBy = orderByClause.map((clause) => ({
2121
compiledExpression: compileExpression(clause.expression),
22-
direction: clause.direction,
22+
compareOptions: clause.compareOptions,
2323
}))
2424

2525
// Create a value extractor function for the orderBy operator
@@ -53,35 +53,31 @@ export function processOrderBy(
5353
}
5454

5555
// Create a multi-property comparator that respects the order and direction of each property
56-
const makeComparator = () => {
57-
return (a: unknown, b: unknown) => {
58-
// If we're comparing arrays (multiple properties), compare each property in order
59-
if (orderByClause.length > 1) {
60-
const arrayA = a as Array<unknown>
61-
const arrayB = b as Array<unknown>
62-
for (let i = 0; i < orderByClause.length; i++) {
63-
const direction = orderByClause[i]!.direction
64-
const compareFn =
65-
direction === `desc` ? descComparator : ascComparator
66-
const result = compareFn(arrayA[i], arrayB[i])
67-
if (result !== 0) {
68-
return result
69-
}
56+
const comparator = (a: unknown, b: unknown) => {
57+
// If we're comparing arrays (multiple properties), compare each property in order
58+
if (orderByClause.length > 1) {
59+
const arrayA = a as Array<unknown>
60+
const arrayB = b as Array<unknown>
61+
for (let i = 0; i < orderByClause.length; i++) {
62+
const clause = orderByClause[i]!
63+
const compareFn = makeComparator(clause.compareOptions)
64+
const result = compareFn(arrayA[i], arrayB[i])
65+
if (result !== 0) {
66+
return result
7067
}
71-
return arrayA.length - arrayB.length
72-
}
73-
74-
// Single property comparison
75-
if (orderByClause.length === 1) {
76-
const direction = orderByClause[0]!.direction
77-
return direction === `desc` ? descComparator(a, b) : ascComparator(a, b)
7868
}
69+
return arrayA.length - arrayB.length
70+
}
7971

80-
return ascComparator(a, b)
72+
// Single property comparison
73+
if (orderByClause.length === 1) {
74+
const clause = orderByClause[0]!
75+
const compareFn = makeComparator(clause.compareOptions)
76+
return compareFn(a, b)
8177
}
82-
}
8378

84-
const comparator = makeComparator()
79+
return defaultComparator(a, b)
80+
}
8581

8682
// Use fractional indexing and return the tuple [value, index]
8783
return pipeline.pipe(

packages/db/src/query/ir.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
This is the intermediate representation of the query.
33
*/
44

5+
import type { CompareOptions } from "./builder/types"
56
import type { CollectionImpl } from "../collection"
67
import type { NamespacedRow } from "../types"
78

@@ -48,7 +49,7 @@ export type OrderBy = Array<OrderByClause>
4849

4950
export type OrderByClause = {
5051
expression: BasicExpression
51-
direction: OrderByDirection
52+
compareOptions: CompareOptions
5253
}
5354

5455
export type OrderByDirection = `asc` | `desc`

packages/db/src/utils/comparison.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { CompareOptions } from "../query/builder/types"
2+
13
// WeakMap to store stable IDs for objects
24
const objectIds = new WeakMap<object, number>()
35
let nextObjectId = 1
@@ -19,21 +21,26 @@ function getObjectId(obj: object): number {
1921
* Handles null/undefined, strings, arrays, dates, objects, and primitives
2022
* Always sorts null/undefined values first
2123
*/
22-
export const ascComparator = (a: any, b: any): number => {
24+
export const ascComparator = (a: any, b: any, opts: CompareOptions): number => {
25+
const { nulls } = opts
26+
2327
// Handle null/undefined
2428
if (a == null && b == null) return 0
25-
if (a == null) return -1
26-
if (b == null) return 1
29+
if (a == null) return nulls === `first` ? -1 : 1
30+
if (b == null) return nulls === `first` ? 1 : -1
2731

2832
// if a and b are both strings, compare them based on locale
2933
if (typeof a === `string` && typeof b === `string`) {
30-
return a.localeCompare(b)
34+
if (opts.stringSort === `locale`) {
35+
return a.localeCompare(b, opts.locale, opts.localeOptions)
36+
}
37+
// For lexical sort we rely on direct comparison for primitive values
3138
}
3239

3340
// if a and b are both arrays, compare them element by element
3441
if (Array.isArray(a) && Array.isArray(b)) {
3542
for (let i = 0; i < Math.min(a.length, b.length); i++) {
36-
const result = ascComparator(a[i], b[i])
43+
const result = ascComparator(a[i], b[i], opts)
3744
if (result !== 0) {
3845
return result
3946
}
@@ -74,6 +81,32 @@ export const ascComparator = (a: any, b: any): number => {
7481
* Descending comparator function for ordering values
7582
* Handles null/undefined as largest values (opposite of ascending)
7683
*/
77-
export const descComparator = (a: unknown, b: unknown): number => {
78-
return ascComparator(b, a)
84+
export const descComparator = (
85+
a: unknown,
86+
b: unknown,
87+
opts: CompareOptions
88+
): number => {
89+
return ascComparator(b, a, {
90+
...opts,
91+
nulls: opts.nulls === `first` ? `last` : `first`,
92+
})
93+
}
94+
95+
export function makeComparator(
96+
opts: CompareOptions
97+
): (a: any, b: any) => number {
98+
return (a, b) => {
99+
if (opts.direction === `asc`) {
100+
return ascComparator(a, b, opts)
101+
} else {
102+
return descComparator(a, b, opts)
103+
}
104+
}
79105
}
106+
107+
/** Default comparator orders values in ascending order with nulls first and locale string comparison. */
108+
export const defaultComparator = makeComparator({
109+
direction: `asc`,
110+
nulls: `first`,
111+
stringSort: `locale`,
112+
})

0 commit comments

Comments
 (0)