📄
概念 📚 beginner-stepup

データベース設計・SQL→Drizzle ORM対応

Oracle SQL経験者がTypeScript/Drizzle ORMに移行するための対応表と、医療・製薬データに適したスキーマ設計パターン

SQLは既に知っている前提で話を進める。このドキュメントの目的は「書いてきたSQL文をTypeScript(Drizzle ORM)でどう表現するか」の対応関係を整理することである。

この章で作るもの(全体像)

b13 で in-memory だった Todo を、Docker で起動した PostgreSQL に保存する。Hono のルートハンドラは Drizzle ORM 経由で型安全に SQL を発行する。

Hono → Drizzle → Postgres のスタック


Oracle SQL → PostgreSQL(Drizzle ORM)対応表

Oracle SQLPostgreSQL + Drizzle ORM備考
VARCHAR2(255)text("col")PostgreSQLではtextが推奨
NUMBER(10,2)decimal("col", { precision: 10, scale: 2 })
DATEdate("col")時刻なし
TIMESTAMPtimestamp("col")
SYSDATE.defaultNow()
SYS_GUID().default(sql\gen_random_uuid()`)`UUID生成
SEQUENCE + NEXTVAL.primaryKey().generatedAlwaysAsIdentity()自動採番
NVL(col, 0)COALESCE(col, 0) または notNull()
ROWNUM < 10.limit(10)
CONNECT BY(階層)再帰CTE(WITH RECURSIVE

スキーマ定義(TypeScriptで書く)

Oracle SQLのDDLと並べて確認する:

-- Oracle SQL での定義
CREATE TABLE clinical_trials (
  trial_id    VARCHAR2(36) DEFAULT SYS_GUID() PRIMARY KEY,
  drug_code   VARCHAR2(20) NOT NULL,
  phase       NUMBER(1) NOT NULL,
  start_date  DATE NOT NULL,
  status      VARCHAR2(20) DEFAULT 'ACTIVE',
  created_at  TIMESTAMP DEFAULT SYSDATE
);
// Drizzle ORM での同等定義
import { pgTable, text, integer, date, timestamp } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";

export const clinicalTrials = pgTable("clinical_trials", {
  trialId: text("trial_id").primaryKey().default(sql`gen_random_uuid()`),
  drugCode: text("drug_code").notNull(),
  phase: integer("phase").notNull(),
  startDate: date("start_date").notNull(),
  status: text("status").default("ACTIVE"),
  createdAt: timestamp("created_at").defaultNow(),
});

カラム名はTypeScriptではキャメルケース(drugCode)、DBにはスネークケース(drug_code)として保存される。Drizzleが自動的にマッピングする。

よく使うクエリのDrizzle変換

import { eq, gt, desc, and, avg, count } from "drizzle-orm";

// SELECT ... WHERE
const active = await db
  .select()
  .from(clinicalTrials)
  .where(eq(clinicalTrials.status, "ACTIVE"));

// JOIN
const results = await db
  .select({
    trialId: clinicalTrials.trialId,
    drugCode: clinicalTrials.drugCode,
    resultValue: trialResults.value,
  })
  .from(clinicalTrials)
  .innerJoin(trialResults, eq(clinicalTrials.trialId, trialResults.trialId))
  .where(eq(clinicalTrials.phase, 3))
  .orderBy(desc(clinicalTrials.startDate));

// GROUP BY + HAVING(集計)
const summary = await db
  .select({
    drugCode: clinicalTrials.drugCode,
    avgValue: avg(trialResults.value),
    sampleCount: count(),
  })
  .from(clinicalTrials)
  .innerJoin(trialResults, eq(clinicalTrials.trialId, trialResults.trialId))
  .groupBy(clinicalTrials.drugCode)
  .having(gt(count(), 10));

SQLそのままでは書けないが、関数チェーンの構造は SELECT句 → FROM → JOIN → WHERE → GROUP BY → ORDER BY の順序と対応している。

テーブル設計のポイント(医療・製薬データ)

医療・製薬データを扱う際に設計段階で考慮すべき点:

テーブル設計例(医療・製薬コンテキスト)

// 患者テーブル(匿名化IDを主キーに使う設計)
export const patients = pgTable("patients", {
  id: text("id").primaryKey().default(sql`gen_random_uuid()`),
  // 氏名はアプリ側で暗号化してから格納(生の個人情報をDBに直書きしない)
  nameEncrypted: text("name_encrypted").notNull(),
  birthDate: date("birth_date").notNull(),
  createdAt: timestamp("created_at").defaultNow(),
});

// 検査結果テーブル(患者と1対多のリレーション)
export const labResults = pgTable("lab_results", {
  id: text("id").primaryKey().default(sql`gen_random_uuid()`),
  patientId: text("patient_id").notNull().references(() => patients.id),
  testDate: date("test_date").notNull(),
  glucose: decimal("glucose", { precision: 5, scale: 2 }),
  hba1c: decimal("hba1c", { precision: 4, scale: 2 }),
  // AI解析結果を格納する場合、承認フラグを持たせる
  aiSummary: text("ai_summary"),
  approvedBy: text("approved_by"),   // 承認者のID
  approvedAt: timestamp("approved_at"), // null = 未承認
});

approvedAtnull のレコードは「AIが処理済みだが人間がまだ確認していない状態」を意味する。このパターンはHuman-in-the-loopワークフローの基本設計(b20参照)。

リレーションの種類

1対多(1:N): 最も一般的。1人の患者が複数の検査結果を持つ。

patients (1) ←── lab_results (N)

多対多(N:M): 中間テーブルで表現する。患者と担当医師が相互に複数持てる場合:

patients ←── patient_doctors ──→ doctors

1対1(1:1): 特定の拡張情報を別テーブルに分離するとき(個人情報テーブルの分離など)。

Drizzle ORM(TypeScriptからDBを操作する)

直接SQLを書く代わりに、TypeScriptのコードとしてDB操作を記述できる:

// スキーマ定義(TypeScriptで書く)
import { pgTable, text, decimal, date, timestamp } from "drizzle-orm/pg-core";

export const patients = pgTable("patients", {
  id: text("id").primaryKey().default(sql`gen_random_uuid()`),
  name: text("name").notNull(),
  birthDate: date("birth_date").notNull(),
  createdAt: timestamp("created_at").defaultNow(),
});

export const labResults = pgTable("lab_results", {
  id: text("id").primaryKey().default(sql`gen_random_uuid()`),
  patientId: text("patient_id").notNull().references(() => patients.id),
  glucose: decimal("glucose", { precision: 5, scale: 2 }),
  testDate: date("test_date").notNull(),
});

// クエリ(型安全)
const results = await db
  .select()
  .from(labResults)
  .where(eq(labResults.patientId, patientId))
  .orderBy(desc(labResults.testDate));

TypeScriptの型が自動的に生成されるため、コンパイル時に列名の誤りを検出できる。

インデックス

検索が遅い場合はインデックスを追加する。よく検索条件に使うカラムに設定:

-- patient_idでの検索が多い場合
CREATE INDEX idx_lab_results_patient_id ON lab_results(patient_id);
CREATE INDEX idx_lab_results_test_date ON lab_results(test_date DESC);

手を動かす

b13 で in-memory だった Todo API を、PostgreSQL + Drizzle ORM で永続化する。API が再起動してもデータが残るようになる。

やること

  1. リポジトリルートに docker-compose.yml を置き、Postgres をコンテナで起動
  2. api/ に Drizzle を導入し、todos テーブルを TypeScript で定義
  3. api/src/index.ts の in-memory 配列を Drizzle クエリに置き換え
docker-compose.yml(クリックで開く)
services:
  postgres:
    image: postgres:16-alpine
    ports:
      - "5432:5432"
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: app
      POSTGRES_DB: todo_dev
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
Drizzle 導入コマンド(クリックで開く)
cd api
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg
api/src/db/schema.ts(クリックで開く)
import { pgTable, serial, text, boolean, timestamp } from 'drizzle-orm/pg-core';

export const todos = pgTable('todos', {
  id: serial('id').primaryKey(),
  text: text('text').notNull(),
  done: boolean('done').default(false).notNull(),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

export type Todo = typeof todos.$inferSelect;
export type NewTodo = typeof todos.$inferInsert;
api/src/db/client.ts(クリックで開く)
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL ?? 'postgres://app:app@localhost:5432/todo_dev',
});

export const db = drizzle(pool);
api/drizzle.config.ts(クリックで開く)
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
  dbCredentials: {
    url: process.env.DATABASE_URL ?? 'postgres://app:app@localhost:5432/todo_dev',
  },
});
テーブル作成コマンド(クリックで開く)
# Postgres 起動
docker compose up -d postgres

