// Copyright (C) 2023 The Android Open Source Project // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import {arrayEquals, isArrayOf} from '../base/array_utils'; import {isString} from '../base/object_utils'; import {intersect} from '../base/set_utils'; // Contents: // CORE_TYPES - The main types for using EventSet. // EVENT_SET_IMPLS - Impl of {Concreate, Empty, Sql, Naive{...}}EventSet // EXPR - Expression logic which can be lowered to either JS or SQL // STUPID_TYPE_MAGIC // HELPERS - Random helpers. // CORE_TYPES ========================================================= // A single value. These are often retrieved from trace_processor so // need to map to the related sqlite type: // null = NULL, string = TEXT, number = INTEGER/REAL, // boolean = INTEGER, bigint = INTEGER export type Primitive = null | string | boolean | number | bigint; export const Null = 'null' as const; export const Num = 'num' as const; export const BigInt = 'bigint' as const; export const Str = 'str' as const; export const Id = 'id' as const; export const Bool = 'bool' as const; // Values may be of any of the above types: export type KeyType = | typeof Num | typeof Str | typeof Null | typeof Id | typeof Bool | typeof BigInt; // KeySet is a specification for the key/value pairs on an Event. // - Every event must have a string ID. // - In addition Events may have 1 or more key/value pairs. // The *specification* for the key/value pair has to be *precisely* one // of the KeySet constants above. So: // const thisTypeChecks: KeySet = { foo: Str }; // const thisDoesNot: KeySet = { foo: "bar" }; // Since although 'bar' is a string it's not a KeyType. export type KeySet = { readonly [key: string]: KeyType; }; // The empty keyset. Events from this KeySet will only have ids. export interface EmptyKeySet extends KeySet {} export type UntypedKeySet = KeySet; // A single trace Event. // Events have: // - A globally unique identifier `id`. // - Zero or more key/value pairs. // Note: Events do *not* have to have all possible keys/value pairs for // the given id. It is expected that users will only materialise the // key/value pairs relevant to the specific use case at hand. export type WritableUntypedEvent = { id: string; [key: string]: Primitive; }; export type UntypedEvent = Readonly; export type Event = { readonly [Property in Exclude]: ConformingValue; } & { readonly id: string; }; // An EventSet is a: // - ordered // - immutable // - subset // of events in the trace. export interface EventSet

