@uwdata/mosaic-core 0.7.1 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwdata/mosaic-core",
3
- "version": "0.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "Scalable and extensible linked data views.",
5
5
  "keywords": [
6
6
  "mosaic",
@@ -23,14 +23,17 @@
23
23
  "scripts": {
24
24
  "prebuild": "rimraf dist && mkdir dist",
25
25
  "build": "node ../../esbuild.js mosaic-core",
26
- "lint": "eslint src test --ext .js",
26
+ "lint": "eslint src test",
27
27
  "test": "mocha 'test/**/*-test.js'",
28
28
  "prepublishOnly": "npm run test && npm run lint && npm run build"
29
29
  },
30
30
  "dependencies": {
31
- "@duckdb/duckdb-wasm": "^1.28.1-dev109.0",
32
- "@uwdata/mosaic-sql": "^0.7.0",
33
- "apache-arrow": "^15.0.0"
31
+ "@duckdb/duckdb-wasm": "^1.28.1-dev195.0",
32
+ "@uwdata/mosaic-sql": "^0.9.0",
33
+ "apache-arrow": "^15.0.2"
34
34
  },
35
- "gitHead": "7e6f3ea9b3011ea2c9201c1aa16e8e5664621a4c"
35
+ "devDependencies": {
36
+ "@uwdata/mosaic-duckdb": "^0.9.0"
37
+ },
38
+ "gitHead": "89bb9b0dfa747aed691eaeba35379525a6764c61"
36
39
  }
@@ -8,8 +8,7 @@ let _instance;
8
8
 
9
9
  /**
10
10
  * Set or retrieve the coordinator instance.
11
- *
12
- * @param {Coordinator} instance the coordinator instance to set
11
+ * @param {Coordinator} [instance] the coordinator instance to set
13
12
  * @returns {Coordinator} the coordinator instance
14
13
  */
