@ganju/utils 0.0.16 → 0.0.18
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/dist/constants.d.ts +9 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +26 -2
- package/dist/customCodeToken.d.ts +15 -0
- package/dist/customCodeToken.d.ts.map +1 -1
- package/dist/customCodeToken.js +49 -1
- package/dist/data.d.ts +6 -0
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +53 -1
- package/dist/dataApi.d.ts +200 -1
- package/dist/dataApi.d.ts.map +1 -1
- package/dist/dataApi.js +106 -1
- package/dist/dataCompiler.d.ts.map +1 -1
- package/dist/dataCompiler.js +5 -1
- package/dist/dataCsv.d.ts +20 -0
- package/dist/dataCsv.d.ts.map +1 -0
- package/dist/dataCsv.js +102 -0
- package/dist/dataDdl.d.ts.map +1 -1
- package/dist/dataDdl.js +3 -1
- package/dist/dataEngine.d.ts.map +1 -1
- package/dist/dataEngine.js +86 -4
- package/dist/dataImport.d.ts +2 -0
- package/dist/dataImport.d.ts.map +1 -1
- package/dist/dataImport.js +4 -1
- package/dist/dataQueries.d.ts +73 -0
- package/dist/dataQueries.d.ts.map +1 -0
- package/dist/dataQueries.js +513 -0
- package/dist/dataRpc.d.ts +44 -0
- package/dist/dataRpc.d.ts.map +1 -1
- package/dist/dataTools.d.ts +40 -0
- package/dist/dataTools.d.ts.map +1 -0
- package/dist/dataTools.js +280 -0
- package/dist/dataTriggers.d.ts +203 -0
- package/dist/dataTriggers.d.ts.map +1 -0
- package/dist/dataTriggers.js +760 -0
- package/dist/dataTypes.d.ts +3 -0
- package/dist/dataTypes.d.ts.map +1 -1
- package/dist/index.d.ts +201 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/localizeDataError.d.ts +18 -0
- package/dist/localizeDataError.d.ts.map +1 -0
- package/dist/localizeDataError.js +359 -0
- package/dist/schema.d.ts +201 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +149 -1
- package/dist/sdkConstants.d.ts +3 -0
- package/dist/sdkConstants.d.ts.map +1 -1
- package/dist/sdkConstants.js +4 -1
- package/package.json +1 -1
- package/src/constants.ts +30 -2
- package/src/customCodeToken.ts +87 -0
- package/src/data.ts +74 -0
- package/src/dataApi.ts +129 -2
- package/src/dataCompiler.ts +5 -1
- package/src/dataCsv.ts +99 -0
- package/src/dataDdl.ts +3 -1
- package/src/dataEngine.ts +94 -4
- package/src/dataImport.ts +3 -0
- package/src/dataQueries.ts +730 -0
- package/src/dataRpc.ts +26 -0
- package/src/dataTools.ts +338 -0
- package/src/dataTriggers.ts +1134 -0
- package/src/dataTypes.ts +9 -0
- package/src/index.ts +11 -1
- package/src/localizeDataError.ts +593 -0
- package/src/schema.ts +158 -1
- package/src/sdkConstants.ts +3 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
// Named queries: a SELECT the owner writes once, with named parameters, for
|
|
2
|
+
// reports the structured query can't express. Functions run them by name, and
|
|
3
|
+
// so may the assistant when the owner allows it; nobody sends SQL at run time.
|
|
4
|
+
//
|
|
5
|
+
// A Durable Object refuses `PRAGMA query_only`, so nothing in SQLite stops a
|
|
6
|
+
// statement from writing. The check when a query is saved is the whole guard,
|
|
7
|
+
// in two layers:
|
|
8
|
+
// - our tokenizer: one statement, starting with SELECT or WITH, naming none
|
|
9
|
+
// of SQLite's or our own tables, with named parameters only. A Durable
|
|
10
|
+
// Object's exec runs every statement in a string ("SELECT 1; DELETE FROM
|
|
11
|
+
// orders" deletes), so the single statement is checked here, on tokens.
|
|
12
|
+
// - SQLite's own parser: the statement is compiled with EXPLAIN, never run,
|
|
13
|
+
// and refused if its program opens anything for writing or reads a table
|
|
14
|
+
// that isn't one of the owner's.
|
|
15
|
+
import {
|
|
16
|
+
DATA_IDENTIFIER_PATTERN,
|
|
17
|
+
quoteIdentifier as q
|
|
18
|
+
} from './dataIdentifiers';
|
|
19
|
+
import { isScoped, toStored } from './dataCompiler';
|
|
20
|
+
import type { SqlValue } from './dataCompiler';
|
|
21
|
+
import {
|
|
22
|
+
DATA_LARGE_TABLE_ROWS,
|
|
23
|
+
DATA_MAX_DESCRIPTION,
|
|
24
|
+
DATA_MAX_PAGE,
|
|
25
|
+
DATA_MAX_RESULT_BYTES,
|
|
26
|
+
DATA_ACCESS_RANK,
|
|
27
|
+
DataError
|
|
28
|
+
} from './dataTypes';
|
|
29
|
+
import type {
|
|
30
|
+
DataCaller,
|
|
31
|
+
DataColumn,
|
|
32
|
+
DataSchema,
|
|
33
|
+
DataTable
|
|
34
|
+
} from './dataTypes';
|
|
35
|
+
import type { SqlRow, SqlRunner } from './dataEngine';
|
|
36
|
+
|
|
37
|
+
export const DATA_QUERY_PARAM_TYPES = [
|
|
38
|
+
'text',
|
|
39
|
+
'integer',
|
|
40
|
+
'decimal',
|
|
41
|
+
'boolean',
|
|
42
|
+
'date',
|
|
43
|
+
'datetime'
|
|
44
|
+
] as const;
|
|
45
|
+
export type DataQueryParamType = (typeof DATA_QUERY_PARAM_TYPES)[number];
|
|
46
|
+
|
|
47
|
+
export interface DataQueryParam {
|
|
48
|
+
name: string;
|
|
49
|
+
type: DataQueryParamType;
|
|
50
|
+
description?: string | null;
|
|
51
|
+
// Required unless false; a missing optional parameter binds NULL.
|
|
52
|
+
required?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// A named query as the owner writes it: the dashboard's form, the API body
|
|
56
|
+
// and `ganju.data.json` all use this shape.
|
|
57
|
+
export interface DataQueryInput {
|
|
58
|
+
name: string;
|
|
59
|
+
description?: string | null;
|
|
60
|
+
sql: string;
|
|
61
|
+
params?: DataQueryParam[];
|
|
62
|
+
// The assistant may run it from data-query. Off by default.
|
|
63
|
+
assistant?: boolean;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface DataQuery {
|
|
67
|
+
name: string;
|
|
68
|
+
description: string | null;
|
|
69
|
+
sql: string;
|
|
70
|
+
params: DataQueryParam[];
|
|
71
|
+
assistant: boolean;
|
|
72
|
+
// The owner's tables it reads, found when it was saved.
|
|
73
|
+
tables: string[];
|
|
74
|
+
// EXPLAIN QUERY PLAN when it was saved: "SCAN orders" means it reads the
|
|
75
|
+
// whole table.
|
|
76
|
+
plan: string[];
|
|
77
|
+
updatedAt: string;
|
|
78
|
+
updatedBy: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const DATA_MAX_QUERIES = 100;
|
|
82
|
+
export const DATA_MAX_QUERY_SQL = 10_000;
|
|
83
|
+
export const DATA_MAX_QUERY_PARAMS = 20;
|
|
84
|
+
export const DATA_DEFAULT_QUERY_ROWS = 100;
|
|
85
|
+
|
|
86
|
+
// Tokens
|
|
87
|
+
|
|
88
|
+
type TokenKind =
|
|
89
|
+
| 'word'
|
|
90
|
+
| 'quoted'
|
|
91
|
+
| 'string'
|
|
92
|
+
| 'number'
|
|
93
|
+
| 'param'
|
|
94
|
+
| 'positional'
|
|
95
|
+
| 'semicolon'
|
|
96
|
+
| 'other';
|
|
97
|
+
|
|
98
|
+
interface Token {
|
|
99
|
+
kind: TokenKind;
|
|
100
|
+
text: string;
|
|
101
|
+
start: number;
|
|
102
|
+
end: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const WORD_START = /[\p{L}_]/u;
|
|
106
|
+
const WORD_PART = /[\p{L}\p{N}_$]/u;
|
|
107
|
+
|
|
108
|
+
const unterminated = (what: string) =>
|
|
109
|
+
new DataError(`The SQL has an unterminated ${what}.`);
|
|
110
|
+
|
|
111
|
+
// SQLite's lexical rules, as far as telling statements, names, literals and
|
|
112
|
+
// parameters apart needs: comments and whitespace are dropped, everything
|
|
113
|
+
// else becomes a token that keeps its place in the text.
|
|
114
|
+
const tokenize = (sql: string): Token[] => {
|
|
115
|
+
const tokens: Token[] = [];
|
|
116
|
+
let i = 0;
|
|
117
|
+
const push = (kind: TokenKind, start: number, end: number) =>
|
|
118
|
+
tokens.push({ kind, text: sql.slice(start, end), start, end });
|
|
119
|
+
while (i < sql.length) {
|
|
120
|
+
const ch = sql[i];
|
|
121
|
+
if (/\s/.test(ch)) {
|
|
122
|
+
i++;
|
|
123
|
+
} else if (ch === '-' && sql[i + 1] === '-') {
|
|
124
|
+
const end = sql.indexOf('\n', i);
|
|
125
|
+
i = end === -1 ? sql.length : end + 1;
|
|
126
|
+
} else if (ch === '/' && sql[i + 1] === '*') {
|
|
127
|
+
const end = sql.indexOf('*/', i + 2);
|
|
128
|
+
if (end === -1) throw unterminated('comment');
|
|
129
|
+
i = end + 2;
|
|
130
|
+
} else if (ch === "'" || ch === '"' || ch === '`' || ch === '[') {
|
|
131
|
+
const close = ch === '[' ? ']' : ch;
|
|
132
|
+
let j = i + 1;
|
|
133
|
+
for (;;) {
|
|
134
|
+
if (j >= sql.length) {
|
|
135
|
+
throw unterminated(ch === "'" ? 'string' : 'quoted name');
|
|
136
|
+
}
|
|
137
|
+
if (sql[j] === close) {
|
|
138
|
+
// A doubled quote is the quote itself, except in [brackets].
|
|
139
|
+
if (close !== ']' && sql[j + 1] === close) {
|
|
140
|
+
j += 2;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
j++;
|
|
146
|
+
}
|
|
147
|
+
push(ch === "'" ? 'string' : 'quoted', i, j + 1);
|
|
148
|
+
i = j + 1;
|
|
149
|
+
} else if (ch === ':' || ch === '@' || ch === '$') {
|
|
150
|
+
let j = i + 1;
|
|
151
|
+
while (j < sql.length && WORD_PART.test(sql[j])) j++;
|
|
152
|
+
if (j === i + 1) {
|
|
153
|
+
push('other', i, j);
|
|
154
|
+
} else {
|
|
155
|
+
push('param', i, j);
|
|
156
|
+
}
|
|
157
|
+
i = j;
|
|
158
|
+
} else if (ch === '?') {
|
|
159
|
+
let j = i + 1;
|
|
160
|
+
while (j < sql.length && /\d/.test(sql[j])) j++;
|
|
161
|
+
push('positional', i, j);
|
|
162
|
+
i = j;
|
|
163
|
+
} else if (ch === ';') {
|
|
164
|
+
push('semicolon', i, i + 1);
|
|
165
|
+
i++;
|
|
166
|
+
} else if (/\d/.test(ch) || (ch === '.' && /\d/.test(sql[i + 1] ?? ''))) {
|
|
167
|
+
let j = i + 1;
|
|
168
|
+
while (j < sql.length && /[\w.]/.test(sql[j])) j++;
|
|
169
|
+
push('number', i, j);
|
|
170
|
+
i = j;
|
|
171
|
+
} else if (WORD_START.test(ch)) {
|
|
172
|
+
let j = i + 1;
|
|
173
|
+
while (j < sql.length && WORD_PART.test(sql[j])) j++;
|
|
174
|
+
push('word', i, j);
|
|
175
|
+
i = j;
|
|
176
|
+
} else {
|
|
177
|
+
push('other', i, i + 1);
|
|
178
|
+
i++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return tokens;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// A quoted name's text without its quotes, the way SQLite reads it.
|
|
185
|
+
const nameOf = (token: Token): string => {
|
|
186
|
+
if (token.kind !== 'quoted') return token.text.toLowerCase();
|
|
187
|
+
const inner = token.text.slice(1, -1);
|
|
188
|
+
const quote = token.text[0];
|
|
189
|
+
return (
|
|
190
|
+
quote === '[' ? inner : inner.split(quote + quote).join(quote)
|
|
191
|
+
).toLowerCase();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const FORBIDDEN_WORDS = new Set(['pragma', 'attach', 'detach', 'vacuum']);
|
|
195
|
+
|
|
196
|
+
const reservedName = (name: string) =>
|
|
197
|
+
name.startsWith('_ganju_') ||
|
|
198
|
+
name.startsWith('sqlite_') ||
|
|
199
|
+
name.startsWith('pragma_') ||
|
|
200
|
+
name.includes('__');
|
|
201
|
+
|
|
202
|
+
export interface CompiledQuerySql {
|
|
203
|
+
// The SQL as run: each `:name` replaced by `?N`, its position in `params`.
|
|
204
|
+
sql: string;
|
|
205
|
+
params: string[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Checks a named query's text and compiles its parameters to positions. Pure:
|
|
210
|
+
* what only SQLite can tell (that it reads, and what) is checked when it is
|
|
211
|
+
* saved, by checkQueryPlan.
|
|
212
|
+
*/
|
|
213
|
+
export const compileQuerySql = (sql: unknown): CompiledQuerySql => {
|
|
214
|
+
if (typeof sql !== 'string' || !sql.trim()) {
|
|
215
|
+
throw new DataError('"sql" is the SELECT to run.');
|
|
216
|
+
}
|
|
217
|
+
if (sql.length > DATA_MAX_QUERY_SQL) {
|
|
218
|
+
throw new DataError(
|
|
219
|
+
`A named query is up to ${DATA_MAX_QUERY_SQL.toLocaleString('en-US')} characters.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
const tokens = tokenize(sql);
|
|
223
|
+
// Trailing semicolons are harmless; any other ends the statement early, and
|
|
224
|
+
// what follows would run as a second one.
|
|
225
|
+
while (tokens.length && tokens[tokens.length - 1].kind === 'semicolon') {
|
|
226
|
+
tokens.pop();
|
|
227
|
+
}
|
|
228
|
+
if (tokens.some(t => t.kind === 'semicolon')) {
|
|
229
|
+
throw new DataError(
|
|
230
|
+
'A named query is one statement: remove the ";" inside it.'
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const head = tokens[0];
|
|
234
|
+
if (
|
|
235
|
+
!head ||
|
|
236
|
+
head.kind !== 'word' ||
|
|
237
|
+
!['select', 'with'].includes(head.text.toLowerCase())
|
|
238
|
+
) {
|
|
239
|
+
throw new DataError(
|
|
240
|
+
'A named query starts with SELECT or WITH; it only reads.'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
const params: string[] = [];
|
|
244
|
+
let out = '';
|
|
245
|
+
let last = 0;
|
|
246
|
+
for (const token of tokens) {
|
|
247
|
+
if (token.kind === 'positional') {
|
|
248
|
+
throw new DataError(
|
|
249
|
+
'Use named parameters (:from, :to) instead of "?", so each one can be described.'
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (
|
|
253
|
+
token.kind === 'word' &&
|
|
254
|
+
FORBIDDEN_WORDS.has(token.text.toLowerCase())
|
|
255
|
+
) {
|
|
256
|
+
throw new DataError(
|
|
257
|
+
`${token.text.toUpperCase()} isn't allowed in a named query.`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
if (
|
|
261
|
+
(token.kind === 'word' || token.kind === 'quoted') &&
|
|
262
|
+
reservedName(nameOf(token))
|
|
263
|
+
) {
|
|
264
|
+
throw new DataError(
|
|
265
|
+
`"${nameOf(token)}" isn't one of this database's tables or columns; named queries read the tables you defined.`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (token.kind === 'param') {
|
|
269
|
+
const name = token.text.slice(1);
|
|
270
|
+
if (!DATA_IDENTIFIER_PATTERN.test(name)) {
|
|
271
|
+
throw new DataError(
|
|
272
|
+
`Parameter ${token.text}: use lowercase letters, digits and _, starting with a letter.`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
let index = params.indexOf(name);
|
|
276
|
+
if (index === -1) index = params.push(name) - 1;
|
|
277
|
+
out += `${sql.slice(last, token.start)}?${index + 1}`;
|
|
278
|
+
last = token.end;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
out += sql.slice(last, tokens.length ? tokens[tokens.length - 1].end : 0);
|
|
282
|
+
if (params.length > DATA_MAX_QUERY_PARAMS) {
|
|
283
|
+
throw new DataError(
|
|
284
|
+
`A named query takes up to ${DATA_MAX_QUERY_PARAMS} parameters.`
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
return { sql: out, params };
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
// What SQLite's program does
|
|
291
|
+
|
|
292
|
+
// Opcodes that only appear in a program that changes the database file. A
|
|
293
|
+
// SELECT's own scratch tables (ORDER BY, DISTINCT, IN lists) use ephemeral
|
|
294
|
+
// cursors and never these.
|
|
295
|
+
const WRITE_OPCODES = new Set([
|
|
296
|
+
'OpenWrite',
|
|
297
|
+
'Clear',
|
|
298
|
+
'Destroy',
|
|
299
|
+
'CreateBtree',
|
|
300
|
+
'ParseSchema',
|
|
301
|
+
'DropTable',
|
|
302
|
+
'DropIndex',
|
|
303
|
+
'DropTrigger',
|
|
304
|
+
'VUpdate',
|
|
305
|
+
'VCreate',
|
|
306
|
+
'VDestroy',
|
|
307
|
+
'SqlExec',
|
|
308
|
+
'Vacuum',
|
|
309
|
+
'IncrVacuum',
|
|
310
|
+
'SetCookie',
|
|
311
|
+
'JournalMode'
|
|
312
|
+
]);
|
|
313
|
+
|
|
314
|
+
// Virtual tables are opened with VOpen, not OpenRead, and the program names
|
|
315
|
+
// no module. What it does give is the virtual table's address, and the
|
|
316
|
+
// built-in ones (json_each, dbstat) keep one per connection: so json_each's
|
|
317
|
+
// and json_tree's are read off a query of their own, and any other is
|
|
318
|
+
// refused. dbstat, for one, lists every table with its size.
|
|
319
|
+
const ALLOWED_VIRTUAL_TABLES = [
|
|
320
|
+
"SELECT 1 FROM json_each('[]')",
|
|
321
|
+
"SELECT 1 FROM json_tree('[]')"
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
const virtualTablesOf = (program: SqlRow[]): unknown[] =>
|
|
325
|
+
program.filter(step => String(step.opcode) === 'VOpen').map(step => step.p4);
|
|
326
|
+
|
|
327
|
+
// SQLite's own error, without the code and offset it appends.
|
|
328
|
+
const sqliteMessage = (error: unknown) =>
|
|
329
|
+
(error instanceof Error ? error.message : String(error))
|
|
330
|
+
.replace(/\s*at offset \d+/, '')
|
|
331
|
+
.replace(/:\s*SQLITE_\w+$/, '')
|
|
332
|
+
.trim();
|
|
333
|
+
|
|
334
|
+
const placeholders = (count: number): SqlValue[] => Array(count).fill(null);
|
|
335
|
+
|
|
336
|
+
export interface QueryPlanCheck {
|
|
337
|
+
tables: string[];
|
|
338
|
+
plan: string[];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Compiles the statement with EXPLAIN (nothing runs) and checks its program:
|
|
343
|
+
* it may not open anything for writing or start a write transaction, and every
|
|
344
|
+
* table it reads must be one of `schema`'s. Returns those tables and the plan.
|
|
345
|
+
*/
|
|
346
|
+
export const checkQueryPlan = (
|
|
347
|
+
runner: SqlRunner,
|
|
348
|
+
schema: DataSchema,
|
|
349
|
+
compiled: CompiledQuerySql
|
|
350
|
+
): QueryPlanCheck => {
|
|
351
|
+
let program: SqlRow[];
|
|
352
|
+
try {
|
|
353
|
+
program = runner.all(
|
|
354
|
+
`EXPLAIN ${compiled.sql}`,
|
|
355
|
+
placeholders(compiled.params.length)
|
|
356
|
+
);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw new DataError(`The SQL doesn't compile: ${sqliteMessage(error)}.`);
|
|
359
|
+
}
|
|
360
|
+
const roots = new Map<number, string>();
|
|
361
|
+
for (const row of runner.all(
|
|
362
|
+
"SELECT rootpage, tbl_name FROM sqlite_schema WHERE type IN ('table', 'index')"
|
|
363
|
+
)) {
|
|
364
|
+
roots.set(Number(row.rootpage), String(row.tbl_name));
|
|
365
|
+
}
|
|
366
|
+
const owned = new Set(schema.tables.map(t => t.name));
|
|
367
|
+
const opened = virtualTablesOf(program);
|
|
368
|
+
if (opened.length > 0) {
|
|
369
|
+
const allowed = new Set(
|
|
370
|
+
ALLOWED_VIRTUAL_TABLES.flatMap(sql =>
|
|
371
|
+
virtualTablesOf(runner.all(`EXPLAIN ${sql}`))
|
|
372
|
+
)
|
|
373
|
+
);
|
|
374
|
+
if (opened.some(vtab => typeof vtab !== 'string' || !allowed.has(vtab))) {
|
|
375
|
+
throw new DataError(
|
|
376
|
+
"A named query reads only this database's tables, with json_each and json_tree for JSON; not other virtual tables such as dbstat."
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const tables = new Set<string>();
|
|
381
|
+
for (const step of program) {
|
|
382
|
+
const opcode = String(step.opcode);
|
|
383
|
+
if (
|
|
384
|
+
WRITE_OPCODES.has(opcode) ||
|
|
385
|
+
(opcode === 'Transaction' && Number(step.p2) !== 0)
|
|
386
|
+
) {
|
|
387
|
+
throw new DataError(
|
|
388
|
+
'A named query only reads: this one would change the database.'
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
if (opcode === 'OpenRead') {
|
|
392
|
+
const table = roots.get(Number(step.p2));
|
|
393
|
+
if (!table || !owned.has(table)) {
|
|
394
|
+
throw new DataError(
|
|
395
|
+
`A named query reads only this database's tables${table ? `, not "${table}"` : ''}.`
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
tables.add(table);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const plan = runner
|
|
402
|
+
.all(
|
|
403
|
+
`EXPLAIN QUERY PLAN ${compiled.sql}`,
|
|
404
|
+
placeholders(compiled.params.length)
|
|
405
|
+
)
|
|
406
|
+
.map(row => String(row.detail));
|
|
407
|
+
return { tables: [...tables].sort(), plan };
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
// Storage
|
|
411
|
+
|
|
412
|
+
interface StoredQueryRow {
|
|
413
|
+
name: string;
|
|
414
|
+
description: string | null;
|
|
415
|
+
sql: string;
|
|
416
|
+
params: string;
|
|
417
|
+
assistant: number;
|
|
418
|
+
tables: string;
|
|
419
|
+
plan: string;
|
|
420
|
+
updated_at: string;
|
|
421
|
+
updated_by: string | null;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const parseList = <T>(raw: unknown): T[] => {
|
|
425
|
+
try {
|
|
426
|
+
const value = JSON.parse(String(raw));
|
|
427
|
+
return Array.isArray(value) ? (value as T[]) : [];
|
|
428
|
+
} catch {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const fromRow = (row: SqlRow): DataQuery => {
|
|
434
|
+
const stored = row as unknown as StoredQueryRow;
|
|
435
|
+
return {
|
|
436
|
+
name: stored.name,
|
|
437
|
+
description: stored.description,
|
|
438
|
+
sql: stored.sql,
|
|
439
|
+
params: parseList<DataQueryParam>(stored.params),
|
|
440
|
+
assistant: Boolean(stored.assistant),
|
|
441
|
+
tables: parseList<string>(stored.tables),
|
|
442
|
+
plan: parseList<string>(stored.plan),
|
|
443
|
+
updatedAt: stored.updated_at,
|
|
444
|
+
updatedBy: stored.updated_by
|
|
445
|
+
};
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
export const listQueries = (runner: SqlRunner): DataQuery[] =>
|
|
449
|
+
runner.all('SELECT * FROM _ganju_queries ORDER BY name').map(fromRow);
|
|
450
|
+
|
|
451
|
+
const findQuery = (runner: SqlRunner, name: unknown): DataQuery => {
|
|
452
|
+
const row =
|
|
453
|
+
typeof name === 'string'
|
|
454
|
+
? runner.all('SELECT * FROM _ganju_queries WHERE name = ?', [name])[0]
|
|
455
|
+
: undefined;
|
|
456
|
+
if (!row) {
|
|
457
|
+
const names = listQueries(runner).map(query => query.name);
|
|
458
|
+
throw new DataError(
|
|
459
|
+
`There is no named query "${String(name)}". Named queries: ${names.join(', ') || '(none)'}.`,
|
|
460
|
+
'not_found'
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
return fromRow(row);
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
|
467
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
468
|
+
|
|
469
|
+
const normaliseParams = (raw: unknown, used: string[]): DataQueryParam[] => {
|
|
470
|
+
const list = raw === undefined || raw === null ? [] : raw;
|
|
471
|
+
if (!Array.isArray(list)) {
|
|
472
|
+
throw new DataError('"params" is a list of { name, type, description }.');
|
|
473
|
+
}
|
|
474
|
+
const params = list.map((entry, i): DataQueryParam => {
|
|
475
|
+
if (!isPlainObject(entry) || typeof entry.name !== 'string') {
|
|
476
|
+
throw new DataError(`params[${i}] needs a name.`);
|
|
477
|
+
}
|
|
478
|
+
if (!DATA_QUERY_PARAM_TYPES.includes(entry.type as DataQueryParamType)) {
|
|
479
|
+
throw new DataError(
|
|
480
|
+
`Parameter ${entry.name}: "type" is one of ${DATA_QUERY_PARAM_TYPES.join(', ')}.`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const description =
|
|
484
|
+
typeof entry.description === 'string' && entry.description.trim()
|
|
485
|
+
? entry.description.trim().slice(0, DATA_MAX_DESCRIPTION)
|
|
486
|
+
: null;
|
|
487
|
+
return {
|
|
488
|
+
name: entry.name,
|
|
489
|
+
type: entry.type as DataQueryParamType,
|
|
490
|
+
...(description ? { description } : {}),
|
|
491
|
+
...(entry.required === false ? { required: false } : {})
|
|
492
|
+
};
|
|
493
|
+
});
|
|
494
|
+
const declared = params.map(p => p.name);
|
|
495
|
+
const undeclared = used.filter(name => !declared.includes(name));
|
|
496
|
+
if (undeclared.length) {
|
|
497
|
+
throw new DataError(
|
|
498
|
+
`Declare the type of ${undeclared.map(n => `:${n}`).join(', ')} in "params".`
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
const unused = declared.filter(name => !used.includes(name));
|
|
502
|
+
if (unused.length) {
|
|
503
|
+
throw new DataError(
|
|
504
|
+
`The SQL doesn't use ${unused.map(n => `:${n}`).join(', ')}.`
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return used.map(name => params.find(p => p.name === name)!);
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Saves a named query, replacing one by the same name. Refused unless it
|
|
512
|
+
* passes both checks; the tables it reads and its plan are kept with it.
|
|
513
|
+
*/
|
|
514
|
+
export const saveQuery = (
|
|
515
|
+
runner: SqlRunner,
|
|
516
|
+
schema: DataSchema,
|
|
517
|
+
caller: DataCaller,
|
|
518
|
+
input: unknown
|
|
519
|
+
): DataQuery => {
|
|
520
|
+
if (!isPlainObject(input)) throw new DataError('A named query is an object.');
|
|
521
|
+
const name = input.name;
|
|
522
|
+
if (typeof name !== 'string' || !DATA_IDENTIFIER_PATTERN.test(name)) {
|
|
523
|
+
throw new DataError(
|
|
524
|
+
'A named query needs a name: lowercase letters, digits and _, starting with a letter.'
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
const compiled = compileQuerySql(input.sql);
|
|
528
|
+
const params = normaliseParams(input.params, compiled.params);
|
|
529
|
+
const { tables, plan } = checkQueryPlan(runner, schema, compiled);
|
|
530
|
+
const description =
|
|
531
|
+
typeof input.description === 'string' && input.description.trim()
|
|
532
|
+
? input.description.trim().slice(0, DATA_MAX_DESCRIPTION)
|
|
533
|
+
: null;
|
|
534
|
+
const exists =
|
|
535
|
+
runner.all('SELECT 1 FROM _ganju_queries WHERE name = ?', [name]).length >
|
|
536
|
+
0;
|
|
537
|
+
if (!exists) {
|
|
538
|
+
const count = Number(
|
|
539
|
+
runner.all('SELECT count(*) AS n FROM _ganju_queries')[0]?.n ?? 0
|
|
540
|
+
);
|
|
541
|
+
if (count >= DATA_MAX_QUERIES) {
|
|
542
|
+
throw new DataError(
|
|
543
|
+
`A database holds up to ${DATA_MAX_QUERIES} named queries.`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
runner.all(
|
|
548
|
+
`INSERT INTO _ganju_queries (name, description, sql, params, assistant, tables, plan, updated_at, updated_by)
|
|
549
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
|
|
550
|
+
ON CONFLICT(name) DO UPDATE SET description = excluded.description, sql = excluded.sql,
|
|
551
|
+
params = excluded.params, assistant = excluded.assistant, tables = excluded.tables,
|
|
552
|
+
plan = excluded.plan, updated_at = excluded.updated_at, updated_by = excluded.updated_by`,
|
|
553
|
+
[
|
|
554
|
+
name,
|
|
555
|
+
description,
|
|
556
|
+
(input.sql as string).trim(),
|
|
557
|
+
JSON.stringify(params),
|
|
558
|
+
input.assistant === true ? 1 : 0,
|
|
559
|
+
JSON.stringify(tables),
|
|
560
|
+
JSON.stringify(plan),
|
|
561
|
+
caller.actor
|
|
562
|
+
]
|
|
563
|
+
);
|
|
564
|
+
return findQuery(runner, name);
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
export const dropQuery = (
|
|
568
|
+
runner: SqlRunner,
|
|
569
|
+
name: unknown
|
|
570
|
+
): { dropped: string } => {
|
|
571
|
+
const query = findQuery(runner, name);
|
|
572
|
+
runner.all('DELETE FROM _ganju_queries WHERE name = ?', [query.name]);
|
|
573
|
+
return { dropped: query.name };
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// The queries a caller may run: all of them, or for a channel bot those the
|
|
577
|
+
// owner let the assistant run that read only tables it may read, none of
|
|
578
|
+
// them scoped to a customer. A named query's SQL can't be scoped, so it could
|
|
579
|
+
// read every customer's rows.
|
|
580
|
+
const mayRun = (schema: DataSchema, caller: DataCaller, query: DataQuery) => {
|
|
581
|
+
if (
|
|
582
|
+
caller.tables &&
|
|
583
|
+
!query.tables.every(name => caller.tables?.includes(name))
|
|
584
|
+
) {
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
if (!caller.restricted) return true;
|
|
588
|
+
if (!query.assistant) return false;
|
|
589
|
+
return query.tables.every(name => {
|
|
590
|
+
const table = schema.tables.find(t => t.name === name);
|
|
591
|
+
return Boolean(
|
|
592
|
+
table && DATA_ACCESS_RANK[table.access] >= 1 && !isScoped(caller, table)
|
|
593
|
+
);
|
|
594
|
+
});
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
export const queriesFor = (
|
|
598
|
+
runner: SqlRunner,
|
|
599
|
+
schema: DataSchema,
|
|
600
|
+
caller: DataCaller
|
|
601
|
+
): DataQuery[] =>
|
|
602
|
+
listQueries(runner).filter(query => mayRun(schema, caller, query));
|
|
603
|
+
|
|
604
|
+
// A parameter's value as the column of its type would store it.
|
|
605
|
+
const bindParam = (
|
|
606
|
+
param: DataQueryParam,
|
|
607
|
+
value: unknown,
|
|
608
|
+
timeZone?: string
|
|
609
|
+
): SqlValue => {
|
|
610
|
+
const column = {
|
|
611
|
+
name: param.name,
|
|
612
|
+
type: param.type,
|
|
613
|
+
required: param.required !== false
|
|
614
|
+
} as DataColumn;
|
|
615
|
+
try {
|
|
616
|
+
return toStored({ name: 'params' } as DataTable, column, value, timeZone);
|
|
617
|
+
} catch (error) {
|
|
618
|
+
if (error instanceof DataError) {
|
|
619
|
+
throw new DataError(error.message.replace(/^params\./, 'Parameter '));
|
|
620
|
+
}
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Runs a named query with `params`, up to `limit` rows (100 by default, 500 at
|
|
627
|
+
* most) and 1 MB. A query that would scan a table over 200,000 rows is refused
|
|
628
|
+
* before it runs: the database serves one request at a time.
|
|
629
|
+
*/
|
|
630
|
+
export const runQuery = (
|
|
631
|
+
runner: SqlRunner,
|
|
632
|
+
schema: DataSchema,
|
|
633
|
+
caller: DataCaller,
|
|
634
|
+
args: unknown
|
|
635
|
+
) => {
|
|
636
|
+
if (!isPlainObject(args)) throw new DataError('Arguments are an object.');
|
|
637
|
+
const query = findQuery(runner, args.name ?? args.query);
|
|
638
|
+
if (!mayRun(schema, caller, query)) {
|
|
639
|
+
throw new DataError(
|
|
640
|
+
`There is no named query "${query.name}" the assistant may run.`,
|
|
641
|
+
'not_found'
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
const given =
|
|
645
|
+
args.params === undefined || args.params === null ? {} : args.params;
|
|
646
|
+
if (!isPlainObject(given)) {
|
|
647
|
+
throw new DataError(
|
|
648
|
+
'"params" is an object, e.g. { "from": "2026-10-01" }.'
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
const unknown = Object.keys(given).filter(
|
|
652
|
+
key => !query.params.some(p => p.name === key)
|
|
653
|
+
);
|
|
654
|
+
if (unknown.length) {
|
|
655
|
+
throw new DataError(
|
|
656
|
+
`${query.name} takes ${query.params.map(p => p.name).join(', ') || 'no parameters'}; not ${unknown.join(', ')}.`
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
const limit =
|
|
660
|
+
args.limit === undefined || args.limit === null
|
|
661
|
+
? DATA_DEFAULT_QUERY_ROWS
|
|
662
|
+
: args.limit;
|
|
663
|
+
if (
|
|
664
|
+
typeof limit !== 'number' ||
|
|
665
|
+
!Number.isInteger(limit) ||
|
|
666
|
+
limit < 1 ||
|
|
667
|
+
limit > DATA_MAX_PAGE
|
|
668
|
+
) {
|
|
669
|
+
throw new DataError(`"limit" is 1 to ${DATA_MAX_PAGE}.`);
|
|
670
|
+
}
|
|
671
|
+
const values = query.params.map(param =>
|
|
672
|
+
bindParam(param, given[param.name], caller.timeZone)
|
|
673
|
+
);
|
|
674
|
+
const compiled = compileQuerySql(query.sql);
|
|
675
|
+
|
|
676
|
+
// A full scan shows in the plan as "SCAN x", with or without "USING
|
|
677
|
+
// (COVERING) INDEX": either way it reads every row, and on a large table
|
|
678
|
+
// that is the request everyone else waits behind.
|
|
679
|
+
const scans = query.plan.some(line => {
|
|
680
|
+
const detail = line.trim();
|
|
681
|
+
return (
|
|
682
|
+
detail.startsWith('SCAN ') &&
|
|
683
|
+
!detail.startsWith('SCAN CONSTANT ROW') &&
|
|
684
|
+
!detail.includes('VIRTUAL TABLE')
|
|
685
|
+
);
|
|
686
|
+
});
|
|
687
|
+
if (scans) {
|
|
688
|
+
for (const name of query.tables) {
|
|
689
|
+
const n = Number(
|
|
690
|
+
runner.all(`SELECT max(id) AS n FROM ${q(name)}`)[0]?.n ?? 0
|
|
691
|
+
);
|
|
692
|
+
if (n > DATA_LARGE_TABLE_ROWS) {
|
|
693
|
+
throw new DataError(
|
|
694
|
+
`${query.name} reads all of ${name}, which has about ${n.toLocaleString('en-US')} rows; filter on an indexed column (id, or a column marked filterable, unique or a relation).`,
|
|
695
|
+
'too_large'
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const rows: SqlRow[] = [];
|
|
702
|
+
let bytes = 0;
|
|
703
|
+
let truncated = false;
|
|
704
|
+
try {
|
|
705
|
+
for (const row of runner.iterate(compiled.sql, values)) {
|
|
706
|
+
if (rows.length === limit) {
|
|
707
|
+
truncated = true;
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
const size = JSON.stringify(row).length;
|
|
711
|
+
if (rows.length > 0 && bytes + size > DATA_MAX_RESULT_BYTES) {
|
|
712
|
+
truncated = true;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
bytes += size;
|
|
716
|
+
rows.push({ ...row });
|
|
717
|
+
}
|
|
718
|
+
} catch (error) {
|
|
719
|
+
throw new DataError(`${query.name} failed: ${sqliteMessage(error)}.`);
|
|
720
|
+
}
|
|
721
|
+
return {
|
|
722
|
+
query: query.name,
|
|
723
|
+
rows,
|
|
724
|
+
...(truncated
|
|
725
|
+
? {
|
|
726
|
+
truncated: `stopped at ${rows.length} rows; narrow the parameters or ask the owner for a query that returns fewer`
|
|
727
|
+
}
|
|
728
|
+
: {})
|
|
729
|
+
};
|
|
730
|
+
};
|