| // 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<WritableUntypedEvent>; |
| |
| export type Event<K extends KeySet> = { |
| readonly [Property in Exclude<keyof K, 'id'>]: ConformingValue<K[Property]>; |
| } & { |
| readonly id: string; |
| }; |
| |
| // An EventSet is a: |
| // - ordered |
| // - immutable |
| // - subset |
| // of events in the trace. |
| export interface EventSet<P extends KeySet> { |
| // 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<P>; |
| sort(...sorts: Sort[]): EventSet<P>; |
| union<Q extends KeySet>(other: EventSet<Q>): Merged<P, Q>; |
| intersect<Q extends KeySet>(other: EventSet<Q>): Merged<P, Q>; |
| |
| // Methods for analysing the set. |
| // Note: these are all asynchronous - it's expected that these will |
| // often have to do queries. |
| count(): Promise<number>; |
| isEmpty(): Promise<boolean>; |
| materialise<T extends KeySet>( |
| keys: T, |
| offset?: number, |
| limit?: number, |
| ): Promise<Materialised<T, P>>; |
| } |
| |
| interface UnionEventSet<T extends KeySet> extends EventSet<T> { |
| readonly parents: EventSet<T>[]; |
| readonly isUnion: true; |
| create(...events: EventSet<KeySet>[]): UnionEventSet<T>; |
| } |
| |
| interface IntersectionEventSet<T extends KeySet> extends EventSet<T> { |
| readonly parents: EventSet<T>[]; |
| readonly isIntersection: true; |
| create(...events: EventSet<KeySet>[]): IntersectionEventSet<T>; |
| } |
| |
| interface FilterEventSet<T extends KeySet> extends EventSet<T> { |
| readonly parent: EventSet<T>; |
| readonly filters: Filter[]; |
| readonly isFilter: true; |
| } |
| |
| interface SortEventSet<T extends KeySet> extends EventSet<T> { |
| readonly parent: EventSet<T>; |
| readonly sorts: Sort[]; |
| readonly isSort: true; |
| } |
| |
| // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| export type UntypedEventSet = EventSet<any>; |
| |
| // 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, string>): 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<P extends KeySet> |
| implements EventSet<P> |
| { |
| 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<P> { |
| const result = new NaiveFilterEventSet(this, filters); |
| const optimised = optimise(result); |
| return optimised; |
| } |
| |
| sort(...sorts: Sort[]): EventSet<P> { |
| const result = new NaiveSortEventSet(this, sorts); |
| const optimised = optimise(result); |
| return optimised; |
| } |
| |
| union<Q extends KeySet>(other: EventSet<Q>): Merged<P, Q> { |
| const merged = mergeKeys(this.keys, other.keys); |
| const result = new NaiveUnionEventSet<MergedKeys<P, Q>>( |
| merged, |
| this as UntypedEventSet, |
| other as UntypedEventSet, |
| ); |
| const optimised = optimise(result); |
| return optimised; |
| } |
| |
| intersect<Q extends KeySet>(other: EventSet<Q>): Merged<P, Q> { |
| const merged = mergeKeys(this.keys, other.keys); |
| const result = new NaiveIntersectionEventSet<MergedKeys<P, Q>>( |
| 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<Q extends KeySet>( |
| keys: Q, |
| offset?: number, |
| limit?: number, |
| ): Promise<Materialised<Q, P>>; |
| |
| // 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<number> { |
| 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<boolean> { |
| const materialised = await this.materialise( |
| {}, |
| 0 /* offset */, |
| 1 /* limit */, |
| ); |
| return materialised.events.length === 0; |
| } |
| } |
| |
| class NaiveFilterEventSet<P extends KeySet> |
| extends OptimisingEventSet<P> |
| implements FilterEventSet<P> |
| { |
| readonly isFilter = true; |
| readonly parent: EventSet<P>; |
| readonly filters: Filter[]; |
| readonly keys: P; |
| |
| constructor(parent: EventSet<P>, filters: Filter[]) { |
| super(); |
| this.parent = parent; |
| this.keys = this.parent.keys; |
| this.filters = filters; |
| } |
| |
| async count(): Promise<number> { |
| 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<boolean> { |
| 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<Q extends KeySet>( |
| keys: Q, |
| offset?: number, |
| limit?: number, |
| ): Promise<Materialised<Q, P>> { |
| 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<P extends KeySet> |
| extends OptimisingEventSet<P> |
| implements SortEventSet<P> |
| { |
| readonly isSort = true; |
| readonly parent: EventSet<P>; |
| readonly sorts: Sort[]; |
| readonly keys: P; |
| |
| constructor(parent: EventSet<P>, sorts: Sort[]) { |
| super(); |
| this.parent = parent; |
| this.keys = this.parent.keys; |
| this.sorts = sorts; |
| } |
| |
| async count(): Promise<number> { |
| return this.parent.count(); |
| } |
| |
| async isEmpty(): Promise<boolean> { |
| return this.parent.isEmpty(); |
| } |
| |
| async materialise<Q extends KeySet>( |
| keys: Q, |
| offset?: number, |
| limit?: number, |
| ): Promise<Materialised<Q, P>> { |
| 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<T extends KeySet> |
| extends OptimisingEventSet<T> |
| implements UnionEventSet<T> |
| { |
| readonly isUnion = true; |
| readonly parents: EventSet<T>[]; |
| readonly keys: T; |
| |
| constructor(keys: T, ...parents: EventSet<T>[]) { |
| super(); |
| this.keys = keys; |
| this.parents = parents; |
| } |
| |
| create(...parents: EventSet<T>[]): NaiveUnionEventSet<T> { |
| 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<Q extends KeySet>( |
| keys: Q, |
| offset?: number, |
| limit?: number, |
| ): Promise<Materialised<Q, T>> { |
| const promises = this.parents.map((p) => p.materialise(keys)); |
| const materialisedParents = (await Promise.all( |
| promises, |
| )) as ConcreteEventSet<Q>[]; |
| const seen = new Set<string>(); |
| 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<Q, T>; |
| } |
| } |
| |
| export class NaiveIntersectionEventSet<T extends KeySet> |
| extends OptimisingEventSet<T> |
| implements IntersectionEventSet<T> |
| { |
| readonly isIntersection = true; |
| readonly parents: EventSet<T>[]; |
| readonly keys: T; |
| |
| constructor(keys: T, ...parents: EventSet<T>[]) { |
| super(); |
| this.keys = keys; |
| this.parents = parents; |
| } |
| |
| create(...parents: EventSet<T>[]): NaiveIntersectionEventSet<T> { |
| 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<Q extends KeySet>( |
| keys: Q, |
| offset?: number, |
| limit?: number, |
| ): Promise<Materialised<Q, T>> { |
| if (this.parents.length === 0) { |
| return ConcreteEventSet.from(keys, []) as Materialised<Q, T>; |
| } |
| |
| 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<Q>; |
| |
| const materialised = await Promise.all(promises); |
| const firstMaterialised = await firstPromise; |
| |
| let ids = new Set<string>(); |
| for (const e of firstMaterialised.events) { |
| ids.add(e.id); |
| } |
| for (const m of materialised) { |
| const newIds = new Set<string>(); |
| 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<Q, T>; |
| } |
| } |
| |
| // A completely empty EventSet. |
| export class EmptyEventSet<T extends KeySet> |
| extends OptimisingEventSet<T> |
| implements EventSet<T> |
| { |
| readonly isEmptyEventSet = true; |
| readonly keys: T; |
| |
| constructor(keys: T) { |
| super(); |
| this.keys = keys; |
| } |
| |
| static get(): EmptyEventSet<EmptyKeySet> { |
| return new EmptyEventSet<EmptyKeySet>({}); |
| } |
| |
| count(): Promise<number> { |
| return Promise.resolve(0); |
| } |
| |
| isEmpty(): Promise<boolean> { |
| return Promise.resolve(true); |
| } |
| |
| async materialise<Q extends KeySet>( |
| keys: Q, |
| _offset?: number, |
| _limit?: number, |
| ): Promise<Materialised<Q, T>> { |
| return Promise.resolve( |
| new ConcreteEventSet<Q>(keys, []) as unknown as Materialised<Q, T>, |
| ); |
| } |
| } |
| |
| export class ConcreteEventSet<P extends KeySet> |
| extends OptimisingEventSet<P> |
| implements EventSet<P> |
| { |
| readonly isConcreteEventSet = true; |
| readonly events: Event<P>[]; |
| readonly keys: P; |
| |
| static from<Q extends KeySet>( |
| keys: Q, |
| events: Event<Q>[], |
| ): ConcreteEventSet<Q> { |
| return new ConcreteEventSet<Q>(keys, events); |
| } |
| |
| constructor(keys: P, events: Event<P>[]) { |
| super(); |
| // TODO(hjd): Add some paranoid mode where we crash here if |
| // `events` and `keys` mismatch? |
| this.events = events; |
| this.keys = keys; |
| } |
| |
| count(): Promise<number> { |
| return Promise.resolve(this.events.length); |
| } |
| |
| isEmpty(): Promise<boolean> { |
| return Promise.resolve(this.events.length === 0); |
| } |
| |
| materialise<Q extends KeySet>( |
| keys: Q, |
| offset?: number, |
| limit?: number, |
| ): Promise<Materialised<Q, P>> { |
| 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<Q, P>); |
| } |
| |
| let events = this.events as Event<Q>[]; |
| |
| 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<Q>; |
| }); |
| } |
| |
| if (shouldSlice) { |
| events = events.slice(actualOffset, actualEnd); |
| } |
| |
| return Promise.resolve( |
| new ConcreteEventSet<Q>(keys, events) as unknown as Materialised<Q, P>, |
| ); |
| } |
| } |
| |
| // 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<T extends KeySet>(eventSet: EventSet<T>): EventSet<T> { |
| // 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<T>[] = 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<ConcreteEventSet<T>, EventSet<T>>( |
| isConcreteEventSet, |
| newParents, |
| ) |
| ) { |
| const seen = new Set<string>(); |
| 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, string>): 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, string>): 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, string>): 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<T extends KeySet>( |
| s: EventSet<T> | EmptyEventSet<T>, |
| ): s is EmptyEventSet<T> { |
| return !!(s as EmptyEventSet<T>).isEmptyEventSet; |
| } |
| |
| export function isConcreteEventSet<T extends KeySet>( |
| s: EventSet<T> | ConcreteEventSet<T>, |
| ): s is ConcreteEventSet<T> { |
| return !!(s as ConcreteEventSet<T>).isConcreteEventSet; |
| } |
| |
| export function isUnionEventSet<T extends KeySet>( |
| s: EventSet<T> | UnionEventSet<T>, |
| ): s is UnionEventSet<T> { |
| return ( |
| (s as UnionEventSet<T>).isUnion && |
| Array.isArray((s as UnionEventSet<T>).parents) |
| ); |
| } |
| |
| export function isIntersectionEventSet<T extends KeySet>( |
| s: EventSet<T> | IntersectionEventSet<T>, |
| ): s is IntersectionEventSet<T> { |
| return ( |
| (s as IntersectionEventSet<T>).isIntersection && |
| Array.isArray((s as IntersectionEventSet<T>).parents) |
| ); |
| } |
| |
| export function isFilterEventSet<T extends KeySet>( |
| s: EventSet<T> | FilterEventSet<T>, |
| ): s is FilterEventSet<T> { |
| return ( |
| (s as FilterEventSet<T>).isFilter && |
| Array.isArray((s as FilterEventSet<T>).filters) |
| ); |
| } |
| |
| export function isSortEventSet<T extends KeySet>( |
| s: EventSet<T> | SortEventSet<T>, |
| ): s is SortEventSet<T> { |
| return ( |
| (s as SortEventSet<T>).isSort && Array.isArray((s as SortEventSet<T>).sorts) |
| ); |
| } |
| |
| // STUPID_TYPE_MAGIC ================================================== |
| type ErrorBrand<T extends string> = { |
| [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> = T extends keyof KeyToType ? KeyToType[T] : void; |
| |
| type Materialised< |
| Concrete extends KeySet, |
| Parent extends KeySet, |
| > = Parent extends Concrete |
| ? ConcreteEventSet<Concrete> |
| : ErrorBrand<`Very bad!`>; |
| |
| type MergedKeys<Left extends KeySet, Right extends KeySet> = Left & Right; |
| |
| type Merged<Left extends KeySet, Right extends KeySet> = EventSet< |
| MergedKeys<Left, Right> |
| >; |
| |
| // HELPERS ============================================================ |
| function applyLimitOffset<T>(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<P extends KeySet, Q extends KeySet>( |
| left: P, |
| right: Q, |
| ): MergedKeys<P, Q> { |
| 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<T extends KeySet>( |
| expr: Expr, |
| ): (l: Event<T>, r: Event<T>) => number { |
| return (l: Event<T>, r: Event<T>) => { |
| 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<T extends KeySet>( |
| sort: Sort, |
| ): (l: Event<T>, r: Event<T>) => number { |
| const cmp = cmpFromExpr<T>(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<T>, r: Event<T>) => cmp(r, l); |
| } |
| } |