{ // All possible keys for Events in this EventSet. readonly keys: P; // Methods for refining the set. // Note: these are all synchronous - we expect the cost (and hence // any asynchronous queries) to be deferred to analysis time. filter(...filters: Filter[]): EventSet

; sort(...sorts: Sort[]): EventSet

; union(other: EventSet): Merged; intersect(other: EventSet): Merged; // Methods for analysing the set. // Note: these are all asynchronous - it's expected that these will // often have to do queries. count(): Promise; isEmpty(): Promise; materialise( keys: T, offset?: number, limit?: number, ): Promise>; } interface UnionEventSet extends EventSet { readonly parents: EventSet[]; readonly isUnion: true; create(...events: EventSet[]): UnionEventSet; } interface IntersectionEventSet extends EventSet { readonly parents: EventSet[]; readonly isIntersection: true; create(...events: EventSet[]): IntersectionEventSet; } interface FilterEventSet extends EventSet { readonly parent: EventSet; readonly filters: Filter[]; readonly isFilter: true; } interface SortEventSet extends EventSet { readonly parent: EventSet; readonly sorts: Sort[]; readonly isSort: true; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export type UntypedEventSet = EventSet; // An expression that operates on an Event and produces a Primitive as // output. Expressions have to work both in JavaScript and in SQL. // In SQL users can use buildQueryFragment to convert the expression // into a snippet of SQL. For JavaScript they call execute(). In both // cases you need to know which keys the expression uses, for this call // `freeVariables`. // TODO(hjd): These should also be paramatised by KeySet and final // type. export interface Expr { // Return a fragment of SQL that can be used to evaluate the // expression. `binding` maps key names to column names in the // resulting SQL. The caller must ensure that binding includes at // least all the keys from `freeVariables`. buildQueryFragment(binding: Map): string; // Run the expression on an Event. The caller must ensure that event // has all the keys from `freeVariables` materialised. execute(event: UntypedEvent): Primitive; // Returns the set of keys used in this expression. // For example in an expression representing `(foo + 4) * bar` // freeVariables would return the set {foo: Num, bar: Num}. freeVariables(): KeySet; } // A filter is a (normally boolean) expression. export type Filter = Expr; // Sorting direction. export enum Direction { ASC, DESC, } // A sort is an expression combined with a direction: export interface Sort { direction: Direction; expression: Expr; } // EVENT_SET_IMPLS ==================================================== // OptimisingEventSet is what makes it a) tractable to write EventSet // implementations and b) have those implementations be fast. // The EventSet interface has two kinds of methods: // 1. Synchronous refinement methods which produce an EventSet and // often take a second EventSet as an argument // 2. Asynchronous 'analysis' methods // // Together this means in the minimal case subclasses only *have* to // implement the single abstract method: materialise(). Everything else // is handled for you. export abstract class OptimisingEventSet

implements EventSet

{ abstract readonly keys: P; // OptimisingEventSet provides the synchronous refinement methods. // The basic pattern is to construct a 'NaiveFoo' EventSet which will // do the given operation (filter, sort, union, intersection) in // JavaScript then call optimise(). Optimse then tries to improve the // EventSet tree - and avoid having to use the fallback naive // implementaion. // Optimise does 'tree rewriting' of the EventSet tree. For example // considering a tree: 'union(A, 0)' where 0 is the empty set and // A is some arbitrary EventSet, optimise(union(A, 0)) returns A. // For more detail see optimise() below. filter(...filters: Filter[]): EventSet

{ const result = new NaiveFilterEventSet(this, filters); const optimised = optimise(result); return optimised; } sort(...sorts: Sort[]): EventSet

{ const result = new NaiveSortEventSet(this, sorts); const optimised = optimise(result); return optimised; } union(other: EventSet): Merged { const merged = mergeKeys(this.keys, other.keys); const result = new NaiveUnionEventSet>( merged, this as UntypedEventSet, other as UntypedEventSet, ); const optimised = optimise(result); return optimised; } intersect(other: EventSet): Merged { const merged = mergeKeys(this.keys, other.keys); const result = new NaiveIntersectionEventSet>( merged, this as UntypedEventSet, other as UntypedEventSet, ); const optimised = optimise(result); return optimised; } // Analysis methods should be implemented by the subclass. // Materialise is abstract and must be implemented by the subclass. abstract materialise( keys: Q, offset?: number, limit?: number, ): Promise>; // We provide a default implementation of count() on top of // materialise(). It's likely the subclass can provide a more // performant implementation. async count(): Promise { const materialised = await this.materialise({}); return materialised.events.length; } // We provide a default implementation of empty() on top of // materialise(). It's likely the subclass can provide a more // performant implementation. async isEmpty(): Promise { const materialised = await this.materialise( {}, 0 /* offset */, 1 /* limit */, ); return materialised.events.length === 0; } } class NaiveFilterEventSet

extends OptimisingEventSet

implements FilterEventSet

{ readonly isFilter = true; readonly parent: EventSet

; readonly filters: Filter[]; readonly keys: P; constructor(parent: EventSet

, filters: Filter[]) { super(); this.parent = parent; this.keys = this.parent.keys; this.filters = filters; } async count(): Promise { const keys = freeVariablesFromFilters(this.filters); const concreteParent = await this.parent.materialise(keys); const events = concreteParent.events; let total = 0; for (const e of events) { if (this.filters.every((f) => f.execute(e))) { total += 1; } } return total; } async isEmpty(): Promise { const keys = freeVariablesFromFilters(this.filters); const concreateParent = await this.parent.materialise(keys); const events = concreateParent.events; for (const e of events) { if (this.filters.every((f) => f.execute(e))) { return false; } } return true; } async materialise( keys: Q, offset?: number, limit?: number, ): Promise> { const combined = freeVariablesFromFilters(this.filters, keys); const concreateParent = await this.parent.materialise(combined); let events = concreateParent.events; for (const filter of this.filters) { events = events.filter((e) => filter.execute(e)); } return new ConcreteEventSet(combined, events).materialise( keys, offset, limit, ); } } class NaiveSortEventSet

extends OptimisingEventSet

implements SortEventSet

{ readonly isSort = true; readonly parent: EventSet

; readonly sorts: Sort[]; readonly keys: P; constructor(parent: EventSet

, sorts: Sort[]) { super(); this.parent = parent; this.keys = this.parent.keys; this.sorts = sorts; } async count(): Promise { return this.parent.count(); } async isEmpty(): Promise { return this.parent.isEmpty(); } async materialise( keys: Q, offset?: number, limit?: number, ): Promise> { const combined = freeVariablesFromSorts(this.sorts, keys); const concreateParent = await this.parent.materialise(combined); let events = concreateParent.events; for (const sort of this.sorts) { events = events.sort(cmpFromSort(sort)); } return new ConcreteEventSet(combined, events).materialise( keys, offset, limit, ); } } export class NaiveUnionEventSet extends OptimisingEventSet implements UnionEventSet { readonly isUnion = true; readonly parents: EventSet[]; readonly keys: T; constructor(keys: T, ...parents: EventSet[]) { super(); this.keys = keys; this.parents = parents; } create(...parents: EventSet[]): NaiveUnionEventSet { return new NaiveUnionEventSet(this.keys, ...parents); } // TODO(hjd): We could implement a more efficient dedicated count(). // TODO(hjd): We could implement a more efficient dedicated isEmpty(). async materialise( keys: Q, offset?: number, limit?: number, ): Promise> { const promises = this.parents.map((p) => p.materialise(keys)); const materialisedParents = (await Promise.all( promises, )) as ConcreteEventSet[]; const seen = new Set(); let events = []; // TODO(hjd): There are various options for doing this in faster // way and we should do one of them. for (const parent of materialisedParents) { for (const e of parent.events) { if (!seen.has(e.id)) { events.push(e); seen.add(e.id); } } } events = applyLimitOffset(events, limit, offset); return ConcreteEventSet.from(keys, events) as unknown as Materialised; } } export class NaiveIntersectionEventSet extends OptimisingEventSet implements IntersectionEventSet { readonly isIntersection = true; readonly parents: EventSet[]; readonly keys: T; constructor(keys: T, ...parents: EventSet[]) { super(); this.keys = keys; this.parents = parents; } create(...parents: EventSet[]): NaiveIntersectionEventSet { return new NaiveIntersectionEventSet(this.keys, ...parents); } // TODO(hjd): We could implement a more efficient dedicated count(). // TODO(hjd): We could implement a more efficient dedicated isEmpty(). async materialise( keys: Q, offset?: number, limit?: number, ): Promise> { if (this.parents.length === 0) { return ConcreteEventSet.from(keys, []) as Materialised; } const parents = this.parents.slice(); const firstParent = parents.pop()!; const promises = parents.map((p) => p.materialise({})); const firstPromise = firstParent.materialise( keys, ) as unknown as ConcreteEventSet; const materialised = await Promise.all(promises); const firstMaterialised = await firstPromise; let ids = new Set(); for (const e of firstMaterialised.events) { ids.add(e.id); } for (const m of materialised) { const newIds = new Set(); for (const e of m.events) { newIds.add(e.id); } ids = intersect(ids, newIds); } let events = firstMaterialised.events.filter((e) => ids.has(e.id)); events = applyLimitOffset(events, limit, offset); return ConcreteEventSet.from(keys, events) as unknown as Materialised; } } // A completely empty EventSet. export class EmptyEventSet extends OptimisingEventSet implements EventSet { readonly isEmptyEventSet = true; readonly keys: T; constructor(keys: T) { super(); this.keys = keys; } static get(): EmptyEventSet { return new EmptyEventSet({}); } count(): Promise { return Promise.resolve(0); } isEmpty(): Promise { return Promise.resolve(true); } async materialise( keys: Q, _offset?: number, _limit?: number, ): Promise> { return Promise.resolve( new ConcreteEventSet(keys, []) as unknown as Materialised, ); } } export class ConcreteEventSet

extends OptimisingEventSet

implements EventSet

{ readonly isConcreteEventSet = true; readonly events: Event

[]; readonly keys: P; static from( keys: Q, events: Event[], ): ConcreteEventSet { return new ConcreteEventSet(keys, events); } constructor(keys: P, events: Event

[]) { super(); // TODO(hjd): Add some paranoid mode where we crash here if // `events` and `keys` mismatch? this.events = events; this.keys = keys; } count(): Promise { return Promise.resolve(this.events.length); } isEmpty(): Promise { return Promise.resolve(this.events.length === 0); } materialise( keys: Q, offset?: number, limit?: number, ): Promise> { const actualOffset = offset === undefined ? 0 : offset; const actualEnd = limit === undefined ? this.events.length : actualOffset + limit; const shouldFilter = !isEqualKeySet(keys, this.keys); const shouldSlice = actualOffset !== 0 || actualEnd !== this.events.length; if (!shouldFilter && !shouldSlice) { return Promise.resolve(this as unknown as Materialised); } let events = this.events as Event[]; if (shouldFilter) { events = events.map((e) => { const result: WritableUntypedEvent = { id: e.id, }; for (const [k, v] of Object.entries(keys)) { // While the static typing prevents folks from hitting // this in the common case people can still on purpose pass // keysets and lie about the types. result[k] = (e as UntypedEvent)[k] ?? getKeyDefault(k, v); } return result as Event; }); } if (shouldSlice) { events = events.slice(actualOffset, actualEnd); } return Promise.resolve( new ConcreteEventSet(keys, events) as unknown as Materialised, ); } } // Optimse: // We have a couple major kinds of optimisation: // 1. Pushing down filters. // 2. Set optimisations (e.g union(empty, A) == A) // 3. Merging EventSets of the same kind // // In more detail: // 1. Pushing down filters. For example: // filter(union(A, B), pred) == // union(filter(A, pred), filter(B, pred)) // This is more useful than it seems since if we manage to push down // filters all the may to SQL they can be implemented very // efficiently in C++. // 2. Classic set optimisations. e.g. // union(A, empty) == A // union(A, A) == A // intersect(A, empty) == empty // etc // 3. Merging EventSets of the same type. For example: // union(concrete(a, b), concrete(b, c)) == concrete(a, b, c) // Similarly the combinations of two SQL EventSets can be converted // into a single SQL EventSet with a more complicated query - // avoiding doing the processing in TypeScript. // // A critical pre-condition of this function is that EventSets are // immutable - this allows us to reuse parts of the input event set tree // in the output. export function optimise(eventSet: EventSet): EventSet { // Empty EventSet can't be futher optimised. if (isEmptyEventSet(eventSet)) { return eventSet; } if (isConcreteEventSet(eventSet)) { // A concrete events with zero elements is the empty events. if (eventSet.events.length === 0) { return new EmptyEventSet(eventSet.keys); } // ...but otherwise can not be optimised further. return eventSet; } if (isUnionEventSet(eventSet)) { const keys = eventSet.keys; let newParents: EventSet[] = eventSet.parents.slice(); // Empty sets don't contribute to the union. newParents = newParents.filter((p) => !isEmptyEventSet(p)); // union([]) is empty. if (newParents.length === 0) { return new EmptyEventSet(keys); } if (newParents.length === 1) { return newParents[0]; } // The union of concrete EventSets is a concrete EventSets with all // the events in. if ( isArrayOf, EventSet>( isConcreteEventSet, newParents, ) ) { const seen = new Set(); const events = []; for (const p of newParents) { for (const e of p.events) { if (!seen.has(e.id)) { events.push(e); seen.add(e.id); } } } return ConcreteEventSet.from(eventSet.keys, events); } if (arrayEquals(newParents, eventSet.parents)) { return eventSet; } else { return eventSet.create(...newParents); } } if (isIntersectionEventSet(eventSet)) { // For any x: intersect([x, 0]) is 0 for (const parent of eventSet.parents) { if (isEmptyEventSet(parent)) { return parent; } } return eventSet; } if (isFilterEventSet(eventSet)) { const parent = eventSet.parent; if (isEmptyEventSet(parent)) { return parent; } return eventSet; } if (isSortEventSet(eventSet)) { const parent = eventSet.parent; if (isEmptyEventSet(parent)) { return parent; } return eventSet; } // TODO(hjd): Re-add the optimisations from the prototype. // TODO(hjd): Union([a, a]) === a but maybe not worth optimising. return eventSet; } // EXPR =============================================================== abstract class BinOp implements Expr { readonly left: Expr; readonly right: Expr; constructor(left: Expr, right: Expr) { this.left = left; this.right = right; } buildQueryFragment(binding: Map): string { const a = this.left.buildQueryFragment(binding); const b = this.right.buildQueryFragment(binding); const op = this.sqlOp(); return `(${a} ${op} ${b})`; } execute(event: UntypedEvent): Primitive { const a = this.left.execute(event); const b = this.right.execute(event); return this.evaluate(a, b); } freeVariables(): KeySet { const a = this.left.freeVariables(); const b = this.right.freeVariables(); return mergeKeys(a, b); } abstract sqlOp(): string; abstract evaluate(lhs: Primitive, rhs: Primitive): Primitive; } class Le extends BinOp implements Expr { sqlOp(): string { return '<='; } evaluate(lhs: Primitive, rhs: Primitive): Primitive { return lhs! <= rhs!; } } class Lt extends BinOp implements Expr { sqlOp(): string { return '<'; } evaluate(lhs: Primitive, rhs: Primitive): Primitive { return lhs! < rhs!; } } class Ge extends BinOp implements Expr { sqlOp(): string { return '>='; } evaluate(lhs: Primitive, rhs: Primitive): Primitive { return lhs! >= rhs!; } } class Gt extends BinOp implements Expr { sqlOp(): string { return '>'; } evaluate(lhs: Primitive, rhs: Primitive): Primitive { return lhs! > rhs!; } } class Eq extends BinOp implements Expr { sqlOp(): string { return '='; } evaluate(lhs: Primitive, rhs: Primitive): Primitive { return lhs === rhs; } } class And extends BinOp implements Expr { sqlOp(): string { return 'AND'; } evaluate(lhs: Primitive, rhs: Primitive): Primitive { // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions return lhs && rhs; } } class Or extends BinOp implements Expr { sqlOp(): string { return 'OR'; } evaluate(lhs: Primitive, rhs: Primitive): Primitive { // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions return lhs || rhs; } } class Ne extends BinOp implements Expr { sqlOp(): string { return '!='; } evaluate(lhs: Primitive, rhs: Primitive): Primitive { return lhs !== rhs; } } class Var implements Expr { readonly name: string; constructor(name: string) { this.name = name; } buildQueryFragment(binding: Map): string { // TODO(hjd): wrap in try catch? return binding.get(this.name)!; } execute(event: UntypedEvent): Primitive { return event[this.name]!; } freeVariables(): KeySet { return { [this.name]: Null, }; } } class Constant implements Expr { readonly value: Primitive; constructor(value: Primitive) { this.value = value; } buildQueryFragment(_: Map): string { const value = this.value; if (value === null) { return 'NULL'; } else if (isString(value)) { return `'${value}'`; } else if (typeof value === 'boolean') { return value ? 'TRUE' : 'FALSE'; } else { return `${value}`; } } execute(_: UntypedEvent): Primitive { return this.value; } freeVariables(): EmptyKeySet { return {}; } } export function eq(left: Expr, right: Expr): Eq { return new Eq(left, right); } export function ne(left: Expr, right: Expr): Ne { return new Ne(left, right); } export function gt(left: Expr, right: Expr): Gt { return new Gt(left, right); } export function ge(left: Expr, right: Expr): Ge { return new Ge(left, right); } export function lt(left: Expr, right: Expr): Lt { return new Lt(left, right); } export function le(left: Expr, right: Expr): Le { return new Le(left, right); } export function and(left: Expr, right: Expr): And { return new And(left, right); } export function or(left: Expr, right: Expr): Or { return new Or(left, right); } export function c(value: Primitive): Constant { return new Constant(value); } export function v(name: string): Var { return new Var(name); } // Type guards: export function isEmptyEventSet( s: EventSet | EmptyEventSet, ): s is EmptyEventSet { return !!(s as EmptyEventSet).isEmptyEventSet; } export function isConcreteEventSet( s: EventSet | ConcreteEventSet, ): s is ConcreteEventSet { return !!(s as ConcreteEventSet).isConcreteEventSet; } export function isUnionEventSet( s: EventSet | UnionEventSet, ): s is UnionEventSet { return ( (s as UnionEventSet).isUnion && Array.isArray((s as UnionEventSet).parents) ); } export function isIntersectionEventSet( s: EventSet | IntersectionEventSet, ): s is IntersectionEventSet { return ( (s as IntersectionEventSet).isIntersection && Array.isArray((s as IntersectionEventSet).parents) ); } export function isFilterEventSet( s: EventSet | FilterEventSet, ): s is FilterEventSet { return ( (s as FilterEventSet).isFilter && Array.isArray((s as FilterEventSet).filters) ); } export function isSortEventSet( s: EventSet | SortEventSet, ): s is SortEventSet { return ( (s as SortEventSet).isSort && Array.isArray((s as SortEventSet).sorts) ); } // STUPID_TYPE_MAGIC ================================================== type ErrorBrand = { [k in T]: void; }; // A particular key/value pair on an Event matches the relevant entry // on the KeySet if the KeyType and the value type 'match': // Id => string // Str => string // Bool => boolean // Null => null // Num => number type KeyToType = { num: number; str: string; bool: boolean; null: null; bigint: bigint; id: string; }; type ConformingValue = T extends keyof KeyToType ? KeyToType[T] : void; type Materialised< Concrete extends KeySet, Parent extends KeySet, > = Parent extends Concrete ? ConcreteEventSet : ErrorBrand<`Very bad!`>; type MergedKeys = Left & Right; type Merged = EventSet< MergedKeys >; // HELPERS ============================================================ function applyLimitOffset(arr: T[], limit?: number, offset?: number): T[] { const actualOffset = offset === undefined ? 0 : offset; const actualEnd = limit === undefined ? arr.length : actualOffset + limit; const shouldSlice = actualOffset !== 0 || actualEnd !== arr.length; return shouldSlice ? arr.slice(actualOffset, actualEnd) : arr; } function mergeKeys

( left: P, right: Q, ): MergedKeys { return Object.assign({}, left, right); } function getKeyDefault(keyName: string, keyType: KeyType): Primitive { switch (keyType) { case Id: throw new Error( `Can't create default for key '${keyName}' with type '${keyType}'`, ); case Num: return 0; case Null: return null; case Str: return ''; case Bool: return false; case BigInt: return 0n; default: const _exhaustiveCheck: never = keyType; return _exhaustiveCheck; } } function isEqualKeySet(a: UntypedKeySet, b: UntypedKeySet): boolean { for (const k in a) { if (a[k] !== b[k]) { return false; } } for (const k in b) { if (b[k] !== a[k]) { return false; } } return true; } function freeVariablesFromFilters( filters: Filter[], initialKeySet?: KeySet, ): KeySet { let result = {}; if (initialKeySet !== undefined) { result = mergeKeys(result, initialKeySet); } for (const filter of filters) { result = mergeKeys(result, filter.freeVariables()); } return result; } function freeVariablesFromSorts(sorts: Sort[], initialKeySet?: KeySet): KeySet { let result = {}; if (initialKeySet !== undefined) { result = mergeKeys(result, initialKeySet); } for (const sort of sorts) { result = mergeKeys(result, sort.expression.freeVariables()); } return result; } function primativeToRank(p: Primitive) { if (p === null) { return 0; } else if (isString(p)) { return 2; } else { return 1; } } // TODO(hjd): test for bignums // Convert an expression into a sort style comparison function. // Exported for testing. export function cmpFromExpr( expr: Expr, ): (l: Event, r: Event) => number { return (l: Event, r: Event) => { const lhs = expr.execute(l); const rhs = expr.execute(r); const lhsRank = primativeToRank(lhs); const rhsRank = primativeToRank(rhs); if (lhsRank < rhsRank) { return -1; } else if (lhsRank > rhsRank) { return 1; } else { // Double equals on purpose so 0 == false and 1 == true are true if (lhs == rhs) { return 0; } else if (lhs! < rhs!) { return -1; } else { return 1; } } }; } // Convert a 'sort' into a sort() style comparison function. // Exported for testing. export function cmpFromSort( sort: Sort, ): (l: Event, r: Event) => number { const cmp = cmpFromExpr(sort.expression); if (sort.direction === Direction.ASC) { return cmp; } else { // cmp(r, l) is better than -cmp(l, r) since JS distinguishes // between -0 and 0. return (l: Event, r: Event) => cmp(r, l); } }