SafeQL

repository·main·Indexed 23 days ago

https://github.com/ts-safeql/safeql

A tool for automatic type inference and validation of PostgreSQL queries in TypeScript. It integrates with ESLint via @ts-safeql/eslint-plugin to infer result types directly from SQL queries and supports various PostgreSQL clients including Prisma, Sequelize, pg, and postgres.js. It features support for multiple database connections, monorepos, and AWS IAM authentication.

Tokens
43K
Snippets
101
Records
194
Agent score
82%

What's inside SafeQL

  1. What is SafeQL?

    main

    SafeQL is an ESLint plugin designed to make writing PostgreSQL queries safer within your TypeScript code. It provides static analysis to catch common SQL errors during development rather than at runtime.

    Key capabilities include:

    • Schema Validation: Detects misspelled tables, columns, or functions.
    • Type Safety: Identifies type mismatches between TypeScript variables and SQL operators (e.g., comparing a string to an integer).
    • Type Annotation Enforcement: Warns when query TypeScript types are missing or incorrect and suggests fixes.
  2. Overview of SafeQL features

    main

    SafeQL provides automatic type inference and validation for PostgreSQL queries.

    Key capabilities include:

    • Automatic Type Inference & Validation: Infers the type of query results directly from the SQL query itself.
    • Library Compatibility: Works with various PostgreSQL clients such as Prisma, Sequelize, pg, and postgres.js.
    • Scalability: Designed for use in monorepos and microservices, supporting multiple database connections.
  3. Prerequisites for AWS IAM authentication

    main

    Before using @ts-safeql/plugin-auth-aws, ensure the following requirements are met:

    1. RDS Configuration: Your RDS instance must have IAM database authentication enabled.
    2. Database Permissions: The database user must be granted the rds_iam role.
    3. AWS Credentials: Valid AWS credentials must be available via an AWS profile, environment variables, or IMDS.

    Note for SSO users: If you are using AWS SSO, you must run aws sso login --profile <your-profile> before running the linting process.

  4. How SafeQL validates Kysely SQL tags

    main

    By default, SafeQL validates standalone Kysely sql template tags (e.g., sql<T>...``. It checks the resulting SQL against your database and ensures the returned row type matches the generic type <T> provided in the tag.

    Supported Kysely expressions include:

    • sql.val(...) or plain ${value} (treated as bound parameters)
    • sql.ref(...), sql.id(...), sql.table(...) (inlined as quoted identifiers for static values)
    • sql.lit(...) (inlined as a SQL literal)
    • sql.join([...]) (expanded to positional placeholders for static arrays)
    • Nested sql fragments (inlined into the outer query)

    Note: sql.raw(...) is only supported when the argument is a static string; otherwise, the query is skipped.

    import { sql } from "kysely";
    
    // Validated against the database; the result row type is checked against <T>.
    const rows = await sql<{ id: number; name: string }>`SELECT id, name FROM person`.execute(db);
    
    // Wrong type → reported (and auto-fixed with `--fix`)
    const bad = await sql<{ id: string }>`SELECT id FROM person`.execute(db);
    //                       ~~~~~~~~~~
    // Error: Incorrect type annotation. Expected: { id: number }
  5. How Slonik fragment embedding works

    main

    When using Slonik, fragment variables are automatically inlined by SafeQL during analysis. This allows SafeQL to see the complete query structure even when parts are defined as fragments.

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

    main

    The sql tag leverages ES6 Tagged Templates to solve the problem of manual query parameterization. Instead of manually separating variables from strings to avoid SQL injection, the sql tag intercepts the template literal (a mix of strings and expressions) and returns a structured object.

    For example, sql SELECT * FROM users WHERE id = ${userId} is converted at runtime into an object like:

    {
      "query": "SELECT * FROM users WHERE id = $1",
      "values": [userId]
    }

    This allows developers to write natural SQL while the library handles the security of parameterization automatically.

  7. How SafeQL integrates with existing SQL libraries

    main

    SafeQL is not a replacement for your existing SQL library (such as Prisma, Sequelize, pg, or postgres). Instead, it is an ESLint plugin that adds a layer of safety to your existing setup.

    Because it functions as a plugin, you can:

    • Use it alongside any SQL library of your choice.
    • Use it with multiple different SQL libraries simultaneously in the same project.
    • Use it specifically for the raw queries that your primary library might struggle to handle (e.g., complex queries or performance-critical operations).
  8. Configure SafeQL using migrations via `migrationsDir`

    main

    Instead of providing a live databaseUrl, you can provide a migrationsDir path. SafeQL will then automatically synchronize your .sql migration files to a temporary "shadow database". This shadow database is used to retrieve type information for your queries, which is useful when keeping a live database in sync with migrations is difficult.

    Important: The Shadow Database and connectionUrl

    • The shadow database is created, run against, and dropped/recreated every time ESLint initializes.
    • If your PostgreSQL superuser credentials differ from the default (postgres://postgres:postgres@localhost:5432/postgres), you must configure the connectionUrl option.
    • connectionUrl is used to create the shadow database, whereas databaseUrl (if used) is for your app. If using migrationsDir, connectionUrl provides the superuser/role permissions needed to run createdb and dropdb.

    Flat Config Example:

    import tseslint from "typescript-eslint";
    import safeql from "@ts-safeql/eslint-plugin/config";
    
    export default tseslint.config(
      {
        languageOptions: {
          parserOptions: {
            projectService: true,
          },
        },
      },
      safeql.configs.connections({
        connections: [
          {
            migrationsDir: "./migrations",
            targets: [{ tag: "db.sql" }],
            // connectionUrl: "postgres://pguser:password@localhost:5432/postgres"
          },
        ],
      })
    );
    // eslint.config.js
    import tseslint from "typescript-eslint";
    import safeql from "@ts-safeql/eslint-plugin/config";
    
    export default tseslint.config(
      {
        languageOptions: {
          parserOptions: {
            projectService: true,
          },
        },
      },
      safeql.configs.connections({
        connections: [
          {
            migrationsDir: "./migrations",
            targets: [
              // Check all of the queries that matches db.sql`...`
              { tag: "db.sql" },
            ],
            // To connect using alternate superuser credentials, see below
            // "connectionUrl": "postgres://pguser:password@localhost:5432/postgres"
          },
        ],
      })
    );
  9. How Slonik Zod schema validation works

    main

    SafeQL validates your Zod schemas against the actual query results returned by the database. If the schema does not match the database types, SafeQL will provide an error or suggestion.

    Example of a mismatch (defaults to suggestion):

    import { z } from "zod";
    import { sql } from "slonik";
    
    // If the DB returns a number for 'id', this will trigger an error/suggestion
    const query = sql.type(z.object({ id: z.string() }))`SELECT id FROM users`;
    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`;
  10. Configure SafeQL for @vercel/postgres

    main

    Create a safeql.config.ts file in your project root. Use defineConfig from @ts-safeql/eslint-plugin to specify your connection details. For @vercel/postgres, you typically provide a databaseUrl (e.g., from process.env.POSTGRES_URL) and define targets to identify which SQL tags should be linted. In the example below, ?(client.)sql is used to target SQL tagged with client.sql or similar patterns.

    // safeql.config.ts
    
    import { defineConfig } from "@ts-safeql/eslint-plugin";
    import dotenv from "dotenv";
    
    dotenv.config({ path: ".env.development.local" });
    
    export default defineConfig({
      connections: {
        databaseUrl: process.env.POSTGRES_URL,
        targets: [{ tag: "?(client.)sql" }],
      },
    });
  11. Enable Builder mode for embedded SQL fragments

    main

    By default, SafeQL skips raw sql fragments embedded within fluent Kysely builder chains (e.g., .select(sql....as('x'))).

    To enable validation for these fragments, set builder: true in the Kysely plugin configuration. In this mode, SafeQL compiles the entire builder chain through the fragments and validates the resulting SQL against the database.

    Limitations:

    • Chains that are not statically reconstructible (e.g., using sql.ref(someVar) with a dynamic identifier, or using a .select((eb) => ...) callback) are skipped.
    • The builder must be recognized by its type (any Kysely or Transaction instance). If the instance is typed as any, it will be skipped.
    // eslint.config.js
    import safeql from "@ts-safeql/eslint-plugin/config";
    import kysely from "@ts-safeql/plugin-kysely";
    import tseslint from "typescript-eslint";
    
    export default tseslint.config(
      // ...
      safeql.configs.connections({
        databaseUrl: "postgres://user:pass@localhost:5432/db",
        plugins: [kysely({ builder: true })],
      }),
    );
    import { sql, type SqlBool } from "kysely";
    
    // The embedded raw sql is validated against the schema:
    db.selectFrom("person")
      .select(sql<string>`upper(first_name)`.as("shout"))
      .execute();
    
    // Caught — the embedded raw sql references a column that doesn't exist:
    db.selectFrom("person")
      .select(sql<string>`upper(nonexistent)`.as("x"))
      .execute();
      //                                          ~~~~~~~~~~~~~~~~~~~ column "nonexistent" does not exist
  12. Adopt SafeQL incrementally in your codebase

    main

    SafeQL supports incremental adoption, allowing you to introduce typed queries one at a time without refactoring your entire codebase. You can opt-in specific query tags to SafeQL validation while leaving the rest of your queries untouched. This is achieved by creating a wrapper or alias for your database library's raw query method and configuring ESLint to target that specific tag.

    At runtime, the aliased method behaves identically to the original library method (e.g., Prisma's $queryRaw). During development, SafeQL uses the ESLint plugin to intercept usages of the aliased tag and validate them against your database schema.

    // db.ts
    import { PrismaClient } from "@prisma/client";
    
    export const prisma = new PrismaClient();
    export const $typedQueryRaw = prisma.$queryRaw; // [!code ++]