From b3157d4678f2d9d8791a6b981d25e0cd41d5cdc1 Mon Sep 17 00:00:00 2001 From: "Ondra M." Date: Tue, 14 Jul 2026 12:13:03 +0200 Subject: [PATCH 1/8] Modernize for 2026: TypeScript rewrite, dual CJS/ESM, tests, CI (3.0.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full modernization of the library while preserving the public API and byte-for- byte output for all documented usage (proven by a differential fuzzer against the vendored 2.4.2 builder over 10k+ seeded scenarios). Packaging & tooling - Reimplement in TypeScript (src/index.ts); ship compiled CJS (dist/) plus a static ESM wrapper (esm/) via an exports map. `main`/`types` updated. - Add first-class, complete type definitions (objects, Date, bigint, null, varargs, projection arrays, optional `values`) — the old index.d.ts only typed scalar arrays. - tsconfig, engines (Node >=16), files allowlist, sideEffects, richer metadata. - GitHub Actions CI (Node 16–24 matrix, differential fuzz, package-boundary smoke for CJS+ESM, typings gate) and a provenance publish workflow. - Add LICENSE, AGENTS.md, CLAUDE.md, llms.txt, .gitignore, issue templates. Tests (replacing the old console.log script) - Assertion-based unit suite, exact type-level tests, package-boundary smoke tests (installed tarball, CJS + ESM), and a seeded differential fuzzer. Bug fixes (behaviour changes vs 2.4.2, covered by unit tests) - Lowercase `in ?` is now expanded; the matcher was case-insensitive but the substitution used a hard-coded uppercase ` IN ?`, so lowercase left a stray placeholder and mis-bound values. - The caller's partials array is no longer mutated (projection join wrote back into the input array). - An empty object passed to VALUES ?/SET ?/WHERE ? now throws a clear error instead of emitting invalid SQL like `() VALUES ()`. - Array detection uses Array.isArray; error messages are clearer. Docs - Rewrite README with current async/await, a Security section documenting that object KEYS are interpolated as identifiers (values remain parameterised). Bump to 3.0.0 (packaging/module-format change; API-compatible). Co-Authored-By: Claude Opus 4.8 --- .github/ISSUE_TEMPLATE/bug_report.md | 32 +++ .github/ISSUE_TEMPLATE/feature_request.md | 23 +++ .github/workflows/ci.yml | 59 ++++++ .github/workflows/publish.yml | 66 +++++++ .gitignore | 3 + AGENTS.md | 56 ++++++ CLAUDE.md | 4 + LICENSE | 21 ++ README.md | 231 +++++++--------------- esm/index.d.mts | 11 ++ esm/index.mjs | 8 + index.d.ts | 15 -- llms.txt | 63 ++++++ package-lock.json | 33 ++++ package.json | 66 ++++++- src/index.ts | 220 +++++++++++++++++++++ test/boundary/consumer.ts | 22 +++ test/boundary/smoke.cjs | 33 ++++ test/boundary/smoke.mjs | 21 ++ test/build_query.js | 72 ------- test/fuzz.cjs | 122 ++++++++++++ test/index.test.cjs | 209 ++++++++++++++++++++ lib/builder.js => test/legacy-oracle.cjs | 0 test/types.test.ts | 51 +++++ tsconfig.json | 24 +++ 25 files changed, 1212 insertions(+), 253 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 esm/index.d.mts create mode 100644 esm/index.mjs delete mode 100644 index.d.ts create mode 100644 llms.txt create mode 100644 package-lock.json create mode 100644 src/index.ts create mode 100644 test/boundary/consumer.ts create mode 100644 test/boundary/smoke.cjs create mode 100644 test/boundary/smoke.mjs delete mode 100644 test/build_query.js create mode 100644 test/fuzz.cjs create mode 100644 test/index.test.cjs rename lib/builder.js => test/legacy-oracle.cjs (100%) create mode 100644 test/types.test.ts create mode 100644 tsconfig.json 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..208dc17 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +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: Differential fuzz vs the 2.4.2 oracle (10000 scenarios) + run: npm run fuzz -- 10000 + + 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: 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..ea63c12 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,66 @@ +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' + run: | + PKG_VERSION="$(node -p "require('./package.json').version")" + TAG="${{ github.event.release.tag_name }}" + 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 fuzz + run: npm run fuzz -- 10000 + + - 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..4cf115c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,56 @@ +# Agent guide — simple-builder + +Zero-dependency npm package: a tiny SQL string builder. The implementation is +~250 lines of TypeScript in `src/index.ts`; everything else is tests and +packaging. + +## Consumer API + +See `llms.txt` (shipped in the npm tarball) for the complete API, rules, +security notes, and gotchas in one file. + +## 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), then runs the unit suite + under `--unhandled-rejections=strict`. +- Fuzz: `npm run fuzz -- [iterations] [seed]` — a differential fuzzer + (`test/fuzz.cjs`) that checks the current build against the vendored 2.4.2 + builder (`test/legacy-oracle.cjs`) for byte-identical output. Failures print + the seed for exact reproduction. + +## Architecture + +- One `build(dialect, args)` core in `src/index.ts`. `pg`/`mysql` are thin + wrappers that bind the dialect. `pg` renders `$1`-style placeholders; `mysql` + keeps `?`. +- The core walks the flattened partials once. An `ignore` counter tracks how + many upcoming partials are values (from the preceding fragment's `?` count); + per-clause flags (`insert`/`where`/`where_in`/`update`) drive object/array + expansion. +- ESM entry is a static wrapper (`esm/index.mjs`) re-exporting the CJS build — + do NOT introduce a second compiled implementation (dual-package hazard). + +## Invariants you must not break (enforced by tests + fuzzer + CI) + +1. 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; only object keys are interpolated. +3. The caller's partials array is never mutated. +4. `values` is omitted from the result when no value was bound. +5. Both CJS `require` and ESM `import` resolve to the same `{ pg, mysql }`. + +## 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. + +## 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..d96021d 100644 --- a/README.md +++ b/README.md @@ -1,222 +1,141 @@ # simple-builder -### Simple SQL query string builder +A tiny SQL string builder that keeps your SQL **visible**. You write SQL with +`?` placeholders, interleave the values, and get back `{ text, values }` ready +for the `pg`, `mysql`, and `mysql2` drivers. -## Installation +Zero dependencies. First-class TypeScript types. Ships CJS **and** ESM. -```javascript +```bash npm install simple-builder --save ``` -## Motivation - -SQL query builders (such as `squel.js`) often feel heavy. It is not easy to create your SQL query without reading the documentation. - -The aim of `simple-builder` is - -- 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 - -## API - -### `build(Array partials)` -> `Object` -### `build(Arguments partials)` -> `Object` +## Why -The `partials` can be either an `Array` or plain function `arguments` consisting of: +Most query builders hide your SQL behind a fluent API you have to learn. +`simple-builder` does the opposite — you write real SQL and it only handles the +tedious parts: numbering placeholders and expanding objects/arrays. -- strings -- numbers -- arrays -- boolean values -- objects +- Doesn't obscure the actual SQL. +- Uses the SQL you already know. +- Queries stay easy to read and easy to follow. -All of them are partial values for constructing query `Object`. +## Quick start ```javascript -var email = "john@doe.wtf" -var partials = [ "SELECT * FROM users WHERE email = ?", email ] -``` - -Every query is constructed by creating a `partials` array and passing it to the `simple-builder`. - -```javascript -var build = require("simple-builder").pg - -var query = build(partials) -// { text: "SELECT * FROM users WHERE email = $1", values: [ "john@doe.wtf" ] } -``` - -The output of `simple-builder` is always an `Object` containing: - -- `text` property -- `values` property +const { pg } = require('simple-builder') // or: const { mysql } = ... -The `values` property may not be set when there was no variable in your `partials` array. +const email = 'john@doe.wtf' +const query = pg(['SELECT * FROM users WHERE email = ?', email]) +// { text: 'SELECT * FROM users WHERE email = $1', values: ['john@doe.wtf'] } -```javascript -var rows = yield db.query(query.text, query.values) +const rows = await db.query(query.text, query.values) ``` -## Syntax - -The `partials` array is a mix of SQL code and variables that are to be inserted into the final query. - -For variable insertion, the `question mark syntax` is used. +ESM works too: ```javascript -var user_id = 1 -var partials = [ "SELECT * FROM users WHERE id = ?", user_id ] +import { pg, mysql } from 'simple-builder' ``` -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. +`pg` renders `$1, $2, …` placeholders; `mysql` (and `mysql2`) keep `?`. That is +the only difference between the two. -```javascript -var user_id = 1 -var email = "john@doe.wtf" -var partials = [ "SELECT * FROM users WHERE id = ? AND email = ?", user_id, email ] -``` +## The `partials` list -You are basically mixing SQL code and variables. +A query is a list mixing SQL fragments and values. Pass an array, an argument +list, or a single ready string: ```javascript -var user_id = 1 -var partials = [ - "SELECT * FROM friends WHERE friend_id = ?", user_id, - "ORDER BY created_at" -] - -var query = build(partials) -// { text: "SELECT * FROM friends WHERE friend_id = $1 ORDER BY created_at", values: [ 1 ] } +pg(['SELECT * FROM users WHERE id = ?', userId]) // array +pg('SELECT * FROM users WHERE id = ?', userId) // arguments +pg('SELECT * FROM users') // ready string → { text } ``` -### `INSERT` query - -Some query builders are making `INSERT` queries easy to write by using insertion object. +For each SQL fragment the `?` are counted, and that many values are expected to +follow it: ```javascript -var insertion = { - username: "John Doe", - email: "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 keys of the `insertion` object represent database columns. +A bare array in fragment position becomes a comma-separated projection list: ```javascript -var partials = [ "INSERT INTO users VALUES ?", insertion ] +pg(['SELECT', ['id', 'username'], 'FROM users']) +// { text: 'SELECT id,username FROM users' } ``` -The example above is the supported syntax for inserting an object. +The result always has a `text` property; `values` is present only when the query +bound at least one value. -```javascript -var partials = [ "INSERT INTO users VALUES ?", insertion, "RETURNING id" ] -``` +## Object & array expansion -When `VALUES ?` substring is found in the SQL partial, the next value is expected to be an `Object`. +### `INSERT … VALUES ?` ```javascript -var query = build(partials) -// { text: "INSERT INTO users (username,email) VALUES ($1,$2) RETURNING id", values: [ "John Doe", "john@doe.wtf" ] } +const user = { username: 'John Doe', email: 'john@doe.wtf' } +pg(['INSERT INTO users VALUES ?', user, 'RETURNING id']) +// { text: 'INSERT INTO users (username,email) VALUES ($1,$2) RETURNING id', +// values: ['John Doe', 'john@doe.wtf'] } ``` -The `INSERT` queries can still be written by hand. +### `UPDATE … SET ?` ```javascript -var partials = [ - "INSERT INTO users (username, email)", - "VALUES (?, ?)", insertion.username, insertion.email, - "RETURNING id" -] +pg(['UPDATE users SET ?', { username: 'Biggie', gender: 'female' }, 'WHERE id = ?', id]) +// { text: 'UPDATE users SET username=$1,gender=$2 WHERE id = $3', +// values: ['Biggie', 'female', 123] } ``` -### `UPDATE` query - -In a similar manner as the `INSERT`, `UPDATE` queries can also be written using update objects. +### `WHERE ?` (AND-joined equality) ```javascript -var update = { - username: "Biggie Smalls", - gender: "female" -} +pg(['SELECT * FROM users WHERE ?', { username: 'x', gender: 'male' }]) +// { text: 'SELECT * FROM users WHERE username=$1 AND gender=$2', values: ['x', 'male'] } ``` -The keys of the update object represent database columns. +### `WHERE … IN ?` ```javascript -var current_username = "John Doe" -var partials = [ "UPDATE users SET ?", update, "WHERE username = ?", current_username ] +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 example above is the supported syntax for updating with an object. +You can always write these by hand instead — the object forms are just sugar. -```javascript -var query = build(partials) -// { text: "UPDATE users SET username=$1,gender=$2 WHERE username=$3", values: [ "Biggie Smalls", "female", "John Doe" ] } -``` +## Security -The `UPDATE` queries can still be written by hand. +**Values are always parameterised** — they go into the `values` array and are +never interpolated into the SQL text, so they cannot cause injection. -```javascript -var partials = [ - "UPDATE users", - "SET username = ?, gender = ?", update.username, update.gender - "WHERE username = ?", current_username -] -``` - -## Examples +**Object keys become identifiers and are interpolated verbatim.** In the +`VALUES ?`, `SET ?`, and `WHERE ?` forms the object's *keys* become column +names written directly into the SQL. Never build those keys from user input: ```javascript -var build = require("simple-builder").mysql - -// SELECT query -var query = build([ - "SELECT * FROM users", - "WHERE id = ? AND username = ?", user_id, username -]) - -// { text: "SELECT * FROM users WHERE id = ? AND username = ?", values: [ 1, "John Doe" ] } - -var rows = yield db.query(query.text, query.values) +// DANGER: keys come straight from the request body +pg(['UPDATE users SET ?', req.body]) // an attacker controls the column list -var build = require("simple-builder").pg - -// UPDATE query -var query = build([ - "UPDATE users SET ?", { username: "something", gender: "male" }, - "WHERE user_id = ? AND is_hidden = ?", user_id, false -]) - -// { "text": "UPDATE users SET username=$1,gender=$2 WHERE user_id = $3 AND is_hidden = $4", "values": [ "something", "male", 123, false ] } - -var rows = yield db.query(query.text, query.values) - -// INSERT query -var query = build([ - "INSERT INTO", "users", - "VALUES ?", { username: "something", gender: "male" } -]) - -// { "text": "INSERT INTO users (username,gender) VALUES ($1,$2)", "values": [ "something", "male" ] } - -var rows = yield db.query(query.text, query.values) +// Safe: you decide the columns; the user only controls values +pg(['UPDATE users SET ?', { username: req.body.username, email: req.body.email }]) ``` -## Dependencies - -This library has no dependencies. +## Requirements & compatibility -## Limitations +- Node.js **>= 16**. Works with the `pg`, `mysql`, and `mysql2` drivers. +- The public API (`pg` / `mysql`, same call shapes and output) is unchanged from + 2.x — see [CHANGELOG / release notes](https://github.com/Acro/simple-builder/releases) + for the 3.0.0 packaging changes and bug fixes. -The output is only suitable for `mysql`, `mysql2` and `pg` drivers. +## Contributing -```javascript -var build = require("simple-builder").pg -var build = require("simple-builder").mysql -``` +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..49c64b6 --- /dev/null +++ b/esm/index.d.mts @@ -0,0 +1,11 @@ +export { + default, + pg, + mysql, + Dialect, + Value, + Row, + Partial, + BuildResult, + Build, +} from '../dist/index.js' diff --git a/esm/index.mjs b/esm/index.mjs new file mode 100644 index 0000000..09a9c90 --- /dev/null +++ b/esm/index.mjs @@ -0,0 +1,8 @@ +// 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 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..f7adf5c --- /dev/null +++ b/llms.txt @@ -0,0 +1,63 @@ +# simple-builder + +> Tiny SQL string builder that keeps your SQL visible. Write SQL with `?` +> placeholders, interleave values, get `{ text, values }` for the pg, mysql and +> mysql2 drivers. Object/array expansion for INSERT/UPDATE/WHERE/IN. +> Zero dependencies. CJS + ESM. Node >= 16. First-class TypeScript types. + +## Install + +npm install simple-builder + +## Import (both work everywhere) + +CJS: const { pg, mysql } = require('simple-builder') + const simpleBuilder = require('simple-builder').default // { pg, mysql } +ESM: import { pg, mysql } from 'simple-builder' + import simpleBuilder from 'simple-builder' // { pg, mysql } + +## API + +pg(partials): { text, values? } // renders $1, $2, … placeholders +mysql(partials): { text, values? } // keeps ? placeholders (also mysql2) + +`partials` is one of: + - an array of fragments/values: pg(['... ?', value]) + - an argument list: pg('... ?', value) + - a single ready SQL string: pg('SELECT * FROM t') -> { text } + +Result: always has `text`; `values` is present only when >= 1 value was bound. + +## Rules + +- For each SQL string fragment, its `?` count determines how many of the + following partials are consumed as bound values. +- A bare array in fragment position is a projection list: ['id','name'] -> "id,name". +- pg rewrites `?` to `$1,$2,…`; mysql leaves `?`. That is the only dialect diff. +- Objects/arrays expand when the fragment ends with a marker: + "... 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 are case-insensitive (`in ?`, `IN ?`, `values ?` all work). +- Values (including Date, null, false, bigint) are always parameterised. + +## Security + +- Values are safe: parameterised, never inlined into `text`. +- Object KEYS become column identifiers, interpolated verbatim into the SQL. + Never pass user-controlled keys to VALUES ?/SET ?/WHERE ? — choose the columns + yourself and let the user control only the values. + +## Errors + +- A value with no preceding `?` fragment throws "expected an SQL string...". +- An empty object passed to VALUES ?/SET ?/WHERE ? throws "empty object...". + +## 3.0.0 notes (vs 2.4.2) + +- Same public API and output for all documented usage (verified by a differential + fuzzer against 2.4.2). +- Now 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 now throw instead of emitting invalid SQL. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..78f5171 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,33 @@ +{ + "name": "simple-builder", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simple-builder", + "version": "3.0.0", + "license": "MIT", + "devDependencies": { + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=16" + } + }, + "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" + } + } + } +} diff --git a/package.json b/package.json index 2347f4f..c430a9b 100644 --- a/package.json +++ b/package.json @@ -1,25 +1,71 @@ { "name": "simple-builder", - "version": "2.4.2", - "description": "Simple SQL query string builder", - "main": "lib/builder.js", + "version": "3.0.0", + "description": "Tiny SQL string builder that keeps your SQL visible. Write SQL with ? placeholders, interleave values, get { text, values } for pg, mysql and mysql2. Object expansion for INSERT/UPDATE/WHERE/IN. Zero dependencies, first-class TypeScript types, CJS + ESM.", + "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", + "fuzz": "tsc && node test/fuzz.cjs" }, - "author": "Ondrej Machek ", - "license": "MIT", - "dependencies": {}, "keywords": [ "sql", "query", "builder", - "query builder", + "query-builder", + "sql-builder", "mysql", + "mysql2", "postgresql", - "postgres" + "postgres", + "pg", + "parameterized", + "placeholders", + "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": { + "typescript": "^5.4.0" } } diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2aaa2d9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,220 @@ +/** + * simple-builder — a tiny SQL string builder that keeps your SQL visible. + * + * You write SQL with `?` placeholders and interleave the bound values; the + * builder returns `{ text, values }` shaped for the `pg`, `mysql`, and `mysql2` + * drivers. For `pg` the `?` are rewritten to `$1, $2, …`; for `mysql` they are + * left as `?`. Objects expand into `INSERT … VALUES`, `SET`, `WHERE`, and + * `IN (…)` fragments. + * + * SECURITY: values are always parameterised and safe. Object *keys* become + * column/identifier names and are interpolated into the SQL verbatim — never + * pass user-controlled keys to `VALUES ?`, `SET ?`, or `WHERE ?`. See README. + */ + +/** Target driver dialect. `pg` renders `$1`-style placeholders; `mysql` + * (also `mysql2`) keeps `?`. */ +export type Dialect = 'pg' | 'mysql' + +/** A single bound value. `Date`/`null`/`undefined` are passed through to the + * driver unchanged. */ +export type Value = string | number | boolean | bigint | null | undefined | Date + +/** An object whose keys are column names and values are bound parameters. Used + * by the `VALUES ?`, `SET ?`, and `WHERE ?` object forms. */ +export type Row = Record + +/** One element of a partials array: an SQL fragment, a bound value, an object + * row, or an array (a projection list, or the value list for `IN ?`). */ +export type Partial = + | string + | number + | boolean + | bigint + | null + | undefined + | Date + | Row + | readonly unknown[] + +/** The result, shaped for `driver.query(text, values)`. `values` is omitted + * when the query bound no parameters. */ +export interface BuildResult { + text: string + values?: unknown[] +} + +/** A dialect-bound builder. Accepts a partials array, an argument list of + * partials, or a single ready SQL string. */ +export interface Build { + (partials: readonly Partial[]): BuildResult + (...partials: Partial[]): BuildResult +} + +const isObject = (value: unknown): value is Row => + value !== null && typeof value === 'object' + +const describe = (value: unknown): string => { + if (value === null) return 'null' + if (Array.isArray(value)) return 'array' + return typeof value +} + +// Markers that make the *next* partial an object (or, for IN, an array) that +// gets expanded. Kept case-insensitive; the whitespace-before-`in` guard stops +// words like "join"/"min" from matching. +const RE_VALUES = /values\s+\?/i +const RE_WHERE = /where\s+\?/i +const RE_IN = /(\sin\s+)\?/i +const RE_SET = /set\s+\?/i + +type Action = 'insert' | 'where' | 'where_in' | 'update' + +function build(dialect: Dialect, args: unknown[]): BuildResult { + // Call shapes: build([...]) | build(a, b, c, …) | build("sql string"). + const query: unknown = args.length > 1 ? args : args[0] + + if (!Array.isArray(query)) { + // A single ready string (or nothing) — nothing to bind. + return { text: query == null ? '' : String(query) } + } + + // Copy so we never mutate the caller's array (the projection join below + // would otherwise clobber their input). + const parts = query.slice() as Partial[] + + const values: unknown[] = [] + let paramIndex = 1 + const placeholder = (): string => (dialect === 'mysql' ? '?' : '$' + paramIndex++) + + const text: string[] = [] + const last = (): number => text.length - 1 + + // How many upcoming partials are values consumed by the current fragment's + // `?` count, and which object-expansion is pending. Flags persist until the + // matching action fires, mirroring the original state machine. + let ignore = 0 + const flags = { insert: false, where: false, where_in: false, update: false } + + // Highest-precedence pending action (update > where_in > where > insert), + // matching the original's `.pop()` over insertion order. + const currentAction = (): Action | null => { + let action: Action | null = null + if (flags.insert) action = 'insert' + if (flags.where) action = 'where' + if (flags.where_in) action = 'where_in' + if (flags.update) action = 'update' + return action + } + + const columns = (row: Row, action: Action): string[] => { + const keys = Object.keys(row) + if (keys.length === 0) { + throw new Error( + `simple-builder: empty object passed to the ${action} clause — ` + + 'an object with at least one key is required.' + ) + } + return keys + } + + const apply: Record void> = { + insert(row) { + const keys = columns(row, 'insert') + const placeholders = keys.map((key) => { + values.push(row[key]) + return placeholder() + }) + text[last()] = text[last()].replace( + RE_VALUES, + '(' + keys.join(',') + ') VALUES (' + placeholders.join(',') + ')' + ) + flags.insert = false + }, + where(row) { + const keys = columns(row, 'where') + const conditions = keys.map((key) => { + values.push(row[key]) + return key + '=' + placeholder() + }) + text[last()] = text[last()].replace('?', conditions.join(' AND ')) + flags.where = false + }, + where_in(row) { + // `row` is typically an array; Object.keys gives its indices. + const keys = columns(row, 'where_in') + const placeholders = keys.map((key) => { + values.push(row[key]) + return placeholder() + }) + // Preserve the author's `IN`/`in` casing and spacing; only swap the `?`. + text[last()] = text[last()].replace(RE_IN, (_m, lead: string) => lead + '(' + placeholders.join(',') + ')') + flags.where_in = false + }, + update(row) { + const keys = columns(row, 'update') + const assignments = keys.map((key) => { + values.push(row[key]) + return key + '=' + placeholder() + }) + text[last()] = text[last()].replace('?', assignments.join(',')) + flags.update = false + }, + } + + for (let i = 0; i < parts.length; i++) { + if (ignore-- > 0) { + const part = parts[i] + const action = currentAction() + + if (isObject(part) && action) { + apply[action](part) + } else { + // A plain bound value. For pg, consume the next `?` in place. + if (dialect !== 'mysql') { + text[last()] = text[last()].replace('?', placeholder()) + } + values.push(part) + } + continue + } + + let part = parts[i] + + // 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)} near [..., ${JSON.stringify(parts[i - 1]) ?? 'start'}, ${JSON.stringify(parts[i])}, ...]. ` + + 'A value must follow a fragment containing a `?` placeholder.' + ) + } + + const marks = part.match(/\?/g) + if (marks) ignore = marks.length + + if (RE_VALUES.test(part)) flags.insert = true + if (RE_WHERE.test(part)) flags.where = true + if (RE_IN.test(part)) flags.where_in = true + if (RE_SET.test(part)) flags.update = true + + text.push(part) + } + + const result: BuildResult = { text: text.join(' ') } + if (values.length > 0) result.values = values + return result +} + +/** Postgres (`pg`) builder — renders `$1, $2, …` placeholders. */ +export const pg: Build = (...args: unknown[]) => build('pg', args) + +/** MySQL (`mysql` / `mysql2`) builder — keeps `?` placeholders. */ +export const mysql: Build = (...args: unknown[]) => build('mysql', args) + +/** Default export: `{ pg, mysql }`, mirroring the classic + * `require('simple-builder')` shape. */ +const simpleBuilder = { pg, mysql } +export default simpleBuilder diff --git a/test/boundary/consumer.ts b/test/boundary/consumer.ts new file mode 100644 index 0000000..ce1317f --- /dev/null +++ b/test/boundary/consumer.ts @@ -0,0 +1,22 @@ +/** + * Package-boundary TypeScript consumer. CI compiles this against the INSTALLED + * package's d.ts with both an older compiler and the latest, so a typings + * change that breaks consumers fails the build. + */ +import simpleBuilder, { pg, mysql, BuildResult, Partial, Row } from 'simple-builder' + +function main(): void { + 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 partials: Partial[] = ['UPDATE t SET ?', row, 'WHERE id = ?', 1] + const c: BuildResult = simpleBuilder.pg(partials) + + const text: string = a.text + const values: unknown[] | undefined = a.values + + void [b, c, text, values] +} + +void main diff --git a/test/boundary/smoke.cjs b/test/boundary/smoke.cjs new file mode 100644 index 0000000..a5f570a --- /dev/null +++ b/test/boundary/smoke.cjs @@ -0,0 +1,33 @@ +'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 } = 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(def.pg, pg) +assert.strictEqual(def.mysql, mysql) + +// pg placeholder rewriting + object expansion. +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] } +) + +// mysql keeps ? placeholders. +assert.deepStrictEqual( + mysql('SELECT * FROM t WHERE id IN ?', [1, 2, 3]), + { text: 'SELECT * FROM t WHERE id IN (?,?,?)', values: [1, 2, 3] } +) + +// No-parameter query has no values property. +assert.deepStrictEqual(pg(['SELECT * FROM t']), { text: 'SELECT * FROM t' }) + +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..e4873b0 --- /dev/null +++ b/test/boundary/smoke.mjs @@ -0,0 +1,21 @@ +// ESM package-boundary smoke test: named imports resolve through the exports +// map's `import` condition, and the default import is the { pg, mysql } object. +import assert from 'assert' +import simpleBuilder, { pg, mysql } 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(simpleBuilder.pg, pg) +assert.strictEqual(simpleBuilder.mysql, mysql) + +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] } +) + +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/fuzz.cjs b/test/fuzz.cjs new file mode 100644 index 0000000..62bcbf4 --- /dev/null +++ b/test/fuzz.cjs @@ -0,0 +1,122 @@ +'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)})`) diff --git a/test/index.test.cjs b/test/index.test.cjs new file mode 100644 index 0000000..da20d4e --- /dev/null +++ b/test/index.test.cjs @@ -0,0 +1,209 @@ +'use strict' + +/** + * Unit suite. Runs against the built package (`../dist`), under + * `--unhandled-rejections=strict`. Covers the documented outputs (as exact + * assertions, replacing the old console.log script) plus regression tests for + * every bug fixed in 3.0.0. + */ + +const assert = require('assert') +const { pg, mysql } = 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 and mysql as named exports and on the default export', () => { + assert.strictEqual(typeof pg, 'function') + assert.strictEqual(typeof mysql, 'function') + assert.strictEqual(def.pg, pg) + assert.strictEqual(def.mysql, mysql) +}) + +// ── 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', () => { + 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], + }) +}) + +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) +}) + +// ── 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/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..676619e --- /dev/null +++ b/test/types.test.ts @@ -0,0 +1,51 @@ +/** + * Type-level regression tests. Compiled (never executed) by `npm test` via + * `tsc --noEmit`; a wrong inference is a build failure. + */ +import simpleBuilder, { pg, mysql, Build, BuildResult, Partial, Row } 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. + expectType>() + expectType>() + + // Array-of-partials call shape. + const a = pg(['SELECT * FROM t WHERE id = ?', 1]) + expectType>() + + // Varargs call shape. + const b = pg('SELECT * FROM t WHERE id = ?', 1) + expectType>() + + // Heterogeneous partials: strings, numbers, booleans, Date, null, objects, + // and arrays are all assignable to Partial without casts. + const row: Row = { username: 'x', active: true } + const mixed: Partial[] = [ + 'UPDATE t SET ?', row, + 'WHERE id = ? AND created_at > ?', 1, new Date(), + 'AND flag = ?', false, + 'AND tag IS ?', null, + ] + const c = mysql(mixed) + expectType>() + + // Result shape: text required, values optional. + const r = pg(['SELECT 1']) + const text: string = r.text + const values: unknown[] | undefined = r.values + void [text, values] + + // A projection array literal is a valid partial. + pg(['SELECT', ['id', 'name'], 'FROM t']) + + void [a, b, c] +} + +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" + ] +} From c79760b4f734b0ccf08858267cdc1be605beede3 Mon Sep 17 00:00:00 2001 From: "Ondra M." Date: Tue, 14 Jul 2026 13:09:46 +0200 Subject: [PATCH 2/8] Add "type": "commonjs" and gate CI on publint + are-the-types-wrong Both surfaced by the packaging research: publint's one suggestion (declare the package type so Node skips type detection) and a strict publint + attw gate in the package-boundary CI job. `publint --strict` is now clean and attw reports no problems across node10 / node16-CJS / node16-ESM / bundler resolution. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 5 +++++ package.json | 1 + 2 files changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 208dc17..75e7cdf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,11 @@ jobs: 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 diff --git a/package.json b/package.json index c430a9b..a3c2cf6 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "simple-builder", "version": "3.0.0", "description": "Tiny SQL string builder that keeps your SQL visible. Write SQL with ? placeholders, interleave values, get { text, values } for pg, mysql and mysql2. 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": { From 43c9a4c338cce3e753291dab56e8c62c3167b98e Mon Sep 17 00:00:00 2001 From: "Ondra M." Date: Tue, 14 Jul 2026 14:31:24 +0200 Subject: [PATCH 3/8] Add the sql`` tagged template, a real SQL lexer, and identifier safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the security / correctness / DX roadmap from the architecture review into the unreleased 3.0.0, keeping the library one file and zero-dependency (~16 kB compiled). The `?` partials API stays byte-identical to 2.4.2 for all documented usage — the differential fuzzer proves it on every run. SECURITY — identifiers are the only injection vector, since no driver can bind an identifier (OWASP: placeholders name data, never tables/columns). - Object keys in VALUES ?/SET ?/WHERE ? are now allow-listed to plain identifiers (`col`, `tbl.col`) and throw otherwise. Legitimate keys pass through byte-for-byte, so this changes output for nothing except payloads. Chosen over auto-quoting, which would silently break case semantics (quoted identifiers are case-sensitive; Postgres folds unquoted names to lower case). - NEW `sql.id(...parts)` quotes dynamic identifiers per dialect ("pg" / `mysql`) and doubles embedded quotes; the helper adds its own quotes so callers cannot create a quote mismatch. Rejects NUL and empty names. - README/llms.txt document the residual footguns the library cannot fix for you: LIKE wildcards in bound values, and second-order injection. CORRECTNESS — replace the regex/flag state machine with a real lexer. A naive `?` scan corrupts real SQL. Previously: pg(["... WHERE tags ?| ARRAY['x'] AND id = ?", 5]) → "... WHERE tags $1| ARRAY['x'] AND id = ?" // operator destroyed, value misbound The lexer now skips string literals ('' and MySQL \ escapes), quoted identifiers ("" and ``), line/block comments (nested), and dollar-quoted bodies ($$…$$ / $tag$…$tag$ — the pgx injection class); treats `?|`/`?&`/`??` as operators; and honours `\?` as an escaped literal `?` for the ambiguous bare jsonb operator. Clause markers are now classified positionally with `\b`, which also fixes `OFFSET ?` being misread as `SET ?`. Too few values now throws instead of silently binding undefined. DX — NEW sql`` tagged template, the recommended API. Every ${interpolation} is always a bound parameter, so accidental injection is structurally impossible; nothing is scanned for `?`, so jsonb operators need no escaping. Fragments nest and compose; arrays expand to `IN ($1,$2)`. Helpers: sql.id / sql.value / sql.join / sql.raw / sql.empty. Both APIs lower to one node representation and render through one Renderer, so `pg`/`mysql` accept either. Rendering is pure — a fragment renders repeatedly, for either dialect. TESTS — 22 → 50 unit tests, plus a second fuzzer. The `sql` tag and lexer have no 2.4.2 counterpart, so they get property-based fuzzing over injection-shaped inputs: values bind in order and never reach the text, pg renders exactly $1..$n, mysql renders n `?`, and rendering is pure. 20k differential + 20k property scenarios per CI run. Also gates publint --strict and are-the-types-wrong in CI and on publish. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 4 +- .github/workflows/publish.yml | 9 +- AGENTS.md | 95 +++-- README.md | 230 ++++++++++--- esm/index.d.mts | 3 +- esm/index.mjs | 2 + llms.txt | 103 ++++-- package.json | 9 +- src/index.ts | 631 +++++++++++++++++++++++++--------- test/boundary/consumer.ts | 20 +- test/boundary/smoke.cjs | 47 ++- test/boundary/smoke.mjs | 39 ++- test/fuzz.cjs | 162 +++++++++ test/index.test.cjs | 278 +++++++++++++-- test/types.test.ts | 49 ++- 15 files changed, 1340 insertions(+), 341 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75e7cdf..03d9d9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,8 +28,8 @@ jobs: with: node-version: 22.x - run: npm install - - name: Differential fuzz vs the 2.4.2 oracle (10000 scenarios) - run: npm run fuzz -- 10000 + - name: Fuzz — differential vs the 2.4.2 oracle + sql-tag property fuzz + run: npm run fuzz -- 20000 package-boundary: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ea63c12..1dd48f3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -43,8 +43,13 @@ jobs: - name: Full gate — build + type gate + unit suite run: npm test - - name: Full gate — differential fuzz - run: npm run fuzz -- 10000 + - 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: | diff --git a/AGENTS.md b/AGENTS.md index 4cf115c..1c976c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,13 @@ # Agent guide — simple-builder -Zero-dependency npm package: a tiny SQL string builder. The implementation is -~250 lines of TypeScript in `src/index.ts`; everything else is tests and -packaging. +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` (shipped in the npm tarball) for the complete API, rules, -security notes, and gotchas in one file. +See `llms.txt` (shipped in the npm tarball) for the complete API, lexing rules, +security model, and gotchas in one file. ## Working on this repo @@ -16,31 +16,61 @@ security notes, and gotchas in one file. - Test: `npm test` — builds, compiles `test/types.test.ts` (exact-type assertions; a wrong inference is a build failure), then runs the unit suite under `--unhandled-rejections=strict`. -- Fuzz: `npm run fuzz -- [iterations] [seed]` — a differential fuzzer - (`test/fuzz.cjs`) that checks the current build against the vendored 2.4.2 - builder (`test/legacy-oracle.cjs`) for byte-identical output. Failures print - the seed for exact reproduction. +- Fuzz: `npm run fuzz -- [iterations] [seed]` — see below. Failures print the + seed for exact reproduction. +- Package surface: `npx publint --strict` and `npx @arethetypeswrong/cli --pack .` + must both stay clean; CI gates on them. ## Architecture -- One `build(dialect, args)` core in `src/index.ts`. `pg`/`mysql` are thin - wrappers that bind the dialect. `pg` renders `$1`-style placeholders; `mysql` - keeps `?`. -- The core walks the flattened partials once. An `ignore` counter tracks how - many upcoming partials are values (from the preceding fragment's `?` count); - per-clause flags (`insert`/`where`/`where_in`/`update`) drive object/array - expansion. -- ESM entry is a static wrapper (`esm/index.mjs`) re-exporting the CJS build — - do NOT introduce a second compiled implementation (dual-package hazard). - -## Invariants you must not break (enforced by tests + fuzzer + CI) - -1. 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; only object keys are interpolated. -3. The caller's partials array is never mutated. -4. `values` is omitted from the result when no value was bound. -5. Both CJS `require` and ESM `import` resolve to the same `{ pg, mysql }`. +`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. +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. ## Intentional 2.4.2 → 3.0.0 behaviour changes @@ -48,9 +78,14 @@ 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`. +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/README.md b/README.md index d96021d..414469d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # simple-builder -A tiny SQL string builder that keeps your SQL **visible**. You write SQL with -`?` placeholders, interleave the values, and get back `{ text, values }` ready -for the `pg`, `mysql`, and `mysql2` drivers. +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. -Zero dependencies. First-class TypeScript types. Ships CJS **and** ESM. +Zero dependencies. ~16 kB. First-class TypeScript types. CJS **and** ESM. ```bash npm install simple-builder --save @@ -13,35 +13,123 @@ npm install simple-builder --save ## Why Most query builders hide your SQL behind a fluent API you have to learn. -`simple-builder` does the opposite — you write real SQL and it only handles the -tedious parts: numbering placeholders and expanding objects/arrays. +`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. -- Doesn't obscure the actual SQL. -- Uses the SQL you already know. -- Queries stay easy to read and easy to follow. - -## Quick start +## Two ways to write a query ```javascript -const { pg } = require('simple-builder') // or: const { mysql } = ... +const { pg, sql } = require('simple-builder') // or: import { pg, sql } from 'simple-builder' + +// 1. Tagged template — recommended +pg(sql`SELECT * FROM users WHERE email = ${email}`) + +// 2. Partials + `?` — the classic API, unchanged +pg(['SELECT * FROM users WHERE email = ?', email]) + +// both → { text: 'SELECT * FROM users WHERE email = $1', values: ['john@doe.wtf'] } +``` -const email = 'john@doe.wtf' -const query = pg(['SELECT * FROM users WHERE email = ?', email]) -// { text: 'SELECT * FROM users WHERE email = $1', values: ['john@doe.wtf'] } +`pg` renders `$1, $2, …`; `mysql` (and `mysql2`) keep `?`. That is the only +difference between them. +```javascript +const query = pg(sql`SELECT * FROM users WHERE id = ${id}`) const rows = await db.query(query.text, query.values) ``` -ESM works too: +The result always has `text`; `values` is present only when the query bound at +least one value. + +--- + +## The `sql` tag (recommended) + +Every `${interpolation}` is **always** a bound parameter. You cannot forget to +parameterise something, which makes accidental injection structurally +impossible: + +```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; --"] } +``` + +### Composition + +Fragments nest. Build queries conditionally without string glue: ```javascript -import { pg, mysql } from 'simple-builder' +const active = onlyActive ? sql`AND active = ${true}` : sql.empty +pg(sql`SELECT * FROM users WHERE org = ${org} ${active}`) ``` -`pg` renders `$1, $2, …` placeholders; `mysql` (and `mysql2`) keep `?`. That is -the only difference between the two. +```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` list +### Lists + +An interpolated array expands to a parenthesised list — the `IN` form: + +```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] } +``` + +Use `sql.value()` when you want an array bound as a *single* parameter (a +Postgres array or jsonb column): + +```javascript +pg(sql`SELECT * FROM t WHERE tags = ${sql.value(['a', 'b'])}`) +// { text: 'SELECT * FROM t WHERE tags = $1', values: [['a','b']] } +``` + +### 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 +pg(sql`SELECT * FROM ${sql.id('public', 'users')} WHERE id = ${1}`) +// { text: 'SELECT * FROM "public"."users" WHERE id = $1', values: [1] } + +mysql(sql`SELECT * FROM ${sql.id('my table')}`) +// { text: 'SELECT * FROM `my table`' } +``` + +> 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`. + +### Escape hatch + +`sql.raw()` inserts unescaped text. It is the only unsafe helper — never give it +user input: + +```javascript +pg(sql`SELECT * FROM t ORDER BY id ${sql.raw('DESC')}`) +``` + +### API + +```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 `?` partials API A query is a list mixing SQL fragments and values. Pass an array, an argument list, or a single ready string: @@ -52,8 +140,7 @@ pg('SELECT * FROM users WHERE id = ?', userId) // arguments pg('SELECT * FROM users') // ready string → { text } ``` -For each SQL fragment the `?` are counted, and that many values are expected to -follow it: +For each SQL fragment the `?` are counted, and that many values must follow it: ```javascript pg([ @@ -70,67 +157,98 @@ pg(['SELECT', ['id', 'username'], 'FROM users']) // { text: 'SELECT id,username FROM users' } ``` -The result always has a `text` property; `values` is present only when the query -bound at least one value. +### Object & list expansion -## Object & array expansion +```javascript +// 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: [...] } -### `INSERT … VALUES ?` +// UPDATE … SET ? +pg(['UPDATE users SET ?', { username: 'Biggie' }, 'WHERE id = ?', id]) +// { text: 'UPDATE users SET username=$1 WHERE id = $2', values: ['Biggie', 123] } -```javascript -const user = { username: 'John Doe', email: 'john@doe.wtf' } -pg(['INSERT INTO users VALUES ?', user, 'RETURNING id']) -// { text: 'INSERT INTO users (username,email) VALUES ($1,$2) RETURNING id', -// values: ['John Doe', 'john@doe.wtf'] } +// 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] } ``` -### `UPDATE … SET ?` +### 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 -pg(['UPDATE users SET ?', { username: 'Biggie', gender: 'female' }, 'WHERE id = ?', id]) -// { text: 'UPDATE users SET username=$1,gender=$2 WHERE id = $3', -// values: ['Biggie', 'female', 123] } +pg(["SELECT * FROM t WHERE tags ?| ARRAY['a'] AND id = ?", 5]) +// { text: "SELECT * FROM t WHERE tags ?| ARRAY['a'] AND id = $1", values: [5] } ``` -### `WHERE ?` (AND-joined equality) +The **bare** jsonb `?` operator is genuinely ambiguous with a placeholder, so +escape it as `\?`: ```javascript -pg(['SELECT * FROM users WHERE ?', { username: 'x', gender: 'male' }]) -// { text: 'SELECT * FROM users WHERE username=$1 AND gender=$2', values: ['x', 'male'] } +pg(["SELECT * FROM t WHERE data \\? 'key' AND id = ?", 7]) +// { text: "SELECT * FROM t WHERE data ? 'key' AND id = $1", values: [7] } ``` -### `WHERE … IN ?` +…or just use the `sql` tag, where nothing is scanned and no escaping is needed: ```javascript -pg('SELECT * FROM users WHERE id IN ?', [1, 2, 3]) -// { text: 'SELECT * FROM users WHERE id IN ($1,$2,$3)', values: [1, 2, 3] } +pg(sql`SELECT * FROM t WHERE data ? ${'key'} AND id = ${7}`) ``` -You can always write these by hand instead — the object forms are just sugar. +--- ## Security -**Values are always parameterised** — they go into the `values` array and are -never interpolated into the SQL text, so they cannot cause injection. +**Values are always parameterised.** They go into the `values` array and never +into the SQL text, so they cannot cause injection — in either API. -**Object keys become identifiers and are interpolated verbatim.** In the -`VALUES ?`, `SET ?`, and `WHERE ?` forms the object's *keys* become column -names written directly into the SQL. Never build those keys from user input: +**Identifiers cannot be parameterised** by any driver — placeholders name data, +never tables or columns. So `simple-builder` handles them two ways: -```javascript -// DANGER: keys come straight from the request body -pg(['UPDATE users SET ?', req.body]) // an attacker controls the column list +- **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: -// Safe: you decide the columns; the user only controls values -pg(['UPDATE users SET ?', { username: req.body.username, email: req.body.email }]) -``` + ```javascript + pg(['UPDATE users SET ?', req.body]) + // throws if req.body has a key like "x=1; DROP TABLE users; --" + ``` + + This is a backstop, not a licence — still choose the columns yourself: + + ```javascript + pg(['UPDATE users SET ?', { username: req.body.username, email: req.body.email }]) + ``` + +- **`sql.id()` quotes** for the dynamic case, escaping embedded quotes. + +**`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()`. + +Two things the library cannot do for you: + +- **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. ## Requirements & compatibility - Node.js **>= 16**. Works with the `pg`, `mysql`, and `mysql2` drivers. -- The public API (`pg` / `mysql`, same call shapes and output) is unchanged from - 2.x — see [CHANGELOG / release notes](https://github.com/Acro/simple-builder/releases) - for the 3.0.0 packaging changes and bug fixes. +- 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 diff --git a/esm/index.d.mts b/esm/index.d.mts index 49c64b6..8d017b7 100644 --- a/esm/index.d.mts +++ b/esm/index.d.mts @@ -2,10 +2,11 @@ export { default, pg, mysql, + sql, + Sql, Dialect, Value, Row, - Partial, BuildResult, Build, } from '../dist/index.js' diff --git a/esm/index.mjs b/esm/index.mjs index 09a9c90..4b0a280 100644 --- a/esm/index.mjs +++ b/esm/index.mjs @@ -6,3 +6,5 @@ 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/llms.txt b/llms.txt index f7adf5c..077ad4f 100644 --- a/llms.txt +++ b/llms.txt @@ -1,9 +1,9 @@ # simple-builder -> Tiny SQL string builder that keeps your SQL visible. Write SQL with `?` -> placeholders, interleave values, get `{ text, values }` for the pg, mysql and -> mysql2 drivers. Object/array expansion for INSERT/UPDATE/WHERE/IN. -> Zero dependencies. CJS + ESM. Node >= 16. First-class TypeScript types. +> 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 @@ -11,53 +11,94 @@ npm install simple-builder ## Import (both work everywhere) -CJS: const { pg, mysql } = require('simple-builder') - const simpleBuilder = require('simple-builder').default // { pg, mysql } -ESM: import { pg, mysql } from 'simple-builder' - import simpleBuilder from 'simple-builder' // { pg, mysql } +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 } -## API +## Renderers -pg(partials): { text, values? } // renders $1, $2, … placeholders -mysql(partials): { text, values? } // keeps ? placeholders (also mysql2) +pg(x): { text, values? } // renders $1, $2, … placeholders +mysql(x): { text, values? } // keeps ? placeholders (also mysql2) -`partials` is one of: - - an array of fragments/values: pg(['... ?', value]) - - an argument list: pg('... ?', value) +`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. +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. -## Rules +## 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. + 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". -- pg rewrites `?` to `$1,$2,…`; mysql leaves `?`. That is the only dialect diff. -- Objects/arrays expand when the fragment ends with a marker: +- 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 are case-insensitive (`in ?`, `IN ?`, `values ?` all work). -- Values (including Date, null, false, bigint) are always parameterised. +- sql.id() may be used in fragment position: pg(['SELECT * FROM', sql.id(t), 'WHERE id = ?', 1]) +- 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', with '' escapes; MySQL \ escapes too) + - quoted identifiers ("a?b" and `a?b`) + - line comments (-- ?) and block comments (/* ? */, nested) + - 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). ## Security -- Values are safe: parameterised, never inlined into `text`. -- Object KEYS become column identifiers, interpolated verbatim into the SQL. - Never pass user-controlled keys to VALUES ?/SET ?/WHERE ? — choose the columns - yourself and let the user control only the values. +- 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 -- A value with no preceding `?` fragment throws "expected an SQL string...". -- An empty object passed to VALUES ?/SET ?/WHERE ? throws "empty object...". +- 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) -- Same public API and output for all documented usage (verified by a differential - fuzzer against 2.4.2). -- Now ships TypeScript types + ESM alongside CJS; `main` is `dist/index.js`. +- 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 now throw instead of emitting invalid SQL. + mutated; empty expansion objects throw; `OFFSET ?` is no longer misread as + `SET ?`; too-few-values throws instead of binding undefined. diff --git a/package.json b/package.json index a3c2cf6..f58dc12 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simple-builder", "version": "3.0.0", - "description": "Tiny SQL string builder that keeps your SQL visible. Write SQL with ? placeholders, interleave values, get { text, values } for pg, mysql and mysql2. Object expansion for INSERT/UPDATE/WHERE/IN. Zero dependencies, first-class TypeScript types, CJS + ESM.", + "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", @@ -37,13 +37,20 @@ "builder", "query-builder", "sql-builder", + "sql-template", + "tagged-template", + "template-literal", "mysql", "mysql2", "postgresql", "postgres", "pg", "parameterized", + "prepared-statements", "placeholders", + "sql-injection", + "escape", + "identifier", "insert", "update", "where-in", diff --git a/src/index.ts b/src/index.ts index 2aaa2d9..39f24aa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,42 +1,34 @@ /** - * simple-builder — a tiny SQL string builder that keeps your SQL visible. + * simple-builder — a tiny SQL builder that keeps your SQL visible. * - * You write SQL with `?` placeholders and interleave the bound values; the - * builder returns `{ text, values }` shaped for the `pg`, `mysql`, and `mysql2` - * drivers. For `pg` the `?` are rewritten to `$1, $2, …`; for `mysql` they are - * left as `?`. Objects expand into `INSERT … VALUES`, `SET`, `WHERE`, and - * `IN (…)` fragments. + * Two ways to write the same query, both returning `{ text, values }` shaped + * for the `pg`, `mysql`, and `mysql2` drivers: * - * SECURITY: values are always parameterised and safe. Object *keys* become - * column/identifier names and are interpolated into the SQL verbatim — never - * pass user-controlled keys to `VALUES ?`, `SET ?`, or `WHERE ?`. See README. + * 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. `Date`/`null`/`undefined` are passed through to the - * driver unchanged. */ +/** 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. Used - * by the `VALUES ?`, `SET ?`, and `WHERE ?` object forms. */ +/** An object whose keys are column names and values are bound parameters. */ export type Row = Record -/** One element of a partials array: an SQL fragment, a bound value, an object - * row, or an array (a projection list, or the value list for `IN ?`). */ -export type Partial = - | string - | number - | boolean - | bigint - | null - | undefined - | Date - | Row - | readonly unknown[] - /** The result, shaped for `driver.query(text, values)`. `values` is omitted * when the query bound no parameters. */ export interface BuildResult { @@ -44,168 +36,499 @@ export interface BuildResult { values?: unknown[] } -/** A dialect-bound builder. Accepts a partials array, an argument list of - * partials, or a single ready SQL string. */ -export interface Build { - (partials: readonly Partial[]): BuildResult - (...partials: Partial[]): BuildResult +// ───────────────────────────────────────────────────────────────────────── +// 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 } -const isObject = (value: unknown): value is Row => - value !== null && typeof value === 'object' +/** 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. +// ───────────────────────────────────────────────────────────────────────── -const describe = (value: unknown): string => { - if (value === null) return 'null' - if (Array.isArray(value)) return 'array' - return typeof value +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 + } } -// Markers that make the *next* partial an object (or, for IN, an array) that -// gets expanded. Kept case-insensitive; the whitespace-before-`in` guard stops -// words like "join"/"min" from matching. -const RE_VALUES = /values\s+\?/i -const RE_WHERE = /where\s+\?/i -const RE_IN = /(\sin\s+)\?/i -const RE_SET = /set\s+\?/i +/** A dynamic identifier, quoted for the dialect at render time. */ +class Identifier { + /** @internal */ + readonly parts: string[] + /** @internal */ + constructor(parts: string[]) { + this.parts = parts + } +} -type Action = 'insert' | 'where' | 'where_in' | 'update' +/** 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. +// ───────────────────────────────────────────────────────────────────────── -function build(dialect: Dialect, args: unknown[]): BuildResult { - // Call shapes: build([...]) | build(a, b, c, …) | build("sql string"). - const query: unknown = args.length > 1 ? args : args[0] +type Piece = { text: string } | { placeholder: true } - if (!Array.isArray(query)) { - // A single ready string (or nothing) — nothing to bind. - return { text: query == null ? '' : String(query) } +const lex = (fragment: string, dialect: Dialect): Piece[] => { + const pieces: Piece[] = [] + let buf = '' + const flush = (): void => { + if (buf) { pieces.push({ text: buf }); buf = '' } } - // Copy so we never mutate the caller's array (the projection join below - // would otherwise clobber their input). - const parts = query.slice() as Partial[] + let i = 0 + const n = fragment.length - const values: unknown[] = [] - let paramIndex = 1 - const placeholder = (): string => (dialect === 'mysql' ? '?' : '$' + paramIndex++) + while (i < n) { + const c = fragment[i] - const text: string[] = [] - const last = (): number => text.length - 1 - - // How many upcoming partials are values consumed by the current fragment's - // `?` count, and which object-expansion is pending. Flags persist until the - // matching action fires, mirroring the original state machine. - let ignore = 0 - const flags = { insert: false, where: false, where_in: false, update: false } - - // Highest-precedence pending action (update > where_in > where > insert), - // matching the original's `.pop()` over insertion order. - const currentAction = (): Action | null => { - let action: Action | null = null - if (flags.insert) action = 'insert' - if (flags.where) action = 'where' - if (flags.where_in) action = 'where_in' - if (flags.update) action = 'update' - return action - } - - const columns = (row: Row, action: Action): string[] => { + // `\?` — 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 everywhere; MySQL also honours + // backslash escapes unless NO_BACKSLASH_ESCAPES is set. + if (c === "'") { + buf += c + i++ + while (i < n) { + if (dialect === 'mysql' && fragment[i] === '\\' && i + 1 < n) { + buf += fragment[i] + fragment[i + 1] + i += 2 + continue + } + if (fragment[i] === "'") { + if (fragment[i + 1] === "'") { buf += "''"; i += 2; continue } + buf += "'" + i++ + break + } + buf += fragment[i] + i++ + } + continue + } + + // Quoted identifier: "..." (pg, and MySQL under ANSI_QUOTES) or `...` (MySQL). + if (c === '"' || c === '`') { + const q = c + buf += c + i++ + while (i < n) { + if (fragment[i] === q) { + if (fragment[i + 1] === q) { buf += q + q; i += 2; continue } + buf += q + i++ + break + } + buf += fragment[i] + i++ + } + continue + } + + // Line comment. + if (c === '-' && fragment[i + 1] === '-') { + 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 + +const isObject = (value: unknown): value is Row => + value !== null && typeof value === 'object' && !(value instanceof Date) + +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 ${action} clause — ` + + `simple-builder: empty object passed to the ${clause} clause — ` + 'an object with at least one key is required.' ) } - return keys - } - const apply: Record void> = { - insert(row) { - const keys = columns(row, 'insert') - const placeholders = keys.map((key) => { - values.push(row[key]) - return placeholder() - }) - text[last()] = text[last()].replace( - RE_VALUES, - '(' + keys.join(',') + ') VALUES (' + placeholders.join(',') + ')' - ) - flags.insert = false - }, - where(row) { - const keys = columns(row, 'where') - const conditions = keys.map((key) => { - values.push(row[key]) - return key + '=' + placeholder() - }) - text[last()] = text[last()].replace('?', conditions.join(' AND ')) - flags.where = false - }, - where_in(row) { - // `row` is typically an array; Object.keys gives its indices. - const keys = columns(row, 'where_in') - const placeholders = keys.map((key) => { - values.push(row[key]) - return placeholder() - }) - // Preserve the author's `IN`/`in` casing and spacing; only swap the `?`. - text[last()] = text[last()].replace(RE_IN, (_m, lead: string) => lead + '(' + placeholders.join(',') + ')') - flags.where_in = false - }, - update(row) { - const keys = columns(row, 'update') - const assignments = keys.map((key) => { - values.push(row[key]) - return key + '=' + placeholder() - }) - text[last()] = text[last()].replace('?', assignments.join(',')) - flags.update = false - }, - } - - for (let i = 0; i < parts.length; i++) { - if (ignore-- > 0) { - const part = parts[i] - const action = currentAction() - - if (isObject(part) && action) { - apply[action](part) - } else { - // A plain bound value. For pg, consume the next `?` in place. - if (dialect !== 'mysql') { - text[last()] = text[last()].replace('?', placeholder()) - } - values.push(part) - } - continue + 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 + } +} + +// ───────────────────────────────────────────────────────────────────────── +// The `?` partials API +// ───────────────────────────────────────────────────────────────────────── + +const buildPartials = (dialect: Dialect, parts: unknown[]): 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)} near [..., ${JSON.stringify(parts[i - 1]) ?? 'start'}, ${JSON.stringify(parts[i])}, ...]. ` + - 'A value must follow a fragment containing a `?` placeholder.' + `${describe(part)} at position ${i}. A value must follow a fragment ` + + 'containing a `?` placeholder.' + ) + } + + const pieces = lex(part, dialect) + 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.` ) } - const marks = part.match(/\?/g) - if (marks) ignore = marks.length + 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) + if (clause && isObject(value)) { + out = r.expand(out, clause, value as Row) + } else if (value instanceof Identifier) { + out += r.id(value.parts) + } else if (value instanceof Raw) { + out += value.text + } else { + out += r.bind(value instanceof Single ? value.value : 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. +// ───────────────────────────────────────────────────────────────────────── - if (RE_VALUES.test(part)) flags.insert = true - if (RE_WHERE.test(part)) flags.where = true - if (RE_IN.test(part)) flags.where_in = true - if (RE_SET.test(part)) flags.update = true +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 } - text.push(part) + // 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 } - const result: BuildResult = { text: text.join(' ') } - if (values.length > 0) result.values = values - return result + nodes.push({ k: 'value', v: value }) +} + +interface SqlTag { + (strings: TemplateStringsArray, ...values: unknown[]): Sql + /** A dynamic identifier, quoted for the dialect: `sql.id('users')`, + * `sql.id('public', 'users')` → `"public"."users"`. */ + id(...parts: string[]): Identifier + /** Unescaped SQL text. NEVER pass user input. */ + raw(text: string): Raw + /** Bind a value as a single parameter (arrays would otherwise expand). */ + value(value: unknown): Single + /** Join fragments/values with a separator. */ + join(items: readonly unknown[], separator?: string): Sql + /** A fragment that renders to nothing. */ + 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) + let text = '' + for (const node of fragment.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 r.result(text) +} + +// ───────────────────────────────────────────────────────────────────────── +// 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 +} + +const build = (dialect: Dialect, args: unknown[]): 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()) + if (!(only instanceof Identifier) && !(only instanceof Raw)) { + // A single ready string (or nothing) — nothing to bind. + return { text: only == null ? '' : String(only) } + } + } + return buildPartials(dialect, args) } /** Postgres (`pg`) builder — renders `$1, $2, …` placeholders. */ @@ -214,7 +537,7 @@ export const pg: Build = (...args: unknown[]) => build('pg', args) /** MySQL (`mysql` / `mysql2`) builder — keeps `?` placeholders. */ export const mysql: Build = (...args: unknown[]) => build('mysql', args) -/** Default export: `{ pg, mysql }`, mirroring the classic +/** Default export: `{ pg, mysql, sql }`, mirroring the classic * `require('simple-builder')` shape. */ -const simpleBuilder = { pg, mysql } +const simpleBuilder = { pg, mysql, sql } export default simpleBuilder diff --git a/test/boundary/consumer.ts b/test/boundary/consumer.ts index ce1317f..b7dd2f4 100644 --- a/test/boundary/consumer.ts +++ b/test/boundary/consumer.ts @@ -1,22 +1,30 @@ /** * Package-boundary TypeScript consumer. CI compiles this against the INSTALLED - * package's d.ts with both an older compiler and the latest, so a typings - * change that breaks consumers fails the build. + * package's d.ts with the latest compiler, so a typings change that breaks + * consumers fails the build. */ -import simpleBuilder, { pg, mysql, BuildResult, Partial, Row } from 'simple-builder' +import simpleBuilder, { pg, mysql, sql, BuildResult, 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 partials: Partial[] = ['UPDATE t SET ?', row, 'WHERE id = ?', 1] - const c: BuildResult = simpleBuilder.pg(partials) + 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])}`) const text: string = a.text const values: unknown[] | undefined = a.values - void [b, c, text, values] + void [b, c, d, e, f, g, h, text, values] } void main diff --git a/test/boundary/smoke.cjs b/test/boundary/smoke.cjs index a5f570a..ad34a07 100644 --- a/test/boundary/smoke.cjs +++ b/test/boundary/smoke.cjs @@ -6,28 +6,59 @@ * installs it into a scratch consumer, copies this file in, and runs it. */ const assert = require('assert') -const { pg, mysql } = require('simple-builder') +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) -// pg placeholder rewriting + object expansion. +// ── 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] } ) -// mysql keeps ? placeholders. -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(mysql('SELECT * FROM t WHERE id IN ?', [1, 2, 3]), { + text: 'SELECT * FROM t WHERE id IN (?,?,?)', + values: [1, 2, 3], +}) -// No-parameter query has no values property. 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) + +// ── 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 index e4873b0..db3e339 100644 --- a/test/boundary/smoke.mjs +++ b/test/boundary/smoke.mjs @@ -1,21 +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 } object. +// map's `import` condition, and the default import is the { pg, mysql, sql } object. import assert from 'assert' -import simpleBuilder, { pg, mysql } from 'simple-builder' +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) -assert.deepStrictEqual( - pg('SELECT * FROM users WHERE ?', { email: 'a@b.c' }), - { text: 'SELECT * FROM users WHERE email=$1', values: ['a@b.c'] } -) +// 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], +}) -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/fuzz.cjs b/test/fuzz.cjs index 62bcbf4..94ca6ee 100644 --- a/test/fuzz.cjs +++ b/test/fuzz.cjs @@ -120,3 +120,165 @@ for (let iter = 0; iter < ITERATIONS; iter++) { } 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`) diff --git a/test/index.test.cjs b/test/index.test.cjs index da20d4e..121ea75 100644 --- a/test/index.test.cjs +++ b/test/index.test.cjs @@ -3,12 +3,12 @@ /** * Unit suite. Runs against the built package (`../dist`), under * `--unhandled-rejections=strict`. Covers the documented outputs (as exact - * assertions, replacing the old console.log script) plus regression tests for - * every bug fixed in 3.0.0. + * 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 } = require('../dist/index') +const { pg, mysql, sql } = require('../dist/index') const def = require('../dist/index').default let passed = 0 @@ -16,11 +16,13 @@ const tests = [] const test = (name, fn) => tests.push([name, fn]) // ── Exports ────────────────────────────────────────────────────────────── -test('exposes pg and mysql as named exports and on the default export', () => { +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) ─────────────────────── @@ -70,20 +72,14 @@ test('pg: UPDATE ... SET ? with a trailing WHERE', () => { 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'], - } + { 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'], - } + { text: 'INSERT INTO users (username, gender) VALUES ($1, $2)', values: ['something', 'male'] } ) }) @@ -94,12 +90,17 @@ test('pg: WHERE ? object expansion (AND-joined equality)', () => { }) }) -test('pg: Date values pass through untouched', () => { +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', () => { @@ -110,25 +111,25 @@ test('pg: WHERE IN ? expands an array', () => { }) 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'] } - ) + 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'] } - ) + 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] } - ) + 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', () => { @@ -150,7 +151,7 @@ test('FIX: lowercase "in ?" is expanded (was left as a literal placeholder)', () }) }) -test('FIX: the caller\'s partials array is never mutated', () => { +test("FIX: the caller's partials array is never mutated", () => { const projection = ['id', 'name'] const partials = ['SELECT', projection, 'FROM t'] pg(partials) @@ -168,6 +169,231 @@ 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: ? 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: :: 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) +}) + +// ── 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), { diff --git a/test/types.test.ts b/test/types.test.ts index 676619e..96bb58d 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -2,7 +2,7 @@ * Type-level regression tests. Compiled (never executed) by `npm test` via * `tsc --noEmit`; a wrong inference is a build failure. */ -import simpleBuilder, { pg, mysql, Build, BuildResult, Partial, Row } from '../src/index' +import simpleBuilder, { pg, mysql, sql, Build, BuildResult, Row, Sql } from '../src/index' type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false declare function expectType(): void @@ -12,40 +12,61 @@ function cases(): void { expectType>() expectType>() - // Default export carries both dialects. + // Default export carries both dialects and the tag. expectType>() expectType>() + expectType>() - // Array-of-partials call shape. + // ── partials API ── const a = pg(['SELECT * FROM t WHERE id = ?', 1]) expectType>() - // Varargs call shape. const b = pg('SELECT * FROM t WHERE id = ?', 1) expectType>() - // Heterogeneous partials: strings, numbers, booleans, Date, null, objects, - // and arrays are all assignable to Partial without casts. + // Heterogeneous partials need no casts. const row: Row = { username: 'x', active: true } - const mixed: Partial[] = [ + const c = mysql([ 'UPDATE t SET ?', row, 'WHERE id = ? AND created_at > ?', 1, new Date(), 'AND flag = ?', false, 'AND tag IS ?', null, - ] - const c = mysql(mixed) + ]) 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 + // Result shape: text required, values optional. - const r = pg(['SELECT 1']) + const r = pg(sql`SELECT 1`) const text: string = r.text const values: unknown[] | undefined = r.values void [text, values] - // A projection array literal is a valid partial. - pg(['SELECT', ['id', 'name'], 'FROM t']) - - void [a, b, c] + void [a, b, c, d, e] } void cases From 2053ce14e2f32e96f32b1ef6bcc742ef896916c8 Mon Sep 17 00:00:00 2001 From: "Ondra M." Date: Tue, 14 Jul 2026 14:34:43 +0200 Subject: [PATCH 4/8] Fix lexer: dialect-aware backslash escapes in quoted strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review of the new lexer found a real bug: Postgres E'...' escape strings honour backslash escapes, but the lexer only did so for MySQL. So pg(["SELECT E'a\\'b ?' AS s FROM t WHERE id = ?", 1]) ended the string early at the escaped quote, bound the value to the `?` INSIDE the string literal, and left the real `id = ?` as a literal question mark: before: "SELECT E'a\\'b $1' AS s FROM t WHERE id = ?" after : "SELECT E'a\\'b ?' AS s FROM t WHERE id = $1" Same class of gap for MySQL's backslash-escaped "..." strings. Quote runs now go through one `consumeQuoted` helper that takes an `escapes` flag, set per dialect and per quote style: - MySQL: backslash escapes in both '...' and "..." - Postgres: backslash escapes ONLY in an E'...' string; a standard string treats `\` as an ordinary character (standard_conforming_strings), and a "..." identifier uses "" doubling alone. Adds 3 regression tests (50 → 53) covering both dialects and both directions (escapes honoured where they apply; `\` ordinary where it doesn't). Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 6 +++- llms.txt | 6 ++-- src/index.ts | 73 +++++++++++++++++++++++++++------------------ test/index.test.cjs | 29 ++++++++++++++++++ 4 files changed, 82 insertions(+), 32 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1c976c7..6adb8a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,11 @@ security model, and gotchas in one file. 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. + what survives all that is a placeholder. Quote runs go through + `consumeQuoted`, which is 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. 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 ?`. diff --git a/llms.txt b/llms.txt index 077ad4f..c5c7862 100644 --- a/llms.txt +++ b/llms.txt @@ -60,8 +60,10 @@ sql.empty // renders to nothing (useful for conditionals) ## Lexing: `?` that is not a placeholder The SQL is LEXED, not scanned. A `?` is left alone inside: - - single-quoted string literals ('a?b', with '' escapes; MySQL \ escapes too) - - quoted identifiers ("a?b" and `a?b`) + - 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 (-- ?) and block comments (/* ? */, nested) - dollar-quoted bodies ($$a ? b$$, $tag$a ? b$tag$) Postgres `?|` and `?&` jsonb operators are recognised and preserved. diff --git a/src/index.ts b/src/index.ts index 39f24aa..7f83081 100644 --- a/src/index.ts +++ b/src/index.ts @@ -141,6 +141,39 @@ class Single { 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): Piece[] => { const pieces: Piece[] = [] let buf = '' @@ -161,44 +194,26 @@ const lex = (fragment: string, dialect: Dialect): Piece[] => { continue } - // Single-quoted string literal. `''` escapes everywhere; MySQL also honours - // backslash escapes unless NO_BACKSLASH_ESCAPES is set. + // Single-quoted string literal. `''` escapes everywhere. Backslash also + // escapes in MySQL (unless NO_BACKSLASH_ESCAPES) and in a Postgres E'…' + // escape string — but NOT in a standard Postgres string, where + // standard_conforming_strings makes backslash an ordinary character. if (c === "'") { + const escapes = dialect === 'mysql' || /(?:^|[^A-Za-z0-9_$])[Ee]$/.test(buf) buf += c i++ - while (i < n) { - if (dialect === 'mysql' && fragment[i] === '\\' && i + 1 < n) { - buf += fragment[i] + fragment[i + 1] - i += 2 - continue - } - if (fragment[i] === "'") { - if (fragment[i + 1] === "'") { buf += "''"; i += 2; continue } - buf += "'" - i++ - break - } - buf += fragment[i] - i++ - } + i = consumeQuoted(fragment, i, "'", escapes, (s) => { buf += s }) continue } - // Quoted identifier: "..." (pg, and MySQL under ANSI_QUOTES) or `...` (MySQL). + // Quoted identifier: `...` (MySQL) or "..." (a pg identifier, or a MySQL + // string). Either way a `?` inside is not a placeholder. Only MySQL honours + // backslash escapes here; pg identifiers use "" doubling alone. if (c === '"' || c === '`') { - const q = c + const escapes = dialect === 'mysql' && c === '"' buf += c i++ - while (i < n) { - if (fragment[i] === q) { - if (fragment[i + 1] === q) { buf += q + q; i += 2; continue } - buf += q - i++ - break - } - buf += fragment[i] - i++ - } + i = consumeQuoted(fragment, i, c, escapes, (s) => { buf += s }) continue } diff --git a/test/index.test.cjs b/test/index.test.cjs index 121ea75..fb1489c 100644 --- a/test/index.test.cjs +++ b/test/index.test.cjs @@ -213,6 +213,35 @@ test("LEXER: '' escape inside a string literal is handled", () => { }) }) +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', From f57ee6b256d29df05e3159eeca6d8040423fcabf Mon Sep 17 00:00:00 2001 From: "Ondra M." Date: Tue, 14 Jul 2026 15:05:42 +0200 Subject: [PATCH 5/8] Fix three findings from adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three reproduced before fixing; each now has a regression test. 1. Marker classes enumerated as a Row at a clause-marker value position (src/index.ts). Sql/Identifier/Raw/Single are plain objects, and the `clause && isObject(value)` branch ran before the instanceof checks, so: pg(['SELECT * FROM t WHERE ?', sql.id('foo')]) → { text: 'SELECT * FROM t WHERE parts=$1', values: [['foo']] } The marker's own field names (parts/nodes/value/text) all satisfy PLAIN_IDENTIFIER, so nothing threw — it silently emitted wrong SQL against fabricated columns. Same for VALUES ?/SET ?/IN ? and for raw/value/tag. Fixed two ways (independent guards): the instanceof branches now run first, and isObject excludes marker instances via a new isMarker predicate. This also surfaced a related gap the review noted: an sql`` fragment at a value position had NO branch at all and was bound as raw node data, even without a clause marker. It now splices via a shared renderNodes(), so placeholder numbering stays continuous: pg(['SELECT * FROM t WHERE x = ?', 9, 'AND ?', sql`a = ${1}`, 'AND b = ?', 2]) → 'SELECT * FROM t WHERE x = $1 AND a = $2 AND b = $3' `build()` now uses isMarker too, so pg(sql.value(x)) no longer stringifies to "[object Object]". 2. MySQL `#` line comments were not lexed (src/index.ts). A `?` inside one was counted as a placeholder, so valid MySQL either threw a bogus "3 placeholder(s) but only 2 value(s)" or silently misaligned parameters. Gated on dialect: Postgres has no `#` comment — it starts the #> and #- jsonb operators, which are covered by a test to prevent an over-eager fix. 3. Script injection in publish.yml: the release tag_name was interpolated into a bash script body. `${{ }}` expands before bash runs and git permits $ ` " ; | in tag names, so a crafted tag could execute shell in a job holding id-token: write and NPM_TOKEN. Now passed via env: and referenced as $TAG. Low severity (requires contents: write to cut a release) but it is the documented anti-pattern and the fix is free. Tests: 53 → 58 unit tests, plus a third fuzzer. The existing fuzzers could not have caught #1 — the tag fuzzer never routes a marker through buildPartials. The new marker fuzzer does, asserting a marker's internal field names never surface as SQL identifiers. Verified it earns its place by reintroducing the original bug and confirming it fails: MARKER PROPERTY FAILURE: marker field "nodes" surfaced as a column: UPDATE t SET nodes=$1 Co-Authored-By: Claude Opus 4.8 --- .github/workflows/publish.yml | 7 ++- AGENTS.md | 6 ++ llms.txt | 8 ++- src/index.ts | 54 +++++++++++++----- test/fuzz.cjs | 104 ++++++++++++++++++++++++++++++++++ test/index.test.cjs | 63 ++++++++++++++++++++ 6 files changed, 225 insertions(+), 17 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1dd48f3..dfe00e1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -32,9 +32,14 @@ jobs: - 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")" - TAG="${{ github.event.release.tag_name }}" if [ "v$PKG_VERSION" != "$TAG" ] && [ "$PKG_VERSION" != "$TAG" ]; then echo "::error::package.json is $PKG_VERSION but the release tag is $TAG" exit 1 diff --git a/AGENTS.md b/AGENTS.md index 6adb8a3..4f22c3d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,6 +75,12 @@ add an export, update `esm/index.mjs` AND `esm/index.d.mts`. 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 diff --git a/llms.txt b/llms.txt index c5c7862..ca334b5 100644 --- a/llms.txt +++ b/llms.txt @@ -54,7 +54,10 @@ sql.empty // renders to nothing (useful for conditionals) "... 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)" -- sql.id() may be used in fragment position: pg(['SELECT * FROM', sql.id(t), 'WHERE id = ?', 1]) +- 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 @@ -64,7 +67,8 @@ The SQL is LEXED, not scanned. A `?` is left alone inside: 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 (-- ?) and block comments (/* ? */, nested) + - line comments (-- ?), MySQL # comments (# ?), block comments (/* ? */, nested) + NOTE: # is a comment in MySQL only — in pg it starts #> / #- jsonb operators. - 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). diff --git a/src/index.ts b/src/index.ts index 7f83081..73cf176 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,8 +217,9 @@ const lex = (fragment: string, dialect: Dialect): Piece[] => { continue } - // Line comment. - if (c === '-' && fragment[i + 1] === '-') { + // Line comment. MySQL additionally treats `#` as one; Postgres does NOT — + // there `#` starts operators like `#>` / `#-`, so this must stay gated. + if ((c === '-' && fragment[i + 1] === '-') || (dialect === 'mysql' && c === '#')) { while (i < n && fragment[i] !== '\n') { buf += fragment[i]; i++ } continue } @@ -304,8 +305,18 @@ const classify = (before: string): Clause => 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) + value !== null && typeof value === 'object' && !(value instanceof Date) && !isMarker(value) const describe = (value: unknown): string => value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value @@ -371,6 +382,19 @@ class Renderer { } } +/** 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 // ───────────────────────────────────────────────────────────────────────── @@ -424,14 +448,22 @@ const buildPartials = (dialect: Dialect, parts: unknown[]): BuildResult => { const value = parts[i + 1 + k] k++ const clause = classify(out) - if (clause && isObject(value)) { - out = r.expand(out, clause, value as Row) + + // 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 instanceof Single ? value.value : value) + out += r.bind(value) } } @@ -512,13 +544,7 @@ export const sql: SqlTag = Object.assign(tag, { const buildSql = (dialect: Dialect, fragment: Sql): BuildResult => { const r = new Renderer(dialect) - let text = '' - for (const node of fragment.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 r.result(text) + return r.result(renderNodes(r, fragment.nodes)) } // ───────────────────────────────────────────────────────────────────────── @@ -538,7 +564,7 @@ const build = (dialect: Dialect, args: unknown[]): BuildResult => { const only = args[0] if (only instanceof Sql) return buildSql(dialect, only) if (Array.isArray(only)) return buildPartials(dialect, only.slice()) - if (!(only instanceof Identifier) && !(only instanceof Raw)) { + if (!isMarker(only)) { // A single ready string (or nothing) — nothing to bind. return { text: only == null ? '' : String(only) } } diff --git a/test/fuzz.cjs b/test/fuzz.cjs index 94ca6ee..4d87d04 100644 --- a/test/fuzz.cjs +++ b/test/fuzz.cjs @@ -282,3 +282,107 @@ for (let iter = 0; iter < ITERATIONS; iter++) { } 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 index fb1489c..2c1f8b4 100644 --- a/test/index.test.cjs +++ b/test/index.test.cjs @@ -275,6 +275,23 @@ test('LEXER: ? inside dollar-quoted strings (anonymous and tagged) is not a plac }) }) +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', @@ -331,6 +348,52 @@ test('sql.id rejects NUL and non-strings', () => { assert.throws(() => pg(sql`SELECT * FROM ${sql.id('')}`), /non-empty string/i) }) +// ── 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}`), { From 14c612f863d1efed1131cec784508318e3098243 Mon Sep 17 00:00:00 2001 From: "Ondra M." Date: Tue, 14 Jul 2026 15:26:24 +0200 Subject: [PATCH 6/8] =?UTF-8?q?Add=20integration=20tests=20against=20real?= =?UTF-8?q?=20Postgres=20and=20MySQL=20=E2=80=94=20found=20a=20lexer=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit suite asserts on the generated { text, values }, which only ever proves the builder agrees with OUR reading of the SQL grammars. Both lexer bugs found so far were exactly that kind of misreading, so this suite executes the built queries against real servers and asserts on the returned rows. It immediately earned its place by finding a third one: MySQL treats `--` as a comment ONLY when followed by whitespace or EOF. Verified on MySQL 8.4: `SELECT 1--2` is 3 (two minus signs), and `SELECT 1--?` really does bind the placeholder. The lexer treated `--` as a comment unconditionally, so it swallowed the `?`: mysql(['SELECT 1--? AS v', 2]) → Error: expected an SQL string fragment but got number at position 1 Postgres, by contrast, always treats `--` as a comment — confirmed against PG 16, where `SELECT 1--$1 AS v\n, $1 ::int AS n` comments out the first and binds the second. So the rule is dialect-gated, like `#`. Verified the regression test earns its place by reverting the fix and watching it fail with exactly that error. test/integration.cjs — 27 tests over Postgres 16 + MySQL 8.4: - INSERT/UPDATE/WHERE/IN object expansion round-trips (assert on rows) - $1..$n numbering correct across mixed clauses - injection payloads stored as literal data, table still standing - jsonb ?| ?& and the bare ? (via \? escape, and via the tag with no escape) - #> / #- are operators, not comments - ? inside string literals, dollar-quotes ($$ and $tag$), and comments - pg E'…' backslash escapes vs standard_conforming_strings literal backslash - MySQL # comments, `--` whitespace rule, backslash escapes in both quote styles - sql.id quoting: case-sensitivity, embedded-quote escaping, schema qualifying - sql tag composition and fragments spliced at a ? value position - LIKE wildcards in a bound value (pins the documented behaviour) Tooling: - pg + mysql2 as devDependencies ONLY — the published tarball still ships zero dependencies (verified: installed package's `dependencies` is {}). - npm run test:integration; npm run db:up / db:down for local docker servers. - CI job with postgres:16 and mysql:8 service containers. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 41 +++++ AGENTS.md | 23 ++- llms.txt | 3 + package-lock.json | 317 +++++++++++++++++++++++++++++++++++++++ package.json | 7 +- src/index.ts | 14 +- test/index.test.cjs | 22 +++ test/integration.cjs | 271 +++++++++++++++++++++++++++++++++ 8 files changed, 689 insertions(+), 9 deletions(-) create mode 100644 test/integration.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03d9d9f..14c6e95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,47 @@ jobs: - 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: diff --git a/AGENTS.md b/AGENTS.md index 4f22c3d..073ecda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,13 @@ security model, and gotchas in one file. under `--unhandled-rejections=strict`. - 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. @@ -33,11 +40,17 @@ security model, and gotchas in one file. 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. Quote runs go through - `consumeQuoted`, which is 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. + what survives all that is a placeholder. + + 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 `#>` / `#-`. 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 ?`. diff --git a/llms.txt b/llms.txt index ca334b5..131fe2d 100644 --- a/llms.txt +++ b/llms.txt @@ -69,6 +69,9 @@ The SQL is LEXED, not scanned. A `?` is left alone inside: - 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). diff --git a/package-lock.json b/package-lock.json index 78f5171..b6c4909 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,311 @@ "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", @@ -28,6 +327,24 @@ "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 f58dc12..bbcbf64 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,10 @@ "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", - "fuzz": "tsc && node test/fuzz.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" }, "keywords": [ "sql", @@ -74,6 +77,8 @@ "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 index 73cf176..a8f2c47 100644 --- a/src/index.ts +++ b/src/index.ts @@ -217,9 +217,17 @@ const lex = (fragment: string, dialect: Dialect): Piece[] => { continue } - // Line comment. MySQL additionally treats `#` as one; Postgres does NOT — - // there `#` starts operators like `#>` / `#-`, so this must stay gated. - if ((c === '-' && fragment[i + 1] === '-') || (dialect === 'mysql' && c === '#')) { + // 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 } diff --git a/test/index.test.cjs b/test/index.test.cjs index 2c1f8b4..4037118 100644 --- a/test/index.test.cjs +++ b/test/index.test.cjs @@ -275,6 +275,28 @@ test('LEXER: ? inside dollar-quoted strings (anonymous and tagged) is not a plac }) }) +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( diff --git a/test/integration.cjs b/test/integration.cjs new file mode 100644 index 0000000..0221443 --- /dev/null +++ b/test/integration.cjs @@ -0,0 +1,271 @@ +'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. +const runPg = async (client, q) => (await client.query(q.text, q.values || [])).rows +const runMy = async (conn, q) => (await conn.query(q.text, q.values || []))[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']) +}) + +// ───────────────────────────────────────────────────────────────────────── +// 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')", + ], +} + +;(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) + + for (const [name, fn] of tests) { + try { + await fn(client, conn) + passed++ + } catch (err) { + console.error(`✗ ${name}`) + console.error(err && err.stack ? err.stack : err) + await client.end() + await conn.end() + process.exit(1) + } + } + + await client.end() + await conn.end() + console.log(`✓ ${passed}/${tests.length} integration tests passed against real Postgres + MySQL`) +})().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) +}) From 4751f80ccbde11eebf9958ddc9f2167c73400b42 Mon Sep 17 00:00:00 2001 From: "Ondra M." Date: Tue, 14 Jul 2026 15:39:59 +0200 Subject: [PATCH 7/8] Add sql_mode matrix tests and withMode() for non-default servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last risk flagged: the integration suite only pinned DEFAULT server configuration, and three settings invert assumptions the lexer encodes. Measured against MySQL 8.4 / PG 16 (each contradicts a plausible assumption): - MySQL ANSI_QUOTES: "…" is an identifier escaped by "" doubling — `"a\"b"` is a SYNTAX ERROR there, so backslash escaping must be off for `"`. - MySQL NO_BACKSLASH_ESCAPES: `\` is ordinary in literals ('a\' is a\). - pg standard_conforming_strings=off: `\` escapes inside '…' — the exact OPPOSITE of the default the lexer assumed. - `''` 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. There is no conservative rule that satisfies both readings — the placeholder count genuinely differs — so the builder has to be told. Added a minimal, default-inert opt-in: const my = mysql.withMode({ ansiQuotes: true, noBackslashEscapes: true }) const pgOld = pg.withMode({ standardConformingStrings: false }) withMode returns a NEW builder (original untouched) and merges when chained. Only affects the `?` partials API. Needed only when your SQL puts a backslash immediately before a quote inside a literal. Tests: 30 matrix tests (4 MySQL modes × 2 pg modes) that set the real SESSION to the mode AND configure the builder to match, so any disagreement fails. Each mode asserts: '' doubling is mode-proof, identifiers work, the tag is mode-proof, object expansion round-trips, and backslash-in-literal follows the mode. Plus 6 unit tests + type/boundary coverage for the new surface. 58 → 64 unit. Also switches the MySQL integration path from query() to execute(), which found a driver-level fact worth documenting: mysql2's query() substitutes values CLIENT-side with backslash escaping, so under NO_BACKSLASH_ESCAPES it errors and round-trips `x\` as `x\\`. execute() binds server-side and is correct in every mode. Using execute() also means MySQL's own parser — not mysql2's scanner — validates our text. README/llms.txt now tell MySQL users to prefer execute(). Corrected an overclaim while verifying: the server is NOT a full oracle for MySQL. Measured — pg rejects too-few AND too-many params; MySQL execute() rejects too-few but silently ignores extras. So the tests assert on returned rows, not merely that the query ran. Recorded in AGENTS.md. Published package still ships zero dependencies (verified: {}). Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 23 +++++ README.md | 36 ++++++++ esm/index.d.mts | 1 + llms.txt | 21 +++++ src/index.ts | 76 +++++++++++---- test/boundary/consumer.ts | 10 +- test/boundary/smoke.cjs | 12 +++ test/index.test.cjs | 61 +++++++++++++ test/integration.cjs | 188 ++++++++++++++++++++++++++++++++++++-- test/types.test.ts | 15 ++- 10 files changed, 416 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 073ecda..b191a3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,10 @@ security model, and gotchas in one file. `?|`/`?&`/`??` 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 @@ -51,6 +55,25 @@ security model, and gotchas in one file. - `--` 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 ?`. diff --git a/README.md b/README.md index 414469d..ba8fcf6 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,42 @@ Two things the library cannot do for you: automatically safe to concatenate into new SQL — parameterise on the way out too. +### On MySQL, prefer `execute()` over `query()` + +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: + +```javascript +const q = mysql(['SELECT * FROM users WHERE id = ?', id]) +const [rows] = await conn.execute(q.text, q.values) // ← prefer this +``` + +## Non-default server modes + +Three server settings change how SQL *lexes*, and therefore which `?` is a +placeholder. If you have changed them, tell the builder: + +```javascript +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. diff --git a/esm/index.d.mts b/esm/index.d.mts index 8d017b7..442f3d1 100644 --- a/esm/index.d.mts +++ b/esm/index.d.mts @@ -8,5 +8,6 @@ export { Value, Row, BuildResult, + Mode, Build, } from '../dist/index.js' diff --git a/llms.txt b/llms.txt index 131fe2d..29f3cd9 100644 --- a/llms.txt +++ b/llms.txt @@ -76,6 +76,27 @@ The SQL is LEXED, not scanned. A `?` is left alone inside: 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) + ## Security - Values are safe: parameterised, never inlined into `text`, in BOTH APIs. diff --git a/src/index.ts b/src/index.ts index a8f2c47..e6c0cac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,6 +36,28 @@ export interface BuildResult { 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 // @@ -174,7 +196,7 @@ const consumeQuoted = ( return i } -const lex = (fragment: string, dialect: Dialect): Piece[] => { +const lex = (fragment: string, dialect: Dialect, mode: Mode): Piece[] => { const pieces: Piece[] = [] let buf = '' const flush = (): void => { @@ -194,23 +216,30 @@ const lex = (fragment: string, dialect: Dialect): Piece[] => { continue } - // Single-quoted string literal. `''` escapes everywhere. Backslash also - // escapes in MySQL (unless NO_BACKSLASH_ESCAPES) and in a Postgres E'…' - // escape string — but NOT in a standard Postgres string, where - // standard_conforming_strings makes backslash an ordinary character. + // 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' || /(?:^|[^A-Za-z0-9_$])[Ee]$/.test(buf) + 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 identifier: `...` (MySQL) or "..." (a pg identifier, or a MySQL - // string). Either way a `?` inside is not a placeholder. Only MySQL honours - // backslash escapes here; pg identifiers use "" doubling alone. + // 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 === '"' + const escapes = + dialect === 'mysql' && c === '"' && !mode.ansiQuotes && !mode.noBackslashEscapes buf += c i++ i = consumeQuoted(fragment, i, c, escapes, (s) => { buf += s }) @@ -407,7 +436,7 @@ const renderNodes = (r: Renderer, nodes: readonly Node[]): string => { // The `?` partials API // ───────────────────────────────────────────────────────────────────────── -const buildPartials = (dialect: Dialect, parts: unknown[]): BuildResult => { +const buildPartials = (dialect: Dialect, parts: unknown[], mode: Mode): BuildResult => { const r = new Renderer(dialect) const text: string[] = [] @@ -438,7 +467,7 @@ const buildPartials = (dialect: Dialect, parts: unknown[]): BuildResult => { ) } - const pieces = lex(part, dialect) + const pieces = lex(part, dialect, mode) const holes = pieces.filter((p) => 'placeholder' in p).length const available = parts.length - i - 1 if (holes > available) { @@ -565,26 +594,39 @@ 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[]): BuildResult => { +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()) + 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) + 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. */ -export const pg: Build = (...args: unknown[]) => build('pg', args) +export const pg: Build = makeBuild('pg', {}) /** MySQL (`mysql` / `mysql2`) builder — keeps `?` placeholders. */ -export const mysql: Build = (...args: unknown[]) => build('mysql', args) +export const mysql: Build = makeBuild('mysql', {}) /** Default export: `{ pg, mysql, sql }`, mirroring the classic * `require('simple-builder')` shape. */ diff --git a/test/boundary/consumer.ts b/test/boundary/consumer.ts index b7dd2f4..3dd8e4b 100644 --- a/test/boundary/consumer.ts +++ b/test/boundary/consumer.ts @@ -3,7 +3,7 @@ * package's d.ts with the latest compiler, so a typings change that breaks * consumers fails the build. */ -import simpleBuilder, { pg, mysql, sql, BuildResult, Row, Sql } from 'simple-builder' +import simpleBuilder, { pg, mysql, sql, Build, BuildResult, Mode, Row, Sql } from 'simple-builder' function main(): void { // partials API @@ -21,10 +21,16 @@ function main(): void { 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, text, 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 index ad34a07..50787cd 100644 --- a/test/boundary/smoke.cjs +++ b/test/boundary/smoke.cjs @@ -39,6 +39,18 @@ assert.deepStrictEqual(pg(["SELECT * FROM t WHERE tags ?| ARRAY['x'] AND id = ?" // ── 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', diff --git a/test/index.test.cjs b/test/index.test.cjs index 4037118..9fdee20 100644 --- a/test/index.test.cjs +++ b/test/index.test.cjs @@ -370,6 +370,67 @@ test('sql.id rejects NUL and non-strings', () => { 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. diff --git a/test/integration.cjs b/test/integration.cjs index 0221443..aafbbb6 100644 --- a/test/integration.cjs +++ b/test/integration.cjs @@ -30,8 +30,32 @@ 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) => (await conn.query(q.text, q.values || []))[0] +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 @@ -217,6 +241,130 @@ test('mysql: sql tag composes fragments', async (_c, m) => { 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 // ───────────────────────────────────────────────────────────────────────── @@ -235,6 +383,8 @@ const schema = { '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)', ], } @@ -246,22 +396,46 @@ const schema = { 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) { - console.error(`✗ ${name}`) - console.error(err && err.stack ? err.stack : err) - await client.end() - await conn.end() - process.exit(1) + 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() - console.log(`✓ ${passed}/${tests.length} integration tests passed against real Postgres + MySQL`) })().catch((err) => { console.error('Integration setup failed:', err && err.message ? err.message : err) console.error('\nStart servers with:') diff --git a/test/types.test.ts b/test/types.test.ts index 96bb58d..b54ec2f 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -2,7 +2,7 @@ * 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, Row, Sql } from '../src/index' +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 @@ -60,6 +60,19 @@ function cases(): void { 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 From 788cdb6424a6c0aca2643d3141ba3ab97ef65e6f Mon Sep 17 00:00:00 2001 From: "Ondra M." Date: Tue, 14 Jul 2026 15:50:46 +0200 Subject: [PATCH 8/8] Make the library discoverable and usable by AI agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three surfaces an agent actually reads — llms.txt in the tarball, the shipped .d.ts via LSP, and runtime errors — audited and closed. Executable documentation (test/docs.test.cjs, wired into npm test) - Parses every ```javascript block in README.md and llms.txt, runs it against the build, and compares with the documented result. Docs are what agents copy verbatim, so drift is now a build failure rather than a silent lie. - 26 examples verified (36 evaluated). Verified the test earns its place by drifting a recipe and watching it fail with a precise diff. - Rejects ellipses in expected values, which caught a real doc bug: the INSERT example documented `values: [...]` — an agent would have copied that literally. It now shows the real values. - A floor on the checked count guards against the parser silently matching nothing and "passing" vacuously. llms.txt — added a Recipes section (12 machine-verified, self-contained) Previously it had no runnable code at all, so an agent had nothing to copy. Covers: value binding, conditional WHERE via sql.empty, sql.join for a variable number of clauses, IN lists, INSERT..RETURNING, UPDATE from an object, dynamic identifiers via sql.id, dynamic ORDER BY via allow-list + sql.raw, pagination, LIKE with wildcard escaping, jsonb `?` in the tag, and one fragment rendered for both dialects. Shipped .d.ts — added 8 @example blocks on pg / mysql / sql / id / raw / value / join, so an LSP hover teaches the safe pattern at the moment of use (e.g. sql.raw's example shows the allow-list first; sql.id's notes the case-sensitivity trap; mysql's points at execute() over query()). Discoverability - README now points at llms.txt and says where it lands on disk. - GitHub repo had NO description, topics, or homepage — all three set. - npm keywords already cover the tagged-template/injection/dialect surface. Verified after a real install: llms.txt (228 lines), the @example-carrying d.ts, and `dependencies: {}` are all present in node_modules/simple-builder. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 22 ++++- README.md | 9 +- llms.txt | 94 ++++++++++++++++++++ package.json | 2 +- src/index.ts | 104 ++++++++++++++++++++-- test/docs.test.cjs | 216 +++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 test/docs.test.cjs diff --git a/AGENTS.md b/AGENTS.md index b191a3b..573e469 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,16 +6,30 @@ tests and packaging. ## Consumer API -See `llms.txt` (shipped in the npm tarball) for the complete API, lexing rules, -security model, and gotchas in one file. +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), then runs the unit suite - under `--unhandled-rejections=strict`. + 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 diff --git a/README.md b/README.md index ba8fcf6..ff003e6 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,12 @@ Zero dependencies. ~16 kB. First-class TypeScript types. CJS **and** ESM. npm install simple-builder --save ``` +> **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. + ## Why Most query builders hide your SQL behind a fluent API you have to learn. @@ -162,7 +168,8 @@ pg(['SELECT', ['id', 'username'], 'FROM users']) ```javascript // 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: [...] } +// { 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]) diff --git a/llms.txt b/llms.txt index 29f3cd9..8a9986b 100644 --- a/llms.txt +++ b/llms.txt @@ -97,6 +97,100 @@ 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. diff --git a/package.json b/package.json index bbcbf64..4c32db9 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "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", + "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", diff --git a/src/index.ts b/src/index.ts index e6c0cac..d596c2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -540,18 +540,77 @@ const interpolate = (value: unknown, nodes: Node[]): void => { 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: `sql.id('users')`, - * `sql.id('public', 'users')` → `"public"."users"`. */ + /** + * 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. NEVER pass user input. */ + /** + * 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 a single parameter (arrays would otherwise expand). */ + /** + * 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. */ + /** + * 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. */ + /** A fragment that renders to nothing — the "off" branch of a conditional. */ readonly empty: Sql } @@ -622,10 +681,39 @@ const makeBuild = (dialect: Dialect, mode: Mode): Build => { return builder } -/** Postgres (`pg`) builder — renders `$1, $2, …` placeholders. */ +/** + * 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. */ +/** + * 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 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`)