tablewalk 0.0.1

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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +553 -0
  3. package/dist/adapters/adapter.js +372 -0
  4. package/dist/adapters/connect.js +33 -0
  5. package/dist/adapters/mysql.js +951 -0
  6. package/dist/adapters/postgres.js +1000 -0
  7. package/dist/adapters/sqlite.js +781 -0
  8. package/dist/client/agent.js +262 -0
  9. package/dist/client/app.js +973 -0
  10. package/dist/client/arrange.js +254 -0
  11. package/dist/client/ask.js +133 -0
  12. package/dist/client/breakdown.js +317 -0
  13. package/dist/client/clauses.js +390 -0
  14. package/dist/client/columns.js +98 -0
  15. package/dist/client/complete.js +437 -0
  16. package/dist/client/compose.js +166 -0
  17. package/dist/client/composer.css +495 -0
  18. package/dist/client/composer.js +1972 -0
  19. package/dist/client/connections.js +234 -0
  20. package/dist/client/connmanager.js +962 -0
  21. package/dist/client/connurl.js +188 -0
  22. package/dist/client/core.js +893 -0
  23. package/dist/client/deeplink.js +270 -0
  24. package/dist/client/delete.js +144 -0
  25. package/dist/client/diagram.js +885 -0
  26. package/dist/client/dropdown.js +279 -0
  27. package/dist/client/export.js +456 -0
  28. package/dist/client/features.css +524 -0
  29. package/dist/client/findvalue.js +169 -0
  30. package/dist/client/grid.js +205 -0
  31. package/dist/client/handoff.js +153 -0
  32. package/dist/client/help.css +145 -0
  33. package/dist/client/help.js +881 -0
  34. package/dist/client/history.js +222 -0
  35. package/dist/client/index.html +116 -0
  36. package/dist/client/insert.js +151 -0
  37. package/dist/client/menu.js +160 -0
  38. package/dist/client/nested.js +255 -0
  39. package/dist/client/page.css +713 -0
  40. package/dist/client/page.js +1345 -0
  41. package/dist/client/pagebuilder.js +1222 -0
  42. package/dist/client/pagemarks.js +95 -0
  43. package/dist/client/palette.js +374 -0
  44. package/dist/client/peek.js +254 -0
  45. package/dist/client/picker.js +139 -0
  46. package/dist/client/pins.js +140 -0
  47. package/dist/client/prompt.js +129 -0
  48. package/dist/client/record.js +707 -0
  49. package/dist/client/schemaexport.js +242 -0
  50. package/dist/client/schematext.js +125 -0
  51. package/dist/client/shape.js +178 -0
  52. package/dist/client/shapecheck.js +129 -0
  53. package/dist/client/skeleton.js +139 -0
  54. package/dist/client/sql.css +126 -0
  55. package/dist/client/sql.js +398 -0
  56. package/dist/client/sqlcomplete.js +163 -0
  57. package/dist/client/sqlsaved.js +107 -0
  58. package/dist/client/style.css +2711 -0
  59. package/dist/client/summary.js +259 -0
  60. package/dist/client/table.js +1035 -0
  61. package/dist/client/template.js +539 -0
  62. package/dist/client/theme.js +74 -0
  63. package/dist/client/tour.js +324 -0
  64. package/dist/client/undo.js +105 -0
  65. package/dist/client/url.js +166 -0
  66. package/dist/client/value.js +223 -0
  67. package/dist/client/views.js +215 -0
  68. package/dist/client/virtual.js +176 -0
  69. package/dist/client/welcome.js +170 -0
  70. package/dist/client/write.js +414 -0
  71. package/dist/server/changeimpact.js +195 -0
  72. package/dist/server/connections.js +615 -0
  73. package/dist/server/constraints.js +62 -0
  74. package/dist/server/credentials.js +230 -0
  75. package/dist/server/fixture.js +199 -0
  76. package/dist/server/graph.js +194 -0
  77. package/dist/server/impact.js +48 -0
  78. package/dist/server/index.js +2204 -0
  79. package/dist/server/journal.js +173 -0
  80. package/dist/server/layouts.js +128 -0
  81. package/dist/server/mcp.js +2840 -0
  82. package/dist/server/shapeonly.js +91 -0
  83. package/dist/shared/breakdown.js +231 -0
  84. package/dist/shared/breakdowntext.js +257 -0
  85. package/dist/shared/diff.js +130 -0
  86. package/dist/shared/like.js +29 -0
  87. package/dist/shared/lint.js +149 -0
  88. package/dist/shared/order.js +133 -0
  89. package/dist/shared/page.js +932 -0
  90. package/dist/shared/query.js +831 -0
  91. package/dist/shared/recordview.js +343 -0
  92. package/dist/shared/schema.js +377 -0
  93. package/dist/shared/sqlsaved.js +67 -0
  94. package/dist/shared/view.js +981 -0
  95. package/dist/shared/viewtext.js +273 -0
  96. package/dist/shared/vocabulary.js +164 -0
  97. package/package.json +57 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Autocomplete for the query bar.
