Skip to content
Draft
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
10 changes: 10 additions & 0 deletions .changeset/drizzle-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@ts-safeql/plugin-drizzle": minor
---

Add `@ts-safeql/plugin-drizzle`, validating Drizzle's `sql` template tag
(`import { sql } from "drizzle-orm"`) and its statically resolvable helpers (`sql.raw`,
`sql.identifier`, `sql.placeholder`, nested fragments). Drizzle's fluent query builder and
column-object interpolation are out of scope. This plugin reuses SafeQL's existing
`onTarget` / `onExpression` hooks with no core changes, demonstrating that the plugin API is
library-agnostic.
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export default defineConfig({
text: "Advanced",
items: [
{ text: "Plugin API", link: "/guide/plugins" },
{ text: "Extending to other ORMs", link: "/plugins/extending-to-other-orms" },
{ text: "Incremental adoption", link: "/advanced/incremental-adoption" },
],
},
Expand Down
68 changes: 68 additions & 0 deletions docs/plugins/extending-to-other-orms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Extending SafeQL to other query libraries

SafeQL's analysis is **library-agnostic**. Everything library-specific lives in a
plugin; the SafeQL core only ever consumes a small, stable seam:

> a plugin turns a node of user code into **`{ kind: "sql"; text }`**, and SafeQL
> validates that SQL against the (shadow) database.

Nothing about Kysely, Drizzle, Prisma, or TypeORM is baked into the core. Building
Kysely support proved it: **embedded-sql validation** (`resolveQuery`) compiles a builder
chain — through the raw `sql` fragments embedded in it — to SQL **inside the Kysely plugin**
(its own `DummyDriver` sandbox) and hands SafeQL only the SQL text, with no change to the
core query seam.

This page maps the plugin hooks to each library so you can add a new one.

## The hooks

| Hook | Process | Purpose |
| --------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `onTarget` | rule (checker) | Decide whether a `TaggedTemplateExpression` is a query to validate, a fragment to skip, or not ours. |
| `onExpression` | rule (checker) | Translate each `${…}` interpolation inside a matched tag to a SQL fragment (`$N`, an identifier, an inlined literal, …). |
| `resolveQuery` + `queryNodeKinds` | rule (checker) | Validate non-tag nodes (e.g. fluent builder `CallExpression`s). The plugin produces the SQL itself (statically, or by compiling in its own sandbox). |
| `migrate` | worker | Build the shadow database from the project's migrations (TS migrations, a CLI, …) instead of the built-in `.sql` runner. |
| `createConnection` | worker | Provide a custom database connection. |

Rule-only hooks (`onTarget`, `onExpression`, `resolveQuery`) run in
the ESLint process and may use the TypeScript checker. Worker hooks (`migrate`,
`createConnection`) run in the synckit worker and must never receive checker-bound values.

## Drizzle (`@ts-safeql/plugin-drizzle`)

Validates Drizzle's `sql` template tag (`import { sql } from "drizzle-orm"`):

- `onTarget` — recognises the `drizzle-orm` `sql` symbol and validates a tag when it is a
standalone query (incl. `db.execute(sql\`…\`)`); a tag passed to a fragment method
(`.where`, `.having`, `.orderBy`, …) or used via `.as()` is skipped.
- `onExpression` — `sql.raw(static)` is inlined, `sql.identifier(static)` is quoted,
`sql.placeholder()` and plain **primitive** interpolations become bound params, nested
`sql` fragments recurse. A non-primitive interpolation (a Drizzle column/table object,
whose SQL can't be reconstructed statically) skips the query rather than guessing.

Drizzle's fluent builder (`db.select().from(…)`) is out of scope; it would follow the
same `resolveQuery` pattern as Kysely's builder support, compiling via Drizzle's own
`.toSQL()` instead of Kysely's `DummyDriver`.

## Prisma

Prisma's raw queries are tagged templates from `@prisma/client`
(`prisma.$queryRaw\`…\``, with `Prisma.sql`/`Prisma.raw`/`Prisma.join`helpers) — the
same shape as Drizzle/Kysely`sql`tags. A Prisma plugin implements`onTarget`(recognise the`$queryRaw`/`$executeRaw`tag) and`onExpression`(translate the`Prisma.\*`
helpers), reusing this page's pattern verbatim.

## TypeORM

TypeORM already exercises the **generic migration abstraction**: a TypeORM plugin
implements only the `migrate` hook to run TypeORM TS migrations against the shadow
database (exactly as the Kysely plugin runs Kysely migrations). Its `EntityManager.query`
takes a plain SQL **string** rather than a tag, so a TypeORM plugin would claim the `query`
call by declaring a `CallExpression` query selector and validate it with `resolveQuery`
rather than `onTarget`. Routing the call is straightforward; validating a plain-string
argument — type annotations, `$1` parameters, dynamically-assembled text — is a separate
concern left to the plugin.

## Summary

Adding a library is "implement the hooks for that library", never "change SafeQL". The
core seam — `{ kind: "sql"; text }` plus DB introspection — is shared by every plugin.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"devDependencies": {
"@changesets/cli": "catalog:",
"@eslint/js": "catalog:",
"drizzle-orm": "^0.45.2",
"prettier": "catalog:",
"turbo": "catalog:",
"typescript": "catalog:",
Expand Down
20 changes: 20 additions & 0 deletions packages/plugins/drizzle/build.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineBuildConfig } from "unbuild";

export default defineBuildConfig([
{
entries: ["src/index"],
declaration: true,
sourcemap: true,
rollup: {
emitCJS: true,
},
externals: [
"@ts-safeql/plugin-utils",
"@typescript-eslint/utils",
"drizzle-orm",
"postgres",
"tsx",
"typescript",
],
},
]);
50 changes: 50 additions & 0 deletions packages/plugins/drizzle/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@ts-safeql/plugin-drizzle",
"version": "0.0.0",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/ts-safeql/safeql.git",
"directory": "packages/plugins/drizzle"
},
"files": [
"dist",
"package.json"
],
"type": "module",
"types": "dist/index.d.ts",
"module": "dist/index.mjs",
"main": "dist/index.cjs",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "unbuild",
"dev": "unbuild --stub",
"typecheck": "tsc -b",
"test": "vitest",
"clean": "rm -rf dist"
},
"devDependencies": {
"@ts-safeql/eslint-plugin": "workspace:*",
"@ts-safeql/plugin-utils": "workspace:*",
"@ts-safeql/test-utils": "workspace:*",
"@types/node": "catalog:",
"@typescript-eslint/parser": "catalog:",
"@typescript-eslint/rule-tester": "catalog:",
"@typescript-eslint/utils": "catalog:",
"drizzle-orm": "^0.45.2",
"postgres": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:",
"unbuild": "catalog:",
"vitest": "catalog:"
},
"peerDependencies": {
"drizzle-orm": ">=0.30.0"
}
}
1 change: 1 addition & 0 deletions packages/plugins/drizzle/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from "./plugin";
98 changes: 98 additions & 0 deletions packages/plugins/drizzle/src/plugin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterAll, describe, expect, it } from "vitest";
import { PluginTestDriver, type ToSQLResult } from "@ts-safeql/plugin-utils/testing";
import plugin from "./plugin";

