diff --git a/skills/nearform-sql/SKILL.md b/skills/nearform-sql/SKILL.md new file mode 100644 index 0000000..1056113 --- /dev/null +++ b/skills/nearform-sql/SKILL.md @@ -0,0 +1,146 @@ +--- +name: nearform-sql +description: "Use this skill when writing, reviewing, or refactoring database queries with @nearform/sql — Nearform's tagged-template library that produces SQL-injection-safe parameterized queries for pg, mysql, and mysql2. Covers installation, the SQL tag (.text/.sql/.values/.debug), helpers (glue, map, unsafe, quoteIdent), Fastify integration, dynamic/bulk queries, and security best practices. Trigger terms: SQL, query, parameterized query, SQL injection, pg, postgres, mysql, @nearform/sql, glue, quoteIdent." +metadata: + author: "NearForm" + version: 1.0.0 + tags: + - category/code-generation + - tool/postgres + - tool/mysql + - domain/engineering + - domain/security +--- + +# @nearform/sql + +`@nearform/sql` is a tagged-template library that turns ES6 template literals into +**parameterized** queries, immune to SQL injection by construction. It works with +`pg` (PostgreSQL), `mysql`, and `mysql2`. You build a query with the `` SQL`...` `` +tag and pass the resulting statement object straight to your driver. + +> **Source:** this skill ships in this repo (under `skills/nearform-sql/`) and is a +> port of the same skill maintained in the +> [`nearform/skills`](https://github.com/nearform/skills) marketplace +> (`plugins/nearform-sql/`). The marketplace version may receive updates first; +> this repo tracks them. + +## When to use this skill + +Reach for this skill whenever you: + +- Write or review code that builds SQL queries in Node.js. +- See a raw SQL string assembled with `+`, `${...}` inside a plain template string, or + `string.replace` — that is a SQL-injection red flag; rewrite it with the `` SQL`` `` tag. +- Need dynamic queries: conditional `WHERE`, dynamic `SET`, bulk `INSERT`, or `IN (...)` lists. +- Interpolate identifiers (table/column names) into a query. + +> For the full API reference see [`references/api-cheatsheet.md`](references/api-cheatsheet.md). +> For framework integration and dynamic-query recipes see [`references/patterns.md`](references/patterns.md). + +## Install & import + +```sh +npm install @nearform/sql +``` + +```js +import SQL from '@nearform/sql' // ESM — prefer this for new code +``` + +```js +const SQL = require('@nearform/sql') // CommonJS — also supported +``` + +The package itself is published as CommonJS (no `exports` map), but it interops +cleanly with ESM through a **default import** — use `import SQL from '@nearform/sql'`, +then call `SQL.glue`, `SQL.map`, etc. off that default. + +Statements are fully typed — the package ships `SQL.d.ts`, and helpers like +`SQL.map(array, mapFunc?)` are generic, so TypeScript infers the element type. + +## The golden rule + +**Every runtime value goes through `${...}` inside the `` SQL`` `` tag.** The tag captures +each interpolated value as a bound parameter — it never injects it into the query text. + +```js +const username = "Robert'); DROP TABLE students;--" + +const sql = SQL`SELECT * FROM users WHERE username = ${username}` +// sql.text -> "SELECT * FROM users WHERE username = $1" (pg) +// sql.sql -> "SELECT * FROM users WHERE username = ?" (mysql) +// sql.values -> ["Robert'); DROP TABLE students;--"] (bound, harmless) +``` + +- **Never** build the query with string concatenation or an untagged template literal. +- `SQL.unsafe(value)` and `append(..., { unsafe: true })` interpolate literally and + **bypass this protection** — use them only for values you fully control, never for + user input. See the cheatsheet. + +## Core usage + +The statement object is driver-agnostic. Choose the property your client expects, or +pass the whole object — `pg`, `mysql`, and `mysql2` all read what they need from it. + +```js +const user = { username: 'alice', email: 'alice@example.com' } + +const sql = SQL` + INSERT INTO users (username, email) + VALUES (${user.username}, ${user.email}) +` + +// PostgreSQL (pg) — uses sql.text ($1, $2) + sql.values +await pgClient.query(sql) + +// MySQL (mysql / mysql2) — uses sql.sql (?, ?) + sql.values +mysqlConnection.query(sql) +``` + +| Property | Use it for | Placeholder style | +|----------|------------|-------------------| +| `sql.text` | PostgreSQL (`pg`) | `$1, $2, …` | +| `sql.sql` | MySQL (`mysql`, `mysql2`) | `?, ?, …` | +| `sql.values` | The bound values array (both) | — | +| `sql.debug` | **Logging/debugging only** — never execute it | inlined (unsafe) | + +## Composing queries + +Nest `` SQL`` `` tags directly — the preferred way to build queries from fragments. +Parameterization is preserved across the nesting. + +```js +const where = SQL`WHERE active = ${true}` +const sql = SQL`SELECT id, email FROM users ${where} ORDER BY created_at DESC` +``` + +For repeated fragments, use the helpers: + +- **`SQL.glue(pieces, separator)`** — join an array of statements (dynamic `SET`/`WHERE`, bulk `VALUES`). +- **`SQL.map(array, mapFunc?)`** — expand an array into bound values (`IN (...)` lists, bulk insert). + +```js +const ids = [1, 2, 3] +const sql = SQL`SELECT * FROM users WHERE id IN (${SQL.map(ids)})` +``` + +See [`references/patterns.md`](references/patterns.md) for complete dynamic-query, Fastify, +and migration recipes. + +> `append()` still works but is **deprecated** — prefer nesting tags. + +## Best practices checklist + +- ✅ Wrap **every** query in the `` SQL`` `` tag; pass interpolated values via `${...}`. +- ✅ Never concatenate strings or use untagged template literals for SQL. +- ⚠️ **No `undefined`** — the tag throws on `undefined`. Coerce nullable fields to `null`: + ```js + SQL`INSERT INTO users (name, address) VALUES (${user.name}, ${user.address || null})` + ``` +- ✅ Use **`SQL.quoteIdent(name)`** for dynamic identifiers (table/column names) — never + interpolate them as values. +- ⚠️ Use **`SQL.unsafe(value)`** only for trusted, non-user values; it bypasses injection protection. +- ✅ Use `sql.debug` for logs only — **never** send it to the driver as the executed query. +- ✅ Enforce the tag in your codebase with [`eslint-plugin-sql`](https://www.npmjs.com/package/eslint-plugin-sql) + and the `thebearingedge.vscode-sql-lit` VS Code extension for syntax highlighting inside the tag. diff --git a/skills/nearform-sql/references/api-cheatsheet.md b/skills/nearform-sql/references/api-cheatsheet.md new file mode 100644 index 0000000..d96cab0 --- /dev/null +++ b/skills/nearform-sql/references/api-cheatsheet.md @@ -0,0 +1,158 @@ +# @nearform/sql — API cheatsheet + +Reference for `@nearform/sql`. Import with `import SQL from '@nearform/sql'` +(ESM) or `const SQL = require('@nearform/sql')` (CommonJS). + +## Statement object + +Building a query with the `` SQL`` `` tag returns a **statement** with these getters: + +| Property | Type | Description | +|----------|------|-------------| +| `.text` | `string` | PostgreSQL form, placeholders `$1, $2, …`. Pass to `pg`. | +| `.sql` | `string` | MySQL form, placeholders `?, ?, …`. Pass to `mysql` / `mysql2`. | +| `.values` | `any[]` | The escaped/bound values, in order. | +| `.debug` | `string` | A formatted but **unsafe** statement with values inlined. For debugging/logging only — never execute it. | + +All three major drivers accept the statement object directly: + +```js +pgClient.query(sql) // reads .text + .values +mysqlConnection.query(sql) // reads .sql + .values +mysql2Connection.query(sql) // reads .sql + .values +``` + +## The `SQL` tag + +```js +SQL`SELECT * FROM users WHERE id = ${userId} AND active = ${true}` +``` + +Each `${value}` becomes a bound parameter. This is the only injection-safe way to put +runtime values into a query. + +## `SQL.glue(pieces, separator)` + +`glue(pieces: StatementLike[], separator: string): SqlStatement` + +Joins an array of statements with a separator. Useful for dynamic `SET`, `WHERE`, and bulk `VALUES`. + +```js +const updates = [] +updates.push(SQL`name = ${username}`) +updates.push(SQL`email = ${email}`) + +const sql = SQL`UPDATE users SET ${SQL.glue(updates, ' , ')} WHERE id = ${userId}` +``` + +Bulk insert: + +```js +const users = [ + { id: 1, name: 'something' }, + { id: 2, name: 'something-else' } +] + +const sql = SQL`INSERT INTO users (id, name) VALUES + ${SQL.glue( + users.map(user => SQL`(${user.id},${user.name})`), + ' , ' + )} +` +``` + +## `SQL.map(array, mapFunc?)` + +`map(array: T[], mapFunc?: (item: T) => unknown): SqlStatement` + +Expands an array into bound values. Ideal for `IN (...)` lists. + +```js +const ids = [1, 2, 3] +const values = SQL.map(ids) +const sql = SQL`SELECT * FROM users WHERE id IN (${values})` +``` + +With a custom mapper: + +```js +const objArray = [{ id: 1, name: 'name1' }, { id: 2, name: 'name2' }] +const values = SQL.map(objArray, (item) => item.id) +const sql = SQL`SELECT * FROM users WHERE id IN (${values})` +``` + +## `SQL.quoteIdent(value)` + +`quoteIdent(value: string): SqlStatement` + +Safely quotes an **identifier** (table/column/schema name). Mimics PostgreSQL's +`quote_ident` and MySQL's `quote_identifier`: + +- PostgreSQL: wraps in double quotes `"…"` with escaping. +- MySQL: wraps in backticks `` `…` `` with escaping. + +Use it whenever an identifier is dynamic — you cannot bind an identifier as a value. + +```js +const table = 'users' + +const sql = SQL` + UPDATE ${SQL.quoteIdent(table)} + SET username = ${username} + WHERE id = ${userId} +` +``` + +## `SQL.unsafe(value)` ⚠️ + +`unsafe(value: T): SqlStatement` + +Interpolates the value **literally**, as-is, into the query text — bypassing +parameterization. Returns a thin wrapper that the `` SQL`` `` tag recognizes and +inlines verbatim. + +> ⚠️ **Security:** `unsafe` interprets interpolated values as literals. It can introduce +> SQL-injection vulnerabilities. Use it **only** for values you fully control (constants, +> enums you validated), **never** for user input. + +```js +const username = 'john' +const userId = 1 + +const sql = SQL` + UPDATE users + SET username = '${SQL.unsafe(username)}' + WHERE id = ${userId} +` +``` + +## `append(statement, options?)` — DEPRECATED + +`append(statement: StatementLike, options?: { unsafe?: boolean }): SqlStatement` + +Appends to an existing statement. **Deprecated** — prefer nesting `` SQL`` `` tags instead: + +```js +// Preferred +const from = SQL`FROM users` +const sql = SQL`SELECT * ${from}` + +// Legacy (deprecated) +const sql = SQL`UPDATE users SET name = ${username}, email = ${email}` +sql.append(SQL`, login = ${dynamicName}`, { unsafe: true }) +sql.append(SQL`WHERE id = ${userId}`) +``` + +## Gotcha: no `undefined` + +The tag **throws on `undefined`** — `undefined` is a JavaScript concept, not a SQL one. +Coerce nullable fields to `null`: + +```js +const user = { name: 'foo bar' } // no `address` + +const sql = SQL` + INSERT INTO users (name, address) + VALUES (${user.name}, ${user.address || null}) +` +``` diff --git a/skills/nearform-sql/references/patterns.md b/skills/nearform-sql/references/patterns.md new file mode 100644 index 0000000..5285604 --- /dev/null +++ b/skills/nearform-sql/references/patterns.md @@ -0,0 +1,168 @@ +# @nearform/sql — patterns & integrations + +Recipes for using `@nearform/sql` in real applications. Examples use ESM +(`import SQL from '@nearform/sql'`); the equivalent CommonJS is `const SQL = require('@nearform/sql')`. + +## Fastify integration + +Register a `pg` pool as a decorator, then build queries with the tag inside your +route handlers or — better — a repository layer. Repositories own DB access; route +handlers stay thin and just call repository methods. + +```js +import fp from 'fastify-plugin' +import pg from 'pg' +import SQL from '@nearform/sql' + +const { Pool } = pg + +// plugins/pg.js — register the pool once +export default fp(async function (fastify) { + const pool = new Pool({ connectionString: process.env.DATABASE_URL }) + fastify.decorate('pg', pool) + fastify.addHook('onClose', async () => { await pool.end() }) +}, { name: 'pg' }) +``` + +```js +// repositories/users.js — DB access lives here +export default function makeUsersRepository ({ pg }) { + return { + async findById (id) { + const sql = SQL`SELECT id, email FROM users WHERE id = ${id}` + const { rows } = await pg.query(sql) // pg reads sql.text + sql.values + return rows[0] + }, + + async create ({ username, email }) { + const sql = SQL` + INSERT INTO users (username, email) + VALUES (${username}, ${email}) + RETURNING id + ` + const { rows } = await pg.query(sql) + return rows[0].id + } + } +} +``` + +Wire the repository onto the instance (after the `pg` plugin is registered), then keep the +route handler thin: + +```js +// plugins/users-repository.js — depends on the `pg` decorator above +import fp from 'fastify-plugin' +import makeUsersRepository from '../repositories/users.js' + +export default fp(async function (fastify) { + fastify.decorate('usersRepository', makeUsersRepository({ pg: fastify.pg })) +}, { dependencies: ['pg'] }) +``` + +```js +// routes/users.js — handler stays thin, schema validates input +fastify.get('/users/:id', { + schema: { params: { type: 'object', properties: { id: { type: 'integer' } } } } +}, async (req) => { + return fastify.usersRepository.findById(req.params.id) +}) +``` + +For MySQL (`mysql2/promise`) the only change is the driver call — the query building is +identical, the driver reads `sql.sql` + `sql.values`: + +```js +const [rows] = await connection.query(SQL`SELECT id FROM users WHERE email = ${email}`) +``` + +## Dynamic queries + +### Conditional WHERE + +Collect the conditions that apply, then `glue` them with `' AND '`. Skip the clause entirely +when there are no filters. + +```js +function searchUsers (pg, { name, minAge, active }) { + const filters = [] + // LIKE works on both pg and mysql; use ILIKE for case-insensitive matching on Postgres only. + if (name !== undefined) filters.push(SQL`name LIKE ${'%' + name + '%'}`) + if (minAge !== undefined) filters.push(SQL`age >= ${minAge}`) + if (active !== undefined) filters.push(SQL`active = ${active}`) + + const where = filters.length + ? SQL`WHERE ${SQL.glue(filters, ' AND ')}` + : SQL`` + + return pg.query(SQL`SELECT id, name FROM users ${where} ORDER BY name`) +} +``` + +### Dynamic UPDATE / SET + +```js +function buildUpdate (pg, id, patch) { + const sets = Object.entries(patch).map( + // `value ?? null` — the tag throws on `undefined`; coerce nullable fields to null. + ([col, value]) => SQL`${SQL.quoteIdent(col)} = ${value ?? null}` + ) + return pg.query(SQL`UPDATE users SET ${SQL.glue(sets, ' , ')} WHERE id = ${id}`) +} +``` + +> Note `SQL.quoteIdent(col)` for the column name (an identifier) and `${value ?? null}` (a +> bound value). Never bind an identifier as a value, and never interpolate a value as an identifier. + +### Bulk INSERT + +```js +const rows = [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' } +] + +const sql = SQL`INSERT INTO users (id, name) VALUES + ${SQL.glue(rows.map(r => SQL`(${r.id}, ${r.name})`), ' , ')} +` +``` + +### IN (...) lists + +```js +const ids = [1, 2, 3] +const sql = SQL`SELECT * FROM users WHERE id IN (${SQL.map(ids)})` +``` + +## Migrations & dynamic identifiers + +When a query references a dynamic schema, table, or column name, that name is an +**identifier**, not a value — bind it with `SQL.quoteIdent`: + +```js +function truncate (pg, table) { + return pg.query(SQL`TRUNCATE TABLE ${SQL.quoteIdent(table)}`) +} +``` + +`SQL.unsafe` interpolates raw text and is acceptable **only** for values you fully control — +e.g. a fixed constant or a value you have validated against an allow-list. It is never +acceptable for anything derived from user input: + +```js +// OK — direction is validated against an allow-list, not user-controlled +const ORDER = { asc: 'ASC', desc: 'DESC' } +const direction = ORDER[input] ?? 'ASC' +const sql = SQL`SELECT * FROM users ORDER BY created_at ${SQL.unsafe(direction)}` +``` + +## Anti-patterns to flag in review + +| ❌ Anti-pattern | ✅ Fix | +|----------------|--------| +| String concatenation: `'... WHERE id = ' + id` | `` SQL`... WHERE id = ${id}` `` | +| Untagged template: `` `... WHERE id = ${id}` `` | Add the `SQL` tag | +| `pg.query(sql.debug)` | `pg.query(sql)` — `.debug` is unsafe, logging only | +| `SQL.unsafe(userInput)` | Bind it: `${userInput}` (or `quoteIdent` if it's an identifier) | +| Identifier as a bound value: `` SQL`SELECT * FROM ${table}` `` | `` SQL`SELECT * FROM ${SQL.quoteIdent(table)}` `` | +| Passing `undefined`: `${user.address}` when it may be undefined | `${user.address || null}` |