Skip to content

Commit f60d991

Browse files
authored
perf(sort,filter): Schwartzian transform and remove recursive match (#63)
sort: - Pre-compute sort keys once per item (O(n)) instead of on every comparison (O(n log n)) via Schwartzian transform - Store decorated rows as flat pre-allocated tuples [item, key0, key1, ...] to halve allocations and improve cache locality vs {item, keys:[]} - Pre-compute direction multipliers array to eliminate map lookups from the comparator hot path filter: - Replace recursive match + per-record ands.filter() with matchTree, eliminating O(n x ors) array allocations - Pre-compute ors/ands arrays per expression tree once per apply() call instead of re-deriving them per record
1 parent 6298cd2 commit f60d991

3 files changed

Lines changed: 33 additions & 40 deletions

File tree

src/operations/filter.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,21 @@ export default class FilterDataOperation<T extends object> extends DataOperation
1313
);
1414
}
1515

16-
protected match(record: T, ands: FilterExpression<T>[], ors: FilterExpression<T>[]): boolean {
17-
for (const or of ors) {
18-
if (this.resolveFilter(record, or)) {
19-
return this.match(
20-
record,
21-
ands.filter((f) => f.key !== or.key),
22-
[]
23-
);
24-
}
16+
protected matchTree(record: T, ors: FilterExpression<T>[], ands: FilterExpression<T>[]): boolean {
17+
if (ors.length > 0 && ors.some((expr) => this.resolveFilter(record, expr))) {
18+
return true;
2519
}
26-
return ands.every((f) => this.resolveFilter(record, f));
20+
return ands.every((expr) => this.resolveFilter(record, expr));
2721
}
2822

2923
public apply(data: T[], state: FilterState<T>): T[] {
3024
if (state.empty) return data;
3125

32-
const { ands, ors } = state;
33-
return data.filter((record) => this.match(record, ands, ors));
26+
// Pre-compute ors/ands per tree once rather than re-deriving them per record
27+
const trees = state.values.map((tree) => ({ ors: tree.ors, ands: tree.ands }));
28+
29+
return data.filter((record) =>
30+
trees.every(({ ors, ands }) => this.matchTree(record, ors, ands))
31+
);
3432
}
3533
}

src/operations/filter/state.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,10 @@ export class FilterState<T> {
99
return this.state.size < 1;
1010
}
1111

12-
public get keys() {
13-
return Array.from(this.state.keys());
14-
}
15-
1612
public get values() {
1713
return Array.from(this.state.values());
1814
}
1915

20-
public get ands() {
21-
return this.values.flatMap((each) => each.ands);
22-
}
23-
24-
public get ors() {
25-
return this.values.flatMap((each) => each.ors);
26-
}
27-
2816
public has(key: Keys<T>) {
2917
return this.state.has(key);
3018
}

src/operations/sort.ts

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import DataOperation from './base.js';
2-
import type { SortingExpression, SortState } from './sort/types.js';
2+
import type { SortState } from './sort/types.js';
33

44
export default class SortDataOperation<T> extends DataOperation<T> {
55
protected orderBy = new Map(
@@ -16,34 +16,41 @@ export default class SortDataOperation<T> extends DataOperation<T> {
1616
return first > second ? 1 : first < second ? -1 : 0;
1717
}
1818

19-
protected compareObjects(first: T, second: T, expression: SortingExpression<T>) {
20-
const { direction, key, caseSensitive, comparer } = expression;
21-
22-
const a = this.resolveCase(this.resolveValue(first, key), caseSensitive);
23-
const b = this.resolveCase(this.resolveValue(second, key), caseSensitive);
24-
25-
// TODO: Remove casting as any
26-
return (
27-
this.orderBy.get(direction)! * (comparer?.(a as any, b as any) ?? this.compareValues(a, b))
28-
);
29-
}
30-
3119
public apply(data: T[], state: SortState<T>) {
3220
const expressions = Array.from(state.values());
3321
const length = expressions.length;
3422

35-
data.sort((a, b) => {
23+
// Pre-compute direction multipliers once to avoid Map lookups in the comparator
24+
const multipliers = expressions.map(({ direction }) => this.orderBy.get(direction)!);
25+
26+
// Store as flat tuples [item, key0, key1, ...] to avoid per-row object allocation
27+
// and transform only once before sorting, then extract the original items after sorting.
28+
const transformed = data.map((item) => {
29+
const tuple: unknown[] = new Array(length + 1);
30+
tuple[0] = item;
31+
for (let i = 0; i < length; i++) {
32+
const { key, caseSensitive } = expressions[i];
33+
tuple[i + 1] = this.resolveCase(this.resolveValue(item, key), caseSensitive);
34+
}
35+
return tuple;
36+
});
37+
38+
transformed.sort((a, b) => {
3639
let i = 0;
3740
let result = 0;
3841

3942
while (i < length && !result) {
40-
result = this.compareObjects(a, b, expressions[i]);
43+
const keyA = a[i + 1];
44+
const keyB = b[i + 1];
45+
result =
46+
multipliers[i] *
47+
(expressions[i].comparer?.(keyA as any, keyB as any) ?? this.compareValues(keyA, keyB));
4148
i++;
4249
}
4350

4451
return result;
4552
});
4653

47-
return data;
54+
return transformed.map((tuple) => tuple[0] as T);
4855
}
4956
}

0 commit comments

Comments
 (0)