@onreza/sqlx-js 0.2.0 → 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/README.md +28 -26
- package/dist/bin/sqlx-js.js +4 -4
- package/dist/src/cache.d.ts +1 -0
- package/dist/src/cache.js +128 -1
- package/dist/src/codegen.js +16 -14
- package/dist/src/commands/prepare.js +35 -10
- package/dist/src/index.d.ts +12 -0
- package/dist/src/pg/oids.js +3 -2
- package/dist/src/pg/param-map.d.ts +2 -1
- package/dist/src/pg/param-map.js +181 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
Compile-time-checked raw SQL for TypeScript + PostgreSQL. Inspired by Rust's [sqlx](https://github.com/launchbadge/sqlx).
|
|
4
4
|
|
|
5
|
-
You write plain SQL strings. A `prepare` step validates them against your database via the PostgreSQL wire protocol and generates a TypeScript declaration file. Wrong column names
|
|
5
|
+
You write plain SQL strings. A `prepare` step validates them against your database via the PostgreSQL wire protocol and generates a TypeScript declaration file. Wrong column names and stale queries fail during `prepare`; mismatched parameter types and row usage become TypeScript errors.
|
|
6
6
|
|
|
7
|
-
The runtime uses [Postgres.js](https://github.com/porsager/postgres)
|
|
7
|
+
The runtime uses [Postgres.js](https://github.com/porsager/postgres) through a single adapter instead of a Bun-specific client. The published CLI is a **Node ≥ 18** binary (`#!/usr/bin/env node`) and can also be run through Bun's npm tooling.
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
10
|
import { sql } from "@onreza/sqlx-js";
|
|
@@ -24,11 +24,11 @@ const rows = await sql(
|
|
|
24
24
|
- **Precise nullability inference** through `libpg-query`: `JOIN` direction (LEFT/RIGHT/FULL), inner `JOIN ... ON` predicates, DML `RETURNING`, `COALESCE`, `CASE`, `COUNT`, expression propagation. Parameters become `T | null` when wrapped in `COALESCE`/`NULLIF`/`IS [NOT] NULL`/`IS [NOT] DISTINCT FROM`, or when bound to a nullable column in `INSERT`/`UPDATE`.
|
|
25
25
|
- **WHERE narrowing**: `IS NOT NULL`, equality chains, `IN`, `LIKE`, `BETWEEN` make columns non-null. Tracks `AND`/`OR` semantics.
|
|
26
26
|
- **PostgreSQL enums** generated as TypeScript literal unions (read + write side).
|
|
27
|
-
- **Schema-aware `jsonb`** via a `SqlxJsJson` global namespace and a config-driven column → type mapping. Works for both result columns and `INSERT`/`UPDATE`/`WHERE` parameters.
|
|
27
|
+
- **Schema-aware `jsonb`** via a `SqlxJsJson` global namespace and a config-driven column → type mapping. Works for both result columns and `INSERT`/`UPDATE`/`WHERE` parameters. Unmapped `json`/`jsonb` falls back to `JsonValue` for rows and `JsonInput` for parameters instead of `unknown`.
|
|
28
28
|
- **Extension types out of the box**: `pgvector` (`vector`, `halfvec`, `sparsevec`), `hstore`, `citext`, `ltree`/`lquery`/`ltxtquery`. Add your own through `customTypes` config.
|
|
29
29
|
- **Domains** resolve to their base TypeScript type (`CREATE DOMAIN email AS text` → `string`), including domains over extension types or other domains.
|
|
30
30
|
- **Wide built-in type coverage**: numeric, text, date/time, UUID, json/jsonb, network (inet/cidr/macaddr/macaddr8), bit strings, ranges/multiranges, geometric, money, tsvector/tsquery, xml — and the matching array variants.
|
|
31
|
-
- **External SQL files** via `sql.file("queries/foo.sql", ...)` —
|
|
31
|
+
- **External SQL files** via `sql.file("queries/foo.sql", ...)` — prepared and typed through `KnownFileQueries`. Watch mode re-prepares on `.sql` edits too.
|
|
32
32
|
- **One-row helpers**: `sql.one(...)`, `sql.optional(...)`, `sql.file.one(...)`, `sql.file.optional(...)`, and the same chain on the `tx` callback — friendly with `noUncheckedIndexedAccess: true`. The scanner walks all of them.
|
|
33
33
|
- **Array params** for `text[]`, `int[]`, etc. are auto-serialised to PostgreSQL array literals (`{a,b,c}`) at runtime — no more `string_to_array` workaround.
|
|
34
34
|
- **Typed transactions** via `sql.transaction(async tx => …)` — the `tx` callback parameter is recognized by the scanner, so queries inside the block keep full type checking.
|
|
@@ -40,8 +40,8 @@ const rows = await sql(
|
|
|
40
40
|
- **Schema snapshot + LLM manifest** via `schema dump` / `schema check`: tables, columns, constraints, indexes, types, and function/procedure metadata are introspected from PostgreSQL.
|
|
41
41
|
- **Shadow database validation** via `migrate dev` / `migrate verify`: auto-create a disposable shadow DB, apply migrations, validate SQL, and drop it afterwards.
|
|
42
42
|
- **Safe identifier quoting** via `sql.id(...)`, backed by the committed schema snapshot whitelist.
|
|
43
|
-
- **
|
|
44
|
-
- **Watch mode**:
|
|
43
|
+
- **Single runtime adapter**: Postgres.js backs the runtime on Node/Bun-compatible environments — no Bun.SQL-specific adapter to choose.
|
|
44
|
+
- **Watch mode**: debounced re-prepare with a warm `PgClient` + `SchemaCache` on `.ts` / `.tsx` / `.mts` / `.cts` / `.sql` changes.
|
|
45
45
|
- **Cache pruning** removes orphaned entries automatically (toggleable with `--no-prune`).
|
|
46
46
|
|
|
47
47
|
## Install
|
|
@@ -73,7 +73,7 @@ DATABASE_URL=postgres://user:password@localhost:5432/your_db
|
|
|
73
73
|
# DATABASE_URL=postgres://user:password@db.example.com:5432/your_db?sslmode=require
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
Supported `sslmode` values: `disable`, `prefer` (default — try TLS, fall back to plaintext), `require` (TLS or fail), `verify-ca`, `verify-full`. For a private/self-signed CA, point `sslrootcert` (and optionally `sslcert` / `sslkey` for client certs) at PEM files: `?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem`. `application_name`, `connect_timeout
|
|
76
|
+
Supported `sslmode` values: `disable`, `prefer` (default — try TLS, fall back to plaintext), `require` (TLS or fail), `verify-ca`, `verify-full`. For a private/self-signed CA, point `sslrootcert` (and optionally `sslcert` / `sslkey` for client certs) at PEM files: `?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem`. `application_name`, `connect_timeout` (seconds), and `statement_timeout` (milliseconds) are also honored when provided as URL parameters.
|
|
77
77
|
|
|
78
78
|
### 2. Create a migration
|
|
79
79
|
|
|
@@ -81,7 +81,7 @@ Supported `sslmode` values: `disable`, `prefer` (default — try TLS, fall back
|
|
|
81
81
|
npx @onreza/sqlx-js migrate add init
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
Edit the
|
|
84
|
+
The command creates matching `.up.sql` and `.down.sql` stubs. Edit the `.up.sql` file (`migrations/0001_init.up.sql`):
|
|
85
85
|
|
|
86
86
|
```sql
|
|
87
87
|
CREATE TABLE users (
|
|
@@ -99,7 +99,7 @@ During local development, validate the migration and regenerate query artifacts
|
|
|
99
99
|
npx @onreza/sqlx-js migrate dev
|
|
100
100
|
```
|
|
101
101
|
|
|
102
|
-
`migrate dev` does not touch your application database. It creates a temporary shadow database using `DATABASE_URL` credentials, applies migrations from scratch, validates that the latest
|
|
102
|
+
`migrate dev` does not touch your application database. It creates a temporary shadow database using `DATABASE_URL` credentials, applies migrations from scratch, validates that the latest migration's `.down.sql` restores the previous schema (squash baselines may omit `.down.sql`), prepares SQL queries against the resulting schema, writes `.sqlx-js/` and `sqlx-js-env.d.ts`, then drops the shadow database.
|
|
103
103
|
|
|
104
104
|
When you want to update your local application database, run:
|
|
105
105
|
|
|
@@ -152,7 +152,7 @@ Unknown queries, wrong parameter types, and dynamic strings are compile errors.
|
|
|
152
152
|
|
|
153
153
|
### `sql.file(path, ...params)`
|
|
154
154
|
|
|
155
|
-
Load SQL from an external file.
|
|
155
|
+
Load SQL from an external file. At prepare time the scanner reads the path relative to the source file. The generated `KnownFileQueries` key is the resolved SQL file path relative to `--root`; at runtime `sql.file(...)` reads the string argument relative to `process.cwd()`.
|
|
156
156
|
|
|
157
157
|
```ts
|
|
158
158
|
// queries/top_admins.sql
|
|
@@ -165,7 +165,7 @@ const admins = await sql.file("queries/top_admins.sql", "admin", 5);
|
|
|
165
165
|
// admins: { id: bigint; name: string }[]
|
|
166
166
|
```
|
|
167
167
|
|
|
168
|
-
File-backed queries are emitted into a separate `KnownFileQueries` interface
|
|
168
|
+
File-backed queries are emitted into a separate `KnownFileQueries` interface. Because the type key is the root-relative resolved SQL file path, keep file-backed call sites under a convention where that key matches the runtime string literal; the example keeps those call sites at the project root. Nested source-relative file paths are a current limitation.
|
|
169
169
|
|
|
170
170
|
### `sql.one(query, ...params)` and `sql.optional(query, ...params)`
|
|
171
171
|
|
|
@@ -240,7 +240,7 @@ const orderBy = sql.id("users", "created_at");
|
|
|
240
240
|
await unsafe(`SELECT id, email FROM ${sql.id("users")} ORDER BY ${orderBy} DESC`);
|
|
241
241
|
```
|
|
242
242
|
|
|
243
|
-
The default snapshot path is `.sqlx-js/schema/schema.json`. Override it at runtime with `SQLX_JS_SCHEMA_PATH`. Pass schema-qualified identifiers as separate segments: `sql.id("public", "users")`, not `sql.id("public.users")`.
|
|
243
|
+
The default snapshot path is `.sqlx-js/schema/schema.json`. Override it at runtime with `SQLX_JS_SCHEMA_PATH`. `sql.id(...)` accepts one to three identifier segments. Pass schema-qualified identifiers as separate segments: `sql.id("public", "users")`, not `sql.id("public.users")`.
|
|
244
244
|
|
|
245
245
|
### `migrate(options)`
|
|
246
246
|
|
|
@@ -286,13 +286,13 @@ setClient(createClient(process.env.DATABASE_URL, {
|
|
|
286
286
|
statementTimeoutMs: 5000,
|
|
287
287
|
// Fires after every query/transaction statement, success or failure.
|
|
288
288
|
onQuery: ({ query, params, durationMs, rowCount, error }) => {
|
|
289
|
-
if (error) logger.error({ query, error }); //
|
|
289
|
+
if (error) logger.error({ query, error }); // database errors are PgError
|
|
290
290
|
else if (durationMs > 200) logger.warn({ slow: query, durationMs, rowCount });
|
|
291
291
|
},
|
|
292
292
|
}));
|
|
293
293
|
```
|
|
294
294
|
|
|
295
|
-
The `onQuery` hook
|
|
295
|
+
The `onQuery` hook is the integration point for metrics, tracing, and slow-query logging — sqlx-js does not log queries itself. The event carries the raw `params`, which may contain personal or sensitive data — don't log them blindly; redact or omit `params` in shared sinks. Database errors are normalized to `PgError`; transport and non-database errors pass through unchanged.
|
|
296
296
|
|
|
297
297
|
### `clearSqlFileCache()`
|
|
298
298
|
|
|
@@ -313,7 +313,7 @@ try {
|
|
|
313
313
|
}
|
|
314
314
|
```
|
|
315
315
|
|
|
316
|
-
`sql.one` throws `NoRowsError` on 0 rows and `TooManyRowsError` (with `.actual`) on >1. Any database error raised by the runtime
|
|
316
|
+
`sql.one` throws `NoRowsError` on 0 rows and `TooManyRowsError` (with `.actual`) on >1. Any database error raised by the default runtime is normalized into a `PgError`, so `e instanceof PgError` works the same in `prepare`, `migrate`, and ordinary `sql(...)` calls. `PgError` exposes `.code`, `.position`, `.hint`, `.detail`, `.severity`, `.schema`, `.table`, `.column`, `.constraint`, and the original driver error on `.cause`. Non-database failures (e.g. a dropped connection) are rethrown unchanged.
|
|
317
317
|
|
|
318
318
|
### Transactions with options
|
|
319
319
|
|
|
@@ -336,16 +336,16 @@ In addition to `import { sql } from "@onreza/sqlx-js"`, the scanner recognises `
|
|
|
336
336
|
```
|
|
337
337
|
sqlx-js init [--root <dir>]
|
|
338
338
|
sqlx-js prepare [--check | --watch] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]
|
|
339
|
-
sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] | verify [--shadow-admin-url <url> | --shadow-url <url>] | run [--dry-run] [--json] [--lock-timeout <ms>] | info [--json] | check [--json] | revert [--dry-run] [--json] [--shadow-admin-url <url> | --shadow-url <url>] | add <name> | squash <name> [--shadow-admin-url <url> | --shadow-url <url>] [--replace] | archive list | archive restore <name> [--force]
|
|
339
|
+
sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | 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]
|
|
340
340
|
sqlx-js schema dump | check [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>]
|
|
341
341
|
sqlx-js --version | --help
|
|
342
342
|
```
|
|
343
343
|
|
|
344
|
-
`prepare` describes queries across a small connection pool (default 8, override with `SQLX_JS_PREPARE_CONCURRENCY`) for faster cold runs on large projects.
|
|
344
|
+
Regular `prepare` describes queries across a small connection pool (default 8, override with `SQLX_JS_PREPARE_CONCURRENCY`) for faster cold runs on large projects. Watch mode keeps one session warm and reuses it between debounced changes.
|
|
345
345
|
|
|
346
346
|
| Flag | Meaning |
|
|
347
347
|
|-----------------------|--------------------------------------------------------------------------------------|
|
|
348
|
-
| `--check` | Offline: verify
|
|
348
|
+
| `--check` | Offline: verify every scanned query is present in cache, no database required. |
|
|
349
349
|
| `--watch` | Persistent connection, re-prepare on file change. |
|
|
350
350
|
| `--root <dir>` | Source/cache/migrations root (default: cwd). |
|
|
351
351
|
| `--dts <path>` | Declarations output (default: `<root>/sqlx-js-env.d.ts`). |
|
|
@@ -354,7 +354,7 @@ sqlx-js --version | --help
|
|
|
354
354
|
| `--dry-run` | For `migrate run` / `migrate revert`: validate without applying to the target DB. |
|
|
355
355
|
| `--json` | Machine-readable output for `migrate info/check` and migration dry-runs. |
|
|
356
356
|
| `--force` | For `migrate archive restore`: allow overwriting existing migration files. |
|
|
357
|
-
| `--lock-timeout <ms>` | Advisory-lock acquisition timeout for `migrate run` / `
|
|
357
|
+
| `--lock-timeout <ms>` | Advisory-lock acquisition timeout for `migrate run` / `revert` / `dev` / `verify` / `squash`. |
|
|
358
358
|
| `--shadow-url <url>` | Use an existing disposable shadow DB instead of auto-creating one. |
|
|
359
359
|
| `--shadow-admin-url <url>` | Admin/maintenance DB URL used to auto-create shadow DBs. |
|
|
360
360
|
| `--replace` | For `migrate squash`: archive replaced migration files after writing the baseline. |
|
|
@@ -363,9 +363,9 @@ sqlx-js --version | --help
|
|
|
363
363
|
| `--manifest <path>` | LLM schema manifest path (default: `<root>/.sqlx-js/schema/schema.md`). |
|
|
364
364
|
| `--no-manifest` | Skip writing the LLM schema manifest during `schema dump`. |
|
|
365
365
|
|
|
366
|
-
|
|
366
|
+
Flags that take a value accept both `--flag value` and `--flag=value` forms.
|
|
367
367
|
|
|
368
|
-
`DATABASE_URL` must be set for any command that touches the application database or auto-creates a shadow database. `SHADOW_ADMIN_DATABASE_URL` can point at a maintenance/admin database when the application user cannot `CREATE DATABASE`; `SHADOW_DATABASE_URL` can point at a pre-created disposable shadow database.
|
|
368
|
+
`DATABASE_URL` must be set for any command that touches the application database or auto-creates a shadow database. `SHADOW_ADMIN_DATABASE_URL` can point at a maintenance/admin database when the application user cannot `CREATE DATABASE`; `SHADOW_DATABASE_URL` can point at a pre-created disposable shadow database. The internal wire client understands `sslmode`, `sslrootcert`, `sslcert`, `sslkey`, `application_name`, `connect_timeout` (seconds), and `statement_timeout` (milliseconds).
|
|
369
369
|
|
|
370
370
|
### Development and deployment flows
|
|
371
371
|
|
|
@@ -377,7 +377,7 @@ sqlx-js migrate add add_users
|
|
|
377
377
|
sqlx-js migrate dev
|
|
378
378
|
```
|
|
379
379
|
|
|
380
|
-
`migrate dev` creates a disposable shadow database, applies all migrations from scratch, validates that the latest
|
|
380
|
+
`migrate dev` creates a disposable shadow database, applies all migrations from scratch, validates that the latest migration's `.down.sql` restores the previous schema (squash baselines may omit `.down.sql`), prepares project SQL against the shadow schema, writes `.sqlx-js/` plus `sqlx-js-env.d.ts`, and drops the shadow database. This means you can keep editing a local WIP migration before it is merged. You do not need to drop your application database or create a new migration for every local edit.
|
|
381
381
|
|
|
382
382
|
Use `migrate verify` in PR/CI before merge:
|
|
383
383
|
|
|
@@ -428,7 +428,7 @@ On an empty database, the baseline runs as ordinary schema SQL. On an already-mi
|
|
|
428
428
|
- `.sqlx-js/schema/schema.json` — machine-readable contract for runtime identifier whitelisting and CI drift checks.
|
|
429
429
|
- `.sqlx-js/schema/schema.md` — compact LLM-facing manifest with tables, columns, constraints, indexes, types, and functions.
|
|
430
430
|
|
|
431
|
-
`schema check` re-introspects the database and fails if the committed snapshot is stale. With `--shadow-url`, both `prepare` and `schema dump/check` first apply pending migrations to the shadow database, then use that database as the source of truth. In watch mode, pending shadow migrations are checked before every re-prepare; when a migration is applied, the prepare session is reopened so schema metadata is not reused across DDL changes.
|
|
431
|
+
`schema check` re-introspects the database and fails if the committed snapshot is stale. With `--shadow-url`, both `prepare` and `schema dump/check` first apply pending migrations to the shadow database, then use that database as the source of truth. Unlike `migrate dev` / `verify` / `squash`, these commands do not clear an explicit shadow database first. In watch mode, pending shadow migrations are checked before every re-prepare; when a migration is applied, the prepare session is reopened so schema metadata is not reused across DDL changes.
|
|
432
432
|
|
|
433
433
|
### Error output
|
|
434
434
|
|
|
@@ -477,7 +477,7 @@ declare global {
|
|
|
477
477
|
export {};
|
|
478
478
|
```
|
|
479
479
|
|
|
480
|
-
After re-running `prepare`, every `jsonb` column or parameter declared in `jsonbTypes` is checked against the corresponding TypeScript type.
|
|
480
|
+
After re-running `prepare`, every `jsonb` column or parameter declared in `jsonbTypes` is checked against the corresponding TypeScript type. Columns without a custom mapping use `JsonValue` for result rows and `JsonInput` for parameters, both exported by `@onreza/sqlx-js`, so non-JSON inputs such as `Date`, functions, and `bigint` are rejected by TypeScript while plain JSON objects, arrays, strings, numbers, booleans, and nested JSON `null` values are accepted. Top-level SQL `null` is added separately as `| null` only when the mapped database parameter is nullable.
|
|
481
481
|
|
|
482
482
|
### Extension types and `customTypes`
|
|
483
483
|
|
|
@@ -494,7 +494,7 @@ sqlx-js ships with a built-in registry that resolves popular PostgreSQL extensio
|
|
|
494
494
|
| `lquery` | `string` | ltree |
|
|
495
495
|
| `ltxtquery` | `string` | ltree |
|
|
496
496
|
|
|
497
|
-
Add or override mappings via `customTypes` in `sqlx-js.config.ts`. Keys are `pg_type.typname` values (the bare type name
|
|
497
|
+
Add or override mappings via `customTypes` in `sqlx-js.config.ts`. Keys are `pg_type.typname` values (the bare type name). The registry is global by type name, so two schemas with the same `typname` cannot be mapped differently:
|
|
498
498
|
|
|
499
499
|
```ts
|
|
500
500
|
import type { SqlxJsConfig } from "@onreza/sqlx-js";
|
|
@@ -561,14 +561,16 @@ Releases are automated via `release-please`: pushes to `main` accumulate into a
|
|
|
561
561
|
`sqlx-js` is a young library. Known gaps:
|
|
562
562
|
|
|
563
563
|
- PostgreSQL only (no MySQL or SQLite).
|
|
564
|
+
- The scanner only follows direct named imports and namespace imports from `@onreza/sqlx-js`; it does not follow re-exports, dynamic aliases, or tagged-template calls.
|
|
564
565
|
- `INSERT INTO t VALUES (...)` without an explicit column list isn't parameter-mapped.
|
|
565
566
|
- `SELECT *` falls back to conservative nullability.
|
|
567
|
+
- Statements without a row description, such as `UPDATE ...` without `RETURNING`, are emitted with `row: never`, so the public return type is `Promise<never[]>`.
|
|
566
568
|
- Nested CTE references (CTE-`b` referencing CTE-`a` in the same `WITH`) and `WITH RECURSIVE` are not analysed transitively — at worst this produces extra `T | null`. Use `AS "id!"` overrides if needed.
|
|
567
569
|
- Column names whose **real** name (not an alias) ends with `!` or `?` are not supported — the runtime strips those suffixes assuming an override. Use `AS "alias"` if you have such a column.
|
|
568
570
|
- Migrations run inside `BEGIN/COMMIT`. DDL that disallows transactions (`CREATE INDEX CONCURRENTLY`, `VACUUM`, `REINDEX CONCURRENTLY`, …) will fail; split such operations into separate migrations executed outside the runner.
|
|
569
571
|
- The **internal** wire client (used by `migrate run`, `prepare`, and the runtime `migrate()` helper) reads `sslmode`, `sslrootcert`/`sslcert`/`sslkey`, `application_name`, `connect_timeout`, and `statement_timeout` from `DATABASE_URL`. The default runtime `sql()` path delegates connection handling to Postgres.js; configure TLS, pooling, and timeouts through the `DATABASE_URL` and `createClient(...)` options it understands (`statementTimeoutMs` is a convenience that maps to a per-connection `statement_timeout`).
|
|
570
572
|
- `connect_timeout` bounds the entire internal-client connect, including the TLS handshake and SCRAM authentication.
|
|
571
|
-
- `sql.file(path)`
|
|
573
|
+
- `sql.file(path)` has a path-key mismatch to be aware of: prepare resolves the file relative to the source file, codegen keys it by root-relative resolved path, and runtime reads the literal path relative to `process.cwd()`. Keep a project convention and verify with `tsc` after `prepare`.
|
|
572
574
|
|
|
573
575
|
See [ROADMAP.md](./ROADMAP.md) for what's planned.
|
|
574
576
|
|
package/dist/bin/sqlx-js.js
CHANGED
|
@@ -25,26 +25,26 @@ function help() {
|
|
|
25
25
|
usage:
|
|
26
26
|
sqlx-js init [--root <dir>]
|
|
27
27
|
sqlx-js prepare [--check | --watch] [--root <dir>] [--dts <path>] [--no-prune] [--shadow-url <url>]
|
|
28
|
-
sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] | verify [--shadow-admin-url <url> | --shadow-url <url>] | 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] | archive list | archive restore <name> [--force]
|
|
28
|
+
sqlx-js migrate dev [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | verify [--shadow-admin-url <url> | --shadow-url <url>] [--lock-timeout <ms>] | 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
29
|
sqlx-js schema dump | check [--schema <path>] [--manifest <path>] [--no-manifest] [--shadow-url <url>]
|
|
30
30
|
sqlx-js --version
|
|
31
31
|
|
|
32
32
|
env:
|
|
33
|
-
DATABASE_URL=postgres://... (supports
|
|
33
|
+
DATABASE_URL=postgres://... (supports sslmode, cert paths, application_name, connect_timeout, statement_timeout)
|
|
34
34
|
SHADOW_DATABASE_URL=postgres://... (optional pre-created disposable shadow DB)
|
|
35
35
|
SHADOW_ADMIN_DATABASE_URL=postgres://... (optional admin URL for auto-created shadow DBs)
|
|
36
36
|
|
|
37
37
|
flags:
|
|
38
38
|
--root <dir> scan root (default: cwd)
|
|
39
39
|
--dts <path> declarations output (default: <root>/sqlx-js-env.d.ts)
|
|
40
|
-
--check offline mode:
|
|
40
|
+
--check offline mode: verify scanned queries exist in cache, no DB
|
|
41
41
|
--watch re-prepare on file change (persistent PG connection)
|
|
42
42
|
--no-prune keep orphaned cache entries (default: remove)
|
|
43
43
|
--migrations <dir> migrations directory (default: <root>/migrations)
|
|
44
44
|
--dry-run validate and print migrate run/revert plan without applying migrations
|
|
45
45
|
--json machine-readable output for migrate info/check and migrate run/revert --dry-run
|
|
46
46
|
--force allow archive restore to overwrite existing migration files
|
|
47
|
-
--lock-timeout <ms> advisory-lock acquisition timeout for migrate run/revert
|
|
47
|
+
--lock-timeout <ms> advisory-lock acquisition timeout for migrate run/revert/dev/verify/squash
|
|
48
48
|
--shadow-url <url> use an existing disposable shadow DB instead of auto-creating one
|
|
49
49
|
--shadow-admin-url <url> admin/maintenance DB URL used to auto-create shadow DBs
|
|
50
50
|
--replace archive replaced migrations after migrate squash writes the baseline
|
package/dist/src/cache.d.ts
CHANGED
package/dist/src/cache.js
CHANGED
|
@@ -2,9 +2,136 @@ 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
|
export function fingerprint(query) {
|
|
5
|
-
const norm = query
|
|
5
|
+
const norm = normalizeForFingerprint(query);
|
|
6
6
|
return createHash("sha256").update(norm).digest("hex").slice(0, 16);
|
|
7
7
|
}
|
|
8
|
+
function normalizeForFingerprint(query) {
|
|
9
|
+
let out = "";
|
|
10
|
+
let pendingSpace = false;
|
|
11
|
+
let i = 0;
|
|
12
|
+
const emit = (text) => {
|
|
13
|
+
if (pendingSpace && out.length > 0)
|
|
14
|
+
out += " ";
|
|
15
|
+
out += text;
|
|
16
|
+
pendingSpace = false;
|
|
17
|
+
};
|
|
18
|
+
const markSpace = () => {
|
|
19
|
+
if (out.length > 0)
|
|
20
|
+
pendingSpace = true;
|
|
21
|
+
};
|
|
22
|
+
while (i < query.length) {
|
|
23
|
+
const ch = query[i];
|
|
24
|
+
if (/\s/.test(ch)) {
|
|
25
|
+
markSpace();
|
|
26
|
+
i++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (ch === "-" && query[i + 1] === "-") {
|
|
30
|
+
i = readLineComment(query, i);
|
|
31
|
+
markSpace();
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === "/" && query[i + 1] === "*") {
|
|
35
|
+
i = readBlockComment(query, i);
|
|
36
|
+
markSpace();
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (ch === "'") {
|
|
40
|
+
const next = readSingleQuoted(query, i, isEscapeStringPrefix(query, i));
|
|
41
|
+
emit(query.slice(i, next));
|
|
42
|
+
i = next;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (ch === "\"") {
|
|
46
|
+
const next = readQuotedIdentifier(query, i);
|
|
47
|
+
emit(query.slice(i, next));
|
|
48
|
+
i = next;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === "$") {
|
|
52
|
+
const next = readDollarQuoted(query, i);
|
|
53
|
+
if (next !== null) {
|
|
54
|
+
emit(query.slice(i, next));
|
|
55
|
+
i = next;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
emit(ch);
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
function readSingleQuoted(query, start, escapeBackslash) {
|
|
65
|
+
let i = start + 1;
|
|
66
|
+
while (i < query.length) {
|
|
67
|
+
const ch = query[i];
|
|
68
|
+
if (escapeBackslash && ch === "\\") {
|
|
69
|
+
i += 2;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (ch === "'") {
|
|
73
|
+
if (query[i + 1] === "'") {
|
|
74
|
+
i += 2;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
return i + 1;
|
|
78
|
+
}
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
return query.length;
|
|
82
|
+
}
|
|
83
|
+
function readQuotedIdentifier(query, start) {
|
|
84
|
+
let i = start + 1;
|
|
85
|
+
while (i < query.length) {
|
|
86
|
+
if (query[i] === "\"") {
|
|
87
|
+
if (query[i + 1] === "\"") {
|
|
88
|
+
i += 2;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
return i + 1;
|
|
92
|
+
}
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
return query.length;
|
|
96
|
+
}
|
|
97
|
+
function readDollarQuoted(query, start) {
|
|
98
|
+
let tagEnd = start + 1;
|
|
99
|
+
while (tagEnd < query.length && /[A-Za-z0-9_]/.test(query[tagEnd]))
|
|
100
|
+
tagEnd++;
|
|
101
|
+
if (query[tagEnd] !== "$")
|
|
102
|
+
return null;
|
|
103
|
+
const tag = query.slice(start, tagEnd + 1);
|
|
104
|
+
const end = query.indexOf(tag, tagEnd + 1);
|
|
105
|
+
return end === -1 ? query.length : end + tag.length;
|
|
106
|
+
}
|
|
107
|
+
function readLineComment(query, start) {
|
|
108
|
+
const end = query.indexOf("\n", start + 2);
|
|
109
|
+
return end === -1 ? query.length : end + 1;
|
|
110
|
+
}
|
|
111
|
+
function readBlockComment(query, start) {
|
|
112
|
+
let depth = 1;
|
|
113
|
+
let i = start + 2;
|
|
114
|
+
while (i < query.length && depth > 0) {
|
|
115
|
+
if (query[i] === "/" && query[i + 1] === "*") {
|
|
116
|
+
depth++;
|
|
117
|
+
i += 2;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (query[i] === "*" && query[i + 1] === "/") {
|
|
121
|
+
depth--;
|
|
122
|
+
i += 2;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
i++;
|
|
126
|
+
}
|
|
127
|
+
return i;
|
|
128
|
+
}
|
|
129
|
+
function isEscapeStringPrefix(query, quoteIndex) {
|
|
130
|
+
if (quoteIndex === 0 || query[quoteIndex - 1]?.toLowerCase() !== "e")
|
|
131
|
+
return false;
|
|
132
|
+
const beforePrefix = query[quoteIndex - 2];
|
|
133
|
+
return beforePrefix === undefined || !/[A-Za-z0-9_$]/.test(beforePrefix);
|
|
134
|
+
}
|
|
8
135
|
export function effectiveNullable(c) {
|
|
9
136
|
if (c.override === "non-null")
|
|
10
137
|
return false;
|
package/dist/src/codegen.js
CHANGED
|
@@ -31,21 +31,23 @@ function emitModule(lines, moduleName, entries) {
|
|
|
31
31
|
lines.push(`declare module ${JSON.stringify(moduleName)} {`);
|
|
32
32
|
lines.push(" interface KnownQueries {");
|
|
33
33
|
const inlineSeen = new Set();
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
34
|
+
const inlinePairs = [];
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
const emitInline = e.hasInline === true || (e.hasInline !== false && (!e.filePaths || e.filePaths.length === 0));
|
|
37
|
+
if (!emitInline)
|
|
38
|
+
continue;
|
|
39
|
+
const queries = e.inlineQueries && e.inlineQueries.length > 0 ? e.inlineQueries : [e.query];
|
|
40
|
+
for (const query of queries)
|
|
41
|
+
inlinePairs.push({ query, entry: e });
|
|
42
|
+
}
|
|
43
|
+
for (const { query, entry } of inlinePairs.slice().sort((a, b) => a.query.localeCompare(b.query))) {
|
|
44
|
+
if (inlineSeen.has(query))
|
|
43
45
|
continue;
|
|
44
|
-
inlineSeen.add(
|
|
45
|
-
const { params, row } = entrySignature(
|
|
46
|
-
if (
|
|
47
|
-
lines.push(` /** Nullability inference degraded: ${
|
|
48
|
-
lines.push(` ${JSON.stringify(
|
|
46
|
+
inlineSeen.add(query);
|
|
47
|
+
const { params, row } = entrySignature(entry);
|
|
48
|
+
if (entry.degraded)
|
|
49
|
+
lines.push(` /** Nullability inference degraded: ${entry.degraded.reason}. All result columns conservatively typed as nullable. */`);
|
|
50
|
+
lines.push(` ${JSON.stringify(query)}: { params: ${params}; row: ${row} };`);
|
|
49
51
|
}
|
|
50
52
|
lines.push(" }");
|
|
51
53
|
lines.push("");
|
|
@@ -10,6 +10,7 @@ import { buildParamMap } from "../pg/param-map.js";
|
|
|
10
10
|
import { mergeExtensionTypes } from "../pg/extensions.js";
|
|
11
11
|
const JSON_OIDS = new Set([114, 3802]);
|
|
12
12
|
const JSON_ARRAY_OIDS = new Set([199, 3807]);
|
|
13
|
+
const JSON_INPUT = 'import("@onreza/sqlx-js").JsonInput';
|
|
13
14
|
function enumUnion(values) {
|
|
14
15
|
if (values.length === 0)
|
|
15
16
|
return "never";
|
|
@@ -56,13 +57,28 @@ function resolveParamTs(paramIndex, paramOid, paramMap, schema, cfg) {
|
|
|
56
57
|
if (JSON_OIDS.has(paramOid) || JSON_ARRAY_OIDS.has(paramOid)) {
|
|
57
58
|
const target = paramMap.get(paramIndex);
|
|
58
59
|
if (target) {
|
|
59
|
-
const
|
|
60
|
+
const column = resolveTargetColumn(target, schema);
|
|
61
|
+
const decl = column ? lookupJsonbType(cfg, target.schema ?? "public", target.table, column) : undefined;
|
|
60
62
|
if (decl)
|
|
61
63
|
return JSON_ARRAY_OIDS.has(paramOid) ? `(${decl})[]` : decl;
|
|
62
64
|
}
|
|
65
|
+
return JSON_ARRAY_OIDS.has(paramOid) ? `(${JSON_INPUT})[]` : JSON_INPUT;
|
|
63
66
|
}
|
|
64
67
|
return resolveTs(paramOid, (oid) => schema.customType(oid));
|
|
65
68
|
}
|
|
69
|
+
function resolveTargetColumn(target, schema) {
|
|
70
|
+
if (target.column)
|
|
71
|
+
return target.column;
|
|
72
|
+
if (target.columnIndex === undefined)
|
|
73
|
+
return undefined;
|
|
74
|
+
const oid = schema.resolveTable(target.schema, target.table);
|
|
75
|
+
if (oid === undefined)
|
|
76
|
+
return undefined;
|
|
77
|
+
const cols = schema.columnsOf(oid);
|
|
78
|
+
if (!cols)
|
|
79
|
+
return undefined;
|
|
80
|
+
return [...cols.values()].sort((a, b) => a.attnum - b.attnum)[target.columnIndex - 1]?.name;
|
|
81
|
+
}
|
|
66
82
|
function resolveParamNullable(paramIndex, pm, schema) {
|
|
67
83
|
if (pm.forceNullable.has(paramIndex))
|
|
68
84
|
return true;
|
|
@@ -73,7 +89,10 @@ function resolveParamNullable(paramIndex, pm, schema) {
|
|
|
73
89
|
const oid = schema.resolveTable(t.schema, t.table);
|
|
74
90
|
if (oid === undefined)
|
|
75
91
|
return false;
|
|
76
|
-
const
|
|
92
|
+
const column = resolveTargetColumn(t, schema);
|
|
93
|
+
if (!column)
|
|
94
|
+
return false;
|
|
95
|
+
const col = schema.columnsOf(oid)?.get(column);
|
|
77
96
|
if (!col)
|
|
78
97
|
return false;
|
|
79
98
|
return !col.notNull;
|
|
@@ -100,6 +119,15 @@ function snippet(query, max = 80) {
|
|
|
100
119
|
const oneLine = query.replace(/\s+/g, " ").trim();
|
|
101
120
|
return oneLine.length > max ? oneLine.slice(0, max) + "…" : oneLine;
|
|
102
121
|
}
|
|
122
|
+
function siteUsage(sites) {
|
|
123
|
+
const inlineQueries = Array.from(new Set(sites.filter((s) => s.kind !== "file").map((s) => s.query))).sort();
|
|
124
|
+
const filePaths = Array.from(new Set(sites.filter((s) => s.kind === "file").map((s) => s.sqlFilePath).filter(Boolean))).sort();
|
|
125
|
+
return {
|
|
126
|
+
hasInline: inlineQueries.length > 0,
|
|
127
|
+
...(inlineQueries.length > 0 ? { inlineQueries } : {}),
|
|
128
|
+
...(filePaths.length > 0 ? { filePaths } : {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
103
131
|
export async function openSession(opts) {
|
|
104
132
|
const userCfg = await loadConfig(opts.root);
|
|
105
133
|
const cfg = parseDatabaseUrl(opts.databaseUrl);
|
|
@@ -284,13 +312,9 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
284
312
|
forceNullable: new Set(),
|
|
285
313
|
dmlBound: new Set(),
|
|
286
314
|
};
|
|
287
|
-
const fileSites = r.sites.filter((s) => s.kind === "file");
|
|
288
|
-
const hasInline = r.sites.some((s) => s.kind !== "file");
|
|
289
|
-
const filePaths = fileSites.length > 0
|
|
290
|
-
? Array.from(new Set(fileSites.map((s) => s.sqlFilePath))).sort()
|
|
291
|
-
: undefined;
|
|
292
315
|
const entry = {
|
|
293
316
|
query: r.query,
|
|
317
|
+
...siteUsage(r.sites),
|
|
294
318
|
paramOids: r.paramOids,
|
|
295
319
|
paramTsTypes: r.paramOids.map((o, idx) => resolveParamTs(idx + 1, o, pm.targets, schema, userCfg)),
|
|
296
320
|
paramNullable: r.paramOids.map((_o, idx) => resolveParamNullable(idx + 1, pm, schema)),
|
|
@@ -306,8 +330,6 @@ export async function prepareOnce(opts, session, log = console.log, err = consol
|
|
|
306
330
|
};
|
|
307
331
|
}),
|
|
308
332
|
hasResultSet: r.fields.length > 0,
|
|
309
|
-
hasInline,
|
|
310
|
-
...(filePaths ? { filePaths } : {}),
|
|
311
333
|
...(analysis.degraded ? { degraded: analysis.degraded } : {}),
|
|
312
334
|
};
|
|
313
335
|
cache.write(r.fp, entry);
|
|
@@ -351,7 +373,10 @@ export async function runPrepare(opts) {
|
|
|
351
373
|
console.error(`\nsqlx-js prepare --check: ${stale} stale/missing entries. Run \`sqlx-js prepare\` against a live DB.`);
|
|
352
374
|
process.exit(1);
|
|
353
375
|
}
|
|
354
|
-
const entries = [...unique.values()].map((u) =>
|
|
376
|
+
const entries = [...unique.values()].map((u) => {
|
|
377
|
+
const entry = cache.read(u.fp);
|
|
378
|
+
return entry ? { ...entry, ...siteUsage(u.sites) } : null;
|
|
379
|
+
}).filter((e) => e !== null);
|
|
355
380
|
emitDts(opts.dtsPath, entries);
|
|
356
381
|
console.log(`ok — ${entries.length} unique queries, types regenerated`);
|
|
357
382
|
return;
|
package/dist/src/index.d.ts
CHANGED
|
@@ -4,6 +4,18 @@ export interface KnownQueries {
|
|
|
4
4
|
}
|
|
5
5
|
export interface KnownFileQueries {
|
|
6
6
|
}
|
|
7
|
+
export type JsonPrimitive = string | number | boolean | null;
|
|
8
|
+
export type JsonValue = JsonPrimitive | JsonObject | JsonArray;
|
|
9
|
+
export type JsonObject = {
|
|
10
|
+
readonly [key: string]: JsonValue;
|
|
11
|
+
};
|
|
12
|
+
export type JsonArray = readonly JsonValue[];
|
|
13
|
+
export type JsonInput = string | number | boolean | JsonInputObject | JsonInputArray;
|
|
14
|
+
export type JsonInputValue = JsonPrimitive | JsonInputObject | JsonInputArray;
|
|
15
|
+
export type JsonInputObject = {
|
|
16
|
+
readonly [key: string]: JsonInputValue | undefined;
|
|
17
|
+
};
|
|
18
|
+
export type JsonInputArray = readonly JsonInputValue[];
|
|
7
19
|
export type { SqlxJsConfig } from "./config.js";
|
|
8
20
|
export type { SslMode, ConnConfig } from "./pg/wire.js";
|
|
9
21
|
export { PgError, ConnectionLostError } from "./pg/wire.js";
|
package/dist/src/pg/oids.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const JSON_VALUE = 'import("@onreza/sqlx-js").JsonValue';
|
|
1
2
|
const SCALAR = {
|
|
2
3
|
16: { ts: "boolean" },
|
|
3
4
|
17: { ts: "Uint8Array" },
|
|
@@ -11,7 +12,7 @@ const SCALAR = {
|
|
|
11
12
|
27: { ts: "string" },
|
|
12
13
|
28: { ts: "string" },
|
|
13
14
|
29: { ts: "string" },
|
|
14
|
-
114: { ts:
|
|
15
|
+
114: { ts: JSON_VALUE },
|
|
15
16
|
142: { ts: "string" },
|
|
16
17
|
600: { ts: "string" },
|
|
17
18
|
601: { ts: "string" },
|
|
@@ -44,7 +45,7 @@ const SCALAR = {
|
|
|
44
45
|
3220: { ts: "string" },
|
|
45
46
|
3614: { ts: "string" },
|
|
46
47
|
3615: { ts: "string" },
|
|
47
|
-
3802: { ts:
|
|
48
|
+
3802: { ts: JSON_VALUE },
|
|
48
49
|
3904: { ts: "string" },
|
|
49
50
|
3906: { ts: "string" },
|
|
50
51
|
3908: { ts: "string" },
|
package/dist/src/pg/param-map.js
CHANGED
|
@@ -12,9 +12,9 @@ export async function buildParamMap(sql) {
|
|
|
12
12
|
else if (stmt.UpdateStmt)
|
|
13
13
|
walkUpdate(stmt.UpdateStmt, targets, dmlBound);
|
|
14
14
|
else if (stmt.SelectStmt)
|
|
15
|
-
|
|
15
|
+
walkSelect(stmt.SelectStmt, targets, dmlBound);
|
|
16
16
|
else if (stmt.DeleteStmt)
|
|
17
|
-
|
|
17
|
+
walkDelete(stmt.DeleteStmt, targets, dmlBound);
|
|
18
18
|
walkForceNullable(stmt, false, forceNullable);
|
|
19
19
|
return { targets, forceNullable, dmlBound };
|
|
20
20
|
}
|
|
@@ -22,6 +22,7 @@ function walkInsert(ins, map, dmlBound) {
|
|
|
22
22
|
const rel = relOf(ins.relation);
|
|
23
23
|
if (!rel)
|
|
24
24
|
return;
|
|
25
|
+
const scope = scopeFromRelationNode(ins.relation, rel);
|
|
25
26
|
const cols = (ins.cols ?? [])
|
|
26
27
|
.map((c) => c?.ResTarget?.name)
|
|
27
28
|
.filter((n) => typeof n === "string");
|
|
@@ -29,82 +30,162 @@ function walkInsert(ins, map, dmlBound) {
|
|
|
29
30
|
for (const row of valuesLists) {
|
|
30
31
|
const items = row?.List?.items ?? [];
|
|
31
32
|
for (let i = 0; i < items.length; i++) {
|
|
32
|
-
const colName = cols[i];
|
|
33
|
-
if (!colName)
|
|
34
|
-
continue;
|
|
35
33
|
const pn = paramNumber(items[i]);
|
|
36
34
|
if (pn !== null) {
|
|
37
|
-
map
|
|
38
|
-
dmlBound.add(pn);
|
|
35
|
+
bindParam(map, dmlBound, pn, insertTarget(rel, cols, i), true);
|
|
39
36
|
}
|
|
40
37
|
}
|
|
41
38
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
39
|
+
const select = ins.selectStmt?.SelectStmt;
|
|
40
|
+
if (select && !select.valuesLists && Array.isArray(select.targetList)) {
|
|
41
|
+
for (let i = 0; i < select.targetList.length; i++) {
|
|
42
|
+
const pn = paramNumber(select.targetList[i]?.ResTarget?.val);
|
|
43
|
+
if (pn !== null) {
|
|
44
|
+
bindParam(map, dmlBound, pn, insertTarget(rel, cols, i), true);
|
|
45
|
+
}
|
|
45
46
|
}
|
|
47
|
+
walkSelect(select, map, dmlBound);
|
|
46
48
|
}
|
|
47
|
-
|
|
49
|
+
if (ins.returningList)
|
|
50
|
+
walkExpr(ins.returningList, scope, map, dmlBound);
|
|
51
|
+
walkOnConflict(ins.onConflictClause, rel, scope, map, dmlBound);
|
|
52
|
+
walkExpr(ins.whereClause, scope, map, dmlBound);
|
|
53
|
+
}
|
|
54
|
+
function walkOnConflict(conflict, rel, scope, map, dmlBound) {
|
|
55
|
+
if (!conflict || conflict.action !== "ONCONFLICT_UPDATE")
|
|
56
|
+
return;
|
|
57
|
+
for (const rt of conflict.targetList ?? []) {
|
|
58
|
+
const colName = rt?.ResTarget?.name;
|
|
59
|
+
if (typeof colName !== "string")
|
|
60
|
+
continue;
|
|
61
|
+
const pn = paramNumber(rt.ResTarget.val);
|
|
62
|
+
if (pn !== null) {
|
|
63
|
+
bindParam(map, dmlBound, pn, { ...rel, column: colName }, true);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
walkExpr(conflict.whereClause, scope, map, dmlBound);
|
|
48
67
|
}
|
|
49
68
|
function walkUpdate(upd, map, dmlBound) {
|
|
50
69
|
const rel = relOf(upd.relation);
|
|
51
70
|
if (!rel)
|
|
52
71
|
return;
|
|
72
|
+
const scope = scopeFromRelationNode(upd.relation, rel);
|
|
73
|
+
addRangeVars(upd.fromClause ?? [], scope);
|
|
53
74
|
for (const rt of upd.targetList ?? []) {
|
|
54
75
|
const colName = rt?.ResTarget?.name;
|
|
55
76
|
if (typeof colName !== "string")
|
|
56
77
|
continue;
|
|
57
78
|
const pn = paramNumber(rt.ResTarget.val);
|
|
58
79
|
if (pn !== null) {
|
|
59
|
-
map
|
|
60
|
-
dmlBound.add(pn);
|
|
80
|
+
bindParam(map, dmlBound, pn, { ...rel, column: colName }, true);
|
|
61
81
|
}
|
|
62
82
|
}
|
|
63
|
-
|
|
83
|
+
walkExpr(upd.whereClause, scope, map, dmlBound);
|
|
64
84
|
}
|
|
65
|
-
function
|
|
66
|
-
|
|
85
|
+
function walkDelete(del, map, dmlBound) {
|
|
86
|
+
const rel = relOf(del.relation);
|
|
87
|
+
if (!rel)
|
|
67
88
|
return;
|
|
89
|
+
const scope = scopeFromRelationNode(del.relation, rel);
|
|
90
|
+
addRangeVars(del.usingClause ?? [], scope);
|
|
91
|
+
walkExpr(del.whereClause, scope, map, dmlBound);
|
|
92
|
+
}
|
|
93
|
+
function walkSelect(select, map, dmlBound) {
|
|
94
|
+
const scope = scopeFromSelect(select);
|
|
95
|
+
walkJoinQuals(select.fromClause ?? [], scope, map, dmlBound);
|
|
96
|
+
walkExpr(select.whereClause, scope, map, dmlBound);
|
|
97
|
+
}
|
|
98
|
+
function walkExpr(node, scope, map, dmlBound) {
|
|
99
|
+
if (!node)
|
|
100
|
+
return;
|
|
101
|
+
if (Array.isArray(node)) {
|
|
102
|
+
for (const item of node)
|
|
103
|
+
walkExpr(item, scope, map, dmlBound);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
68
106
|
if (node.BoolExpr) {
|
|
69
107
|
for (const a of node.BoolExpr.args ?? [])
|
|
70
|
-
|
|
108
|
+
walkExpr(a, scope, map, dmlBound);
|
|
71
109
|
return;
|
|
72
110
|
}
|
|
73
111
|
if (node.A_Expr) {
|
|
74
|
-
collectFromExpr(node,
|
|
112
|
+
collectFromExpr(node, scope, map, dmlBound);
|
|
113
|
+
walkExpr(node.A_Expr.lexpr, scope, map, dmlBound);
|
|
114
|
+
walkExpr(node.A_Expr.rexpr, scope, map, dmlBound);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (node.TypeCast) {
|
|
118
|
+
walkExpr(node.TypeCast.arg, scope, map, dmlBound);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (node.NullTest) {
|
|
122
|
+
walkExpr(node.NullTest.arg, scope, map, dmlBound);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (node.CoalesceExpr) {
|
|
126
|
+
walkExpr(node.CoalesceExpr.args ?? [], scope, map, dmlBound);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (node.FuncCall) {
|
|
130
|
+
walkExpr(node.FuncCall.args ?? [], scope, map, dmlBound);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
if (node.CaseExpr) {
|
|
134
|
+
walkExpr(node.CaseExpr.arg, scope, map, dmlBound);
|
|
135
|
+
walkExpr(node.CaseExpr.args ?? [], scope, map, dmlBound);
|
|
136
|
+
walkExpr(node.CaseExpr.defresult, scope, map, dmlBound);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (node.CaseWhen) {
|
|
140
|
+
walkExpr(node.CaseWhen.expr, scope, map, dmlBound);
|
|
141
|
+
walkExpr(node.CaseWhen.result, scope, map, dmlBound);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (node.SubLink) {
|
|
145
|
+
const sub = node.SubLink.subselect?.SelectStmt;
|
|
146
|
+
if (sub)
|
|
147
|
+
walkSelect(sub, map, dmlBound);
|
|
148
|
+
walkExpr(node.SubLink.testexpr, scope, map, dmlBound);
|
|
75
149
|
return;
|
|
76
150
|
}
|
|
77
151
|
}
|
|
78
|
-
function collectFromExpr(node,
|
|
79
|
-
if (!node
|
|
152
|
+
function collectFromExpr(node, scope, map, dmlBound) {
|
|
153
|
+
if (!node)
|
|
80
154
|
return;
|
|
81
155
|
if (node.A_Expr) {
|
|
82
156
|
const e = node.A_Expr;
|
|
83
157
|
const opName = e.name?.[0]?.String?.sval;
|
|
84
158
|
if (e.kind === "AEXPR_OP" && opName === "=") {
|
|
85
|
-
tryBind(e.lexpr, e.rexpr,
|
|
86
|
-
tryBind(e.rexpr, e.lexpr,
|
|
159
|
+
tryBind(e.lexpr, e.rexpr, scope, map, dmlBound);
|
|
160
|
+
tryBind(e.rexpr, e.lexpr, scope, map, dmlBound);
|
|
87
161
|
}
|
|
88
162
|
if (e.kind === "AEXPR_IN") {
|
|
89
|
-
const
|
|
90
|
-
if (
|
|
91
|
-
const list = Array.isArray(e.rexpr) ? e.rexpr : [];
|
|
163
|
+
const target = targetOfColumnRef(e.lexpr, scope);
|
|
164
|
+
if (target) {
|
|
165
|
+
const list = Array.isArray(e.rexpr) ? e.rexpr : e.rexpr?.List?.items ?? [];
|
|
92
166
|
for (const item of list) {
|
|
93
167
|
const pn = paramNumber(item);
|
|
94
168
|
if (pn !== null)
|
|
95
|
-
map
|
|
169
|
+
bindParam(map, dmlBound, pn, target, false);
|
|
96
170
|
}
|
|
97
171
|
}
|
|
98
172
|
}
|
|
99
173
|
}
|
|
100
174
|
}
|
|
101
|
-
function tryBind(colSide, valSide,
|
|
102
|
-
const
|
|
175
|
+
function tryBind(colSide, valSide, scope, map, dmlBound) {
|
|
176
|
+
const target = targetOfColumnRef(colSide, scope);
|
|
103
177
|
const pn = paramNumber(valSide);
|
|
104
|
-
if (
|
|
105
|
-
map
|
|
178
|
+
if (target && pn !== null)
|
|
179
|
+
bindParam(map, dmlBound, pn, target, false);
|
|
106
180
|
}
|
|
107
|
-
function
|
|
181
|
+
function bindParam(map, dmlBound, pn, target, dml) {
|
|
182
|
+
if (!dml && dmlBound.has(pn))
|
|
183
|
+
return;
|
|
184
|
+
map.set(pn, target);
|
|
185
|
+
if (dml)
|
|
186
|
+
dmlBound.add(pn);
|
|
187
|
+
}
|
|
188
|
+
function stringFields(node) {
|
|
108
189
|
if (!node?.ColumnRef)
|
|
109
190
|
return null;
|
|
110
191
|
const fields = node.ColumnRef.fields;
|
|
@@ -112,7 +193,25 @@ function colNameOf(node) {
|
|
|
112
193
|
return null;
|
|
113
194
|
if (fields.some((f) => f.A_Star !== undefined))
|
|
114
195
|
return null;
|
|
115
|
-
|
|
196
|
+
const out = fields.map((f) => f.String?.sval);
|
|
197
|
+
return out.every((s) => typeof s === "string") ? out : null;
|
|
198
|
+
}
|
|
199
|
+
function targetOfColumnRef(node, scope) {
|
|
200
|
+
const fields = stringFields(node);
|
|
201
|
+
if (!fields)
|
|
202
|
+
return null;
|
|
203
|
+
const column = fields[fields.length - 1];
|
|
204
|
+
if (fields.length === 1) {
|
|
205
|
+
return scope.defaultRel ? { ...scope.defaultRel, column } : null;
|
|
206
|
+
}
|
|
207
|
+
if (fields.length === 2) {
|
|
208
|
+
const qualifier = fields[0];
|
|
209
|
+
const rel = scope.aliases.get(qualifier) ?? scope.relations.find((r) => r.table === qualifier);
|
|
210
|
+
return rel ? { ...rel, column } : { table: qualifier, column };
|
|
211
|
+
}
|
|
212
|
+
const table = fields[fields.length - 2];
|
|
213
|
+
const schema = fields[fields.length - 3];
|
|
214
|
+
return { schema, table, column };
|
|
116
215
|
}
|
|
117
216
|
function paramNumber(node) {
|
|
118
217
|
if (node?.ParamRef && typeof node.ParamRef.number === "number")
|
|
@@ -129,14 +228,56 @@ function relOf(relation) {
|
|
|
129
228
|
return null;
|
|
130
229
|
return { schema: relation.schemaname || undefined, table: name };
|
|
131
230
|
}
|
|
132
|
-
function
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
231
|
+
function insertTarget(rel, cols, index) {
|
|
232
|
+
const column = cols[index];
|
|
233
|
+
return column ? { ...rel, column } : { ...rel, columnIndex: index + 1 };
|
|
234
|
+
}
|
|
235
|
+
function scopeFromSelect(select) {
|
|
236
|
+
const scope = scopeFromRelations([], null);
|
|
237
|
+
addRangeVars(select?.fromClause ?? [], scope);
|
|
238
|
+
if (scope.relations.length === 1)
|
|
239
|
+
scope.defaultRel = scope.relations[0];
|
|
240
|
+
return scope;
|
|
241
|
+
}
|
|
242
|
+
function scopeFromRelations(relations, defaultRel) {
|
|
243
|
+
const scope = { aliases: new Map(), relations: [], defaultRel };
|
|
244
|
+
for (const rel of relations)
|
|
245
|
+
addRelation(scope, rel, rel.table);
|
|
246
|
+
return scope;
|
|
247
|
+
}
|
|
248
|
+
function scopeFromRelationNode(relation, rel) {
|
|
249
|
+
const scope = scopeFromRelations([rel], rel);
|
|
250
|
+
const alias = relation?.alias?.aliasname;
|
|
251
|
+
if (typeof alias === "string")
|
|
252
|
+
scope.aliases.set(alias, rel);
|
|
253
|
+
return scope;
|
|
254
|
+
}
|
|
255
|
+
function addRelation(scope, rel, alias) {
|
|
256
|
+
scope.relations.push(rel);
|
|
257
|
+
scope.aliases.set(alias, rel);
|
|
258
|
+
}
|
|
259
|
+
function addRangeVars(nodes, scope) {
|
|
260
|
+
for (const node of nodes) {
|
|
261
|
+
if (node?.RangeVar) {
|
|
262
|
+
const rel = relOf(node.RangeVar);
|
|
263
|
+
if (!rel)
|
|
264
|
+
continue;
|
|
265
|
+
const alias = node.RangeVar.alias?.aliasname ?? rel.table;
|
|
266
|
+
addRelation(scope, rel, alias);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (node?.JoinExpr) {
|
|
270
|
+
addRangeVars([node.JoinExpr.larg, node.JoinExpr.rarg], scope);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
function walkJoinQuals(nodes, scope, map, dmlBound) {
|
|
275
|
+
for (const node of nodes) {
|
|
276
|
+
if (!node?.JoinExpr)
|
|
277
|
+
continue;
|
|
278
|
+
walkExpr(node.JoinExpr.quals, scope, map, dmlBound);
|
|
279
|
+
walkJoinQuals([node.JoinExpr.larg, node.JoinExpr.rarg], scope, map, dmlBound);
|
|
280
|
+
}
|
|
140
281
|
}
|
|
141
282
|
function walkForceNullable(node, forceContext, out) {
|
|
142
283
|
if (node === null || node === undefined)
|