diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..65019d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: A query builds to the wrong text/values, or an error you didn't expect +title: '' +labels: bug +assignees: '' +--- + +**What you called** + +```javascript +const { pg } = require('simple-builder') // or mysql +pg([ /* your partials */ ]) +``` + +**What you got** + +``` +{ text: '...', values: [...] } // or the error message +``` + +**What you expected** + +``` +{ text: '...', values: [...] } +``` + +**Environment** + +- simple-builder version: +- Node.js version: +- Driver (pg / mysql / mysql2): diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..750ce10 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,23 @@ +--- +name: Feature request +about: Suggest an addition that keeps the SQL visible and the API tiny +title: '' +labels: enhancement +assignees: '' +--- + +**The problem** + +What are you trying to write, and how does it feel awkward today? + +**The query you'd like to write** + +```javascript +pg([ /* the ideal partials */ ]) +// desired: { text: '...', values: [...] } +``` + +**Notes** + +Anything about dialect differences (pg vs mysql), backward compatibility, or +alternatives you considered. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..14c6e95 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,105 @@ +name: CI + +on: + push: + branches: [master, main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [16.x, 18.x, 20.x, 22.x, 24.x] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test + + fuzz: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: npm install + - name: Fuzz — differential vs the 2.4.2 oracle + sql-tag property fuzz + run: npm run fuzz -- 20000 + + # Executes the built queries against real servers and asserts on the rows that + # come back. The unit suite only ever proves the builder agrees with OUR + # reading of the SQL grammars — every lexer bug found so far was exactly that + # kind of mistake, so this is the gate that actually retires the risk. + integration: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: secret + POSTGRES_DB: sbtest + ports: ['5432:5432'] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 10 + mysql: + image: mysql:8 + env: + MYSQL_ROOT_PASSWORD: secret + MYSQL_DATABASE: sbtest + ports: ['3306:3306'] + options: >- + --health-cmd "mysqladmin ping -uroot -psecret --silent" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: npm install + - name: Integration tests against real Postgres + MySQL + env: + PG_URL: postgres://postgres:secret@localhost:5432/sbtest + MYSQL_URL: mysql://root:secret@127.0.0.1:3306/sbtest + run: npm run test:integration + + package-boundary: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + - run: npm install + - name: Lint the published package surface (publint + are-the-types-wrong) + run: | + npm run build + npx --yes publint@latest --strict + npx --yes @arethetypeswrong/cli@latest --pack . --ignore-rules cjs-resolves-to-esm + - name: Pack tarball + run: | + npm pack --pack-destination /tmp + echo "TARBALL=$(ls /tmp/simple-builder-*.tgz)" >> "$GITHUB_ENV" + - name: Install into a fresh consumer and run the smoke tests (CJS + ESM) + run: | + mkdir -p /tmp/consumer && cd /tmp/consumer + npm init -y > /dev/null + npm install "$TARBALL" + cp "$GITHUB_WORKSPACE/test/boundary/smoke.cjs" "$GITHUB_WORKSPACE/test/boundary/smoke.mjs" . + node --unhandled-rejections=strict smoke.cjs + node --unhandled-rejections=strict smoke.mjs + - name: Typings gate — latest TypeScript compiles the installed d.ts + run: | + cd /tmp/consumer + cp "$GITHUB_WORKSPACE/test/boundary/consumer.ts" . + npm install --no-save typescript@latest + ./node_modules/.bin/tsc --noEmit --strict --target es2017 --lib es2017 --module node16 --moduleResolution node16 consumer.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..dfe00e1 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,76 @@ +name: Publish + +# Publishes to npm with provenance attestations. Trigger by publishing a +# GitHub Release (tag `vX.Y.Z`), or manually via workflow_dispatch. +# +# One-time setup: add an npm Automation token as the `NPM_TOKEN` repository +# secret (Settings → Secrets and variables → Actions), OR configure a trusted +# publisher on npmjs.com (OIDC, no token needed). Provenance requires a public +# repo and the `id-token: write` permission below. + +on: + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + id-token: write # required for npm provenance (OIDC) + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22.x + registry-url: 'https://registry.npmjs.org' + + - run: npm install + + - name: Guard — tag matches package.json version + if: github.event_name == 'release' + # The tag is passed through `env:`, never interpolated into the script + # body: `${{ }}` expands before bash runs, so a tag name containing shell + # metacharacters (git permits $ ` " ; |) would otherwise execute here — + # in a job holding id-token: write and NPM_TOKEN. + env: + TAG: ${{ github.event.release.tag_name }} + run: | + PKG_VERSION="$(node -p "require('./package.json').version")" + if [ "v$PKG_VERSION" != "$TAG" ] && [ "$PKG_VERSION" != "$TAG" ]; then + echo "::error::package.json is $PKG_VERSION but the release tag is $TAG" + exit 1 + fi + + - name: Full gate — build + type gate + unit suite + run: npm test + + - name: Full gate — differential + property fuzz + run: npm run fuzz -- 20000 + + - name: Full gate — package surface (publint + are-the-types-wrong) + run: | + npx --yes publint@latest --strict + npx --yes @arethetypeswrong/cli@latest --pack . --ignore-rules cjs-resolves-to-esm + + - name: Full gate — package-boundary smoke (CJS + ESM) from the tarball + run: | + npm pack --pack-destination /tmp + TARBALL="$(ls /tmp/simple-builder-*.tgz)" + mkdir -p /tmp/consumer && cd /tmp/consumer + npm init -y > /dev/null + npm install "$TARBALL" + cp "$GITHUB_WORKSPACE/test/boundary/smoke.cjs" "$GITHUB_WORKSPACE/test/boundary/smoke.mjs" . + node --unhandled-rejections=strict smoke.cjs + node --unhandled-rejections=strict smoke.mjs + + - name: Publish with provenance + run: | + npm install -g npm@latest + npm --version + npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..573e469 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,151 @@ +# Agent guide — simple-builder + +Zero-dependency npm package: a tiny SQL builder. The implementation is one file, +`src/index.ts` (~540 lines with comments, ~16 kB compiled); everything else is +tests and packaging. + +## Consumer API + +See `llms.txt` for the complete API, lexing rules, security model, recipes, and +gotchas in one file. It is the primary surface for AI agents and ships in the +npm tarball, so it lands at `node_modules/simple-builder/llms.txt`. Keep it in +sync with the API — its examples are executed by `npm test`, so a wrong one +fails the build, but a *missing* one fails silently. The other two agent-facing +surfaces are the shipped `dist/index.d.ts` (JSDoc + `@example` blocks, which is +what an LSP hover shows) and the runtime error messages — all three should teach +the fix, not just state the problem. + +## Working on this repo + +- Build: `npm run build` (tsc → `dist/`). `dist/` is git-ignored; `prepare` + builds on install/publish. +- Test: `npm test` — builds, compiles `test/types.test.ts` (exact-type + assertions; a wrong inference is a build failure), runs the unit suite under + `--unhandled-rejections=strict`, then runs the doc tests. +- Docs are executable: `test/docs.test.cjs` parses every ```javascript block in + `README.md` and `llms.txt`, runs it, and compares against the documented + result. Docs are what humans AND AI agents copy verbatim, so drift is a build + failure. To be checked, an example must be a `pg(…)`/`mysql(…)` expression + followed by a `// { … }` comment; anything else in the block is treated as + setup and evaluated. Ellipses (`values: [...]`) are rejected — write the real + value. A floor on the checked count guards against the parser silently + matching nothing. +- Fuzz: `npm run fuzz -- [iterations] [seed]` — see below. Failures print the + seed for exact reproduction. +- Integration: `npm run db:up` (docker Postgres + MySQL), then + `npm run test:integration`; `npm run db:down` to clean up. CI runs this via + service containers. **This is the only gate that checks the lexer against the + engines rather than against our reading of their manuals** — every lexer bug + found so far (pg `E'…'` escapes, MySQL's whitespace-after-`--` rule) was a + grammar misreading that unit tests happily confirmed. Add a case here whenever + you touch `lex()`. +- Package surface: `npx publint --strict` and `npx @arethetypeswrong/cli --pack .` + must both stay clean; CI gates on them. + +## Architecture + +`src/index.ts`, in dependency order: + +1. **Identifiers** — `assertPlainIdentifier` (allow-list for object keys) and + `quoteIdentifier` (dialect quoting for `sql.id`). Identifiers can never be + parameterised, so these are the only things that write non-value text. +2. **Nodes / `Sql`** — the shared representation: `text` | `value` | `id` nodes. + Both APIs lower to this. +3. **`lex()`** — the reason the `?` API is correct. Skips string literals, + quoted identifiers, line/block comments, and dollar-quoted bodies; treats + `?|`/`?&`/`??` as operators; honours `\?` as an escaped literal `?`. Only + what survives all that is a placeholder. + + Lexing settings that differ per server (`Mode`) are threaded in via + `withMode`; `makeBuild` closes over them. Defaults match a stock server, so + the option is inert unless set. + + Every dialect difference here is load-bearing and verified against real + servers by `test/integration.cjs` — do not "simplify" any of them: + - Quote runs go through `consumeQuoted`, dialect-aware about backslash + escapes: they apply in MySQL and in a Postgres `E'…'` escape string, but + NOT in a standard pg string (`standard_conforming_strings` makes `\` + ordinary). Getting this wrong binds a value *inside* a string literal. + - `--` is a comment in MySQL only when followed by whitespace or EOF + (`SELECT 1--2` is 3 there); Postgres always treats it as a comment. + - `#` is a comment in MySQL only; in Postgres it starts `#>` / `#-`. + - Under `ANSI_QUOTES`, MySQL `"…"` is an identifier (doubling only), and + `"a\"b"` is a *syntax error* on the server — so backslash escaping must be + off for `"` in that mode. Under `NO_BACKSLASH_ESCAPES`, `\` is ordinary in + every literal. + +## Facts measured against real servers (do not "correct" from memory) + +MySQL 8.4 / PG 16, recorded because each one contradicts a plausible assumption: + +- `''` doubling lexes identically in **every** mode of both engines. It is the + only mode-proof escape. The `sql` tag never lexes, so it is mode-proof too. +- Placeholder-count strictness: **pg rejects both too-few and too-many** params; + **MySQL `execute()` rejects too-few but silently ignores extras**. So the + server is a full oracle for pg and only a half-oracle for MySQL — integration + tests must assert on returned ROWS, not merely that the query ran. +- mysql2's `query()` escapes values client-side with backslashes, which is wrong + under `NO_BACKSLASH_ESCAPES` (errors; round-trips `x\` as `x\\`). The + integration suite therefore drives MySQL through `execute()`, which also means + MySQL's own parser — not mysql2's scanner — validates our text. +4. **`classify()`** — positional clause detection from the text immediately + before each placeholder (`\bVALUES\s+$` etc). `\b` is load-bearing: it keeps + `OFFSET ?` from reading as `SET ?` and `JOIN ?` from reading as `IN ?`. +5. **`Renderer`** — owns placeholder numbering and the values array; `pg` emits + `$n`, `mysql` emits `?`. +6. **`buildPartials`** / **`buildSql`** — the two front ends. +7. **`pg` / `mysql`** — dialect-bound entry points that accept either API. + +ESM entry is a static wrapper (`esm/index.mjs`) re-exporting the CJS build — do +NOT introduce a second compiled implementation (dual-package hazard). When you +add an export, update `esm/index.mjs` AND `esm/index.d.mts`. + +## Invariants you must not break (enforced by tests + fuzzers + CI) + +1. **The `?` API's output is byte-identical to 2.4.2** for all documented usage. + The differential fuzzer is the guard — keep it green. +2. Values are ALWAYS parameterised and never appear in `text`, in both APIs. + Only identifiers (allow-listed keys, or quoted `sql.id`) and `sql.raw` write + text. +3. `pg` renders exactly `$1..$n` in interpolation order; `mysql` renders `n` `?`. +4. Rendering is pure: the same `Sql` fragment renders identically, repeatedly, + for either dialect. +5. The caller's partials array is never mutated. +6. `values` is omitted from the result when no value was bound. +7. Both CJS `require` and ESM `import` resolve to the same `{ pg, mysql, sql }`. + +## The two fuzzers (`test/fuzz.cjs`) + +- **Differential** — random well-formed queries through both the current build + and the vendored 2.4.2 builder (`test/legacy-oracle.cjs`); output must match + byte-for-byte. Its generator deliberately stays inside the domain where the + two MUST agree. +- **Property** — the `sql` tag and lexer have no 2.4.2 counterpart, so they are + checked against invariants 2–4 above using injection-shaped "poison" strings. + Value poison and identifier poison are kept DISJOINT, and placeholder counting + strips quoted identifiers **dialect-aware** (backticks are not quotes in pg) — + both are checker correctness requirements, not cosmetics. +- **Marker** — routes the marker classes through `buildPartials` (the tag fuzzer + never does), asserting that a marker's internal field names (`parts`, `nodes`, + `value`, `text`) never surface as SQL identifiers. This guards a real bug: + markers are objects, so a clause marker before the `?` used to enumerate them + as a Row and emit `WHERE parts=$1`. Verify changes here by reintroducing that + bug and confirming the fuzzer fails. + +## Intentional 2.4.2 → 3.0.0 behaviour changes + +Kept OUT of the differential fuzzer's domain (covered by unit tests instead): + +- Lowercase `in ?` now expands (was left as a literal placeholder). +- An empty object to `VALUES ?`/`SET ?`/`WHERE ?` now throws. +- A non-identifier object key now throws. +- Fewer values than placeholders now throws (was binding `undefined`). +- `OFFSET ?` is no longer misread as `SET ?`. +- `?` inside literals/identifiers/comments/dollar-quotes is no longer a + placeholder; `?|`/`?&` are operators. + +## Releasing + +Bump `version` in `package.json`, then publish a GitHub Release tagged `vX.Y.Z`. +The publish workflow re-runs the full gate and publishes to npm with provenance. +See `.github/workflows/publish.yml`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..440e66e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,4 @@ +# CLAUDE.md + +See [AGENTS.md](./AGENTS.md) for the repo layout, build, test, and release +workflow. Consumer-facing API and semantics live in [llms.txt](./llms.txt). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6785347 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Ondrej Machek + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 3b264c0..ff003e6 100644 --- a/README.md +++ b/README.md @@ -1,222 +1,302 @@ # simple-builder -### Simple SQL query string builder +A tiny SQL builder that keeps your SQL **visible**. You write real SQL; it only +handles the tedious parts — numbering placeholders, expanding objects and lists, +and quoting identifiers. -## Installation +Zero dependencies. ~16 kB. First-class TypeScript types. CJS **and** ESM. -```javascript +```bash npm install simple-builder --save ``` -## Motivation +> **Using an AI coding agent?** The complete API, semantics, recipes, and +> gotchas are in [`llms.txt`](./llms.txt) — one file, no prose, written to be +> read by an LLM. It ships inside the npm tarball, so it is already on disk at +> `node_modules/simple-builder/llms.txt`. Every example in it is executed and +> verified on each CI run. -SQL query builders (such as `squel.js`) often feel heavy. It is not easy to create your SQL query without reading the documentation. +## Why -The aim of `simple-builder` is +Most query builders hide your SQL behind a fluent API you have to learn. +`simple-builder` does the opposite — the SQL in your source *is* the SQL that +runs. It returns `{ text, values }`, ready for the `pg`, `mysql`, and `mysql2` +drivers. -- to NOT obscure the actual SQL code -- to make use of your existing SQL knowledge -- to make queries easy-to-read and easy-to-follow -- to eliminate annoyances of different client libraries +## Two ways to write a query -## API +```javascript +const { pg, sql } = require('simple-builder') // or: import { pg, sql } from 'simple-builder' -### `build(Array partials)` -> `Object` -### `build(Arguments partials)` -> `Object` +// 1. Tagged template — recommended +pg(sql`SELECT * FROM users WHERE email = ${email}`) -The `partials` can be either an `Array` or plain function `arguments` consisting of: +// 2. Partials + `?` — the classic API, unchanged +pg(['SELECT * FROM users WHERE email = ?', email]) -- strings -- numbers -- arrays -- boolean values -- objects +// both → { text: 'SELECT * FROM users WHERE email = $1', values: ['john@doe.wtf'] } +``` -All of them are partial values for constructing query `Object`. +`pg` renders `$1, $2, …`; `mysql` (and `mysql2`) keep `?`. That is the only +difference between them. ```javascript -var email = "john@doe.wtf" -var partials = [ "SELECT * FROM users WHERE email = ?", email ] +const query = pg(sql`SELECT * FROM users WHERE id = ${id}`) +const rows = await db.query(query.text, query.values) ``` -Every query is constructed by creating a `partials` array and passing it to the `simple-builder`. +The result always has `text`; `values` is present only when the query bound at +least one value. -```javascript -var build = require("simple-builder").pg +--- -var query = build(partials) -// { text: "SELECT * FROM users WHERE email = $1", values: [ "john@doe.wtf" ] } -``` +## The `sql` tag (recommended) -The output of `simple-builder` is always an `Object` containing: +Every `${interpolation}` is **always** a bound parameter. You cannot forget to +parameterise something, which makes accidental injection structurally +impossible: -- `text` property -- `values` property +```javascript +const evil = "1; DROP TABLE users; --" +pg(sql`SELECT * FROM users WHERE id = ${evil}`) +// { text: 'SELECT * FROM users WHERE id = $1', values: ["1; DROP TABLE users; --"] } +``` -The `values` property may not be set when there was no variable in your `partials` array. +### Composition + +Fragments nest. Build queries conditionally without string glue: ```javascript -var rows = yield db.query(query.text, query.values) +const active = onlyActive ? sql`AND active = ${true}` : sql.empty +pg(sql`SELECT * FROM users WHERE org = ${org} ${active}`) ``` -## Syntax +```javascript +const where = sql.join([sql`a = ${1}`, sql`b = ${2}`], ' AND ') +pg(sql`SELECT * FROM t WHERE ${where}`) +// { text: 'SELECT * FROM t WHERE a = $1 AND b = $2', values: [1, 2] } +``` -The `partials` array is a mix of SQL code and variables that are to be inserted into the final query. +### Lists -For variable insertion, the `question mark syntax` is used. +An interpolated array expands to a parenthesised list — the `IN` form: ```javascript -var user_id = 1 -var partials = [ "SELECT * FROM users WHERE id = ?", user_id ] +pg(sql`SELECT * FROM users WHERE id IN ${[1, 2, 3]}`) +// { text: 'SELECT * FROM users WHERE id IN ($1,$2,$3)', values: [1, 2, 3] } ``` -For every SQL string in your `partials` array the number of `question marks` is retrieved. Matching number of variables is then expected right after this SQL string. +Use `sql.value()` when you want an array bound as a *single* parameter (a +Postgres array or jsonb column): ```javascript -var user_id = 1 -var email = "john@doe.wtf" -var partials = [ "SELECT * FROM users WHERE id = ? AND email = ?", user_id, email ] +pg(sql`SELECT * FROM t WHERE tags = ${sql.value(['a', 'b'])}`) +// { text: 'SELECT * FROM t WHERE tags = $1', values: [['a','b']] } ``` -You are basically mixing SQL code and variables. +### Dynamic identifiers + +Identifiers can never be parameterised by any driver, so they get their own +helper. `sql.id()` quotes per dialect and escapes embedded quotes: ```javascript -var user_id = 1 -var partials = [ - "SELECT * FROM friends WHERE friend_id = ?", user_id, - "ORDER BY created_at" -] +pg(sql`SELECT * FROM ${sql.id('public', 'users')} WHERE id = ${1}`) +// { text: 'SELECT * FROM "public"."users" WHERE id = $1', values: [1] } -var query = build(partials) -// { text: "SELECT * FROM friends WHERE friend_id = $1 ORDER BY created_at", values: [ 1 ] } +mysql(sql`SELECT * FROM ${sql.id('my table')}`) +// { text: 'SELECT * FROM `my table`' } ``` -### `INSERT` query +> Quoted identifiers are **case-sensitive** in Postgres, where unquoted names +> fold to lower case. `sql.id('userName')` refers to a column literally named +> `userName`, not `username`. -Some query builders are making `INSERT` queries easy to write by using insertion object. +### Escape hatch + +`sql.raw()` inserts unescaped text. It is the only unsafe helper — never give it +user input: ```javascript -var insertion = { - username: "John Doe", - email: "john@doe.wtf" -} +pg(sql`SELECT * FROM t ORDER BY id ${sql.raw('DESC')}`) ``` -The keys of the `insertion` object represent database columns. +### API -```javascript -var partials = [ "INSERT INTO users VALUES ?", insertion ] +```typescript +sql`...` // → Sql fragment (nestable) +sql.id(...parts: string[]) // safe, dialect-quoted identifier +sql.value(v: unknown) // bind as exactly one parameter +sql.join(items: unknown[], sep = ', ') // join fragments/values +sql.raw(text: string) // unescaped SQL — unsafe with user input +sql.empty // renders to nothing + +pg(fragment) / mysql(fragment) // → { text, values? } ``` -The example above is the supported syntax for inserting an object. +--- + +## The `?` partials API + +A query is a list mixing SQL fragments and values. Pass an array, an argument +list, or a single ready string: ```javascript -var partials = [ "INSERT INTO users VALUES ?", insertion, "RETURNING id" ] +pg(['SELECT * FROM users WHERE id = ?', userId]) // array +pg('SELECT * FROM users WHERE id = ?', userId) // arguments +pg('SELECT * FROM users') // ready string → { text } ``` -When `VALUES ?` substring is found in the SQL partial, the next value is expected to be an `Object`. +For each SQL fragment the `?` are counted, and that many values must follow it: ```javascript -var query = build(partials) -// { text: "INSERT INTO users (username,email) VALUES ($1,$2) RETURNING id", values: [ "John Doe", "john@doe.wtf" ] } +pg([ + 'SELECT * FROM friends WHERE friend_id = ?', userId, + 'ORDER BY created_at', +]) +// { text: 'SELECT * FROM friends WHERE friend_id = $1 ORDER BY created_at', values: [1] } ``` -The `INSERT` queries can still be written by hand. +A bare array in fragment position becomes a comma-separated projection list: ```javascript -var partials = [ - "INSERT INTO users (username, email)", - "VALUES (?, ?)", insertion.username, insertion.email, - "RETURNING id" -] +pg(['SELECT', ['id', 'username'], 'FROM users']) +// { text: 'SELECT id,username FROM users' } ``` -### `UPDATE` query - -In a similar manner as the `INSERT`, `UPDATE` queries can also be written using update objects. +### Object & list expansion ```javascript -var update = { - username: "Biggie Smalls", - gender: "female" -} +// INSERT … VALUES ? +pg(['INSERT INTO users VALUES ?', { username: 'John', email: 'j@d.wtf' }, 'RETURNING id']) +// { text: 'INSERT INTO users (username,email) VALUES ($1,$2) RETURNING id', +// values: ['John', 'j@d.wtf'] } + +// UPDATE … SET ? +pg(['UPDATE users SET ?', { username: 'Biggie' }, 'WHERE id = ?', id]) +// { text: 'UPDATE users SET username=$1 WHERE id = $2', values: ['Biggie', 123] } + +// WHERE ? (AND-joined equality) +pg(['SELECT * FROM users WHERE ?', { username: 'x', gender: 'male' }]) +// { text: 'SELECT * FROM users WHERE username=$1 AND gender=$2', values: ['x', 'male'] } + +// WHERE … IN ? +pg(['SELECT * FROM users WHERE id IN ?', [1, 2, 3]]) +// { text: 'SELECT * FROM users WHERE id IN ($1,$2,$3)', values: [1, 2, 3] } ``` -The keys of the update object represent database columns. +### Question marks that aren't placeholders + +`simple-builder` lexes your SQL rather than counting `?`, so a `?` inside a +string literal, quoted identifier, comment, or dollar-quoted body is left alone, +and Postgres' `?|` / `?&` jsonb operators keep working: ```javascript -var current_username = "John Doe" -var partials = [ "UPDATE users SET ?", update, "WHERE username = ?", current_username ] +pg(["SELECT * FROM t WHERE tags ?| ARRAY['a'] AND id = ?", 5]) +// { text: "SELECT * FROM t WHERE tags ?| ARRAY['a'] AND id = $1", values: [5] } ``` -The example above is the supported syntax for updating with an object. +The **bare** jsonb `?` operator is genuinely ambiguous with a placeholder, so +escape it as `\?`: ```javascript -var query = build(partials) -// { text: "UPDATE users SET username=$1,gender=$2 WHERE username=$3", values: [ "Biggie Smalls", "female", "John Doe" ] } +pg(["SELECT * FROM t WHERE data \\? 'key' AND id = ?", 7]) +// { text: "SELECT * FROM t WHERE data ? 'key' AND id = $1", values: [7] } ``` -The `UPDATE` queries can still be written by hand. +…or just use the `sql` tag, where nothing is scanned and no escaping is needed: ```javascript -var partials = [ - "UPDATE users", - "SET username = ?, gender = ?", update.username, update.gender - "WHERE username = ?", current_username -] +pg(sql`SELECT * FROM t WHERE data ? ${'key'} AND id = ${7}`) ``` -## Examples +--- -```javascript -var build = require("simple-builder").mysql +## Security -// SELECT query -var query = build([ - "SELECT * FROM users", - "WHERE id = ? AND username = ?", user_id, username -]) +**Values are always parameterised.** They go into the `values` array and never +into the SQL text, so they cannot cause injection — in either API. -// { text: "SELECT * FROM users WHERE id = ? AND username = ?", values: [ 1, "John Doe" ] } +**Identifiers cannot be parameterised** by any driver — placeholders name data, +never tables or columns. So `simple-builder` handles them two ways: -var rows = yield db.query(query.text, query.values) +- **Object keys are allow-listed.** In `VALUES ?` / `SET ?` / `WHERE ?`, keys + become column names, so only plain identifiers (`col`, `tbl.col`) are + accepted. Anything else throws: -var build = require("simple-builder").pg + ```javascript + pg(['UPDATE users SET ?', req.body]) + // throws if req.body has a key like "x=1; DROP TABLE users; --" + ``` -// UPDATE query -var query = build([ - "UPDATE users SET ?", { username: "something", gender: "male" }, - "WHERE user_id = ? AND is_hidden = ?", user_id, false -]) + This is a backstop, not a licence — still choose the columns yourself: -// { "text": "UPDATE users SET username=$1,gender=$2 WHERE user_id = $3 AND is_hidden = $4", "values": [ "something", "male", 123, false ] } + ```javascript + pg(['UPDATE users SET ?', { username: req.body.username, email: req.body.email }]) + ``` -var rows = yield db.query(query.text, query.values) +- **`sql.id()` quotes** for the dynamic case, escaping embedded quotes. -// INSERT query -var query = build([ - "INSERT INTO", "users", - "VALUES ?", { username: "something", gender: "male" } -]) +**`sql.raw()` is unsafe by construction** — it exists for things like `ASC`/`DESC` +that cannot be parameterised. Map user input to a fixed set of allowed values +before it ever reaches `raw()`. -// { "text": "INSERT INTO users (username,gender) VALUES ($1,$2)", "values": [ "something", "male" ] } +Two things the library cannot do for you: -var rows = yield db.query(query.text, query.values) -``` +- **LIKE wildcards.** A bound value in `LIKE ?` is injection-safe, but `%` and + `_` inside it still act as wildcards. Escape them (`\%`, `\_`) if the value + should be literal. +- **Second-order injection.** Data read back out of the database is not + automatically safe to concatenate into new SQL — parameterise on the way out + too. + +### On MySQL, prefer `execute()` over `query()` -## Dependencies +This is about the driver, not this library, but it affects you: `mysql2`'s +`query()` substitutes values **client-side** using backslash escaping +(`escape("it's")` → `'it\'s'`). Under `sql_mode=NO_BACKSLASH_ESCAPES` that +escaping is wrong — we measured it erroring outright and round-tripping `x\` as +`x\\`. `execute()` uses a real server-side prepared statement and is correct in +every mode: -This library has no dependencies. +```javascript +const q = mysql(['SELECT * FROM users WHERE id = ?', id]) +const [rows] = await conn.execute(q.text, q.values) // ← prefer this +``` -## Limitations +## Non-default server modes -The output is only suitable for `mysql`, `mysql2` and `pg` drivers. +Three server settings change how SQL *lexes*, and therefore which `?` is a +placeholder. If you have changed them, tell the builder: ```javascript -var build = require("simple-builder").pg -var build = require("simple-builder").mysql +const my = mysql.withMode({ ansiQuotes: true, noBackslashEscapes: true }) +const pgOld = pg.withMode({ standardConformingStrings: false }) ``` +| Setting | Effect | +|---|---| +| `ansiQuotes` | MySQL `sql_mode=ANSI_QUOTES`: `"…"` is an identifier, not a string | +| `noBackslashEscapes` | MySQL `sql_mode=NO_BACKSLASH_ESCAPES`: `\` is ordinary in literals | +| `standardConformingStrings: false` | Postgres: `\` escapes inside `'…'` | + +`withMode` returns a new builder and leaves the original alone. **You only need +it if your SQL puts a backslash immediately before a quote inside a literal** — +`''` doubling lexes identically in every mode, and the `sql` tag never lexes at +all, so both are mode-proof. All of the above is verified against real Postgres +16 and MySQL 8.4 across the full mode matrix. + +## Requirements & compatibility + +- Node.js **>= 16**. Works with the `pg`, `mysql`, and `mysql2` drivers. +- The `?` partials API is unchanged from 2.x — same call shapes, same output. + Verified by a differential fuzzer against 2.4.2 on every CI run. +- See the [release notes](https://github.com/Acro/simple-builder/releases) for + the 3.0.0 packaging changes, bug fixes, and the new `sql` tag. + +## Contributing + +See [`AGENTS.md`](./AGENTS.md) for the repo layout, build, and test commands. + ## License -MIT \ No newline at end of file +MIT diff --git a/esm/index.d.mts b/esm/index.d.mts new file mode 100644 index 0000000..442f3d1 --- /dev/null +++ b/esm/index.d.mts @@ -0,0 +1,13 @@ +export { + default, + pg, + mysql, + sql, + Sql, + Dialect, + Value, + Row, + BuildResult, + Mode, + Build, +} from '../dist/index.js' diff --git a/esm/index.mjs b/esm/index.mjs new file mode 100644 index 0000000..4b0a280 --- /dev/null +++ b/esm/index.mjs @@ -0,0 +1,10 @@ +// Static ESM wrapper over the CJS build. Node's ESM loader exposes a CJS +// module's `module.exports` as the default import, so re-export the named +// bindings explicitly. Single implementation — no dual-package hazard. +import cjs from '../dist/index.js' + +export default cjs.default +export const pg = cjs.pg +export const mysql = cjs.mysql +export const sql = cjs.sql +export const Sql = cjs.Sql diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index ee29bc7..0000000 --- a/index.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -type QueryType = number | string | boolean; - -interface IBuilderResult { - text:string; - values:QueryType[]; -} - -interface IBuilder { - (query:QueryType[]) : IBuilderResult; -} - -declare module 'simple-builder' { - export const pg:IBuilder; - export const mysql:IBuilder; -} diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..8a9986b --- /dev/null +++ b/llms.txt @@ -0,0 +1,228 @@ +# simple-builder + +> Tiny SQL builder that keeps your SQL visible. Two APIs — a tagged template +> (`sql`) and the classic `?` partials list — both returning `{ text, values }` +> for the pg, mysql and mysql2 drivers. Zero dependencies, ~16 kB. CJS + ESM. +> Node >= 16. First-class TypeScript types. + +## Install + +npm install simple-builder + +## Import (both work everywhere) + +CJS: const { pg, mysql, sql } = require('simple-builder') + const simpleBuilder = require('simple-builder').default // { pg, mysql, sql } +ESM: import { pg, mysql, sql } from 'simple-builder' + import simpleBuilder from 'simple-builder' // { pg, mysql, sql } + +## Renderers + +pg(x): { text, values? } // renders $1, $2, … placeholders +mysql(x): { text, values? } // keeps ? placeholders (also mysql2) + +`x` is one of: + - an Sql fragment: pg(sql`... ${v}`) + - an array of partials/values: pg(['... ?', v]) + - an argument list: pg('... ?', v) + - a single ready SQL string: pg('SELECT * FROM t') -> { text } + +Result always has `text`; `values` is present only when >= 1 value was bound. +Rendering is pure — the same fragment can be rendered for both dialects. + +## The sql tag (recommended) + +sql`SELECT * FROM t WHERE id = ${id}` // every ${} is ALWAYS a bound parameter + +sql.id(...parts) // dialect-quoted identifier: sql.id('public','users') -> "public"."users" +sql.value(v) // bind v as exactly ONE parameter (use for pg array/jsonb columns) +sql.join(items, sep = ', ') // join fragments/values +sql.raw(text) // UNESCAPED text — unsafe with user input +sql.empty // renders to nothing (useful for conditionals) + +- Fragments NEST: sql`... ${sql`a = ${1}`} ...` +- An interpolated ARRAY expands to a parenthesised list: `id IN ${[1,2,3]}` -> `id IN ($1,$2,$3)` +- Nothing is scanned for `?` in the tag, so jsonb `?` operators need no escaping. + +## The ? partials API (classic, unchanged from 2.x) + +- For each SQL string fragment, its `?` count determines how many of the + following partials are consumed as bound values. Too few values throws. +- A bare array in fragment position is a projection list: ['id','name'] -> "id,name". +- Objects/arrays expand when the fragment ends with a marker (case-insensitive): + "... VALUES ?" + object -> "(k1,k2) VALUES ($1,$2)" (INSERT) + "... SET ?" + object -> "SET k1=$1,k2=$2" (UPDATE) + "... WHERE ?" + object -> "WHERE k1=$1 AND k2=$2" (equality, AND-joined) + "... IN ?" + array -> "IN ($1,$2,$3)" +- Markers work in BOTH fragment and value position and always splice, never expand: + pg(['SELECT * FROM', sql.id(t), 'WHERE id = ?', 1]) // fragment position + pg(['SELECT * FROM t WHERE ?', sql`a = ${1}`]) // value position + An sql`` fragment spliced at a value position keeps placeholder numbering continuous. +- Values (Date, null, false, bigint) are always parameterised. + +## Lexing: `?` that is not a placeholder + +The SQL is LEXED, not scanned. A `?` is left alone inside: + - single-quoted string literals ('a?b'). '' escapes everywhere; backslash + escapes only in MySQL and in a Postgres E'a\'b ?' escape string (a standard + pg string treats \ as an ordinary char — standard_conforming_strings). + - quoted identifiers ("a?b" and `a?b`); MySQL "..." also honours \ escapes + - line comments (-- ?), MySQL # comments (# ?), block comments (/* ? */, nested) + NOTE: # is a comment in MySQL only — in pg it starts #> / #- jsonb operators. + NOTE: MySQL needs whitespace after `--` for a comment (verified on 8.4): + `SELECT 1--?` is NOT a comment there (it is 1 minus -?, and ? binds); + `SELECT 1-- ?` is. Postgres always treats `--` as a comment. + - dollar-quoted bodies ($$a ? b$$, $tag$a ? b$tag$) +Postgres `?|` and `?&` jsonb operators are recognised and preserved. +The BARE jsonb `?` operator is ambiguous — escape it as `\?` (or use the tag). + +## Non-default server modes (lexing only; the tag is immune) + +pg.withMode(opts) / mysql.withMode(opts) -> a NEW builder (original unchanged). + + ansiQuotes: true MySQL sql_mode=ANSI_QUOTES ("..." is an identifier) + noBackslashEscapes: true MySQL sql_mode=NO_BACKSLASH_ESCAPES (\ is ordinary) + standardConformingStrings: false Postgres: \ escapes inside '...' (default true) + +Only needed when your SQL has a backslash immediately before a quote inside a +literal. `''` doubling lexes the same in EVERY mode, and the sql tag never lexes. +Verified against PG 16 + MySQL 8.4 across the full matrix. + +## MySQL driver caveat (about mysql2, not this library) + +mysql2's `query()` substitutes values CLIENT-side with backslash escaping +(escape("it's") -> 'it\'s'). Under sql_mode=NO_BACKSLASH_ESCAPES that is wrong: +it errors, and round-trips `x\` as `x\\`. Use `execute()` (a real server-side +prepared statement), which is correct in every mode: + const q = mysql(['SELECT * FROM t WHERE id = ?', id]) + const [rows] = await conn.execute(q.text, q.values) + +## Recipes (every one below is executed and verified on each CI run) + +Basic value binding — the tag is the recommended default: + +```javascript +pg(sql`SELECT * FROM users WHERE email = ${'a@b.c'}`) +// { text: 'SELECT * FROM users WHERE email = $1', values: ['a@b.c'] } +``` + +Conditional WHERE — use sql.empty for the "off" branch, never string concat: + +```javascript +const activeOnly = true +const clause = activeOnly ? sql`AND active = ${true}` : sql.empty +pg(sql`SELECT * FROM users WHERE org = ${'acme'} ${clause}`) +// { text: 'SELECT * FROM users WHERE org = $1 AND active = $2', values: ['acme', true] } +``` + +Build a WHERE from a variable number of conditions: + +```javascript +const conds = [sql`age >= ${18}`, sql`country = ${'CZ'}`] +pg(sql`SELECT * FROM users WHERE ${sql.join(conds, ' AND ')}`) +// { text: 'SELECT * FROM users WHERE age >= $1 AND country = $2', values: [18, 'CZ'] } +``` + +IN list from an array (do NOT build this by hand): + +```javascript +pg(sql`SELECT * FROM users WHERE id IN ${[1, 2, 3]}`) +// { text: 'SELECT * FROM users WHERE id IN ($1,$2,$3)', values: [1, 2, 3] } +``` + +INSERT an object and return the id: + +```javascript +pg(['INSERT INTO users VALUES ?', { username: 'ada', age: 36 }, 'RETURNING id']) +// { text: 'INSERT INTO users (username,age) VALUES ($1,$2) RETURNING id', values: ['ada', 36] } +``` + +UPDATE from an object (choose the columns yourself — never pass req.body): + +```javascript +pg(['UPDATE users SET ?', { age: 37 }, 'WHERE id = ?', 1]) +// { text: 'UPDATE users SET age=$1 WHERE id = $2', values: [37, 1] } +``` + +Dynamic table/column name — sql.id quotes it; a placeholder cannot: + +```javascript +pg(sql`SELECT * FROM ${sql.id('public', 'users')} WHERE id = ${1}`) +// { text: 'SELECT * FROM "public"."users" WHERE id = $1', values: [1] } +``` + +Dynamic ORDER BY direction — map user input to a fixed set, THEN sql.raw: + +```javascript +const dir = ({ asc: 'ASC', desc: 'DESC' })['desc'] || 'ASC' +pg(sql`SELECT * FROM users ORDER BY ${sql.id('created_at')} ${sql.raw(dir)}`) +// { text: 'SELECT * FROM users ORDER BY "created_at" DESC' } +``` + +Pagination: + +```javascript +pg(sql`SELECT * FROM users ORDER BY id LIMIT ${20} OFFSET ${40}`) +// { text: 'SELECT * FROM users ORDER BY id LIMIT $1 OFFSET $2', values: [20, 40] } +``` + +LIKE search — the value is injection-safe, but escape % and _ if they should be +literal (the library cannot know your intent): + +```javascript +const term = '50%_off' +const escaped = term.replace(/([%_\\])/g, '\\$1') +pg(sql`SELECT * FROM products WHERE name LIKE ${'%' + escaped + '%'}`) +// { text: 'SELECT * FROM products WHERE name LIKE $1', values: ['%50\\%\\_off%'] } +``` + +Postgres jsonb — the tag needs no escaping for the `?` operator: + +```javascript +pg(sql`SELECT * FROM t WHERE data ? ${'key'} AND id = ${1}`) +// { text: 'SELECT * FROM t WHERE data ? $1 AND id = $2', values: ['key', 1] } +``` + +The same fragment renders for either dialect (rendering is pure): + +```javascript +const frag = sql`SELECT * FROM t WHERE id = ${7}` +mysql(frag) +// { text: 'SELECT * FROM t WHERE id = ?', values: [7] } +``` + +## Security + +- Values are safe: parameterised, never inlined into `text`, in BOTH APIs. +- Identifiers CANNOT be parameterised by any driver. So: + * Object keys in VALUES ?/SET ?/WHERE ? are ALLOW-LISTED to plain identifiers + (/^[A-Za-z_][A-Za-z0-9_$]*(\.[...])*$/). Anything else THROWS. Still don't + pass user-controlled keys — choose columns yourself. + * Use sql.id() for dynamic identifiers; it quotes and escapes per dialect. + * NOTE: quoting makes identifiers case-sensitive in Postgres (unquoted names + fold to lower case). +- sql.raw() is unsafe by construction; map user input to a fixed allowed set first. +- NOT handled for you: LIKE wildcards (% and _ in a bound value still match as + wildcards — escape \% \_), and second-order injection (parameterise data read + back out of the DB too). + +## Errors + +- Value with no preceding `?` fragment -> "expected an SQL string fragment...". +- More `?` than values that follow -> "has N placeholder(s) but only M value(s)...". +- Empty object to VALUES ?/SET ?/WHERE ? -> "empty object...". +- Non-identifier object key -> "is not a valid column name...". +- sql.id('') or NUL in identifier -> throws. + +## 3.0.0 notes (vs 2.4.2) + +- The `?` partials API keeps the same output for all documented usage (verified + by a differential fuzzer against 2.4.2 on every CI run). +- NEW: the `sql` tagged template + sql.id/value/join/raw/empty. +- NEW: real lexer — `?` inside literals/identifiers/comments/dollar-quotes is no + longer mistaken for a placeholder; jsonb `?|`/`?&` survive. +- NEW: object keys allow-listed; `sql.id()` for dynamic identifiers. +- Ships TypeScript types + ESM alongside CJS; `main` is `dist/index.js`. +- Fixes: lowercase `in ?` now expands; the caller's partials array is no longer + mutated; empty expansion objects throw; `OFFSET ?` is no longer misread as + `SET ?`; too-few-values throws instead of binding undefined. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b6c4909 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,350 @@ +{ + "name": "simple-builder", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simple-builder", + "version": "3.0.0", + "license": "MIT", + "devDependencies": { + "mysql2": "^3.22.6", + "pg": "^8.22.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.2" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "dev": true, + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/lru.min": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", + "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, + "node_modules/mysql2": { + "version": "3.22.6", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.6.tgz", + "integrity": "sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "aws-ssl-profiles": "^1.1.2", + "denque": "^2.1.0", + "generate-function": "^2.3.1", + "iconv-lite": "^0.7.2", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.4.0" + }, + "engines": { + "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" + } + }, + "node_modules/named-placeholders": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", + "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru.min": "^1.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", + "dev": true, + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/package.json b/package.json index 2347f4f..4c32db9 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,84 @@ { "name": "simple-builder", - "version": "2.4.2", - "description": "Simple SQL query string builder", - "main": "lib/builder.js", + "version": "3.0.0", + "description": "Tiny SQL builder that keeps your SQL visible. A safe sql`` tagged template plus the classic ? partials API, both returning { text, values } for pg, mysql and mysql2. Always-parameterised values, quoted identifiers, object expansion for INSERT/UPDATE/WHERE/IN. Zero dependencies, first-class TypeScript types, CJS + ESM.", + "type": "commonjs", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./esm/index.d.mts", + "default": "./esm/index.mjs" + }, + "require": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "esm", + "llms.txt" + ], + "sideEffects": false, "scripts": { - "test": "node test/build_query.js" + "build": "tsc", + "clean": "rm -rf dist", + "prepare": "tsc", + "test": "tsc && tsc --noEmit --strict --target es2017 --lib es2017 --moduleResolution node test/types.test.ts && node --unhandled-rejections=strict test/index.test.cjs && node --unhandled-rejections=strict test/docs.test.cjs", + "fuzz": "tsc && node test/fuzz.cjs", + "test:integration": "tsc && node --unhandled-rejections=strict test/integration.cjs", + "db:up": "docker run -d --name sb-pg -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=sbtest -p 55432:5432 postgres:16-alpine && docker run -d --name sb-mysql -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=sbtest -p 53306:3306 mysql:8", + "db:down": "docker rm -f sb-pg sb-mysql" }, - "author": "Ondrej Machek ", - "license": "MIT", - "dependencies": {}, "keywords": [ "sql", "query", "builder", - "query builder", + "query-builder", + "sql-builder", + "sql-template", + "tagged-template", + "template-literal", "mysql", + "mysql2", "postgresql", - "postgres" + "postgres", + "pg", + "parameterized", + "prepared-statements", + "placeholders", + "sql-injection", + "escape", + "identifier", + "insert", + "update", + "where-in", + "typescript", + "zero-dependency", + "esm", + "cjs" ], + "author": "Ondrej Machek, acrocz@gmail.com", + "license": "MIT", + "engines": { + "node": ">=16" + }, "repository": { "type": "git", - "url": "git://github.com/Acro/simple-builder" + "url": "git+https://github.com/Acro/simple-builder.git" + }, + "bugs": { + "url": "https://github.com/Acro/simple-builder/issues" + }, + "homepage": "https://github.com/Acro/simple-builder#readme", + "dependencies": {}, + "devDependencies": { + "mysql2": "^3.22.6", + "pg": "^8.22.0", + "typescript": "^5.4.0" } } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..d596c2a --- /dev/null +++ b/src/index.ts @@ -0,0 +1,722 @@ +/** + * simple-builder — a tiny SQL builder that keeps your SQL visible. + * + * Two ways to write the same query, both returning `{ text, values }` shaped + * for the `pg`, `mysql`, and `mysql2` drivers: + * + * pg(['SELECT * FROM users WHERE id = ?', id]) // partials + `?` + * pg(sql`SELECT * FROM users WHERE id = ${id}`) // tagged template + * + * For `pg` the placeholders render as `$1, $2, …`; for `mysql` they stay `?`. + * + * SECURITY MODEL + * - Values are ALWAYS parameterised — they never enter the SQL text. + * - Identifiers cannot be parameterised by any driver, so object keys used by + * `VALUES ?` / `SET ?` / `WHERE ?` are validated against a strict identifier + * allow-list and rejected if they are anything else. Use `sql.id()` for + * dynamic identifiers; it quotes per dialect. + * - `sql.raw()` is the only way to get unescaped text in, and is unsafe with + * user input by construction. + */ + +/** Target driver dialect. `pg` renders `$1`-style placeholders; `mysql` + * (also `mysql2`) keeps `?`. */ +export type Dialect = 'pg' | 'mysql' + +/** A single bound value. Passed through to the driver untouched. */ +export type Value = string | number | boolean | bigint | null | undefined | Date + +/** An object whose keys are column names and values are bound parameters. */ +export type Row = Record + +/** The result, shaped for `driver.query(text, values)`. `values` is omitted + * when the query bound no parameters. */ +export interface BuildResult { + text: string + values?: unknown[] +} + +/** + * Server settings that change how SQL *lexes*, and therefore which `?` is a + * placeholder. The defaults match a stock server, so you only need this if you + * have changed them. Verified against Postgres 16 and MySQL 8.4. + * + * Only matters for a backslash immediately before a quote inside a literal — + * `''` doubling lexes identically in every mode, and the `sql` tag never lexes + * at all, so both are mode-proof. + */ +export interface Mode { + /** MySQL, `sql_mode=ANSI_QUOTES`: `"…"` delimits an identifier (escaped by + * `""` doubling), not a string with backslash escapes. Default `false`. */ + ansiQuotes?: boolean + /** MySQL, `sql_mode=NO_BACKSLASH_ESCAPES`: `\` is an ordinary character + * inside string literals. Default `false`. */ + noBackslashEscapes?: boolean + /** Postgres, `standard_conforming_strings`: when `false`, `\` escapes inside + * ordinary `'…'` strings (as it always does inside `E'…'`). Default `true`, + * the server default since 9.1. */ + standardConformingStrings?: boolean +} + +// ───────────────────────────────────────────────────────────────────────── +// Identifiers +// +// No driver can bind an identifier — placeholders name data, never tables or +// columns (OWASP Query Parameterization Cheat Sheet). So identifiers are +// handled two ways: object keys are ALLOW-LISTED (a plain identifier passes +// through byte-for-byte, anything else throws), and `sql.id()` QUOTES per +// dialect for the dynamic case. +// ───────────────────────────────────────────────────────────────────────── + +// A conservative allow-list: `col`, `tbl.col`, `a.b.c`. Deliberately narrower +// than what the engines accept — exotic names must go through `sql.id()`. +const PLAIN_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)*$/ + +const assertPlainIdentifier = (key: string, clause: string): string => { + if (!PLAIN_IDENTIFIER.test(key)) { + throw new Error( + `simple-builder: ${JSON.stringify(key)} is not a valid column name for the ` + + `${clause} clause. Keys become SQL identifiers and cannot be parameterised, ` + + 'so only plain identifiers (`col`, `tbl.col`) are accepted here. ' + + 'Never pass user-controlled keys; for a dynamic identifier use sql.id().' + ) + } + return key +} + +/** Quote one identifier part for the dialect: `pg` uses "double quotes" and + * doubles embedded quotes; `mysql` uses `backticks` and doubles embedded + * backticks. The quotes are added here — callers never add their own, which + * is what makes quote-mismatch injection impossible. */ +const quoteIdentifier = (dialect: Dialect, name: string): string => { + if (typeof name !== 'string' || name.length === 0) { + throw new Error('simple-builder: sql.id() requires a non-empty string.') + } + // Rejected by both engines; also the classic quote-escape bypass. + if (name.indexOf('\0') !== -1) { + throw new Error('simple-builder: identifiers cannot contain a NUL character.') + } + return dialect === 'mysql' + ? '`' + name.replace(/`/g, '``') + '`' + : '"' + name.replace(/"/g, '""') + '"' +} + +// ───────────────────────────────────────────────────────────────────────── +// Fragment nodes — the shared representation behind both APIs. +// ───────────────────────────────────────────────────────────────────────── + +type Node = + | { k: 'text'; v: string } + | { k: 'value'; v: unknown } + | { k: 'id'; v: string[] } + +/** A composable, dialect-agnostic SQL fragment produced by the `sql` tag. + * Render it by passing it to `pg()` or `mysql()`. Fragments nest. */ +export class Sql { + /** @internal */ + readonly nodes: Node[] + /** @internal */ + constructor(nodes: Node[]) { + this.nodes = nodes + } +} + +/** A dynamic identifier, quoted for the dialect at render time. */ +class Identifier { + /** @internal */ + readonly parts: string[] + /** @internal */ + constructor(parts: string[]) { + this.parts = parts + } +} + +/** Unescaped SQL text. Unsafe with user input by construction. */ +class Raw { + /** @internal */ + readonly text: string + /** @internal */ + constructor(text: string) { + this.text = text + } +} + +/** Forces a single bound parameter (arrays would otherwise expand to a list). */ +class Single { + /** @internal */ + readonly value: unknown + /** @internal */ + constructor(value: unknown) { + this.value = value + } +} + +// ───────────────────────────────────────────────────────────────────────── +// Lexer for the `?` partials API. +// +// A naive scan for `?` corrupts real SQL: Postgres' jsonb operators `?`, `?|` +// and `?&` are bare question marks, and a `?` can sit inside a string literal, +// a quoted identifier, a comment, or a dollar-quoted body. (pgx shipped a +// security fix for exactly the dollar-quote case.) So we lex instead of count: +// everything below is skipped verbatim, and only a real placeholder is bound. +// ───────────────────────────────────────────────────────────────────────── + +type Piece = { text: string } | { placeholder: true } + +/** + * Consume a quoted run starting just past its opening delimiter, emitting the + * text verbatim, and return the index just past the closing delimiter. The + * delimiter is escaped by doubling it; `escapes` additionally honours + * backslash escapes. An unterminated run consumes the rest of the fragment + * rather than guessing. + */ +const consumeQuoted = ( + fragment: string, + start: number, + quote: string, + escapes: boolean, + emit: (s: string) => void +): number => { + const n = fragment.length + let i = start + while (i < n) { + if (escapes && fragment[i] === '\\' && i + 1 < n) { + emit(fragment[i] + fragment[i + 1]) + i += 2 + continue + } + if (fragment[i] === quote) { + if (fragment[i + 1] === quote) { emit(quote + quote); i += 2; continue } + emit(quote) + return i + 1 + } + emit(fragment[i]) + i++ + } + return i +} + +const lex = (fragment: string, dialect: Dialect, mode: Mode): Piece[] => { + const pieces: Piece[] = [] + let buf = '' + const flush = (): void => { + if (buf) { pieces.push({ text: buf }); buf = '' } + } + + let i = 0 + const n = fragment.length + + while (i < n) { + const c = fragment[i] + + // `\?` — escape hatch for a literal `?` (e.g. the jsonb existence operator). + if (c === '\\' && fragment[i + 1] === '?') { + buf += '?' + i += 2 + continue + } + + // Single-quoted string literal. `''` escapes in every mode of both engines. + // Backslash is mode-dependent: + // - MySQL: escapes, unless sql_mode=NO_BACKSLASH_ESCAPES. + // - Postgres: escapes inside E'…' always; inside a plain '…' only when + // standard_conforming_strings is off (it is on by default since 9.1). + if (c === "'") { + const escapes = + dialect === 'mysql' + ? !mode.noBackslashEscapes + : /(?:^|[^A-Za-z0-9_$])[Ee]$/.test(buf) || mode.standardConformingStrings === false + buf += c + i++ + i = consumeQuoted(fragment, i, "'", escapes, (s) => { buf += s }) + continue + } + + // Quoted run: `…` (a MySQL identifier) or "…" — a pg identifier, a MySQL + // string, or a MySQL identifier under ANSI_QUOTES. A `?` inside is never a + // placeholder in any of those readings; only the backslash rule differs. + // Backticks and pg/ANSI_QUOTES identifiers use doubling alone; a MySQL + // "…" string honours backslash unless NO_BACKSLASH_ESCAPES. + if (c === '"' || c === '`') { + const escapes = + dialect === 'mysql' && c === '"' && !mode.ansiQuotes && !mode.noBackslashEscapes + buf += c + i++ + i = consumeQuoted(fragment, i, c, escapes, (s) => { buf += s }) + continue + } + + // Line comment. Two dialect differences, both verified against real servers: + // - MySQL needs whitespace (or EOF) after `--`; without it `--` is two + // minus signs, so `SELECT 1--2` is 3 and `SELECT 1--?` really does bind. + // Postgres always treats `--` as a comment. + // - MySQL also treats `#` as a line comment; Postgres does NOT — there `#` + // starts operators like `#>` / `#-`. + const dashComment = + c === '-' && + fragment[i + 1] === '-' && + (dialect !== 'mysql' || i + 2 >= n || /\s/.test(fragment[i + 2])) + if (dashComment || (dialect === 'mysql' && c === '#')) { + while (i < n && fragment[i] !== '\n') { buf += fragment[i]; i++ } + continue + } + + // Block comment — Postgres allows nesting. + if (c === '/' && fragment[i + 1] === '*') { + let depth = 0 + while (i < n) { + if (fragment[i] === '/' && fragment[i + 1] === '*') { depth++; buf += '/*'; i += 2; continue } + if (fragment[i] === '*' && fragment[i + 1] === '/') { + depth-- + buf += '*/' + i += 2 + if (depth === 0) break + continue + } + buf += fragment[i] + i++ + } + continue + } + + // Dollar-quoted string: $$...$$ or $tag$...$tag$ (Postgres). + if (c === '$') { + const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(fragment.slice(i)) + if (tag) { + const delim = tag[0] + const end = fragment.indexOf(delim, i + delim.length) + if (end === -1) { + // Unterminated — consume the rest verbatim rather than guess. + buf += fragment.slice(i) + i = n + continue + } + buf += fragment.slice(i, end + delim.length) + i = end + delim.length + continue + } + buf += c + i++ + continue + } + + // `?` — a placeholder only when it is not part of a `?`-family operator. + if (c === '?') { + const next = fragment[i + 1] + if (next === '|' || next === '&' || next === '?') { + buf += c + next + i += 2 + continue + } + flush() + pieces.push({ placeholder: true }) + i++ + continue + } + + buf += c + i++ + } + + flush() + return pieces +} + +// ───────────────────────────────────────────────────────────────────────── +// Clause markers — classified positionally, from the text immediately before +// each placeholder. `\b` keeps `offset ?` from reading as `SET ?` and `JOIN ?` +// from reading as `IN ?`. +// ───────────────────────────────────────────────────────────────────────── + +type Clause = 'insert' | 'update' | 'where' | 'where_in' | null + +const RE_VALUES = /\bVALUES\s+$/i +const RE_SET = /\bSET\s+$/i +const RE_WHERE = /\bWHERE\s+$/i +const RE_IN = /\bIN\s+$/i + +const classify = (before: string): Clause => + RE_VALUES.test(before) ? 'insert' : + RE_SET.test(before) ? 'update' : + RE_WHERE.test(before) ? 'where' : + RE_IN.test(before) ? 'where_in' : + null + +/** One of the library's own fragment markers — never a plain data Row. */ +const isMarker = (value: unknown): boolean => + value instanceof Sql || + value instanceof Identifier || + value instanceof Raw || + value instanceof Single + +/** A plain object whose keys are column names. Deliberately excludes Date and + * the marker classes: enumerating those would turn their internal fields + * (`parts`, `text`, `nodes`, `value`) into fabricated column names. */ +const isObject = (value: unknown): value is Row => + value !== null && typeof value === 'object' && !(value instanceof Date) && !isMarker(value) + +const describe = (value: unknown): string => + value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value + +// ───────────────────────────────────────────────────────────────────────── +// Renderer +// ───────────────────────────────────────────────────────────────────────── + +class Renderer { + private readonly dialect: Dialect + private paramIndex = 1 + readonly values: unknown[] = [] + + constructor(dialect: Dialect) { + this.dialect = dialect + } + + /** Bind a value and return its placeholder. */ + bind(value: unknown): string { + this.values.push(value) + return this.dialect === 'mysql' ? '?' : '$' + this.paramIndex++ + } + + id(parts: string[]): string { + return parts.map((p) => quoteIdentifier(this.dialect, p)).join('.') + } + + /** Expand an object/array at a clause marker. `before` is the text emitted so + * far for this fragment; the insert form rewrites its tail. */ + expand(before: string, clause: Exclude, row: Row): string { + const keys = Object.keys(row) + if (keys.length === 0) { + throw new Error( + `simple-builder: empty object passed to the ${clause} clause — ` + + 'an object with at least one key is required.' + ) + } + + if (clause === 'where_in') { + // Array (or object) of values — keys are indices, not identifiers. + const list = keys.map((k) => this.bind(row[k])).join(',') + return before + '(' + list + ')' + } + + if (clause === 'insert') { + const cols = keys.map((k) => assertPlainIdentifier(k, 'VALUES')).join(',') + const list = keys.map((k) => this.bind(row[k])).join(',') + return before.replace(RE_VALUES, '') + '(' + cols + ') VALUES (' + list + ')' + } + + const sep = clause === 'where' ? ' AND ' : ',' + const label = clause === 'where' ? 'WHERE' : 'SET' + const assignments = keys + .map((k) => assertPlainIdentifier(k, label) + '=' + this.bind(row[k])) + .join(sep) + return before + assignments + } + + result(text: string): BuildResult { + const out: BuildResult = { text } + if (this.values.length > 0) out.values = this.values + return out + } +} + +/** Render fragment nodes through a renderer, so placeholder numbering and the + * values array stay continuous whether the fragment is the whole query or is + * spliced into a `?` partials list. */ +const renderNodes = (r: Renderer, nodes: readonly Node[]): string => { + let text = '' + for (const node of nodes) { + if (node.k === 'text') text += node.v + else if (node.k === 'id') text += r.id(node.v) + else text += r.bind(node.v) + } + return text +} + +// ───────────────────────────────────────────────────────────────────────── +// The `?` partials API +// ───────────────────────────────────────────────────────────────────────── + +const buildPartials = (dialect: Dialect, parts: unknown[], mode: Mode): BuildResult => { + const r = new Renderer(dialect) + const text: string[] = [] + + let i = 0 + while (i < parts.length) { + let part = parts[i] + + // A dynamic identifier in fragment position. + if (part instanceof Identifier) { + text.push(r.id(part.parts)) + i++ + continue + } + if (part instanceof Raw) { + text.push(part.text) + i++ + continue + } + + // A bare array in fragment position is a projection list: "a","b" → "a,b". + if (Array.isArray(part)) part = part.join(',') + + if (typeof part !== 'string') { + throw new Error( + 'simple-builder: expected an SQL string fragment but got ' + + `${describe(part)} at position ${i}. A value must follow a fragment ` + + 'containing a `?` placeholder.' + ) + } + + const pieces = lex(part, dialect, mode) + const holes = pieces.filter((p) => 'placeholder' in p).length + const available = parts.length - i - 1 + if (holes > available) { + throw new Error( + `simple-builder: fragment ${JSON.stringify(part)} has ${holes} placeholder(s) ` + + `but only ${available} value(s) follow it.` + ) + } + + let out = '' + let k = 0 + for (const piece of pieces) { + if ('text' in piece) { out += piece.text; continue } + + const value = parts[i + 1 + k] + k++ + const clause = classify(out) + + // Markers are checked BEFORE the clause expansion: they are objects, so + // `WHERE ?` + sql.id(…) would otherwise enumerate the marker's own fields + // as column names and emit `WHERE parts=$1`. + if (value instanceof Sql) { + out += renderNodes(r, value.nodes) + } else if (value instanceof Identifier) { + out += r.id(value.parts) + } else if (value instanceof Raw) { + out += value.text + } else if (value instanceof Single) { + out += r.bind(value.value) + } else if (clause && isObject(value)) { + out = r.expand(out, clause, value) + } else { + out += r.bind(value) + } + } + + text.push(out) + i += 1 + holes + } + + return r.result(text.join(' ')) +} + +// ───────────────────────────────────────────────────────────────────────── +// The `sql` tagged-template API +// +// Nothing here scans for `?`: the literal chunks are yours verbatim and every +// ${interpolation} is a value unless it is explicitly an Sql / id / raw. That +// makes accidental injection structurally impossible, and sidesteps the +// jsonb-operator ambiguity entirely. +// ───────────────────────────────────────────────────────────────────────── + +const interpolate = (value: unknown, nodes: Node[]): void => { + if (value instanceof Sql) { for (const nd of value.nodes) nodes.push(nd); return } + if (value instanceof Identifier) { nodes.push({ k: 'id', v: value.parts }); return } + if (value instanceof Raw) { nodes.push({ k: 'text', v: value.text }); return } + if (value instanceof Single) { nodes.push({ k: 'value', v: value.value }); return } + + // An array becomes a parenthesised list — the `IN ${ids}` form. + if (Array.isArray(value)) { + nodes.push({ k: 'text', v: '(' }) + value.forEach((item, idx) => { + if (idx > 0) nodes.push({ k: 'text', v: ',' }) + interpolate(item, nodes) + }) + nodes.push({ k: 'text', v: ')' }) + return + } + + nodes.push({ k: 'value', v: value }) +} + +/** + * The `sql` tagged template — the recommended way to write a query. + * + * Every `${interpolation}` is ALWAYS a bound parameter, so you cannot forget to + * parameterise something. Nothing is scanned for `?`, so Postgres' jsonb `?` + * operators need no escaping. Fragments nest, so queries compose. + * + * @example + * ```ts + * pg(sql`SELECT * FROM users WHERE id = ${id}`) + * + * // Composition — sql.empty is the "off" branch; never concatenate strings. + * const active = onlyActive ? sql`AND active = ${true}` : sql.empty + * pg(sql`SELECT * FROM users WHERE org = ${org} ${active}`) + * + * // An array interpolates as a parenthesised list. + * pg(sql`SELECT * FROM users WHERE id IN ${[1, 2, 3]}`) + * // { text: 'SELECT * FROM users WHERE id IN ($1,$2,$3)', values: [1, 2, 3] } + * ``` + */ +interface SqlTag { + (strings: TemplateStringsArray, ...values: unknown[]): Sql + /** + * A dynamic identifier, quoted for the dialect. Use this for table/column + * names — no driver can bind an identifier to a placeholder. + * + * Note: quoting makes an identifier case-sensitive, and Postgres folds + * unquoted names to lower case — `sql.id('userName')` means a column literally + * named `userName`, not `username`. + * + * @example + * ```ts + * pg(sql`SELECT * FROM ${sql.id('public', 'users')}`) // "public"."users" + * mysql(sql`SELECT * FROM ${sql.id('my table')}`) // `my table` + * ``` + */ + id(...parts: string[]): Identifier + /** + * Unescaped SQL text — the one unsafe helper. NEVER pass user input. Map it + * to a fixed set of allowed values first. + * + * @example + * ```ts + * const dir = { asc: 'ASC', desc: 'DESC' }[input] || 'ASC' // allow-list + * pg(sql`SELECT * FROM t ORDER BY id ${sql.raw(dir)}`) + * ``` + */ + raw(text: string): Raw + /** + * Bind a value as exactly ONE parameter. Needed for arrays, which otherwise + * expand into a parenthesised list — use this for a Postgres array or jsonb + * column. + * + * @example + * ```ts + * pg(sql`SELECT * FROM t WHERE tags = ${sql.value(['a', 'b'])}`) + * // { text: 'SELECT * FROM t WHERE tags = $1', values: [['a', 'b']] } + * ``` + */ + value(value: unknown): Single + /** + * Join fragments/values with a separator — for a variable number of clauses. + * + * @example + * ```ts + * const conds = [sql`age >= ${18}`, sql`country = ${'CZ'}`] + * pg(sql`SELECT * FROM users WHERE ${sql.join(conds, ' AND ')}`) + * ``` + */ + join(items: readonly unknown[], separator?: string): Sql + /** A fragment that renders to nothing — the "off" branch of a conditional. */ + readonly empty: Sql +} + +const tag = (strings: TemplateStringsArray, ...values: unknown[]): Sql => { + const nodes: Node[] = [] + for (let i = 0; i < strings.length; i++) { + if (strings[i]) nodes.push({ k: 'text', v: strings[i] }) + if (i < values.length) interpolate(values[i], nodes) + } + return new Sql(nodes) +} + +export const sql: SqlTag = Object.assign(tag, { + id: (...parts: string[]): Identifier => new Identifier(parts), + raw: (text: string): Raw => new Raw(text), + value: (value: unknown): Single => new Single(value), + join: (items: readonly unknown[], separator = ', '): Sql => { + const nodes: Node[] = [] + items.forEach((item, idx) => { + if (idx > 0) nodes.push({ k: 'text', v: separator }) + interpolate(item, nodes) + }) + return new Sql(nodes) + }, + empty: new Sql([]), +}) + +const buildSql = (dialect: Dialect, fragment: Sql): BuildResult => { + const r = new Renderer(dialect) + return r.result(renderNodes(r, fragment.nodes)) +} + +// ───────────────────────────────────────────────────────────────────────── +// Public entry points +// ───────────────────────────────────────────────────────────────────────── + +/** A dialect-bound builder. Accepts a `sql` fragment, a partials array, an + * argument list of partials, or a single ready SQL string. */ +export interface Build { + (fragment: Sql): BuildResult + (partials: readonly unknown[]): BuildResult + (...partials: unknown[]): BuildResult + /** + * A builder for a server whose lexing settings differ from the defaults — + * `mysql.withMode({ ansiQuotes: true })`. Returns a new builder; the original + * is unchanged. Only affects the `?` partials API (the `sql` tag never lexes). + */ + withMode(mode: Mode): Build +} + +const build = (dialect: Dialect, args: unknown[], mode: Mode): BuildResult => { + if (args.length === 1) { + const only = args[0] + if (only instanceof Sql) return buildSql(dialect, only) + if (Array.isArray(only)) return buildPartials(dialect, only.slice(), mode) + if (!isMarker(only)) { + // A single ready string (or nothing) — nothing to bind. + return { text: only == null ? '' : String(only) } + } + } + return buildPartials(dialect, args, mode) +} + +const makeBuild = (dialect: Dialect, mode: Mode): Build => { + const builder = ((...args: unknown[]) => build(dialect, args, mode)) as Build + builder.withMode = (extra: Mode): Build => + makeBuild(dialect, Object.assign({}, mode, extra)) + return builder +} + +/** + * Postgres (`pg`) builder — renders `$1, $2, …` placeholders. + * + * @example Tagged template (recommended — every `${}` is always parameterised) + * ```ts + * const q = pg(sql`SELECT * FROM users WHERE id = ${id}`) + * // { text: 'SELECT * FROM users WHERE id = $1', values: [id] } + * const { rows } = await client.query(q.text, q.values) + * ``` + * + * @example Classic `?` partials + * ```ts + * pg(['SELECT * FROM users WHERE id = ?', id]) + * pg(['UPDATE users SET ?', { age: 37 }, 'WHERE id = ?', id]) + * // { text: 'UPDATE users SET age=$1 WHERE id = $2', values: [37, id] } + * ``` + */ +export const pg: Build = makeBuild('pg', {}) + +/** + * MySQL (`mysql` / `mysql2`) builder — keeps `?` placeholders. + * + * Prefer the driver's `execute()` over `query()`: mysql2's `query()` escapes + * values client-side with backslashes, which is wrong under + * `sql_mode=NO_BACKSLASH_ESCAPES`. `execute()` binds server-side. + * + * @example + * ```ts + * const q = mysql(sql`SELECT * FROM users WHERE id = ${id}`) + * // { text: 'SELECT * FROM users WHERE id = ?', values: [id] } + * const [rows] = await conn.execute(q.text, q.values) + * ``` + */ +export const mysql: Build = makeBuild('mysql', {}) + +/** Default export: `{ pg, mysql, sql }`, mirroring the classic + * `require('simple-builder')` shape. */ +const simpleBuilder = { pg, mysql, sql } +export default simpleBuilder diff --git a/test/boundary/consumer.ts b/test/boundary/consumer.ts new file mode 100644 index 0000000..3dd8e4b --- /dev/null +++ b/test/boundary/consumer.ts @@ -0,0 +1,36 @@ +/** + * Package-boundary TypeScript consumer. CI compiles this against the INSTALLED + * package's d.ts with the latest compiler, so a typings change that breaks + * consumers fails the build. + */ +import simpleBuilder, { pg, mysql, sql, Build, BuildResult, Mode, Row, Sql } from 'simple-builder' + +function main(): void { + // partials API + const a: BuildResult = pg(['SELECT * FROM t WHERE id = ?', 1]) + const b: BuildResult = mysql('SELECT * FROM t WHERE id = ?', 1) + + const row: Row = { username: 'x', active: true } + const c: BuildResult = simpleBuilder.pg(['UPDATE t SET ?', row, 'WHERE id = ?', 1]) + + // sql tag + const frag: Sql = sql`SELECT * FROM t WHERE id = ${1}` + const d: BuildResult = pg(frag) + const e: BuildResult = mysql(sql`SELECT * FROM ${sql.id('users')} WHERE id IN ${[1, 2]}`) + const f: BuildResult = pg(sql`SELECT * FROM t WHERE ${sql.join([sql`a = ${1}`], ' AND ')} ${sql.empty}`) + const g: BuildResult = pg(sql`SELECT * FROM t ORDER BY id ${sql.raw('DESC')}`) + const h: BuildResult = pg(sql`SELECT * FROM t WHERE tags = ${sql.value([1, 2])}`) + + // withMode + const mode: Mode = { ansiQuotes: true, noBackslashEscapes: true } + const configured: Build = mysql.withMode(mode) + const i: BuildResult = configured(['SELECT ? AS n', 1]) + const j: BuildResult = pg.withMode({ standardConformingStrings: false })(sql`SELECT ${1}`) + + const text: string = a.text + const values: unknown[] | undefined = a.values + + void [b, c, d, e, f, g, h, i, j, text, values] +} + +void main diff --git a/test/boundary/smoke.cjs b/test/boundary/smoke.cjs new file mode 100644 index 0000000..50787cd --- /dev/null +++ b/test/boundary/smoke.cjs @@ -0,0 +1,76 @@ +'use strict' + +/** + * Package-boundary smoke test. Runs against the INSTALLED package + * (`require('simple-builder')`), not ../src or ../dist — CI packs the tarball, + * installs it into a scratch consumer, copies this file in, and runs it. + */ +const assert = require('assert') +const { pg, mysql, sql } = require('simple-builder') +const def = require('simple-builder').default + +// Exports present at the package boundary. +assert.strictEqual(typeof pg, 'function') +assert.strictEqual(typeof mysql, 'function') +assert.strictEqual(typeof sql, 'function') +assert.strictEqual(def.pg, pg) +assert.strictEqual(def.mysql, mysql) +assert.strictEqual(def.sql, sql) + +// ── partials API ── +assert.deepStrictEqual( + pg(['UPDATE users SET ?', { username: 'x', gender: 'male' }, 'WHERE id = ?', 7]), + { text: 'UPDATE users SET username=$1,gender=$2 WHERE id = $3', values: ['x', 'male', 7] } +) + +assert.deepStrictEqual(mysql('SELECT * FROM t WHERE id IN ?', [1, 2, 3]), { + text: 'SELECT * FROM t WHERE id IN (?,?,?)', + values: [1, 2, 3], +}) + +assert.deepStrictEqual(pg(['SELECT * FROM t']), { text: 'SELECT * FROM t' }) + +// ── lexer: jsonb operators survive ── +assert.deepStrictEqual(pg(["SELECT * FROM t WHERE tags ?| ARRAY['x'] AND id = ?", 5]), { + text: "SELECT * FROM t WHERE tags ?| ARRAY['x'] AND id = $1", + values: [5], +}) + +// ── identifier safety ── +assert.throws(() => pg(['UPDATE t SET ?', { 'x=1; DROP TABLE t; --': 1 }]), /not a valid column name/i) + +// ── withMode ── +assert.strictEqual(typeof mysql.withMode, 'function') +assert.notStrictEqual(mysql.withMode({ ansiQuotes: true }), mysql) +assert.deepStrictEqual(mysql.withMode({ ansiQuotes: true })(['SELECT "a""b" AS s, ? AS n', 1]), { + text: 'SELECT "a""b" AS s, ? AS n', + values: [1], +}) +assert.deepStrictEqual( + pg.withMode({ standardConformingStrings: false })(["SELECT 'a\\'b ?' AS s, ? ::int AS n", 1]), + { text: "SELECT 'a\\'b ?' AS s, $1 ::int AS n", values: [1] } +) + +// ── sql tag ── +assert.deepStrictEqual(pg(sql`SELECT * FROM users WHERE id = ${42}`), { + text: 'SELECT * FROM users WHERE id = $1', + values: [42], +}) +assert.deepStrictEqual(mysql(sql`SELECT * FROM users WHERE id = ${42}`), { + text: 'SELECT * FROM users WHERE id = ?', + values: [42], +}) +assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE id IN ${[1, 2]}`), { + text: 'SELECT * FROM t WHERE id IN ($1,$2)', + values: [1, 2], +}) +assert.deepStrictEqual(pg(sql`SELECT * FROM ${sql.id('public', 'users')} WHERE a = ${1}`), { + text: 'SELECT * FROM "public"."users" WHERE a = $1', + values: [1], +}) +assert.deepStrictEqual( + pg(sql`SELECT * FROM t WHERE ${sql.join([sql`a = ${1}`, sql`b = ${2}`], ' AND ')}`), + { text: 'SELECT * FROM t WHERE a = $1 AND b = $2', values: [1, 2] } +) + +console.log('CJS package-boundary smoke: all checks passed') diff --git a/test/boundary/smoke.mjs b/test/boundary/smoke.mjs new file mode 100644 index 0000000..db3e339 --- /dev/null +++ b/test/boundary/smoke.mjs @@ -0,0 +1,40 @@ +// ESM package-boundary smoke test: named imports resolve through the exports +// map's `import` condition, and the default import is the { pg, mysql, sql } object. +import assert from 'assert' +import simpleBuilder, { pg, mysql, sql } from 'simple-builder' + +assert.strictEqual(typeof pg, 'function', 'named pg import must be the function') +assert.strictEqual(typeof mysql, 'function', 'named mysql import must be the function') +assert.strictEqual(typeof sql, 'function', 'named sql import must be the tag') +assert.strictEqual(simpleBuilder.pg, pg) +assert.strictEqual(simpleBuilder.mysql, mysql) +assert.strictEqual(simpleBuilder.sql, sql) + +// partials API +assert.deepStrictEqual(pg('SELECT * FROM users WHERE ?', { email: 'a@b.c' }), { + text: 'SELECT * FROM users WHERE email=$1', + values: ['a@b.c'], +}) +assert.deepStrictEqual(mysql(['INSERT INTO t VALUES ?', { a: 1, b: 2 }]), { + text: 'INSERT INTO t (a,b) VALUES (?,?)', + values: [1, 2], +}) + +// sql tag, composition and identifiers +assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE id = ${1} AND name = ${'x'}`), { + text: 'SELECT * FROM t WHERE id = $1 AND name = $2', + values: [1, 'x'], +}) +assert.deepStrictEqual(pg(sql`SELECT * FROM ${sql.id('users')} WHERE id IN ${[1, 2, 3]}`), { + text: 'SELECT * FROM "users" WHERE id IN ($1,$2,$3)', + values: [1, 2, 3], +}) + +// an injection payload stays a bound value +const evil = "1; DROP TABLE users; --" +assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE id = ${evil}`), { + text: 'SELECT * FROM t WHERE id = $1', + values: [evil], +}) + +console.log('ESM package-boundary smoke: all checks passed') diff --git a/test/build_query.js b/test/build_query.js deleted file mode 100644 index e716ac7..0000000 --- a/test/build_query.js +++ /dev/null @@ -1,72 +0,0 @@ -var build = require("../lib/builder").pg - -var projection = [ "id", "username" ] - -var user_id = 123 -var username = "sadasd?asdasd" - -var q = [ - "SELECT", projection, "FROM users", - "WHERE id = ? AND username = ?", user_id, username, - "OR id = ?", user_id, - "AND username = ?", username -] - -console.log(JSON.stringify(build(q), null, 4)) -console.log() - -var q2 = [ - "SELECT * FROM users" -] - -console.log(JSON.stringify(build(q2), null, 4)) -console.log() - -var q3 = [ - "SELECT *", - "FROM users" -] - -console.log(JSON.stringify(build("SELECT", projection, "FROM users"), null, 4)) -console.log() - -var q4 = "SELECT * FROM users" - -console.log(JSON.stringify(build(q4), null, 4)) - -var q5 = [ - "UPDATE users SET ?", { username: "something", gender: "male" }, - "WHERE user_id = ? AND is_hidden = ?", user_id, false -] - -console.log(JSON.stringify(build(q5), null, 4)) - -var q6 = [ - "INSERT INTO", "users", - "VALUES ?", { username: "something", gender: "male" } -] - -console.log(JSON.stringify(build(q6), null, 4)) - -var user = { - username: "something", - gender: "male" -} - -var q7 = [ - "INSERT INTO users (username, gender)", - "VALUES (?, ?)", user.username, user.gender -] - -console.log(JSON.stringify(build("INSERT INTO users (username, gender) VALUES (?, ?)", user.username, user.gender), null, 4)) - -var where_cond = { - username: "something", - gender: "male" -} - -console.log(JSON.stringify(build("SELECT * FROM users WHERE ?", where_cond), null, 4)) - -console.log(JSON.stringify(build("SELECT id FROM users WHERE created_at > ?", new Date), null, 4)) - -console.log(JSON.stringify(build("SELECT id FROM users WHERE user_id IN ?", [1,2,3]), null, 4)) diff --git a/test/docs.test.cjs b/test/docs.test.cjs new file mode 100644 index 0000000..61bca22 --- /dev/null +++ b/test/docs.test.cjs @@ -0,0 +1,216 @@ +'use strict' + +/** + * Executable documentation. + * + * Every ```javascript example in README.md and llms.txt that shows an expected + * result is parsed out, run against the built library, and compared. Docs are + * the surface both humans and AI agents copy from verbatim, so a drifted + * example is a bug — this makes it a failing build instead. + * + * The convention an example must follow to be checked: + * + * pg(['SELECT * FROM t WHERE id = ?', 1]) + * // { text: 'SELECT * FROM t WHERE id = $1', values: [1] } + * + * i.e. a `pg(...)` / `mysql(...)` expression, followed by comment lines holding + * the expected `{ … }` object literal (which may wrap across lines, and may be + * introduced by `→` or `both →`). Lines the parser doesn't recognise as such a + * pair are treated as setup code and evaluated, so `const x = …` in a block + * works. Examples with no expected-result comment are still *evaluated* (they + * must not throw), just not compared. + */ + +const assert = require('assert') +const fs = require('fs') +const path = require('path') +const { pg, mysql, sql } = require('../dist/index') + +const ROOT = path.join(__dirname, '..') + +// Free variables the prose examples assume. Kept small and obvious; an example +// needing anything else should define it inline. +const fixtures = () => ({ + pg, + mysql, + sql, + email: 'john@doe.wtf', + id: 123, + userId: 1, + org: 'acme', + onlyActive: true, + user: { username: 'John Doe', email: 'john@doe.wtf' }, + // The security examples illustrate request-shaped input. A benign body keeps + // them runnable; the prose covers what a hostile key does. + req: { body: { username: 'John Doe', email: 'john@doe.wtf' } }, + // `await db.query(...)` / `conn.execute(...)` examples: record, don't hit a DB. + db: { query: async () => [] }, + conn: { execute: async () => [[]], query: async () => [[]] }, + require: () => ({ pg, mysql, sql }), + console: { log: () => {} }, +}) + +const blocksOf = (md) => + [...md.matchAll(/```javascript\n([\s\S]*?)```/g)].map((m) => m[1]) + +// Does this line begin a checkable expression? +const startsExpression = (line) => /^\s*(?:pg|mysql)\s*[.(]/.test(line) + +// Comment lines that carry the expected value. +const isExpectedComment = (line) => /^\s*\/\/\s*(?:both\s*→\s*|→\s*)?[{[]/.test(line) +const isContinuationComment = (line) => /^\s*\/\/\s+\S/.test(line) + +const stripComment = (line) => + line.replace(/^\s*\/\/\s?/, '').replace(/^(?:both\s*)?→\s*/, '') + +// Balance parens/brackets/braces so a multi-line expression is captured whole. +// Quote-aware (a `(` inside a SQL string must not count) and comment-aware (a +// trailing `// … { text }` must not count either). +const balanced = (src) => { + let depth = 0 + let quote = null + for (let i = 0; i < src.length; i++) { + const c = src[i] + if (quote) { + if (c === '\\') { i++; continue } + if (c === quote) quote = null + continue + } + if (c === '/' && src[i + 1] === '/') { + const nl = src.indexOf('\n', i) + if (nl === -1) break + i = nl + continue + } + if (c === "'" || c === '"' || c === '`') { quote = c; continue } + if (c === '(' || c === '[' || c === '{') depth++ + else if (c === ')' || c === ']' || c === '}') depth-- + } + return depth <= 0 +} + +let checked = 0 +let evaluated = 0 +const failures = [] + +const runBlock = (file, blockIndex, block) => { + const lines = block.split('\n') + const scope = fixtures() + const names = Object.keys(scope) + const values = Object.values(scope) + const setup = [] + + // Evaluated in THIS realm (not a vm context) so that a documented object + // literal and a value returned by the library share an Object.prototype — + // otherwise deepStrictEqual fails on realm mismatch for identical-looking + // values. Setup statements are replayed so `const x = …` in a block works. + // The closing paren goes on its own line so a trailing `// comment` in the + // example cannot comment it out. + const evalIn = (code) => + // eslint-disable-next-line no-new-func + new Function(...names, setup.join('\n') + '\nreturn (' + code + '\n)')(...values) + + const evalStatement = (code) => + // eslint-disable-next-line no-new-func + new Function(...names, setup.join('\n') + '\n' + code)(...values) + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + if (!line.trim() || /^\s*\/\//.test(line)) continue + + if (!startsExpression(line)) { + // Setup statement (const …, import …, etc.). Skip module syntax. + if (/^\s*(?:import|export)\b/.test(line)) continue + let stmt = line + let j = i + while (!balanced(stmt) && j + 1 < lines.length) { j++; stmt += '\n' + lines[j] } + i = j + try { evalStatement(stmt); setup.push(stmt) } catch (err) { + // A prose-only line (e.g. `await db.query(...)`) that can't run is fine + // as long as it isn't a checkable example. + void err + } + continue + } + + // Capture the full expression. + let expr = line + let j = i + while (!balanced(expr) && j + 1 < lines.length) { j++; expr += '\n' + lines[j] } + i = j + + // Collect the expected-value comment, if any. + let expected = null + if (j + 1 < lines.length && isExpectedComment(lines[j + 1])) { + expected = stripComment(lines[j + 1]) + let k = j + 1 + while (!balanced(expected) && k + 1 < lines.length && isContinuationComment(lines[k + 1])) { + k++ + expected += ' ' + stripComment(lines[k]) + } + i = k + } + + let actual + try { + actual = evalIn(expr) + evaluated++ + } catch (err) { + failures.push(`${file} block#${blockIndex}: example threw\n ${expr}\n ${err.message}`) + continue + } + + if (expected === null) continue + + if (/\.\.\./.test(expected)) { + failures.push( + `${file} block#${blockIndex}: expected value contains an ellipsis, so it is not a ` + + `copy-pasteable result. Write the real value.\n ${expr}\n // ${expected}` + ) + continue + } + + let want + try { + want = evalIn(expected) + } catch (err) { + failures.push(`${file} block#${blockIndex}: expected value is not a literal\n // ${expected}\n ${err.message}`) + continue + } + + try { + assert.deepStrictEqual(actual, want) + checked++ + } catch (err) { + failures.push( + `${file} block#${blockIndex}: DOC DRIFT\n ${expr}\n` + + ` documented: ${JSON.stringify(want)}\n` + + ` actual: ${JSON.stringify(actual)}` + ) + } + } +} + +for (const file of ['README.md', 'llms.txt']) { + const md = fs.readFileSync(path.join(ROOT, file), 'utf8') + blocksOf(md).forEach((block, idx) => runBlock(file, idx + 1, block)) +} + +if (failures.length) { + console.error(`✗ ${failures.length} documentation problem(s):\n`) + for (const f of failures) console.error(' ' + f + '\n') + process.exit(1) +} + +// Canary: if a refactor breaks the parser, every example silently "passes". +// This floor makes that fail loudly instead. Raise it as docs grow. +const FLOOR = 24 +if (checked < FLOOR) { + console.error( + `✗ only ${checked} documented examples were checked (expected >= ${FLOOR}) — ` + + 'the parser probably stopped matching, so the docs are NOT actually verified.' + ) + process.exit(1) +} + +console.log(`✓ ${checked} documented examples verified (${evaluated} evaluated) across README.md + llms.txt`) diff --git a/test/fuzz.cjs b/test/fuzz.cjs new file mode 100644 index 0000000..4d87d04 --- /dev/null +++ b/test/fuzz.cjs @@ -0,0 +1,388 @@ +'use strict' + +/** + * Differential fuzzer. Generates seeded random queries and checks the 3.x + * implementation (`../dist`) against the original 2.4.2 builder + * (`./legacy-oracle.cjs`, vendored verbatim) for byte-identical output. + * + * The generator stays inside the domain where the two MUST agree: it never + * emits the inputs whose behaviour 3.0.0 intentionally changed (lowercase + * `in ?`, empty objects). Those fixes are covered by the unit suite. Any other + * divergence is a real regression, and the seed + scenario is printed for exact + * reproduction. + * + * Usage: node test/fuzz.cjs [iterations] [seed] + */ + +const assert = require('assert') +const dist = require('../dist/index') +const legacy = require('./legacy-oracle.cjs') + +const ITERATIONS = Number(process.argv[2]) || 2000 +const BASE_SEED = Number(process.argv[3]) || 0x5119b1 + +// xorshift32 — deterministic, seedable. +const prng = (seed) => { + let s = seed >>> 0 || 1 + return () => { + s ^= s << 13; s >>>= 0 + s ^= s >> 17 + s ^= s << 5; s >>>= 0 + return s / 0x100000000 + } +} + +const COLUMNS = ['id', 'user_id', 'username', 'gender', 'email', 'status', 'age', 'is_hidden', 'created_at'] +const pick = (rand, arr) => arr[Math.floor(rand() * arr.length)] + +const randScalar = (rand) => { + switch (Math.floor(rand() * 7)) { + case 0: return Math.floor(rand() * 1000) + case 1: return 'str' + Math.floor(rand() * 1000) + case 2: return rand() < 0.5 + case 3: return null + case 4: return 'has?question' // a `?` inside a value must not confuse binding + case 5: return new Date(Math.floor(rand() * 1e12)) + default: return 'value with spaces' + } +} + +const randRow = (rand) => { + const n = 1 + Math.floor(rand() * 4) // never empty + const row = {} + const cols = COLUMNS.slice() + for (let i = 0; i < n && cols.length; i++) { + const idx = Math.floor(rand() * cols.length) + row[cols.splice(idx, 1)[0]] = randScalar(rand) + } + return row +} + +const randArray = (rand) => Array.from({ length: 1 + Math.floor(rand() * 5) }, () => randScalar(rand)) + +// Each template returns a partials array. Markers use uppercase IN/VALUES/SET so +// old and new agree (lowercase `in` is a 3.0.0-only fix, unit-tested separately). +const templates = [ + (rand) => ['SELECT * FROM t WHERE id = ?', randScalar(rand)], + (rand) => ['SELECT * FROM t WHERE a = ? AND b = ?', randScalar(rand), randScalar(rand)], + (rand) => ['SELECT', COLUMNS.slice(0, 1 + Math.floor(rand() * 4)), 'FROM t'], + (rand) => ['SELECT * FROM t WHERE ?', randRow(rand)], + (rand) => ['SELECT * FROM t WHERE ?', randRow(rand), 'ORDER BY id'], + (rand) => ['SELECT * FROM t WHERE ' + pick(rand, COLUMNS) + ' IN ?', randArray(rand)], + (rand) => ['INSERT INTO t VALUES ?', randRow(rand)], + (rand) => ['INSERT INTO t VALUES ?', randRow(rand), 'RETURNING id'], + (rand) => ['UPDATE t SET ?', randRow(rand), 'WHERE id = ?', randScalar(rand)], + (rand) => ['UPDATE t SET ?', randRow(rand), 'WHERE ' + pick(rand, COLUMNS) + ' IN ?', randArray(rand)], + (rand) => [ + 'SELECT * FROM t', + 'WHERE ' + pick(rand, COLUMNS) + ' = ? AND ' + pick(rand, COLUMNS) + ' = ?', randScalar(rand), randScalar(rand), + 'ORDER BY created_at', + ], + (rand) => ['INSERT INTO t (username, gender) VALUES (?, ?)', randScalar(rand), randScalar(rand)], +] + +let checked = 0 +for (let iter = 0; iter < ITERATIONS; iter++) { + const seed = (BASE_SEED + iter) >>> 0 + + // Build the SAME scenario twice from independent PRNGs seeded identically, so + // each builder gets its own fresh objects/Dates. This sidesteps the legacy + // builder's input-array mutation bug without any fragile deep-copy. + const gen = () => { + const rand = prng(seed) + const dialect = rand() < 0.5 ? 'pg' : 'mysql' + const template = templates[Math.floor(rand() * templates.length)] + return { dialect, partials: template(rand) } + } + const { dialect, partials } = gen() + + let a, b + try { + a = dist[dialect].apply(null, partials) + b = legacy[dialect].apply(null, gen().partials) + } catch (err) { + console.error(`\nFUZZ ERROR at seed ${seed} (dialect ${dialect}):`) + console.error(JSON.stringify(partials)) + throw err + } + + try { + assert.strictEqual(a.text, b.text, 'text mismatch') + assert.deepStrictEqual(a.values, b.values, 'values mismatch') + } catch (err) { + console.error(`\nDIVERGENCE at seed ${seed} (dialect ${dialect}):`) + console.error(' input :', JSON.stringify(partials)) + console.error(' 3.x :', JSON.stringify(a)) + console.error(' 2.4.2 :', JSON.stringify(b)) + throw err + } + checked++ +} + +console.log(`✓ differential fuzz: ${checked} scenarios agree with the 2.4.2 oracle (base seed 0x${BASE_SEED.toString(16)})`) + +// ───────────────────────────────────────────────────────────────────────── +// Property fuzz — the `sql` tag and the lexer have no 2.4.2 counterpart, so +// they are checked against invariants rather than an oracle. +// +// 1. every interpolated value is bound, in left-to-right order, and NEVER +// appears in the SQL text (the core safety property) +// 2. pg renders exactly $1..$n, in order, with n === values.length +// 3. mysql renders exactly n `?`, with n === values.length +// 4. rendering is pure — the same fragment renders identically every time, +// and for either dialect +// ───────────────────────────────────────────────────────────────────────── + +const { sql, pg, mysql } = dist + +// Distinctive, injection-shaped strings: if any of these ever land in `text`, +// the safety property is broken. +const POISON = [ + "'; DROP TABLE users; --", + '1 OR 1=1', + '$1', + '?', + 'a`b"c', + '$$x$$', +] + +// Identifier poison is kept DISJOINT from value poison: sql.id legitimately +// writes (escaped) identifier text into the SQL, so sharing a pool would make +// the "no value ever reaches the text" check ambiguous. +const ID_POISON = [ + 'we`ird', + 'ev"il', + 'my table', + 'sel$ect', + 'x?y', + 'has$1dollar', +] + +const randPoison = (rand) => POISON[Math.floor(rand() * POISON.length)] +const randIdPoison = (rand) => ID_POISON[Math.floor(rand() * ID_POISON.length)] + +// Build a random nested fragment, tracking the values it should bind in order. +const randFragment = (rand, depth, expected) => { + const roll = rand() + + if (depth < 2 && roll < 0.25) { + // Nested composition. + const left = randFragment(rand, depth + 1, expected) + const right = randFragment(rand, depth + 1, expected) + return sql`(${left} AND ${right})` + } + + if (roll < 0.4) { + const v = randPoison(rand) + expected.push(v) + return sql`name = ${v}` + } + + if (roll < 0.55) { + const arr = Array.from({ length: 1 + Math.floor(rand() * 4) }, () => randPoison(rand)) + arr.forEach((v) => expected.push(v)) + return sql`id IN ${arr}` + } + + if (roll < 0.65) { + // Identifiers are quoted, not bound — they add no values. + return sql`${sql.id(randIdPoison(rand))} IS NOT NULL` + } + + if (roll < 0.75) { + const items = Array.from({ length: 1 + Math.floor(rand() * 3) }, () => { + const v = randPoison(rand) + expected.push(v) + return sql`x = ${v}` + }) + return sql`(${sql.join(items, ' OR ')})` + } + + if (roll < 0.85) { + // A jsonb `?` operator in the tag needs no escaping — it must survive. + const v = randPoison(rand) + expected.push(v) + return sql`data ? ${v}` + } + + if (roll < 0.92) { + // sql.value binds the array itself as ONE parameter. + const v = randPoison(rand) + expected.push([v]) + return sql`tags = ${sql.value([v])}` + } + + const v = randPoison(rand) + expected.push(v) + return sql`created_at > ${v}` +} + +// Quoted identifiers may legitimately contain `?` or `$1` (sql.id quotes the +// poison strings), so blank them out before scanning for placeholders — +// otherwise the checker, not the library, is what's wrong. +// +// Dialect-aware on purpose: pg quotes with "double quotes" and treats backticks +// as ordinary characters, so stripping backtick spans from pg text would eat +// whatever sits between two identifiers — including a real $1. +const stripQuoted = (text, dialect) => + dialect === 'mysql' + ? text.replace(/`(?:[^`]|``)*`/g, '``') + : text.replace(/"(?:[^"]|"")*"/g, '""') + +const pgPlaceholders = (text) => (stripQuoted(text, 'pg').match(/\$\d+/g) || []) + +let propChecked = 0 +for (let iter = 0; iter < ITERATIONS; iter++) { + const seed = (BASE_SEED + 0x9e3779b9 + iter) >>> 0 + const rand = prng(seed) + + const expected = [] + const frag = sql`SELECT * FROM t WHERE ${randFragment(rand, 0, expected)}` + + try { + const a = pg(frag) + const b = mysql(frag) + + const aValues = a.values || [] + const bValues = b.values || [] + + // 1. values bound in order, and never inlined into the text. + assert.deepStrictEqual(aValues, expected, 'pg values must match interpolation order') + assert.deepStrictEqual(bValues, expected, 'mysql values must match interpolation order') + for (const v of expected) { + if (typeof v === 'string' && v.length > 3) { + assert.ok(a.text.indexOf(v) === -1, `value leaked into pg text: ${v}`) + assert.ok(b.text.indexOf(v) === -1, `value leaked into mysql text: ${v}`) + } + } + + // 2. pg renders exactly $1..$n in order. + const holes = pgPlaceholders(a.text) + assert.strictEqual(holes.length, expected.length, 'pg placeholder count') + holes.forEach((h, idx) => assert.strictEqual(h, '$' + (idx + 1), 'pg placeholder order')) + + // 3. mysql renders exactly n `?` — minus the jsonb `?` operators the + // generator may emit, which are part of the literal text. + const mysqlText = stripQuoted(b.text, 'mysql') + const jsonbOps = (mysqlText.match(/data \?/g) || []).length + const qs = (mysqlText.match(/\?/g) || []).length - jsonbOps + assert.strictEqual(qs, expected.length, 'mysql placeholder count') + + // 4. rendering is pure. + assert.deepStrictEqual(pg(frag), a, 'pg render must be repeatable') + assert.deepStrictEqual(mysql(frag), b, 'mysql render must be repeatable') + } catch (err) { + console.error(`\nPROPERTY FAILURE at seed ${seed}:`) + console.error(' pg :', JSON.stringify(pg(frag))) + console.error(' mysql :', JSON.stringify(mysql(frag))) + console.error(' expect:', JSON.stringify(expected)) + throw err + } + propChecked++ +} + +console.log(`✓ property fuzz: ${propChecked} sql-tag scenarios hold all invariants`) + +// ───────────────────────────────────────────────────────────────────────── +// Property fuzz — markers through the PARTIALS API. +// +// The marker classes (Sql/Identifier/Raw/Single) are plain objects, so a clause +// marker before a `?` used to enumerate their internal fields as column names +// (`WHERE ?` + sql.id(x) → `WHERE parts=$1`). The tag fuzzer above could not see +// that: it never routes a marker through `buildPartials`. This does. +// +// 1. a marker's internal field names never surface as SQL identifiers +// 2. bound values never appear in the text +// 3. pg renders exactly $1..$n, in order +// ───────────────────────────────────────────────────────────────────────── + +// Every own-property name of the marker classes. If any of these is ever +// emitted as a column, a marker got enumerated as a Row. +const MARKER_FIELDS = ['parts', 'nodes', 'value', 'text'] + +// Clause markers are what triggered the bug, so bias hard toward them. +const CLAUSES = [ + 'SELECT * FROM t WHERE ?', + 'INSERT INTO t VALUES ?', + 'UPDATE t SET ?', + 'SELECT * FROM t WHERE id IN ?', + 'SELECT ?', + 'SELECT * FROM t WHERE a = ? AND b = ?', +] + +let markerChecked = 0 +for (let iter = 0; iter < ITERATIONS; iter++) { + const seed = (BASE_SEED + 0x7f4a7c15 + iter) >>> 0 + const rand = prng(seed) + + const fragment = pick(rand, CLAUSES) + const holes = (fragment.match(/\?/g) || []).length + const expected = [] + + const partials = [fragment] + for (let h = 0; h < holes; h++) { + const roll = rand() + if (roll < 0.2) { + partials.push(sql.id(randIdPoison(rand))) // splices, binds nothing + } else if (roll < 0.35) { + partials.push(sql.raw('a=1')) // splices, binds nothing + } else if (roll < 0.5) { + const v = randPoison(rand) + expected.push([v]) + partials.push(sql.value([v])) // one bound value + } else if (roll < 0.65) { + const v = randPoison(rand) + expected.push(v) + partials.push(sql`a = ${v}`) // splices, binds one + } else if (roll < 0.8) { + const v = randPoison(rand) + expected.push(v) + partials.push({ col_a: v }) // a genuine Row expansion + } else { + const v = randPoison(rand) + expected.push(v) + partials.push(v) // a plain scalar + } + } + + let out + try { + out = pg(partials) + } catch (err) { + // A Row expansion under `SELECT ?` / `a = ?` has no clause marker, so the + // object binds as a value — never an error. Anything thrown is real. + console.error(`\nMARKER FUZZ ERROR at seed ${seed}:`) + console.error(' partials:', partials.map((p) => (typeof p === 'string' ? p : p.constructor.name)).join(' | ')) + throw err + } + + try { + // 1. no marker internal ever becomes a column name. + for (const field of MARKER_FIELDS) { + assert.ok( + !new RegExp(`(?:^|[\\s(,])${field}=\\$\\d`).test(out.text), + `marker field "${field}" surfaced as a column: ${out.text}` + ) + } + + // 2. values never reach the text. + for (const v of expected) { + if (typeof v === 'string' && v.length > 3) { + assert.ok(stripQuoted(out.text, 'pg').indexOf(v) === -1, `value leaked into text: ${v}`) + } + } + + // 3. pg renders $1..$n in order. + const ph = pgPlaceholders(out.text) + assert.strictEqual(ph.length, (out.values || []).length, 'pg placeholder count') + ph.forEach((h, idx) => assert.strictEqual(h, '$' + (idx + 1), 'pg placeholder order')) + } catch (err) { + console.error(`\nMARKER PROPERTY FAILURE at seed ${seed}:`) + console.error(' result:', JSON.stringify(out)) + console.error(' expect:', JSON.stringify(expected)) + throw err + } + markerChecked++ +} + +console.log(`✓ marker fuzz: ${markerChecked} partials-API scenarios hold all invariants`) diff --git a/test/index.test.cjs b/test/index.test.cjs new file mode 100644 index 0000000..9fdee20 --- /dev/null +++ b/test/index.test.cjs @@ -0,0 +1,610 @@ +'use strict' + +/** + * Unit suite. Runs against the built package (`../dist`), under + * `--unhandled-rejections=strict`. Covers the documented outputs (as exact + * assertions), every bug fixed in 3.0.0, the lexer edge cases, identifier + * safety, and the `sql` tagged-template API. + */ + +const assert = require('assert') +const { pg, mysql, sql } = require('../dist/index') +const def = require('../dist/index').default + +let passed = 0 +const tests = [] +const test = (name, fn) => tests.push([name, fn]) + +// ── Exports ────────────────────────────────────────────────────────────── +test('exposes pg, mysql and sql as named exports and on the default export', () => { + assert.strictEqual(typeof pg, 'function') + assert.strictEqual(typeof mysql, 'function') + assert.strictEqual(typeof sql, 'function') + assert.strictEqual(def.pg, pg) + assert.strictEqual(def.mysql, mysql) + assert.strictEqual(def.sql, sql) +}) + +// ── Documented behaviour (was test/build_query.js) ─────────────────────── +test('pg: scalars, OR, repeated params, projection array', () => { + const projection = ['id', 'username'] + assert.deepStrictEqual( + pg([ + 'SELECT', projection, 'FROM users', + 'WHERE id = ? AND username = ?', 123, 'sadasd?asdasd', + 'OR id = ?', 123, + 'AND username = ?', 'sadasd?asdasd', + ]), + { + text: 'SELECT id,username FROM users WHERE id = $1 AND username = $2 OR id = $3 AND username = $4', + values: [123, 'sadasd?asdasd', 123, 'sadasd?asdasd'], + } + ) +}) + +test('pg: no parameters means no values property', () => { + assert.deepStrictEqual(pg(['SELECT * FROM users']), { text: 'SELECT * FROM users' }) +}) + +test('pg: varargs call shape', () => { + assert.deepStrictEqual(pg('SELECT', ['id', 'username'], 'FROM users'), { + text: 'SELECT id,username FROM users', + }) +}) + +test('pg: a single ready string returns it verbatim', () => { + assert.deepStrictEqual(pg('SELECT * FROM users'), { text: 'SELECT * FROM users' }) +}) + +test('pg: UPDATE ... SET ? with a trailing WHERE', () => { + assert.deepStrictEqual( + pg([ + 'UPDATE users SET ?', { username: 'something', gender: 'male' }, + 'WHERE user_id = ? AND is_hidden = ?', 123, false, + ]), + { + text: 'UPDATE users SET username=$1,gender=$2 WHERE user_id = $3 AND is_hidden = $4', + values: ['something', 'male', 123, false], + } + ) +}) + +test('pg: INSERT ... VALUES ? object expansion', () => { + assert.deepStrictEqual( + pg(['INSERT INTO', 'users', 'VALUES ?', { username: 'something', gender: 'male' }]), + { text: 'INSERT INTO users (username,gender) VALUES ($1,$2)', values: ['something', 'male'] } + ) +}) + +test('pg: hand-written INSERT with explicit placeholders', () => { + assert.deepStrictEqual( + pg('INSERT INTO users (username, gender) VALUES (?, ?)', 'something', 'male'), + { text: 'INSERT INTO users (username, gender) VALUES ($1, $2)', values: ['something', 'male'] } + ) +}) + +test('pg: WHERE ? object expansion (AND-joined equality)', () => { + assert.deepStrictEqual(pg('SELECT * FROM users WHERE ?', { username: 'something', gender: 'male' }), { + text: 'SELECT * FROM users WHERE username=$1 AND gender=$2', + values: ['something', 'male'], + }) +}) + +test('pg: Date values pass through untouched and are not expanded as objects', () => { + const d = new Date('2026-01-01T00:00:00.000Z') + assert.deepStrictEqual(pg('SELECT id FROM users WHERE created_at > ?', d), { + text: 'SELECT id FROM users WHERE created_at > $1', + values: [d], + }) + // A Date directly after WHERE ? must bind, not expand into columns. + assert.deepStrictEqual(pg('SELECT * FROM t WHERE ?', d), { + text: 'SELECT * FROM t WHERE $1', + values: [d], + }) +}) + +test('pg: WHERE IN ? expands an array', () => { + assert.deepStrictEqual(pg('SELECT id FROM users WHERE user_id IN ?', [1, 2, 3]), { + text: 'SELECT id FROM users WHERE user_id IN ($1,$2,$3)', + values: [1, 2, 3], + }) +}) + +test('pg: RETURNING clause survives after VALUES ?', () => { + assert.deepStrictEqual(pg('INSERT INTO users VALUES ?', { username: 'John' }, 'RETURNING id'), { + text: 'INSERT INTO users (username) VALUES ($1) RETURNING id', + values: ['John'], + }) +}) + +// ── mysql dialect ──────────────────────────────────────────────────────── +test('mysql: keeps ? placeholders for scalars', () => { + assert.deepStrictEqual(mysql(['SELECT * FROM users', 'WHERE id = ? AND username = ?', 1, 'John Doe']), { + text: 'SELECT * FROM users WHERE id = ? AND username = ?', + values: [1, 'John Doe'], + }) +}) + +test('mysql: object expansion still uses ? placeholders', () => { + assert.deepStrictEqual(mysql('UPDATE users SET ?', { a: 1, b: 2 }, 'WHERE id = ?', 9), { + text: 'UPDATE users SET a=?,b=? WHERE id = ?', + values: [1, 2, 9], + }) +}) + +test('mysql: IN ? expansion', () => { + assert.deepStrictEqual(mysql('SELECT * FROM t WHERE id IN ?', [1, 2, 3]), { + text: 'SELECT * FROM t WHERE id IN (?,?,?)', + values: [1, 2, 3], + }) +}) + +// ── Regression tests for 3.0.0 bug fixes ───────────────────────────────── +test('FIX: lowercase "in ?" is expanded (was left as a literal placeholder)', () => { + assert.deepStrictEqual(pg('SELECT * FROM t WHERE id in ?', [1, 2, 3]), { + text: 'SELECT * FROM t WHERE id in ($1,$2,$3)', + values: [1, 2, 3], + }) + assert.deepStrictEqual(mysql('SELECT * FROM t WHERE id in ?', [7, 8]), { + text: 'SELECT * FROM t WHERE id in (?,?)', + values: [7, 8], + }) +}) + +test("FIX: the caller's partials array is never mutated", () => { + const projection = ['id', 'name'] + const partials = ['SELECT', projection, 'FROM t'] + pg(partials) + assert.deepStrictEqual(partials, ['SELECT', ['id', 'name'], 'FROM t']) + assert.deepStrictEqual(projection, ['id', 'name']) +}) + +test('FIX: an empty object throws a clear error instead of emitting invalid SQL', () => { + assert.throws(() => pg('INSERT INTO t VALUES ?', {}), /empty object/i) + assert.throws(() => pg('UPDATE t SET ?', {}), /empty object/i) + assert.throws(() => pg('SELECT * FROM t WHERE ?', {}), /empty object/i) +}) + +test('FIX: a stray value with no preceding placeholder throws a helpful error', () => { + assert.throws(() => pg(['SELECT * FROM t', 42]), /expected an SQL string/i) +}) + +test('FIX: too few values for the placeholders throws instead of binding undefined', () => { + assert.throws(() => pg(['SELECT * FROM t WHERE a = ? AND b = ?', 1]), /but only 1 value/i) +}) + +test('FIX: OFFSET ? is not misread as the SET ? clause', () => { + assert.deepStrictEqual(pg('SELECT * FROM t LIMIT 10 OFFSET ?', 20), { + text: 'SELECT * FROM t LIMIT 10 OFFSET $1', + values: [20], + }) +}) + +// ── Lexer: `?` that is NOT a placeholder ───────────────────────────────── +test('LEXER: postgres jsonb ?| and ?& operators survive alongside real placeholders', () => { + assert.deepStrictEqual(pg(["SELECT * FROM t WHERE tags ?| ARRAY['x'] AND id = ?", 5]), { + text: "SELECT * FROM t WHERE tags ?| ARRAY['x'] AND id = $1", + values: [5], + }) + assert.deepStrictEqual(pg(["SELECT * FROM t WHERE tags ?& ARRAY['x'] AND id = ?", 9]), { + text: "SELECT * FROM t WHERE tags ?& ARRAY['x'] AND id = $1", + values: [9], + }) +}) + +test('LEXER: \\? escapes a literal question mark (the bare jsonb ? operator)', () => { + assert.deepStrictEqual(pg(["SELECT * FROM t WHERE data \\? 'key' AND id = ?", 7]), { + text: "SELECT * FROM t WHERE data ? 'key' AND id = $1", + values: [7], + }) +}) + +test('LEXER: ? inside a single-quoted string literal is not a placeholder', () => { + assert.deepStrictEqual(pg(["SELECT 'why?' AS q FROM t WHERE id = ?", 1]), { + text: "SELECT 'why?' AS q FROM t WHERE id = $1", + values: [1], + }) +}) + +test("LEXER: '' escape inside a string literal is handled", () => { + assert.deepStrictEqual(pg(["SELECT 'it''s a ?' AS q FROM t WHERE id = ?", 1]), { + text: "SELECT 'it''s a ?' AS q FROM t WHERE id = $1", + values: [1], + }) +}) + +test("LEXER: pg E'...' escape strings honour backslash escapes", () => { + // Backslash escapes the quote inside E'…', so the string does not end early + // and the `?` inside it is not a placeholder. + assert.deepStrictEqual(pg(["SELECT E'a\\'b ?' AS s FROM t WHERE id = ?", 1]), { + text: "SELECT E'a\\'b ?' AS s FROM t WHERE id = $1", + values: [1], + }) +}) + +test('LEXER: pg standard strings treat backslash as an ordinary character', () => { + // standard_conforming_strings: `\` does NOT escape, so this string ends at + // the second quote and the trailing ? is the placeholder. + assert.deepStrictEqual(pg(["SELECT 'a\\' AS s, ? AS n FROM t", 1]), { + text: "SELECT 'a\\' AS s, $1 AS n FROM t", + values: [1], + }) +}) + +test('LEXER: mysql honours backslash escapes in both quote styles', () => { + assert.deepStrictEqual(mysql(["SELECT 'a\\'b ?' AS s FROM t WHERE id = ?", 1]), { + text: "SELECT 'a\\'b ?' AS s FROM t WHERE id = ?", + values: [1], + }) + assert.deepStrictEqual(mysql(['SELECT "a\\"b ?" AS s FROM t WHERE id = ?', 1]), { + text: 'SELECT "a\\"b ?" AS s FROM t WHERE id = ?', + values: [1], + }) +}) + +test('LEXER: ? inside a quoted identifier is not a placeholder', () => { + assert.deepStrictEqual(pg(['SELECT "we?ird" FROM t WHERE id = ?', 6]), { + text: 'SELECT "we?ird" FROM t WHERE id = $1', + values: [6], + }) + assert.deepStrictEqual(mysql(['SELECT `we?ird` FROM t WHERE id = ?', 6]), { + text: 'SELECT `we?ird` FROM t WHERE id = ?', + values: [6], + }) +}) + +test('LEXER: ? inside line and block comments is not a placeholder', () => { + assert.deepStrictEqual(pg(['SELECT * FROM t -- what?\nWHERE id = ?', 2]), { + text: 'SELECT * FROM t -- what?\nWHERE id = $1', + values: [2], + }) + assert.deepStrictEqual(pg(['SELECT * /* huh? */ FROM t WHERE id = ?', 8]), { + text: 'SELECT * /* huh? */ FROM t WHERE id = $1', + values: [8], + }) +}) + +test('LEXER: ? inside dollar-quoted strings (anonymous and tagged) is not a placeholder', () => { + assert.deepStrictEqual(pg(['SELECT $$a ? b$$ AS s FROM t WHERE id = ?', 3]), { + text: 'SELECT $$a ? b$$ AS s FROM t WHERE id = $1', + values: [3], + }) + assert.deepStrictEqual(pg(['SELECT $x$a ? b$x$ AS s FROM t WHERE id = ?', 4]), { + text: 'SELECT $x$a ? b$x$ AS s FROM t WHERE id = $1', + values: [4], + }) +}) + +test('LEXER: mysql `--` is only a comment when followed by whitespace', () => { + // Verified against MySQL 8.4: `SELECT 1--2` is 3 (two minus signs), and + // `SELECT 1--?` really does bind — so `--` must NOT swallow the placeholder. + assert.deepStrictEqual(mysql(['SELECT 1--? AS v', 2]), { + text: 'SELECT 1--? AS v', + values: [2], + }) + // With whitespace it IS a comment, so the ? inside is not a placeholder. + assert.deepStrictEqual(mysql(['SELECT ? AS n -- note ? here\n', 1]), { + text: 'SELECT ? AS n -- note ? here\n', + values: [1], + }) + // A bare `--` at end of input is a comment. + assert.deepStrictEqual(mysql(['SELECT ? AS n --', 1]), { text: 'SELECT ? AS n --', values: [1] }) + // Postgres always treats `--` as a comment, no whitespace required — so here + // the first ? is commented out and only the one on the next line binds. + assert.deepStrictEqual(pg(['SELECT 1--? AS v\n, ? ::int AS n', 2]), { + text: 'SELECT 1--? AS v\n, $1 ::int AS n', + values: [2], + }) +}) + +test('LEXER: mysql # line comments are skipped; pg # starts an operator', () => { + // MySQL: `#` runs to end of line, so the ? inside it is not a placeholder. + assert.deepStrictEqual( + mysql('SELECT * FROM t WHERE id = ? # note ? here\nAND active = ?', 1, true), + { text: 'SELECT * FROM t WHERE id = ? # note ? here\nAND active = ?', values: [1, true] } + ) + // Postgres has no # comment — #> and #- are jsonb operators and must survive. + assert.deepStrictEqual(pg(["SELECT data #> '{a}' FROM t WHERE id = ?", 1]), { + text: "SELECT data #> '{a}' FROM t WHERE id = $1", + values: [1], + }) + assert.deepStrictEqual(pg(["SELECT data #- '{a}' FROM t WHERE id = ?", 1]), { + text: "SELECT data #- '{a}' FROM t WHERE id = $1", + values: [1], + }) +}) + +test('LEXER: :: casts and $n text are left alone', () => { + assert.deepStrictEqual(pg(['SELECT id::text FROM t WHERE id = ?', 1]), { + text: 'SELECT id::text FROM t WHERE id = $1', + values: [1], + }) +}) + +// ── Identifier safety ──────────────────────────────────────────────────── +test('SECURITY: an injection payload as an object key is rejected', () => { + assert.throws(() => pg(['UPDATE t SET ?', { 'x=1; DROP TABLE t; --': 'v' }]), /not a valid column name/i) + assert.throws(() => pg(['INSERT INTO t VALUES ?', { 'a,b': 1 }]), /not a valid column name/i) + assert.throws(() => pg(['SELECT * FROM t WHERE ?', { 'a"b': 1 }]), /not a valid column name/i) +}) + +test('SECURITY: plain and dotted identifiers still pass through byte-for-byte', () => { + assert.deepStrictEqual(pg(['SELECT * FROM t WHERE ?', { 'u.id': 1, name_2$: 'x' }]), { + text: 'SELECT * FROM t WHERE u.id=$1 AND name_2$=$2', + values: [1, 'x'], + }) +}) + +test('SECURITY: IN ? values are never treated as identifiers', () => { + // Array indices are keys here — they must not go through identifier checks. + assert.deepStrictEqual(pg(['SELECT * FROM t WHERE id IN ?', ['; DROP TABLE t; --']]), { + text: 'SELECT * FROM t WHERE id IN ($1)', + values: ['; DROP TABLE t; --'], + }) +}) + +test('sql.id quotes per dialect and escapes embedded quotes', () => { + assert.deepStrictEqual(pg(['SELECT * FROM', sql.id('my table'), 'WHERE id = ?', 1]), { + text: 'SELECT * FROM "my table" WHERE id = $1', + values: [1], + }) + assert.deepStrictEqual(pg(['SELECT * FROM', sql.id('ev"il'), 'WHERE id = ?', 1]), { + text: 'SELECT * FROM "ev""il" WHERE id = $1', + values: [1], + }) + assert.deepStrictEqual(mysql(['SELECT * FROM', sql.id('we`ird'), 'WHERE id = ?', 1]), { + text: 'SELECT * FROM `we``ird` WHERE id = ?', + values: [1], + }) +}) + +test('sql.id joins parts with a dot for schema qualification', () => { + assert.deepStrictEqual(pg(sql`SELECT * FROM ${sql.id('public', 'users')} WHERE id = ${1}`), { + text: 'SELECT * FROM "public"."users" WHERE id = $1', + values: [1], + }) +}) + +test('sql.id rejects NUL and non-strings', () => { + assert.throws(() => pg(sql`SELECT * FROM ${sql.id('a\0b')}`), /NUL/i) + assert.throws(() => pg(sql`SELECT * FROM ${sql.id('')}`), /non-empty string/i) +}) + +// ── withMode: non-default server lexing settings ───────────────────────── +test('withMode: pg standardConformingStrings=false makes \\ escape in \'...\'', () => { + const frag = "SELECT 'a\\'b ?' AS s, ? ::int AS n" + // Default (scs=on): \ is ordinary, so the string ends at the 2nd quote and + // the FIRST ? is the placeholder. + assert.deepStrictEqual(pg([frag, 1]), { + text: "SELECT 'a\\'b $1' AS s, ? ::int AS n", + values: [1], + }) + // scs=off: \' escapes, so the ? inside the string is not a placeholder and + // the SECOND ? binds. + assert.deepStrictEqual(pg.withMode({ standardConformingStrings: false })([frag, 1]), { + text: "SELECT 'a\\'b ?' AS s, $1 ::int AS n", + values: [1], + }) +}) + +test('withMode: mysql noBackslashEscapes changes where the string ends', () => { + // Default: \' escapes → the string swallows the first ?, so only one value. + assert.deepStrictEqual(mysql(["SELECT 'a\\'b ?' AS s, ? AS n", 1]), { + text: "SELECT 'a\\'b ?' AS s, ? AS n", + values: [1], + }) + // noBackslashEscapes: the string is 'a\' and ends early, so the ? after it is + // a placeholder — same count here, but the binding position differs. + assert.deepStrictEqual(mysql.withMode({ noBackslashEscapes: true })(["SELECT 'a\\' AS s, ? AS n", 1]), { + text: "SELECT 'a\\' AS s, ? AS n", + values: [1], + }) +}) + +test('withMode: mysql ansiQuotes treats "..." as an identifier (no \\ escape)', () => { + // Under ANSI_QUOTES a "…" identifier is escaped by doubling, and `"a\"b"` is + // a syntax error on the server — so only the doubling form is valid SQL. + assert.deepStrictEqual(mysql.withMode({ ansiQuotes: true })(['SELECT "a""b" AS s, ? AS n', 1]), { + text: 'SELECT "a""b" AS s, ? AS n', + values: [1], + }) +}) + +test('withMode: returns a new builder and never mutates the original', () => { + const configured = mysql.withMode({ noBackslashEscapes: true }) + assert.notStrictEqual(configured, mysql) + assert.strictEqual(typeof configured.withMode, 'function') + // The default builder still lexes with backslash escapes. + assert.deepStrictEqual(mysql(["SELECT 'a\\'b ?' AS s, ? AS n", 1]).values, [1]) + // Modes merge when chained. + const both = mysql.withMode({ ansiQuotes: true }).withMode({ noBackslashEscapes: true }) + assert.deepStrictEqual(both(['SELECT "a""b" AS s, ? AS n', 1]), { + text: 'SELECT "a""b" AS s, ? AS n', + values: [1], + }) +}) + +test('withMode: the sql tag is unaffected by mode (it never lexes)', () => { + const frag = sql`SELECT ${"it's ?"} AS a, ${2} AS n` + const expected = { text: 'SELECT ? AS a, ? AS n', values: ["it's ?", 2] } + assert.deepStrictEqual(mysql(frag), expected) + assert.deepStrictEqual(mysql.withMode({ ansiQuotes: true, noBackslashEscapes: true })(frag), expected) +}) + +// ── Markers at a value position ────────────────────────────────────────── +// The marker classes are objects, so a clause marker before the `?` must not +// enumerate their internal fields (`parts`/`text`/`nodes`/`value`) as columns. +test('MARKERS: sql.id at a clause-marker value position splices, never expands', () => { + assert.deepStrictEqual(pg(['SELECT * FROM t WHERE ?', sql.id('foo')]), { + text: 'SELECT * FROM t WHERE "foo"', + }) + assert.deepStrictEqual(pg(['INSERT INTO t VALUES ?', sql.id('foo')]), { + text: 'INSERT INTO t VALUES "foo"', + }) + assert.deepStrictEqual(pg(['UPDATE t SET ?', sql.id('foo')]), { + text: 'UPDATE t SET "foo"', + }) + assert.deepStrictEqual(pg(['SELECT * FROM t WHERE id IN ?', sql.id('foo')]), { + text: 'SELECT * FROM t WHERE id IN "foo"', + }) +}) + +test('MARKERS: sql.raw / sql.value at a clause-marker value position', () => { + assert.deepStrictEqual(pg(['UPDATE t SET ?', sql.raw('a=1')]), { text: 'UPDATE t SET a=1' }) + assert.deepStrictEqual(pg(['SELECT * FROM t WHERE id IN ?', sql.value([1, 2])]), { + text: 'SELECT * FROM t WHERE id IN $1', + values: [[1, 2]], + }) +}) + +test('MARKERS: an sql`` fragment at a value position splices with continuous numbering', () => { + assert.deepStrictEqual(pg(['SELECT * FROM t WHERE ?', sql`a = ${1}`]), { + text: 'SELECT * FROM t WHERE a = $1', + values: [1], + }) + // Also without a clause marker, and with numbering continuing across it. + assert.deepStrictEqual(pg(['SELECT * FROM t WHERE x = ?', 9, 'AND ?', sql`a = ${1}`, 'AND b = ?', 2]), { + text: 'SELECT * FROM t WHERE x = $1 AND a = $2 AND b = $3', + values: [9, 1, 2], + }) +}) + +test('MARKERS: a plain object still expands at a clause marker', () => { + // The reordering must not break the ordinary Row path. + assert.deepStrictEqual(pg(['SELECT * FROM t WHERE ?', { a: 1, b: 2 }]), { + text: 'SELECT * FROM t WHERE a=$1 AND b=$2', + values: [1, 2], + }) +}) + +// ── The sql tagged-template API ────────────────────────────────────────── +test('sql: interpolations are always parameterised, in both dialects', () => { + assert.deepStrictEqual(pg(sql`SELECT * FROM users WHERE id = ${42}`), { + text: 'SELECT * FROM users WHERE id = $1', + values: [42], + }) + assert.deepStrictEqual(mysql(sql`SELECT * FROM users WHERE id = ${42}`), { + text: 'SELECT * FROM users WHERE id = ?', + values: [42], + }) +}) + +test('sql: an injection payload stays a bound value', () => { + const evil = '1; DROP TABLE users; --' + assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE id = ${evil}`), { + text: 'SELECT * FROM t WHERE id = $1', + values: [evil], + }) +}) + +test('sql: a bare jsonb ? operator needs no escaping in the tag', () => { + assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE data ? ${'key'} AND id = ${1}`), { + text: 'SELECT * FROM t WHERE data ? $1 AND id = $2', + values: ['key', 1], + }) +}) + +test('sql: arrays expand to a parenthesised list (the IN form)', () => { + assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE id IN ${[1, 2, 3]}`), { + text: 'SELECT * FROM t WHERE id IN ($1,$2,$3)', + values: [1, 2, 3], + }) +}) + +test('sql.value forces a single bound parameter for an array', () => { + assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE tags = ${sql.value([1, 2])}`), { + text: 'SELECT * FROM t WHERE tags = $1', + values: [[1, 2]], + }) +}) + +test('sql: fragments compose by nesting, numbering stays continuous', () => { + assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE ${sql`a = ${1}`} AND ${sql`b = ${2}`}`), { + text: 'SELECT * FROM t WHERE a = $1 AND b = $2', + values: [1, 2], + }) +}) + +test('sql.join joins fragments with a separator', () => { + assert.deepStrictEqual( + pg(sql`SELECT * FROM t WHERE ${sql.join([sql`a = ${1}`, sql`b = ${2}`], ' AND ')}`), + { text: 'SELECT * FROM t WHERE a = $1 AND b = $2', values: [1, 2] } + ) + assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE id IN (${sql.join([1, 2])})`), { + text: 'SELECT * FROM t WHERE id IN ($1, $2)', + values: [1, 2], + }) +}) + +test('sql.raw injects unescaped text and binds nothing', () => { + assert.deepStrictEqual(pg(sql`SELECT * FROM t ORDER BY id ${sql.raw('DESC')}`), { + text: 'SELECT * FROM t ORDER BY id DESC', + }) +}) + +test('sql.empty renders to nothing and is usable as a conditional', () => { + const cond = false + assert.deepStrictEqual(pg(sql`SELECT * FROM t${cond ? sql` WHERE a = ${1}` : sql.empty}`), { + text: 'SELECT * FROM t', + }) +}) + +test('sql: a fragment with no values has no values property', () => { + assert.deepStrictEqual(pg(sql`SELECT 1`), { text: 'SELECT 1' }) +}) + +test('sql: null / false / Date interpolate as bound values', () => { + const d = new Date('2026-01-01T00:00:00.000Z') + assert.deepStrictEqual(pg(sql`SELECT * FROM t WHERE a = ${null} AND b = ${false} AND c > ${d}`), { + text: 'SELECT * FROM t WHERE a = $1 AND b = $2 AND c > $3', + values: [null, false, d], + }) +}) + +test('sql: the same fragment can be rendered for both dialects independently', () => { + const frag = sql`SELECT * FROM t WHERE id = ${1} AND b = ${2}` + assert.deepStrictEqual(pg(frag), { text: 'SELECT * FROM t WHERE id = $1 AND b = $2', values: [1, 2] }) + assert.deepStrictEqual(mysql(frag), { text: 'SELECT * FROM t WHERE id = ? AND b = ?', values: [1, 2] }) + // Rendering must not consume/mutate the fragment. + assert.deepStrictEqual(pg(frag), { text: 'SELECT * FROM t WHERE id = $1 AND b = $2', values: [1, 2] }) +}) + +// ── Edge cases ─────────────────────────────────────────────────────────── +test('null and boolean values are bound, not inlined', () => { + assert.deepStrictEqual(pg('SELECT * FROM t WHERE a = ? AND b = ?', null, false), { + text: 'SELECT * FROM t WHERE a = $1 AND b = $2', + values: [null, false], + }) +}) + +test('WHERE ? object followed by a trailing scalar placeholder', () => { + assert.deepStrictEqual(pg('SELECT * FROM t WHERE ? AND active = ?', { a: 1 }, true), { + text: 'SELECT * FROM t WHERE a=$1 AND active = $2', + values: [1, true], + }) +}) + +test('pg placeholder numbering is continuous across mixed clauses', () => { + const q = pg([ + 'UPDATE t SET ?', { a: 1, b: 2 }, + 'WHERE id IN ?', [10, 11], + 'AND status = ?', 'x', + ]) + assert.strictEqual(q.text, 'UPDATE t SET a=$1,b=$2 WHERE id IN ($3,$4) AND status = $5') + assert.deepStrictEqual(q.values, [1, 2, 10, 11, 'x']) +}) + +// ── Runner ─────────────────────────────────────────────────────────────── +;(async () => { + for (const [name, fn] of tests) { + try { + await fn() + passed++ + } catch (err) { + console.error(`✗ ${name}`) + console.error(err && err.stack ? err.stack : err) + process.exit(1) + } + } + console.log(`✓ ${passed}/${tests.length} unit tests passed`) +})() diff --git a/test/integration.cjs b/test/integration.cjs new file mode 100644 index 0000000..aafbbb6 --- /dev/null +++ b/test/integration.cjs @@ -0,0 +1,445 @@ +'use strict' + +/** + * Integration tests against REAL Postgres and MySQL servers. + * + * The unit suite asserts on the generated `{ text, values }` — which only ever + * proves the builder agrees with *our reading* of the SQL grammars. This suite + * executes the built queries and asserts on the rows that come back, so the + * lexer's grammar assumptions are checked against the engines themselves. + * (Both of the lexer bugs found so far — pg `E'…'` escapes and MySQL's + * whitespace-after-`--` rule — were exactly that kind of mistake.) + * + * Config (CI sets these; both default to the docker-compose-ish local ports): + * PG_URL postgres://postgres:secret@localhost:55432/sbtest + * MYSQL_URL mysql://root:secret@127.0.0.1:53306/sbtest + * + * Usage: node test/integration.cjs + */ + +const assert = require('assert') +const { Client } = require('pg') +const mysql = require('mysql2/promise') +const { pg, mysql: my, sql } = require('../dist/index') + +const PG_URL = process.env.PG_URL || 'postgres://postgres:secret@localhost:55432/sbtest' +const MYSQL_URL = process.env.MYSQL_URL || 'mysql://root:secret@127.0.0.1:53306/sbtest' + +let passed = 0 +const tests = [] +const test = (name, fn) => tests.push([name, fn]) + +// Run a built query and return the rows. `q` is a BuildResult. +// +// Postgres always binds server-side, so `query` is a true prepared statement. +// +// For MySQL we deliberately use `execute` (a real server-side prepared +// statement) rather than `query`, for two reasons: +// 1. `query` makes mysql2 substitute the `?` itself, client-side — that would +// test mysql2's scanner, not ours. `execute` sends our text to the server +// verbatim, so MySQL's own parser decides how many placeholders it has. +// 2. mysql2's client-side escaping uses backslashes (`escape("it's")` → +// `'it\'s'`), which breaks under sql_mode=NO_BACKSLASH_ESCAPES — it errors, +// and can corrupt values (`x\` round-trips as `x\\`). `execute` binds +// server-side and is correct in every mode. +// +// How strict each server is about a placeholder-count mismatch (measured, not +// assumed — MySQL 8.4 / PG 16): +// pg rejects too FEW and too MANY ("bind message supplies N +// parameters, but prepared statement requires M"). +// mysql execute() rejects too FEW ("Incorrect arguments to COM_STMT_EXECUTE") +// but SILENTLY IGNORES extras. +// So the server is a full oracle for pg, and only a half-oracle for MySQL — +// which is why every test below asserts on the returned ROWS, not merely on the +// query having executed. An over-count on MySQL is caught by the values coming +// back wrong, not by an error. +const runPg = async (client, q) => (await client.query(q.text, q.values || [])).rows +const runMy = async (conn, q) => + q.values && q.values.length ? (await conn.execute(q.text, q.values))[0] : (await conn.query(q.text))[0] + +// ───────────────────────────────────────────────────────────────────────── +// Postgres +// ───────────────────────────────────────────────────────────────────────── + +test('pg: values round-trip through INSERT ... VALUES ? / SELECT ... WHERE ?', async (c) => { + await runPg(c, pg(['INSERT INTO users VALUES ?', { username: 'ada', gender: 'f', age: 36 }])) + const rows = await runPg(c, pg(['SELECT username, age FROM users WHERE ?', { username: 'ada' }])) + assert.deepStrictEqual(rows, [{ username: 'ada', age: 36 }]) +}) + +test('pg: UPDATE ... SET ? applies to the right row', async (c) => { + await runPg(c, pg(['INSERT INTO users VALUES ?', { username: 'grace', gender: 'f', age: 45 }])) + await runPg(c, pg(['UPDATE users SET ?', { age: 46 }, 'WHERE username = ?', 'grace'])) + const rows = await runPg(c, pg(['SELECT age FROM users WHERE username = ?', 'grace'])) + assert.deepStrictEqual(rows, [{ age: 46 }]) +}) + +test('pg: WHERE ... IN ? matches exactly the listed rows', async (c) => { + const rows = await runPg( + c, + pg(['SELECT username FROM users WHERE username IN ?', ['ada', 'grace'], 'ORDER BY username']) + ) + assert.deepStrictEqual(rows.map((r) => r.username), ['ada', 'grace']) +}) + +test('pg: $1..$n numbering is correct across mixed clauses', async (c) => { + const rows = await runPg( + c, + pg(['SELECT username FROM users WHERE ?', { gender: 'f' }, 'AND age IN ?', [36, 46], 'AND username <> ?', 'nobody', 'ORDER BY username']) + ) + assert.deepStrictEqual(rows.map((r) => r.username), ['ada', 'grace']) +}) + +test('pg: an injection payload is stored as literal data, not executed', async (c) => { + const evil = "'; DROP TABLE users; --" + await runPg(c, pg(['INSERT INTO users VALUES ?', { username: evil, gender: 'x', age: 1 }])) + // The table still exists and the payload came back verbatim. + const rows = await runPg(c, pg(['SELECT username FROM users WHERE username = ?', evil])) + assert.deepStrictEqual(rows, [{ username: evil }]) + await runPg(c, pg(['DELETE FROM users WHERE username = ?', evil])) +}) + +test('pg: sql tag parameterises an injection payload', async (c) => { + const evil = "1; DROP TABLE users; --" + const rows = await runPg(c, pg(sql`SELECT ${evil}::text AS v`)) + assert.deepStrictEqual(rows, [{ v: evil }]) +}) + +test('pg: jsonb ?| and ?& operators execute correctly alongside a placeholder', async (c) => { + const rows = await runPg( + c, + pg([`SELECT '{"k":1,"z":2}'::jsonb ?| ARRAY['k','nope'] AS any_of, '{"k":1,"z":2}'::jsonb ?& ARRAY['k','z'] AS all_of, ? ::int AS n`, 7]) + ) + assert.deepStrictEqual(rows, [{ any_of: true, all_of: true, n: 7 }]) +}) + +test('pg: the bare jsonb ? operator works via the \\? escape', async (c) => { + const rows = await runPg(c, pg([`SELECT '{"k":1}'::jsonb \\? 'k' AS has_k, ? ::int AS n`, 3])) + assert.deepStrictEqual(rows, [{ has_k: true, n: 3 }]) +}) + +test('pg: the bare jsonb ? operator needs no escape in the sql tag', async (c) => { + const rows = await runPg(c, pg(sql`SELECT '{"k":1}'::jsonb ? ${'k'} AS has_k, ${3}::int AS n`)) + assert.deepStrictEqual(rows, [{ has_k: true, n: 3 }]) +}) + +test('pg: #> and #- are operators, not comments', async (c) => { + const rows = await runPg( + c, + pg([`SELECT '{"a":{"b":1}}'::jsonb #> '{a,b}' AS got, '{"a":1,"b":2}'::jsonb #- '{a}' AS deleted, ? ::int AS n`, 5]) + ) + assert.deepStrictEqual(rows, [{ got: 1, deleted: { b: 2 }, n: 5 }]) +}) + +test('pg: ? inside string literals, dollar-quotes and comments is not a placeholder', async (c) => { + const rows = await runPg(c, pg(["SELECT 'why?' AS a, $$d ? q$$ AS b, $x$t ? q$x$ AS c, ? ::int AS n -- trailing ?\n", 1])) + assert.deepStrictEqual(rows, [{ a: 'why?', b: 'd ? q', c: 't ? q', n: 1 }]) +}) + +test("pg: E'...' honours backslash escapes; a standard string does not", async (c) => { + // E'a\'b ?' → the ? is inside the string; the real placeholder is the second. + const rows = await runPg(c, pg(["SELECT E'a\\'b ?' AS e, ? ::int AS n", 2])) + assert.deepStrictEqual(rows, [{ e: "a'b ?", n: 2 }]) + // standard_conforming_strings: backslash is literal, so this string ends at + // the second quote and the trailing ? is the placeholder. + const rows2 = await runPg(c, pg(["SELECT 'a\\' AS s, ? ::int AS n", 4])) + assert.deepStrictEqual(rows2, [{ s: 'a\\', n: 4 }]) +}) + +test('pg: sql.id quotes identifiers, and quoting is case-sensitive', async (c) => { + // The column is created case-sensitively, so only the quoted form finds it. + const rows = await runPg(c, pg(['SELECT', sql.id('MixedCase'), 'FROM quoted WHERE id = ?', 1])) + assert.deepStrictEqual(rows, [{ MixedCase: 'v' }]) +}) + +test('pg: sql.id escapes an embedded double quote', async (c) => { + const rows = await runPg(c, pg(['SELECT', sql.id('ev"il'), 'FROM quoted WHERE id = ?', 1])) + assert.deepStrictEqual(rows, [{ 'ev"il': 'w' }]) +}) + +test('pg: sql.id schema-qualifies', async (c) => { + const rows = await runPg(c, pg(sql`SELECT count(*)::int AS n FROM ${sql.id('public', 'users')}`)) + assert.ok(rows[0].n >= 2) +}) + +test('pg: sql tag composes fragments with correct numbering', async (c) => { + const where = sql.join([sql`gender = ${'f'}`, sql`age >= ${40}`], ' AND ') + const rows = await runPg(c, pg(sql`SELECT username FROM users WHERE ${where} ORDER BY username`)) + assert.deepStrictEqual(rows.map((r) => r.username), ['grace']) +}) + +test('pg: an sql`` fragment spliced at a ? value position executes', async (c) => { + const rows = await runPg(c, pg(['SELECT username FROM users WHERE ?', sql`age = ${36}`])) + assert.deepStrictEqual(rows.map((r) => r.username), ['ada']) +}) + +test('pg: LIKE wildcards in a bound value still act as wildcards (documented)', async (c) => { + const rows = await runPg(c, pg(['SELECT username FROM users WHERE username LIKE ? ORDER BY username', '%a%'])) + assert.ok(rows.length >= 2, 'the % in the value matched as a wildcard, as documented') +}) + +// ───────────────────────────────────────────────────────────────────────── +// MySQL +// ───────────────────────────────────────────────────────────────────────── + +test('mysql: values round-trip through INSERT ... VALUES ? / SELECT ... WHERE ?', async (_c, m) => { + await runMy(m, my(['INSERT INTO users VALUES ?', { username: 'ada', gender: 'f', age: 36 }])) + const rows = await runMy(m, my(['SELECT username, age FROM users WHERE ?', { username: 'ada' }])) + assert.deepStrictEqual(rows.map((r) => ({ username: r.username, age: r.age })), [{ username: 'ada', age: 36 }]) +}) + +test('mysql: UPDATE ... SET ? applies to the right row', async (_c, m) => { + await runMy(m, my(['INSERT INTO users VALUES ?', { username: 'grace', gender: 'f', age: 45 }])) + await runMy(m, my(['UPDATE users SET ?', { age: 46 }, 'WHERE username = ?', 'grace'])) + const rows = await runMy(m, my(['SELECT age FROM users WHERE username = ?', 'grace'])) + assert.strictEqual(rows[0].age, 46) +}) + +test('mysql: WHERE ... IN ? matches exactly the listed rows', async (_c, m) => { + const rows = await runMy(m, my(['SELECT username FROM users WHERE username IN ?', ['ada', 'grace'], 'ORDER BY username'])) + assert.deepStrictEqual(rows.map((r) => r.username), ['ada', 'grace']) +}) + +test('mysql: an injection payload is stored as literal data, not executed', async (_c, m) => { + const evil = "'; DROP TABLE users; --" + await runMy(m, my(['INSERT INTO users VALUES ?', { username: evil, gender: 'x', age: 1 }])) + const rows = await runMy(m, my(['SELECT username FROM users WHERE username = ?', evil])) + assert.deepStrictEqual(rows.map((r) => r.username), [evil]) + await runMy(m, my(['DELETE FROM users WHERE username = ?', evil])) +}) + +test('mysql: # line comment is not scanned for placeholders', async (_c, m) => { + const rows = await runMy(m, my(['SELECT ? AS n # note ? here\n', 1])) + assert.strictEqual(rows[0].n, 1) +}) + +test('mysql: `--` is only a comment when followed by whitespace', async (_c, m) => { + // `-- ` (with space) → comment: the trailing ? is not a placeholder. + const rows = await runMy(m, my(['SELECT ? AS n -- note ? here\n', 1])) + assert.strictEqual(rows[0].n, 1) + // `--?` (no space) → NOT a comment: two minus signs, so ? really binds. + // 1--2 === 3 in MySQL. + const rows2 = await runMy(m, my(['SELECT 1--? AS v', 2])) + assert.strictEqual(Number(rows2[0].v), 3) +}) + +test('mysql: ? inside string literals and backslash escapes is not a placeholder', async (_c, m) => { + const rows = await runMy(m, my(["SELECT 'why?' AS a, 'a\\'b ?' AS b, ? AS n", 1])) + assert.strictEqual(rows[0].a, 'why?') + assert.strictEqual(rows[0].b, "a'b ?") + assert.strictEqual(rows[0].n, 1) +}) + +test('mysql: sql.id quotes with backticks and escapes an embedded backtick', async (_c, m) => { + const rows = await runMy(m, my(['SELECT', sql.id('we`ird'), 'FROM quoted WHERE id = ?', 1])) + assert.strictEqual(rows[0]['we`ird'], 'v') +}) + +test('mysql: sql tag composes fragments', async (_c, m) => { + const where = sql.join([sql`gender = ${'f'}`, sql`age >= ${40}`], ' AND ') + const rows = await runMy(m, my(sql`SELECT username FROM users WHERE ${where} ORDER BY username`)) + assert.deepStrictEqual(rows.map((r) => r.username), ['grace']) +}) + +// ───────────────────────────────────────────────────────────────────────── +// sql_mode / standard_conforming_strings matrix +// +// Three server settings change how SQL *lexes*, and therefore which `?` is a +// placeholder. Each is applied to a real session AND to the builder via +// `withMode`, so the two must agree. Verified behaviour (PG 16 / MySQL 8.4): +// +// MySQL ANSI_QUOTES "…" is an identifier ("" doubling), not a +// string; `"a\"b"` is a syntax error there. +// MySQL NO_BACKSLASH_ESCAPES `\` is ordinary inside literals: 'a\' is a\. +// pg standard_conforming_strings=off +// `\` escapes inside '…' — the opposite of the +// default, which treats it as ordinary. +// +// `''` doubling lexes identically in EVERY mode, and the `sql` tag never lexes +// at all, so the "universal" cases below must pass in all modes unconfigured. +// ───────────────────────────────────────────────────────────────────────── + +const MYSQL_MODES = [ + { name: 'DEFAULT', sqlMode: '', mode: {} }, + { name: 'ANSI_QUOTES', sqlMode: 'ANSI_QUOTES', mode: { ansiQuotes: true } }, + { name: 'NO_BACKSLASH_ESCAPES', sqlMode: 'NO_BACKSLASH_ESCAPES', mode: { noBackslashEscapes: true } }, + { + name: 'ANSI_QUOTES,NO_BACKSLASH_ESCAPES', + sqlMode: 'ANSI_QUOTES,NO_BACKSLASH_ESCAPES', + mode: { ansiQuotes: true, noBackslashEscapes: true }, + }, +] + +const PG_MODES = [ + { name: 'standard_conforming_strings=on', scs: 'on', mode: {} }, + { name: 'standard_conforming_strings=off', scs: 'off', mode: { standardConformingStrings: false } }, +] + +const modeTests = [] +const modeTest = (name, fn) => modeTests.push([name, fn]) + +for (const m of MYSQL_MODES) { + const escapes = !m.mode.noBackslashEscapes + + modeTest(`mysql[${m.name}]: '' doubling is mode-proof`, async (_c, conn) => { + const b = my.withMode(m.mode) + const rows = await runMy(conn, b(["SELECT 'it''s ?' AS a, ? AS n", 1])) + assert.strictEqual(rows[0].a, "it's ?") + assert.strictEqual(rows[0].n, 1) + }) + + modeTest(`mysql[${m.name}]: backtick identifiers work in every mode`, async (_c, conn) => { + const b = my.withMode(m.mode) + const rows = await runMy(conn, b(['SELECT', sql.id('we`ird'), 'FROM quoted WHERE id = ?', 1])) + assert.strictEqual(rows[0]['we`ird'], 'v') + }) + + modeTest(`mysql[${m.name}]: object expansion round-trips`, async (_c, conn) => { + const b = my.withMode(m.mode) + await runMy(conn, b(['INSERT INTO modes VALUES ?', { k: m.name, n: 7 }])) + const rows = await runMy(conn, b(['SELECT n FROM modes WHERE ?', { k: m.name }])) + assert.strictEqual(rows[0].n, 7) + }) + + modeTest(`mysql[${m.name}]: the sql tag is mode-proof (never lexes)`, async (_c, conn) => { + const b = my.withMode(m.mode) + const rows = await runMy(conn, b(sql`SELECT ${"it's ?"} AS a, ${2} AS n`)) + assert.strictEqual(rows[0].a, "it's ?") + assert.strictEqual(rows[0].n, 2) + }) + + modeTest(`mysql[${m.name}]: backslash-in-literal follows the mode`, async (_c, conn) => { + const b = my.withMode(m.mode) + if (escapes) { + // \' escapes: the ? lives inside the string, so the 2nd ? is the param. + const rows = await runMy(conn, b(["SELECT 'a\\'b ?' AS a, ? AS n", 1])) + assert.strictEqual(rows[0].a, "a'b ?") + assert.strictEqual(rows[0].n, 1) + } else { + // \ is ordinary: the string is 'a\' and ends at the second quote. + const rows = await runMy(conn, b(["SELECT 'a\\' AS a, ? AS n", 1])) + assert.strictEqual(rows[0].a, 'a\\') + assert.strictEqual(rows[0].n, 1) + } + }) +} + +for (const m of PG_MODES) { + const escapes = m.mode.standardConformingStrings === false + + modeTest(`pg[${m.name}]: '' doubling is mode-proof`, async (c) => { + const b = pg.withMode(m.mode) + const rows = await runPg(c, b(["SELECT 'it''s ?' AS a, ? ::int AS n", 1])) + assert.deepStrictEqual(rows, [{ a: "it's ?", n: 1 }]) + }) + + modeTest(`pg[${m.name}]: quoted identifiers work in every mode`, async (c) => { + const b = pg.withMode(m.mode) + const rows = await runPg(c, b(['SELECT', sql.id('MixedCase'), 'FROM quoted WHERE id = ?', 1])) + assert.deepStrictEqual(rows, [{ MixedCase: 'v' }]) + }) + + modeTest(`pg[${m.name}]: the sql tag is mode-proof (never lexes)`, async (c) => { + const b = pg.withMode(m.mode) + const rows = await runPg(c, b(sql`SELECT ${"it's ?"}::text AS a, ${2}::int AS n`)) + assert.deepStrictEqual(rows, [{ a: "it's ?", n: 2 }]) + }) + + modeTest(`pg[${m.name}]: E'…' always honours backslash escapes`, async (c) => { + const b = pg.withMode(m.mode) + const rows = await runPg(c, b(["SELECT E'a\\'b ?' AS a, ? ::int AS n", 1])) + assert.deepStrictEqual(rows, [{ a: "a'b ?", n: 1 }]) + }) + + modeTest(`pg[${m.name}]: backslash-in-literal follows the mode`, async (c) => { + const b = pg.withMode(m.mode) + if (escapes) { + // scs=off: \' escapes, so the ? is inside the string. + const rows = await runPg(c, b(["SELECT 'a\\'b ?' AS a, ? ::int AS n", 1])) + assert.deepStrictEqual(rows, [{ a: "a'b ?", n: 1 }]) + } else { + // scs=on (default): \ is ordinary, so the string is 'a\'. + const rows = await runPg(c, b(["SELECT 'a\\' AS a, ? ::int AS n", 1])) + assert.deepStrictEqual(rows, [{ a: 'a\\', n: 1 }]) + } + }) +} + +// ───────────────────────────────────────────────────────────────────────── +// Runner +// ───────────────────────────────────────────────────────────────────────── + +const schema = { + pg: [ + 'DROP TABLE IF EXISTS users', + 'DROP TABLE IF EXISTS quoted', + 'CREATE TABLE users (username text, gender text, age int)', + 'CREATE TABLE quoted (id int, "MixedCase" text, "ev""il" text)', + `INSERT INTO quoted VALUES (1, 'v', 'w')`, + ], + mysql: [ + 'DROP TABLE IF EXISTS users', + 'DROP TABLE IF EXISTS quoted', + 'CREATE TABLE users (username varchar(64), gender varchar(8), age int)', + 'CREATE TABLE quoted (id int, `we``ird` varchar(8))', + "INSERT INTO quoted VALUES (1, 'v')", + 'DROP TABLE IF EXISTS modes', + 'CREATE TABLE modes (k varchar(64), n int)', + ], +} + +;(async () => { + const client = new Client({ connectionString: PG_URL }) + await client.connect() + const conn = await mysql.createConnection(MYSQL_URL) + + for (const stmt of schema.pg) await client.query(stmt) + for (const stmt of schema.mysql) await conn.query(stmt) + + const fail = async (name, err) => { + console.error(`✗ ${name}`) + console.error(err && err.stack ? err.stack : err) + await client.end() + await conn.end() + process.exit(1) + } + + for (const [name, fn] of tests) { + try { + await fn(client, conn) + passed++ + } catch (err) { + await fail(name, err) + } + } + console.log(`✓ ${passed}/${tests.length} integration tests passed against real Postgres + MySQL`) + + // Matrix: put the SESSION into the mode the test is named for, so the server + // and the builder are configured the same way. Anything that disagrees fails. + let modePassed = 0 + for (const [name, fn] of modeTests) { + const my_ = MYSQL_MODES.find((m) => name.startsWith(`mysql[${m.name}]`)) + const pg_ = PG_MODES.find((m) => name.startsWith(`pg[${m.name}]`)) + try { + if (my_) await conn.query(`SET SESSION sql_mode='${my_.sqlMode}'`) + if (pg_) await client.query(`SET standard_conforming_strings = ${pg_.scs}`) + await fn(client, conn) + modePassed++ + } catch (err) { + await fail(name, err) + } finally { + if (my_) await conn.query('SET SESSION sql_mode=DEFAULT') + if (pg_) await client.query('SET standard_conforming_strings = on') + } + } + console.log(`✓ ${modePassed}/${modeTests.length} sql_mode matrix tests passed (${MYSQL_MODES.length} MySQL modes × ${PG_MODES.length} pg modes)`) + + await client.end() + await conn.end() +})().catch((err) => { + console.error('Integration setup failed:', err && err.message ? err.message : err) + console.error('\nStart servers with:') + console.error(' docker run -d --name sb-pg -e POSTGRES_PASSWORD=secret -e POSTGRES_DB=sbtest -p 55432:5432 postgres:16-alpine') + console.error(' docker run -d --name sb-mysql -e MYSQL_ROOT_PASSWORD=secret -e MYSQL_DATABASE=sbtest -p 53306:3306 mysql:8') + process.exit(1) +}) diff --git a/lib/builder.js b/test/legacy-oracle.cjs similarity index 100% rename from lib/builder.js rename to test/legacy-oracle.cjs diff --git a/test/types.test.ts b/test/types.test.ts new file mode 100644 index 0000000..b54ec2f --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,85 @@ +/** + * Type-level regression tests. Compiled (never executed) by `npm test` via + * `tsc --noEmit`; a wrong inference is a build failure. + */ +import simpleBuilder, { pg, mysql, sql, Build, BuildResult, Mode, Row, Sql } from '../src/index' + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false +declare function expectType(): void + +function cases(): void { + // Both dialect exports share the Build signature. + expectType>() + expectType>() + + // Default export carries both dialects and the tag. + expectType>() + expectType>() + expectType>() + + // ── partials API ── + const a = pg(['SELECT * FROM t WHERE id = ?', 1]) + expectType>() + + const b = pg('SELECT * FROM t WHERE id = ?', 1) + expectType>() + + // Heterogeneous partials need no casts. + const row: Row = { username: 'x', active: true } + const c = mysql([ + 'UPDATE t SET ?', row, + 'WHERE id = ? AND created_at > ?', 1, new Date(), + 'AND flag = ?', false, + 'AND tag IS ?', null, + ]) + expectType>() + + // A projection array literal is a valid partial. + pg(['SELECT', ['id', 'name'], 'FROM t']) + + // sql.id is usable in fragment position of the partials API. + pg(['SELECT * FROM', sql.id('users'), 'WHERE id = ?', 1]) + + // ── sql tag ── + const frag = sql`SELECT * FROM t WHERE id = ${1}` + expectType>() + + const d = pg(frag) + expectType>() + const e = mysql(sql`SELECT * FROM t WHERE id = ${1}`) + expectType>() + + // Helpers compose and are accepted as interpolations. + pg(sql`SELECT * FROM ${sql.id('public', 'users')} WHERE id IN ${[1, 2, 3]}`) + pg(sql`SELECT * FROM t WHERE ${sql.join([sql`a = ${1}`, sql`b = ${2}`], ' AND ')}`) + pg(sql`SELECT * FROM t ORDER BY id ${sql.raw('DESC')}`) + pg(sql`SELECT * FROM t WHERE tags = ${sql.value([1, 2])}`) + pg(sql`SELECT * FROM t ${sql.empty}`) + + // Nesting a fragment inside a fragment is allowed. + const nested: Sql = sql`SELECT * FROM t WHERE ${sql`a = ${1}`}` + void nested + + // ── withMode ── + // Returns a Build, so it chains and accepts every call shape. + const configured: Build = mysql.withMode({ ansiQuotes: true }) + expectType>() + const chained: Build = mysql.withMode({ ansiQuotes: true }).withMode({ noBackslashEscapes: true }) + const m: BuildResult = chained(['SELECT ? AS n', 1]) + const m2: BuildResult = pg.withMode({ standardConformingStrings: false })(sql`SELECT ${1}`) + + // Mode is exported and all fields are optional. + const mode: Mode = {} + const full: Mode = { ansiQuotes: true, noBackslashEscapes: false, standardConformingStrings: true } + void [configured, m, m2, mode, full] + + // Result shape: text required, values optional. + const r = pg(sql`SELECT 1`) + const text: string = r.text + const values: unknown[] | undefined = r.values + void [text, values] + + void [a, b, c, d, e] +} + +void cases diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e6079fc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "declaration": true, + "inlineSourceMap": true, + "inlineSources": true, + "module": "commonjs", + "moduleResolution": "node", + "strict": true, + "noImplicitAny": true, + "noImplicitReturns": true, + "strictNullChecks": true, + "outDir": "dist", + "removeComments": false, + "rootDir": "src", + "target": "es2017" + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +}