Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/slonik-plugin-public.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@ts-safeql/eslint-plugin": patch
"@ts-safeql/plugin-utils": patch
"@ts-safeql/zod-annotator": patch
"@ts-safeql/plugin-slonik": minor
---

Publish the experimental Slonik plugin and tighten plugin resolution behavior.
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ pnpm --filter <pkg> test -- --run # single package tests
## References
- [Creating a New Package](.agents/docs/creating-package.md)
- [Authoring a Plugin](.agents/docs/authoring-plugin.md)
- [Coding Guidelines](.agents/docs/coding-guidelines.md)
21 changes: 21 additions & 0 deletions demos/plugin-slonik/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// @ts-check

import safeql from "@ts-safeql/eslint-plugin/config";
import slonik from "@ts-safeql/plugin-slonik";
import tseslint from "typescript-eslint";

export default tseslint.config({
files: ["src/**/*.ts"],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
projectService: true,
},
},
extends: [
safeql.configs.connections({
databaseUrl: "postgres://postgres:postgres@localhost:5432/postgres",
plugins: [slonik()],
}),
],
});
24 changes: 24 additions & 0 deletions demos/plugin-slonik/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@ts-safeql-demos/plugin-slonik",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"lint": "eslint src"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@slonik/pg-driver": "^48.13.2",
"@types/node": "catalog:",
"eslint": "catalog:",
"slonik": "^48.13.2",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"zod": "^4.3.6"
},
"dependencies": {
"@ts-safeql/eslint-plugin": "workspace:*",
"@ts-safeql/plugin-slonik": "workspace:*"
}
}
176 changes: 176 additions & 0 deletions demos/plugin-slonik/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { createPool, sql } from "slonik";
import { createPgDriverFactory } from "@slonik/pg-driver";
import { z } from "zod";

const pool = await createPool("postgres://", { driverFactory: createPgDriverFactory() });

section("sql.type — Zod schema validates result", () => {
// inline schema
pool.one(sql.type(z.object({ id: z.number(), version: z.string() }))`
SELECT oid::int4 AS id, typname::text AS version FROM pg_type LIMIT 1
`);

// referenced schema variable
const TypeRow = z.object({ id: z.number(), version: z.string() });
pool.one(sql.type(TypeRow)`
SELECT oid::int4 AS id, typname::text AS version FROM pg_type LIMIT 1
`);

// nullable column
pool.one(sql.type(z.object({ v: z.string().nullable() }))`SELECT NULL::text AS v`);

// multiple columns with mixed types
pool.one(sql.type(z.object({ n: z.number(), s: z.string(), b: z.boolean() }))`
SELECT 1::int4 AS n, 'hello'::text AS s, true AS b
`);
});

section("sql.typeAlias — alias→schema is runtime-only", () => {
// validates SQL but skips type annotations
pool.query(sql.typeAlias("id")`SELECT 1 AS id`);
});

section("sql.unsafe — opt-out of type checking", () => {
// no type annotation needed
pool.query(sql.unsafe`SELECT 1`);
});

section("sql.identifier", () => {
// single name
pool.query(sql.unsafe`SELECT typname::text FROM ${sql.identifier(["pg_type"])} LIMIT 1`);

// schema-qualified
pool.one(sql.type(z.object({ oid: z.number() }))`
SELECT oid::int4 AS oid FROM ${sql.identifier(["pg_catalog", "pg_type"])} LIMIT 1
`);
});

section("sql.json / sql.jsonb", () => {
// sql.json
pool.one(sql.type(z.object({ p: z.string().nullable() }))`
SELECT ${sql.json({ id: 1 })}::jsonb ->> 'id' AS p
`);

// sql.jsonb
pool.one(sql.type(z.object({ p: z.string().nullable() }))`
SELECT ${sql.jsonb([1, 2, 3])}::jsonb ->> 0 AS p
`);
});

section("sql.binary", () => {
// bytea parameter
pool.query(sql.unsafe`SELECT ${sql.binary(Buffer.from("foo"))}`);
});

section("sql.date / sql.timestamp / sql.interval", () => {
// sql.date
pool.one(sql.type(z.object({ d: z.string().nullable() }))`
SELECT ${sql.date(new Date("2022-08-19T03:27:24.951Z"))}::text AS d
`);

// sql.timestamp
pool.one(sql.type(z.object({ d: z.string().nullable() }))`
SELECT ${sql.timestamp(new Date("2022-08-19T03:27:24.951Z"))}::text AS d
`);

// sql.interval
pool.one(sql.type(z.object({ i: z.string().nullable() }))`
SELECT ${sql.interval({ days: 3 })}::text AS i
`);
});

