🤖

sqlc を TypeScript で利用する

2023/12/25に公開

まとめ

  • sqlc-gen-typescript かなり良い
  • 自分が TypeScript でウェブアプリを利用するなら間違いなく sqlc を選択する
  • SQL は共通言語という点で本当に偉大

sqlc とは

sqlc とは Go で書かれた SQL を元にコードを生成するツール。

$ sqlc compile

なぜ sqlc ?

  • 結局、それぞれの ORM 固有の技術を覚えるくらいなら SQL を覚えた方が早い
  • 拡張に ORM が対応していようがいまいが関係ない
  • SQL パーサーが pg_query_go を利用しているので信頼できる

sqlc は PostgreSQL だけなの?

sqlc は MySQL や SQLite にも対応している。

sqlc は Go だけなの?

sqlc は Wasm でプラグインが書けるようになってきており、つい最近 TypeScript 版がリリースされた。
現時点では、 PostgreSQL と MySQL のみ対応。

https://github.com/sqlc-dev/sqlc-gen-typescript

Python

Python 向けの sqlc plugin もある。

https://github.com/sqlc-dev/sqlc-gen-python

Cloudflare D1

非公式だが Cloudflare D1 向けの sqlc plugin もある。

https://github.com/orisano/sqlc-gen-ts-d1

マイグレーションは?

sqlc にはマイグレーションはない。好きなのを使えばいいと思うが sqldef をお勧めしたい。

https://github.com/sqldef/sqldef

参考コード

動くコードを見て貰うのが良い

https://github.com/voluntas/sqlc-gen-ts-template

  • sqlc-gen-typescript を利用して TypeScript コードを src/gen/sqlc/pg と src/gen/sqlc/postgres 以下に生成
  • Vitesttestcontainers を利用してモック無しでのテスト
  • db 以下に query と schema の SQL を配置
  • sqlc.yaml にて利用する sqlc-gen-typescript の Wasm を指定
  • PostgreSQL のドライバーは pgpostgres を提供
  • GitHub Actions でもテスト
sqlc.yaml
version: "2"
plugins:
  - name: ts
    wasm:
      url: https://downloads.sqlc.dev/plugin/sqlc-gen-typescript_0.1.3.wasm
      sha256: 287df8f6cc06377d67ad5ba02c9e0f00c585509881434d15ea8bd9fc751a9368

# どちらか一方で良い
sql:
  # こちらは pg 用の生成
  - schema: db/schema.sql
    queries: db/query/
    engine: postgresql
    codegen:
      - out: src/gen/sqlc/pg
        plugin: ts
        options:
          runtime: node
          driver: pg
  # こちらは postgres 用の生成
  - schema: db/schema.sql
    queries: db/query/
    engine: postgresql
    codegen:
      - out: src/gen/sqlc/postgres
        plugin: ts
        options:
          runtime: node
          driver: postgres
test/sqlc_pg.test.ts
import { Client } from "pg";
import { GenericContainer, Wait } from "testcontainers";
import { expect, test } from "vitest";

import fs from "fs";
import {
  createAccount,
  deleteAccount,
  getAccount,
  listAccounts,
} from "../src/gen/sqlc/account_sql";

test("account", async () => {
  // PostgreSQL コンテナを起動
  const container = await new GenericContainer("postgres:latest")
    .withEnvironment({
      POSTGRES_DB: "testdb",
      POSTGRES_USER: "user",
      POSTGRES_PASSWORD: "password",
    })
    .withExposedPorts(5432)
    // TCPポートが利用可能になるまで待機
    .withWaitStrategy(Wait.forListeningPorts())
    .start();

  // postgres クライアントの設定
  const client = new Client({
    host: container.getHost(),
    port: container.getMappedPort(5432),
    database: "testdb",
    user: "user",
    password: "password",
  });
  await client.connect();

  // データベースへの ping (接続テスト)
  await client.query("SELECT 1");

  // ファイルを読み込んでSQL文を取得
  const schemaSQL = fs.readFileSync("db/schema.sql", "utf-8");

  // スキーマの初期化
  await client.query(schemaSQL);

  await createAccount(client, {
    id: "spam",
    displayName: "Egg",
    email: "ham@example.com",
  });

  const account = await getAccount(client, { id: "spam" });
  expect(account).not.toBeNull();
  // ここダサい、なんかいい書き方 Vitest にありそう
  if (account) {
    expect(account.id).toBe("spam");
    expect(account.displayName).toBe("Egg");
    expect(account.email).toBe("ham@example.com");
  }

  await deleteAccount(client, { id: "spam" });

  const accounts = await listAccounts(client);
  expect(accounts.length).toBe(0);

  await client.end();

  // コンテナを停止
  await container.stop();
}, 30_000);
db/query/account.sql
-- name: GetAccount :one
SELECT *
FROM account
WHERE id = @id;

