From 807275252bd7f9c0812b962ee870876d1e49f9aa Mon Sep 17 00:00:00 2001 From: Radoslav Karaivanov Date: Wed, 22 Apr 2026 14:43:49 +0300 Subject: [PATCH] perf(sort,filter): Schwartzian transform and remove recursive match 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 --- src/operations/filter.ts | 22 +++++++++---------- src/operations/filter/state.ts | 12 ----------- src/operations/sort.ts | 39 ++++++++++++++++++++-------------- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/src/operations/filter.ts b/src/operations/filter.ts index 79ff2de..10cc5dc 100644 --- a/src/operations/filter.ts +++ b/src/operations/filter.ts @@ -13,23 +13,21 @@ export default class FilterDataOperation extends DataOperation ); } - protected match(record: T, ands: FilterExpression[], ors: FilterExpression[]): boolean { - for (const or of ors) { - if (this.resolveFilter(record, or)) { - return this.match( - record, - ands.filter((f) => f.key !== or.key), - [] - ); - } + protected matchTree(record: T, ors: FilterExpression[], ands: FilterExpression[]): boolean { + if (ors.length > 0 && ors.some((expr) => this.resolveFilter(record, expr))) { + return true; } - return ands.every((f) => this.resolveFilter(record, f)); + return ands.every((expr) => this.resolveFilter(record, expr)); } public apply(data: T[], state: FilterState): T[] { if (state.empty) return data; - const { ands, ors } = state; - return data.filter((record) => this.match(record, ands, ors)); + // Pre-compute ors/ands per tree once rather than re-deriving them per record + const trees = state.values.map((tree) => ({ ors: tree.ors, ands: tree.ands })); + + return data.filter((record) => + trees.every(({ ors, ands }) => this.matchTree(record, ors, ands)) + ); } } diff --git a/src/operations/filter/state.ts b/src/operations/filter/state.ts index 0313822..e910ddf 100644 --- a/src/operations/filter/state.ts +++ b/src/operations/filter/state.ts @@ -9,22 +9,10 @@ export class FilterState { return this.state.size < 1; } - public get keys() { - return Array.from(this.state.keys()); - } - public get values() { return Array.from(this.state.values()); } - public get ands() { - return this.values.flatMap((each) => each.ands); - } - - public get ors() { - return this.values.flatMap((each) => each.ors); - } - public has(key: Keys) { return this.state.has(key); } diff --git a/src/operations/sort.ts b/src/operations/sort.ts index 69ba125..b29f283 100644 --- a/src/operations/sort.ts +++ b/src/operations/sort.ts @@ -1,5 +1,5 @@ import DataOperation from './base.js'; -import type { SortingExpression, SortState } from './sort/types.js'; +import type { SortState } from './sort/types.js'; export default class SortDataOperation extends DataOperation { protected orderBy = new Map( @@ -16,34 +16,41 @@ export default class SortDataOperation extends DataOperation { return first > second ? 1 : first < second ? -1 : 0; } - protected compareObjects(first: T, second: T, expression: SortingExpression) { - const { direction, key, caseSensitive, comparer } = expression; - - const a = this.resolveCase(this.resolveValue(first, key), caseSensitive); - const b = this.resolveCase(this.resolveValue(second, key), caseSensitive); - - // TODO: Remove casting as any - return ( - this.orderBy.get(direction)! * (comparer?.(a as any, b as any) ?? this.compareValues(a, b)) - ); - } - public apply(data: T[], state: SortState) { const expressions = Array.from(state.values()); const length = expressions.length; - data.sort((a, b) => { + // Pre-compute direction multipliers once to avoid Map lookups in the comparator + const multipliers = expressions.map(({ direction }) => this.orderBy.get(direction)!); + + // Store as flat tuples [item, key0, key1, ...] to avoid per-row object allocation + // and transform only once before sorting, then extract the original items after sorting. + const transformed = data.map((item) => { + const tuple: unknown[] = new Array(length + 1); + tuple[0] = item; + for (let i = 0; i < length; i++) { + const { key, caseSensitive } = expressions[i]; + tuple[i + 1] = this.resolveCase(this.resolveValue(item, key), caseSensitive); + } + return tuple; + }); + + transformed.sort((a, b) => { let i = 0; let result = 0; while (i < length && !result) { - result = this.compareObjects(a, b, expressions[i]); + const keyA = a[i + 1]; + const keyB = b[i + 1]; + result = + multipliers[i] * + (expressions[i].comparer?.(keyA as any, keyB as any) ?? this.compareValues(keyA, keyB)); i++; } return result; }); - return data; + return transformed.map((tuple) => tuple[0] as T); } }