npm stats
  • Search
  • About
  • Repo
  • Sponsor
  • more
    • Search
    • About
    • Repo
    • Sponsor

Made by Antonio Ramirez

eslint-plugin-slonik

1.13.5

@GitHub Actions

npmHomeRepoSnykSocket
Downloads:109
$ npm install eslint-plugin-slonik
DailyWeeklyMonthlyYearly

eslint-plugin-slonik

Provides compile-time SQL query validation by checking your raw SQL strings against your actual database schema, catching errors before runtime.

This is a fork of @ts-safeql/eslint-plugin with native support for Slonik's SQL tag builders (sql.array, sql.fragment, sql.identifier, sql.unnest, etc.).

Features

  • 🔍 SQL Validation — Validates SQL queries against your PostgreSQL database schema at lint time
  • 🏷️ Slonik SQL Tags — Native support for all Slonik SQL tag builders
  • 🎯 Type Inference — Extracts type hints from sql.array(), sql.unnest(), and sql.identifier()
  • 📝 Fragment Support — Properly handles sql.fragment for dynamic query composition
  • ✨ Graceful Degradation — Skips validation for runtime-dependent constructs like sql.join()

Installation

npm install eslint-plugin-slonik --save-dev
# or
pnpm add eslint-plugin-slonik --save-dev
# or
yarn add eslint-plugin-slonik --dev

Peer Dependencies

npm install libpg-query --save-dev

Configuration

ESLint Flat Config (eslint.config.js)

import slonik from "eslint-plugin-slonik";

export default [
  // ... other configs
  {
    plugins: {
      slonik,
    },
    rules: {
      "slonik/check-sql": [
        "error",
        {
          connections: {
            // ...
          },
        },
      ],
    },
  },
];

Rule Options (JSON)

{
  "connections": {
    "databaseUrl": "postgresql://user:password@localhost:5432/database",
    "overrides": {
      "types": {
        "date": "DateSqlToken",
        "timestamp": "TimestampSqlToken",
        "interval": "IntervalSqlToken",
        "json": "JsonSqlToken",
        "jsonb": "JsonBinarySqlToken",
        "uuid": "UuidSqlToken",
        "int4[]": "ArraySqlToken<\"int4\">",
        "text[]": "ArraySqlToken<\"text\">",
        "uuid[]": "ArraySqlToken<\"uuid\">",
        "numeric[]": "ArraySqlToken<\"numeric\">",
        "real[]": "VectorSqlToken"
      }
    },
    "targets": [
      {
        "tag": "sql.+(type\\(*\\)|typeAlias\\(*\\)|unsafe)"
      }
    ]
  }
}

Optional Database URL

The databaseUrl can be undefined or null. When not configured, SQL validation is disabled and a warning is logged:

[eslint-plugin-slonik] databaseUrl is not configured. SQL validation is disabled.
Set the DATABASE_URL environment variable or configure databaseUrl in your ESLint config.

This allows you to use the plugin in environments where a database connection is not available (e.g., CI builds without database access), while still having the configuration in place for local development.

Slonik SQL Tag Support

SQL TagSupportBehavior
sql.array([1,2], 'int4')✅ FullExtracts type → $1::int4[]
sql.array([1,2], sql.fragment`int[]`)✅ GracefulFalls back to $1
sql.unnest([[...]], ['int4','text'])✅ FullExtracts types → unnest($1::int4[], $2::text[])
sql.identifier(['schema','table'])✅ FullEmbeds → "schema"."table"
sql.fragment`...`✅ FullEmbeds SQL content directly
sql.date(date)✅ FullExtracts type → $1::date
sql.timestamp(date)✅ FullExtracts type → $1::timestamptz
sql.interval({...})✅ FullExtracts type → $1::interval
sql.json(value)✅ FullExtracts type → $1::json
sql.jsonb(value)✅ FullExtracts type → $1::jsonb
sql.literalValue(value)✅ FullEmbeds as literal → ''
sql.uuid(str)✅ FullExtracts type → $1::uuid
sql.binary(buffer)✅ FullExtracts type → $1::bytea
sql.join([...], glue)✅ SkipSkipped (runtime content)

How It Works

Full Support means the plugin extracts type information and generates accurate PostgreSQL placeholders for validation:

// sql.array with type hint
sql.type(z.object({ ids: z.array(z.number()) }))`
  SELECT * FROM users WHERE id = ANY(${sql.array(userIds, 'int4')})
`;
// → Validates: SELECT * FROM users WHERE id = ANY($1::int4[])