section("sql.uuid", () => {
// uuid parameter
pool.one(sql.type(z.object({ u: z.string().nullable() }))`
SELECT ${sql.uuid("00000000-0000-0000-0000-000000000000")}::text AS u
`);
});

section("sql.array", () => {
// typed array
pool.one(sql.type(z.object({ a: z.number().nullable() }))`
SELECT ${sql.array([1, 2, 3], "int4")} AS a
`);

// ANY() pattern from README
pool.query(sql.typeAlias("id")`
SELECT oid::int4 AS id FROM pg_type
WHERE oid = ANY(${sql.array([1, 2, 3], "int4")})
`);
});

section("sql.join — too dynamic, query skipped", () => {
// comma-separated values
pool.query(sql.unsafe`SELECT ${sql.join([1, 2, 3], sql.fragment`, `)}`);

// boolean expressions
pool.query(sql.unsafe`SELECT ${sql.join([1, 2], sql.fragment` AND `)}`);

// tuple list
pool.query(sql.unsafe`
SELECT ${sql.join(
[
sql.fragment`(${sql.join([1, 2], sql.fragment`, `)})`,
sql.fragment`(${sql.join([3, 4], sql.fragment`, `)})`,
],
sql.fragment`, `,
)}
`);
});

section("sql.unnest — too dynamic, query skipped", () => {
// bulk insert with string type names
pool.query(sql.unsafe`
SELECT bar, baz
FROM ${sql.unnest(
[
[1, "foo"],
[2, "bar"],
],
["int4", "text"],
)} AS foo(bar, baz)
`);
});

section("sql.literalValue — too dynamic, query skipped", () => {
// raw literal interpolation
pool.query(sql.unsafe`SELECT ${sql.literalValue("foo")}`);
});

section("sql.fragment — composable pieces", () => {
// standalone fragment (not linted)
sql.fragment`WHERE 1 = 1`;

// fragment as expression (query skipped)
const whereFragment = sql.fragment`WHERE typname = ${"bool"}`;
pool.query(sql.unsafe`SELECT typname FROM pg_type ${whereFragment}`);

// nested fragments
const nestedCondition = sql.fragment`typname = ${"bool"}`;
const nestedWhereFragment = sql.fragment`WHERE ${nestedCondition}`;
pool.query(sql.unsafe`SELECT typname FROM pg_type ${nestedWhereFragment}`);
});

section("value placeholders — plain variables", () => {
// plain value becomes $N parameter
const name = "bool";
pool.query(sql.unsafe`SELECT typname FROM pg_type WHERE typname = ${name}`);
});

section("invalid cases SafeQL catches", () => {
// bad column
// eslint-disable-next-line @ts-safeql/check-sql -- column "nonexistent" does not exist
pool.query(sql.unsafe`SELECT nonexistent FROM pg_type`);

// bad table
// eslint-disable-next-line @ts-safeql/check-sql -- relation "nonexistent" does not exist
pool.query(sql.type(z.object({}))`SELECT 1 FROM nonexistent`);

// wrong zod schema
// eslint-disable-next-line @ts-safeql/check-sql -- Expected: z.object({ id: z.number() })
pool.one(sql.type(z.object({ id: z.string() }))`SELECT 1::int4 AS id`);
});

function section(_: string, fn: () => void) {
fn();
}
6 changes: 6 additions & 0 deletions demos/plugin-slonik/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "../../tsconfig.node.json",
"compilerOptions": {
"outDir": "./dist"
}
}
2 changes: 1 addition & 1 deletion docs/compatibility/postgres.js.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,4 @@ const query = sql`SELECT id FROM users`

// After: ✅
const query = sql<{ id: number; }[]>`SELECT id FROM users`
```
```
105 changes: 84 additions & 21 deletions docs/compatibility/slonik.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,85 @@ layout: doc

# SafeQL :heart: Slonik

