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.
- package/LICENSE +21 -0
- package/README.md +553 -0
- package/dist/adapters/adapter.js +372 -0
- package/dist/adapters/connect.js +33 -0
- package/dist/adapters/mysql.js +951 -0
- package/dist/adapters/postgres.js +1000 -0
- package/dist/adapters/sqlite.js +781 -0
- package/dist/client/agent.js +262 -0
- package/dist/client/app.js +973 -0
- package/dist/client/arrange.js +254 -0
- package/dist/client/ask.js +133 -0
- package/dist/client/breakdown.js +317 -0
- package/dist/client/clauses.js +390 -0
- package/dist/client/columns.js +98 -0
- package/dist/client/complete.js +437 -0
- package/dist/client/compose.js +166 -0
- package/dist/client/composer.css +495 -0
- package/dist/client/composer.js +1972 -0
- package/dist/client/connections.js +234 -0
- package/dist/client/connmanager.js +962 -0
- package/dist/client/connurl.js +188 -0
- package/dist/client/core.js +893 -0
- package/dist/client/deeplink.js +270 -0
- package/dist/client/delete.js +144 -0
- package/dist/client/diagram.js +885 -0
- package/dist/client/dropdown.js +279 -0
- package/dist/client/export.js +456 -0
- package/dist/client/features.css +524 -0
- package/dist/client/findvalue.js +169 -0
- package/dist/client/grid.js +205 -0
- package/dist/client/handoff.js +153 -0
- package/dist/client/help.css +145 -0
- package/dist/client/help.js +881 -0
- package/dist/client/history.js +222 -0
- package/dist/client/index.html +116 -0
- package/dist/client/insert.js +151 -0
- package/dist/client/menu.js +160 -0
- package/dist/client/nested.js +255 -0
- package/dist/client/page.css +713 -0
- package/dist/client/page.js +1345 -0
- package/dist/client/pagebuilder.js +1222 -0
- package/dist/client/pagemarks.js +95 -0
- package/dist/client/palette.js +374 -0
- package/dist/client/peek.js +254 -0
- package/dist/client/picker.js +139 -0
- package/dist/client/pins.js +140 -0
- package/dist/client/prompt.js +129 -0
- package/dist/client/record.js +707 -0
- package/dist/client/schemaexport.js +242 -0
- package/dist/client/schematext.js +125 -0
- package/dist/client/shape.js +178 -0
- package/dist/client/shapecheck.js +129 -0
- package/dist/client/skeleton.js +139 -0
- package/dist/client/sql.css +126 -0
- package/dist/client/sql.js +398 -0
- package/dist/client/sqlcomplete.js +163 -0
- package/dist/client/sqlsaved.js +107 -0
- package/dist/client/style.css +2711 -0
- package/dist/client/summary.js +259 -0
- package/dist/client/table.js +1035 -0
- package/dist/client/template.js +539 -0
- package/dist/client/theme.js +74 -0
- package/dist/client/tour.js +324 -0
- package/dist/client/undo.js +105 -0
- package/dist/client/url.js +166 -0
- package/dist/client/value.js +223 -0
- package/dist/client/views.js +215 -0
- package/dist/client/virtual.js +176 -0
- package/dist/client/welcome.js +170 -0
- package/dist/client/write.js +414 -0
- package/dist/server/changeimpact.js +195 -0
- package/dist/server/connections.js +615 -0
- package/dist/server/constraints.js +62 -0
- package/dist/server/credentials.js +230 -0
- package/dist/server/fixture.js +199 -0
- package/dist/server/graph.js +194 -0
- package/dist/server/impact.js +48 -0
- package/dist/server/index.js +2204 -0
- package/dist/server/journal.js +173 -0
- package/dist/server/layouts.js +128 -0
- package/dist/server/mcp.js +2840 -0
- package/dist/server/shapeonly.js +91 -0
- package/dist/shared/breakdown.js +231 -0
- package/dist/shared/breakdowntext.js +257 -0
- package/dist/shared/diff.js +130 -0
- package/dist/shared/like.js +29 -0
- package/dist/shared/lint.js +149 -0
- package/dist/shared/order.js +133 -0
- package/dist/shared/page.js +932 -0
- package/dist/shared/query.js +831 -0
- package/dist/shared/recordview.js +343 -0
- package/dist/shared/schema.js +377 -0
- package/dist/shared/sqlsaved.js +67 -0
- package/dist/shared/view.js +981 -0
- package/dist/shared/viewtext.js +273 -0
- package/dist/shared/vocabulary.js +164 -0
- 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
|
+
}
|