// sql.identifier for dynamic table/column names
sql.type(z.object({ id: z.number() }))`
  SELECT id FROM ${sql.identifier(['public', 'users'])}
`;
// → Validates: SELECT id FROM "public"."users"

// sql.fragment for query composition
const whereClause = sql.fragment`WHERE active = true`;
sql.type(z.object({ id: z.number() }))`
  SELECT id FROM users ${whereClause}
`;
// → Validates: SELECT id FROM users WHERE active = true

// sql.date for date values
sql.type(z.object({ id: z.number() }))`
  SELECT id FROM events WHERE event_date = ${sql.date(myDate)}
`;
// → Validates: SELECT id FROM events WHERE event_date = $1::date

// sql.timestamp for timestamp values
sql.type(z.object({ id: z.number() }))`
  SELECT id FROM events WHERE created_at = ${sql.timestamp(myTimestamp)}
`;
// → Validates: SELECT id FROM events WHERE created_at = $1::timestamptz

// sql.interval for interval values
sql.type(z.object({ id: z.number() }))`
  SELECT id FROM events WHERE created_at > NOW() - ${sql.interval({ days: 7 })}
`;
// → Validates: SELECT id FROM events WHERE created_at > NOW() - $1::interval

// sql.json and sql.jsonb for JSON values
sql.type(z.object({ id: z.number() }))`
  INSERT INTO settings (config) VALUES (${sql.jsonb({ theme: 'dark' })})
`;
// → Validates: INSERT INTO settings (config) VALUES ($1::jsonb)

// sql.literalValue for literal SQL values
sql.type(z.object({ result: z.string() }))`
  SELECT ${sql.literalValue('hello')} AS result
`;
// → Validates: SELECT '' AS result

// sql.uuid for UUID values
sql.type(z.object({ id: z.number() }))`
  SELECT id FROM users WHERE external_id = ${sql.uuid(externalId)}
`;
// → Validates: SELECT id FROM users WHERE external_id = $1::uuid

// sql.binary for binary data
sql.type(z.object({ id: z.number() }))`
  UPDATE files SET content = ${sql.binary(buffer)} WHERE id = ${id}
`;
// → Validates: UPDATE files SET content = $1::bytea WHERE id = $2

Graceful Skip means the plugin recognizes Slonik tokens and skips validation for those expressions, preventing false positives:

// sql.join - content determined at runtime
sql.unsafe`
  SELECT * FROM users WHERE ${sql.join([
    sql.fragment`name = ${name}`,
    sql.fragment`age > ${age}`,
  ], sql.fragment` AND `)}
`;
// → Plugin skips validation for the join expression

Type Override Reference

When using Slonik, you'll want to map PostgreSQL types to Slonik's token types:

overrides: {
  types: {
    // Date/Time types
    date: 'DateSqlToken',
    timestamp: 'TimestampSqlToken',
    timestamptz: "TimestampSqlToken",
    interval: 'IntervalSqlToken',

    // JSON types
    json: 'JsonSqlToken',
    jsonb: 'JsonBinarySqlToken',

    // UUID
    uuid: "UuidSqlToken",

    // Array types (use ArraySqlToken<"element_type">)
    "int4[]": 'ArraySqlToken<"int4">',
    "int8[]": 'ArraySqlToken<"int8">',
    "text[]": 'ArraySqlToken<"text">',
    "uuid[]": 'ArraySqlToken<"uuid">',
    "numeric[]": 'ArraySqlToken<"numeric">',
    "bool[]": 'ArraySqlToken<"bool">',

    // Vector types (for pgvector)
    "real[]": "VectorSqlToken",
    vector: "VectorSqlToken",
  },
}

Target Pattern Reference

The tag option uses regex to match Slonik's query methods:

targets: [
  {
    // Matches: sql.type(...)``, sql.typeAlias(...)``, sql.unsafe``
    tag: "sql.+(type\\(*\\)|typeAlias\\(*\\)|unsafe)",
  },
]

Example Project Setup

1. Install dependencies

pnpm add slonik zod
pnpm add -D eslint-plugin-slonik libpg-query

2. Create your SQL tag with type aliases

// src/slonik.ts
import { createSqlTag } from "slonik";
import { z } from "zod";