15
14
  export function coordinator(instance) {
@@ -25,7 +24,7 @@ export class Coordinator {
25
24
  constructor(db = socketConnector(), options = {}) {
26
25
  const {
27
26
  logger = console,
28
- manager = QueryManager()
27
+ manager = new QueryManager()
29
28
  } = options;
30
29
  this.manager = manager;
31
30
  this.logger(logger);
@@ -42,7 +41,15 @@ export class Coordinator {
42
41
  return this._logger;
43
42
  }
44
43
 
45
- configure({ cache = true, consolidate = true, indexes = true }) {
44
+ /**
45
+ * Set configuration options for this coordinator.
46
+ * @param {object} [options] Configration options.
47
+ * @param {boolean} [options.cache=true] Boolean flag to enable/disable query caching.
48
+ * @param {boolean} [options.consolidate=true] Boolean flag to enable/disable query consolidation.
49
+ * @param {boolean|object} [options.indexes=true] Boolean flag to enable/disable
50
+ * automatic data cube indexes or an index options object.
51
+ */
52
+ configure({ cache = true, consolidate = true, indexes = true } = {}) {
46
53
  this.manager.cache(cache);
47
54
  this.manager.consolidate(consolidate);
48
55
  this.indexes = indexes;
@@ -116,7 +123,6 @@ export class Coordinator {
116
123
 
117
124
  /**
118
125
  * Connect a client to the coordinator.
119
- *
120
126
  * @param {import('./MosaicClient.js').MosaicClient} client the client to disconnect
121
127
  */
122
128
  async connect(client) {
@@ -1,5 +1,6 @@
1
- import { Query, and, create, isBetween, scaleTransform, sql } from '@uwdata/mosaic-sql';
1
+ import { Query, and, asColumn, create, isBetween, scaleTransform, sql } from '@uwdata/mosaic-sql';
2
2
  import { fnv_hash } from './util/hash.js';
3
+ import { indexColumns } from './util/index-columns.js';
3
4
 
4
5
  /**
5
6
  * Build and query optimized indices ("data cubes") for fast computation of
@@ -9,7 +10,7 @@ import { fnv_hash } from './util/hash.js';
9
10
  * as temporary database tables that can be queried for rapid updates.
10
11
  * Compatible client queries must pull data from the same backing table and
11
12
  * must consist of only groupby dimensions and supported aggregates.
12
- * Compatible selections must contain an active clause that exposes a schema
13
+ * Compatible selections must contain an active clause that exposes metadata
13
14
  * for an interval or point value predicate.
14
15
  */
15
16
  export class DataCubeIndexer {
@@ -30,38 +31,40 @@ export class DataCubeIndexer {
30
31
  this.enabled = false;
31
32
  this.clients = null;
32
33
  this.indices = null;
33
- this.activeView = null;
34
+ this.active = null;
34
35
  }
35
36
 
36
37
  clear() {
37
38
  if (this.indices) {
38
- this.mc.cancel(Array.from(this.indices.values(), index => index.result));
39
+ this.mc.cancel(Array.from(this.indices.values(), index => index?.result));
39
40
  this.indices = null;
40
41
  }
41
42
  }
42
43
 
43
- index(clients, active) {
44
+ index(clients, activeClause) {
44
45
  if (this.clients !== clients) {
45
46
  // test client views for compatibility
46
- const cols = Array.from(clients, getIndexColumns);
47
+ const cols = Array.from(clients, indexColumns).filter(x => x);
47
48
  const from = cols[0]?.from;
48
- this.enabled = cols.every(c => c && c.from === from);
49
+ this.enabled = cols.length && cols.every(c => c.from === from);
49
50
  this.clients = clients;
50
- this.activeView = null;
51
+ this.active = null;
51
52
  this.clear();
52
53
  }
53
54
  if (!this.enabled) return false; // client views are not indexable
54
55
 
55
- active = active || this.selection.active;
56
- const { source } = active;
57
- if (source && source === this.activeView?.source) return true; // we're good!
56
+ activeClause = activeClause || this.selection.active;
57
+ const { source } = activeClause;
58
+ // exit early if indexes already set up for active view
59
+ if (source && source === this.active?.source) return true;
58
60
 
59
61
  this.clear();
60
62
  if (!source) return false; // nothing to work with
61
- const activeView = this.activeView = getActiveView(active);
62
- if (!activeView) return false; // active selection clause not compatible
63
+ const active = this.active = activeColumns(activeClause);
64
+ if (!active) return false; // active selection clause not compatible
63
65
 
64
- this.mc.logger().warn('DATA CUBE INDEX CONSTRUCTION');
66
+ const logger = this.mc.logger();
67
+ logger.warn('DATA CUBE INDEX CONSTRUCTION');
65
68
 
66
69
  // create a selection with the active source removed
67
70
  const sel = this.selection.remove(source);
@@ -70,18 +73,29 @@ export class DataCubeIndexer {
70
73
  const indices = this.indices = new Map;
71
74
  const { mc, temp } = this;
72
75
  for (const client of clients) {
73
- if (sel.skip(client, active)) continue;
74
- const index = getIndexColumns(client);
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
+ }
75
89
 
76
- // build index construction query
90
+ // build index table construction query
77
91
  const query = client.query(sel.predicate(client))
78
- .select({ ...activeView.columns, ...index.count })
79
- .groupby(Object.keys(activeView.columns));
92
+ .select({ ...active.columns, ...index.aux })
93
+ .groupby(Object.keys(active.columns));
80
94
 
81
95
  // ensure active view columns are selected by subqueries
82
96
  const [subq] = query.subqueries;
83
97
  if (subq) {
84
- const cols = Object.values(activeView.columns).map(c => c.columns[0]);
98
+ const cols = Object.values(active.columns).flatMap(c => c.columns);
85
99
  subqueryPushdown(subq, cols);
86
100
  }
87
101
 
@@ -93,26 +107,37 @@ export class DataCubeIndexer {
93
107
  const id = (fnv_hash(sql) >>> 0).toString(16);
94
108
  const table = `cube_index_${id}`;
95
109
  const result = mc.exec(create(table, sql, { temp }));
110
+ result.catch(e => logger.error(e));
96
111
  indices.set(client, { table, result, order, ...index });
97
112
  }
113
+
114
+ // index creation successful
115
+ return true;
98
116
  }
99
117
 
100
118
  async update() {
101
- const { clients, selection, activeView } = this;
102
- const filter = activeView.predicate(selection.active.predicate);
119
+ const { clients, selection, active } = this;
120
+ const filter = active.predicate(selection.active.predicate);
103
121
  return Promise.all(
104
122
  Array.from(clients).map(client => this.updateClient(client, filter))
105
123
  );
106
124
  }
107
125
 
108
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
+
109
135
  const index = this.indices.get(client);
110
- if (!index) return;
111
136
 
112
- if (!filter) {
113
- filter = this.activeView.predicate(this.selection.active.predicate);
114
- }
137
+ // skip update if cross-filtered
138
+ if (!index) return;
115
139
 
140
+ // otherwise, query a data cube index table
116
141
  const { table, dims, aggr, order = [] } = index;
117
142
  const query = Query
118
143
  .select(dims, aggr)
@@ -120,25 +145,30 @@ export class DataCubeIndexer {
120
145
  .groupby(dims)
121
146
  .where(filter)
122
147
  .orderby(order);
123
- return this.mc.updateClient(client, query);
148
+ return mc.updateClient(client, query);
124
149
  }
125
150
  }
126
151
 
127
- function getActiveView(clause) {
128
- const { source, schema } = clause;
152
+ function activeColumns(clause) {
153
+ const { source, meta } = clause;
129
154
  let columns = clause.predicate?.columns;
130
- if (!schema || !columns) return null;
131
- const { type, scales, pixelSize = 1 } = schema;
155
+ if (!meta || !columns) return null;
156
+ const { type, scales, bin, pixelSize = 1 } = meta;
132
157
  let predicate;
133
158
 
134
159
  if (type === 'interval' && scales) {
135
- const bins = scales.map(s => binInterval(s, pixelSize));
136
- if (bins.some(b => b == null)) return null; // unsupported scale type
160
+ // determine pixel-level binning
161
+ const bins = scales.map(s => binInterval(s, pixelSize, bin));
162
+
163
+ // bail if the scale type is unsupported
164
+ if (bins.some(b => b == null)) return null;
137
165
 
138
166
  if (bins.length === 1) {
167
+ // single interval selection
139
168
  predicate = p => p ? isBetween('active0', p.range.map(bins[0])) : [];
140
169
  columns = { active0: bins[0](clause.predicate.field) };
141
170
  } else {
171
+ // multiple interval selection
142
172
  predicate = p => p
143
173
  ? and(p.children.map(({ range }, i) => isBetween(`active${i}`, range.map(bins[i]))))
144
174
  : [];
@@ -148,87 +178,27 @@ function getActiveView(clause) {
148
178
  }
149
179
  } else if (type === 'point') {
150
180
  predicate = x => x;
151
- columns = Object.fromEntries(columns.map(col => [col.toString(), col]));
181
+ columns = Object.fromEntries(columns.map(col => [`${col}`, asColumn(col)]));
152
182
  } else {
153
- return null; // unsupported type
183
+ // unsupported selection type
184
+ return null;
154
185
  }
155
186
 
156
187
  return { source, columns, predicate };
157
188
  }
158
189
 
159
- function binInterval(scale, pixelSize) {
160
- const { apply, sqlApply } = scaleTransform(scale);
161
- if (apply) {
162
- const { domain, range } = scale;
163
- const lo = apply(Math.min(...domain));
164
- const hi = apply(Math.max(...domain));
165
- const a = (Math.abs(range[1] - range[0]) / (hi - lo)) / pixelSize;
166
- const s = pixelSize === 1 ? '' : `${pixelSize}::INTEGER * `;
167
- return value => sql`${s}FLOOR(${a}::DOUBLE * (${sqlApply(value)} - ${lo}::DOUBLE))::INTEGER`;
168
- }
169
- }
170
-
171
- const NO_INDEX = { from: NaN };
172
-
173
- function getIndexColumns(client) {
174
- if (!client.filterIndexable) return NO_INDEX;
175
- const q = client.query();
176
- const from = getBaseTable(q);
177
- if (!from || !q.groupby) return NO_INDEX;
178
- const g = new Set(q.groupby().map(c => c.column));
179
-
180
- const aggr = [];
181
- const dims = [];
182
- let count;
183
-
184
- for (const { as, expr: { aggregate } } of q.select()) {
185
- switch (aggregate?.toUpperCase?.()) {
186
- case 'COUNT':
187
- case 'SUM':
188
- aggr.push({ [as]: sql`SUM("${as}")::DOUBLE` });
189
- break;
190
- case 'AVG':
191
- count = '_count_';
192
- aggr.push({ [as]: sql`(SUM("${as}" * ${count}) / SUM(${count}))::DOUBLE` });
193
- break;
194
- case 'MAX':
195
- aggr.push({ [as]: sql`MAX("${as}")` });
196
- break;
197
- case 'MIN':
198
- aggr.push({ [as]: sql`MIN("${as}")` });
199
- break;
200
- default:
201
- if (g.has(as)) dims.push(as);
202
- else return null;
203
- }
204
- }
205
-
206
- return {
207
- aggr,
208
- dims,
209
- count: count ? { [count]: sql`COUNT(*)` } : {},
210
- from
211
- };
212
- }
213
-
214
- function getBaseTable(query) {
215
- const subq = query.subqueries;
216
-
217
- // select query
218
- if (query.select) {
219
- const from = query.from();
220
- if (!from.length) return undefined;
221
- if (subq.length === 0) return from[0].from.table;
222
- }
223
-
224
- // handle set operations / subqueries
225
- const base = getBaseTable(subq[0]);
226
- for (let i = 1; i < subq.length; ++i) {
227
- const from = getBaseTable(subq[i]);
228
- if (from === undefined) continue;
229
- if (from !== base) return NaN;
230
- }
231
- return base;
190
+ const BIN = { ceil: 'CEIL', round: 'ROUND' };
191
+
192
+ function binInterval(scale, pixelSize, bin) {
193
+ const { type, domain, range, apply, sqlApply } = scaleTransform(scale);
194
+ if (!apply) return; // unsupported scale type
195
+ const fn = BIN[`${bin}`.toLowerCase()] || 'FLOOR';
196
+ const lo = apply(Math.min(...domain));
197
+ const hi = apply(Math.max(...domain));
198
+ const a = type === 'identity' ? 1 : Math.abs(range[1] - range[0]) / (hi - lo);
199
+ const s = a / pixelSize === 1 ? '' : `${a / pixelSize}::DOUBLE * `;
200
+ const d = lo === 0 ? '' : ` - ${lo}::DOUBLE`;
201
+ return value => sql`${fn}(${s}(${sqlApply(value)}${d}))::INTEGER`;
232
202
  }
233
203
 
234
204
  function subqueryPushdown(query, cols) {
@@ -1,24 +1,27 @@
1
+ import { Coordinator } from './Coordinator.js';
1
2
  import { DataCubeIndexer } from './DataCubeIndexer.js';
3
+ import { MosaicClient } from './MosaicClient.js';
4
+ import { Selection } from './Selection.js';
2
5
 
3
6
  export class FilterGroup {
4
7
  /**
5
- * @param {import('./Coordinator.js').Coordinator} coordinator The Mosaic coordinator.
6
- * @param {*} selection The shared filter selection.
7
- * @param {*} index Boolean flag or options hash for data cube indexer.
8
- * Falsy values disable indexing.
8
+ * @param {Coordinator} coordinator The Mosaic coordinator.
9
+ * @param {Selection} selection The shared filter selection.
10
+ * @param {object|boolean} index Boolean flag or options hash for
11
+ * a data cube indexer. Falsy values disable indexing.
9
12
  */
10
13
  constructor(coordinator, selection, index = true) {
11
- /** @type import('./Coordinator.js').Coordinator */
12
14
  this.mc = coordinator;
13
15
  this.selection = selection;
16
+ /** @type {Set<MosaicClient>} */
14
17
  this.clients = new Set();
15
- this.indexer = index
16
- ? new DataCubeIndexer(this.mc, { ...index, selection })
17
- : null;
18
+ /** @type {DataCubeIndexer | null} */
19
+ this.indexer = null;
20
+ this.index(index);
18
21
 
19
22
  const { value, activate } = this.handlers = {
20
23
  value: () => this.update(),
21
- activate: clause => this.indexer?.index(this.clients, clause)
24
+ activate: clause => { this.indexer?.index(this.clients, clause); }
22
25
  };
23
26
  selection.addEventListener('value', value);
24
27
  selection.addEventListener('activate', activate);
@@ -30,6 +33,14 @@ export class FilterGroup {
30
33
  this.selection.removeEventListener('activate', activate);
31
34
  }
32
35
 
36
+ index(state) {
37
+ const { selection } = this;
38
+ const { resolver } = selection;
39
+ this.indexer = state && (resolver.single || !resolver.union)
40
+ ? new DataCubeIndexer(this.mc, { ...state, selection })
41
+ : null;
42
+ }
43
+
33
44
  reset() {
34
45
  this.indexer?.reset();
35
46
  }
@@ -46,9 +57,15 @@ export class FilterGroup {
46
57
  return this;
47
58
  }
48
59
 
60
+ /**
61
+ * Internal method to process a selection update.
62
+ * The return value is passed as a selection callback value.
63
+ * @returns {Promise} A Promise that resolves when the update completes.
64
+ */
49
65
  update() {
50
66
  const { mc, indexer, clients, selection } = this;
51
- return indexer?.index(clients)
67
+ const hasIndex = indexer?.index(clients);
68
+ return hasIndex
52
69
  ? indexer.update()
53
70
  : defaultUpdate(mc, clients, selection);
54
71
  }
@@ -48,6 +48,7 @@ export class MosaicClient {
48
48
 
49
49
  /**
50
50
  * Return an array of fields queried by this client.
51
+ * @returns {object[]|null} The fields to retrieve info for.
51
52
  */
52
53
  fields() {
53
54
  return null;
@@ -55,21 +56,25 @@ export class MosaicClient {
55
56
 
56
57
  /**
57
58
  * Called by the coordinator to set the field info for this client.
59
+ * @param {*} info The field info result.
58
60
  * @returns {this}
59
61
  */
60
- fieldInfo() {
62
+ fieldInfo(info) { // eslint-disable-line no-unused-vars
61
63
  return this;
62
64
  }
63
65
 
64
66
  /**
65
67
  * Return a query specifying the data needed by this client.
68
+ * @param {*} [filter] The filtering criteria to apply in the query.
69
+ * @returns {*} The client query
66
70
  */
67
- query() {
71
+ query(filter) { // eslint-disable-line no-unused-vars
68
72
  return null;
69
73
  }
70
74
 
71
75
  /**
72
76
  * Called by the coordinator to inform the client that a query is pending.
77
+ * @returns {this}
73
78
  */
74
79
  queryPending() {
75
80
  return this;
@@ -77,16 +82,17 @@ export class MosaicClient {
77
82
 
78
83
  /**
79
84
  * Called by the coordinator to return a query result.
80
- *
81
- * @param {*} data the query result
85
+ * @param {*} data The query result.
82
86
  * @returns {this}
83
87
  */
84
- queryResult() {
88
+ queryResult(data) { // eslint-disable-line no-unused-vars
85
89
  return this;
86
90
  }
87
91
 
88
92
  /**
89
93
  * Called by the coordinator to report a query execution error.
94
+ * @param {*} error
95
+ * @returns {this}
90
96
  */
91
97
  queryError(error) {
92
98
  console.error(error);
@@ -116,6 +122,8 @@ export class MosaicClient {
116
122
  /**
117
123
  * Requests a client update.
118
124
  * For example to (re-)render an interface component.
125
+ *
126
+ * @returns {this | Promise<any>}
119
127
  */
120
128
  update() {
121
129
  return this;
package/src/Param.js CHANGED
@@ -4,7 +4,7 @@ import { distinct } from './util/distinct.js';
4
4
  /**
5
5
  * Test if a value is a Param instance.
6
6
  * @param {*} x The value to test.
7
- * @returns {boolean} True if the input is a Param, false otherwise.
7
+ * @returns {x is Param} True if the input is a Param, false otherwise.
8
8
  */
9
9
  export function isParam(x) {
10
10
  return x instanceof Param;
@@ -43,7 +43,9 @@ export class Param extends AsyncDispatch {
43
43
  static array(values) {
44
44
  if (values.some(v => isParam(v))) {
45
45
  const p = new Param();
46
- const update = () => p.update(values.map(v => isParam(v) ? v.value : v));
46
+ const update = () => {
47
+ p.update(values.map(v => isParam(v) ? v.value : v));
48
+ };
47
49
  update();
48
50
  values.forEach(v => isParam(v) ? v.addEventListener('value', update) : 0);
49
51
  return p;
@@ -5,6 +5,7 @@ function wait(callback) {
5
5
  const method = typeof requestAnimationFrame !== 'undefined'
6
6
  ? requestAnimationFrame
7
7
  : typeof setImmediate !== 'undefined' ? setImmediate : setTimeout;
8
+ // @ts-ignore
8
9
  return method(callback);
9
10
  }
10
11
 
@@ -82,7 +83,9 @@ function consolidationKey(query, cache) {
82
83
  const sql = `${query}`;
83
84
  if (query instanceof Query && !cache.get(sql)) {
84
85
  if (
86
+ // @ts-ignore
85
87
  query.orderby().length || query.where().length ||
88
+ // @ts-ignore
86
89
  query.qualify().length || query.having().length
87
90
  ) {
88
91
  // do not try to analyze if query includes clauses
@@ -97,9 +100,12 @@ function consolidationKey(query, cache) {
97
100
  // queries may refer to *derived* columns as group by criteria
98
101
  // we resolve these against the true grouping expressions
99
102
  const groupby = query.groupby();
103
+ // @ts-ignore
100
104
  if (groupby.length) {
101
105
  const map = {}; // expression map (as -> expr)
106
+ // @ts-ignore
102
107
  query.select().forEach(({ as, expr }) => map[as] = expr);
108
+ // @ts-ignore
103
109
  q.$groupby(groupby.map(e => (e instanceof Ref && map[e.column]) || e));
104
110
  }
105
111