@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 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, mismatched parameter types, stale queries after a migration all become compile errors.
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), so both the runtime and the CLI work on **Node ≥ 18, Bun, and Deno** the CLI ships a `#!/usr/bin/env node` shebang and uses no runtime-specific APIs.
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", ...)` — typed exactly like inline queries. Watch mode re-prepares on `.sql` edits too.
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
- - **Cross-runtime**: one Postgres.js-backed runtime that works on Node, Bun, and Deno — no runtime-specific adapter to choose.
44
- - **Watch mode**: ~15ms incremental re-prepare on file change.
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`, and `statement_timeout` (milliseconds) are also honored when provided as URL parameters.
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 created file (`migrations/0001_init.up.sql`):
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 non-squash `.down.sql` restores the schema, prepares SQL queries against the resulting schema, writes `.sqlx-js/` and `sqlx-js-env.d.ts`, then drops the shadow database.
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. The path is resolved against the source file at scan time (so `prepare` can read it), and against `process.cwd()` at runtime (so the running process can read it). Both must point at the same content.
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; the path becomes the type key.
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 }); // error is a PgError
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 works on any runtime (Node/Bun/Deno) and 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.
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 — whatever the adapter — 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.
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] [--migrations <dir>]
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 cache matches sources, no database required. |
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` / `migrate revert`. |
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
- All flags accept both `--flag value` and `--flag=value` forms.
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. Supported URL search params: `sslmode`, `application_name`, `connect_timeout`.
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 non-squash `.down.sql` restores the schema, 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.
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; namespacing isn't required):
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)` path is matched literally between scan time and runtime they must agree on the working directory. Document a convention for your team (e.g. always run from the repo root).
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
 
@@ -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 ?sslmode=require|verify-ca|verify-full)
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: validate cache vs sources, no DB
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
@@ -7,6 +7,7 @@ export type CacheColumn = {
7
7
  };
8
8
  export type CacheEntry = {
9
9
  query: string;
10
+ inlineQueries?: string[];
10
11
  paramOids: number[];
11
12
  paramTsTypes: string[];
12
13
  paramNullable?: boolean[];
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.replace(/\s+/g, " ").trim();
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;
@@ -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 inlineEntries = entries.filter((e) => {
35
- if (e.hasInline === true)
36
- return true;
37
- if (e.hasInline === false)
38
- return false;
39
- return !e.filePaths || e.filePaths.length === 0;
40
- });
41
- for (const e of inlineEntries.slice().sort((a, b) => a.query.localeCompare(b.query))) {
42
- if (inlineSeen.has(e.query))
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(e.query);
45
- const { params, row } = entrySignature(e);
46
- if (e.degraded)
47
- lines.push(` /** Nullability inference degraded: ${e.degraded.reason}. All result columns conservatively typed as nullable. */`);
48
- lines.push(` ${JSON.stringify(e.query)}: { params: ${params}; row: ${row} };`);
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 decl = lookupJsonbType(cfg, target.schema ?? "public", target.table, target.column);
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 col = schema.columnsOf(oid)?.get(t.column);
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) => cache.read(u.fp)).filter(Boolean);
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;
@@ -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";
@@ -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: "unknown" },
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: "unknown" },
48
+ 3802: { ts: JSON_VALUE },
48
49
  3904: { ts: "string" },
49
50
  3906: { ts: "string" },
50
51
  3908: { ts: "string" },
@@ -1,7 +1,8 @@
1
1
  export type ParamTarget = {
2
2
  schema?: string;
3
3
  table: string;
4
- column: string;
4
+ column?: string;
5
+ columnIndex?: number;
5
6
  };
6
7
  export type ParamMap = Map<number, ParamTarget>;
7
8
  export type ParamMapResult = {
@@ -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
- walkWhere(stmt.SelectStmt.whereClause, defaultRel(stmt.SelectStmt), targets);
15
+ walkSelect(stmt.SelectStmt, targets, dmlBound);
16
16
  else if (stmt.DeleteStmt)
17
- walkWhere(stmt.DeleteStmt.whereClause, relOf(stmt.DeleteStmt.relation), targets);
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.set(pn, { ...rel, column: colName });
38
- dmlBound.add(pn);
35
+ bindParam(map, dmlBound, pn, insertTarget(rel, cols, i), true);
39
36
  }
40
37
  }
41
38
  }
42
- if (ins.returningList) {
43
- for (const rt of ins.returningList) {
44
- collectFromExpr(rt?.ResTarget?.val, rel, map);
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
- walkWhere(ins.whereClause, rel, map);
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.set(pn, { ...rel, column: colName });
60
- dmlBound.add(pn);
80
+ bindParam(map, dmlBound, pn, { ...rel, column: colName }, true);
61
81
  }
62
82
  }
63
- walkWhere(upd.whereClause, rel, map);
83
+ walkExpr(upd.whereClause, scope, map, dmlBound);
64
84
  }
65
- function walkWhere(node, defaultRel, map) {
66
- if (!node || !defaultRel)
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
- walkWhere(a, defaultRel, map);
108
+ walkExpr(a, scope, map, dmlBound);
71
109
  return;
72
110
  }
73
111
  if (node.A_Expr) {
74
- collectFromExpr(node, defaultRel, map);
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, defaultRel, map) {
79
- if (!node || !defaultRel)
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, defaultRel, map);
86
- tryBind(e.rexpr, e.lexpr, defaultRel, map);
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 colName = colNameOf(e.lexpr);
90
- if (colName) {
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.set(pn, { ...defaultRel, column: colName });
169
+ bindParam(map, dmlBound, pn, target, false);
96
170
  }
97
171
  }
98
172
  }
99
173
  }
100
174
  }
101
- function tryBind(colSide, valSide, defaultRel, map) {
102
- const colName = colNameOf(colSide);
175
+ function tryBind(colSide, valSide, scope, map, dmlBound) {
176
+ const target = targetOfColumnRef(colSide, scope);
103
177
  const pn = paramNumber(valSide);
104
- if (colName !== null && pn !== null)
105
- map.set(pn, { ...defaultRel, column: colName });
178
+ if (target && pn !== null)
179
+ bindParam(map, dmlBound, pn, target, false);
106
180
  }
107
- function colNameOf(node) {
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
- return fields[fields.length - 1]?.String?.sval ?? null;
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 defaultRel(select) {
133
- const from = select?.fromClause;
134
- if (!Array.isArray(from) || from.length !== 1)
135
- return null;
136
- const node = from[0];
137
- if (node?.RangeVar)
138
- return relOf(node.RangeVar);
139
- return null;
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onreza/sqlx-js",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Compile-time-checked raw SQL for TypeScript + PostgreSQL. Inspired by sqlx.",
5
5
  "license": "MIT",
6
6
  "type": "module",