const driver = new PluginTestDriver({
plugin: plugin.factory({}),
projectDir: process.cwd(),
});

afterAll(() => driver.teardown());

type Case = { name: string; source: string; output: ToSQLResult };

const imp = (code: string) =>
`import { sql } from "drizzle-orm"; declare const db: { execute: (q: unknown) => unknown }; ${code}`;

const targetCases: Case[] = [
{
name: "standalone sql tag is validated",
source: imp("sql`select 1`"),
output: { sql: "select 1" },
},
{
name: "sql tag inside db.execute is validated",
source: imp("db.execute(sql`select 1`)"),
output: { sql: "select 1" },
},
{
name: "sql fragment passed to .where is skipped",
source: imp("declare const qb: { where: (q: unknown) => unknown }; qb.where(sql`id = 1`)"),
output: { skipped: true },
},
{
name: ".as() selection fragment is skipped",
source: imp("sql`first_name`.as('n')"),
output: { skipped: true },
},
{
name: ".mapWith() selection fragment is skipped",
source: imp("sql`count(*)`.mapWith(Number)"),
output: { skipped: true },
},
{
name: "a sql tag NOT imported from drizzle is ignored",
source: "const sql = (s: TemplateStringsArray) => s; sql`select 1`",
output: { skipped: true },
},
];

const expressionCases: Case[] = [
{
name: "plain primitive interpolation -> positional param",
source: imp("const id = 123; sql`select * from person where id = ${id}`"),
output: { sql: "select * from person where id = $1" },
},
{
name: "sql.raw static -> inlined",
source: imp("sql`select * from person where ${sql.raw('age > 18')}`"),
output: { sql: "select * from person where age > 18" },
},
{
name: "sql.raw dynamic -> skip",
source: imp("declare const cond: string; sql`select * from person where ${sql.raw(cond)}`"),
output: { sql: "select * from person where /* skipped */" },
},
{
name: "sql.identifier -> quoted identifier",
source: imp("sql`select ${sql.identifier('first_name')} from person`"),
output: { sql: 'select "first_name" from person' },
},
{
name: "sql.placeholder -> positional param",
source: imp("sql`select ${sql.placeholder('id')}`"),
output: { sql: "select $N" },
},
{
name: "non-primitive (object) interpolation -> skip",
source: imp("sql`select ${{ a: 1 }}`"),
output: { sql: "select /* skipped */" },
},
{
name: "nested sql fragment is spliced in",
source: imp("sql`select * from person where ${sql`age > ${18}`}`"),
output: { sql: "select * from person where age > $N" },
},
];

describe("drizzle plugin — onTarget", () => {
for (const c of targetCases) {
it(c.name, () => expect(driver.toSQL(c.source)).toEqual(c.output));
}
});

describe("drizzle plugin — onExpression", () => {
for (const c of expressionCases) {
it(c.name, () => expect(driver.toSQL(c.source)).toEqual(c.output));
}
});
Loading
Loading