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,372 @@
|
|
|
1
|
+
import { LIKE_ESCAPE, likePattern } from '../shared/like.js';
|
|
2
|
+
/**
|
|
3
|
+
* The operators, as a value rather than only as a type.
|
|
4
|
+
*
|
|
5
|
+
* `Op` is checked by the compiler, and the compiler is not present when a
|
|
6
|
+
* request arrives: `/api/query`, `/api/count`, `/api/view/run` and
|
|
7
|
+
* `/api/references` all cast a request body straight to `Filter`, so the type
|
|
8
|
+
* was a comment as far as those paths were concerned. This is the list
|
|
9
|
+
* `buildWhere` actually checks against.
|
|
10
|
+
*/
|
|
11
|
+
export const OPS = new Set([
|
|
12
|
+
'=', '!=', '>', '>=', '<', '<=',
|
|
13
|
+
'contains', 'startsWith', 'endsWith', 'like', 'in', 'isNull', 'isNotNull',
|
|
14
|
+
]);
|
|
15
|
+
/* ---------- the limit policy ----------
|
|
16
|
+
|
|
17
|
+
One place, because a ceiling re-derived in four files is a ceiling that
|
|
18
|
+
disagrees with itself. Every read goes through `clampLimit`, so there is no
|
|
19
|
+
path to an unbounded query: a caller that omits a limit gets the default, a
|
|
20
|
+
caller that asks for too many gets the ceiling, and a caller that sends
|
|
21
|
+
nonsense gets the default rather than `LIMIT NaN`. */
|
|
22
|
+
/** What a caller gets when it does not say. Small on purpose: the first page
|
|
23
|
+
of a browse should be instant, and paging is one click. */
|
|
24
|
+
export const DEFAULT_LIMIT = 20;
|
|
25
|
+
/** The hard ceiling. Nothing may exceed it, including an explicit request. */
|
|
26
|
+
export const MAX_LIMIT = 100_000;
|
|
27
|
+
/**
|
|
28
|
+
* Beyond this, a result is being fetched to be processed rather than read.
|
|
29
|
+
* Callers can use it to decide whether to warn; it is not enforced.
|
|
30
|
+
*/
|
|
31
|
+
export const LARGE_RESULT = 2_000;
|
|
32
|
+
/**
|
|
33
|
+
* Above this many rows, an exact count is not worth what it costs.
|
|
34
|
+
*
|
|
35
|
+
* `COUNT(*)` is a scan. On a table of a few thousand rows that is free and the
|
|
36
|
+
* exact number is strictly better; on a table of fifty million it is seconds of
|
|
37
|
+
* work to compute a number that is stale before it is rendered, and it runs on
|
|
38
|
+
* *every* query — the header stat is not something anyone asked for.
|
|
39
|
+
*
|
|
40
|
+
* The threshold is deliberately well above anything a person would notice the
|
|
41
|
+
* difference on. Below it you get the true number; above it you get the
|
|
42
|
+
* catalog's estimate and are told that is what it is.
|
|
43
|
+
*/
|
|
44
|
+
export const ESTIMATE_ABOVE = 50_000;
|
|
45
|
+
/**
|
|
46
|
+
* Only a number or a numeric string counts as a request. Everything else —
|
|
47
|
+
* undefined, null, '', [], {}, 'abc' — means "not specified" and gets the
|
|
48
|
+
* default.
|
|
49
|
+
*
|
|
50
|
+
* The `null` case is why this cannot just be `Number(value)`: `Number(null)`
|
|
51
|
+
* is 0, not NaN, so a null limit passed a finite check and clamped to 1. A
|
|
52
|
+
* caller that omitted a limit would have got a single row and no error.
|
|
53
|
+
*/
|
|
54
|
+
function asNumber(value) {
|
|
55
|
+
if (typeof value === 'number')
|
|
56
|
+
return Number.isFinite(value) ? value : undefined;
|
|
57
|
+
if (typeof value === 'string' && value.trim() !== '') {
|
|
58
|
+
const n = Number(value);
|
|
59
|
+
return Number.isFinite(n) ? n : undefined;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* A request an adapter will not carry out.
|
|
65
|
+
*
|
|
66
|
+
* Distinct from a database failure, and the distinction is the whole point:
|
|
67
|
+
* "that is not the whole primary key" and "the database is down" are both
|
|
68
|
+
* exceptions, and a server that cannot tell them apart answers both with a
|
|
69
|
+
* 500. The first is something the caller can fix by asking differently.
|
|
70
|
+
*
|
|
71
|
+
* Thrown only by guards that run *before* a statement is issued. Once the
|
|
72
|
+
* database has been asked, whatever comes back is the database's answer.
|
|
73
|
+
*/
|
|
74
|
+
export class Refusal extends Error {
|
|
75
|
+
constructor(message) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'Refusal';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
export function clampLimit(value, fallback = DEFAULT_LIMIT) {
|
|
81
|
+
const n = asNumber(value);
|
|
82
|
+
if (n === undefined)
|
|
83
|
+
return fallback;
|
|
84
|
+
return Math.min(Math.max(Math.floor(n), 1), MAX_LIMIT);
|
|
85
|
+
}
|
|
86
|
+
/** Offsets have no ceiling but must be a real, non-negative integer. */
|
|
87
|
+
export function clampOffset(value) {
|
|
88
|
+
const n = asNumber(value);
|
|
89
|
+
if (n === undefined || n < 0)
|
|
90
|
+
return 0;
|
|
91
|
+
return Math.floor(n);
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Refuse anything that is more than one statement.
|
|
95
|
+
*
|
|
96
|
+
* Not a SQL parser and not trying to be one — it answers exactly one question,
|
|
97
|
+
* "is there a second statement here", and it answers it by scanning for a
|
|
98
|
+
* semicolon that has something after it. Everything that can contain a
|
|
99
|
+
* semicolon harmlessly is skipped: single- and double-quoted strings,
|
|
100
|
+
* backquoted identifiers, line comments, block comments, and Postgres's
|
|
101
|
+
* dollar-quoted bodies, which is how a function definition carries semicolons
|
|
102
|
+
* without being several statements.
|
|
103
|
+
*
|
|
104
|
+
* Stacking is refused rather than truncated. Running the first half of what
|
|
105
|
+
* someone typed and discarding the rest is worse than refusing both: they
|
|
106
|
+
* asked for two things and would be told about neither.
|
|
107
|
+
*
|
|
108
|
+
* Returns null when the text is a single statement, or the reason it is not.
|
|
109
|
+
*/
|
|
110
|
+
export function refuseStacked(text) {
|
|
111
|
+
let i = 0;
|
|
112
|
+
const n = text.length;
|
|
113
|
+
while (i < n) {
|
|
114
|
+
const ch = text[i];
|
|
115
|
+
// Line comment, to end of line.
|
|
116
|
+
if (ch === '-' && text[i + 1] === '-') {
|
|
117
|
+
const nl = text.indexOf('\n', i);
|
|
118
|
+
i = nl < 0 ? n : nl + 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Block comment. Not nested: no dialect here nests them.
|
|
122
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
123
|
+
const end = text.indexOf('*/', i + 2);
|
|
124
|
+
i = end < 0 ? n : end + 2;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
// Dollar-quoted body: $$…$$ or $tag$…$tag$. Postgres only, harmless
|
|
128
|
+
// elsewhere because the opening tag simply will not match.
|
|
129
|
+
if (ch === '$') {
|
|
130
|
+
const tag = /^\$[A-Za-z_]\w*\$|^\$\$/.exec(text.slice(i))?.[0];
|
|
131
|
+
if (tag) {
|
|
132
|
+
const end = text.indexOf(tag, i + tag.length);
|
|
133
|
+
i = end < 0 ? n : end + tag.length;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
138
|
+
i += 1;
|
|
139
|
+
while (i < n) {
|
|
140
|
+
if (text[i] === '\\') {
|
|
141
|
+
i += 2;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// A doubled quote is an escaped quote, not the end of the string.
|
|
145
|
+
if (text[i] === ch && text[i + 1] === ch) {
|
|
146
|
+
i += 2;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (text[i] === ch) {
|
|
150
|
+
i += 1;
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
i += 1;
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (ch === ';') {
|
|
158
|
+
// A trailing semicolon is punctuation, not a second statement.
|
|
159
|
+
if (text.slice(i + 1).trim() === '')
|
|
160
|
+
return null;
|
|
161
|
+
return 'One statement at a time. Remove the semicolon and everything after it, or run them separately.';
|
|
162
|
+
}
|
|
163
|
+
i += 1;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Output column names, with collisions given a suffix.
|
|
169
|
+
*
|
|
170
|
+
* `SELECT id, id FROM t` names both columns `id`. Left alone, the grid draws
|
|
171
|
+
* one header twice and nothing says which value belongs to which. The suffix
|
|
172
|
+
* is the same answer `compileView` gives an explicit collision, for the same
|
|
173
|
+
* reason: one column quietly standing in for two is worse than an ugly name.
|
|
174
|
+
*/
|
|
175
|
+
export function dedupe(names) {
|
|
176
|
+
const seen = new Map();
|
|
177
|
+
return names.map((name) => {
|
|
178
|
+
const count = seen.get(name) ?? 0;
|
|
179
|
+
seen.set(name, count + 1);
|
|
180
|
+
return count ? `${name}_${count + 1}` : name;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
/** A statement with its trailing semicolon and surrounding space removed. */
|
|
184
|
+
export function trimStatement(text) {
|
|
185
|
+
/* The whitespace *before* the semicolon goes too. `select 1 ;` left a
|
|
186
|
+
trailing space, which is invisible in the editor and shows up in the
|
|
187
|
+
reported statement as a difference between what ran and what was typed. */
|
|
188
|
+
return text.trim().replace(/\s*;+\s*$/, '');
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Wrap a statement so it cannot return more rows than asked for.
|
|
192
|
+
*
|
|
193
|
+
* `clampLimit` is the invariant everywhere else in this file, and it has no
|
|
194
|
+
* application point in a statement this process did not build — there is no
|
|
195
|
+
* request object to clamp. Wrapping is the equivalent: the database is asked
|
|
196
|
+
* for a bounded result rather than trusted to have been asked politely.
|
|
197
|
+
*
|
|
198
|
+
* One more row than the caller wanted, so "there is more than this" is known
|
|
199
|
+
* without a second query — the same trick `runView` uses.
|
|
200
|
+
*
|
|
201
|
+
* The limit is interpolated rather than bound, which is the one deliberate
|
|
202
|
+
* exception to the rule that nothing is interpolated. It is an integer this
|
|
203
|
+
* process computed from `clampLimit`, never a value that came from outside,
|
|
204
|
+
* and it is asserted to be one before it is used. Binding it would need a
|
|
205
|
+
* dialect-specific placeholder here *and* MySQL's decimal-string workaround
|
|
206
|
+
* for row counts, which is a lot of machinery to parameterise a number we
|
|
207
|
+
* chose ourselves.
|
|
208
|
+
*
|
|
209
|
+
* Not every statement can be wrapped — `EXPLAIN`, `PRAGMA`, a `WITH` that ends
|
|
210
|
+
* in `INSERT … RETURNING` — so the caller is told whether it applied and can
|
|
211
|
+
* fall back to running the statement bare and saying so.
|
|
212
|
+
*/
|
|
213
|
+
export function boundStatement(text, limit) {
|
|
214
|
+
if (!Number.isSafeInteger(limit) || limit < 1) {
|
|
215
|
+
throw new Error(`A row limit must be a positive integer, not ${String(limit)}.`);
|
|
216
|
+
}
|
|
217
|
+
return `SELECT * FROM (${trimStatement(text)}) AS tablewalk_bounded LIMIT ${limit + 1}`;
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Whether a statement is worth trying to wrap.
|
|
221
|
+
*
|
|
222
|
+
* A conservative first token test, and deliberately not a parse. Getting this
|
|
223
|
+
* wrong in the cautious direction costs a bounded result; getting it wrong in
|
|
224
|
+
* the other direction costs a confusing syntax error from the database about a
|
|
225
|
+
* statement the user did not write. `WITH` is included because a CTE ending in
|
|
226
|
+
* a SELECT is the common case, and the wrap failing on one that does not is
|
|
227
|
+
* handled by the fallback rather than by being clever here.
|
|
228
|
+
*/
|
|
229
|
+
export function looksWrappable(text) {
|
|
230
|
+
const first = trimStatement(text).replace(/^\s*(?:--[^\n]*\n|\/\*[\s\S]*?\*\/|\s)+/g, '');
|
|
231
|
+
return /^(select|with|table|values)\b/i.test(first);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* A queue of one.
|
|
235
|
+
*
|
|
236
|
+
* Every adapter here writes through a single connection, and a transaction is
|
|
237
|
+
* a span of statements on it that nothing else may enter. Node is
|
|
238
|
+
* single-threaded but `await` is not: two requests arriving together interleave
|
|
239
|
+
* at every await point, so a plain `/api/update` can land in the middle of
|
|
240
|
+
* somebody's open transaction and be rolled back with it — a write that
|
|
241
|
+
* succeeded, reported success, and then quietly did not happen.
|
|
242
|
+
*
|
|
243
|
+
* So writes queue. The cost is that two concurrent edits are applied one after
|
|
244
|
+
* the other rather than at once, which is what a single connection was always
|
|
245
|
+
* going to do anyway.
|
|
246
|
+
*/
|
|
247
|
+
export class Serial {
|
|
248
|
+
tail = Promise.resolve();
|
|
249
|
+
run(work) {
|
|
250
|
+
/* The chain must not break on a failure: `catch` here keeps the *queue*
|
|
251
|
+
going while the caller still gets the rejection from `next`. */
|
|
252
|
+
const next = this.tail.then(work, work);
|
|
253
|
+
this.tail = next.catch(() => { });
|
|
254
|
+
return next;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
export const ANSI_STYLE = {
|
|
258
|
+
// Doubling an embedded quote is the standard escape and is what makes a
|
|
259
|
+
// table called `weird"name` safe rather than a syntax error.
|
|
260
|
+
quote: (id) => `"${id.replace(/"/g, '""')}"`,
|
|
261
|
+
placeholder: () => '?',
|
|
262
|
+
likeOp: 'LIKE',
|
|
263
|
+
/* `strftime` has no quarter, so it is arithmetic on the month: 1–3 → 1.
|
|
264
|
+
Written with the engine's own integer division rather than a CASE, which
|
|
265
|
+
would be four branches to say one sum. */
|
|
266
|
+
dateBucket: (e, unit) => (unit === 'quarter'
|
|
267
|
+
? `strftime('%Y', ${e}) || '-Q' || CAST((CAST(strftime('%m', ${e}) AS INTEGER) + 2) / 3 AS TEXT)`
|
|
268
|
+
: `strftime('${{ day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' }[unit]}', ${e})`),
|
|
269
|
+
};
|
|
270
|
+
export const POSTGRES_STYLE = {
|
|
271
|
+
quote: ANSI_STYLE.quote,
|
|
272
|
+
placeholder: (i) => `$${i}`,
|
|
273
|
+
likeOp: 'ILIKE',
|
|
274
|
+
/* `to_char` rather than `date_trunc`, for the text the interface says it
|
|
275
|
+
returns. `Q` is a to_char field, so the quarter needs no arithmetic. */
|
|
276
|
+
dateBucket: (e, unit) => `to_char(${e}, '${{ day: 'YYYY-MM-DD', month: 'YYYY-MM', quarter: 'YYYY-"Q"Q', year: 'YYYY' }[unit]}')`,
|
|
277
|
+
};
|
|
278
|
+
export const MYSQL_STYLE = {
|
|
279
|
+
quote: (id) => `\`${id.replace(/`/g, '``')}\``,
|
|
280
|
+
placeholder: () => '?',
|
|
281
|
+
/* A column declared with a `_bin` or `_cs` collation compares case
|
|
282
|
+
sensitively even here. Forcing a collation in the predicate would fix
|
|
283
|
+
that and break every latin1 column, so this follows the column. */
|
|
284
|
+
likeOp: 'LIKE',
|
|
285
|
+
/* `DATE_FORMAT` has no quarter specifier either, and MySQL's `QUARTER()`
|
|
286
|
+
returns a number — concatenated rather than formatted, to land on the
|
|
287
|
+
same `2026-Q3` the other two produce. */
|
|
288
|
+
dateBucket: (e, unit) => (unit === 'quarter'
|
|
289
|
+
? `CONCAT(DATE_FORMAT(${e}, '%Y'), '-Q', QUARTER(${e}))`
|
|
290
|
+
: `DATE_FORMAT(${e}, '${{ day: '%Y-%m-%d', month: '%Y-%m', year: '%Y' }[unit]}')`),
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* Compile a filter to a WHERE clause plus bound parameters.
|
|
294
|
+
*
|
|
295
|
+
* `startIndex` exists for Postgres, where placeholders are numbered across the
|
|
296
|
+
* whole statement rather than positional.
|
|
297
|
+
*
|
|
298
|
+
* `column` exists for views. A multi-table statement needs
|
|
299
|
+
* `"customer_1"."name"` where a single-table one needs `"name"`, and the
|
|
300
|
+
* difference is *only* how an identifier is written — the operators, the
|
|
301
|
+
* empty-IN case, the LIKE wrapping and the invariant that every value is a
|
|
302
|
+
* bound parameter are the same either way. Passing the resolver in keeps one
|
|
303
|
+
* predicate builder rather than two that can drift; a second copy of this
|
|
304
|
+
* function is a second chance to interpolate a value by accident.
|
|
305
|
+
*/
|
|
306
|
+
export function buildWhere(filter, style, startIndex = 1, column = (name) => style.quote(name)) {
|
|
307
|
+
if (!filter?.groups.length)
|
|
308
|
+
return { text: '', params: [] };
|
|
309
|
+
const params = [];
|
|
310
|
+
let index = startIndex;
|
|
311
|
+
const next = () => style.placeholder(index++);
|
|
312
|
+
const groupSql = filter.groups
|
|
313
|
+
.filter((g) => g.length)
|
|
314
|
+
.map((group) => group
|
|
315
|
+
.map((c) => {
|
|
316
|
+
const col = column(c.column);
|
|
317
|
+
switch (c.op) {
|
|
318
|
+
case 'isNull':
|
|
319
|
+
return `${col} IS NULL`;
|
|
320
|
+
case 'isNotNull':
|
|
321
|
+
return `${col} IS NOT NULL`;
|
|
322
|
+
case 'in': {
|
|
323
|
+
const list = Array.isArray(c.value) ? c.value : [c.value];
|
|
324
|
+
if (!list.length)
|
|
325
|
+
return '1 = 0'; // an empty IN matches nothing
|
|
326
|
+
const holes = list.map((v) => {
|
|
327
|
+
params.push(v ?? null);
|
|
328
|
+
return next();
|
|
329
|
+
});
|
|
330
|
+
return `${col} IN (${holes.join(', ')})`;
|
|
331
|
+
}
|
|
332
|
+
/* All three go through one pattern builder, which escapes the
|
|
333
|
+
wildcards in the value: `contains 50%` is a search for a
|
|
334
|
+
percent sign, not a search for everything. */
|
|
335
|
+
case 'contains':
|
|
336
|
+
case 'startsWith':
|
|
337
|
+
case 'endsWith':
|
|
338
|
+
params.push(likePattern(c.value, c.op));
|
|
339
|
+
return `${col} ${style.likeOp} ${next()} ESCAPE '${LIKE_ESCAPE}'`;
|
|
340
|
+
/* Bound, not escaped: the wildcards belong to whoever typed them.
|
|
341
|
+
Still a parameter — the value never becomes SQL text — so the
|
|
342
|
+
safety argument is unchanged, and still `ESCAPE '#'`, so the
|
|
343
|
+
way to ask for a literal `%` is the same one `contains` uses
|
|
344
|
+
internally. */
|
|
345
|
+
case 'like':
|
|
346
|
+
params.push(String(c.value ?? ''));
|
|
347
|
+
return `${col} ${style.likeOp} ${next()} ESCAPE '${LIKE_ESCAPE}'`;
|
|
348
|
+
default: {
|
|
349
|
+
/* The one field in a structured request that becomes SQL text
|
|
350
|
+
rather than a bound parameter — and until now it was checked
|
|
351
|
+
only by the config-file parser, so every endpoint that casts
|
|
352
|
+
a body to `Filter` accepted whatever it was sent. The column
|
|
353
|
+
is quoted and the value is bound; this was the gap between
|
|
354
|
+
those two, and the whole safety argument is that a structured
|
|
355
|
+
request cannot express injection.
|
|
356
|
+
|
|
357
|
+
Checked here rather than at each caller: one choke point
|
|
358
|
+
covers the four endpoints and anything added later. */
|
|
359
|
+
if (!OPS.has(c.op)) {
|
|
360
|
+
throw new Refusal(`"${String(c.op)}" is not an operator this tool knows.`);
|
|
361
|
+
}
|
|
362
|
+
params.push(c.value ?? null);
|
|
363
|
+
return `${col} ${c.op} ${next()}`;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
.join(' AND '));
|
|
368
|
+
if (!groupSql.length)
|
|
369
|
+
return { text: '', params: [] };
|
|
370
|
+
const text = groupSql.length === 1 ? groupSql[0] : groupSql.map((g) => `(${g})`).join(' OR ');
|
|
371
|
+
return { text: ` WHERE ${text}`, params };
|
|
372
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { SqliteAdapter } from './sqlite.js';
|
|
2
|
+
import { PostgresAdapter } from './postgres.js';
|
|
3
|
+
import { MysqlAdapter } from './mysql.js';
|
|
4
|
+
export function connect(target, options = {}) {
|
|
5
|
+
if (/^postgres(ql)?:\/\//i.test(target)) {
|
|
6
|
+
return new PostgresAdapter(target, options.label, options.schemas ?? []);
|
|
7
|
+
}
|
|
8
|
+
/* `mariadb://` too: MariaDB speaks the MySQL protocol and its catalog is
|
|
9
|
+
the same `information_schema`, so refusing the scheme would be turning
|
|
10
|
+
someone away from a database this adapter can already read. */
|
|
11
|
+
if (/^(mysql|mariadb):\/\//i.test(target)) {
|
|
12
|
+
return new MysqlAdapter(target, options.label, options.schemas ?? []);
|
|
13
|
+
}
|
|
14
|
+
if (/^sqlite:/i.test(target)) {
|
|
15
|
+
const file = target.replace(/^sqlite:(\/\/)?/i, '');
|
|
16
|
+
return new SqliteAdapter(file, options.label ?? file);
|
|
17
|
+
}
|
|
18
|
+
// A bare path is a SQLite file. This is the common case for the demo and
|
|
19
|
+
// for anyone poking at an app's local database, so it needs no scheme.
|
|
20
|
+
if (/\.(db|sqlite|sqlite3)$/i.test(target) || !target.includes('://')) {
|
|
21
|
+
return new SqliteAdapter(target, options.label ?? target);
|
|
22
|
+
}
|
|
23
|
+
/* The scheme, not the target.
|
|
24
|
+
|
|
25
|
+
`target` here is the *resolved* connection string — the password has
|
|
26
|
+
already been expanded into it — and this message was stored on the
|
|
27
|
+
connection entry and served to the browser from then on. The scheme is
|
|
28
|
+
the whole of the useful content anyway: what is wrong is the protocol,
|
|
29
|
+
and repeating the credentials does not help anyone fix it. */
|
|
30
|
+
const scheme = target.match(/^([a-z][\w+.-]*):\/\//i)?.[1];
|
|
31
|
+
throw new Error(`Don't know how to open ${scheme ? `a "${scheme}://" connection` : 'that connection'}. `
|
|
32
|
+
+ 'Supported: a SQLite file path, sqlite:<path>, postgres://… or mysql://…');
|
|
33
|
+
}
|