@uwdata/mosaic-core
Version:
Scalable and extensible linked data views.
458 lines (415 loc) • 16.6 kB
text/typescript
import { ExprNode, ScaleOptions, SelectQuery, Query, ExprValue, MaybeArray, FunctionNode, BetweenOpNode, AndNode } from '@uwdata/mosaic-sql';
import type { Coordinator } from '../Coordinator.js';
import type { MosaicClient } from '../MosaicClient.js';
import type { Selection } from '../Selection.js';
import type { BinMethod, ClauseSource, IntervalMetadata, SelectionClause } from '../SelectionClause.js';
import { Query as QueryBuilder, and, asNode, ceil, collectColumns, createTable, float64, floor, isBetween, int32, mul, round, scaleTransform, sub, isSelectQuery, isAggregateExpression, ColumnNameRefNode } from '@uwdata/mosaic-sql';
import { preaggColumns, PreAggColumnsResult } from './preagg-columns.js';
import { fnv_hash } from '../util/hash.js';
const Skip = { skip: true, result: null };
export interface PreAggregateOptions {
/** Database schema (namespace) in which to write pre-aggregated materialized views (default 'mosaic'). */
schema?: string;
/** Flag to enable or disable the pre-aggregation. This flag can be updated later via the `enabled` property. */
enabled?: boolean;
}
type ActivePredicate = (p?: ExprNode) => MaybeArray<ExprNode> | undefined;
interface ActiveColumnsResult {
source: ClauseSource | null;
columns?: Record<string, ExprNode>;
predicate?: ActivePredicate;
}
interface PreAggregateInfoOptions {
table: string;
create: string;
active: ActiveColumnsResult;
select: SelectQuery;
}
/**
* Build and query optimized pre-aggregated materaialized views, for fast
* computation of groupby aggregate queries over compatible client queries
* and selections. The materialized views contains pre-aggregated data for a
* Mosaic client, subdivided by possible query values from an active selection
* clause. These materialized views are database tables that can be queried
* for rapid updates.
*
* Compatible client queries must consist of only groupby dimensions and
* supported aggregate functions. Compatible selections must contain an active
* clause that exposes metadata for an interval or point value predicate.
*
* Materialized views are written to a dedicated schema (namespace) that
* can be set using the *schema* constructor option. This schema acts as a
* persistent cache, and materialized view tables may be used across sessions.
* The `dropSchema` method issues a query to remove *all* tables within this
* schema. This may be needed if the original tables have updated data, but
* should be used with care.
*/
export class PreAggregator {
public entries: Map<MosaicClient, PreAggregateInfo | typeof Skip | null>;
private active: ActiveColumnsResult | null;
private mc: Coordinator;
private _schema: string;
private _enabled: boolean;
/**
* Create a new manager of materialized views of pre-aggregated data.
* @param coordinator A Mosaic coordinator.
* @param options Pre-aggregation options.
*/
constructor(coordinator: Coordinator, {
schema = 'mosaic',
enabled = true
}: PreAggregateOptions = {}) {
this.entries = new Map();
this.active = null;
this.mc = coordinator;
this._schema = schema;
this._enabled = enabled;
}
/**
* Set the enabled state of this manager. If false, any local state is
* cleared and subsequent request calls will return null until re-enabled.
* This method has no effect on any pre-aggregated tables already in the
* database.
* @param state The enabled state to set.
*/
set enabled(state: boolean) {
if (this._enabled !== state) {
if (!state) this.clear();
this._enabled = state;
}
}
/**
* Get the enabled state of this manager.
* @returns The current enabled state.
*/
get enabled(): boolean {
return this._enabled;
}
/**
* Set the database schema used for pre-aggregated materialized view tables.
* Upon changes, any local state is cleared. This method does _not_ drop any
* existing materialized views, use `dropSchema` before changing the schema
* to also remove existing materalized views in the database.
* @param schema The schema name to set.
*/
set schema(schema: string) {
if (this._schema !== schema) {
this.clear();
this._schema = schema;
}
}
/**
* Get the database schema used for pre-aggregated materialized view tables.
* @returns The current schema name.
*/
get schema(): string {
return this._schema;
}
/**
* Issues a query through the coordinator to drop the current schema for
* pre-aggregated materialized views. *All* materialized view tables in the
* schema will be removed and local state is cleared. Call this method if
* the underlying base tables have been updated, causing materialized view
* to become stale and inaccurate. Use this method with care! Once dropped,
* the schema will be repopulated by future pre-aggregation requests.
* @returns A query result promise.
*/
dropSchema(): Promise<unknown> {
this.clear();
return this.mc.exec(`DROP SCHEMA IF EXISTS "${this.schema}" CASCADE`);
}
/**
* Clear the cache of pre-aggregation entries for the current active
* selection clause. This method does _not_ drop any existing materialized
* views. Use `dropSchema` to remove existing materialized view tables from
* the database.
*/
clear(): void {
this.entries.clear();
this.active = null;
}
/**
* Return pre-aggregation information for the active state of a
* client-selection pair, or null if the client has unstable filters.
* This method has multiple possible side effects, including materialized
* view creation and updating internal caches.
* @param client A Mosaic client.
* @param selection A Mosaic selection to filter the client by.
* @param activeClause A representative active selection
* clause for which to generate materialized views of pre-aggregates.
* @returns Information and query generator
* for pre-aggregated tables, or null if the client has unstable filters.
*/
request(client: MosaicClient, selection: Selection, activeClause: SelectionClause | null): PreAggregateInfo | typeof Skip | null {
// if not enabled, do nothing
if (!this.enabled || activeClause == null) return null;
const { entries, mc, schema } = this;
const { source } = activeClause;
// if there is no clause source to track, do nothing
if (!source) return null;
// if we have cached active columns, check for updates or exit
if (this.active) {
// if the active clause source has changed, clear the state
// this cancels outstanding requests and clears the local cache
// a clear also sets this.active to null
if (this.active.source !== source) this.clear();
// if we've seen this source and it has unstable filters, do nothing
if (this.active?.source === null) return null;
}
// the current active columns cache value
let { active } = this;
// if cached active columns are unset, analyze the active clause
if (!active) {
// if active clause predicate is null, we can't analyze it
// return null to backoff to standard client query
// non-null clauses may come later, so don't set active state
if (activeClause.predicate == null) return null;
// generate active dimension columns to select over
// will return an object with null source if it has unstable filters
this.active = active = activeColumns(activeClause);
// if the active clause has unstable filters, exit now
if (active.source === null) return null;
}
// if we have cached pre-aggregate info, return that
if (entries.has(client)) {
return entries.get(client)!;
}
// get non-active materialized view columns
const preaggCols = preaggColumns(client);
let info: PreAggregateInfo | typeof Skip | null;
if (!preaggCols) {
// if client is not indexable, record null info
info = null;
} else if (selection.skip(client, activeClause)) {
// skip client if untouched by cross-filtering
info = Skip;
} else {
// generate materialized view table
const filter = selection.remove(source).predicate(client);
info = preaggregateInfo(
client.query(filter) as SelectQuery,
active, preaggCols, schema
);
info.result = mc.exec([
`CREATE SCHEMA IF NOT EXISTS ${schema}`,
createTable(info.table, info.create, { temp: false })
]);
info.result.catch((e: Error) => mc.logger().error(e));
}
entries.set(client, info);
return info;
}
}
/**
* Determines the active dimension columns to select over. Returns an object
* with the clause source, column definitions, and a predicate generator
* function for the active dimensions of a pre-aggregated materialized view.
* If the active clause is not indexable or is missing metadata, this method
* returns an object with a null source property.
* @param clause The active selection clause to analyze.
*/
function activeColumns(clause: SelectionClause): ActiveColumnsResult {
const { source, meta } = clause;
const clausePred = clause.predicate;
const clauseCols = collectColumns(clausePred!).map(c => c.column);
let predicate: ActivePredicate | undefined;
let columns: Record<string, ExprNode> | undefined;
if (!meta || !clauseCols) {
return { source: null, columns, predicate };
}
switch (meta.type) {
case 'point':
predicate = x => x;
columns = Object.fromEntries(
clauseCols.map(col => [`${col}`, asNode(col)])
);
break;
case 'interval': {
const { scales, bin, pixelSize = 1 } = (meta as IntervalMetadata);
if (!scales) break;
// determine pixel-level binning
const bins = scales.map((s: ScaleOptions) => binInterval(s, pixelSize, bin));
if (bins.some(b => !b)) {
// bail if a scale type is unsupported
} else if (bins.length === 1) {
// selection clause predicate has type BetweenOpNode
// single interval selection
predicate = (p?: ExprNode) => p
? isBetween('active0', (p as BetweenOpNode).extent?.map(bins[0]!))
: [];
columns = { active0: bins[0]!((clausePred as BetweenOpNode).expr) };
} else {
// selection clause predicate has type AndNode<BetweenOpNode>
// multiple interval selection
predicate = (p?: ExprNode) => p
? and((p as AndNode<BetweenOpNode>).clauses.map(
(c, i) => isBetween(`active${i}`, c.extent?.map(bins[i]!))
))
: [];
columns = Object.fromEntries(
(clausePred as AndNode<BetweenOpNode>).clauses.map(
(p, i) => [`active${i}`, bins[i]!(p.expr)]
)
);
}
}
}
return { source: columns ? source : null, columns, predicate };
}
const BIN: Record<string, (expr: ExprValue) => FunctionNode> = { ceil, round };
/**
* Returns a bin function generator to discretize a selection interval domain.
* @param scale A scale that maps domain values to the output range
* (typically pixels).
* @param pixelSize The interactive pixel size. This value indicates
* the bin step size and may be greater than an actual screen pixel.
* @param bin The binning method to apply, one of `floor`, `ceil', or `round`.
* @returns A bin function generator.
*/
function binInterval(
scale: ScaleOptions,
pixelSize: number,
bin?: BinMethod
): ((value: ExprValue) => ExprNode) | undefined {
const { type, domain, range, apply, sqlApply } = scaleTransform(scale)!;
if (!apply) return; // unsupported scale type
const binFn = BIN[`${bin}`.toLowerCase()] || floor;
const dom = domain!.map(x => Number(x));
const lo = apply(Math.min(...dom));
const hi = apply(Math.max(...dom));
const s = (type === 'identity'
? 1
: Math.abs(range![1] - range![0]) / (hi - lo)) / pixelSize;
const scalar = s === 1
? (x: ExprValue) => x
: (x: ExprValue) => mul(float64(s), x);
const diff = lo === 0
? (x: ExprValue) => x
: (x: ExprValue) => sub(x, float64(lo));
return value => int32(binFn(scalar(diff(sqlApply(value)))));
}
/**
* Generate pre-aggregate query information.
* @param clientQuery The original client query.
* @param active Active (selected) columns.
* @param preaggCols Pre-aggregation columns.
* @param schema Database schema name.
* @returns Pre-aggregation information.
*/
function preaggregateInfo(
clientQuery: SelectQuery,
active: ActiveColumnsResult,
preaggCols: PreAggColumnsResult,
schema: string
): PreAggregateInfo {
const { group, output, preagg } = preaggCols;
const { columns } = active;
// build materialized view construction query
const query = clientQuery
.setSelect({ ...preagg, ...columns })
.groupby(Object.keys(columns!));
// ensure active clause columns are selected by subqueries
const [subq] = query.subqueries;
if (subq) {
const cols = Object.values(columns!)
.flatMap(c => collectColumns(c).map(c => c.column));
subqueryPushdown(subq, cols);
}
// push any having or orderby criteria to output queries
const having = query._having;
const order = query._orderby;
query._having = [];
query._orderby = [];
// generate creation query string and hash id
const create = query.toString();
const id = (fnv_hash(create) >>> 0).toString(16);
const table = `${schema}.preagg_${id}`;
// generate preaggregate select query
const select = QueryBuilder
.select(group, output)
.from(table)
.groupby(group)
.having(having)
.orderby(order);
return new PreAggregateInfo({ table, create, active, select });
}
/**
* Push column selections down to subqueries.
* @param query The (sub)query to push down to.
* @param cols The column names to push down.
*/
function subqueryPushdown(query: Query, cols: string[]): void {
const memo = new Set();
const pushdown = (q: Query) => {
// it is possible to have duplicate subqueries
// so we memoize and exit early if already seen
if (memo.has(q)) return;
memo.add(q);
if (isSelectQuery(q) && q._from.length) {
// select the pushed down columns
// note that the select method will deduplicate for us
q.select(cols);
if (isAggregateQuery(q)) {
// if an aggregation query, we need to push to groupby as well
// we also deduplicate as the column may already be present
const set = new Set(
q._groupby.flatMap(x => x instanceof ColumnNameRefNode ? [x.name] : [])
);
q.groupby(cols.filter(c => !set.has(c)));
}
}
q.subqueries.forEach(pushdown);
};
pushdown(query);
}
/**
* Test if a query performs aggregation.
* @param query Select query to test.
* @returns True if query performs aggregation.
*/
function isAggregateQuery(query: SelectQuery): boolean {
return query._groupby.length > 0
|| query._select.some(node => isAggregateExpression(node));
}
/**
* Metadata and query generator for materialized views of pre-aggregated data.
* This object provides the information needed to generate and query the
* materialized views for a client-selection pair relative to a specific
* active clause and selection state.
*/
export class PreAggregateInfo {
/** The name of the materialized view. */
table: string;
/** The SQL query used to generate the materialized view. */
create: string;
/** A result promise returned for the materialized view creation query. */
result: Promise<unknown> | null;
/** Definitions and predicate function for the active columns,
* which are dynamically filtered by the active clause. */
active: ActiveColumnsResult;
/** Select query (sans where clause) for materialized views. */
select: SelectQuery;
/** Boolean flag indicating a client that should be skipped.
* This value is always false for a created materialized view. */
skip: boolean;
/**
* Create a new pre-aggregation information instance.
* @param options Options object.
*/
constructor({ table, create, active, select }: PreAggregateInfoOptions) {
this.table = table;
this.create = create;
this.result = null;
this.active = active;
this.select = select;
this.skip = false;
}
/**
* Generate a materialized view query for the given predicate.
* @param predicate The current active clause predicate.
* @returns A materialized view query.
*/
query(predicate: ExprNode): SelectQuery {
return this.select.clone().where(this.active.predicate!(predicate)!);
}
}