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,831 @@
1
+ /**
2
+ * A small query language for browsing.
3
+ *
4
+ * customers country = "Australia" and credit_limit > 1000
5
+ * invoices where total >= 50 sort invoice_date desc limit 20
6
+ * employees title contains manager show first_name, last_name, email
7
+ * orders order_date = last 30 days
8
+ *
9
+ * The table comes first, because that is how people say it out loud. Every
10
+ * other clause is optional, and `where` / `order by` are accepted as aliases
11
+ * for `filter` / `sort` so SQL habits do not have to be unlearned.
12
+ *
13
+ * Why a language at all, when the thing underneath is SQL?
14
+ *
15
+ * Because this one compiles to a `Filter` — a structure — and never to SQL
16
+ * text. The adapter turns that structure into a parameterised statement, so
17
+ * there is no path from what a user types to what the database parses as
18
+ * syntax. A tool that let people type SQL directly would have to either trust
19
+ * them or write a SQL parser to untrust them; this sidesteps both by making
20
+ * the dangerous shape unrepresentable.
21
+ *
22
+ * Two things fall out of it for free. Because the compiler holds the schema,
23
+ * a mistyped column is an error at the caret instead of a database exception
24
+ * — and, worse, a mistyped *value* on a real column can be caught too. And
25
+ * because the compiled statement is shown next to the query, the language
26
+ * teaches SQL rather than hiding it.
27
+ */
28
+ import { MAX_LIMIT } from '../adapters/adapter.js';
29
+ import { LIKE_ESCAPE, likePattern } from './like.js';
30
+ import { columnKind, findTable, } from './schema.js';
31
+ /**
32
+ * What can appear in a field position.
33
+ *
34
+ * `\w`, plus `.` for reference paths, `$` because some dialects allow it in
35
+ * identifiers, and `+` for the composite-hop syntax a view uses:
36
+ * `invoice_id+line_no.sku` follows a two-column foreign key. Without `+`
37
+ * those hops were addressable only by their constraint name, which is a
38
+ * database artefact nobody wants to type.
39
+ *
40
+ * `+` is safe to admit because the language has no arithmetic — there is
41
+ * nothing it could otherwise have meant — and it cannot appear in a real
42
+ * column name, so it can never be ambiguous with one.
43
+ */
44
+ const FIELD = String.raw `[\w.$+]+`;
45
+ /** Operator words users type, mapped to the filter's operators. */
46
+ const OPERATORS = {
47
+ '=': '=',
48
+ '!=': '!=',
49
+ '<': '<',
50
+ '<=': '<=',
51
+ '>': '>',
52
+ '>=': '>=',
53
+ contains: 'contains',
54
+ startswith: 'startsWith',
55
+ endswith: 'endsWith',
56
+ like: 'like',
57
+ in: 'in',
58
+ };
59
+ /* Re-exported: the classifier moved to schema.ts to keep adapters from
60
+ importing this module, but callers here have always got it from here. */
61
+ export { columnKind } from './schema.js';
62
+ export const QUERY_OPERATORS = Object.keys(OPERATORS);
63
+ export const QUERY_KEYWORDS = ['filter', 'where', 'show', 'sort', 'order by', 'limit', 'and', 'or', 'desc', 'asc'];
64
+ /* Written as a range and compiled as two comparisons — see `between` in
65
+ parseCondition. Listed separately because it is an operator to whoever is
66
+ typing, whatever it becomes afterwards. */
67
+ export const QUERY_RANGE_OPERATOR = 'between';
68
+ function startOfDay(d) {
69
+ const c = new Date(d);
70
+ c.setHours(0, 0, 0, 0);
71
+ return c;
72
+ }
73
+ function addDays(d, n) {
74
+ const c = new Date(d);
75
+ c.setDate(c.getDate() + n);
76
+ return c;
77
+ }
78
+ /**
79
+ * `now` is a parameter rather than a call to the clock, so the compiler is a
80
+ * pure function of its inputs. That is what makes the tests below able to
81
+ * assert on an exact timestamp instead of a range.
82
+ */
83
+ export function dateWindow(phrase, now) {
84
+ const p = phrase.toLowerCase().trim();
85
+ const today = startOfDay(now);
86
+ const fixed = {
87
+ today: [today, addDays(today, 1)],
88
+ yesterday: [addDays(today, -1), today],
89
+ tomorrow: [addDays(today, 1), addDays(today, 2)],
90
+ };
91
+ if (fixed[p])
92
+ return { label: p, start: fixed[p][0], end: fixed[p][1] };
93
+ if (p === 'this week' || p === 'last week') {
94
+ // Weeks start Monday, which is what a working calendar means by "this week".
95
+ const dow = (today.getDay() + 6) % 7;
96
+ const monday = addDays(today, -dow);
97
+ const start = p === 'this week' ? monday : addDays(monday, -7);
98
+ return { label: p, start, end: addDays(start, 7) };
99
+ }
100
+ if (p === 'this month' || p === 'last month') {
101
+ const base = new Date(today.getFullYear(), today.getMonth() + (p === 'last month' ? -1 : 0), 1);
102
+ const end = new Date(base.getFullYear(), base.getMonth() + 1, 1);
103
+ return { label: p, start: base, end };
104
+ }
105
+ if (p === 'this year' || p === 'last year') {
106
+ const year = today.getFullYear() + (p === 'last year' ? -1 : 0);
107
+ return { label: p, start: new Date(year, 0, 1), end: new Date(year + 1, 0, 1) };
108
+ }
109
+ const span = p.match(/^(last|next)\s+(\d+)\s+(hour|day|week|month|year)s?$/);
110
+ if (span) {
111
+ const [, dir, amountRaw, unit] = span;
112
+ const amount = Number(amountRaw);
113
+ const past = dir === 'last';
114
+ if (unit === 'hour') {
115
+ const ms = amount * 3600_000;
116
+ return past
117
+ ? { label: p, start: new Date(now.getTime() - ms), end: now }
118
+ : { label: p, start: now, end: new Date(now.getTime() + ms) };
119
+ }
120
+ const days = unit === 'week' ? amount * 7 : amount;
121
+ if (unit === 'month' || unit === 'year') {
122
+ /* Counted in calendar units rather than in days, so `last 3 months`
123
+ from the 31st lands on a date that exists and `last 1 year` from a
124
+ leap day does not drift. `Date` normalises an overflowing day of the
125
+ month, which is the behaviour wanted here: three months before the
126
+ 31st of May is the 28th of February, not a date in March. */
127
+ const months = unit === 'year' ? amount * 12 : amount;
128
+ const other = new Date(today.getFullYear(), today.getMonth() + (past ? -months : months), today.getDate());
129
+ return past ? { label: p, start: other, end: addDays(today, 1) } : { label: p, start: today, end: other };
130
+ }
131
+ return past
132
+ ? { label: p, start: addDays(today, -days), end: addDays(today, 1) }
133
+ : { label: p, start: today, end: addDays(today, days) };
134
+ }
135
+ return undefined;
136
+ }
137
+ /**
138
+ * Split on a keyword at depth zero, honouring quotes and parentheses so that
139
+ * `a and (b or c)` and `name = "Smith and Sons"` both survive intact.
140
+ */
141
+ /** Whether `word` sits at `i` as a whole word rather than inside a longer one. */
142
+ function isWord(text, i, word) {
143
+ const before = text[i - 1];
144
+ const after = text[i + word.length];
145
+ return (before === undefined || /[\s(]/.test(before))
146
+ && (after === undefined || /[\s(]/.test(after));
147
+ }
148
+ function splitTopLevel(text, keyword, offset) {
149
+ const parts = [];
150
+ let current = '';
151
+ let start = 0;
152
+ let quote = null;
153
+ let depth = 0;
154
+ /** Set when a `between` is open and its `and` has not been seen yet. */
155
+ let pendingBetween = false;
156
+ const lower = text.toLowerCase();
157
+ for (let i = 0; i < text.length; i++) {
158
+ const ch = text[i];
159
+ if (quote) {
160
+ /* An escaped character cannot end the quote — the same rule
161
+ `maskQuotedValues` applies below, and the reason this loop needed it:
162
+ without it `name = "a \\" and b"` closed its quote at the escaped
163
+ one, found a top-level `and` inside the value, and split a single
164
+ condition in half. Two quote scanners in one file disagreeing about
165
+ whether the grammar has escapes. */
166
+ if (ch === '\\' && i + 1 < text.length) {
167
+ current += ch + text[i + 1];
168
+ i += 1;
169
+ continue;
170
+ }
171
+ current += ch;
172
+ if (ch === quote)
173
+ quote = null;
174
+ continue;
175
+ }
176
+ if (ch === '"' || ch === "'") {
177
+ quote = ch;
178
+ current += ch;
179
+ continue;
180
+ }
181
+ if (ch === '(')
182
+ depth++;
183
+ if (ch === ')')
184
+ depth = Math.max(0, depth - 1);
185
+ /* `between 10 and 20` contains an `and` that joins a range rather than two
186
+ conditions. Splitting there cut one condition in half and left `20` as
187
+ a condition of its own, which parsed as nothing and reported an error
188
+ about `20` — so the word had to be tracked rather than the grammar
189
+ reworded. The first top-level `and` after a `between` belongs to it. */
190
+ if (keyword === 'and' && lower.startsWith('between', i) && isWord(text, i, 'between')) {
191
+ pendingBetween = true;
192
+ }
193
+ const atBoundary = depth === 0 &&
194
+ lower.startsWith(keyword, i) &&
195
+ (i === 0 || /[\s)]/.test(text[i - 1])) &&
196
+ /[\s(]/.test(text[i + keyword.length] ?? ' ');
197
+ if (atBoundary && pendingBetween) {
198
+ pendingBetween = false;
199
+ current += text[i];
200
+ continue;
201
+ }
202
+ if (atBoundary) {
203
+ parts.push({ text: current, at: offset + start });
204
+ i += keyword.length - 1;
205
+ current = '';
206
+ start = i + 1;
207
+ continue;
208
+ }
209
+ current += ch;
210
+ }
211
+ if (current.trim())
212
+ parts.push({ text: current, at: offset + start });
213
+ return parts.filter((p) => p.text.trim());
214
+ }
215
+ /** Strip one layer of wrapping parentheses, when they wrap the whole term. */
216
+ function unwrap(text) {
217
+ const t = text.trim();
218
+ const lead = text.length - text.trimStart().length;
219
+ if (!t.startsWith('(') || !t.endsWith(')'))
220
+ return { text: t, shift: lead };
221
+ let depth = 0;
222
+ for (let i = 0; i < t.length; i++) {
223
+ if (t[i] === '(')
224
+ depth++;
225
+ else if (t[i] === ')') {
226
+ depth--;
227
+ if (depth === 0 && i < t.length - 1)
228
+ return { text: t, shift: lead }; // `)(` — not one wrap
229
+ }
230
+ }
231
+ return { text: t.slice(1, -1).trim(), shift: lead + 1 };
232
+ }
233
+ /** `or` binds loosest, then `and`, then parentheses. */
234
+ function parseExpression(text, offset) {
235
+ const { text: inner, shift } = unwrap(text);
236
+ const base = offset + shift;
237
+ const ors = splitTopLevel(inner, 'or', base);
238
+ if (ors.length > 1)
239
+ return { kind: 'or', children: ors.map((p) => parseExpression(p.text, p.at)) };
240
+ const ands = splitTopLevel(inner, 'and', base);
241
+ if (ands.length > 1)
242
+ return { kind: 'and', children: ands.map((p) => parseExpression(p.text, p.at)) };
243
+ return { kind: 'cond', text: inner, at: base };
244
+ }
245
+ /**
246
+ * Flatten to OR-of-ANDs, which is exactly the shape `Filter` holds.
247
+ *
248
+ * The distribution can blow up on a pathological query — `(a or b) and (c or
249
+ * d) and (e or f)` is eight groups — so it is capped. Better to refuse than
250
+ * to build a statement with ten thousand clauses in it.
251
+ */
252
+ const MAX_GROUPS = 64;
253
+ function toGroups(node) {
254
+ if (node.kind === 'cond')
255
+ return [[node]];
256
+ if (node.kind === 'or')
257
+ return node.children.flatMap(toGroups);
258
+ return node.children.reduce((groups, child) => {
259
+ const childGroups = toGroups(child);
260
+ const out = groups.flatMap((g) => childGroups.map((c) => [...g, ...c]));
261
+ return out.slice(0, MAX_GROUPS + 1);
262
+ }, [[]]);
263
+ }
264
+ /* ---------- value coercion ----------
265
+
266
+ The schema is the reason this is possible at all. Knowing the column's
267
+ declared type lets `active = true` mean the boolean `true` on Postgres and
268
+ the integer `1` on SQLite, without the user thinking about either. */
269
+ /**
270
+ * Take the quotes off a value, and the escapes out of it.
271
+ *
272
+ * The unescaping half was missing, and its absence was invisible because the
273
+ * *tokenizer* has always understood `\"` — `maskQuotedValues` skips an
274
+ * escaped character precisely so it cannot end the quote. So `name =
275
+ * "Harbour \"HQ\""` was split into a clause correctly and then bound as the
276
+ * literal `Harbour \"HQ\"`, backslashes and all, which matches no row. Two
277
+ * halves of one grammar disagreeing about whether it has escapes.
278
+ *
279
+ * Only `\"`, `\'` and `\\` are unescaped. This is not C: `\n` in a value
280
+ * is a backslash and an n, because that is what someone typing a Windows path
281
+ * into a filter means by it.
282
+ */
283
+ function unquoteValue(raw) {
284
+ const t = raw.trim();
285
+ if ((t.startsWith('"') && t.endsWith('"') && t.length > 1)
286
+ || (t.startsWith("'") && t.endsWith("'") && t.length > 1)) {
287
+ return { value: unescapeValue(t.slice(1, -1)), quoted: true };
288
+ }
289
+ return { value: t, quoted: false };
290
+ }
291
+ /**
292
+ * Write a value back into the grammar, quoting only when it needs it.
293
+ *
294
+ * The exact counterpart of `unquoteValue` above, and the reason it lives here
295
+ * rather than beside the caller: quoting used to be written out wherever a
296
+ * value had to be turned back into query text — three times in the client and
297
+ * once here — and the four disagreed about an embedded `"`, so the same value
298
+ * produced a different filter depending on which button was pressed.
299
+ *
300
+ * The backslash is escaped before the quote, or a value ending in one would
301
+ * escape its own closing quote and swallow the rest of the query. The client
302
+ * has its own copy in `core.quote`, because the browser cannot import this
303
+ * file; `mirrors.test.ts` asserts the two agree character for character.
304
+ */
305
+ export function quoteValue(value) {
306
+ const text = String(value);
307
+ if (/^[\w.-]+$/.test(text))
308
+ return text;
309
+ return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
310
+ }
311
+ function unescapeValue(text) {
312
+ return text.replace(/\\(["'\\])/g, '$1');
313
+ }
314
+ /**
315
+ * Dates are bound as ISO strings because both target dialects compare them
316
+ * correctly that way: Postgres casts on the way in, and SQLite's documented
317
+ * text-date format is exactly ISO-8601. A `Date` object handed to a driver
318
+ * is at the mercy of whatever it decides to do with the local timezone.
319
+ */
320
+ function bindDate(d) {
321
+ const pad = (n) => String(n).padStart(2, '0');
322
+ return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
323
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`);
324
+ }
325
+ function coerce(column, value, quoted) {
326
+ const kind = column ? columnKind(column.type) : 'other';
327
+ if (quoted)
328
+ return value; // an explicit quote is an explicit "this is text"
329
+ const truthy = /^(true|yes)$/i.test(value);
330
+ const falsy = /^(false|no)$/i.test(value);
331
+ if (kind === 'boolean') {
332
+ if (truthy || value === '1')
333
+ return true;
334
+ if (falsy || value === '0')
335
+ return false;
336
+ }
337
+ if (kind === 'number') {
338
+ /* SQLite has no boolean type, so a flag column is declared INTEGER and
339
+ stores 1 and 0. Someone typing `active = true` against it means the
340
+ integer, and binding the string 'true' would match nothing while
341
+ looking exactly like an empty table. */
342
+ if (truthy)
343
+ return 1;
344
+ if (falsy)
345
+ return 0;
346
+ const n = Number(value);
347
+ if (Number.isFinite(n) && value !== '')
348
+ return n;
349
+ }
350
+ if (kind === 'other') {
351
+ /* An untyped column compared to a bare number is still probably a number
352
+ — SQLite's dynamic typing makes untyped columns common. */
353
+ if (/^-?\d+(\.\d+)?$/.test(value))
354
+ return Number(value);
355
+ if (truthy)
356
+ return true;
357
+ if (falsy)
358
+ return false;
359
+ }
360
+ return value;
361
+ }
362
+ /* ---------- condition compilation ---------- */
363
+ /** Levenshtein distance, capped — only used to suggest a near-miss column. */
364
+ export function distance(a, b) {
365
+ if (Math.abs(a.length - b.length) > 3)
366
+ return 99;
367
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
368
+ for (let i = 1; i <= a.length; i++) {
369
+ let last = prev[0];
370
+ prev[0] = i;
371
+ for (let j = 1; j <= b.length; j++) {
372
+ const tmp = prev[j];
373
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1));
374
+ last = tmp;
375
+ }
376
+ }
377
+ return prev[b.length];
378
+ }
379
+ function resolveColumn(table, name) {
380
+ if (!table)
381
+ return {};
382
+ const exact = table.columns.find((c) => c.name === name);
383
+ if (exact)
384
+ return { column: exact };
385
+ // Case-insensitive next: people type `Name` for `name` constantly, and
386
+ // both dialects fold unquoted identifiers anyway.
387
+ const insensitive = table.columns.find((c) => c.name.toLowerCase() === name.toLowerCase());
388
+ if (insensitive)
389
+ return { column: insensitive };
390
+ const near = table.columns
391
+ .map((c) => ({ name: c.name, d: distance(name.toLowerCase(), c.name.toLowerCase()) }))
392
+ .sort((a, b) => a.d - b.d)[0];
393
+ const suggestion = near && near.d <= 3 ? ` Did you mean "${near.name}"?` : '';
394
+ return { error: `"${name}" is not a column on ${table.name}.${suggestion}` };
395
+ }
396
+ function compileCondition(raw, table, now) {
397
+ const text = raw.trim();
398
+ /**
399
+ * `column between a and b`.
400
+ *
401
+ * Expanded into `>= a` and `<= b` rather than carried as an operator of its
402
+ * own. The filter model would need a new `Op` in three adapters to express
403
+ * a range it can already express, and every one of them would have to get
404
+ * the inclusive ends right. Two conditions is the same set of rows, needs
405
+ * nothing new, and shows in the SQL panel as exactly what it does.
406
+ *
407
+ * Inclusive at both ends, which is what SQL's BETWEEN means and what
408
+ * someone typing `total between 100 and 500` expects of `500`.
409
+ */
410
+ const between = text.match(new RegExp(String.raw `^(${FIELD})\s+(not\s+)?between\s+(.+?)\s+and\s+(.+)$`, 'i'));
411
+ if (between) {
412
+ const [, field, not, lowRaw, highRaw] = between;
413
+ const { column, error } = resolveColumn(table, field);
414
+ if (error)
415
+ return { error };
416
+ const name = column?.name ?? field;
417
+ if (not) {
418
+ return {
419
+ error: `"not between" is not supported. Write it as two conditions: `
420
+ + `${name} < ${lowRaw.trim()} or ${name} > ${highRaw.trim()}`,
421
+ };
422
+ }
423
+ const low = unquoteValue(lowRaw);
424
+ const high = unquoteValue(highRaw);
425
+ if (!low.value || !high.value) {
426
+ return { error: `"between" needs two values, as in ${name} between 10 and 20` };
427
+ }
428
+ /* Each end coerced on its own, so a date range gets the same treatment a
429
+ single date comparison does — `between 2024-01-01 and 2024-06-30` binds
430
+ two timestamps, not two strings. */
431
+ return {
432
+ conditions: [
433
+ { column: name, op: '>=', value: coerce(column, low.value, low.quoted) },
434
+ { column: name, op: '<=', value: coerce(column, high.value, high.quoted) },
435
+ ],
436
+ };
437
+ }
438
+ const empty = text.match(new RegExp(String.raw `^([\w.$+"]+)\s+is\s+(not\s+)?empty$`, 'i'));
439
+ if (empty) {
440
+ const [, field, not] = empty;
441
+ const { column, error } = resolveColumn(table, field);
442
+ if (error)
443
+ return { error };
444
+ return { conditions: [{ column: column?.name ?? field, op: not ? 'isNotNull' : 'isNull' }] };
445
+ }
446
+ // Word operators are tried first because they are multi-character and a
447
+ // symbol match would otherwise never see them.
448
+ const word = text.match(new RegExp(String.raw `^(${FIELD})\s+(not\s+contains|contains|startswith|endswith|like|not\s+in|in)\s+(.+)$`, 'i'));
449
+ if (word) {
450
+ const [, field, opRaw, valueRaw] = word;
451
+ const { column, error } = resolveColumn(table, field);
452
+ if (error)
453
+ return { error };
454
+ const name = column?.name ?? field;
455
+ const key = opRaw.toLowerCase().replace(/\s+/g, ' ');
456
+ const negated = key.startsWith('not ');
457
+ const op = OPERATORS[negated ? key.slice(4) : key];
458
+ if (!op)
459
+ return { error: `Unknown operator "${opRaw}"` };
460
+ if (op === 'in') {
461
+ // `in (a, b)` and `in [a, b]` read more naturally than a bare list.
462
+ const list = valueRaw.trim().replace(/^[([]\s*/, '').replace(/\s*[)\]]$/, '');
463
+ const items = list
464
+ .split(',')
465
+ .map((s) => unquoteValue(s))
466
+ .filter((s) => s.value !== '');
467
+ if (!items.length)
468
+ return { error: `"${opRaw}" needs at least one value` };
469
+ const values = items.map((i) => coerce(column, i.value, i.quoted));
470
+ if (negated) {
471
+ /* There is no NOT IN in the filter model, and adding one would mean a
472
+ new operator in every adapter. An expanded chain of != is the same
473
+ set and needs nothing new. */
474
+ return { conditions: values.map((v) => ({ column: name, op: '!=', value: v })) };
475
+ }
476
+ return { conditions: [{ column: name, op: 'in', value: values }] };
477
+ }
478
+ if (negated) {
479
+ return { error: `"not ${op}" is not supported yet — use "is empty" or a different operator` };
480
+ }
481
+ const { value, quoted } = unquoteValue(valueRaw);
482
+ // LIKE operands stay text even on a numeric column; `contains 5` on an
483
+ // integer is a substring question, not an equality one.
484
+ return { conditions: [{ column: name, op, value: quoted ? value : value }] };
485
+ }
486
+ const symbol = text.match(new RegExp(String.raw `^(${FIELD})\s*(!=|<=|>=|=|<|>)\s*(.+)$`));
487
+ if (symbol) {
488
+ const [, field, opRaw, valueRaw] = symbol;
489
+ const { column, error } = resolveColumn(table, field);
490
+ if (error)
491
+ return { error };
492
+ const name = column?.name ?? field;
493
+ const op = OPERATORS[opRaw];
494
+ const { value, quoted } = unquoteValue(valueRaw);
495
+ /* A date phrase is a range, not a point, so `=` becomes two conditions.
496
+ Getting this wrong is the failure this language is built to avoid: an
497
+ unparseable phrase sent through as a literal matches nothing, and a
498
+ filter that silently matches nothing looks exactly like a table that is
499
+ genuinely empty. */
500
+ if (!quoted) {
501
+ const window = dateWindow(value, now);
502
+ if (window) {
503
+ if (op === '=') {
504
+ return {
505
+ conditions: [
506
+ { column: name, op: '>=', value: bindDate(window.start) },
507
+ { column: name, op: '<', value: bindDate(window.end) },
508
+ ],
509
+ };
510
+ }
511
+ if (op === '>=' || op === '<')
512
+ return { conditions: [{ column: name, op, value: bindDate(window.start) }] };
513
+ if (op === '>' || op === '<=')
514
+ return { conditions: [{ column: name, op, value: bindDate(window.end) }] };
515
+ return { error: `"${value}" is a date range — use =, >=, >, <= or < with it, not ${opRaw}` };
516
+ }
517
+ }
518
+ /* A bare multi-word value is nearly always a missing pair of quotes. It
519
+ would otherwise bind as a literal that matches nothing, which reads as
520
+ "no results" rather than "you made a mistake". */
521
+ if (!quoted && /\s/.test(value)) {
522
+ return {
523
+ error: `"${value}" has spaces — quote it ("${value}"), or use a date phrase like today or last 30 days`,
524
+ };
525
+ }
526
+ return { conditions: [{ column: name, op, value: coerce(column, value, quoted) }] };
527
+ }
528
+ /* A bare column name is a common half-typed state, and it is ambiguous in a
529
+ useful way: the user may be starting a filter, or may want projection and
530
+ not know the word for it. Naming both is how `show` gets discovered —
531
+ an error that only suggests filtering teaches that projection does not
532
+ exist. */
533
+ if (new RegExp(String.raw `^${FIELD}$`).test(text) && table) {
534
+ const { column } = resolveColumn(table, text);
535
+ if (column) {
536
+ return {
537
+ error: `What about "${text}"? To filter, try ${text} = … or ${text} is not empty. ` +
538
+ `To show only that column, put "show" first: show ${text}`,
539
+ };
540
+ }
541
+ }
542
+ return { error: `Could not read "${text}" — try e.g. status = open, or amount > 100` };
543
+ }
544
+ /* ---------- the parser ---------- */
545
+ /**
546
+ * What makes a clause word a column name instead: an operator right after it.
547
+ * A table with a column called `filter`, `sort` or `limit` is not unusual, and
548
+ * eating the word as a clause turns `limit is empty` into a nonsense query.
549
+ */
550
+ const FOLLOWED_BY_OPERATOR = /^\s*(?:=|!=|>=|<=|>|<|\bis\s+not\s+empty\b|\bis\s+empty\b|\bis\b|\bcontains\b|\bstartswith\b|\bendswith\b|\blike\b|\bnot\s+in\b|\bin\b)/i;
551
+ const CLAUSE_RE = /\b(filter|where|show|select|sort|order\s+by|limit)\b/gi;
552
+ /**
553
+ * A copy of the query with the contents of quoted values replaced by spaces.
554
+ *
555
+ * Same length as the input and the quote characters left in place, so an index
556
+ * into this string is an index into the original. That is the whole trick: the
557
+ * scan happens on a version where a value cannot be mistaken for syntax, and
558
+ * every position it reports still points at the text the person typed.
559
+ *
560
+ * An unterminated quote blanks to the end, which is the safe direction — the
561
+ * alternative is reading the tail of a half-typed value as clauses while
562
+ * someone is still typing it.
563
+ */
564
+ function maskQuotedValues(text) {
565
+ let out = '';
566
+ let quote = null;
567
+ for (let i = 0; i < text.length; i += 1) {
568
+ const ch = text[i];
569
+ if (quote) {
570
+ if (ch === '\\' && i + 1 < text.length) {
571
+ // An escaped character cannot end the quote, and both halves of it are
572
+ // inside the value.
573
+ out += ' ';
574
+ i += 1;
575
+ continue;
576
+ }
577
+ out += ch === quote ? ((quote = null), ch) : ' ';
578
+ continue;
579
+ }
580
+ if (ch === '"' || ch === "'") {
581
+ quote = ch;
582
+ out += ch;
583
+ continue;
584
+ }
585
+ out += ch;
586
+ }
587
+ return out;
588
+ }
589
+ /**
590
+ * What to say about a clause given twice.
591
+ *
592
+ * `filter` is the one that combines, and it is not routed here: two conditions
593
+ * genuinely narrow one result, so they are joined with `and`. Nothing else
594
+ * does. `show a show b` used to compile as a single column literally named
595
+ * "a and b" and then fail as an unknown column — an error naming a word the
596
+ * user never typed, about a mistake it never mentioned.
597
+ *
598
+ * Refusing beats picking one. There is no reading of two `limit`s under which
599
+ * the discarded number was not asked for, and a silently dropped clause is the
600
+ * failure this language exists to avoid: the answer looks like an answer. So
601
+ * the clause is named, and — where the language has a way to say both things
602
+ * at once — the comma that does it is shown.
603
+ */
604
+ function repeatedClause(keyword) {
605
+ const combine = {
606
+ show: 'Name every column in one, separated by commas: show first_name, last_name',
607
+ sort: 'Name every field in one, separated by commas: sort invoice_date desc, id',
608
+ limit: 'Only one number can be the number of rows — keep the one you meant.',
609
+ };
610
+ return `Two "${keyword}" clauses, and the second cannot add to the first. ${combine[keyword] ?? 'Say it once.'}`;
611
+ }
612
+ function normalizeClause(word) {
613
+ const w = word.toLowerCase().replace(/\s+/g, ' ');
614
+ if (w === 'where')
615
+ return 'filter';
616
+ if (w === 'order by')
617
+ return 'sort';
618
+ if (w === 'select')
619
+ return 'show';
620
+ return w;
621
+ }
622
+ export function parseQuery(input, options = {}) {
623
+ const text = input.trim();
624
+ const now = options.now ?? new Date();
625
+ if (!text)
626
+ return { errors: [{ message: 'Start with a table name.' }] };
627
+ const errors = [];
628
+ /* Clause keywords are found in a copy with the *insides* of quoted values
629
+ blanked out, and then used as offsets into the real text.
630
+
631
+ Scanning the raw string made a value that happens to contain a clause word
632
+ into a clause: `name = "order by"` split at the quote, and the error came
633
+ back "\"\"\" is not a column on customer. Did you mean \"id\"?" — confident,
634
+ specific, and about a column nobody typed. The blanking preserves length
635
+ and the quote characters themselves, so every offset still lines up with
636
+ the original and error positions stay honest. */
637
+ const scannable = maskQuotedValues(text);
638
+ CLAUSE_RE.lastIndex = 0;
639
+ const marks = [];
640
+ let m;
641
+ while ((m = CLAUSE_RE.exec(scannable))) {
642
+ if (FOLLOWED_BY_OPERATOR.test(scannable.slice(CLAUSE_RE.lastIndex)))
643
+ continue;
644
+ marks.push({ keyword: normalizeClause(m[1]), start: m.index, end: CLAUSE_RE.lastIndex });
645
+ }
646
+ const head = (marks.length ? text.slice(0, marks[0].start) : text).trim();
647
+ const tableName = head.split(/\s+/)[0] ?? '';
648
+ if (!tableName)
649
+ return { errors: [{ message: 'Which table?' }] };
650
+ /* The table has to resolve before anything else can be checked, since every
651
+ column error is relative to it. An unqualified name is matched on the
652
+ bare table name too, so `customers` finds `public.customers`. */
653
+ let table;
654
+ if (options.schema) {
655
+ table = findTable(options.schema, tableName);
656
+ if (!table) {
657
+ const candidates = options.schema.tables.filter((t) => t.name === tableName);
658
+ if (candidates.length === 1)
659
+ table = candidates[0];
660
+ else if (candidates.length > 1) {
661
+ errors.push({
662
+ message: `"${tableName}" is ambiguous — it exists in ${candidates.map((c) => c.schema).join(', ')}. Qualify it, e.g. ${candidates[0].id}.`,
663
+ at: 0,
664
+ });
665
+ }
666
+ else {
667
+ const near = options.schema.tables
668
+ .map((t) => ({ id: t.id, d: Math.min(distance(tableName, t.name), distance(tableName, t.id)) }))
669
+ .sort((a, b) => a.d - b.d)[0];
670
+ errors.push({
671
+ message: `No table called "${tableName}".${near && near.d <= 3 ? ` Did you mean "${near.id}"?` : ''}`,
672
+ at: 0,
673
+ });
674
+ }
675
+ }
676
+ }
677
+ // Anything after the table name and before the first clause word is already
678
+ // a filter. `where` stays as optional sugar, but making it mandatory would
679
+ // be ceremony with nothing to disambiguate.
680
+ const implied = head.slice(tableName.length).trim();
681
+ const clauses = {};
682
+ const clauseAt = {};
683
+ if (implied) {
684
+ clauses.filter = implied;
685
+ clauseAt.filter = tableName.length + 1;
686
+ }
687
+ marks.forEach((mark, i) => {
688
+ const bodyEnd = i + 1 < marks.length ? marks[i + 1].start : text.length;
689
+ const body = text.slice(mark.end, bodyEnd).trim();
690
+ if (clauses[mark.keyword] === undefined) {
691
+ clauses[mark.keyword] = body;
692
+ clauseAt[mark.keyword] = mark.end;
693
+ return;
694
+ }
695
+ // Repeated conditions concatenate: `where a where b` is `a and b`, which
696
+ // is also how the implied filter and an explicit `where` are joined.
697
+ if (mark.keyword === 'filter') {
698
+ clauses.filter = `${clauses.filter} and ${body}`;
699
+ return;
700
+ }
701
+ /* Everything else refuses. Located at the repeat rather than the first
702
+ one, because that is the word that made the query ambiguous — and the
703
+ first clause is left standing, so the partial result the editor shows
704
+ is still something that was typed. */
705
+ errors.push({ message: repeatedClause(mark.keyword), at: mark.start });
706
+ });
707
+ const filter = { groups: [] };
708
+ if (clauses.filter) {
709
+ const tree = parseExpression(clauses.filter, clauseAt.filter ?? 0);
710
+ const groups = toGroups(tree);
711
+ if (groups.length > MAX_GROUPS) {
712
+ errors.push({ message: `That expands to more than ${MAX_GROUPS} OR branches. Simplify it or filter in two steps.` });
713
+ }
714
+ else {
715
+ for (const group of groups) {
716
+ const conditions = [];
717
+ for (const node of group) {
718
+ if (node.kind !== 'cond')
719
+ continue;
720
+ const { conditions: got, error } = compileCondition(node.text, table, now);
721
+ if (error)
722
+ errors.push({ message: error, at: node.at });
723
+ else if (got)
724
+ conditions.push(...got);
725
+ }
726
+ if (conditions.length)
727
+ filter.groups.push(conditions);
728
+ }
729
+ }
730
+ }
731
+ const orderBy = [];
732
+ if (clauses.sort) {
733
+ for (const part of clauses.sort.split(/\s*,\s*|\s+and\s+/i)) {
734
+ const [field, dir] = part.trim().split(/\s+/);
735
+ if (!field)
736
+ continue;
737
+ const { column, error } = resolveColumn(table, field);
738
+ if (error)
739
+ errors.push({ message: error, at: clauseAt.sort });
740
+ else
741
+ orderBy.push({ column: column?.name ?? field, direction: (dir ?? '').toLowerCase() === 'desc' ? 'desc' : 'asc' });
742
+ }
743
+ }
744
+ const columns = [];
745
+ for (const raw of (clauses.show ?? '').split(',')) {
746
+ const field = raw.trim();
747
+ if (!field)
748
+ continue;
749
+ const { column, error } = resolveColumn(table, field);
750
+ if (error)
751
+ errors.push({ message: error, at: clauseAt.show });
752
+ else
753
+ columns.push(column?.name ?? field);
754
+ }
755
+ let limit;
756
+ if (clauses.limit) {
757
+ const n = Number(clauses.limit.split(/\s+/)[0]);
758
+ if (Number.isFinite(n) && n > 0)
759
+ limit = Math.min(Math.floor(n), MAX_LIMIT);
760
+ else
761
+ errors.push({ message: `limit expects a number, got "${clauses.limit}"`, at: clauseAt.limit });
762
+ }
763
+ /* A query is returned even when there are errors, so the editor can show a
764
+ partial result while someone is still typing. The caller decides whether
765
+ errors are fatal; only a missing table makes the query unusable. */
766
+ if (!tableName)
767
+ return { errors };
768
+ return {
769
+ query: { table: table?.id ?? tableName, filter, columns, orderBy, limit },
770
+ errors,
771
+ };
772
+ }
773
+ /**
774
+ * Render a parsed query back as the SQL it will become.
775
+ *
776
+ * This is for the explain panel, not for execution — the adapter builds the
777
+ * real statement from the same `Filter` with bound parameters. Showing values
778
+ * inline here is safe because the string never reaches a database, and it is
779
+ * the whole point: seeing `country = $1` next to `country = 'Australia'` is
780
+ * how someone learns what the tool is doing on their behalf.
781
+ */
782
+ export function explain(query) {
783
+ const cols = query.columns.length ? query.columns.join(', ') : '*';
784
+ const lines = [`SELECT ${cols}`, ` FROM ${query.table}`];
785
+ if (query.filter.groups.length) {
786
+ const groups = query.filter.groups.map((g) => g
787
+ .map((c) => {
788
+ if (c.op === 'isNull')
789
+ return `${c.column} IS NULL`;
790
+ if (c.op === 'isNotNull')
791
+ return `${c.column} IS NOT NULL`;
792
+ if (c.op === 'in') {
793
+ const list = (Array.isArray(c.value) ? c.value : [c.value]).map(literal).join(', ');
794
+ return `${c.column} IN (${list})`;
795
+ }
796
+ if (c.op === 'like') {
797
+ /* Shown with its ESCAPE clause always, unlike the searches above.
798
+ There the clause is plumbing behind a plain search; here the
799
+ escape character is part of the operator someone chose to write
800
+ by hand, and the preview is where they find out it exists. */
801
+ return `${c.column} LIKE ${literal(String(c.value ?? ''))} ESCAPE '${LIKE_ESCAPE}'`;
802
+ }
803
+ if (c.op === 'contains' || c.op === 'startsWith' || c.op === 'endsWith') {
804
+ const pattern = likePattern(c.value, c.op);
805
+ /* The ESCAPE clause is shown only when the value needed escaping.
806
+ It is always sent — but printing it on every plain search would
807
+ put dialect plumbing in front of the thing this preview exists
808
+ to teach, and printing the escaped pattern *without* it would
809
+ be a statement that means something else. */
810
+ const escape = pattern.includes(LIKE_ESCAPE) ? ` ESCAPE '${LIKE_ESCAPE}'` : '';
811
+ return `${c.column} LIKE ${literal(pattern)}${escape}`;
812
+ }
813
+ return `${c.column} ${c.op} ${literal(c.value)}`;
814
+ })
815
+ .join(' AND '));
816
+ lines.push(` WHERE ${groups.length === 1 ? groups[0] : groups.map((g) => `(${g})`).join('\n OR ')}`);
817
+ }
818
+ if (query.orderBy.length) {
819
+ lines.push(` ORDER BY ${query.orderBy.map((o) => `${o.column} ${o.direction.toUpperCase()}`).join(', ')}`);
820
+ }
821
+ if (query.limit !== undefined)
822
+ lines.push(` LIMIT ${query.limit}`);
823
+ return lines.join('\n');
824
+ }
825
+ function literal(value) {
826
+ if (value === null || value === undefined)
827
+ return 'NULL';
828
+ if (typeof value === 'number' || typeof value === 'boolean')
829
+ return String(value);
830
+ return `'${String(value).replace(/'/g, "''")}'`;
831
+ }