SafeQL is compatible with [Slonik](https://github.com/gajus/slonik) as well with a few setting tweaks.
SafeQL is compatible with [Slonik](https://github.com/gajus/slonik) with full support for Slonik's SQL helpers and Zod schema validation.

## Using the Slonik Plugin (Experimental)

::: warning EXPERIMENTAL
The Slonik plugin is experimental and may change in future releases.
:::

```bash
npm install @ts-safeql/plugin-slonik
```

```js
// eslint.config.js
import safeql from "@ts-safeql/eslint-plugin/config";
import slonik from "@ts-safeql/plugin-slonik";
import tseslint from "typescript-eslint";

export default tseslint.config(
// ...
safeql.configs.connections({
databaseUrl: "postgres://user:pass@localhost:5432/db",
plugins: [slonik()],
}),
);
```

The Slonik plugin defaults `sql.type(...)` schema mismatches to suggestions instead of autofix.
If you want `--fix` to rewrite the schema automatically, set `enforceType: "fix"` in the connection config.

### Zod Schema Validation

SafeQL validates your Zod schemas against the actual query results:

```typescript
import { z } from "zod";
import { sql } from "slonik";

// Wrong field type → suggestion by default
const query = sql.type(z.object({ id: z.string() }))`SELECT id FROM users`;
// ~~~~~~~~~~
// Error: Zod schema does not match query result.
// Expected: z.object({ id: z.number() })

// Correct ✅
const query = sql.type(z.object({ id: z.number() }))`SELECT id FROM users`;
```

### Fragment Embedding

Fragment variables are automatically inlined:

```typescript
const where = sql.fragment`WHERE id = 1`;
const query = sql.unsafe`SELECT * FROM users ${where}`;
// Analyzed as: SELECT * FROM users WHERE id = 1
```

### Support Matrix

Legend: `✅` supported, `⚠️` partial support, `❌` unsupported.

| Library syntax | Support | Notes |
| -------------- | ------- | ----- |
| `sql.unsafe` | ✅ | Validated as a query, type annotations skipped |
| `sql.type(schema)` | ✅ | Validated as a query, Zod schema checked against DB result types; suggestions by default, autofix with `enforceType: "fix"` |
| `sql.typeAlias("name")` | ✅ | Validated as a query, type annotations skipped |
| Embedded fragment variables like `${sql.fragment\`...\`}` | ✅ | Inlined into the outer query |
| Standalone `sql.fragment` | ⚠️ | Intentionally skipped because it is not a complete query on its own |
| `sql.identifier(["schema", "table"])` | ✅ | Inlined as escaped identifiers |
| `sql.json(...)`, `sql.jsonb(...)`, `sql.binary(...)`, `sql.date(...)`, `sql.timestamp(...)`, `sql.interval(...)`, `sql.uuid(...)` | ✅ | Rewritten to typed SQL placeholders |
| `sql.array([...], "type")` | ✅ | Rewritten as `type[]` |
| `sql.unnest([...], ["type1", "type2"])` | ✅ | Rewritten as `unnest(type1[], type2[])` |
| `sql.literalValue("foo")` | ✅ | Inlined as a SQL literal |
| `sql.join(...)` | ❌ | Query is skipped because the composition is too dynamic to analyze safely |

## Manual Configuration

If you prefer not to use the plugin, you can configure SafeQL manually:

::: tabs key:eslintrc

Expand All @@ -22,14 +100,11 @@ export default tseslint.config(
// ... (read more about configuration in the API docs)
targets: [
{
// This will lint syntax that matches "sql.typeAlias()`...`", "sql.type()`...`" or "sql.unsafe`...`"
tag: "sql.+(type\\(*\\)|typeAlias\\(*\\)|unsafe)",
// this will tell SafeQL to not suggest type annotations
// since we will be using our Zod schemas in slonik
skipTypeAnnotations: true,
},
],
})
}),
);
```

Expand Down Expand Up @@ -63,11 +138,7 @@ export default tseslint.config(
// ... (read more about configuration in the API docs)
"targets": [
{
// This will lint syntax that matches
// "sql.type`...`" or "sql.unsafe`...`"
"tag": "sql.+(type\\(*\\)|unsafe)",
// this will tell safeql to not suggest type annotations
// since we will be using our Zod schemas in slonik
"skipTypeAnnotations": true
}
]
Expand All @@ -79,16 +150,8 @@ export default tseslint.config(
}
```

Once you've set up your configuration, you can start linting your queries:
:::

```typescript
import { z } from 'zod';
import { sql } from 'slonik';

// Before:
const query = sql.type(z.object({ id: z.number() }))`SELECT idd FROM users`;
~~~ Error: column "idd" does not exist // [!code error]

// After: ✅
const query = sql.type(z.object({ id: z.number() }))`SELECT id FROM users`;
```
::: warning Manual Configuration Limitations
The manual approach doesn't support Zod schema validation, helper translation, or fragment inlining. For full Slonik support, use the plugin.
:::
Loading
Loading