# スキーマを DB に反映(本章では db:push で即時反映。b16 で migration 方式を学ぶ)
cd api
pnpm drizzle-kit push
api/src/index.ts を Drizzle に差し替え(クリックで開く)
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
import { eq } from 'drizzle-orm';
import { db } from './db/client';
import { todos } from './db/schema';

const app = new Hono();
app.use('/api/*', cors());

app.get('/api/todos', async (c) => {
  const rows = await db.select().from(todos);
  return c.json(rows);
});

app.get('/api/todos/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const [todo] = await db.select().from(todos).where(eq(todos.id, id));
  if (!todo) return c.json({ error: 'not found' }, 404);
  return c.json(todo);
});

app.post('/api/todos', async (c) => {
  const { text } = await c.req.json<{ text: string }>();
  if (!text?.trim()) return c.json({ error: 'text is required' }, 400);
  const [todo] = await db.insert(todos).values({ text }).returning();
  return c.json(todo, 201);
});

app.patch('/api/todos/:id', async (c) => {
  const id = Number(c.req.param('id'));
  const [current] = await db.select().from(todos).where(eq(todos.id, id));
  if (!current) return c.json({ error: 'not found' }, 404);
  const [updated] = await db.update(todos).set({ done: !current.done }).where(eq(todos.id, id)).returning();
  return c.json(updated);
});

