@uwdata/mosaic-core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,194 +1,211 @@
1
- import { Query, and, asColumn, create, isBetween, scaleTransform, sql } from '@uwdata/mosaic-sql';
2
- import { fnv_hash } from './util/hash.js';
1
+ import {
2
+ Query, and, asColumn, create, isBetween, scaleTransform, sql
3
+ } from '@uwdata/mosaic-sql';
3
4
  import { indexColumns } from './util/index-columns.js';
5
+ import { fnv_hash } from './util/hash.js';
6
+
7
+ const Skip = { skip: true, result: null };
4
8
 
5
9
  /**
6
10
  * Build and query optimized indices ("data cubes") for fast computation of
7
11
  * groupby aggregate queries over compatible client queries and selections.
8
12
  * A data cube contains pre-aggregated data for a Mosaic client, subdivided
9
- * by possible query values from an active view. Indexes are realized as
10
- * as temporary database tables that can be queried for rapid updates.
11
- * Compatible client queries must pull data from the same backing table and
12
- * must consist of only groupby dimensions and supported aggregates.
13
- * Compatible selections must contain an active clause that exposes metadata
14
- * for an interval or point value predicate.
13
+ * by possible query values from an active selection clause. These cubes are
14
+ * realized as as database tables that can be queried for rapid updates.
15
+ * Compatible client queries must consist of only groupby dimensions and
16
+ * supported aggregate functions. Compatible selections must contain an active
17
+ * clause that exposes metadata for an interval or point value predicate.
15
18
  */
