Skip to content

Commit aa7f14c

Browse files
committed
feat: add Slonik plugin with Zod validation and SQL helper translation
1 parent 00b9904 commit aa7f14c

29 files changed

+2354
-147
lines changed

.changeset/slonik-plugin-public.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@ts-safeql/eslint-plugin": patch
3+
"@ts-safeql/plugin-utils": patch
4+
"@ts-safeql/zod-annotator": patch
5+
"@ts-safeql/plugin-slonik": minor
6+
---
7+
8+
Publish the experimental Slonik plugin and tighten plugin resolution behavior.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// @ts-check
2+
3+
import safeql from "@ts-safeql/eslint-plugin/config";
4+
import slonik from "@ts-safeql/plugin-slonik";
5+
import tseslint from "typescript-eslint";
6+
7+
export default tseslint.config({
8+
files: ["src/**/*.ts"],
9+
languageOptions: {
10+
parser: tseslint.parser,
11+
parserOptions: {
12+
projectService: true,
13+
},
14+
},
15+
extends: [
16+
safeql.configs.connections({
17+
databaseUrl: "postgres://postgres:postgres@localhost:5432/postgres",
18+
plugins: [slonik()],
19+
}),
20+
],
21+
});

demos/plugin-slonik/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@ts-safeql-demos/plugin-slonik",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"build": "tsc",
8+
"lint": "eslint src"
9+
},
10+
"devDependencies": {
11+
"@eslint/js": "catalog:",
12+
"@slonik/pg-driver": "^48.13.2",
13+
"@types/node": "catalog:",
14+
"eslint": "catalog:",
15+
"slonik": "^48.13.2",
16+
"typescript": "catalog:",
17+
"typescript-eslint": "catalog:",
18+
"zod": "^4.3.6"
19+
},
20+
"dependencies": {
21+
"@ts-safeql/eslint-plugin": "workspace:*",
22+
"@ts-safeql/plugin-slonik": "workspace:*"
23+
}
24+
}

