@onreza/sqlx-js 0.7.0 → 0.8.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/README.md +27 -2
- package/ROADMAP.md +3 -2
- package/dist/bin/sqlx-js.js +80 -7
- package/dist/src/cache.d.ts +3 -2
- package/dist/src/cache.js +26 -74
- package/dist/src/codegen.js +3 -1
- package/dist/src/commands/ci.d.ts +28 -0
- package/dist/src/commands/ci.js +103 -0
- package/dist/src/commands/init.js +1 -0
- package/dist/src/commands/prepare.js +23 -18
- package/dist/src/runtime.js +9 -3
- package/dist/src/scan/scanner.js +21 -0
- package/dist/src/sql-lex.d.ts +7 -0
- package/dist/src/sql-lex.js +76 -0
- package/dist/src/sql-params.d.ts +11 -0
- package/dist/src/sql-params.js +101 -0
- package/dist/src/typed.d.ts +2 -2
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -53,11 +53,13 @@ const rows = await sql(
|
|
|
53
53
|
|
|
54
54
|
```bash
|
|
55
55
|
npm install @onreza/sqlx-js
|
|
56
|
+
npm install --save-dev typescript
|
|
56
57
|
# or
|
|
57
58
|
bun add @onreza/sqlx-js
|
|
59
|
+
bun add --dev typescript
|
|
58
60
|
```
|
|
59
61
|
|
|
60
|
-
Node.js 24 or newer is required. Bun users need Bun 1.3 or newer.
|
|
62
|
+
Node.js 24 or newer is required. Bun users need Bun 1.3 or newer. TypeScript is an optional peer so production-only installs do not pull the compiler and its platform package into the application image; source scanning commands (`prepare`, `doctor`, `ci`, and migration development/verification) require it in development dependencies.
|
|
61
63
|
|
|
62
64
|
The package installs `sqlx-js` and `sqlx-js-diagnostics` binaries. The CLI examples below use `npx @onreza/sqlx-js`; `bunx @onreza/sqlx-js ...` works the same if your project uses Bun.
|
|
63
65
|
|
|
@@ -112,6 +114,20 @@ CREATE TABLE users (
|
|
|
112
114
|
);
|
|
113
115
|
```
|
|
114
116
|
|
|
117
|
+
For queries with several values, named parameters keep the SQL and arguments aligned:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const rows = await sql(
|
|
121
|
+
`SELECT id, name
|
|
122
|
+
FROM users
|
|
123
|
+
WHERE email = $email OR recovery_email = $email
|
|
124
|
+
LIMIT $limit`,
|
|
125
|
+
{ email: "user@example.com", limit: 10 },
|
|
126
|
+
);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Named parameters use ASCII identifier names (`$user_id`), are numbered by first appearance before PostgreSQL sees the query, and repeated names reuse the same positional parameter. The generated object contract rejects missing, extra, and incorrectly typed properties. Named `$name` and positional `$1` parameters cannot be mixed in one query. Quoted strings, comments, dollar-quoted bodies, and `$` inside PostgreSQL identifiers are left unchanged. Positional parameters remain supported and are still the shortest form for simple queries.
|
|
130
|
+
|
|
115
131
|
During local development, validate the migration and regenerate query artifacts against a disposable shadow database:
|
|
116
132
|
|
|
117
133
|
```bash
|
|
@@ -411,10 +427,11 @@ In addition to `import { sql } from "@onreza/sqlx-js"`, the scanner recognises `
|
|
|
411
427
|
```
|
|
412
428
|
sqlx-js init [--root <dir>] [--schema-provider builtin|pgschema]
|
|
413
429
|
sqlx-js doctor [--root <dir>] [--dts <path>] [--json]
|
|
430
|
+
sqlx-js ci [--root <dir>] [--dts <path>] [--schema <path>] [--json] [--shadow-url <url>] [--shadow-admin-url <url>]
|
|
414
431
|
sqlx-js db install | check [--root <dir>]
|
|
415
432
|
sqlx-js db plan | apply [--root <dir>] [-- <pgschema args>]
|
|
416
433
|
sqlx-js prepare [--check | --offline | --verify | --watch] [--json | --jsonl] [--strict-inference] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]
|
|
417
|
-
sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] [--pg-dump <path>] [--lock-timeout <ms>] | archive list | archive restore <name> [--force]
|
|
434
|
+
sqlx-js migrate dev [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] [--pg-dump <path>] [--lock-timeout <ms>] | archive list | archive restore <name> [--force]
|
|
418
435
|
sqlx-js schema dump [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>]
|
|
419
436
|
sqlx-js schema check [--schema <path>] [--shadow-url <url>]
|
|
420
437
|
sqlx-js --version | --help
|
|
@@ -680,6 +697,14 @@ The runtime strips the `!`/`?` suffix from column keys so the row shape stays cl
|
|
|
680
697
|
|
|
681
698
|
## CI workflow
|
|
682
699
|
|
|
700
|
+
The shortest production gate is provider-aware:
|
|
701
|
+
|
|
702
|
+
```bash
|
|
703
|
+
sqlx-js ci
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
For the built-in migration provider it runs shadow migration verification with strict inference, followed by the read-only offline artifact check. For pgschema it checks the configured provider, fails when the desired schema produces an unapplied plan, performs live `prepare --verify --strict-inference`, and then verifies committed artifacts offline. If a committed schema snapshot exists, both flows also run `schema check`. `--json` returns a versioned per-step report suitable for CI systems.
|
|
707
|
+
|
|
683
708
|
Commit the generated `sqlx-js-env.d.ts` and the `.sqlx-js/` cache directory to your repo. In CI:
|
|
684
709
|
|
|
685
710
|
```yaml
|
package/ROADMAP.md
CHANGED
|
@@ -7,11 +7,12 @@ Items already shipped live in the [README](./README.md) feature list; this file
|
|
|
7
7
|
| Feature | ROI | Notes |
|
|
8
8
|
|---------|-----|-------|
|
|
9
9
|
| pgschema integration hardening | 6 | Managed install/check/plan/apply now has a real pinned-binary PostgreSQL E2E in CI. Continue with schema snapshot handoff and migration guidance for projects that outgrow built-in migrations. |
|
|
10
|
+
| Separate runtime package | Deferred | The audited root import already excludes compile-time modules. Making TypeScript an optional peer reduced a clean production install from about 33 MB to 2.4 MB; a second public package and release boundary is not justified for the remaining analyzer dependency unless production consumers demonstrate measurable pressure. |
|
|
10
11
|
| Built-in migration lifecycle maintenance | 5 | Keep `migrate run/dev/verify/revert/squash/archive` stable for simple projects and application startup, but avoid expanding it into a full PostgreSQL schema-as-code system. |
|
|
11
12
|
| Prisma migration assistant | 7 | Import Prisma Migrate SQL history and Prisma TypedSQL/raw SQL into `sqlx-js`; classify Prisma Client CRUD/nested-write sites as assisted/manual instead of promising a fully automatic ORM rewrite. |
|
|
12
13
|
| Self-join precision (unqualified ColumnRef) | 4 | `SELECT name FROM users u1 JOIN users u2 ON ...` with unqualified `name` can't be attributed to a specific alias. PG would reject ambiguous unqualified refs anyway, but explicit aliasing currently has no narrowing benefit in self-joins. |
|
|
13
14
|
| Tagged-template literal API (`` sql`SELECT ${x}` ``) | 8 | Restoring sqlx's inline-SQL aesthetic requires either a TS compiler plugin (`ts-patch`) or a Bun preload-time AST rewriter. TS itself hardcodes the first tag argument as `TemplateStringsArray` and refuses to narrow to literal tuples. Significant effort, large UX win. |
|
|
14
|
-
|
|
|
15
|
+
| Editor integration / LSP | Deferred | Keep the versioned batch JSON, incremental `prepare --watch --jsonl`, and `sqlx-js-diagnostics` transport stable, but do not build or maintain a VS Code extension or full LSP until real consumer demand justifies the separate editor clients and release lifecycle. |
|
|
15
16
|
| Schema-aware `jsonb` runtime validation | 5 | Optional opt-in: pass a Zod / Valibot / ArkType schema, validate rows on read. Currently we are compile-time-only by design. |
|
|
16
17
|
| MySQL backend | 5 | Some runtime clients support it, but MySQL has no `Describe Statement` equivalent. Would need a real SQL parser pass + `INFORMATION_SCHEMA` introspection. |
|
|
17
18
|
| SQLite backend | 4 | SQLite's column types are dynamic. Would require running `EXPLAIN` and a heuristic mapper, or schema-driven inference per-statement. |
|
|
@@ -21,6 +22,6 @@ Items already shipped live in the [README](./README.md) feature list; this file
|
|
|
21
22
|
|
|
22
23
|
## Long-term
|
|
23
24
|
|
|
24
|
-
-
|
|
25
|
+
- Editor clients or a full LSP only after repeated consumer demand demonstrates that the separate maintenance and release lifecycle will pay for itself.
|
|
25
26
|
- Hooks for ORM-like helpers that build on top of the typed `sql()` primitive (joins, paginated queries, etc.) without becoming an ORM.
|
|
26
27
|
- Optional binary protocol support in the underlying wire client for measurable perf gain on large result sets.
|
package/dist/bin/sqlx-js.js
CHANGED
|
@@ -22,10 +22,11 @@ const HELP = {
|
|
|
22
22
|
usage:
|
|
23
23
|
sqlx-js init [--root <dir>] [--schema-provider builtin|pgschema]
|
|
24
24
|
sqlx-js doctor [--root <dir>] [--dts <path>] [--json]
|
|
25
|
+
sqlx-js ci [--root <dir>] [--dts <path>] [--schema <path>] [--json] [--shadow-url <url>] [--shadow-admin-url <url>]
|
|
25
26
|
sqlx-js db install | check [--root <dir>]
|
|
26
27
|
sqlx-js db plan | apply [--root <dir>] [-- <pgschema args>]
|
|
27
28
|
sqlx-js prepare [--check | --offline | --verify | --watch] [--json | --jsonl] [--strict-inference] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]
|
|
28
|
-
sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] [--pg-dump <path>] [--lock-timeout <ms>] | archive list | archive restore <name> [--force]
|
|
29
|
+
sqlx-js migrate dev [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] [--pg-dump <path>] [--lock-timeout <ms>] | archive list | archive restore <name> [--force]
|
|
29
30
|
sqlx-js schema dump [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>]
|
|
30
31
|
sqlx-js schema check [--schema <path>] [--shadow-url <url>]
|
|
31
32
|
sqlx-js --version
|
|
@@ -62,9 +63,10 @@ flags:
|
|
|
62
63
|
`,
|
|
63
64
|
init: `usage: sqlx-js init [--root <dir>] [--schema-provider builtin|pgschema]`,
|
|
64
65
|
doctor: `usage: sqlx-js doctor [--root <dir>] [--dts <path>] [--json]`,
|
|
66
|
+
ci: `usage: sqlx-js ci [--root <dir>] [--dts <path>] [--schema <path>] [--json] [--shadow-url <url>] [--shadow-admin-url <url>] [--migrations <dir>]`,
|
|
65
67
|
db: `usage: sqlx-js db install | check [--root <dir>] | plan | apply [--root <dir>] [-- <pgschema args>]`,
|
|
66
68
|
prepare: `usage: sqlx-js prepare [--check | --offline | --verify | --watch] [--json | --jsonl] [--strict-inference] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]`,
|
|
67
|
-
migrate: `usage: sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] [--pg-dump <path>] [--lock-timeout <ms>] | archive list | archive restore <name> [--force]`,
|
|
69
|
+
migrate: `usage: sqlx-js migrate dev [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | verify [--dts <path>] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] [--strict-inference] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] [--pg-dump <path>] [--lock-timeout <ms>] | archive list | archive restore <name> [--force]`,
|
|
68
70
|
schema: `usage: sqlx-js schema dump [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>] | check [--schema <path>] [--shadow-url <url>]`,
|
|
69
71
|
};
|
|
70
72
|
function printHelp(scope, error = false) {
|
|
@@ -84,7 +86,7 @@ const passthroughIndex = rawArgv.indexOf("--");
|
|
|
84
86
|
const cliArgv = passthroughIndex >= 0 ? rawArgv.slice(0, passthroughIndex) : rawArgv;
|
|
85
87
|
const passthroughArgs = passthroughIndex >= 0 ? rawArgv.slice(passthroughIndex + 1) : [];
|
|
86
88
|
const cmd = cliArgv[0];
|
|
87
|
-
const scopes = new Set(["init", "doctor", "db", "prepare", "migrate", "schema"]);
|
|
89
|
+
const scopes = new Set(["init", "doctor", "ci", "db", "prepare", "migrate", "schema"]);
|
|
88
90
|
if (cmd === "--version" || cmd === "-v") {
|
|
89
91
|
console.log(VERSION);
|
|
90
92
|
process.exit(0);
|
|
@@ -107,6 +109,16 @@ function optionsFor(command, subcommand) {
|
|
|
107
109
|
return { ...ROOT_OPTIONS, "schema-provider": { type: "string" } };
|
|
108
110
|
if (command === "doctor")
|
|
109
111
|
return { ...ROOT_OPTIONS, dts: { type: "string" }, json: { type: "boolean" } };
|
|
112
|
+
if (command === "ci")
|
|
113
|
+
return {
|
|
114
|
+
...ROOT_OPTIONS,
|
|
115
|
+
json: { type: "boolean" },
|
|
116
|
+
dts: { type: "string" },
|
|
117
|
+
schema: { type: "string" },
|
|
118
|
+
migrations: { type: "string" },
|
|
119
|
+
"shadow-url": { type: "string" },
|
|
120
|
+
"shadow-admin-url": { type: "string" },
|
|
121
|
+
};
|
|
110
122
|
if (command === "db")
|
|
111
123
|
return ROOT_OPTIONS;
|
|
112
124
|
if (command === "prepare") {
|
|
@@ -145,6 +157,7 @@ function optionsFor(command, subcommand) {
|
|
|
145
157
|
if (subcommand === "dev") {
|
|
146
158
|
return {
|
|
147
159
|
...common,
|
|
160
|
+
dts: { type: "string" },
|
|
148
161
|
"shadow-admin-url": { type: "string" },
|
|
149
162
|
"shadow-url": { type: "string" },
|
|
150
163
|
"lock-timeout": { type: "string" },
|
|
@@ -155,6 +168,7 @@ function optionsFor(command, subcommand) {
|
|
|
155
168
|
if (subcommand === "verify") {
|
|
156
169
|
return {
|
|
157
170
|
...common,
|
|
171
|
+
dts: { type: "string" },
|
|
158
172
|
"shadow-admin-url": { type: "string" },
|
|
159
173
|
"shadow-url": { type: "string" },
|
|
160
174
|
"lock-timeout": { type: "string" },
|
|
@@ -214,7 +228,7 @@ function requirePositionals(min, max, label) {
|
|
|
214
228
|
}
|
|
215
229
|
}
|
|
216
230
|
function validateInvocation() {
|
|
217
|
-
if (cmd === "init" || cmd === "doctor" || cmd === "prepare") {
|
|
231
|
+
if (cmd === "init" || cmd === "doctor" || cmd === "ci" || cmd === "prepare") {
|
|
218
232
|
requirePositionals(0, 0, cmd);
|
|
219
233
|
return;
|
|
220
234
|
}
|
|
@@ -264,17 +278,55 @@ function validateInvocation() {
|
|
|
264
278
|
}
|
|
265
279
|
validateInvocation();
|
|
266
280
|
const root = resolve(arg("--root", process.cwd()));
|
|
281
|
+
function failCiPreflight(message) {
|
|
282
|
+
if (cmd === "ci" && flag("--json")) {
|
|
283
|
+
console.log(JSON.stringify({
|
|
284
|
+
formatVersion: 1,
|
|
285
|
+
ok: false,
|
|
286
|
+
results: [{ name: "preflight", ok: false, durationMs: 0, exitCode: 2, stderr: message }],
|
|
287
|
+
}, null, 2));
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
console.error(message);
|
|
291
|
+
}
|
|
292
|
+
process.exit(2);
|
|
293
|
+
}
|
|
267
294
|
if (cmd !== "doctor") {
|
|
268
295
|
try {
|
|
269
296
|
assertSupportedRuntime();
|
|
270
297
|
}
|
|
271
298
|
catch (e) {
|
|
272
|
-
|
|
299
|
+
failCiPreflight(e.message);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const needsTypeScript = cmd === "doctor" ||
|
|
303
|
+
cmd === "ci" ||
|
|
304
|
+
cmd === "prepare" ||
|
|
305
|
+
(cmd === "migrate" && (positionals[0] === "dev" || positionals[0] === "verify"));
|
|
306
|
+
if (needsTypeScript) {
|
|
307
|
+
try {
|
|
308
|
+
import.meta.resolve("typescript");
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
const message = "sqlx-js: TypeScript is required for source scanning. Install it with `npm install --save-dev typescript` or `bun add --dev typescript`.";
|
|
312
|
+
if (cmd === "doctor" && flag("--json")) {
|
|
313
|
+
console.log(JSON.stringify({
|
|
314
|
+
formatVersion: 1,
|
|
315
|
+
ok: false,
|
|
316
|
+
checks: [{ name: "typescript", status: "error", message }],
|
|
317
|
+
}, null, 2));
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
if (cmd === "ci")
|
|
321
|
+
failCiPreflight(message);
|
|
322
|
+
console.error(message);
|
|
323
|
+
}
|
|
273
324
|
process.exit(2);
|
|
274
325
|
}
|
|
275
326
|
}
|
|
276
327
|
let envError;
|
|
277
328
|
const needsEnvironment = cmd === "doctor" ||
|
|
329
|
+
cmd === "ci" ||
|
|
278
330
|
cmd === "schema" ||
|
|
279
331
|
(cmd === "db" && (positionals[0] === "plan" || positionals[0] === "apply")) ||
|
|
280
332
|
(cmd === "prepare" && !flag("--check") && !flag("--offline")) ||
|
|
@@ -286,8 +338,7 @@ if (needsEnvironment) {
|
|
|
286
338
|
catch (e) {
|
|
287
339
|
envError = e.message;
|
|
288
340
|
if (cmd !== "doctor") {
|
|
289
|
-
|
|
290
|
-
process.exit(2);
|
|
341
|
+
failCiPreflight(envError);
|
|
291
342
|
}
|
|
292
343
|
}
|
|
293
344
|
}
|
|
@@ -316,6 +367,28 @@ else if (cmd === "doctor") {
|
|
|
316
367
|
const { runDoctor } = await import("../src/commands/doctor.js");
|
|
317
368
|
await runDoctor({ root, databaseUrl, cacheDir, dtsPath, json: flag("--json"), envError });
|
|
318
369
|
}
|
|
370
|
+
else if (cmd === "ci") {
|
|
371
|
+
const { runCi } = await import("../src/commands/ci.js");
|
|
372
|
+
let config;
|
|
373
|
+
try {
|
|
374
|
+
config = await loadConfig(root);
|
|
375
|
+
}
|
|
376
|
+
catch (error) {
|
|
377
|
+
failCiPreflight(error.message);
|
|
378
|
+
}
|
|
379
|
+
runCi({
|
|
380
|
+
executable: process.execPath,
|
|
381
|
+
cliPath: fileURLToPath(import.meta.url),
|
|
382
|
+
root,
|
|
383
|
+
config,
|
|
384
|
+
schemaPath,
|
|
385
|
+
json: flag("--json"),
|
|
386
|
+
shadowUrl,
|
|
387
|
+
shadowAdminUrl,
|
|
388
|
+
migrationsDir: arg("--migrations"),
|
|
389
|
+
dtsPath: dtsArg ? dtsPath : undefined,
|
|
390
|
+
});
|
|
391
|
+
}
|
|
319
392
|
else if (cmd === "db") {
|
|
320
393
|
const { runPgschemaCommand, runPgschemaInstall } = await import("../src/commands/pgschema.js");
|
|
321
394
|
const sub = positionals[0];
|
package/dist/src/cache.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export declare const CACHE_FORMAT_VERSION =
|
|
2
|
-
export declare const GENERATOR_REVISION =
|
|
1
|
+
export declare const CACHE_FORMAT_VERSION = 3;
|
|
2
|
+
export declare const GENERATOR_REVISION = 5;
|
|
3
3
|
export declare const CACHE_MANIFEST_FILE = "cache-manifest.json";
|
|
4
4
|
export type CacheManifest = {
|
|
5
5
|
cacheFormat: typeof CACHE_FORMAT_VERSION;
|
|
@@ -19,6 +19,7 @@ export type CacheEntry = {
|
|
|
19
19
|
paramOids: number[];
|
|
20
20
|
paramTsTypes: string[];
|
|
21
21
|
paramNullable?: boolean[];
|
|
22
|
+
paramNames?: string[];
|
|
22
23
|
columns: CacheColumn[];
|
|
23
24
|
hasResultSet: boolean;
|
|
24
25
|
hasInline?: boolean;
|
package/dist/src/cache.js
CHANGED
|
@@ -2,8 +2,10 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { isBuiltinOid } from "./pg/oids.js";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { isEscapeStringPrefix, isIdentifierContinuation, readBlockComment, readDollarQuoted, readLineComment, readQuotedIdentifier, readSingleQuoted, } from "./sql-lex.js";
|
|
6
|
+
import { rewriteNamedParameters } from "./sql-params.js";
|
|
7
|
+
export const CACHE_FORMAT_VERSION = 3;
|
|
8
|
+
export const GENERATOR_REVISION = 5;
|
|
7
9
|
export const CACHE_MANIFEST_FILE = "cache-manifest.json";
|
|
8
10
|
export function portableCacheOid(oid) {
|
|
9
11
|
return isBuiltinOid(oid) ? oid : 0;
|
|
@@ -56,7 +58,7 @@ function normalizeForFingerprint(query) {
|
|
|
56
58
|
continue;
|
|
57
59
|
}
|
|
58
60
|
if (ch === "$") {
|
|
59
|
-
const next = readDollarQuoted(query, i);
|
|
61
|
+
const next = i === 0 || !isIdentifierContinuation(query[i - 1]) ? readDollarQuoted(query, i) : null;
|
|
60
62
|
if (next !== null) {
|
|
61
63
|
emit(query.slice(i, next));
|
|
62
64
|
i = next;
|
|
@@ -68,77 +70,6 @@ function normalizeForFingerprint(query) {
|
|
|
68
70
|
}
|
|
69
71
|
return out;
|
|
70
72
|
}
|
|
71
|
-
function readSingleQuoted(query, start, escapeBackslash) {
|
|
72
|
-
let i = start + 1;
|
|
73
|
-
while (i < query.length) {
|
|
74
|
-
const ch = query[i];
|
|
75
|
-
if (escapeBackslash && ch === "\\") {
|
|
76
|
-
i += 2;
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (ch === "'") {
|
|
80
|
-
if (query[i + 1] === "'") {
|
|
81
|
-
i += 2;
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
return i + 1;
|
|
85
|
-
}
|
|
86
|
-
i++;
|
|
87
|
-
}
|
|
88
|
-
return query.length;
|
|
89
|
-
}
|
|
90
|
-
function readQuotedIdentifier(query, start) {
|
|
91
|
-
let i = start + 1;
|
|
92
|
-
while (i < query.length) {
|
|
93
|
-
if (query[i] === "\"") {
|
|
94
|
-
if (query[i + 1] === "\"") {
|
|
95
|
-
i += 2;
|
|
96
|
-
continue;
|
|
97
|
-
}
|
|
98
|
-
return i + 1;
|
|
99
|
-
}
|
|
100
|
-
i++;
|
|
101
|
-
}
|
|
102
|
-
return query.length;
|
|
103
|
-
}
|
|
104
|
-
function readDollarQuoted(query, start) {
|
|
105
|
-
let tagEnd = start + 1;
|
|
106
|
-
while (tagEnd < query.length && /[A-Za-z0-9_]/.test(query[tagEnd]))
|
|
107
|
-
tagEnd++;
|
|
108
|
-
if (query[tagEnd] !== "$")
|
|
109
|
-
return null;
|
|
110
|
-
const tag = query.slice(start, tagEnd + 1);
|
|
111
|
-
const end = query.indexOf(tag, tagEnd + 1);
|
|
112
|
-
return end === -1 ? query.length : end + tag.length;
|
|
113
|
-
}
|
|
114
|
-
function readLineComment(query, start) {
|
|
115
|
-
const end = query.indexOf("\n", start + 2);
|
|
116
|
-
return end === -1 ? query.length : end + 1;
|
|
117
|
-
}
|
|
118
|
-
function readBlockComment(query, start) {
|
|
119
|
-
let depth = 1;
|
|
120
|
-
let i = start + 2;
|
|
121
|
-
while (i < query.length && depth > 0) {
|
|
122
|
-
if (query[i] === "/" && query[i + 1] === "*") {
|
|
123
|
-
depth++;
|
|
124
|
-
i += 2;
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
if (query[i] === "*" && query[i + 1] === "/") {
|
|
128
|
-
depth--;
|
|
129
|
-
i += 2;
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
i++;
|
|
133
|
-
}
|
|
134
|
-
return i;
|
|
135
|
-
}
|
|
136
|
-
function isEscapeStringPrefix(query, quoteIndex) {
|
|
137
|
-
if (quoteIndex === 0 || query[quoteIndex - 1]?.toLowerCase() !== "e")
|
|
138
|
-
return false;
|
|
139
|
-
const beforePrefix = query[quoteIndex - 2];
|
|
140
|
-
return beforePrefix === undefined || !/[A-Za-z0-9_$]/.test(beforePrefix);
|
|
141
|
-
}
|
|
142
73
|
export function effectiveNullable(c) {
|
|
143
74
|
if (c.override === "non-null")
|
|
144
75
|
return false;
|
|
@@ -166,6 +97,27 @@ function assertEntryShape(fp, raw) {
|
|
|
166
97
|
throw new Error(`sqlx-js: cache entry ${fp}.json is malformed`);
|
|
167
98
|
}
|
|
168
99
|
const cols = raw.columns;
|
|
100
|
+
const entry = raw;
|
|
101
|
+
let expectedNames;
|
|
102
|
+
try {
|
|
103
|
+
if (typeof entry.query !== "string")
|
|
104
|
+
throw new Error("query must be a string");
|
|
105
|
+
expectedNames = rewriteNamedParameters(entry.query).names;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
throw new Error(`sqlx-js: cache entry ${fp}.json has malformed named parameter metadata. Run \`sqlx-js prepare\`.`);
|
|
109
|
+
}
|
|
110
|
+
if (entry.paramNames !== undefined || expectedNames.length > 0) {
|
|
111
|
+
if (!Array.isArray(entry.paramNames) ||
|
|
112
|
+
!entry.paramNames.every((name) => typeof name === "string") ||
|
|
113
|
+
!Array.isArray(entry.paramTsTypes) ||
|
|
114
|
+
entry.paramNames.length !== entry.paramTsTypes.length ||
|
|
115
|
+
new Set(entry.paramNames).size !== entry.paramNames.length ||
|
|
116
|
+
entry.paramNames.length !== expectedNames.length ||
|
|
117
|
+
entry.paramNames.some((name, index) => name !== expectedNames[index])) {
|
|
118
|
+
throw new Error(`sqlx-js: cache entry ${fp}.json has malformed named parameter metadata. Run \`sqlx-js prepare\`.`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
169
121
|
if (cols.length > 0) {
|
|
170
122
|
const c = cols[0];
|
|
171
123
|
if ("forceNonNull" in c || "forceNullable" in c) {
|
package/dist/src/codegen.js
CHANGED
|
@@ -7,7 +7,9 @@ function entrySignature(e) {
|
|
|
7
7
|
const nullable = e.paramNullable?.[i] === true;
|
|
8
8
|
return nullable ? `${t} | null` : t;
|
|
9
9
|
});
|
|
10
|
-
const params =
|
|
10
|
+
const params = e.paramNames && e.paramNames.length > 0
|
|
11
|
+
? `{ ${e.paramNames.map((name, index) => `${JSON.stringify(name)}: ${paramTypes[index]}`).join("; ")} }`
|
|
12
|
+
: paramTypes.length === 0 ? "[]" : `[${paramTypes.join(", ")}]`;
|
|
11
13
|
if (!e.hasResultSet)
|
|
12
14
|
return { params, row: "never" };
|
|
13
15
|
const cols = e.columns.map((c) => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SqlxJsConfig } from "../config.js";
|
|
2
|
+
export type CiStep = {
|
|
3
|
+
name: string;
|
|
4
|
+
args: string[];
|
|
5
|
+
check?: "pgschema-plan";
|
|
6
|
+
};
|
|
7
|
+
export type CiStepResult = {
|
|
8
|
+
name: string;
|
|
9
|
+
ok: boolean;
|
|
10
|
+
durationMs: number;
|
|
11
|
+
exitCode: number;
|
|
12
|
+
stderr?: string;
|
|
13
|
+
};
|
|
14
|
+
export type CiOptions = {
|
|
15
|
+
executable: string;
|
|
16
|
+
cliPath: string;
|
|
17
|
+
root: string;
|
|
18
|
+
config: SqlxJsConfig;
|
|
19
|
+
schemaPath: string;
|
|
20
|
+
json?: boolean;
|
|
21
|
+
shadowUrl?: string;
|
|
22
|
+
shadowAdminUrl?: string;
|
|
23
|
+
migrationsDir?: string;
|
|
24
|
+
dtsPath?: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function buildCiSteps(opts: Pick<CiOptions, "config" | "root" | "schemaPath" | "shadowUrl" | "shadowAdminUrl" | "migrationsDir" | "dtsPath">): CiStep[];
|
|
27
|
+
export declare function assertPgschemaPlanClean(path: string): void;
|
|
28
|
+
export declare function runCi(opts: CiOptions): void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
const PLAN_PATH = "{sqlx-js-ci-plan}";
|
|
6
|
+
export function buildCiSteps(opts) {
|
|
7
|
+
const root = ["--root", opts.root];
|
|
8
|
+
const shadow = opts.shadowUrl ? ["--shadow-url", opts.shadowUrl] : [];
|
|
9
|
+
const shadowAdmin = opts.shadowAdminUrl ? ["--shadow-admin-url", opts.shadowAdminUrl] : [];
|
|
10
|
+
const migrations = opts.migrationsDir ? ["--migrations", opts.migrationsDir] : [];
|
|
11
|
+
const dts = opts.dtsPath ? ["--dts", opts.dtsPath] : [];
|
|
12
|
+
const steps = opts.config.schema?.provider === "pgschema"
|
|
13
|
+
? [
|
|
14
|
+
{ name: "pgschema", args: ["db", "check", ...root] },
|
|
15
|
+
{
|
|
16
|
+
name: "pgschema-plan",
|
|
17
|
+
args: ["db", "plan", ...root, "--", "--output-json", PLAN_PATH, "--no-color"],
|
|
18
|
+
check: "pgschema-plan",
|
|
19
|
+
},
|
|
20
|
+
{ name: "prepare-live", args: ["prepare", "--verify", "--strict-inference", ...shadow, ...dts, ...root] },
|
|
21
|
+
]
|
|
22
|
+
: [{
|
|
23
|
+
name: "migrations",
|
|
24
|
+
args: ["migrate", "verify", "--strict-inference", ...shadow, ...shadowAdmin, ...migrations, ...dts, ...root],
|
|
25
|
+
}];
|
|
26
|
+
steps.push({ name: "prepare-offline", args: ["prepare", "--check", "--strict-inference", ...dts, ...root] });
|
|
27
|
+
if (existsSync(opts.schemaPath)) {
|
|
28
|
+
steps.push({ name: "schema", args: ["schema", "check", ...shadow, "--schema", opts.schemaPath, ...migrations, ...root] });
|
|
29
|
+
}
|
|
30
|
+
return steps;
|
|
31
|
+
}
|
|
32
|
+
export function assertPgschemaPlanClean(path) {
|
|
33
|
+
const plan = JSON.parse(readFileSync(path, "utf8"));
|
|
34
|
+
if (plan.groups === null)
|
|
35
|
+
return;
|
|
36
|
+
if (!Array.isArray(plan.groups))
|
|
37
|
+
throw new Error("plan JSON does not contain groups as an array or null");
|
|
38
|
+
if (plan.groups.length > 0)
|
|
39
|
+
throw new Error(`pgschema plan contains ${plan.groups.length} unapplied change group(s)`);
|
|
40
|
+
}
|
|
41
|
+
export function runCi(opts) {
|
|
42
|
+
const results = [];
|
|
43
|
+
const temp = mkdtempSync(join(tmpdir(), "sqlx-js-ci-"));
|
|
44
|
+
try {
|
|
45
|
+
for (const step of buildCiSteps(opts)) {
|
|
46
|
+
if (!opts.json)
|
|
47
|
+
console.log(`ci: ${step.name}`);
|
|
48
|
+
const started = performance.now();
|
|
49
|
+
const planPath = join(temp, "pgschema-plan.json");
|
|
50
|
+
const args = step.args.map((arg) => arg === PLAN_PATH ? planPath : arg);
|
|
51
|
+
const result = spawnSync(opts.executable, [opts.cliPath, ...args], {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
env: process.env,
|
|
54
|
+
stdio: opts.json ? ["ignore", "ignore", "pipe"] : "inherit",
|
|
55
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
56
|
+
});
|
|
57
|
+
let exitCode = result.status ?? 1;
|
|
58
|
+
let checkError;
|
|
59
|
+
if (exitCode === 0 && step.check === "pgschema-plan") {
|
|
60
|
+
try {
|
|
61
|
+
assertPgschemaPlanClean(planPath);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
checkError = error.message.startsWith("pgschema plan contains")
|
|
65
|
+
? error.message
|
|
66
|
+
: `cannot verify pgschema plan: ${error.message}`;
|
|
67
|
+
exitCode = 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (result.error) {
|
|
71
|
+
checkError = result.error.message;
|
|
72
|
+
exitCode = 1;
|
|
73
|
+
}
|
|
74
|
+
else if (result.signal) {
|
|
75
|
+
checkError = `terminated by signal ${result.signal}`;
|
|
76
|
+
exitCode = 1;
|
|
77
|
+
}
|
|
78
|
+
if (checkError && !opts.json)
|
|
79
|
+
console.error(`ci: ${step.name}: ${checkError}`);
|
|
80
|
+
results.push({
|
|
81
|
+
name: step.name,
|
|
82
|
+
ok: exitCode === 0,
|
|
83
|
+
durationMs: Math.round(performance.now() - started),
|
|
84
|
+
exitCode,
|
|
85
|
+
...(opts.json && exitCode !== 0 && (checkError || result.stderr)
|
|
86
|
+
? { stderr: [result.stderr?.trim(), checkError].filter(Boolean).join("\n") }
|
|
87
|
+
: {}),
|
|
88
|
+
});
|
|
89
|
+
if (exitCode !== 0)
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
rmSync(temp, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
const ok = results.length > 0 && results.every((result) => result.ok);
|
|
97
|
+
if (opts.json)
|
|
98
|
+
console.log(JSON.stringify({ formatVersion: 1, ok, results }, null, 2));
|
|
99
|
+
else if (ok)
|
|
100
|
+
console.log(`ci: ok — ${results.length} check(s) passed`);
|
|
101
|
+
if (!ok)
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
}
|
|
@@ -143,6 +143,7 @@ export function runInit(opts) {
|
|
|
143
143
|
"sqlx:check": "sqlx-js prepare --check",
|
|
144
144
|
"sqlx:offline": "sqlx-js prepare --offline",
|
|
145
145
|
"sqlx:verify": "sqlx-js prepare --verify --strict-inference",
|
|
146
|
+
"sqlx:ci": "sqlx-js ci",
|
|
146
147
|
};
|
|
147
148
|
let changed = false;
|
|
148
149
|
for (const [name, command] of Object.entries(defaults)) {
|
|
@@ -15,6 +15,7 @@ import { buildParamMap } from "../pg/param-map.js";
|
|
|
15
15
|
import { mergeExtensionTypes } from "../pg/extensions.js";
|
|
16
16
|
import { compareArtifacts } from "../artifacts.js";
|
|
17
17
|
import { containsUnknownType } from "../type-inspection.js";
|
|
18
|
+
import { originalPosition, rewriteNamedParameters } from "../sql-params.js";
|
|
18
19
|
const JSON_OIDS = new Set([114, 3802]);
|
|
19
20
|
const JSON_ARRAY_OIDS = new Set([199, 3807]);
|
|
20
21
|
const JSON_INPUT_VALUE = 'import("@onreza/sqlx-js").JsonInputValue';
|
|
@@ -207,8 +208,9 @@ function inferenceIssues(entry) {
|
|
|
207
208
|
if (entry.degraded)
|
|
208
209
|
issues.push(`nullability inference degraded: ${entry.degraded.reason}`);
|
|
209
210
|
entry.paramTsTypes.forEach((type, index) => {
|
|
211
|
+
const parameter = entry.paramNames?.[index] ? `$${entry.paramNames[index]}` : `$${index + 1}`;
|
|
210
212
|
if (containsUnknownType(type))
|
|
211
|
-
issues.push(`parameter
|
|
213
|
+
issues.push(`parameter ${parameter} resolved to ${type}`);
|
|
212
214
|
});
|
|
213
215
|
for (const column of entry.columns) {
|
|
214
216
|
if (containsUnknownType(column.tsType)) {
|
|
@@ -225,7 +227,7 @@ function inferenceDiagnostics(entry, site, strict) {
|
|
|
225
227
|
file: site.file,
|
|
226
228
|
line: site.line,
|
|
227
229
|
column: site.column,
|
|
228
|
-
query:
|
|
230
|
+
query: site.query,
|
|
229
231
|
}));
|
|
230
232
|
}
|
|
231
233
|
export async function openSession(opts) {
|
|
@@ -326,12 +328,13 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
326
328
|
let failures = 0;
|
|
327
329
|
const unique = new Map();
|
|
328
330
|
for (const s of sites) {
|
|
331
|
+
const rewritten = rewriteNamedParameters(s.query);
|
|
329
332
|
const fp = fingerprint(s.query);
|
|
330
333
|
const existing = unique.get(fp);
|
|
331
334
|
if (existing)
|
|
332
335
|
existing.sites.push(s);
|
|
333
336
|
else
|
|
334
|
-
unique.set(fp, { fp, query:
|
|
337
|
+
unique.set(fp, { fp, query: rewritten.query, paramNames: rewritten.names, sites: [s] });
|
|
335
338
|
}
|
|
336
339
|
const raw = [];
|
|
337
340
|
const reusedEntries = [];
|
|
@@ -372,18 +375,19 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
372
375
|
file: site.file,
|
|
373
376
|
line: site.line,
|
|
374
377
|
column: site.column,
|
|
375
|
-
query,
|
|
378
|
+
query: site.query,
|
|
376
379
|
});
|
|
377
380
|
err(` ✗ ${formatSite(site)} — ${message}`);
|
|
378
|
-
err(` query: ${snippet(query)}`);
|
|
381
|
+
err(` query: ${snippet(site.query)}`);
|
|
379
382
|
continue;
|
|
380
383
|
}
|
|
381
|
-
raw.push({ fp, query, sites: ss, paramOids: outcome.paramOids, fields: outcome.fields });
|
|
384
|
+
raw.push({ fp, query, sites: ss, paramOids: outcome.paramOids, fields: outcome.fields, paramNames: toPrepare.get(fp).paramNames });
|
|
382
385
|
continue;
|
|
383
386
|
}
|
|
384
387
|
failures++;
|
|
385
388
|
const e = outcome.error;
|
|
386
389
|
if (e instanceof PgError) {
|
|
390
|
+
const position = e.position ? originalPosition(rewriteNamedParameters(site.query), e.position) : undefined;
|
|
387
391
|
diagnostics.push({
|
|
388
392
|
severity: "error",
|
|
389
393
|
phase: "describe",
|
|
@@ -391,21 +395,21 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
391
395
|
file: site.file,
|
|
392
396
|
line: site.line,
|
|
393
397
|
column: site.column,
|
|
394
|
-
query,
|
|
398
|
+
query: site.query,
|
|
395
399
|
...(e.code ? { code: e.code } : {}),
|
|
396
|
-
...(
|
|
400
|
+
...(position ? { position } : {}),
|
|
397
401
|
...(e.hint ? { hint: e.hint } : {}),
|
|
398
402
|
});
|
|
399
403
|
const extras = [];
|
|
400
|
-
if (
|
|
401
|
-
extras.push(`pos ${
|
|
404
|
+
if (position)
|
|
405
|
+
extras.push(`pos ${position}`);
|
|
402
406
|
if (e.code)
|
|
403
407
|
extras.push(`code ${e.code}`);
|
|
404
408
|
const tail = extras.length > 0 ? ` (${extras.join(", ")})` : "";
|
|
405
409
|
err(` ✗ ${formatSite(site)} — describe failed: ${e.message}${tail}`);
|
|
406
410
|
if (e.hint)
|
|
407
411
|
err(` hint: ${e.hint}`);
|
|
408
|
-
err(` query: ${snippet(query)}`);
|
|
412
|
+
err(` query: ${snippet(site.query)}`);
|
|
409
413
|
}
|
|
410
414
|
else {
|
|
411
415
|
diagnostics.push({
|
|
@@ -415,10 +419,10 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
415
419
|
file: site.file,
|
|
416
420
|
line: site.line,
|
|
417
421
|
column: site.column,
|
|
418
|
-
query,
|
|
422
|
+
query: site.query,
|
|
419
423
|
});
|
|
420
424
|
err(` ✗ ${formatSite(site)} — describe failed: ${e.message}`);
|
|
421
|
-
err(` query: ${snippet(query)}`);
|
|
425
|
+
err(` query: ${snippet(site.query)}`);
|
|
422
426
|
}
|
|
423
427
|
}
|
|
424
428
|
const allAttrRefs = [];
|
|
@@ -456,10 +460,10 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
456
460
|
file: site.file,
|
|
457
461
|
line: site.line,
|
|
458
462
|
column: site.column,
|
|
459
|
-
query:
|
|
463
|
+
query: site.query,
|
|
460
464
|
});
|
|
461
465
|
err(` ✗ ${formatSite(site)} — analyze failed: ${e.message}`);
|
|
462
|
-
err(` query: ${snippet(
|
|
466
|
+
err(` query: ${snippet(site.query)}`);
|
|
463
467
|
continue;
|
|
464
468
|
}
|
|
465
469
|
try {
|
|
@@ -475,10 +479,10 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
475
479
|
file: site.file,
|
|
476
480
|
line: site.line,
|
|
477
481
|
column: site.column,
|
|
478
|
-
query:
|
|
482
|
+
query: site.query,
|
|
479
483
|
});
|
|
480
484
|
err(` ✗ ${formatSite(site)} — paramMap failed: ${e.message}`);
|
|
481
|
-
err(` query: ${snippet(
|
|
485
|
+
err(` query: ${snippet(site.query)}`);
|
|
482
486
|
}
|
|
483
487
|
}
|
|
484
488
|
const dmlTablesToLoad = new Map();
|
|
@@ -534,11 +538,12 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
534
538
|
dmlBound: new Set(),
|
|
535
539
|
};
|
|
536
540
|
const entry = {
|
|
537
|
-
query: r.query,
|
|
541
|
+
query: r.sites[0].query,
|
|
538
542
|
...siteUsage(r.sites),
|
|
539
543
|
paramOids: r.paramOids.map(portableCacheOid),
|
|
540
544
|
paramTsTypes: r.paramOids.map((o, idx) => resolveParamTs(idx + 1, o, pm.targets, schema, userCfg)),
|
|
541
545
|
paramNullable: r.paramOids.map((_o, idx) => resolveParamNullable(idx + 1, pm, schema)),
|
|
546
|
+
...(r.paramNames.length > 0 ? { paramNames: r.paramNames } : {}),
|
|
542
547
|
columns: r.fields.map((f, i) => {
|
|
543
548
|
const parsed = parseColumnOverride(f.name);
|
|
544
549
|
const treatAsOverride = parsed.override !== undefined && isAliasOrExpression(f, schema);
|
package/dist/src/runtime.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
|
|
|
2
2
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
3
3
|
import { PgClient, parseDatabaseUrl, PgError } from "./pg/wire.js";
|
|
4
4
|
import { applyPending, acquireMigrateLock, releaseMigrateLock, DEFAULT_MIGRATE_LOCK_KEY } from "./migration-core.js";
|
|
5
|
+
import { bindNamedParameters, rewriteNamedParameters } from "./sql-params.js";
|
|
5
6
|
const PARAMETER_KIND = Symbol("sqlx-js.parameter");
|
|
6
7
|
export function json(value) {
|
|
7
8
|
return { [PARAMETER_KIND]: "json", value };
|
|
@@ -242,6 +243,11 @@ export const _internal = {
|
|
|
242
243
|
toPgError,
|
|
243
244
|
};
|
|
244
245
|
async function runRawQuery(client, query, params) {
|
|
246
|
+
const observedQuery = query;
|
|
247
|
+
const observedParams = params;
|
|
248
|
+
const bound = bindNamedParameters(rewriteNamedParameters(query), params);
|
|
249
|
+
query = bound.query;
|
|
250
|
+
params = bound.params;
|
|
245
251
|
const encoded = params.length === 0
|
|
246
252
|
? params
|
|
247
253
|
: params.map((p) => client.transformParam ? client.transformParam(p) : encodeParam(p));
|
|
@@ -258,8 +264,8 @@ async function runRawQuery(client, query, params) {
|
|
|
258
264
|
try {
|
|
259
265
|
const result = await client.query(query, encoded);
|
|
260
266
|
notifyQuery(client, {
|
|
261
|
-
query,
|
|
262
|
-
params,
|
|
267
|
+
query: observedQuery,
|
|
268
|
+
params: observedParams,
|
|
263
269
|
durationMs: performance.now() - start,
|
|
264
270
|
rowCount: result.count ?? result.length,
|
|
265
271
|
});
|
|
@@ -267,7 +273,7 @@ async function runRawQuery(client, query, params) {
|
|
|
267
273
|
}
|
|
268
274
|
catch (e) {
|
|
269
275
|
const error = toPgError(e) ?? e;
|
|
270
|
-
notifyQuery(client, { query, params, durationMs: performance.now() - start, error });
|
|
276
|
+
notifyQuery(client, { query: observedQuery, params: observedParams, durationMs: performance.now() - start, error });
|
|
271
277
|
throw error;
|
|
272
278
|
}
|
|
273
279
|
}
|
package/dist/src/scan/scanner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
4
|
+
import { rewriteNamedParameters } from "../sql-params.js";
|
|
4
5
|
export class ScanError extends Error {
|
|
5
6
|
file;
|
|
6
7
|
line;
|
|
@@ -187,6 +188,16 @@ export function scanFile(absPath, root, modules = DEFAULT_SQLX_MODULES) {
|
|
|
187
188
|
throw new ScanError(fileRel, pos.line, pos.column, "sql() requires a string literal as first argument");
|
|
188
189
|
}
|
|
189
190
|
const pos = here(first);
|
|
191
|
+
let named;
|
|
192
|
+
try {
|
|
193
|
+
named = rewriteNamedParameters(first.text).names;
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw new ScanError(fileRel, pos.line, pos.column, error.message.replace(/^sqlx-js: /, ""));
|
|
197
|
+
}
|
|
198
|
+
if (named.length > 0 && args.length !== 2) {
|
|
199
|
+
throw new ScanError(fileRel, pos.line, pos.column, "a query with named parameters requires exactly one parameter object");
|
|
200
|
+
}
|
|
190
201
|
out.push({
|
|
191
202
|
file: fileRel,
|
|
192
203
|
line: pos.line,
|
|
@@ -219,6 +230,16 @@ export function scanFile(absPath, root, modules = DEFAULT_SQLX_MODULES) {
|
|
|
219
230
|
}
|
|
220
231
|
const query = readFileSync(abs, "utf8");
|
|
221
232
|
const pos = here(first);
|
|
233
|
+
let named;
|
|
234
|
+
try {
|
|
235
|
+
named = rewriteNamedParameters(query).names;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
throw new ScanError(fileRel, pos.line, pos.column, error.message.replace(/^sqlx-js: /, ""));
|
|
239
|
+
}
|
|
240
|
+
if (named.length > 0 && args.length !== 2) {
|
|
241
|
+
throw new ScanError(fileRel, pos.line, pos.column, "a SQL file with named parameters requires exactly one parameter object");
|
|
242
|
+
}
|
|
222
243
|
out.push({
|
|
223
244
|
file: fileRel,
|
|
224
245
|
line: pos.line,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare function isIdentifierContinuation(value: string): boolean;
|
|
2
|
+
export declare function isEscapeStringPrefix(query: string, quote: number): boolean;
|
|
3
|
+
export declare function readSingleQuoted(query: string, start: number, escapeBackslash: boolean): number;
|
|
4
|
+
export declare function readQuotedIdentifier(query: string, start: number): number;
|
|
5
|
+
export declare function readDollarQuoted(query: string, start: number): number | null;
|
|
6
|
+
export declare function readLineComment(query: string, start: number): number;
|
|
7
|
+
export declare function readBlockComment(query: string, start: number): number;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export function isIdentifierContinuation(value) {
|
|
2
|
+
return /[A-Za-z0-9_$\u0080-\uFFFF]/.test(value);
|
|
3
|
+
}
|
|
4
|
+
export function isEscapeStringPrefix(query, quote) {
|
|
5
|
+
const prefix = query[quote - 1];
|
|
6
|
+
if (prefix !== "e" && prefix !== "E")
|
|
7
|
+
return false;
|
|
8
|
+
const before = query[quote - 2];
|
|
9
|
+
return before === undefined || !isIdentifierContinuation(before);
|
|
10
|
+
}
|
|
11
|
+
export function readSingleQuoted(query, start, escapeBackslash) {
|
|
12
|
+
let i = start + 1;
|
|
13
|
+
while (i < query.length) {
|
|
14
|
+
if (escapeBackslash && query[i] === "\\") {
|
|
15
|
+
i += 2;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
if (query[i] === "'") {
|
|
19
|
+
if (query[i + 1] === "'") {
|
|
20
|
+
i += 2;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
return i + 1;
|
|
24
|
+
}
|
|
25
|
+
i++;
|
|
26
|
+
}
|
|
27
|
+
return query.length;
|
|
28
|
+
}
|
|
29
|
+
export function readQuotedIdentifier(query, start) {
|
|
30
|
+
let i = start + 1;
|
|
31
|
+
while (i < query.length) {
|
|
32
|
+
if (query[i] === '"') {
|
|
33
|
+
if (query[i + 1] === '"') {
|
|
34
|
+
i += 2;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
return i + 1;
|
|
38
|
+
}
|
|
39
|
+
i++;
|
|
40
|
+
}
|
|
41
|
+
return query.length;
|
|
42
|
+
}
|
|
43
|
+
export function readDollarQuoted(query, start) {
|
|
44
|
+
let end = start + 1;
|
|
45
|
+
if (query[end] !== "$" && !/[A-Za-z_\u0080-\uFFFF]/.test(query[end]))
|
|
46
|
+
return null;
|
|
47
|
+
while (end < query.length && /[A-Za-z0-9_\u0080-\uFFFF]/.test(query[end]))
|
|
48
|
+
end++;
|
|
49
|
+
if (query[end] !== "$")
|
|
50
|
+
return null;
|
|
51
|
+
const tag = query.slice(start, end + 1);
|
|
52
|
+
const close = query.indexOf(tag, end + 1);
|
|
53
|
+
return close === -1 ? query.length : close + tag.length;
|
|
54
|
+
}
|
|
55
|
+
export function readLineComment(query, start) {
|
|
56
|
+
const end = query.indexOf("\n", start + 2);
|
|
57
|
+
return end === -1 ? query.length : end + 1;
|
|
58
|
+
}
|
|
59
|
+
export function readBlockComment(query, start) {
|
|
60
|
+
let depth = 1;
|
|
61
|
+
let i = start + 2;
|
|
62
|
+
while (i < query.length && depth > 0) {
|
|
63
|
+
if (query[i] === "/" && query[i + 1] === "*") {
|
|
64
|
+
depth++;
|
|
65
|
+
i += 2;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (query[i] === "*" && query[i + 1] === "/") {
|
|
69
|
+
depth--;
|
|
70
|
+
i += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
return i;
|
|
76
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type RewrittenSql = {
|
|
2
|
+
query: string;
|
|
3
|
+
names: string[];
|
|
4
|
+
positionMap: number[];
|
|
5
|
+
};
|
|
6
|
+
export declare function rewriteNamedParameters(query: string): RewrittenSql;
|
|
7
|
+
export declare function bindNamedParameters(rewritten: RewrittenSql, args: readonly unknown[]): {
|
|
8
|
+
query: string;
|
|
9
|
+
params: unknown[];
|
|
10
|
+
};
|
|
11
|
+
export declare function originalPosition(rewritten: RewrittenSql, oneBasedPosition: number): number;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { isEscapeStringPrefix, isIdentifierContinuation, readBlockComment, readDollarQuoted, readLineComment, readQuotedIdentifier, readSingleQuoted, } from "./sql-lex.js";
|
|
2
|
+
export function rewriteNamedParameters(query) {
|
|
3
|
+
let out = "";
|
|
4
|
+
let i = 0;
|
|
5
|
+
let positional = false;
|
|
6
|
+
const names = [];
|
|
7
|
+
const indexes = new Map();
|
|
8
|
+
const positionMap = [];
|
|
9
|
+
const append = (text, sourceStart) => {
|
|
10
|
+
out += text;
|
|
11
|
+
for (let offset = 0; offset < text.length; offset++)
|
|
12
|
+
positionMap.push(sourceStart + offset);
|
|
13
|
+
};
|
|
14
|
+
while (i < query.length) {
|
|
15
|
+
const start = i;
|
|
16
|
+
const ch = query[i];
|
|
17
|
+
if (ch === "-" && query[i + 1] === "-") {
|
|
18
|
+
i = readLineComment(query, i);
|
|
19
|
+
append(query.slice(start, i), start);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (ch === "/" && query[i + 1] === "*") {
|
|
23
|
+
i = readBlockComment(query, i);
|
|
24
|
+
append(query.slice(start, i), start);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (ch === "'") {
|
|
28
|
+
i = readSingleQuoted(query, i, isEscapeStringPrefix(query, i));
|
|
29
|
+
append(query.slice(start, i), start);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (ch === '"') {
|
|
33
|
+
i = readQuotedIdentifier(query, i);
|
|
34
|
+
append(query.slice(start, i), start);
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (ch === "$") {
|
|
38
|
+
if (i > 0 && isIdentifierContinuation(query[i - 1])) {
|
|
39
|
+
append(ch, i);
|
|
40
|
+
i++;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const quotedEnd = readDollarQuoted(query, i);
|
|
44
|
+
if (quotedEnd !== null) {
|
|
45
|
+
i = quotedEnd;
|
|
46
|
+
append(query.slice(start, i), start);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const positionalMatch = /^\$[1-9][0-9]*/.exec(query.slice(i));
|
|
50
|
+
if (positionalMatch) {
|
|
51
|
+
positional = true;
|
|
52
|
+
i += positionalMatch[0].length;
|
|
53
|
+
append(positionalMatch[0], start);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const namedMatch = /^\$([A-Za-z_][A-Za-z0-9_]*)/.exec(query.slice(i));
|
|
57
|
+
if (namedMatch) {
|
|
58
|
+
const name = namedMatch[1];
|
|
59
|
+
let index = indexes.get(name);
|
|
60
|
+
if (index === undefined) {
|
|
61
|
+
names.push(name);
|
|
62
|
+
index = names.length;
|
|
63
|
+
indexes.set(name, index);
|
|
64
|
+
}
|
|
65
|
+
const replacement = `$${index}`;
|
|
66
|
+
append(replacement, start);
|
|
67
|
+
i += namedMatch[0].length;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
append(ch, i);
|
|
72
|
+
i++;
|
|
73
|
+
}
|
|
74
|
+
if (positional && names.length > 0) {
|
|
75
|
+
throw new Error("sqlx-js: named and positional parameters cannot be mixed in one query");
|
|
76
|
+
}
|
|
77
|
+
positionMap.push(query.length);
|
|
78
|
+
return { query: out, names, positionMap };
|
|
79
|
+
}
|
|
80
|
+
export function bindNamedParameters(rewritten, args) {
|
|
81
|
+
if (rewritten.names.length === 0)
|
|
82
|
+
return { query: rewritten.query, params: [...args] };
|
|
83
|
+
if (args.length !== 1 || args[0] === null || typeof args[0] !== "object" || Array.isArray(args[0])) {
|
|
84
|
+
throw new Error("sqlx-js: a query with named parameters requires exactly one parameter object");
|
|
85
|
+
}
|
|
86
|
+
const values = args[0];
|
|
87
|
+
const missing = rewritten.names.filter((name) => !Object.hasOwn(values, name));
|
|
88
|
+
const expected = new Set(rewritten.names);
|
|
89
|
+
const extra = Object.keys(values).filter((name) => !expected.has(name));
|
|
90
|
+
if (missing.length > 0)
|
|
91
|
+
throw new Error(`sqlx-js: missing named parameter(s): ${missing.join(", ")}`);
|
|
92
|
+
if (extra.length > 0)
|
|
93
|
+
throw new Error(`sqlx-js: unknown named parameter(s): ${extra.join(", ")}`);
|
|
94
|
+
return { query: rewritten.query, params: rewritten.names.map((name) => values[name]) };
|
|
95
|
+
}
|
|
96
|
+
export function originalPosition(rewritten, oneBasedPosition) {
|
|
97
|
+
const position = Number(oneBasedPosition);
|
|
98
|
+
if (!Number.isInteger(position) || position < 1)
|
|
99
|
+
return oneBasedPosition;
|
|
100
|
+
return (rewritten.positionMap[position - 1] ?? position - 1) + 1;
|
|
101
|
+
}
|
package/dist/src/typed.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@onreza/sqlx-js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Compile-time-checked raw SQL for TypeScript + PostgreSQL. Inspired by sqlx.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -66,6 +66,11 @@
|
|
|
66
66
|
"peerDependencies": {
|
|
67
67
|
"typescript": ">=5.4"
|
|
68
68
|
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"typescript": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
73
|
+
},
|
|
69
74
|
"dependencies": {
|
|
70
75
|
"libpg-query": "^17.7.3",
|
|
71
76
|
"postgres": "^3.4.9"
|