16
19
  export class DataCubeIndexer {
17
20
  /**
18
- *
19
- * @param {import('./Coordinator.js').Coordinator} mc a Mosaic coordinator
20
- * @param {*} options Options hash to configure the data cube indexes and pass selections to the coordinator.
21
+ * Create a new data cube index table manager.
22
+ * @param {import('./Coordinator.js').Coordinator} coordinator A Mosaic coordinator.
23
+ * @param {object} [options] Indexer options.
24
+ * @param {boolean} [options.enabled=true] Flag to enable/disable indexer.
25
+ * @param {boolean} [options.temp=true] Flag to indicate if generated data
26
+ * cube index tables should be temporary tables.
21
27
  */
22
- constructor(mc, { selection, temp = true }) {
23
- /** @type import('./Coordinator.js').Coordinator */
24
- this.mc = mc;
25
- this.selection = selection;
28
+ constructor(coordinator, {
29
+ enabled = true,
30
+ temp = true
31
+ } = {}) {
32
+ /** @type {Map<import('./MosaicClient.js').MosaicClient, DataCubeInfo | Skip | null>} */
33
+ this.indexes = new Map();
34
+ this.active = null;
26
35
  this.temp = temp;
27
- this.reset();
36
+ this.mc = coordinator;
37
+ this._enabled = enabled;
28
38
  }
29
39
 
30
- reset() {
31
- this.enabled = false;
32
- this.clients = null;
33
- this.indices = null;
34
- this.active = null;
40
+ /**
41
+ * Set the enabled state of this indexer. If false, any cached state is
42
+ * cleared and subsequent index calls will return null until re-enabled.
43
+ * @param {boolean} state The enabled state.
44
+ */
45
+ enabled(state) {
46
+ if (state === undefined) {
47
+ return this._enabled;
48
+ } else if (this._enabled !== state) {
49
+ if (!state) this.clear();
50
+ this._enabled = state;
51
+ }
35
52
  }
36
53
 
54
+ /**
55
+ * Clear the cache of data cube index table entries for the current active
56
+ * selection clause. This method will also cancel any queued data cube table
57
+ * creation queries that have not yet been submitted to the database. This
58
+ * method does _not_ drop any existing data cube tables.
59
+ */
37
60
  clear() {
38
- if (this.indices) {
39
- this.mc.cancel(Array.from(this.indices.values(), index => index?.result));
40
- this.indices = null;
41
- }
61
+ this.mc.cancel(Array.from(this.indexes.values(), info => info?.result));
62
+ this.indexes.clear();
63
+ this.active = null;
42
64
  }
43
65
 
44
- index(clients, activeClause) {
45
- if (this.clients !== clients) {
46
- // test client views for compatibility
47
- const cols = Array.from(clients, indexColumns).filter(x => x);
48
- const from = cols[0]?.from;
49
- this.enabled = cols.length && cols.every(c => c.from === from);
50
- this.clients = clients;
51
- this.active = null;
52
- this.clear();
53
- }
54
- if (!this.enabled) return false; // client views are not indexable
66
+ /**
67
+ * Return data cube index table information for the active state of a
68
+ * client-selection pair, or null if the client is not indexable. This
69
+ * method has multiple possible side effects, including data cube table
70
+ * generation and updating internal caches.
71
+ * @param {import('./MosaicClient.js').MosaicClient} client A Mosaic client.
72
+ * @param {import('./Selection.js').Selection} selection A Mosaic selection
73
+ * to filter the client by.
74
+ * @param {import('./util/selection-types.js').SelectionClause} activeClause
75
+ * A representative active selection clause for which to (possibly) generate
76
+ * data cube index tables.
77
+ * @returns {DataCubeInfo | Skip | null} Data cube index table
78
+ * information and query generator, or null if the client is not indexable.
79
+ */
80
+ index(client, selection, activeClause) {
81
+ // if not enabled, do nothing
82
+ if (!this._enabled) return null;
55
83
 
56
- activeClause = activeClause || this.selection.active;
84
+ const { indexes, mc, temp } = this;
57
85
  const { source } = activeClause;
58
- // exit early if indexes already set up for active view
59
- if (source && source === this.active?.source) return true;
60
-
61
- this.clear();
62
- if (!source) return false; // nothing to work with
63
- const active = this.active = activeColumns(activeClause);
64
- if (!active) return false; // active selection clause not compatible
65
-
66
- const logger = this.mc.logger();
67
- logger.warn('DATA CUBE INDEX CONSTRUCTION');
68
-
69
- // create a selection with the active source removed
70
- const sel = this.selection.remove(source);
71
-
72
- // generate data cube indices
73
- const indices = this.indices = new Map;
74
- const { mc, temp } = this;
75
- for (const client of clients) {
76
- // determine if client should be skipped due to cross-filtering
77
- if (sel.skip(client, activeClause)) {
78
- indices.set(client, null);
79
- continue;
80
- }
81
-
82
- // generate column definitions for data cube and cube queries
83
- const index = indexColumns(client);
84
-
85
- // skip if client is not indexable
86
- if (!index) {
87
- continue;
88
- }
89
-
90
- // build index table construction query
91
- const query = client.query(sel.predicate(client))
92
- .select({ ...active.columns, ...index.aux })
93
- .groupby(Object.keys(active.columns));
94
-
95
- // ensure active view columns are selected by subqueries
96
- const [subq] = query.subqueries;
97
- if (subq) {
98
- const cols = Object.values(active.columns).flatMap(c => c.columns);
99
- subqueryPushdown(subq, cols);
100
- }
101
-
102
- // push orderby criteria to later cube queries
103
- const order = query.orderby();
104
- query.query.orderby = [];
105
-
106
- const sql = query.toString();
107
- const id = (fnv_hash(sql) >>> 0).toString(16);
108
- const table = `cube_index_${id}`;
109
- const result = mc.exec(create(table, sql, { temp }));
110
- result.catch(e => logger.error(e));
111
- indices.set(client, { table, result, order, ...index });
86
+
87
+ // if there is no clause source to track, do nothing
88
+ if (!source) return null;
89
+
90
+ // if we have cached active columns, check for updates or exit
91
+ if (this.active) {
92
+ // if the active clause source has changed, clear indexer state
93
+ // this cancels outstanding requests and clears the index cache
94
+ // a clear also sets this.active to null
95
+ if (this.active.source !== source) this.clear();
96
+ // if we've seen this source and it's not indexable, do nothing
97
+ if (this.active?.source === null) return null;
112
98
  }
113
99
 
114
- // index creation successful
115
- return true;
116
- }
100
+ // the current active columns cache value
101
+ let { active } = this;
117
102
 
118
- async update() {
119
- const { clients, selection, active } = this;
120
- const filter = active.predicate(selection.active.predicate);
121
- return Promise.all(
122
- Array.from(clients).map(client => this.updateClient(client, filter))
123
- );
124
- }
103
+ // if cached active columns are unset, analyze the active clause
104
+ if (!active) {
105
+ // generate active data cube dimension columns to select over
106
+ // will return an object with null source if not indexable
107
+ this.active = active = activeColumns(activeClause);
108
+ // if the active clause is not indexable, exit now
109
+ if (active.source === null) return null;
110
+ }
111
+
112
+ // if we have cached data cube index table info, return that
113
+ if (indexes.has(client)) {
114
+ return indexes.get(client);
115
+ }
116
+
117
+ // get non-active data cube index table columns
118
+ const indexCols = indexColumns(client);
125
119
 
126
- async updateClient(client, filter) {
127
- const { mc, indices, selection } = this;
128
-
129
- // if client has no index, perform a standard update
130
- if (!indices.has(client)) {
131
- filter = selection.predicate(client);
132
- return mc.updateClient(client, client.query(filter));
133
- };
134
-
135
- const index = this.indices.get(client);
136
-
137
- // skip update if cross-filtered
138
- if (!index) return;
139
-
140
- // otherwise, query a data cube index table
141
- const { table, dims, aggr, order = [] } = index;
142
- const query = Query
143
- .select(dims, aggr)
144
- .from(table)
145
- .groupby(dims)
146
- .where(filter)
147
- .orderby(order);
148
- return mc.updateClient(client, query);
120
+ let info;
121
+ if (!indexCols) {
122
+ // if client is not indexable, record null index
123
+ info = null;
124
+ } else if (selection.skip(client, activeClause)) {
125
+ // skip client if untouched by cross-filtering
126
+ info = Skip;
127
+ } else {
128
+ // generate data cube index table
129
+ const filter = selection.remove(source).predicate(client);
130
+ info = dataCubeInfo(client.query(filter), active, indexCols);
131
+ info.result = mc.exec(create(info.table, info.create, { temp }));
132
+ info.result.catch(e => mc.logger().error(e));
133
+ }
134
+
135
+ indexes.set(client, info);
136
+ return info;
149
137
  }
150
138
  }
151
139
 
140
+ /**
141
+ * Determines the active data cube dimension columns to select over. Returns
142
+ * an object with the clause source, column definitions, and a predicate
143
+ * generator function for the active dimensions of a data cube index table. If
144
+ * the active clause is not indexable or is missing metadata, this method
145
+ * returns an object with a null source property.
146
+ * @param {import('./util/selection-types.js').SelectionClause} clause The
147
+ * active selection clause to analyze.
148
+ */
152
149
  function activeColumns(clause) {
153
150
  const { source, meta } = clause;
154
- let columns = clause.predicate?.columns;
155
- if (!meta || !columns) return null;
156
- const { type, scales, bin, pixelSize = 1 } = meta;
151
+ const clausePred = clause.predicate;
152
+ const clauseCols = clausePred?.columns;
157
153
  let predicate;
154
+ let columns;
155
+
156
+ if (!meta || !clauseCols) {
157
+ return { source: null, columns, predicate };
158
+ }
158
159
 
159
- if (type === 'interval' && scales) {
160
+ // @ts-ignore
161
+ const { type, scales, bin, pixelSize = 1 } = meta;
162
+
163
+ if (type === 'point') {
164
+ predicate = x => x;
165
+ columns = Object.fromEntries(
166
+ clauseCols.map(col => [`${col}`, asColumn(col)])
167
+ );
168
+ } else if (type === 'interval' && scales) {
160
169
  // determine pixel-level binning
161
170
  const bins = scales.map(s => binInterval(s, pixelSize, bin));
162
171
 
163
- // bail if the scale type is unsupported
164
- if (bins.some(b => b == null)) return null;
165
-
166
- if (bins.length === 1) {
172
+ if (bins.some(b => !b)) {
173
+ // bail if a scale type is unsupported
174
+ } else if (bins.length === 1) {
167
175
  // single interval selection
168
176
  predicate = p => p ? isBetween('active0', p.range.map(bins[0])) : [];
169
- columns = { active0: bins[0](clause.predicate.field) };
177
+ // @ts-ignore
178
+ columns = { active0: bins[0](clausePred.field) };
170
179
  } else {
171
180
  // multiple interval selection
172
181
  predicate = p => p
173
- ? and(p.children.map(({ range }, i) => isBetween(`active${i}`, range.map(bins[i]))))
182
+ ? and(p.children.map(
183
+ ({ range }, i) => isBetween(`active${i}`, range.map(bins[i]))
184
+ ))
174
185
  : [];
175
186
  columns = Object.fromEntries(
176
- clause.predicate.children.map((p, i) => [`active${i}`, bins[i](p.field)])
187
+ // @ts-ignore
188
+ clausePred.children.map((p, i) => [`active${i}`, bins[i](p.field)])
177
189
  );
178
190
  }
179
- } else if (type === 'point') {
180
- predicate = x => x;
181
- columns = Object.fromEntries(columns.map(col => [`${col}`, asColumn(col)]));
182
- } else {
183
- // unsupported selection type
184
- return null;
185
191
  }
186
192
 
187
- return { source, columns, predicate };
193
+ return { source: columns ? source : null, columns, predicate };
188
194
  }
189
195
 
190
196
  const BIN = { ceil: 'CEIL', round: 'ROUND' };
191
197
 
198
+ /**
199
+ * Returns a bin function generator to discretize a selection interval domain.
200
+ * @param {import('./util/selection-types.js').Scale} scale A scale that maps
201
+ * domain values to the output range (typically pixels).
202
+ * @param {number} pixelSize The interactive pixel size. This value indicates
203
+ * the bin step size and may be greater than an actual screen pixel.
204
+ * @param {import('./util/selection-types.js').BinMethod} bin The binning
205
+ * method to apply, one of `floor`, `ceil', or `round`.
206
+ * @returns {(value: any) => import('@uwdata/mosaic-sql').SQLExpression}
207
+ * A bin function generator.
208
+ */
192
209
  function binInterval(scale, pixelSize, bin) {
193
210
  const { type, domain, range, apply, sqlApply } = scaleTransform(scale);
194
211
  if (!apply) return; // unsupported scale type
@@ -201,6 +218,51 @@ function binInterval(scale, pixelSize, bin) {
201
218
  return value => sql`${fn}(${s}(${sqlApply(value)}${d}))::INTEGER`;
202
219
  }
203
220
 
221
+ /**
222
+ * Generate data cube table query information.
223
+ * @param {Query} clientQuery The original client query.
224
+ * @param {*} active Active (selected) column definitions.
225
+ * @param {*} indexCols Data cube index column definitions.
226
+ * @returns {DataCubeInfo}
227
+ */
228
+ function dataCubeInfo(clientQuery, active, indexCols) {
229
+ const { dims, aggr, aux } = indexCols;
230
+ const { columns } = active;
231
+
232
+ // build index table construction query
233
+ const query = clientQuery
234
+ .select({ ...columns, ...aux })
235
+ .groupby(Object.keys(columns));
236
+
237
+ // ensure active clause columns are selected by subqueries
238
+ const [subq] = query.subqueries;
239
+ if (subq) {
240
+ const cols = Object.values(columns).flatMap(c => c.columns);
241
+ subqueryPushdown(subq, cols);
242
+ }
243
+
244
+ // push orderby criteria to later cube queries
245
+ const order = query.orderby();
246
+ query.query.orderby = [];
247
+
248
+ // generate creation query string and hash id
249
+ const create = query.toString();
250
+ const id = (fnv_hash(create) >>> 0).toString(16);
251
+ const table = `cube_index_${id}`;
252
+
253
+ // generate data cube select query
254
+ const select = Query
255
+ .select(dims, aggr)
256
+ .from(table)
257
+ .groupby(dims)
258
+ .orderby(order);
259
+
260
+ return new DataCubeInfo({ table, create, active, select });
261
+ }
262
+
263
+ /**
264
+ * Push column selections down to subqueries.
265
+ */
204
266
  function subqueryPushdown(query, cols) {
205
267
  const memo = new Set;
206
268
  const pushdown = q => {
@@ -213,3 +275,46 @@ function subqueryPushdown(query, cols) {
213
275
  };
214
276
  pushdown(query);
215
277
  }
278
+
279
+ /**
280
+ * Metadata and query generator for a data cube index table. This
281
+ * object provides the information needed to generate and query
282
+ * a data cube index table for a client-selection pair relative to
283
+ * a specific active clause and selection state.
284
+ */
285
+ export class DataCubeInfo {
286
+ /**
287
+ * Create a new DataCubeInfo instance.
288
+ * @param {object} options
289
+ */
290
+ constructor({ table, create, active, select } = {}) {
291
+ /** The name of the data cube index table. */
292
+ this.table = table;
293
+ /** The SQL query used to generate the data cube index table. */
294
+ this.create = create;
295
+ /** A result promise returned for the data cube creation query. */
296
+ this.result = null;
297
+ /**
298
+ * Definitions and predicate function for the active columns,
299
+ * which are dynamically filtered by the active clause.
300
+ */
301
+ this.active = active;
302
+ /** Select query (sans where clause) for data cube tables. */
303
+ this.select = select;
304
+ /**
305
+ * Boolean flag indicating a client that should be skipped.
306
+ * This value is always false for completed data cube info.
307
+ */
308
+ this.skip = false;
309
+ }
310
+
311
+ /**
312
+ * Generate a data cube index table query for the given predicate.
313
+ * @param {import('@uwdata/mosaic-sql').SQLExpression} predicate The current
314
+ * active clause predicate.
315
+ * @returns {Query} A data cube index table query.
316
+ */
317
+ query(predicate) {
318
+ return this.select.clone().where(this.active.predicate(predicate));
319
+ }
320
+ }
@@ -94,8 +94,8 @@ export class MosaicClient {
94
94
  * @param {*} error
95
95
  * @returns {this}
96
96
  */
97
- queryError(error) {
98
- console.error(error);
97
+ queryError(error) { // eslint-disable-line no-unused-vars
98
+ // do nothing, the coordinator logs the error
99
99
  return this;
100
100
  }
101
101
 
@@ -122,7 +122,7 @@ export class MosaicClient {
122
122
  /**
123
123
  * Requests a client update.
124
124
  * For example to (re-)render an interface component.
125
- *
125
+ *
126
126
  * @returns {this | Promise<any>}
127
127
  */
128
128
  update() {
@@ -1,5 +1,5 @@
1
1
  import { Query, Ref, isDescribeQuery } from '@uwdata/mosaic-sql';
2
- import { queryResult } from './util/query-result.js';
2
+ import { QueryResult } from './util/query-result.js';
3
3
 
4
4
  function wait(callback) {
5
5
  const method = typeof requestAnimationFrame !== 'undefined'
@@ -133,7 +133,7 @@ function consolidate(group, enqueue, record) {
133
133
  record: false,
134
134
  query: (group.query = consolidatedQuery(group, record))
135
135
  },
136
- result: (group.result = queryResult())
136
+ result: (group.result = new QueryResult())
137
137
  });
138
138
  } else {
139
139
  // issue queries directly
@@ -1,7 +1,7 @@
1
1
  import { consolidator } from './QueryConsolidator.js';
2
2
  import { lruCache, voidCache } from './util/cache.js';
3
3
  import { priorityQueue } from './util/priority-queue.js';
4
- import { queryResult } from './util/query-result.js';
4
+ import { QueryResult } from './util/query-result.js';
5
5
 
6
6
  export const Priority = { High: 0, Normal: 1, Low: 2 };
7
7
 
@@ -96,7 +96,7 @@ export class QueryManager {
96
96
  }
97
97
 
98
98
  request(request, priority = Priority.Normal) {
99
- const result = queryResult();
99
+ const result = new QueryResult();
100
100
  const entry = { request, result };
101
101
  if (this._consolidate) {
102
102
  this._consolidate.add(entry, priority);
@@ -108,7 +108,9 @@ export class QueryManager {
108
108
 
109
109
  cancel(requests) {
110
110
  const set = new Set(requests);
111
- this.queue.remove(({ result }) => set.has(result));
111
+ if (set.size) {
112
+ this.queue.remove(({ result }) => set.has(result));
113
+ }
112
114
  }
113
115
 
114
116
  clear() {
package/src/Selection.js CHANGED
@@ -22,10 +22,13 @@ export class Selection extends Param {
22
22
  * @param {boolean} [options.cross=false] Boolean flag indicating
23
23
  * cross-filtered resolution. If true, selection clauses will not
24
24
  * be applied to the clients they are associated with.
25
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
26
+ * of clauses should correspond to an empty selection with no records. This
27
+ * setting determines the default selection state.
25
28
  * @returns {Selection} The new Selection instance.
26
29
  */
27
- static intersect({ cross = false } = {}) {
28
- return new Selection(new SelectionResolver({ cross }));
30
+ static intersect({ cross = false, empty = false } = {}) {
31
+ return new Selection(new SelectionResolver({ cross, empty }));
29
32
  }
30
33
 
31
34
  /**
@@ -35,10 +38,13 @@ export class Selection extends Param {
35
38
  * @param {boolean} [options.cross=false] Boolean flag indicating
36
39
  * cross-filtered resolution. If true, selection clauses will not
37
40
  * be applied to the clients they are associated with.
41
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
42
+ * of clauses should correspond to an empty selection with no records. This
43
+ * setting determines the default selection state.
38
44
  * @returns {Selection} The new Selection instance.
39
45
  */
40
- static union({ cross = false } = {}) {
41
- return new Selection(new SelectionResolver({ cross, union: true }));
46
+ static union({ cross = false, empty = false } = {}) {
47
+ return new Selection(new SelectionResolver({ cross, empty, union: true }));
42
48
  }
43
49
 
44
50
  /**
@@ -48,19 +54,26 @@ export class Selection extends Param {
48
54
  * @param {boolean} [options.cross=false] Boolean flag indicating
49
55
  * cross-filtered resolution. If true, selection clauses will not
50
56
  * be applied to the clients they are associated with.
57
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
58
+ * of clauses should correspond to an empty selection with no records. This
59
+ * setting determines the default selection state.
51
60
  * @returns {Selection} The new Selection instance.
52
61
  */
53
- static single({ cross = false } = {}) {
54
- return new Selection(new SelectionResolver({ cross, single: true }));
62
+ static single({ cross = false, empty = false } = {}) {
63
+ return new Selection(new SelectionResolver({ cross, empty, single: true }));
55
64
  }
56
65
 
57
66
  /**
58
67
  * Create a new Selection instance with a
59
68
  * cross-filtered intersect resolution strategy.
69
+ * @param {object} [options] The selection options.
70
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
71
+ * of clauses should correspond to an empty selection with no records. This
72
+ * setting determines the default selection state.
60
73
  * @returns {Selection} The new Selection instance.
61
74
  */
62
- static crossfilter() {
63
- return new Selection(new SelectionResolver({ cross: true }));
75
+ static crossfilter({ empty = false } = {}) {
76
+ return new Selection(new SelectionResolver({ cross: true, empty }));
64
77
  }
65
78
 
66
79
  /**
@@ -232,11 +245,15 @@ export class SelectionResolver {
232
245
  * If false, an intersection strategy is used.
233
246
  * @param {boolean} [options.cross=false] Boolean flag to indicate cross-filtering.
234
247
  * @param {boolean} [options.single=false] Boolean flag to indicate single clauses only.
248
+ * @param {boolean} [options.empty=false] Boolean flag indicating if a lack
249
+ * of clauses should correspond to an empty selection with no records. This
250
+ * setting determines the default selection state.
235
251
  */
236
- constructor({ union, cross, single } = {}) {
252
+ constructor({ union, cross, single, empty } = {}) {
237
253
  this.union = !!union;
238
254
  this.cross = !!cross;
239
255
  this.single = !!single;
256
+ this.empty = !!empty;
240
257
  }
241
258
 
242
259
  /**
@@ -274,7 +291,11 @@ export class SelectionResolver {
274
291
  * based on the current state of this selection.
275
292
  */
276
293
  predicate(clauseList, active, client) {
277
- const { union } = this;
294
+ const { empty, union } = this;
295
+
296
+ if (empty && !clauseList.length) {
297
+ return ['FALSE'];
298
+ }
278
299
 
279
300
  // do nothing if cross-filtering and client is currently active
280
301
  if (this.skip(client, active)) return undefined;