terrascale 0.3.0
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 +202 -0
- package/README.md +143 -0
- package/package.json +159 -0
- package/sdk-current-contract.json +27 -0
- package/sdk-route-manifest.json +67 -0
- package/src/admin.js +9 -0
- package/src/better-auth.js +14 -0
- package/src/config.js +121 -0
- package/src/database-codec.js +845 -0
- package/src/database-types.js +237 -0
- package/src/database-view.js +422 -0
- package/src/database.js +420 -0
- package/src/discovery.js +374 -0
- package/src/http.js +887 -0
- package/src/index.js +79 -0
- package/src/local/authentication.js +47 -0
- package/src/local/better-auth.js +517 -0
- package/src/local/cli.js +51 -0
- package/src/local/context.js +23 -0
- package/src/local/environment.js +109 -0
- package/src/local/index.js +204 -0
- package/src/local/router.js +999 -0
- package/src/local/server.js +664 -0
- package/src/local/store.js +530 -0
- package/src/local/test-environment.js +74 -0
- package/src/management-contracts.js +72 -0
- package/src/management.js +12 -0
- package/src/native-origin.js +75 -0
- package/src/postgres.js +494 -0
- package/src/react/core.js +743 -0
- package/src/react/index.js +99 -0
- package/src/result.js +251 -0
- package/src/schema.js +366 -0
- package/src/sql.js +996 -0
- package/src/svelte/index.js +129 -0
- package/src/tanstack/index.js +511 -0
- package/src/ts-auth-discovery.js +190 -0
- package/src/ts-auth.js +3497 -0
- package/types/admin.d.ts +6 -0
- package/types/better-auth.d.ts +8 -0
- package/types/config.d.ts +58 -0
- package/types/database-codec.d.ts +111 -0
- package/types/database-types.d.ts +213 -0
- package/types/database-view.d.ts +183 -0
- package/types/database.d.ts +98 -0
- package/types/discovery.d.ts +114 -0
- package/types/http.d.ts +46 -0
- package/types/index.d.ts +52 -0
- package/types/local/authentication.d.ts +11 -0
- package/types/local/better-auth.d.ts +33 -0
- package/types/local/cli.d.ts +2 -0
- package/types/local/context.d.ts +14 -0
- package/types/local/environment.d.ts +23 -0
- package/types/local/index.d.ts +94 -0
- package/types/local/router.d.ts +66 -0
- package/types/local/server.d.ts +54 -0
- package/types/local/store.d.ts +106 -0
- package/types/local/test-environment.d.ts +25 -0
- package/types/management-contracts.d.ts +44 -0
- package/types/management.d.ts +6 -0
- package/types/native-origin.d.ts +23 -0
- package/types/postgres.d.ts +123 -0
- package/types/react/core.d.ts +366 -0
- package/types/react/index.d.ts +54 -0
- package/types/result.d.ts +161 -0
- package/types/schema.d.ts +145 -0
- package/types/sql.d.ts +288 -0
- package/types/svelte/index.d.ts +81 -0
- package/types/tanstack/index.d.ts +165 -0
- package/types/ts-auth-discovery.d.ts +11 -0
- package/types/ts-auth.d.ts +1826 -0
package/src/sql.js
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parameterized SQL construction. Listener capabilities are checked by the PostgreSQL transport.
|
|
3
|
+
* This module performs no execution and grants no server SQL capabilities.
|
|
4
|
+
*
|
|
5
|
+
* The typed builder accepts schema field descriptors, not SQL fragments. All
|
|
6
|
+
* values (including LIMIT/OFFSET values) are kept separately from the SQL
|
|
7
|
+
* statement and are sent as positional parameters.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { FieldTypes } from "./schema.js";
|
|
11
|
+
/** @import { CollectionSchema, FieldDef, FieldType } from "./schema.js" */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Pick<FieldDef, "name" | "type"> &
|
|
15
|
+
* Partial<Omit<FieldDef, "name" | "type">>} SqlFieldDef
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* @typedef {{
|
|
19
|
+
* readonly name: string;
|
|
20
|
+
* readonly fields: readonly SqlFieldDef[];
|
|
21
|
+
* } & Partial<Omit<CollectionSchema, "name" | "fields">>} SqlCollectionSchema
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* @template {SqlCollectionSchema} S
|
|
25
|
+
* @typedef {S["fields"][number]} SchemaField
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* @template {SqlCollectionSchema} S
|
|
29
|
+
* @typedef {SchemaField<S>} QueryField
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export const Direction = Object.freeze(
|
|
33
|
+
/** @type {const} */ ({
|
|
34
|
+
Asc: "ASC",
|
|
35
|
+
Desc: "DESC",
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
/** @typedef {(typeof Direction)[keyof typeof Direction]} Direction */
|
|
40
|
+
/** @typedef {boolean | bigint | number | string | Uint8Array | null} SqlScalar */
|
|
41
|
+
/** @typedef {SqlScalar} SqlParameter */
|
|
42
|
+
|
|
43
|
+
export class SqlValidationError extends Error {
|
|
44
|
+
/**
|
|
45
|
+
* @override
|
|
46
|
+
* @readonly
|
|
47
|
+
*/
|
|
48
|
+
name = /** @type {const} */ ("SqlValidationError");
|
|
49
|
+
/** @readonly */
|
|
50
|
+
code = /** @type {const} */ ("invalid_sql");
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {string} message
|
|
54
|
+
*/
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(`terrascale SQL: ${message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @typedef {{
|
|
62
|
+
* readonly statement: string;
|
|
63
|
+
* readonly parameters: readonly SqlParameter[];
|
|
64
|
+
* }} QueryDescriptor
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {readonly SqlParameter[]} parameters
|
|
69
|
+
* @returns {readonly SqlParameter[]}
|
|
70
|
+
*/
|
|
71
|
+
function freezeParameters(parameters) {
|
|
72
|
+
return Object.freeze(
|
|
73
|
+
parameters.map((parameter) => cloneParameter(parameter)),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {SqlParameter} parameter
|
|
79
|
+
* @returns {SqlParameter}
|
|
80
|
+
*/
|
|
81
|
+
function cloneParameter(parameter) {
|
|
82
|
+
return parameter instanceof Uint8Array ? parameter.slice() : parameter;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A validated, parameterized read-only SQL statement. */
|
|
86
|
+
export class Query {
|
|
87
|
+
/**
|
|
88
|
+
* @readonly
|
|
89
|
+
* @type {string}
|
|
90
|
+
*/
|
|
91
|
+
statement;
|
|
92
|
+
/** @type {readonly SqlParameter[]} */
|
|
93
|
+
#parameters;
|
|
94
|
+
/**
|
|
95
|
+
* @returns {readonly SqlParameter[]}
|
|
96
|
+
*/
|
|
97
|
+
get parameters() {
|
|
98
|
+
return freezeParameters(this.#parameters);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @private
|
|
103
|
+
* @param {string} sql
|
|
104
|
+
* @param {readonly SqlParameter[]} parameters
|
|
105
|
+
*/
|
|
106
|
+
constructor(sql, parameters) {
|
|
107
|
+
validateSql(sql, parameters);
|
|
108
|
+
this.statement = sql;
|
|
109
|
+
this.#parameters = freezeParameters(parameters);
|
|
110
|
+
Object.freeze(this);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} sql
|
|
115
|
+
* @param {readonly SqlParameter[]} [parameters]
|
|
116
|
+
* @returns {Query}
|
|
117
|
+
*/
|
|
118
|
+
static raw(sql, parameters = []) {
|
|
119
|
+
return new Query(sql, parameters);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {readonly string[]} [columns]
|
|
124
|
+
* @returns {SelectBuilder}
|
|
125
|
+
*/
|
|
126
|
+
static select(columns = ["*"]) {
|
|
127
|
+
return SelectBuilder.fromColumns(columns);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @template {SqlCollectionSchema} S
|
|
132
|
+
* @param {S} schema
|
|
133
|
+
* @returns {SelectBuilder<S>}
|
|
134
|
+
*/
|
|
135
|
+
static selectSchema(schema) {
|
|
136
|
+
return SelectBuilder.fromSchema(schema);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @returns {QueryDescriptor}
|
|
141
|
+
*/
|
|
142
|
+
toDescriptor() {
|
|
143
|
+
return Object.freeze({
|
|
144
|
+
statement: this.statement,
|
|
145
|
+
parameters: this.parameters,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Schema-bound SELECT builder.
|
|
152
|
+
*
|
|
153
|
+
* @template {SqlCollectionSchema} [S=SqlCollectionSchema]
|
|
154
|
+
*/
|
|
155
|
+
export class SelectBuilder {
|
|
156
|
+
/** @type {readonly string[]} */
|
|
157
|
+
#columns;
|
|
158
|
+
/** @type {string | undefined} */
|
|
159
|
+
#from;
|
|
160
|
+
/** @type {S | undefined} */
|
|
161
|
+
#schema;
|
|
162
|
+
/** @type {readonly string[]} */
|
|
163
|
+
#predicates;
|
|
164
|
+
/** @type {readonly (readonly [string, Direction])[]} */
|
|
165
|
+
#order;
|
|
166
|
+
/** @type {number | undefined} */
|
|
167
|
+
#limit;
|
|
168
|
+
/** @type {number | undefined} */
|
|
169
|
+
#offset;
|
|
170
|
+
/** @type {readonly SqlParameter[]} */
|
|
171
|
+
#parameters;
|
|
172
|
+
/** @type {boolean} */
|
|
173
|
+
#schemaBound;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* @private
|
|
177
|
+
* @param {{
|
|
178
|
+
* columns: readonly string[];
|
|
179
|
+
* from?: string | undefined;
|
|
180
|
+
* schema?: S | undefined;
|
|
181
|
+
* predicates?: readonly string[];
|
|
182
|
+
* order?: readonly (readonly [string, Direction])[];
|
|
183
|
+
* limit?: number | undefined;
|
|
184
|
+
* offset?: number | undefined;
|
|
185
|
+
* parameters?: readonly SqlParameter[];
|
|
186
|
+
* schemaBound?: boolean;
|
|
187
|
+
* }} input
|
|
188
|
+
*/
|
|
189
|
+
constructor(input) {
|
|
190
|
+
this.#columns = Object.freeze([...input.columns]);
|
|
191
|
+
this.#from = input.from;
|
|
192
|
+
this.#schema = input.schema;
|
|
193
|
+
this.#predicates = Object.freeze([...(input.predicates ?? [])]);
|
|
194
|
+
this.#order = input.order ?? [];
|
|
195
|
+
this.#limit = input.limit;
|
|
196
|
+
this.#offset = input.offset;
|
|
197
|
+
this.#parameters = freezeParameters(input.parameters ?? []);
|
|
198
|
+
this.#schemaBound = input.schemaBound ?? false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {Partial<{
|
|
203
|
+
* columns: readonly string[];
|
|
204
|
+
* from: string | undefined;
|
|
205
|
+
* predicates: readonly string[];
|
|
206
|
+
* order: readonly (readonly [string, Direction])[];
|
|
207
|
+
* limit: number | undefined;
|
|
208
|
+
* offset: number | undefined;
|
|
209
|
+
* parameters: readonly SqlParameter[];
|
|
210
|
+
* }>} changes
|
|
211
|
+
* @returns {SelectBuilder<S>}
|
|
212
|
+
*/
|
|
213
|
+
#copy(changes) {
|
|
214
|
+
return /** @type {SelectBuilder<S>} */ (
|
|
215
|
+
new SelectBuilder({
|
|
216
|
+
columns: changes.columns ?? this.#columns,
|
|
217
|
+
from: changes.from === undefined ? this.#from : changes.from,
|
|
218
|
+
schema: this.#schema,
|
|
219
|
+
predicates: changes.predicates ?? this.#predicates,
|
|
220
|
+
order: changes.order ?? this.#order,
|
|
221
|
+
limit: changes.limit === undefined ? this.#limit : changes.limit,
|
|
222
|
+
offset: changes.offset === undefined ? this.#offset : changes.offset,
|
|
223
|
+
parameters: changes.parameters ?? this.#parameters,
|
|
224
|
+
schemaBound: this.#schemaBound,
|
|
225
|
+
})
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @param {readonly string[]} columns
|
|
231
|
+
* @returns {SelectBuilder}
|
|
232
|
+
*/
|
|
233
|
+
static fromColumns(columns) {
|
|
234
|
+
return new SelectBuilder({ columns: [...columns] });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* @template {SqlCollectionSchema} T
|
|
239
|
+
* @param {T} schema
|
|
240
|
+
* @returns {SelectBuilder<T>}
|
|
241
|
+
*/
|
|
242
|
+
static fromSchema(schema) {
|
|
243
|
+
validateIdentifier(schema.name, "collection");
|
|
244
|
+
if (!Array.isArray(schema.fields))
|
|
245
|
+
throw new SqlValidationError("schema fields must be an array");
|
|
246
|
+
/** @type {Set<string>} */
|
|
247
|
+
const names = new Set();
|
|
248
|
+
for (const field of schema.fields) {
|
|
249
|
+
validateField(field, "schema field");
|
|
250
|
+
if (names.has(field.name))
|
|
251
|
+
throw new SqlValidationError(`duplicate schema field '${field.name}'`);
|
|
252
|
+
names.add(field.name);
|
|
253
|
+
}
|
|
254
|
+
return /** @type {SelectBuilder<T>} */ (
|
|
255
|
+
new SelectBuilder({
|
|
256
|
+
columns: ["*"],
|
|
257
|
+
from: schema.name,
|
|
258
|
+
schema: /** @type {T} */ (
|
|
259
|
+
Object.freeze({
|
|
260
|
+
...schema,
|
|
261
|
+
fields: Object.freeze(
|
|
262
|
+
schema.fields.map((field) => Object.freeze({ ...field })),
|
|
263
|
+
),
|
|
264
|
+
})
|
|
265
|
+
),
|
|
266
|
+
schemaBound: true,
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* @param {QueryField<S>} field
|
|
273
|
+
* @returns {SelectBuilder<S>}
|
|
274
|
+
*/
|
|
275
|
+
column(field) {
|
|
276
|
+
const name = this.#checkedField(field, "column");
|
|
277
|
+
return this.#copy({
|
|
278
|
+
columns: [
|
|
279
|
+
...(this.#columns.length === 1 && this.#columns[0] === "*"
|
|
280
|
+
? []
|
|
281
|
+
: this.#columns),
|
|
282
|
+
name,
|
|
283
|
+
],
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* @param {readonly QueryField<S>[]} fields
|
|
289
|
+
* @returns {SelectBuilder<S>}
|
|
290
|
+
*/
|
|
291
|
+
columns(fields) {
|
|
292
|
+
/** @type {SelectBuilder<S>} */
|
|
293
|
+
let builder = this;
|
|
294
|
+
for (const field of fields) builder = builder.column(field);
|
|
295
|
+
return builder;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @param {string} collection
|
|
300
|
+
* @returns {SelectBuilder<S>}
|
|
301
|
+
*/
|
|
302
|
+
from(collection) {
|
|
303
|
+
const valid = validateIdentifier(collection, "collection");
|
|
304
|
+
if (this.#schemaBound && valid !== this.#from) {
|
|
305
|
+
throw new SqlValidationError(
|
|
306
|
+
"a schema-bound query cannot change its collection",
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
return this.#copy({ from: valid });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* @template {SqlParameter} T
|
|
314
|
+
* @param {QueryField<S>} field
|
|
315
|
+
* @param {T} value
|
|
316
|
+
* @returns {SelectBuilder<S>}
|
|
317
|
+
*/
|
|
318
|
+
whereEq(field, value) {
|
|
319
|
+
return this.#comparison(field, "=", value);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @template {SqlParameter} T
|
|
324
|
+
* @param {QueryField<S>} field
|
|
325
|
+
* @param {T} value
|
|
326
|
+
* @returns {SelectBuilder<S>}
|
|
327
|
+
*/
|
|
328
|
+
whereNe(field, value) {
|
|
329
|
+
return this.#comparison(field, "<>", value);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* @template {SqlParameter} T
|
|
334
|
+
* @param {QueryField<S>} field
|
|
335
|
+
* @param {T} value
|
|
336
|
+
* @returns {SelectBuilder<S>}
|
|
337
|
+
*/
|
|
338
|
+
whereGt(field, value) {
|
|
339
|
+
return this.#comparison(field, ">", value);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* @template {SqlParameter} T
|
|
344
|
+
* @param {QueryField<S>} field
|
|
345
|
+
* @param {T} value
|
|
346
|
+
* @returns {SelectBuilder<S>}
|
|
347
|
+
*/
|
|
348
|
+
whereGte(field, value) {
|
|
349
|
+
return this.#comparison(field, ">=", value);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* @template {SqlParameter} T
|
|
354
|
+
* @param {QueryField<S>} field
|
|
355
|
+
* @param {T} value
|
|
356
|
+
* @returns {SelectBuilder<S>}
|
|
357
|
+
*/
|
|
358
|
+
whereLt(field, value) {
|
|
359
|
+
return this.#comparison(field, "<", value);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* @template {SqlParameter} T
|
|
364
|
+
* @param {QueryField<S>} field
|
|
365
|
+
* @param {T} value
|
|
366
|
+
* @returns {SelectBuilder<S>}
|
|
367
|
+
*/
|
|
368
|
+
whereLte(field, value) {
|
|
369
|
+
return this.#comparison(field, "<=", value);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @template {SqlParameter} T
|
|
374
|
+
* @param {QueryField<S>} field
|
|
375
|
+
* @param {T} value
|
|
376
|
+
* @returns {SelectBuilder<S>}
|
|
377
|
+
*/
|
|
378
|
+
whereLike(field, value) {
|
|
379
|
+
return this.#comparison(field, "LIKE", value);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @template {SqlParameter} T
|
|
384
|
+
* @param {QueryField<S>} field
|
|
385
|
+
* @param {readonly T[]} values
|
|
386
|
+
* @returns {SelectBuilder<S>}
|
|
387
|
+
*/
|
|
388
|
+
whereIn(field, values) {
|
|
389
|
+
if (values.length === 0)
|
|
390
|
+
throw new SqlValidationError("IN requires at least one value");
|
|
391
|
+
const name = this.#checkedField(field, "predicate");
|
|
392
|
+
const placeholders = Array.from(
|
|
393
|
+
{ length: values.length },
|
|
394
|
+
(_, index) => `$${this.#parameters.length + index + 1}`,
|
|
395
|
+
);
|
|
396
|
+
return this.#copy({
|
|
397
|
+
predicates: [
|
|
398
|
+
...this.#predicates,
|
|
399
|
+
`${name} IN (${placeholders.join(", ")})`,
|
|
400
|
+
],
|
|
401
|
+
parameters: [...this.#parameters, ...values],
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* @param {SchemaField<S>} field
|
|
407
|
+
* @param {Direction} [direction]
|
|
408
|
+
* @returns {SelectBuilder<S>}
|
|
409
|
+
*/
|
|
410
|
+
orderByField(field, direction = Direction.Asc) {
|
|
411
|
+
return this.orderBy(this.#checkedField(field, "order_by"), direction);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {string} column
|
|
416
|
+
* @param {Direction} [direction]
|
|
417
|
+
* @returns {SelectBuilder<S>}
|
|
418
|
+
*/
|
|
419
|
+
orderBy(column, direction = Direction.Asc) {
|
|
420
|
+
if (direction !== Direction.Asc && direction !== Direction.Desc) {
|
|
421
|
+
throw new SqlValidationError("ORDER BY direction must be ASC or DESC");
|
|
422
|
+
}
|
|
423
|
+
const valid = this.#schemaBound
|
|
424
|
+
? this.#checkedFieldName(column, "order_by")
|
|
425
|
+
: validateIdentifier(column, "order_by");
|
|
426
|
+
return this.#copy({ order: [...this.#order, [valid, direction]] });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* @param {number} value
|
|
431
|
+
* @returns {SelectBuilder<S>}
|
|
432
|
+
*/
|
|
433
|
+
limit(value) {
|
|
434
|
+
return this.#paginationValue(value, "limit", { limit: value });
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* @param {number} value
|
|
439
|
+
* @returns {SelectBuilder<S>}
|
|
440
|
+
*/
|
|
441
|
+
offset(value) {
|
|
442
|
+
return this.#paginationValue(value, "offset", { offset: value });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* @param {{
|
|
447
|
+
* readonly limit: number;
|
|
448
|
+
* readonly offset?: number | undefined;
|
|
449
|
+
* }} options
|
|
450
|
+
* @returns {SelectBuilder<S>}
|
|
451
|
+
*/
|
|
452
|
+
paginate(options) {
|
|
453
|
+
const withLimit = this.limit(options.limit);
|
|
454
|
+
return options.offset === undefined
|
|
455
|
+
? withLimit
|
|
456
|
+
: withLimit.offset(options.offset);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* @param {number} page
|
|
461
|
+
* @param {number} pageSize
|
|
462
|
+
* @returns {SelectBuilder<S>}
|
|
463
|
+
*/
|
|
464
|
+
page(page, pageSize) {
|
|
465
|
+
if (!Number.isSafeInteger(page) || page < 1)
|
|
466
|
+
throw new SqlValidationError("page must be a positive integer");
|
|
467
|
+
const result = this.limit(pageSize);
|
|
468
|
+
return result.offset((page - 1) * pageSize);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* @returns {Query}
|
|
473
|
+
*/
|
|
474
|
+
build() {
|
|
475
|
+
if (this.#from === undefined)
|
|
476
|
+
throw new SqlValidationError("a query requires .from(collection)");
|
|
477
|
+
if (this.#columns.length === 0)
|
|
478
|
+
throw new SqlValidationError(
|
|
479
|
+
"a query requires at least one selected column",
|
|
480
|
+
);
|
|
481
|
+
const columns = this.#columns.map(validateProjection);
|
|
482
|
+
const parts = [
|
|
483
|
+
`SELECT ${columns.join(", ")} FROM ${validateIdentifier(this.#from, "collection")}`,
|
|
484
|
+
];
|
|
485
|
+
if (this.#predicates.length > 0)
|
|
486
|
+
parts.push(`WHERE ${this.#predicates.join(" AND ")}`);
|
|
487
|
+
if (this.#order.length > 0) {
|
|
488
|
+
parts.push(
|
|
489
|
+
`ORDER BY ${this.#order.map(([column, direction]) => `${column} ${direction}`).join(", ")}`,
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
const parameters = [...this.#parameters];
|
|
493
|
+
if (this.#limit !== undefined) {
|
|
494
|
+
parts.push(`LIMIT $${parameters.length + 1}`);
|
|
495
|
+
parameters.push(this.#limit);
|
|
496
|
+
}
|
|
497
|
+
if (this.#offset !== undefined) {
|
|
498
|
+
parts.push(`OFFSET $${parameters.length + 1}`);
|
|
499
|
+
parameters.push(this.#offset);
|
|
500
|
+
}
|
|
501
|
+
return Query.raw(parts.join(" "), parameters);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* @returns {QueryDescriptor}
|
|
506
|
+
*/
|
|
507
|
+
toDescriptor() {
|
|
508
|
+
return this.build().toDescriptor();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* @template {SqlParameter} T
|
|
513
|
+
* @param {SchemaField<S>} field
|
|
514
|
+
* @param {"=" | "<>" | "!=" | ">" | ">=" | "<" | "<=" | "MATCH" | "LIKE"} operator
|
|
515
|
+
* @param {T} value
|
|
516
|
+
* @returns {SelectBuilder<S>}
|
|
517
|
+
*/
|
|
518
|
+
#comparison(field, operator, value) {
|
|
519
|
+
return this.#comparisonExpression(
|
|
520
|
+
this.#checkedField(field, "predicate"),
|
|
521
|
+
operator,
|
|
522
|
+
value,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* @template {SqlParameter} T
|
|
528
|
+
* @param {string} expression
|
|
529
|
+
* @param {"=" | "<>" | "!=" | ">" | ">=" | "<" | "<=" | "MATCH" | "LIKE"} operator
|
|
530
|
+
* @param {T} value
|
|
531
|
+
* @returns {SelectBuilder<S>}
|
|
532
|
+
*/
|
|
533
|
+
#comparisonExpression(expression, operator, value) {
|
|
534
|
+
if (!COMPARISON_OPERATORS.has(operator))
|
|
535
|
+
throw new SqlValidationError("unsupported SQL comparison operator");
|
|
536
|
+
const placeholder = `$${this.#parameters.length + 1}`;
|
|
537
|
+
return this.#copy({
|
|
538
|
+
predicates: [
|
|
539
|
+
...this.#predicates,
|
|
540
|
+
`${expression} ${operator} ${placeholder}`,
|
|
541
|
+
],
|
|
542
|
+
parameters: [...this.#parameters, value],
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* @param {number} value
|
|
548
|
+
* @param {string} label
|
|
549
|
+
* @param {{ readonly limit?: number; readonly offset?: number }} pagination
|
|
550
|
+
* @returns {SelectBuilder<S>}
|
|
551
|
+
*/
|
|
552
|
+
#paginationValue(value, label, pagination) {
|
|
553
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
554
|
+
throw new SqlValidationError(
|
|
555
|
+
`${label} must be a non-negative safe integer`,
|
|
556
|
+
);
|
|
557
|
+
return this.#copy({
|
|
558
|
+
limit: pagination.limit === undefined ? this.#limit : pagination.limit,
|
|
559
|
+
offset:
|
|
560
|
+
pagination.offset === undefined ? this.#offset : pagination.offset,
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* @param {QueryField<S>} field
|
|
566
|
+
* @param {string} context
|
|
567
|
+
* @returns {string}
|
|
568
|
+
*/
|
|
569
|
+
#checkedField(field, context) {
|
|
570
|
+
return this.#checkedFieldDef(field, context).name;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* @param {QueryField<S>} field
|
|
575
|
+
* @param {string} context
|
|
576
|
+
* @returns {SqlFieldDef}
|
|
577
|
+
*/
|
|
578
|
+
#checkedFieldDef(field, context) {
|
|
579
|
+
if (!field || typeof field !== "object")
|
|
580
|
+
throw new SqlValidationError(`${context} field is invalid`);
|
|
581
|
+
const name = validateIdentifier(field.name, context);
|
|
582
|
+
if (!this.#schemaBound || this.#schema === undefined)
|
|
583
|
+
throw new SqlValidationError(
|
|
584
|
+
"use a schema-bound SELECT before passing field descriptors",
|
|
585
|
+
);
|
|
586
|
+
const declared = this.#schema.fields.find(
|
|
587
|
+
(candidate) => candidate.name === name,
|
|
588
|
+
);
|
|
589
|
+
if (declared === undefined || fieldType(declared) !== fieldType(field)) {
|
|
590
|
+
throw new SqlValidationError(
|
|
591
|
+
`${context} field '${name}' is not declared by this collection`,
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
return declared;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* @param {string} name
|
|
599
|
+
* @param {string} context
|
|
600
|
+
* @returns {string}
|
|
601
|
+
*/
|
|
602
|
+
#checkedFieldName(name, context) {
|
|
603
|
+
validateIdentifier(name, context);
|
|
604
|
+
if (
|
|
605
|
+
!this.#schemaBound ||
|
|
606
|
+
this.#schema === undefined ||
|
|
607
|
+
!this.#schema.fields.some((field) => field.name === name)
|
|
608
|
+
) {
|
|
609
|
+
throw new SqlValidationError(
|
|
610
|
+
`${context} field '${name}' is not declared by this collection`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
return name;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* @template {SqlCollectionSchema} S
|
|
619
|
+
* @overload
|
|
620
|
+
* @param {S} schema
|
|
621
|
+
* @returns {SelectBuilder<S>}
|
|
622
|
+
*/
|
|
623
|
+
/**
|
|
624
|
+
* @overload
|
|
625
|
+
* @param {readonly string[]} [columns]
|
|
626
|
+
* @returns {SelectBuilder}
|
|
627
|
+
*/
|
|
628
|
+
/**
|
|
629
|
+
* @param {SqlCollectionSchema | readonly string[]} [input]
|
|
630
|
+
* @returns {SelectBuilder}
|
|
631
|
+
*/
|
|
632
|
+
export function select(input = ["*"]) {
|
|
633
|
+
if (Array.isArray(input)) return SelectBuilder.fromColumns(input);
|
|
634
|
+
return SelectBuilder.fromSchema(/** @type {CollectionSchema} */ (input));
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* @template {SqlCollectionSchema} S
|
|
639
|
+
* @param {S} schema
|
|
640
|
+
* @returns {SelectBuilder<S>}
|
|
641
|
+
*/
|
|
642
|
+
export function selectSchema(schema) {
|
|
643
|
+
return SelectBuilder.fromSchema(schema);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* @param {string} sql
|
|
648
|
+
* @param {readonly SqlParameter[]} [parameters]
|
|
649
|
+
* @returns {Query}
|
|
650
|
+
*/
|
|
651
|
+
export function raw(sql, parameters = []) {
|
|
652
|
+
return Query.raw(sql, parameters);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** @type {Set<string>} */
|
|
656
|
+
const COMPARISON_OPERATORS = new Set([
|
|
657
|
+
"=",
|
|
658
|
+
"<>",
|
|
659
|
+
"!=",
|
|
660
|
+
">",
|
|
661
|
+
">=",
|
|
662
|
+
"<",
|
|
663
|
+
"<=",
|
|
664
|
+
"MATCH",
|
|
665
|
+
"LIKE",
|
|
666
|
+
]);
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* @param {SqlFieldDef} field
|
|
670
|
+
* @returns {FieldType | undefined}
|
|
671
|
+
*/
|
|
672
|
+
function fieldType(field) {
|
|
673
|
+
return normalizeFieldType(field.type);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* @param {FieldType | string} value
|
|
678
|
+
* @returns {FieldType | undefined}
|
|
679
|
+
*/
|
|
680
|
+
function normalizeFieldType(value) {
|
|
681
|
+
return Object.values(FieldTypes).includes(/** @type {FieldType} */ (value))
|
|
682
|
+
? /** @type {FieldType} */ (value)
|
|
683
|
+
: undefined;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* @param {SqlFieldDef} field
|
|
688
|
+
* @param {string} context
|
|
689
|
+
* @returns {void}
|
|
690
|
+
*/
|
|
691
|
+
function validateField(field, context) {
|
|
692
|
+
validateIdentifier(field.name, context);
|
|
693
|
+
if (fieldType(field) === undefined)
|
|
694
|
+
throw new SqlValidationError(
|
|
695
|
+
`${context} '${field.name}' has an unknown type`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/**
|
|
700
|
+
* @param {string} value
|
|
701
|
+
* @returns {boolean}
|
|
702
|
+
*/
|
|
703
|
+
function isIdentifier(value) {
|
|
704
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/u.test(value);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* @param {string} value
|
|
709
|
+
* @param {string} context
|
|
710
|
+
* @returns {string}
|
|
711
|
+
*/
|
|
712
|
+
function validateIdentifier(value, context) {
|
|
713
|
+
if (!isIdentifier(value))
|
|
714
|
+
throw new SqlValidationError(
|
|
715
|
+
`${context} must match [A-Za-z_][A-Za-z0-9_]*`,
|
|
716
|
+
);
|
|
717
|
+
return value;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* @param {string} value
|
|
722
|
+
* @returns {string}
|
|
723
|
+
*/
|
|
724
|
+
function validateProjection(value) {
|
|
725
|
+
if (value.trim() === "*") return "*";
|
|
726
|
+
return validateIdentifier(value, "column");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const FORBIDDEN_KEYWORDS = new Set([
|
|
730
|
+
"attach",
|
|
731
|
+
"alter",
|
|
732
|
+
"analyze",
|
|
733
|
+
"begin",
|
|
734
|
+
"commit",
|
|
735
|
+
"create",
|
|
736
|
+
"delete",
|
|
737
|
+
"detach",
|
|
738
|
+
"drop",
|
|
739
|
+
"end",
|
|
740
|
+
"insert",
|
|
741
|
+
"pragma",
|
|
742
|
+
"reindex",
|
|
743
|
+
"replace",
|
|
744
|
+
"rollback",
|
|
745
|
+
"savepoint",
|
|
746
|
+
"transaction",
|
|
747
|
+
"truncate",
|
|
748
|
+
"update",
|
|
749
|
+
"vacuum",
|
|
750
|
+
"upsert",
|
|
751
|
+
]);
|
|
752
|
+
const FORBIDDEN_FUNCTIONS = new Set([
|
|
753
|
+
"random",
|
|
754
|
+
"randomblob",
|
|
755
|
+
"changes",
|
|
756
|
+
"total_changes",
|
|
757
|
+
"last_insert_rowid",
|
|
758
|
+
"load_extension",
|
|
759
|
+
"readfile",
|
|
760
|
+
"writefile",
|
|
761
|
+
"datetime",
|
|
762
|
+
"date",
|
|
763
|
+
"time",
|
|
764
|
+
"strftime",
|
|
765
|
+
"julianday",
|
|
766
|
+
"unixepoch",
|
|
767
|
+
]);
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Validate the client-side read-only SQL allow-list and positional bindings.
|
|
771
|
+
*
|
|
772
|
+
* @param {string} sql
|
|
773
|
+
* @param {readonly SqlParameter[]} [parameters]
|
|
774
|
+
* @returns {void}
|
|
775
|
+
*/
|
|
776
|
+
export function validateSql(sql, parameters = []) {
|
|
777
|
+
if (typeof sql !== "string" || sql.trim() === "")
|
|
778
|
+
throw new SqlValidationError("SQL must not be empty");
|
|
779
|
+
if (new TextEncoder().encode(sql).length > 1_048_576)
|
|
780
|
+
throw new SqlValidationError("SQL exceeds the 1 MiB query text limit");
|
|
781
|
+
for (const parameter of parameters) validateParameter(parameter);
|
|
782
|
+
const tokens = tokenizeSql(sql);
|
|
783
|
+
const first = tokens[0]?.toLowerCase();
|
|
784
|
+
let bodyStart = 1;
|
|
785
|
+
if (first === "select") {
|
|
786
|
+
// normal SELECT
|
|
787
|
+
} else if (first === "with") {
|
|
788
|
+
if (tokens[1]?.toLowerCase() === "recursive")
|
|
789
|
+
throw new SqlValidationError("WITH RECURSIVE is not allowed");
|
|
790
|
+
} else if (first === "explain") {
|
|
791
|
+
if (tokens[1]?.toLowerCase() === "query") {
|
|
792
|
+
if (tokens[2]?.toLowerCase() !== "plan")
|
|
793
|
+
throw new SqlValidationError(
|
|
794
|
+
"EXPLAIN may only use EXPLAIN QUERY PLAN or EXPLAIN SELECT",
|
|
795
|
+
);
|
|
796
|
+
bodyStart = 3;
|
|
797
|
+
}
|
|
798
|
+
if (
|
|
799
|
+
tokens[bodyStart]?.toLowerCase() !== "select" &&
|
|
800
|
+
tokens[bodyStart]?.toLowerCase() !== "with"
|
|
801
|
+
) {
|
|
802
|
+
throw new SqlValidationError(
|
|
803
|
+
"only EXPLAIN SELECT and EXPLAIN QUERY PLAN SELECT are allowed",
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
if (
|
|
807
|
+
tokens[bodyStart]?.toLowerCase() === "with" &&
|
|
808
|
+
tokens[bodyStart + 1]?.toLowerCase() === "recursive"
|
|
809
|
+
) {
|
|
810
|
+
throw new SqlValidationError("WITH RECURSIVE is not allowed");
|
|
811
|
+
}
|
|
812
|
+
} else {
|
|
813
|
+
throw new SqlValidationError(
|
|
814
|
+
"only SELECT, WITH, and EXPLAIN statements are allowed",
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
for (let index = bodyStart; index < tokens.length; index += 1) {
|
|
818
|
+
const token = /** @type {string} */ (tokens[index]).toLowerCase();
|
|
819
|
+
if (FORBIDDEN_KEYWORDS.has(token))
|
|
820
|
+
throw new SqlValidationError(
|
|
821
|
+
"SQL contains a DDL, DML, or transaction keyword",
|
|
822
|
+
);
|
|
823
|
+
if (["current_date", "current_time", "current_timestamp"].includes(token))
|
|
824
|
+
throw new SqlValidationError(
|
|
825
|
+
"non-deterministic SQL functions are not allowed",
|
|
826
|
+
);
|
|
827
|
+
if (FORBIDDEN_FUNCTIONS.has(token) && tokens[index + 1] === "(")
|
|
828
|
+
throw new SqlValidationError("unsafe SQL functions are not allowed");
|
|
829
|
+
if (token.startsWith("sqlite_") || token.startsWith("pragma_"))
|
|
830
|
+
throw new SqlValidationError(
|
|
831
|
+
"internal SQLite names and pragma functions are not allowed",
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
validateBindings(tokens, parameters.length);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export const validate_sql = validateSql;
|
|
838
|
+
|
|
839
|
+
/**
|
|
840
|
+
* @param {unknown} value
|
|
841
|
+
* @returns {void}
|
|
842
|
+
*/
|
|
843
|
+
function validateParameter(value) {
|
|
844
|
+
if (
|
|
845
|
+
value === null ||
|
|
846
|
+
typeof value === "string" ||
|
|
847
|
+
typeof value === "boolean" ||
|
|
848
|
+
value instanceof Uint8Array
|
|
849
|
+
)
|
|
850
|
+
return;
|
|
851
|
+
if (typeof value === "bigint" && value >= -(1n << 63n) && value < 1n << 63n)
|
|
852
|
+
return;
|
|
853
|
+
if (typeof value === "number" && Number.isSafeInteger(value)) return;
|
|
854
|
+
throw new SqlValidationError(
|
|
855
|
+
"SQL parameters require exact i64 integers, strings, booleans, bytes or null; listener support is separate.",
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* @param {string} sql
|
|
861
|
+
* @returns {string[]}
|
|
862
|
+
*/
|
|
863
|
+
function tokenizeSql(sql) {
|
|
864
|
+
/** @type {string[]} */
|
|
865
|
+
const tokens = [];
|
|
866
|
+
const bytes = new TextEncoder().encode(sql);
|
|
867
|
+
let index = 0;
|
|
868
|
+
while (index < bytes.length) {
|
|
869
|
+
const byte = /** @type {number} */ (bytes[index]);
|
|
870
|
+
if (byte === 32 || byte === 9 || byte === 10 || byte === 13) {
|
|
871
|
+
index += 1;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (byte === 59) {
|
|
875
|
+
if (
|
|
876
|
+
bytes
|
|
877
|
+
.slice(index + 1)
|
|
878
|
+
.some(
|
|
879
|
+
(next) => next !== 32 && next !== 9 && next !== 10 && next !== 13,
|
|
880
|
+
)
|
|
881
|
+
)
|
|
882
|
+
throw new SqlValidationError("multiple SQL statements are not allowed");
|
|
883
|
+
index += 1;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (
|
|
887
|
+
(byte === 45 && bytes[index + 1] === 45) ||
|
|
888
|
+
(byte === 47 && bytes[index + 1] === 42)
|
|
889
|
+
)
|
|
890
|
+
throw new SqlValidationError("SQL comments are not allowed");
|
|
891
|
+
if (byte === 39 || byte === 34 || byte === 96 || byte === 91) {
|
|
892
|
+
const end = byte === 91 ? 93 : byte;
|
|
893
|
+
index += 1;
|
|
894
|
+
let closed = false;
|
|
895
|
+
while (index < bytes.length) {
|
|
896
|
+
if (bytes[index] === end) {
|
|
897
|
+
if (end !== 93 && bytes[index + 1] === end) {
|
|
898
|
+
index += 2;
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
index += 1;
|
|
902
|
+
closed = true;
|
|
903
|
+
break;
|
|
904
|
+
}
|
|
905
|
+
index += 1;
|
|
906
|
+
}
|
|
907
|
+
if (!closed)
|
|
908
|
+
throw new SqlValidationError(
|
|
909
|
+
"SQL contains an unterminated quoted value",
|
|
910
|
+
);
|
|
911
|
+
tokens.push("<quoted>");
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (byte === 36) {
|
|
915
|
+
const start = index;
|
|
916
|
+
index += 1;
|
|
917
|
+
while (
|
|
918
|
+
index < bytes.length &&
|
|
919
|
+
/** @type {number} */ (bytes[index]) >= 48 &&
|
|
920
|
+
/** @type {number} */ (bytes[index]) <= 57
|
|
921
|
+
)
|
|
922
|
+
index += 1;
|
|
923
|
+
if (index === start + 1)
|
|
924
|
+
throw new SqlValidationError("only numbered $n parameters are allowed");
|
|
925
|
+
tokens.push(new TextDecoder().decode(bytes.slice(start, index)));
|
|
926
|
+
continue;
|
|
927
|
+
}
|
|
928
|
+
if (byte === 63) {
|
|
929
|
+
const start = index;
|
|
930
|
+
index += 1;
|
|
931
|
+
while (
|
|
932
|
+
index < bytes.length &&
|
|
933
|
+
/** @type {number} */ (bytes[index]) >= 48 &&
|
|
934
|
+
/** @type {number} */ (bytes[index]) <= 57
|
|
935
|
+
)
|
|
936
|
+
index += 1;
|
|
937
|
+
tokens.push(new TextDecoder().decode(bytes.slice(start, index)));
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (
|
|
941
|
+
(byte >= 48 && byte <= 57) ||
|
|
942
|
+
(byte >= 65 && byte <= 90) ||
|
|
943
|
+
(byte >= 97 && byte <= 122) ||
|
|
944
|
+
byte === 95
|
|
945
|
+
) {
|
|
946
|
+
const start = index;
|
|
947
|
+
index += 1;
|
|
948
|
+
while (
|
|
949
|
+
index < bytes.length &&
|
|
950
|
+
((/** @type {number} */ (bytes[index]) >= 48 &&
|
|
951
|
+
/** @type {number} */ (bytes[index]) <= 57) ||
|
|
952
|
+
(/** @type {number} */ (bytes[index]) >= 65 &&
|
|
953
|
+
/** @type {number} */ (bytes[index]) <= 90) ||
|
|
954
|
+
(/** @type {number} */ (bytes[index]) >= 97 &&
|
|
955
|
+
/** @type {number} */ (bytes[index]) <= 122) ||
|
|
956
|
+
bytes[index] === 95)
|
|
957
|
+
)
|
|
958
|
+
index += 1;
|
|
959
|
+
tokens.push(new TextDecoder().decode(bytes.slice(start, index)));
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
tokens.push(new TextDecoder().decode(bytes.slice(index, index + 1)));
|
|
963
|
+
index += 1;
|
|
964
|
+
}
|
|
965
|
+
return tokens;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* @param {readonly string[]} tokens
|
|
970
|
+
* @param {number} parameterCount
|
|
971
|
+
* @returns {void}
|
|
972
|
+
*/
|
|
973
|
+
function validateBindings(tokens, parameterCount) {
|
|
974
|
+
let nextUnnamed = 1;
|
|
975
|
+
let highest = 0;
|
|
976
|
+
/** @type {Set<number>} */
|
|
977
|
+
const seen = new Set();
|
|
978
|
+
for (const token of tokens) {
|
|
979
|
+
/** @type {number | undefined} */
|
|
980
|
+
let number;
|
|
981
|
+
if (/^\$[0-9]+$/u.test(token)) number = Number(token.slice(1));
|
|
982
|
+
else if (token === "?") {
|
|
983
|
+
number = nextUnnamed;
|
|
984
|
+
nextUnnamed += 1;
|
|
985
|
+
} else if (/^\?[0-9]+$/u.test(token)) number = Number(token.slice(1));
|
|
986
|
+
if (number === undefined) continue;
|
|
987
|
+
if (!Number.isSafeInteger(number) || number < 1)
|
|
988
|
+
throw new SqlValidationError("parameter numbering starts at 1");
|
|
989
|
+
seen.add(number);
|
|
990
|
+
highest = Math.max(highest, number);
|
|
991
|
+
}
|
|
992
|
+
if (highest !== parameterCount || seen.size !== parameterCount)
|
|
993
|
+
throw new SqlValidationError(
|
|
994
|
+
`SQL has ${highest} positional parameter slots but ${parameterCount} values were supplied`,
|
|
995
|
+
);
|
|
996
|
+
}
|