export const sql = createSqlTag({
  typeAliases: {
    id: z.object({ id: z.number() }),
    void: z.object({}).strict(),
  },
});

3. Configure ESLint

// eslint.config.js
import slonik from "eslint-plugin-slonik";
import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.recommended,
  {
    plugins: {
      slonik,
    },
    rules: {
      "slonik/check-sql": ["error", { connections: { /* ... */ } }],
    },
  }
);

4. Write validated queries

import { pool, sql } from "./slonik";

// ✅ Valid - query matches schema
const users = await pool.many(
  sql.type(z.object({ id: z.number(), name: z.string() }))`
    SELECT id, name FROM users WHERE active = true
  `
);

// ✅ Valid - using sql.array with type hint
const usersByIds = await pool.many(
  sql.type(z.object({ id: z.number(), name: z.string() }))`
    SELECT id, name FROM users WHERE id = ANY(${sql.array(ids, 'int4')})
  `
);

// ✅ Valid - using sql.fragment for composition
const orderBy = sql.fragment`ORDER BY created_at DESC`;
const recentUsers = await pool.many(
  sql.type(z.object({ id: z.number(), name: z.string() }))`
    SELECT id, name FROM users ${orderBy}
  `
);

// ❌ Error - column 'naem' does not exist
const typo = await pool.many(
  sql.type(z.object({ id: z.number(), name: z.string() }))`
    SELECT id, naem FROM users
  `
);

Disabling Validation for Specific Queries

You can disable check-sql validation for individual queries by adding a @check-sql-disable comment inside the SQL template literal:

Block Comment Style

sql`/* @check-sql-disable */ SELECT * FROM ${sql.identifier([dynamicTable])}`

Line Comment Style

sql`
  -- @check-sql-disable
  SELECT * FROM ${sql.identifier([dynamicTable])}
`

When to Use

This is useful when you have:

  1. Dynamic SQL that cannot be validated statically — Complex dynamic queries where even Slonik tokens aren't enough
  2. Queries with edge cases — SQL syntax that the plugin doesn't support yet
  3. Intentional invalid SQL for testing — When you need to test error handling
  4. Temporary workarounds — While waiting for a plugin fix or improvement
// Example: Complex dynamic query that can't be validated
function buildDynamicReport(columns: string[], table: string) {
  return sql`
    /* @check-sql-disable */
    SELECT ${sql.join(columns.map(c => sql.identifier([c])), sql.fragment`, `)}
    FROM ${sql.identifier([table])}
  `;
}

[!NOTE] The comment must be placed inside the template literal, not outside of it. ESLint's standard eslint-disable comments work on the JavaScript/TypeScript level, while @check-sql-disable works on the SQL level.

Differences from @ts-safeql/eslint-plugin

This plugin is specifically designed for Slonik and includes:

  1. Native Slonik token recognition — Recognizes all Slonik SQL token types (ArraySqlToken, FragmentSqlToken, etc.)
  2. Type hint extraction — Extracts PostgreSQL types from sql.array() and sql.unnest() calls
  3. Fragment embedding — Properly embeds sql.fragment content into the query for validation
  4. Identifier support — Converts sql.identifier() to quoted identifiers
  5. Graceful degradation — Skips validation for runtime-dependent constructs instead of erroring

How It Works

ESLint rules must be synchronous, but SQL validation requires async operations like database connections. This plugin solves this using synckit, which enables synchronous calls to async worker threads.

The architecture:

  1. Worker Thread — Runs all async operations (database connections, migrations, type generation) in a separate thread
  2. Synchronous Bridge — Uses synckit to block the main thread until the worker completes, making async operations appear synchronous to ESLint
  3. Connection Pooling — Reuses database connections across lint runs for performance

Under the hood, synckit uses Node.js Worker Threads with Atomics.wait() to block the main thread until the worker signals completion via Atomics.notify().

Development

Prerequisites

  • Node.js 24+
  • pnpm 10+
  • PostgreSQL 17

Setup

# Install dependencies
pnpm install

# Start PostgreSQL (e.g., using Docker)
docker run -d --name postgres -p 5432:5432 \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=postgres \
  postgres:18

Running Tests

# Run tests with DATABASE_URL
DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres pnpm run test:vitest

Linting

pnpm run lint:eslint    # ESLint
pnpm run lint:tsc       # TypeScript type checking
pnpm run lint:cspell    # Spell checking
pnpm run lint:knip      # Unused code detection

Building

pnpm run build