@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.
@@ -5,48 +5,51 @@ import { queryResult } from './util/query-result.js';
5
5
 
6
6
  export const Priority = { High: 0, Normal: 1, Low: 2 };
7
7
 
8
- export function QueryManager() {
9
- const queue = priorityQueue(3);
10
- let db;
11
- let clientCache;
12
- let logger;
13
- let recorders = [];
14
- let pending = null;
15
- let consolidate;
16
-
17
- function next() {
18
- if (pending || queue.isEmpty()) return;
19
- const { request, result } = queue.next();
20
- pending = submit(request, result);
21
- pending.finally(() => { pending = null; next(); });
8
+ export class QueryManager {
9
+ constructor() {
10
+ this.queue = priorityQueue(3);
11
+ this.db = null;
12
+ this.clientCache = null;
13
+ this._logger = null;
14
+ this._logQueries = false;
15
+ this.recorders = [];
16
+ this.pending = null;
17
+ this._consolidate = null;
22
18
  }
23
19
 
24
- function enqueue(entry, priority = Priority.Normal) {
25
- queue.insert(entry, priority);
26
- next();
20
+ next() {
21
+ if (this.pending || this.queue.isEmpty()) return;
22
+ const { request, result } = this.queue.next();
23
+ this.pending = this.submit(request, result);
24
+ this.pending.finally(() => { this.pending = null; this.next(); });
27
25
  }
28
26
 
29
- function recordQuery(sql) {
30
- if (recorders.length && sql) {
31
- recorders.forEach(rec => rec.add(sql));
27
+ enqueue(entry, priority = Priority.Normal) {
28
+ this.queue.insert(entry, priority);
29
+ this.next();
30
+ }
31
+
32
+ recordQuery(sql) {
33
+ if (this.recorders.length && sql) {
34
+ this.recorders.forEach(rec => rec.add(sql));
32
35
  }
33
36
  }
34
37
 
35
- async function submit(request, result) {
38
+ async submit(request, result) {
36
39
  try {
37
40
  const { query, type, cache = false, record = true, options } = request;
38
41
  const sql = query ? `${query}` : null;
39
42
 
40
43
  // update recorders
41
44
  if (record) {
42
- recordQuery(sql);
45
+ this.recordQuery(sql);
43
46
  }
44
47
 
45
48
  // check query cache
46
49
  if (cache) {
47
- const cached = clientCache.get(sql);
50
+ const cached = this.clientCache.get(sql);
48
51
  if (cached) {
49
- logger.debug('Cache');
52
+ this._logger.debug('Cache');
50
53
  result.fulfill(cached);
51
54
  return;
52
55
  }
@@ -54,80 +57,85 @@ export function QueryManager() {
54
57
 
55
58
  // issue query, potentially cache result
56
59
  const t0 = performance.now();
57
- const data = await db.query({ type, sql, ...options });
58
- if (cache) clientCache.set(sql, data);
59
- logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`);
60
+ if (this._logQueries) {
61
+ this._logger.debug('Query', { type, sql, ...options });
62
+ }
63
+ const data = await this.db.query({ type, sql, ...options });
64
+ if (cache) this.clientCache.set(sql, data);
65
+ this._logger.debug(`Request: ${(performance.now() - t0).toFixed(1)}`);
60
66
  result.fulfill(data);
61
67
  } catch (err) {
62
68
  result.reject(err);
63
69
  }
64
70
  }
65
71
 
66
- return {
67
- cache(value) {
68
- return value !== undefined
69
- ? (clientCache = value === true ? lruCache() : (value || voidCache()))
70
- : clientCache;
71
- },
72
-
73
- logger(value) {
74
- return value ? (logger = value) : logger;
75
- },
76
-
77
- connector(connector) {
78
- return connector ? (db = connector) : db;
79
- },
80
-
81
- consolidate(flag) {
82
- if (flag && !consolidate) {
83
- consolidate = consolidator(enqueue, clientCache, recordQuery);
84
- } else if (!flag && consolidate) {
85
- consolidate = null;
86
- }
87
- },
88
-
89
- request(request, priority = Priority.Normal) {
90
- const result = queryResult();
91
- const entry = { request, result };
92
- if (consolidate) {
93
- consolidate.add(entry, priority);
94
- } else {
95
- enqueue(entry, priority);
96
- }
97
- return result;
98
- },
99
-
100
- cancel(requests) {
101
- const set = new Set(requests);
102
- queue.remove(({ result }) => set.has(result));
103
- },
104
-
105
- clear() {
106
- queue.remove(({ result }) => {
107
- result.reject('Cleared');
108
- return true;
109
- });
110
- },
111
-
112
- record() {
113
- let state = [];
114
- const recorder = {
115
- add(query) {
116
- state.push(query);
117
- },
118
- reset() {
119
- state = [];
120
- },
121
- snapshot() {
122
- return state.slice();
123
- },
124
- stop() {
125
- recorders = recorders.filter(x => x !== recorder);
126
- return state;
127
- }
128
- };
129
- recorders.push(recorder);
130
- return recorder;
72
+ cache(value) {
73
+ return value !== undefined
74
+ ? (this.clientCache = value === true ? lruCache() : (value || voidCache()))
75
+ : this.clientCache;
76
+ }
77
+
78
+ logger(value) {
79
+ return value ? (this._logger = value) : this._logger;
80
+ }
81
+
82
+ logQueries(value) {
83
+ return value !== undefined ? this._logQueries = !!value : this._logQueries;
84
+ }
85
+
86
+ connector(connector) {
87
+ return connector ? (this.db = connector) : this.db;
88
+ }
89
+
90
+ consolidate(flag) {
91
+ if (flag && !this._consolidate) {
92
+ this._consolidate = consolidator(this.enqueue.bind(this), this.clientCache, this.recordQuery.bind(this));
93
+ } else if (!flag && this._consolidate) {
94
+ this._consolidate = null;
95
+ }
96
+ }
97
+
98
+ request(request, priority = Priority.Normal) {
99
+ const result = queryResult();
100
+ const entry = { request, result };
101
+ if (this._consolidate) {
102
+ this._consolidate.add(entry, priority);
103
+ } else {
104
+ this.enqueue(entry, priority);
131
105
  }
132
- };
106
+ return result;
107
+ }
108
+
109
+ cancel(requests) {
110
+ const set = new Set(requests);
111
+ this.queue.remove(({ result }) => set.has(result));
112
+ }
113
+
114
+ clear() {
115
+ this.queue.remove(({ result }) => {
116
+ result.reject('Cleared');
117
+ return true;
118
+ });
119
+ }
120
+
121
+ record() {
122
+ let state = [];
123
+ const recorder = {
124
+ add(query) {
125
+ state.push(query);
126
+ },
127
+ reset() {
128
+ state = [];
129
+ },
130
+ snapshot() {
131
+ return state.slice();
132
+ },
133
+ stop() {
134
+ this.recorders = this.recorders.filter(x => x !== recorder);
135
+ return state;
136
+ }
137
+ };
138
+ this.recorders.push(recorder);
139
+ return recorder;
140
+ }
133
141
  }
package/src/Selection.js CHANGED
@@ -4,7 +4,7 @@ import { Param } from './Param.js';
4
4
  /**
5
5
  * Test if a value is a Selection instance.
6
6
  * @param {*} x The value to test.
7
- * @returns {boolean} True if the input is a Selection, false otherwise.
7
+ * @returns {x is Selection} True if the input is a Selection, false otherwise.
8
8
  */
9
9
  export function isSelection(x) {
10
10
  return x instanceof Selection;
@@ -76,7 +76,7 @@ export class Selection extends Param {
76
76
 
77
77
  /**
78
78
  * Create a cloned copy of this Selection instance.
79
- * @returns {this} A clone of this selection.
79
+ * @returns {Selection} A clone of this selection.
80
80
  */
81
81
  clone() {
82
82
  const s = new Selection(this._resolver);
@@ -88,7 +88,7 @@ export class Selection extends Param {
88
88
  * Create a clone of this Selection with clauses corresponding
89
89
  * to the provided source removed.
90
90
  * @param {*} source The clause source to remove.
91
- * @returns {this} A cloned and updated Selection.
91
+ * @returns {Selection} A cloned and updated Selection.
92
92
  */
93
93
  remove(source) {
94
94
  const s = this.clone();
@@ -98,19 +98,17 @@ export class Selection extends Param {
98
98
  }
99
99
 
100
100
  /**
101
- * The current active (most recently updated) selection clause.
101
+ * The selection clause resolver.
102
102
  */
103
- get active() {
104
- return this.clauses.active;
103
+ get resolver() {
104
+ return this._resolver;
105
105
  }
106
106
 
107
107
  /**
108
- * The value corresponding to the current active selection clause.
109
- * This method ensures compatibility where a normal Param is expected.
108
+ * Indicate if this selection has a single resolution strategy.
110
109
  */
111
- get value() {
112
- // return value of the active clause
113
- return this.active?.value;
110
+ get single() {
111
+ return this._resolver.single;
114
112
  }
115
113
 
116
114
  /**
@@ -121,10 +119,27 @@ export class Selection extends Param {
121
119
  }
122
120
 
123
121
  /**
124
- * Indicate if this selection has a single resolution strategy.
122
+ * The current active (most recently updated) selection clause.
125
123
  */
126
- get single() {
127
- return this._resolver.single;
124
+ get active() {
125
+ return this.clauses.active;
126
+ }
127
+
128
+ /**
129
+ * The value corresponding to the current active selection clause.
130
+ * This method ensures compatibility where a normal Param is expected.
131
+ */
132
+ get value() {
133
+ return this.active?.value;
134
+ }
135
+
136
+ /**
137
+ * The value corresponding to a given source. Returns undefined if
138
+ * this selection does not include a clause from this source.
139
+ * @param {*} source The clause source to look up the value for.
140
+ */
141
+ valueFor(source) {
142
+ return this.clauses.find(c => c.source === source)?.value;
128
143
  }
129
144
 
130
145
  /**
@@ -168,9 +183,9 @@ export class Selection extends Param {
168
183
  * Upon value-typed updates, returns a dispatch queue filter function.
169
184
  * The return value depends on the selection resolution strategy.
170
185
  * @param {string} type The event type.
171
- * @param {*} value The input event value.
172
- * @returns {*} For value-typed events, returns a dispatch queue filter
173
- * function. Otherwise returns null.
186
+ * @param {*} value The new event value that will be enqueued.
187
+ * @returns {(value: *) => boolean|null} For value-typed events,
188
+ * returns a dispatch queue filter function. Otherwise returns null.
174
189
  */
175
190
  emitQueueFilter(type, value) {
176
191
  return type === 'value'
@@ -285,5 +300,6 @@ export class SelectionResolver {
285
300
  const source = value.active?.source;
286
301
  return clauses => clauses.active?.source !== source;
287
302
  }
303
+ return null;
288
304
  }
289
305
  }
@@ -0,0 +1,147 @@
1
+ import {
2
+ SQLExpression, and, contains, isBetween, isNotDistinct, literal,
3
+ or, prefix, regexp_matches, suffix
4
+ } from '@uwdata/mosaic-sql';
5
+ import { MosaicClient } from './MosaicClient.js';
6
+
7
+ /**
8
+ * @typedef {import('./util/selection-types.js').SelectionClause} SelectionClause
9
+ * @typedef {import('./util/selection-types.js').Scale} Scale
10
+ * @typedef {import('./util/selection-types.js').Extent} Extent
11
+ * @typedef {import('./util/selection-types.js').MatchMethod} MatchMethod
12
+ * @typedef {import('./util/selection-types.js').BinMethod} BinMethod
13
+ * @typedef {SQLExpression | string} Field
14
+ */
15
+
16
+ /**
17
+ * Generate a selection clause for a single selected point value.
18
+ * @param {Field} field The table column or expression to select.
19
+ * @param {*} value The selected value.
20
+ * @param {object} options Additional clause properties.
21
+ * @param {*} options.source The source component generating this clause.
22
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
23
+ * with this clause. These clients are not filtered by this clause in
24
+ * cross-filtering contexts.
25
+ * @returns {SelectionClause} The generated selection clause.
26
+ */
27
+ export function point(field, value, { source, clients = undefined }) {
28
+ /** @type {SQLExpression | null} */
29
+ const predicate = value !== undefined
30
+ ? isNotDistinct(field, literal(value))
31
+ : null;
32
+ return {
33
+ meta: { type: 'point' },
34
+ source,
35
+ clients,
36
+ value,
37
+ predicate
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Generate a selection clause for multiple selected point values.
43
+ * @param {Field[]} fields The table columns or expressions to select.
44
+ * @param {any[][]} value The selected values, as an array of arrays where
45
+ * each subarray contains values corresponding to each *fields* entry.
46
+ * @param {object} options Additional clause properties.
47
+ * @param {*} options.source The source component generating this clause.
48
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
49
+ * with this clause. These clients are not filtered by this clause in
50
+ * cross-filtering contexts.
51
+ * @returns {SelectionClause} The generated selection clause.
52
+ */
53
+ export function points(fields, value, { source, clients = undefined }) {
54
+ /** @type {SQLExpression | null} */
55
+ let predicate = null;
56
+ if (value) {
57
+ const clauses = value.map(vals => {
58
+ const list = vals.map((v, i) => isNotDistinct(fields[i], literal(v)));
59
+ return list.length > 1 ? and(list) : list[0];
60
+ });
61
+ predicate = clauses.length > 1 ? or(clauses) : clauses[0];
62
+ }
63
+ return {
64
+ meta: { type: 'point' },
65
+ source,
66
+ clients,
67
+ value,
68
+ predicate
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Generate a selection clause for a selected 1D interval.
74
+ * @param {Field} field The table column or expression to select.
75
+ * @param {Extent} value The selected interval as a [lo, hi] array.
76
+ * @param {object} options Additional clause properties.
77
+ * @param {*} options.source The source component generating this clause.
78
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
79
+ * with this clause. These clients are not filtered by this clause in
80
+ * cross-filtering contexts.
81
+ * @param {Scale} [options.scale] The scale mapping descriptor.
82
+ * @param {BinMethod} [options.bin] A binning method hint.
83
+ * @param {number} [options.pixelSize=1] The interactive pixel size.
84
+ * @returns {SelectionClause} The generated selection clause.
85
+ */
86
+ export function interval(field, value, {
87
+ source, clients, bin, scale, pixelSize = 1
88
+ }) {
89
+ /** @type {SQLExpression | null} */
90
+ const predicate = value != null ? isBetween(field, value) : null;
91
+ /** @type {import('./util/selection-types.js').IntervalMetadata} */
92
+ const meta = { type: 'interval', scales: [scale], bin, pixelSize };
93
+ return { meta, source, clients, value, predicate };
94
+ }
95
+
96
+ /**
97
+ * Generate a selection clause for multiple selected intervals.
98
+ * @param {Field[]} fields The table columns or expressions to select.
99
+ * @param {Extent[]} value The selected intervals, as an array of extents.
100
+ * @param {object} options Additional clause properties.
101
+ * @param {*} options.source The source component generating this clause.
102
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
103
+ * with this clause. These clients are not filtered by this clause in
104
+ * cross-filtering contexts.
105
+ * @param {Scale[]} [options.scales] The scale mapping descriptors,
106
+ * in an order matching the given *fields* and *value* extents.
107
+ * @param {BinMethod} [options.bin] A binning method hint.
108
+ * @param {number} [options.pixelSize=1] The interactive pixel size.
109
+ * @returns {SelectionClause} The generated selection clause.
110
+ */
111
+ export function intervals(fields, value, {
112
+ source, clients, bin, scales = [], pixelSize = 1
113
+ }) {
114
+ /** @type {SQLExpression | null} */
115
+ const predicate = value != null
116
+ ? and(fields.map((f, i) => isBetween(f, value[i])))
117
+ : null;
118
+ /** @type {import('./util/selection-types.js').IntervalMetadata} */
119
+ const meta = { type: 'interval', scales, bin, pixelSize };
120
+ return { meta, source, clients, value, predicate };
121
+ }
122
+
123
+ const MATCH_METHODS = { contains, prefix, suffix, regexp: regexp_matches };
124
+
125
+ /**
126
+ * Generate a selection clause for text search matching.
127
+ * @param {Field} field The table column or expression to select.
128
+ * @param {string} value The selected text search query string.
129
+ * @param {object} options Additional clause properties.
130
+ * @param {*} options.source The source component generating this clause.
131
+ * @param {Set<MosaicClient>} [options.clients] The Mosaic clients associated
132
+ * with this clause. These clients are not filtered by this clause in
133
+ * cross-filtering contexts.
134
+ * @param {MatchMethod} [options.method] The
135
+ * text matching method to use. Defaults to `'contains'`.
136
+ * @returns {SelectionClause} The generated selection clause.
137
+ */
138
+ export function match(field, value, {
139
+ source, clients = undefined, method = 'contains'
140
+ }) {
141
+ let fn = MATCH_METHODS[method];
142
+ /** @type {SQLExpression | null} */
143
+ const predicate = value ? fn(field, literal(value)) : null;
144
+ /** @type {import('./util/selection-types.js').MatchMetadata} */
145
+ const meta = { type: 'match', method };
146
+ return { meta, source, clients, value, predicate };
147
+ }
@@ -2,6 +2,13 @@ import { tableFromIPC } from 'apache-arrow';
2
2
 
3
3
  export function restConnector(uri = 'http://localhost:3000/') {
4
4
  return {
5
+ /**
6
+ * Query the DuckDB server.
7
+ * @param {object} query
8
+ * @param {'exec' | 'arrow' | 'json'} [query.type] The query type: 'exec', 'arrow', or 'json'.
9
+ * @param {string} query.sql A SQL query string.
10
+ * @returns the query result
11
+ */
5
12
  async query(query) {
6
13
  const req = fetch(uri, {
7
14
  method: 'POST',
@@ -81,6 +81,13 @@ export function socketConnector(uri = 'ws://localhost:3000/') {
81
81
  get connected() {
82
82
  return connected;
83
83
  },
84
+ /**
85
+ * Query the DuckDB server.
86
+ * @param {object} query
87
+ * @param {'exec' | 'arrow' | 'json'} [query.type] The query type: 'exec', 'arrow', or 'json'.
88
+ * @param {string} query.sql A SQL query string.
89
+ * @returns the query result
90
+ */
84
91
  query(query) {
85
92
  return new Promise(
86
93
  (resolve, reject) => enqueue(query, resolve, reject)
@@ -22,7 +22,7 @@ export function wasmConnector(options = {}) {
22
22
  /**
23
23
  * Get the backing DuckDB-WASM instance.
24
24
  * Will lazily initialize DuckDB-WASM if not already loaded.
25
- * @returns {duckdb.AsyncDuckDB} The DuckDB-WASM instance.
25
+ * @returns {Promise<duckdb.AsyncDuckDB>} The DuckDB-WASM instance.
26
26
  */
27
27
  async function getDuckDB() {
28
28
  if (!db) await load();
@@ -32,7 +32,7 @@ export function wasmConnector(options = {}) {
32
32
  /**
33
33
  * Get the backing DuckDB-WASM connection.
34
34
  * Will lazily initialize DuckDB-WASM if not already loaded.
35
- * @returns {duckdb.AsyncDuckDBConnection} The DuckDB-WASM connection.
35
+ * @returns {Promise<duckdb.AsyncDuckDBConnection>} The DuckDB-WASM connection.
36
36
  */
37
37
  async function getConnection() {
38
38
  if (!con) await load();
@@ -45,7 +45,7 @@ export function wasmConnector(options = {}) {
45
45
  /**
46
46
  * Query the DuckDB-WASM instance.
47
47
  * @param {object} query
48
- * @param {string} [query.type] The query type: 'exec', 'arrow', or 'json'.
48
+ * @param {'exec' | 'arrow' | 'json'} [query.type] The query type: 'exec', 'arrow', or 'json'.
49
49
  * @param {string} query.sql A SQL query string.
50
50
  * @returns the query result
51
51
  */
@@ -55,7 +55,7 @@ export function wasmConnector(options = {}) {
55
55
  const result = await con.query(sql);
56
56
  return type === 'exec' ? undefined
57
57
  : type === 'arrow' ? result
58
- : Array.from(result);
58
+ : result.toArray();
59
59
  }
60
60
  };
61
61
  }
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ export { Coordinator, coordinator } from './Coordinator.js';
3
3
  export { Selection, isSelection } from './Selection.js';
4
4
  export { Param, isParam } from './Param.js';
5
5
  export { Priority } from './QueryManager.js';
6
+ export { point, points, interval, intervals, match } from './SelectionClause.js';
6
7
 
7
8
  export { restConnector } from './connectors/rest.js';
8
9
  export { socketConnector } from './connectors/socket.js';
@@ -16,7 +16,7 @@ export class AsyncDispatch {
16
16
  /**
17
17
  * Add an event listener callback for the provided event type.
18
18
  * @param {string} type The event type.
19
- * @param {(value: *) => Promise?} callback The event handler
19
+ * @param {(value: *) => void | Promise} callback The event handler
20
20
  * callback function to add. If the callback has already been
21
21
  * added for the event type, this method has no effect.
22
22
  */
@@ -35,7 +35,7 @@ export class AsyncDispatch {
35
35
  /**
36
36
  * Remove an event listener callback for the provided event type.
37
37
  * @param {string} type The event type.
38
- * @param {(value: *) => Promise?} callback The event handler
38
+ * @param {(value: *) => void | Promise} callback The event handler
39
39
  * callback function to remove.
40
40
  */
41
41
  removeEventListener(type, callback) {
@@ -64,11 +64,12 @@ export class AsyncDispatch {
64
64
  * This default implementation simply returns null, indicating that
65
65
  * any other unemitted event values should be dropped (that is, all
66
66
  * queued events are filtered)
67
+ * @param {string} type The event type.
67
68
  * @param {*} value The new event value that will be enqueued.
68
69
  * @returns {(value: *) => boolean|null} A dispatch queue filter
69
70
  * function, or null if all unemitted event values should be filtered.
70
71
  */
71
- emitQueueFilter() {
72
+ emitQueueFilter(type, value) { // eslint-disable-line no-unused-vars
72
73
  // removes all pending items
73
74
  return null;
74
75
  }
@@ -94,20 +95,22 @@ export class AsyncDispatch {
94
95
  emit(type, value) {
95
96
  const entry = this._callbacks.get(type) || {};
96
97
  if (entry.pending) {
98
+ // an earlier emit is still processing
99
+ // enqueue the current update, possibly filtering other pending updates
97
100
  entry.queue.enqueue(value, this.emitQueueFilter(type, value));
98
101
  } else {
99
102
  const event = this.willEmit(type, value);
100
103
  const { callbacks, queue } = entry;
101
104
  if (callbacks?.size) {
102
- const promise = Promise
103
- .allSettled(Array.from(callbacks, callback => callback(event)))
104
- .then(() => {
105
- entry.pending = null;
106
- if (!queue.isEmpty()) {
107
- this.emit(type, queue.dequeue());
108
- }
109
- });
110
- entry.pending = promise;
105
+ // broadcast update to callbacks, which may return promises
106
+ // wait until promises resolve, then process pending updates
107
+ const callbackValues = Array.from(callbacks, cb => cb(event));
108
+ entry.pending = Promise.allSettled(callbackValues).then(() => {
109
+ entry.pending = null;
110
+ if (!queue.isEmpty()) {
111
+ this.emit(type, queue.dequeue());
112
+ }
113
+ });
111
114
  }
112
115
  }
113
116
  }