serve({ fetch: app.fetch, port: 3333 });

成功条件

  • docker compose pspostgres コンテナが running になっていること
  • pnpm drizzle-kit push の出力に Everything is in sync が含まれていること
  • psql postgres://app:app@localhost:5432/todo_dev -c '\d todos'id / text / done / created_at の 4 列が表示されていること
  • ブラウザで Todo を追加した後、api を再起動しても画面上に同じ Todo が残っていること
  • docker compose down -v でボリュームごと消した後に起動し直すと、一覧が空になっていること(DB に入っている証拠)

次の章(b16)で カラム追加をマイグレーションとして管理する 方法を扱う。


参考リソース

  • Drizzle ORM(https://orm.drizzle.team/)— TypeScript向けORMの公式ドキュメント
  • PostgreSQL公式ドキュメント(https://www.postgresql.org/docs/)
  • 『データベース設計のベストプラクティス』— 正規化とインデックス設計の入門
  • HIPAA Technical Safeguards(医療データの技術的保護要件)— 医療アプリ設計の参考

確認クイズ

Q1. Oracle SQLの VARCHAR2(255) に対応するDrizzle ORM(PostgreSQL)の型はどれか。

正解: text("col")

解説: PostgreSQLでは可変長文字列に text 型を使うのが推奨される。Oracle固有の VARCHAR2 とは異なり、PostgreSQLの text は長さ制限を明示しなくてよく、内部的にも同等の性能を持つ。

Q2. Drizzle ORMでスキーマ定義するとき、TypeScript側のカラム名とDB側のカラム名の命名規則の違いは何か。

正解: TypeScriptはキャメルケース(drugCode)、DBはスネークケース(drug_code)で書き、Drizzleが自動マッピングする

解説: JavaScriptの慣習はキャメルケース、SQLの慣習はスネークケースである。Drizzle ORMはこの違いを吸収するため、スキーマ定義時に drugCode: text("drug_code") と両方を明示的に書く。

Q3. Drizzle ORMで WHERE 条件に等価比較を書く場合、使う関数はどれか。A. equals B. eq C. where D. is

正解: B. eq

解説: Drizzle ORMは eqgtltandor などのヘルパー関数を drizzle-orm からインポートして使う。eq(clinicalTrials.status, "ACTIVE") のように書くとSQLの WHERE status = 'ACTIVE' に対応する。

Q4. 医療データの患者テーブルで、氏名をそのままDB に格納せず暗号化してから格納する理由は何か。

正解: 生の個人情報をDBに直書きすると、DBが漏洩した際に個人情報が直接露出するため

解説: nameEncrypted のようにアプリ側で暗号化してから格納する設計では、DBへの不正アクセスがあっても暗号化鍵がなければ氏名を復元できない。医療・製薬データでは個人情報保護の観点からこの設計が重要になる。

Q5. approvedAt カラムが null のレコードが意味するのは何か。

正解: AIが処理済みだが人間がまだ確認していない(未承認)状態

解説: approvedAt timestamp を持つ設計では、値が null = 未承認、値がある = 承認済みとして扱う。これはHuman-in-the-loopワークフローの基本パターンで、AI出力を人間が確認・承認するまで正式採用しない設計を実現する。

Q6. 患者と担当医師が互いに複数持てる関係(多対多)をRDBで表現するにはどうするか。

正解: 中間テーブル(例:patient_doctors)を作成し、両テーブルへの外部キーを持たせる

解説: RDBは多対多を直接表現できないため、中間テーブルを挟む。patient_doctors テーブルに patient_iddoctor_id の両外部キーを置き、1対多を2つ組み合わせることで多対多を実現する。

Q7. Oracle SQLの SYS_GUID() に相当するPostgreSQL(Drizzle ORM)でのUUID自動生成の書き方はどれか。

正解: .default(sql\gen_random_uuid()`)`

解説: PostgreSQLには gen_random_uuid() 関数が組み込まれており、INSERT時にランダムなUUIDを自動生成する。Drizzle ORMでは sql テンプレートリテラルを使ってSQL関数を呼び出せる。

Q8. Drizzle ORMで GROUP BY + HAVING を使う場合、関数チェーンの順序として正しいのはどれか。A. .groupBy() → .having() → .where() B. .where() → .groupBy() → .having() C. .having() → .groupBy() → .where() D. .groupBy() → .where() → .having()

正解: B. .where().groupBy().having()

解説: DrizzleのクエリビルダーはSQLの論理的な実行順序に対応している。WHERE(行フィルタ)→ GROUP BY(集計)→ HAVING(集計後フィルタ)の順序はSQLと同じである。

Q9. Drizzle ORMを使う最大のメリットとして正しいのはどれか。

正解: TypeScriptの型が自動生成されるため、コンパイル時に列名の誤りや型の不一致を検出できる

解説: 生のSQLを文字列で書くと列名の誤りは実行時まで気づかない。Drizzle ORMはスキーマ定義からTypeScript型を自動生成するため、存在しないカラムへのアクセスやデータ型の不一致をコンパイル時にエラーとして検出できる。

Q10. lab_results テーブルの patient_id カラムにインデックスを追加する主な理由は何か。

正解: 特定の患者の検査結果を検索する際のクエリ実行速度を向上させるため

解説: インデックスがない場合、WHERE patient_id = 'xxx' の検索は全行スキャンになる。patient_id は頻繁に検索条件として使われるため、インデックスを追加することで検索がインデックスツリーを使った高速なルックアップになる。

発展:より実務的な設計パターン

複合主キー・複合インデックス

「多対多テーブル」(フォロー・リアクション等)は自然な主キーを持ちません。Drizzle では primaryKey() に複数カラムを渡します:

import { pgTable, text, timestamp, primaryKey, index } from "drizzle-orm/pg-core";

export const reactions = pgTable(
  "reactions",
  {
    userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
    postId: text("post_id").notNull().references(() => posts.id, { onDelete: "cascade" }),
    emoji: text("emoji").notNull(),
    createdAt: timestamp("created_at").defaultNow(),
  },
  (t) => ({
    pk: primaryKey({ columns: [t.userId, t.postId, t.emoji] }),
    postIdx: index("reactions_post_idx").on(t.postId),
  }),
);

複合主キーの順序 = 暗黙の複合インデックス(userId, postId, emoji) の順なら WHERE user_id = ? は速いが、WHERE post_id = ? 単独は遅い。だから post_id 単独の検索用に 2 つ目のインデックスを張ります。

N+1 問題と JOIN 最適化

悪い例: 各投稿ごとに authorを個別取得 → N+1 クエリ

const posts = await db.select().from(postsTable);
for (const post of posts) {
  const [author] = await db.select().from(users).where(eq(users.id, post.authorId));
  // 1 投稿ごとに 1 クエリ = N+1
}

良い例: Drizzle の with で 1 クエリで取得

const postsWithAuthor = await db.query.posts.findMany({
  with: {
    author: { columns: { id: true, name: true } },  // 機密カラムは除外
  },
  orderBy: (p, { desc }) => desc(p.createdAt),
  limit: 20,
});

これには relations() の定義が必要:

import { relations } from "drizzle-orm";

export const postsRelations = relations(posts, ({ one, many }) => ({
  author: one(users, { fields: [posts.authorId], references: [users.id] }),
  reactions: many(reactions),
}));

export const usersRelations = relations(users, ({ many }) => ({
  posts: many(posts),
}));

COUNT 集計の JOIN

「各投稿のいいね数」を 1 クエリで:

import { sql, eq } from "drizzle-orm";

const postsWithLikeCount = await db
  .select({
    id: posts.id,
    content: posts.content,
    likeCount: sql<number>`count(${reactions.userId})`.as("like_count"),
  })
  .from(posts)
  .leftJoin(reactions, eq(reactions.postId, posts.id))
  .groupBy(posts.id);

leftJoin なので、リアクション 0 個の投稿も拾えます。

アプリレベルの暗号化(個人情報列)

DB 側の暗号化(TDE)に加え、アプリ側で機密カラムだけ暗号化する選択肢:

// api/src/lib/crypto.ts
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";

const KEY = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, "hex"); // 32 bytes hex
const ALGO = "aes-256-gcm";

export function encryptField(plain: string): string {
  const iv = randomBytes(12);
  const cipher = createCipheriv(ALGO, KEY, iv);
  const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
  const tag = cipher.getAuthTag();
  // iv + tag + ciphertext を Base64 で保存
  return Buffer.concat([iv, tag, enc]).toString("base64");
}

export function decryptField(stored: string): string {
  const buf = Buffer.from(stored, "base64");
  const iv = buf.subarray(0, 12);
  const tag = buf.subarray(12, 28);
  const enc = buf.subarray(28);
  const decipher = createDecipheriv(ALGO, KEY, iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
}

使用例(氏名だけ暗号化):

const nameEncrypted = encryptField(plainName);
await db.insert(patients).values({ nameEncrypted, birthDate });

// 取り出すとき
const [p] = await db.select().from(patients).where(eq(patients.id, id));
const plainName = decryptField(p.nameEncrypted);

注意: 暗号化したカラムは 検索できないWHERE name = ? が使えない)。検索が必要な場合はハッシュ化した別カラムも持つ等の設計が必要です。

ソフトデリート vs ハードデリート

削除の表現方法:

方式どう消すかメリットデメリット
ハードDELETE FROM ...ストレージ節約、シンプル復元不可
ソフトdeletedAt 列を NOW() に復元可能、監査ログに残る全クエリに WHERE deletedAt IS NULL が必要
export const posts = pgTable("posts", {
  id: text("id").primaryKey(),
  content: text("content").notNull(),
  deletedAt: timestamp("deleted_at"),  // null = 生存
});

GDPR(本カリキュラムでは気にしなくていいが将来に備えて)は「完全削除が必要な場合」を規定しているので、ソフトデリート一択ではありません。


参考リソース

生きているコード

本ドキュメントで扱ったパターンの完全な動作コードは、メンター側リポジトリの参照ブランチで確認できます。

ブランチの作り方・見方は b00-curriculum-map を参照してください。

📚 beginner-stepup 全 53 章
導入編 13 章
  1. 1. 📄Web とは何か
  2. 2. 📄URL を打ってから画面が表示されるまで
  3. 3. 📄ネットワーク基礎(TCP/IP・DNS・HTTPS)
  4. 4. 📄【発展】物理層から通信が成立するまで(電力・Ethernet・Wi-Fi・Bluetooth)
  5. 5. 📄WSL2・Docker セットアップ詳細(Windows 向け)
  6. 6. 📄環境構築の段階的導入(macOS / Windows)
  7. 7. 📄カリキュラム全体マップ(Week × 教材 × 参照ブランチ × 要求チェックリスト)
  8. 8. 📄このカリキュラムの使い方(SQL・Python・Dify経験者向け)
  9. 9. 📄シェル・ターミナル基礎
  10. 10. 📄Windows で完全にゼロから始める開発環境構築(Week 1)
  11. 11. 📄Git基礎
  12. 12. 📄GitHubワークフロー
  13. 13. 📄パッケージ管理(pnpm workspace)
応用編 16 章
  1. 1. 📄AWSインフラ基礎
  2. 2. 📄AWS Budget Alert の設定(Month 5 Week 17)
  3. 3. 📄環境変数管理
  4. 4. 📄Bastion EC2 と SSH ProxyJump(Month 5 Week 18)
  5. 5. 📄CI/CD基礎
  6. 6. 📄ECR への Docker イメージ push と App EC2 デプロイ(Month 5 Week 19)
  7. 7. 📄テスト設計の基本
  8. 8. 📄CloudFront + S3 + ALB で公開する(Month 5 Week 20)
  9. 9. 📄CLAUDE.md・プロジェクト設定
  10. 10. 📄PR レビュー 5 観点ルーブリック(全 Week 共通)
  11. 11. 📄タスク分解・仕様の書き方
  12. 12. 📄Playwright で E2E テスト(Month 6 Week 22)
  13. 13. 📄生成コードのレビュー・デバッグの勘所
  14. 14. 📄Trivy で脆弱性スキャン(Month 6 Week 23)
  15. 15. 📄CloudWatch Logs の読み方と運用(Month 6 Week 23)
  16. 16. 📄PDF ポートフォリオの自動生成(Month 6 Week 24)