3
+ *
4
+ * The language is small enough to memorise and nobody should have to. The
5
+ * schema is already in the browser, so the bar can propose the table you mean,
6
+ * the columns it has, and the operators that make sense next — which is also
7
+ * the fastest way to *learn* the language, since every suggestion is a working
8
+ * example of the next token.
9
+ *
10
+ * Deliberately not a parser. It reads the token under the caret and the one
11
+ * before it, which is enough to know whether a table, a column, an operator or
12
+ * a keyword comes next. A full parse would be more correct and would also have
13
+ * to cope with the half-written text a parser sees mid-keystroke, where the
14
+ * useful answer is usually "you are in the middle of a column name".
15
+ */
16
+ import { $, columnKind, el, looksLikeDateColumn, state } from './core.js';
17
+
18
+ const OPERATORS = ['=', '!=', '>', '>=', '<', '<=', 'between', 'contains', 'startswith', 'endswith', 'like', 'in', 'not in'];
19
+
20
+ /* `between` is offered only where a range means something. On a text column
21
+ it is legal and useless — nobody asks for the names between "a" and "b" —
22
+ and an operator list that offers everything everywhere is a list you stop
23
+ reading. */
24
+ const RANGEABLE = new Set(['number', 'date']);
25
+ const CLAUSES = ['sort', 'show', 'limit', 'where', 'order by'];
26
+ const JOINERS = ['and', 'or'];
27
+ const NULL_CHECKS = ['is empty', 'is not empty'];
28
+ const DATE_VALUES = ['today', 'yesterday', 'this week', 'last week', 'this month', 'last month', 'this year', 'last year', 'last 7 days', 'last 30 days', 'last 24 hours', 'last 12 months', 'last 3 years'];
29
+
30
+ /** Words that mean a column name comes next. */
31
+ const EXPECTS_COLUMN = new Set(['and', 'or', 'where', 'filter', 'show', 'select', 'sort', 'by', ',']);
32
+
33
+ /* Clauses whose value is a comma-separated list, so more than one suggestion
34
+ at a time is a sensible thing to ask for. `show a, b, c` is the case: after
35
+ `show` you nearly always want several columns, and taking them one at a
36
+ time means reopening the list once per column. A filter is not on this
37
+ list — `name` and `email` in the same condition is not a query. */
38
+ const TAKES_A_LIST = new Set(['show', 'select', 'sort', 'by', ',']);
39
+
40
+ /* One popup is open at a time, so the module holds the state of whichever
41
+ field currently has focus rather than a copy per field. `bind()` in
42
+ `attachComplete` points these at the field being typed in — the alternative
43
+ is closures per field and a second set of key handlers, for a list that can
44
+ only ever be in one place. */
45
+ let box = null;
46
+ let items = [];
47
+ let index = -1;
48
+ /**
49
+ * Where a Shift-extended range started.
50
+ *
51
+ * A range needs two ends: the one the arrow keys are moving and the one it is
52
+ * measured from. Without an anchor, Shift+Down twice would select the two
53
+ * items either side of the cursor rather than the run between them.
54
+ */
55
+ let anchorIndex = -1;
56
+ /* Typed, not just initialised.
57
+
58
+ A default of `() => []` tells the checker the function takes no arguments,
59
+ so every real call — which passes two — reads as an error. The signature is
60
+ the contract between this module and whatever attaches to it, and writing it
61
+ down is what lets a caller that gets it wrong be caught. */
62
+
63
+ /** @type {(text: string, caret: number) => Array<Record<string, any>>} */
64
+ let suggester = () => [];
65
+
66
+ /** @type {(text: string, caret: number) => { word: string, start: number }} */
67
+ let splitter = (text, caret) => tokenAt(text, caret);
68
+ let anchor = 'field';
69
+
70
+ export function initComplete() {
71
+ attachComplete($('query'), suggestionsFor);
72
+ }
73
+
74
+ /**
75
+ * Give any text field the same completion behaviour.
76
+ *
77
+ * Extracted from the query bar so the SQL editor gets the keyboard contract
78
+ * rather than a second implementation of it: Tab takes the first suggestion
79
+ * even with nothing highlighted, Enter only completes when something is, and
80
+ * Ctrl+Space reopens a list that closed itself. Those are small decisions that
81
+ * took a while to settle, and two copies of them would drift the way every
82
+ * other pair of copies in this codebase has.
83
+ *
84
+ * @param {HTMLInputElement|HTMLTextAreaElement} input
85
+ * @param {(text: string, caret: number) => Array} suggest
86
+ * @param {{ anchor?: 'field' | 'caret', split?: (text: string, caret: number) => {word: string, start: number} }} [options]
87
+ */
88
+ export function attachComplete(input, suggest, options = {}) {
89
+ const own = el('div', { class: 'ac', hidden: true, role: 'listbox' });
90
+ input.parentElement.append(own);
91
+
92
+ const bind = () => {
93
+ box = own;
94
+ suggester = suggest;
95
+ splitter = options.split ?? tokenAt;
96
+ anchor = options.anchor ?? 'field';
97
+ };
98
+
99
+ input.addEventListener('input', () => { bind(); update(input); });
100
+ input.addEventListener('click', () => { bind(); update(input); });
101
+ input.addEventListener('blur', () => setTimeout(() => { bind(); close(); }, 120));
102
+ input.addEventListener('keydown', (e) => { bind(); onKey(e, input); });
103
+ return () => own.remove();
104
+ }
105
+
106
+ function close() {
107
+ if (box) box.hidden = true;
108
+ items = [];
109
+ index = -1;
110
+ anchorIndex = -1;
111
+ }
112
+
113
+ /** The word being typed, and where it starts. */
114
+ function tokenAt(text, caret) {
115
+ const before = text.slice(0, caret);
116
+ const start = Math.max(before.lastIndexOf(' '), before.lastIndexOf(',')) + 1;
117
+ return { word: before.slice(start), start };
118
+ }
119
+
120
+ function previousWord(text, start) {
121
+ return text.slice(0, start).trim().split(/\s+/).pop()?.toLowerCase() ?? '';
122
+ }
123
+
124
+ /**
125
+ * What could come next, given where the caret is.
126
+ *
127
+ * The ordering within each list matters more than the list: the first entry is
128
+ * what Tab accepts, so it should be the likeliest thing, not the alphabetically
129
+ * first.
130
+ *
131
+ * Exported for test/mirrors.test.ts, which asks it for the suggestions on
132
+ * every column of a fixture schema and checks that the ones offering relative
133
+ * dates are exactly the columns shared/schema.ts calls dates. That the rule is
134
+ * shared is a fact about the source; that the completer *applies* it is not.
135
+ */
136
+ export function suggestionsFor(text, caret) {
137
+ const { word, start } = tokenAt(text, caret);
138
+ const lower = word.toLowerCase();
139
+ const head = text.trim().split(/\s+/)[0] ?? '';
140
+
141
+ // Nothing typed yet, or still on the first word: this is the table.
142
+ if (start === 0) {
143
+ return state.schema.tables
144
+ .filter((t) => !lower || t.id.toLowerCase().includes(lower) || t.name.toLowerCase().includes(lower))
145
+ .map((t) => ({
146
+ value: t.name === t.id ? t.id : t.id,
147
+ label: t.id,
148
+ detail: `${t.columns.length} columns${t.isView ? ' · view' : ''}`,
149
+ kind: 'table',
150
+ }));
151
+ }
152
+
153
+ const table =
154
+ state.schema.tables.find((t) => t.id === head) ??
155
+ state.schema.tables.find((t) => t.name === head);
156
+ if (!table) return [];
157
+
158
+ const columns = table.columns.map((c) => ({
159
+ value: c.name,
160
+ label: c.name,
161
+ detail: `${c.type.toLowerCase()}${c.primaryKey ? ' · key' : ''}${c.references ? ` → ${c.references.table}` : ''}`,
162
+ kind: 'column',
163
+ }));
164
+
165
+ const previous = previousWord(text, start);
166
+ const matches = (list) => list.filter((s) => !lower || s.value.toLowerCase().startsWith(lower));
167
+
168
+ /* Immediately after the table name a column is what you want essentially
169
+ always — `customer ` is the start of a filter, not the end of a query.
170
+ Keywords come after, for the rarer case of jumping straight to `sort`. */
171
+ if (previous === head.toLowerCase() || previous === table.name.toLowerCase()) {
172
+ return matches([
173
+ ...columns,
174
+ ...CLAUSES.map((c) => ({ value: c, label: c, detail: 'clause', kind: 'kw' })),
175
+ ]);
176
+ }
177
+
178
+ // After a joiner or a clause word, a column name comes next.
179
+ if (EXPECTS_COLUMN.has(previous) || previous === '') {
180
+ const listed = TAKES_A_LIST.has(previous) || inList(text, start);
181
+ return matches(columns).map((c) => (listed ? { ...c, list: true } : c));
182
+ }
183
+
184
+ const previousIsColumn = table.columns.some((c) => c.name.toLowerCase() === previous);
185
+ if (previousIsColumn) {
186
+ const column = table.columns.find((c) => c.name.toLowerCase() === previous);
187
+ /* An operator, or a null check — and for a date column the relative-date
188
+ phrases, because `opened_on = last 30 days` is the single most useful
189
+ thing you can write about a date and the least guessable. */
190
+ /* The date test comes from core.js, which mirrors looksLikeDate in
191
+ shared/schema.ts. Written out here it had lost `timestamp`, `datetime`
192
+ and `deleted` from the name pattern, so `event_timestamp TEXT` was
193
+ offered `=` and nothing else while the server was perfectly willing to
194
+ answer `event_timestamp = last 30 days` on it — the language's best
195
+ feature, hidden on the columns it exists for. */
196
+ const isDate = column ? looksLikeDateColumn(column) : false;
197
+ const dateHints = isDate
198
+ ? DATE_VALUES.map((v) => ({ value: `= ${v}`, label: `= ${v}`, detail: 'date range', kind: 'value' }))
199
+ : [];
200
+ const ranged = isDate || RANGEABLE.has(columnKind(column?.type ?? ''));
201
+ const ops = [
202
+ ...OPERATORS
203
+ .filter((o) => o !== 'between' || ranged)
204
+ .map((o) => ({
205
+ value: o,
206
+ label: o,
207
+ detail: o === 'between' ? 'a range, both ends included' : 'operator',
208
+ kind: 'op',
209
+ })),
210
+ ...NULL_CHECKS.map((o) => ({ value: o, label: o, detail: 'null check', kind: 'op' })),
211
+ ];
212
+ /* On a date column the relative phrases lead. They are the most useful
213
+ thing you can say about a date and the least guessable, whereas `=` on
214
+ a timestamp is usually a mistake — it matches an exact instant. */
215
+ return matches(isDate ? [...dateHints, ...ops] : ops);
216
+ }
217
+
218
+ // Otherwise: another condition, or a clause to finish with.
219
+ return matches([
220
+ ...JOINERS.map((j) => ({ value: j, label: j, detail: 'combine', kind: 'kw' })),
221
+ ...CLAUSES.map((c) => ({ value: c, label: c, detail: 'clause', kind: 'kw' })),
222
+ ...columns,
223
+ ]);
224
+ }
225
+
226
+ /**
227
+ * Whether the caret sits inside a clause that is already a list.
228
+ *
229
+ * `show name, ` is a list whether or not the word just before the caret is
230
+ * the comma — after `show name, ema` the previous word is `ema`'s neighbour,
231
+ * not a comma. Looking back to the nearest clause word answers it properly.
232
+ */
233
+ function inList(text, start) {
234
+ const before = text.slice(0, start).toLowerCase();
235
+ const clause = before.match(/\b(show|select|sort|order\s+by|where|filter|and|or)\b(?![\s\S]*\b(?:show|select|sort|order\s+by|where|filter|and|or)\b)/);
236
+ if (!clause) return false;
237
+ return TAKES_A_LIST.has(clause[1].replace(/\s+/g, ' ') === 'order by' ? 'by' : clause[1]);
238
+ }
239
+
240
+ function update(input) {
241
+ const caret = input.selectionStart ?? input.value.length;
242
+ items = suggester(input.value, caret).slice(0, 30);
243
+ if (!items.length) return close();
244
+
245
+ box.replaceChildren();
246
+ items.forEach((item, i) => {
247
+ box.append(el('div', {
248
+ class: `ac-item kind-${item.kind}`,
249
+ role: 'option',
250
+ 'aria-selected': String(i === index),
251
+ // mousedown, not click: the input's blur fires first on click and would
252
+ // close the list before the selection landed.
253
+ onmousedown: (e) => { e.preventDefault(); accept(input, item); },
254
+ }, [
255
+ el('span', { class: 'ac-label', text: item.label }),
256
+ el('span', { class: 'ac-detail', text: item.detail }),
257
+ ]));
258
+ });
259
+ index = -1;
260
+ box.hidden = false;
261
+ if (anchor === 'caret') placeAtCaret(input, box);
262
+ }
263
+
264
+ /**
265
+ * Put the list under the caret rather than under the field.
266
+ *
267
+ * In a one-line query bar those are the same place. In a ten-line SQL editor
268
+ * they are not, and a list pinned to the bottom of the box while you type on
269
+ * line two is a list you have to go and find.
270
+ *
271
+ * The position is measured by rendering the text before the caret into a
272
+ * hidden copy of the field and reading where that copy ends — a textarea will
273
+ * not tell you where its caret is, and every other approach to this is a
274
+ * guess about font metrics.
275
+ */
276
+ function placeAtCaret(input, list) {
277
+ const style = getComputedStyle(input);
278
+ const mirror = el('div', { class: 'ac-mirror' });
279
+ for (const property of [
280
+ 'fontFamily', 'fontSize', 'fontWeight', 'lineHeight', 'letterSpacing',
281
+ 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
282
+ 'borderTopWidth', 'borderLeftWidth', 'whiteSpace', 'wordWrap', 'boxSizing',
283
+ ]) {
284
+ mirror.style[property] = style[property];
285
+ }
286
+ mirror.style.width = `${input.clientWidth}px`;
287
+ mirror.style.whiteSpace = 'pre-wrap';
288
+
289
+ const caret = input.selectionStart ?? input.value.length;
290
+ mirror.textContent = input.value.slice(0, caret);
291
+ const marker = el('span', { text: '\u200b' });
292
+ mirror.append(marker);
293
+ document.body.append(mirror);
294
+
295
+ const field = input.getBoundingClientRect();
296
+ const at = marker.getBoundingClientRect();
297
+ const mirrorBox = mirror.getBoundingClientRect();
298
+ mirror.remove();
299
+
300
+ const lineHeight = parseFloat(style.lineHeight || '18');
301
+ const caretTop = field.top + (at.top - mirrorBox.top) - input.scrollTop;
302
+ const left = field.left + (at.left - mirrorBox.left);
303
+
304
+ list.style.position = 'fixed';
305
+ list.style.left = `${Math.max(8, Math.min(left, window.innerWidth - 320))}px`;
306
+
307
+ /* Below the caret when there is room, above it when there is not.
308
+
309
+ The SQL editor sits at the bottom of the window, so its caret is usually
310
+ in the lower third and a list that only ever opens downwards opens off the
311
+ screen — which looks like a list with two items in it. Measured after the
312
+ items are in, because the height depends on how many there are. */
313
+ const height = list.offsetHeight;
314
+ const below = window.innerHeight - (caretTop + lineHeight);
315
+ list.style.top = below >= height + 12 || caretTop < height + 12
316
+ ? `${caretTop + lineHeight}px`
317
+ : `${caretTop - height - 4}px`;
318
+ }
319
+
320
+ /** The run between the anchor and the cursor, inclusive. Empty when there is no range. */
321
+ function selected() {
322
+ if (anchorIndex < 0 || anchorIndex === index) return [];
323
+ const from = Math.min(anchorIndex, index);
324
+ const to = Math.max(anchorIndex, index);
325
+ return items.slice(from, to + 1);
326
+ }
327
+
328
+ function paint() {
329
+ const from = anchorIndex < 0 ? index : Math.min(anchorIndex, index);
330
+ const to = anchorIndex < 0 ? index : Math.max(anchorIndex, index);
331
+ [...box.children].forEach((node, i) => {
332
+ const inRange = i >= from && i <= to;
333
+ node.setAttribute('aria-selected', String(inRange));
334
+ node.classList.toggle('active', i === index);
335
+ node.classList.toggle('picked', inRange && anchorIndex >= 0 && anchorIndex !== index);
336
+ });
337
+ box.children[index]?.scrollIntoView({ block: 'nearest' });
338
+ }
339
+
340
+ /**
341
+ * Move the cursor, extending the selection when Shift is held.
342
+ *
343
+ * Only where a list is meaningful — `show` and `sort` take several columns,
344
+ * a filter condition takes one. Offering a range where it cannot be used
345
+ * would produce a selection that silently collapsed to its last item.
346
+ */
347
+ function extend(next) {
348
+ if (!items[0]?.list) return highlight(next);
349
+ if (anchorIndex < 0) anchorIndex = index < 0 ? 0 : index;
350
+ index = Math.max(0, Math.min(items.length - 1, next));
351
+ paint();
352
+ }
353
+
354
+ function highlight(next) {
355
+ if (!items.length) return;
356
+ index = (next + items.length) % items.length;
357
+ [...box.children].forEach((node, i) => {
358
+ node.setAttribute('aria-selected', String(i === index));
359
+ node.classList.toggle('active', i === index);
360
+ });
361
+ box.children[index]?.scrollIntoView({ block: 'nearest' });
362
+ }
363
+
364
+ function accept(input, item) {
365
+ acceptMany(input, [item]);
366
+ }
367
+
368
+ /**
369
+ * Put one suggestion in, or a run of them as a comma list.
370
+ *
371
+ * One code path for both, because the interesting part — where the word being
372
+ * completed starts, and whether a space is needed after it — is the same
373
+ * either way, and the version that handled only one is the version that would
374
+ * have kept the trailing-space rule while the other lost it.
375
+ */
376
+ function acceptMany(input, chosen) {
377
+ if (!chosen.length) return;
378
+ const caret = input.selectionStart ?? input.value.length;
379
+ const { start } = splitter(input.value, caret);
380
+ const after = input.value.slice(caret);
381
+ const text = chosen.map((c) => c.value).join(', ');
382
+ const insert = text + (after.startsWith(' ') ? '' : ' ');
383
+ input.value = input.value.slice(0, start) + insert + after;
384
+ const next = start + insert.length;
385
+ input.setSelectionRange(next, next);
386
+ close();
387
+ // Chain straight into the next suggestion: pick a table, get its columns.
388
+ update(input);
389
+ }
390
+
391
+ function onKey(e, input) {
392
+ if (box.hidden) {
393
+ // Ctrl+Space opens it deliberately, for when it has closed itself.
394
+ if (e.key === ' ' && (e.ctrlKey || e.metaKey)) {
395
+ e.preventDefault();
396
+ update(input);
397
+ }
398
+ return;
399
+ }
400
+ switch (e.key) {
401
+ case 'ArrowDown':
402
+ e.preventDefault();
403
+ if (e.shiftKey) extend(index + 1); else highlight(index + 1);
404
+ return;
405
+ case 'ArrowUp':
406
+ e.preventDefault();
407
+ if (e.shiftKey) extend(index - 1); else highlight(index - 1);
408
+ return;
409
+ case 'Escape': e.preventDefault(); e.stopPropagation(); close(); return;
410
+ case 'Tab':
411
+ // Tab always completes — with nothing highlighted it takes the first,
412
+ // which is what makes typing a prefix and pressing Tab work.
413
+ e.preventDefault();
414
+ accept(input, items[index >= 0 ? index : 0]);
415
+ return;
416
+ case 'Enter':
417
+ /* Enter only completes when something is explicitly highlighted.
418
+ Otherwise it submits, so a finished query is one keystroke away and
419
+ does not need Escape first. */
420
+ if (selected().length) {
421
+ /* A range takes all of it. This is the whole point of holding Shift:
422
+ `show` wants several columns, and taking them one at a time means
423
+ reopening the list once per column. */
424
+ e.preventDefault();
425
+ acceptMany(input, selected());
426
+ return;
427
+ }
428
+ if (index >= 0) {
429
+ e.preventDefault();
430
+ accept(input, items[index]);
431
+ } else {
432
+ close();
433
+ }
434
+ return;
435
+ default:
436
+ }
437
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Folding a filter into an existing query.
3
+ *
4
+ * Pure — no DOM, no state — so the rules can be tested directly rather than
5
+ * through a browser. They are fiddly enough to deserve it: the naive version
6
+ * of this produces queries that silently match nothing.
7
+ */
8
+
9
+ /**
10
+ * Fold a new condition into an existing query, following the grammar.
11
+ *
12
+ * The naive version — append " and <condition>" — produces
13
+ * `status = scheduled and status = scheduled`, and picking a second value for
14
+ * the same column gives `status = a and status = b`, which matches nothing.
15
+ * Neither is what anyone means by clicking two values in a list.
16
+ *
17
+ * The rule that matches intent: conditions on *different* columns combine
18
+ * with AND, conditions on the *same* column combine with OR — and the
19
+ * language already has a word for OR within one column, so repeat equality
20
+ * folds into `in (…)` rather than growing a chain of ors. That keeps the
21
+ * query readable, which matters because the query is the thing you save,
22
+ * share and edit by hand.
23
+ */
24
+ import { splitConditions, splitQuery } from './clauses.js';
25
+
26
+ export function composeFilter(query, table, column, condition) {
27
+ /* Clauses have to stay at the end for the parser to read them as clauses,
28
+ so a new condition is inserted before them, never appended.
29
+
30
+ Split by the shared splitter rather than by a regex of its own. The regex
31
+ was blind to quotes, so `customer note = "sort me"` split at the `sort`
32
+ inside the value: everything from that word became a clause, the
33
+ condition was cut in half, and adding a filter destroyed the query it was
34
+ added to. `splitQuery` masks quoted spans and sorts boundaries by
35
+ position, which is the behaviour the query bar has always had — there was
36
+ no reason for this file to have a second, worse one. */
37
+ const parts = splitQuery(query);
38
+ const head = `${parts.table}${parts.filter ? ` ${parts.filter}` : ''}`.trim();
39
+ const tailPieces = [
40
+ parts.show ? ` ${parts.show}` : '',
41
+ parts.sort ? ` ${parts.sort}` : '',
42
+ parts.limit ? ` ${parts.limit}` : '',
43
+ ];
44
+ const tail = tailPieces.join('');
45
+
46
+ const equality = condition.match(new RegExp(`^${escapeRe(column)}\\s*=\\s*(.+)$`));
47
+ if (equality) {
48
+ const value = equality[1].trim();
49
+ /* Found by splitting the conditions rather than by matching a value with
50
+ a regex. The regex read a value as `\\S+` — one token — which is most
51
+ values and not all of them: `hired_on = this year` matched `this`, and
52
+ folding it into a list replaced only that much, leaving
53
+
54
+ employee hired_on in (this, last 7 days) year
55
+
56
+ — text the parser cannot read, which came back as "0 rows" rather than
57
+ as an error. A filter UI that silently empties the table is the thing
58
+ this module exists to prevent, and it was doing it. */
59
+ const existing = conditionOn(head, column, table);
60
+
61
+ if (existing && existing.op === 'in') {
62
+ if (existing.values.includes(value)) return `${head}${tail}`;
63
+ /* A value that cannot sit in a list does not join one. */
64
+ if (!listable(value)) return `${replaceCondition(head, table, existing, condition)}${tail}`;
65
+ const merged = `${column} in (${[...existing.values, value].join(', ')})`;
66
+ return `${replaceCondition(head, table, existing, merged)}${tail}`;
67
+ }
68
+ if (existing && existing.op === '=') {
69
+ if (existing.value === value) return `${head}${tail}`;
70
+ /* Two values fold into `in (…)` only when both are things a list can
71
+ hold. A relative date phrase is not one: `last 7 days` is a *range*,
72
+ and "hired in this year or in the last seven days" is not what
73
+ picking a second period from a menu of periods means — picking one
74
+ replaces the last. It is also not expressible, which is how the bug
75
+ above announced itself. */
76
+ if (!listable(value) || !listable(existing.value)) {
77
+ return `${replaceCondition(head, table, existing, condition)}${tail}`;
78
+ }
79
+ const merged = `${column} in (${existing.value}, ${value})`;
80
+ return `${replaceCondition(head, table, existing, merged)}${tail}`;
81
+ }
82
+ }
83
+
84
+ /* A null check replaces any other condition on the same column: `x is
85
+ empty and x = 3` is a contradiction, not a refinement. */
86
+ const nullCheck = condition.match(new RegExp(`^${escapeRe(column)}\\s+is\\s+(not\\s+)?empty$`, 'i'));
87
+ if (nullCheck) {
88
+ const stripped = stripConditionsOn(head, column, table);
89
+ return `${stripped}${stripped.trim() === table ? '' : ' and'} ${condition}${tail}`;
90
+ }
91
+
92
+ if (head === table) return `${head} ${condition}${tail}`;
93
+ return `${head} and ${condition}${tail}`;
94
+ }
95
+
96
+ /**
97
+ * Can this value sit inside `in (…)`?
98
+ *
99
+ * A bare token or a quoted string. Anything else — a relative date phrase, a
100
+ * range, an unquoted value with a space in it — is not a list member, and the
101
+ * list it would be written into is text no parser here accepts.
102
+ */
103
+ function listable(value) {
104
+ const text = String(value ?? '').trim();
105
+ if (!text) return false;
106
+ /* Spelled out rather than as one regex: a quote character inside a regex
107
+ literal is indistinguishable from the start of a string to anything
108
+ reading this file with a tokenizer simpler than a parser, and
109
+ `check-client-imports` is exactly that. */
110
+ const quote = String.fromCharCode(34);
111
+ if (text.startsWith(quote) && text.endsWith(quote) && text.length > 1) {
112
+ return !text.slice(1, -1).includes(quote);
113
+ }
114
+ return !/[\s,()]/.test(text);
115
+ }
116
+
117
+ /**
118
+ * The one top-level condition on `column`, split the way the parser splits.
119
+ *
120
+ * Returns its exact text, so it can be swapped out without a regex having to
121
+ * work out where a value ends — which is the mistake this file has now made
122
+ * twice, once on quotes and once on multi-word values.
123
+ */
124
+ function conditionOn(head, column, table) {
125
+ const body = head.slice(table.length).trim();
126
+ const on = new RegExp(`^${escapeRe(column)}\\s*(=|\\bin\\b)`, 'i');
127
+ for (const part of splitConditions(body)) {
128
+ const text = part.trim();
129
+ if (!on.test(text)) continue;
130
+ const list = text.match(new RegExp(`^${escapeRe(column)}\\s+in\\s*\\(([^)]*)\\)$`, 'i'));
131
+ if (list) {
132
+ return { text, op: 'in', values: list[1].split(',').map((v) => v.trim()).filter(Boolean) };
133
+ }
134
+ const eq = text.match(new RegExp(`^${escapeRe(column)}\\s*=\\s*(.+)$`, 'i'));
135
+ if (eq) return { text, op: '=', value: eq[1].trim() };
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /** Swap one condition for another, leaving every other condition alone. */
141
+ function replaceCondition(head, table, existing, next) {
142
+ const body = head.slice(table.length).trim();
143
+ const parts = splitConditions(body).map((part) => (part.trim() === existing.text ? next : part.trim()));
144
+ return `${table} ${parts.join(' and ')}`.trim();
145
+ }
146
+
147
+ /**
148
+ * Remove every top-level condition mentioning `column`.
149
+ *
150
+ * Split by `splitConditions`, which tracks quotes and parentheses. Splitting
151
+ * on `\s+and\s+` took `name = "a and b"` apart in the middle of a value, and
152
+ * took `(a = 1 and b = 2)` apart inside a group it was supposed to leave
153
+ * whole — turning one condition the reader wrote into two the parser could
154
+ * not read.
155
+ */
156
+ function stripConditionsOn(head, column, table) {
157
+ const body = head.slice(table.length).trim();
158
+ const kept = splitConditions(body)
159
+ .filter((part) => !new RegExp(`^${escapeRe(column)}\\b`, 'i').test(part.trim()));
160
+ return kept.length ? `${table} ${kept.join(' and ')}` : table;
161
+ }
162
+
163
+ function escapeRe(text) {
164
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
165
+ }
166
+