ts-order
Version:
Type-safe Order utility for delarative, composable, and immutable multi-key ordering logic
340 lines (339 loc) • 9.7 kB
JavaScript
//#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 };