UNPKG

ts-order

Version:

Type-safe Order utility for delarative, composable, and immutable multi-key ordering logic

340 lines (339 loc) 9.7 kB
//#region src/order.ts const directionSignByDirection = { asc: 1, desc: -1 }; /** * Builder for immutable multi-step ordering rules. * * Create an `Order`, chain `.by()` calls to describe each step, then use * `.compare` with `Array.prototype.sort` or call `.sort()` for DSU-style sorting * that evaluates keys only once per step. * * @example * ```ts * const byStatusThenName = new Order<User>() * .by((u) => u.isActive, { direction: 'desc' }) * .by((u) => u.lastName) * .by((u) => u.firstName); * * users.sort(byStatusThenName.compare); * ``` */ var Order = class Order { _steps = []; constructor(sourceOrSources) { if (!sourceOrSources) return; if (Symbol.iterator in new Object(sourceOrSources)) { for (const s of sourceOrSources) { if (!s || s._steps.length === 0) continue; for (let i = 0; i < s._steps.length; i++) this._steps.push(s._steps[i]); } return; } for (let i = 0; i < sourceOrSources._steps.length; i++) this._steps.push(sourceOrSources._steps[i]); } /** * Create a new order with a single sort step. * * @example * ```ts * const byCreatedAt = Order.by((u: User) => u.createdAt, { * direction: 'desc', * }); * ``` */ static by(selectorFn, options) { const order = new Order(); const direction = directionSignByDirection[options?.direction ?? "asc"]; const compare = options?.compare; order._assignSteps([{ key: selectorFn, direction, compare, predicate: options?.predicate }]); return order; } /** * Append a sort step and return a new order instance. * * @example * ```ts * const byCreatedThenId = new Order<User>() * .by((u) => u.createdAt) * .by((u) => u.id); * ``` */ by(selectorFn, options) { const nextOrder = new Order(); const direction = directionSignByDirection[options?.direction ?? "asc"]; const compare = options?.compare; nextOrder._assignSteps([...this._steps, { key: selectorFn, direction, compare, predicate: options?.predicate }]); return nextOrder; } /** * Flip the direction of every step in an order. * * @example * ```ts * const newestFirst = Order.reverse(Order.by((u: User) => u.createdAt)); * ``` */ static reverse(input) { if (input._steps.length === 0) return new Order(); const reversedOrder = new Order(); reversedOrder._assignSteps(input._steps.map((s) => ({ key: s.key, direction: s.direction === 1 ? -1 : 1, compare: s.compare, predicate: s.predicate }))); return reversedOrder; } /** * Flip the direction of every step in this order. * * @example * ```ts * const newestFirst = Order.by((u: User) => u.createdAt).reverse(); * ``` */ reverse() { return Order.reverse(this); } /** * Lifts an order defined for a derived or nested value into the parent domain. * The provided mapping function extracts the inner value, and the given order * is applied to that value when comparing parent items. * * @example * ```ts * interface Address { * city: string; * postcode: string; * } * interface Customer { * id: number; * address: Address; * } * const byAddress = Order.by((a: Address) => a.city).by((a) => a.postcode); * const byCustomerAddress = Order.map((c: Customer) => c.address, byAddress); * ``` */ static map(outer, sub) { if (sub._steps.length === 0) return new Order(); const mappedOrder = new Order(); mappedOrder._assignSteps(sub._steps.map((s) => ({ key: (t) => s.key(outer(t)), direction: s.direction, compare: s.compare, predicate: s.predicate ? (t) => s.predicate(outer(t)) : void 0 }))); return mappedOrder; } /** * Appends additional sort steps that only apply when both compared items satisfy the given predicate. * If either item fails the predicate, the appended steps are skipped and sorting continues * with the next step in the current `Order`. * * @example * ```ts * const euPriority = Order.when( * (u: User) => u.region === 'eu', * Order.by((u: User) => u.score, { direction: 'desc' }), * ); * ``` */ static when(predicate, input) { if (input._steps.length === 0) return new Order(); const guardedOrder = new Order(); guardedOrder._assignSteps(input._steps.map((step) => ({ key: step.key, direction: step.direction, compare: step.compare, predicate: step.predicate ? (value) => step.predicate(value) && predicate(value) : predicate }))); return guardedOrder; } /** * Lifts an order defined for a derived or nested value into the parent domain. * The provided mapping function extracts the inner value, and the given order * is applied to that value when comparing parent items. * * @example * ```ts * interface Address { * city: string; * postcode: string; * } * interface Customer { * id: number; * address: Address; * } * const byIdThenAddress = new Order<Customer>() * .by((c) => c.id) * .map((c) => c.address, byAddress); * ``` */ map(outer, sub) { if (this._steps.length === 0) return Order.map(outer, sub); const mappedOrder = Order.map(outer, sub); const nextOrder = new Order(); nextOrder._assignSteps([...this._steps, ...mappedOrder._steps]); return nextOrder; } /** * Appends additional sort steps that only apply when both compared items satisfy the given predicate. * If either item fails the predicate, the appended steps are skipped and sorting continues * with the next step in the current `Order`. * * @example * ```ts * const byRegion = new Order<User>() * .by((u) => u.region) * .when( * (u) => u.region === 'eu', * Order.by((u) => u.score, { direction: 'desc' }), * ) * // Append a final tiebreaker (runs for all items) * .by((u) => u.id); * ``` */ when(predicate, order) { const guarded = Order.when(predicate, order); if (this._steps.length === 0) return guarded; if (guarded._steps.length === 0) return new Order(this); const nextOrder = new Order(); nextOrder._assignSteps([...this._steps, ...guarded._steps]); return nextOrder; } /** * Retrieve a comparator compatible with `Array.prototype.sort`. * * @example * ```ts * users.sort(Order.by((u: User) => u.id).compare); * ``` */ get compare() { const steps = this._steps; const numberOfSteps = steps.length; if (numberOfSteps === 0) return () => 0; return (a, b) => { for (let i = 0; i < numberOfSteps; i++) { const s = steps[i]; const p = s.predicate; if (p && (!p(a) || !p(b))) continue; const x = s.key(a); const y = s.key(b); const c = s.compare; if (c) { const r = c(x, y); if (r !== 0) return r > 0 ? s.direction : -s.direction; } else { if (x < y) return -s.direction; if (x > y) return s.direction; } } return 0; }; } /** * Sort an array with the provided order and return a new array. * * This method implements the Schwartzian Transform or DSU * (decorate-sort-undecorate) technique, which ensures that each key * selector is only invoked once per element per step. For larger arrays or * costly key computations, this can yield significant performance * improvements over repeatedly calling the selector during comparisons. * * @example * ```ts * const out = Order.sort(users, Order.by((u: User) => u.lastName)); * ``` */ static sort(array, order) { const steps = order._steps; const arrayLength = array.length; if (arrayLength <= 1) return array.slice(); if (steps.length === 0) return array.slice(); const numberOfSteps = steps.length; const keysPerStep = new Array(numberOfSteps); const predicateMatchesPerStep = new Array(numberOfSteps); for (let j = 0; j < numberOfSteps; j++) { const step = steps[j]; const keys = new Array(arrayLength); const predicate = step.predicate; if (predicate) { const matches = new Array(arrayLength); for (let i = 0; i < arrayLength; i++) { const item = array[i]; const match = predicate(item); matches[i] = match; if (match) keys[i] = step.key(item); } predicateMatchesPerStep[j] = matches; } else for (let i = 0; i < arrayLength; i++) keys[i] = step.key(array[i]); keysPerStep[j] = keys; } const arrayIndexes = new Array(arrayLength); for (let i = 0; i < arrayLength; i++) arrayIndexes[i] = i; const dirs = new Int8Array(numberOfSteps); const cmps = new Array(numberOfSteps); for (let j = 0; j < numberOfSteps; j++) { dirs[j] = steps[j].direction; cmps[j] = steps[j].compare; } arrayIndexes.sort((ia, ib) => { for (let j = 0; j < numberOfSteps; j++) { const kj = keysPerStep[j]; const matches = predicateMatchesPerStep[j]; if (matches && (!matches[ia] || !matches[ib])) continue; const a = kj[ia]; const b = kj[ib]; const c = cmps[j]; const d = dirs[j]; if (c) { const r = c(a, b); if (r !== 0) return r > 0 ? d : -d; } else { if (a < b) return -d; if (a > b) return d; } } return 0; }); const out = new Array(arrayLength); for (let i = 0; i < arrayLength; i++) out[i] = array[arrayIndexes[i]]; return out; } /** * Sort an array with the provided order and return a new array. * * This method implements the Schwartzian Transform or DSU * (decorate-sort-undecorate) technique, which ensures that each key * selector is only invoked once per element per step. For larger arrays or * costly key computations, this can yield significant performance * improvements over repeatedly calling the selector during comparisons. * * @example * ```ts * const sorted = Order.by((u: User) => u.lastName).sort(users); * ``` */ sort(array) { return Order.sort(array, this); } /** Replace the internal step list with provided steps. */ _assignSteps(steps) { this._steps = steps; } }; //#endregion export { Order };