-- name: ListAccounts :many
SELECT *
FROM account;

-- name: CreateAccount :exec
INSERT INTO account (id, display_name, email)
VALUES (@id, @display_name, @email);

-- name: UpdateAccountDisplayName :one
UPDATE account
SET display_name = @display_name
WHERE id = @id
RETURNING *;

-- name: DeleteAccount :exec
DELETE FROM account
WHERE id = @id;
src/gen/sqlc/pg/account_sql.ts
import { QueryArrayConfig, QueryArrayResult } from "pg";

interface Client {
  query: (config: QueryArrayConfig) => Promise<QueryArrayResult>;
}

export const getAccountQuery = `-- name: GetAccount :one
SELECT pk, id, display_name, email, created_at
FROM account
WHERE id = $1`;

export interface GetAccountArgs {
  id: string;
}

export interface GetAccountRow {
  pk: number;
  id: string;
  displayName: string;
  email: string | null;
  createdAt: Date;
}

export async function getAccount(
  client: Client,
  args: GetAccountArgs
): Promise<GetAccountRow | null> {
  const result = await client.query({
    text: getAccountQuery,
    values: [args.id],
    rowMode: "array",
  });
  if (result.rows.length !== 1) {
    return null;
  }
  const row = result.rows[0];
  return {
    pk: row[0],
    id: row[1],
    displayName: row[2],
    email: row[3],
    createdAt: row[4],
  };
}

export const listAccountsQuery = `-- name: ListAccounts :many
SELECT pk, id, display_name, email, created_at
FROM account`;

export interface ListAccountsRow {
  pk: number;
  id: string;
  displayName: string;
  email: string | null;
  createdAt: Date;
}

export async function listAccounts(client: Client): Promise<ListAccountsRow[]> {
  const result = await client.query({
    text: listAccountsQuery,
    values: [],
    rowMode: "array",
  });
  return result.rows.map((row) => {
    return {
      pk: row[0],
      id: row[1],
      displayName: row[2],
      email: row[3],
      createdAt: row[4],
    };
  });
}

export const createAccountQuery = `-- name: CreateAccount :exec
INSERT INTO account (id, display_name, email)
VALUES ($1, $2, $3)`;

export interface CreateAccountArgs {
  id: string;
  displayName: string;
  email: string | null;
}

export async function createAccount(
  client: Client,
  args: CreateAccountArgs
): Promise<void> {
  await client.query({
    text: createAccountQuery,
    values: [args.id, args.displayName, args.email],
    rowMode: "array",
  });
}

export const updateAccountDisplayNameQuery = `-- name: UpdateAccountDisplayName :one
UPDATE account
SET display_name = $1
WHERE id = $2
RETURNING pk, id, display_name, email, created_at`;

export interface UpdateAccountDisplayNameArgs {
  displayName: string;
  id: string;
}

export interface UpdateAccountDisplayNameRow {
  pk: number;
  id: string;
  displayName: string;
  email: string | null;
  createdAt: Date;
}

export async function updateAccountDisplayName(
  client: Client,
  args: UpdateAccountDisplayNameArgs
): Promise<UpdateAccountDisplayNameRow | null> {
  const result = await client.query({
    text: updateAccountDisplayNameQuery,
    values: [args.displayName, args.id],
    rowMode: "array",
  });
  if (result.rows.length !== 1) {
    return null;
  }
  const row = result.rows[0];
  return {
    pk: row[0],
    id: row[1],
    displayName: row[2],
    email: row[3],
    createdAt: row[4],
  };
}

export const deleteAccountQuery = `-- name: DeleteAccount :exec
DELETE FROM account
WHERE id = $1`;

export interface DeleteAccountArgs {
  id: string;
}

export async function deleteAccount(
  client: Client,
  args: DeleteAccountArgs
): Promise<void> {
  await client.query({
    text: deleteAccountQuery,
    values: [args.id],
    rowMode: "array",
  });
}

Discussion