Skip to content

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. This was proven twice while building Kysely support:

  • 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.
  • Schema-drift validation (check-schema) added an entirely new validation mode via the resolveSchemaType hook + an introspectSchema worker endpoint without a single change to the core query seam.

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

The hooks

HookProcessPurpose
onTargetrule (checker)Decide whether a TaggedTemplateExpression is a query to validate, a fragment to skip, or not ours.
onExpressionrule (checker)Translate each ${…} interpolation inside a matched tag to a SQL fragment ($N, an identifier, an inlined literal, …).
resolveQuery + queryNodeKindsrule (checker)Validate non-tag nodes (e.g. fluent builder CallExpressions). The plugin produces the SQL itself (statically, or by compiling in its own sandbox).
resolveSchemaTyperule (checker)Locate + interpret a library schema type (e.g. a Kysely Database interface) for check-schema.
migrateworkerBuild the shadow database from the project's migrations (TS migrations, a CLI, …) instead of the built-in .sql runner.
createConnectionworkerProvide a custom database connection.

Rule-only hooks (onTarget, onExpression, resolveQuery, resolveSchemaType) 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.
  • onExpressionsql.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.joinhelpers) — the same shape as Drizzle/Kyselysqltags. A Prisma plugin implementsonTarget(recognise the$queryRaw/$executeRawtag) andonExpression(translate thePrisma.*` 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 query validation would use resolveQuery over a CallExpression (à la the builder pre-filter) rather than onTarget.

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.