🤖
sqlc を TypeScript で利用する
まとめ
- sqlc-gen-typescript かなり良い
- 自分が TypeScript でウェブアプリを利用するなら間違いなく sqlc を選択する
- SQL は共通言語という点で本当に偉大
sqlc とは
sqlc とは Go で書かれた SQL を元にコードを生成するツール。
$ sqlc compile
なぜ sqlc ?
- 結局、それぞれの ORM 固有の技術を覚えるくらいなら SQL を覚えた方が早い
- 拡張に ORM が対応していようがいまいが関係ない
- SQL パーサーが libpg_query という実際の PostgreSQL サーバーソースを使用している
- wasilibs/go-pgquery を利用しているので信頼できる
- Wasm 化された libpg_query
sqlc は PostgreSQL だけなの?
sqlc は MySQL や SQLite にも対応している。
sqlc は Go だけなの?
sqlc は Wasm でプラグインが書けるようになってきており、つい最近 TypeScript 版がリリースされた。
現時点では、 PostgreSQL と MySQL のみ対応。
Python
Python 向けの sqlc plugin もある。
マイグレーションは?
sqlc にはマイグレーションはない。好きなのを使えばいいと思うが go-migrate をお勧めしたい。 dbmate も良いらしい。
利用したことはないが sqldef も気になってる。
- https://github.com/golang-migrate/migrate
- https://github.com/amacneil/dbmate
- https://github.com/sqldef/sqldef
参考コード
動くコードを見て貰うのが良い
- sqlc-gen-typescript を利用して TypeScript コードを src/gen/sqlc/pg と src/gen/sqlc/postgres 以下に生成
- Vitest と testcontainers を利用してモック無しでの E2E テスト
- db 以下に query と schema の SQL を配置
- sqlc.yaml にて利用する sqlc-gen-typescript の Wasm を指定
- PostgreSQL のドライバーは pg と postgres を提供
- 特にこだわりが無ければ pg を利用することをお勧めする
- 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