demos/plugin-slonik/src/index.ts

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { createPool, sql } from "slonik";
2+
import { createPgDriverFactory } from "@slonik/pg-driver";
3+
import { z } from "zod";
4+
5+
const pool = await createPool("postgres://", { driverFactory: createPgDriverFactory() });
6+
7+
section("sql.type — Zod schema validates result", () => {
8+
example("inline schema", () =>
9+
pool.one(sql.type(z.object({ id: z.number(), version: z.string() }))`
10+
SELECT oid::int4 AS id, typname::text AS version FROM pg_type LIMIT 1
11+
`),
12+
);
13+
14+
example("referenced schema variable", () => {
15+
const TypeRow = z.object({ id: z.number(), version: z.string() });
16+
pool.one(sql.type(TypeRow)`
17+
SELECT oid::int4 AS id, typname::text AS version FROM pg_type LIMIT 1
18+
`);
19+
});
20+
21+
example("nullable column", () =>
22+
pool.one(sql.type(z.object({ v: z.string().nullable() }))`SELECT NULL::text AS v`),
23+
);
24+
25+
example("multiple columns with mixed types", () =>
26+
pool.one(sql.type(z.object({ n: z.number(), s: z.string(), b: z.boolean() }))`
27+
SELECT 1::int4 AS n, 'hello'::text AS s, true AS b
28+
`),
29+
);
30+
});
31+
32+
section("sql.typeAlias — alias→schema is runtime-only", () => {
33+
example("validates SQL but skips type annotations", () =>
34+
pool.query(sql.typeAlias("id")`SELECT 1 AS id`),
35+
);
36+
});
37+
38+
section("sql.unsafe — opt-out of type checking", () => {
39+
example("no type annotation needed", () => pool.query(sql.unsafe`SELECT 1`));
40+
});
41+
42+
section("sql.identifier", () => {
43+
example("single name", () =>
44+
pool.query(sql.unsafe`SELECT typname::text FROM ${sql.identifier(["pg_type"])} LIMIT 1`),
45+
);
46+
47+
example("schema-qualified", () =>
48+
pool.one(sql.type(z.object({ oid: z.number() }))`
49+
SELECT oid::int4 AS oid FROM ${sql.identifier(["pg_catalog", "pg_type"])} LIMIT 1
50+
`),
51+
);
52+
});
53+
54+
section("sql.json / sql.jsonb", () => {
55+
example("sql.json", () =>
56+
pool.one(sql.type(z.object({ p: z.string().nullable() }))`
57+
SELECT ${sql.json({ id: 1 })}::jsonb ->> 'id' AS p
58+
`),
59+
);
60+
61+
example("sql.jsonb", () =>
62+
pool.one(sql.type(z.object({ p: z.string().nullable() }))`
63+
SELECT ${sql.jsonb([1, 2, 3])}::jsonb ->> 0 AS p
64+
`),
65+
);
66+
});
67+
68+
section("sql.binary", () => {
69+
example("bytea parameter", () =>
70+
pool.query(sql.unsafe`SELECT ${sql.binary(Buffer.from("foo"))}`),
71+
);
72+
});
73+
74+
section("sql.date / sql.timestamp / sql.interval", () => {
75+
example("sql.date", () =>
76+
pool.one(sql.type(z.object({ d: z.string().nullable() }))`
77+
SELECT ${sql.date(new Date("2022-08-19T03:27:24.951Z"))}::text AS d
78+
`),
79+
);
80+
81+
example("sql.timestamp", () =>
82+
pool.one(sql.type(z.object({ d: z.string().nullable() }))`
83+
SELECT ${sql.timestamp(new Date("2022-08-19T03:27:24.951Z"))}::text AS d
84+
`),
85+
);
86+
87+
example("sql.interval", () =>
88+
pool.one(sql.type(z.object({ i: z.string().nullable() }))`
89+
SELECT ${sql.interval({ days: 3 })}::text AS i
90+
`),
91+
);
92+
});
93+
94+
section("sql.uuid", () => {
95+
example("uuid parameter", () =>
96+
pool.one(sql.type(z.object({ u: z.string().nullable() }))`
97+
SELECT ${sql.uuid("00000000-0000-0000-0000-000000000000")}::text AS u
98+
`),
99+
);
100+
});
101+
102+
section("sql.array", () => {
103+
example("typed array", () =>
104+
pool.one(sql.type(z.object({ a: z.number().nullable() }))`
105+
SELECT ${sql.array([1, 2, 3], "int4")} AS a
106+
`),
107+
);
108+
109+
example("ANY() pattern from README", () =>
110+
pool.query(sql.typeAlias("id")`
111+
SELECT oid::int4 AS id FROM pg_type
112+
WHERE oid = ANY(${sql.array([1, 2, 3], "int4")})
113+
`),
114+
);
115+
});
116+
117+
section("sql.join — too dynamic, query skipped", () => {
118+
example("comma-separated values", () =>
119+
pool.query(sql.unsafe`SELECT ${sql.join([1, 2, 3], sql.fragment`, `)}`),
120+
);
121+
122+
example("boolean expressions", () =>
123+
pool.query(sql.unsafe`SELECT ${sql.join([1, 2], sql.fragment` AND `)}`),
124+
);
125+
126+
example("tuple list", () =>
127+
pool.query(sql.unsafe`
128+
SELECT ${sql.join(
129+
[
130+
sql.fragment`(${sql.join([1, 2], sql.fragment`, `)})`,
131+
sql.fragment`(${sql.join([3, 4], sql.fragment`, `)})`,
132+
],
133+
sql.fragment`, `,
134+
)}
135+
`),
136+
);
137+
});
138+
139+
section("sql.unnest — too dynamic, query skipped", () => {
140+
example("bulk insert with string type names", () =>
141+
pool.query(sql.unsafe`
142+
SELECT bar, baz
143+
FROM ${sql.unnest(
144+
[
145+
[1, "foo"],
146+
[2, "bar"],
147+
],
148+
["int4", "text"],
149+
)} AS foo(bar, baz)
150+
`),
151+
);
152+
});
153+
154+
section("sql.literalValue — too dynamic, query skipped", () => {
155+
example("raw literal interpolation", () =>
156+
pool.query(sql.unsafe`SELECT ${sql.literalValue("foo")}`),
157+
);
158+
});
159+
160+
section("sql.fragment — composable pieces", () => {
161+
example("standalone fragment (not linted)", () => {
162+
sql.fragment`WHERE 1 = 1`;
163+
});
164+
165+
example("fragment as expression (query skipped)", () => {
166+
const where = sql.fragment`WHERE typname = ${"bool"}`;
167+
pool.query(sql.unsafe`SELECT typname FROM pg_type ${where}`);
168+
});
169+
170+
example("nested fragments", () => {
171+
const cond = sql.fragment`typname = ${"bool"}`;
172+
const where = sql.fragment`WHERE ${cond}`;
173+
pool.query(sql.unsafe`SELECT typname FROM pg_type ${where}`);
174+
});
175+
});
176+
177+
section("value placeholders — plain variables", () => {
178+
example("plain value becomes $N parameter", () => {
179+
const name = "bool";
180+
pool.query(sql.unsafe`SELECT typname FROM pg_type WHERE typname = ${name}`);
181+
});
182+
});
183+
184+
section("invalid cases SafeQL catches", () => {
185+
example("bad column", () => {
186+
// eslint-disable-next-line @ts-safeql/check-sql -- column "nonexistent" does not exist
187+
pool.query(sql.unsafe`SELECT nonexistent FROM pg_type`);
188+
});
189+
190+
example("bad table", () => {
191+
// eslint-disable-next-line @ts-safeql/check-sql -- relation "nonexistent" does not exist
192+
pool.query(sql.type(z.object({}))`SELECT 1 FROM nonexistent`);
193+
});
194+
195+
example("wrong zod schema", () => {
196+
// eslint-disable-next-line @ts-safeql/check-sql -- Expected: z.object({ id: z.number() })
197+
pool.one(sql.type(z.object({ id: z.string() }))`SELECT 1::int4 AS id`);
198+
});
199+
});
200+
201+
function section(_: string, fn: () => void) {
202+
fn();
203+
}
204+
function example(_: string, fn: () => void) {
205+
fn();
206+
}

demos/plugin-slonik/tsconfig.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends": "../../tsconfig.node.json",
3+
"compilerOptions": {
4+
"outDir": "./dist"
5+
}
6+
}

docs/compatibility/postgres.js.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,4 @@ const query = sql`SELECT id FROM users`
8686

8787
// After: ✅
8888
const query = sql<{ id: number; }[]>`SELECT id FROM users`
89-
```
89+
```

0 commit comments

Comments
 (0)