home

Drizzle ORM

作成: 2026-02-17 17:03

データベース工学という講義の課題2

SQLコマンドを文字列として扱うことの限界

リレーショナルデータベースに対する操作は、多くの場合、アプリケーションプログラム内から動的にSQLを発行することで行われます。この際、従来の手法ではSQL文を単なる「文字列」としてプログラム内に記述し、変数を結合することでクエリ(データベースへの命令)を組み立てていました。

しかし、文字列の結合によるクエリ構築には本質的な弱点があります。SQLのクエリは本来、SELECT句やWHERE句、JOINなどが階層的・論理的に組み合わさった「構造を持った言語」です。それを単なる1次元の文字の並びに落とし込んで結合・操作することは、クエリが持つ本来の構造を壊してしまうことになります。

// 1次元の文字列結合による危険なクエリ構築の例
const column = "age";
// コンパイラ(やランタイム)はこれを単なる文字列としてしか認識しないため、
// 実行時に構文エラーになるか、最悪の場合はSQLインジェクションを引き起こします
const query = "SELECT * FROM users WHERE " + column + " > " + minAge;

コンパイラ(やランタイム)から見れば、これは単なる文字列のデータに過ぎません。そのため、カラム名(列名)の打ち間違いやSQLの文法エラーがあっても、実際にプログラムを動かしてデータベースへクエリが送信される瞬間まで、エラーに気づくことができません。また、プログラム側のデータ構造(オブジェクトなど)と、データベース側のデータ構造(表と行)の形が根本的に異なるため、その変換を手作業で行うのは非常に面倒でバグの温床になります。

今でもやってるじゃないか、ねえ文字列で、連結すりゃあいいそんなことやってるからSQLインジェクションなどが起こって、そのほぼ大きな部分の脆弱性をこの世に生んでいるわけですねこれであれば、そんなものは、既にissueでさえない

構造化されたクエリと型安全性へのアプローチ

この「1次元の文字列の限界」と「データ構造のズレ」を解消するため、プログラミング言語やツールのレベルで様々な解決策が考案されてきました。

  • SML#のML式としての合成: プログラミング言語の文法レベルでSQLを深く統合し、SQLクエリそのものを「MLの型付きの式」として記述・合成できるようにしています。これにより、コンパイル時にプログラムの他の部分と同じように厳密な型検査ができるため、実行前に確実にSQLの矛盾や型のエラーを発見でき、非常に安全にデータベースを操作できます。
  • C#のLINQ (Language Integrated Query): C#の文法自体にデータ操作の構文を組み込み、コンパイル時の型チェックと直感的な記述を可能にしています。
  • Prisma ORM: Node.jsやTypeScriptの環境で主流なアプローチの一つです。専用の設定ファイルからデータベースにアクセスするためのコードを自動生成し、SQLの存在を高度に隠しつつ、プログラム側から安全にデータを操作できるようにします。

Drizzle ORM

このように様々な優れた解決策が存在する中、ここではTypeScriptの環境において近年急速に支持を集めている Drizzle ORM を取り上げます。

Drizzle ORMの最大の特徴は「SQLを知っていれば、Drizzleも書ける」という基本方針にあります。PrismaのようにSQLの概念を過度に隠すのではなく、SQLの構文要素(SELECT, WHERE, LEFT JOINなど)をそのままTypeScriptの関数と型に1対1で対応させるアプローチをとっています。

これにより、開発者はSQLが持つ本来の表現力を損なうことなく、1次元の文字列結合から脱却し、コンパイラの強力な型チェックとエラー検知の恩恵(型安全性)を享受することができます。
本記事では、PostgreSQL環境に対するDrizzle ORMの導入から、その型推論の威力、そして実際にどのように構造化されたクエリを組み立てるのかを検証していきます。

実験環境の構築(PostgreSQLとDrizzle ORMの準備)

ここでは、データベース(PostgreSQL)と、それを操作するためのプログラム(Node.js + TypeScript)の環境を構築します。適当な作業用のディレクトリを一つ作成し、ターミナルでその中に移動してから作業を始めてください。

データベース(PostgreSQL 17.6)の起動

まずはデータベースを用意します。作業ディレクトリの中に compose.yaml (または docker-compose.yml)という名前のファイルを作成し、以下の内容を記述します。

services:
  db:
    image: postgres:17.6
    container_name: drizzle-postgres-lab
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
      POSTGRES_DB: drizzle_db
    ports:
      - "5432:5432"

ファイルを保存したら、ターミナルで以下のコマンドを実行してデータベースをバックグラウンドで起動します。

docker compose up -d

Node.jsプロジェクトの初期化とパッケージのインストール

次に、プログラム側の準備をします。同じ作業ディレクトリで、Node.js のプロジェクトを初期化し、必要なパッケージをインストールします。

# プロジェクトの初期化(package.json が作成されます)
npm init -y

# アプリケーションの実行に必要なパッケージ(Drizzle本体とPostgreSQLの接続ツールなど)
npm install drizzle-orm pg dotenv

# 開発時に必要なツール(TypeScript本体、Drizzleの管理ツールなど)
npm install -D drizzle-kit tsx typescript @types/pg @types/node

ここでインストールした主なパッケージの役割は以下の通りです。

  • drizzle-orm: Drizzle本体。プログラム内で型安全なSQLを書くために使います。
  • pg: Node.js から PostgreSQL に接続するための定番の通信ドライバーです。
  • drizzle-kit: TypeScriptで書いたテーブル定義を読み取り、実際のデータベースの形を変える(マイグレーションする)ための強力な管理ツールです。
  • tsx: TypeScriptのファイルをコンパイル(変換)せずに、そのまま手軽に実行するためのツールです。

各種設定ファイルの作成

ツールを動かすために、いくつか簡単な設定ファイルを用意します。作業ディレクトリの直下に、以下の3つのファイルを作成してください。

① TypeScriptの設定ファイル(tsconfig.json)

TypeScriptがコードをどう解釈するかを指定します。

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "CommonJS",
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  }
}

② データベースの接続情報(.env)

プログラムがどこに接続すればいいのか、パスワードは何かなどの情報を環境変数として切り出します。compose.yaml で設定した内容と合わせます。

DATABASE_URL="postgres://postgres:password@localhost:5432/drizzle_db"

③ Drizzle Kitの設定ファイル(drizzle.config.ts)

テーブル構造を変更するツール(drizzle-kit)に対して、「どのファイルの定義を読み取って、どのデータベースに反映させるか」を教えます。

import { defineConfig } from 'drizzle-kit';
import * as dotenv from 'dotenv';

// .env ファイルを読み込む
dotenv.config();

export default defineConfig({
  // テーブル定義を書くファイルを指定
  schema: './src/schema.ts',
  // PostgreSQLを使用することを指定
  dialect: 'postgresql',
  dbCredentials: {
    // .env で設定した接続先URLを使用
    url: process.env.DATABASE_URL!,
  },
});

ここまで完了すると、作業ディレクトリの中身は以下のような構成になっているはずです。

📁 あなたの作業ディレクトリ/
 ┣ 📁 node_modules/
 ┣ 📄 .env
 ┣ 📄 compose.yaml
 ┣ 📄 drizzle.config.ts
 ┣ 📄 package.json
 ┣ 📄 package-lock.json
 ┗ 📄 tsconfig.json

これで、Drizzle ORM の強力な型推論と型検査を体験するための舞台が整いました。

テーブル定義とマイグレーション

これまでの開発手法では、データベース側の構造(テーブル)とプログラム側の構造(型やクラス)を別々に管理する必要があり、両者にズレが生じることでバグが発生しやすくなっていました。Drizzle ORMでは、TypeScriptでテーブルの設計図(スキーマ)を書くことで、この問題を解決します。

スキーマ(テーブル設計図)の作成

作業ディレクトリの中に新しく src というフォルダを作成し、その中に schema.ts というファイルを作成してください。ここに、ユーザー(users)と投稿(posts)の2つのテーブルを定義します。

// src/schema.ts
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

// usersテーブルの定義
export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  age: integer('age'),
});

// postsテーブルの定義(外部キー制約を含む)
export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  authorId: integer('author_id').references(() => users.id),
});

このコードを見るとわかるように、Drizzleのスキーマ定義はSQLの CREATE TABLE の構文と非常に似ています。serial(自動連番)や textinteger といったデータ型が、そのままTypeScriptの関数として提供されています。また、references() を使うことで、外部キー制約(Foreign Key)も簡単に表現できます。

データベースへの反映(drizzle-kit push)

TypeScriptで設計図を書いただけでは、まだPostgreSQLの中身は空っぽです。ここで、先ほどインストールした drizzle-kit というツールの出番です。

ターミナルで以下のコマンドを実行してください。

npx drizzle-kit push

このコマンドを実行すると、Drizzle Kit は以下の作業を自動で行います。

  1. drizzle.config.ts を読み込み、接続先のPostgreSQLを見つける。
  2. src/schema.ts に書かれたTypeScriptの定義を解析する。
  3. 現在のPostgreSQLの実際のテーブル状態と比較し、足りないテーブルやカラムを作成するためのSQL(CREATE TABLE など)を自動生成して実行する。

ターミナルに [✓] Pulling shcmema from database...[✓]Changes applied のような緑色のメッセージが表示されれば成功です。これで、あなたが書いたTypeScriptの型定義と、PostgreSQLの実際の物理的なテーブル構造が完全に一致した状態になりました。

Drizzle Studioでデータベースの中身を覗く

Drizzleには、データベースの中身をブラウザ上で簡単に確認・編集できるGUIツールが標準で付属しています。ターミナルで以下のコマンドを実行してみてください。

npx drizzle-kit studio

コマンドを実行すると、https://local.drizzle.studio というURLが表示されます。これをブラウザで開くと、先ほど作成した usersposts テーブルがPostgreSQL上に確かに存在していることを視覚的に確認できます。

Drizzle Studioのスクリーンショット

実験

データベースへアクセスするためのメインプログラムを作成します。src フォルダ内に index.ts というファイルを作成し、データベースへの接続準備を記述します。

// src/index.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Client } from 'pg';
import { users } from './schema';
import * as dotenv from 'dotenv';

dotenv.config();

// PostgreSQLへの接続クライアントを作成
const client = new Client({
  connectionString: process.env.DATABASE_URL,
});

async function main() {
  await client.connect();
  const db = drizzle(client); // Drizzleのインスタンスを作成

  console.log("データベースに接続しました!");
  
  // === ここに実験コードを書いていきます ===

  await client.end();
}

main();

このプログラムを実行するには、ターミナルで npx tsx src/index.ts と入力します。(※ tsx はTypeScriptをそのまま実行してくれる便利なツールです)

実験1:INSERT時の型検査(文字列と数値のミスを防ぐ)

まずは、users テーブルに新しいユーザーを追加(INSERT)する処理を書いてみましょう。もし従来の生のSQL(文字列結合)で、年齢(整数型)の場所に誤って文字列を入れようとした場合、プログラムは実行され、PostgreSQLに到達した段階で初めてエラーとして弾かれます。

しかし、Drizzle ORMでは違います。index.tsmain 関数の中に、わざと型を間違えた以下のコードを記述してみてください。

  // ❌ 意図的なエラーの実験
  await db.insert(users).values({
    name: 'Alice',
    age: '二十歳' // ageは整数(integer)で定義したのに、文字列を渡そうとしている
  });

このコードをVSCodeなどのエディタに貼り付けると、実行するまでもなく age の部分に赤い波線(コンパイルエラー)が表示されるはずです。マウスを乗せると、以下のようなTypeScriptのエラーメッセージが出ます。

【TypeScriptのコンパイルエラー(抜粋)】
型 'string' を型 'number | SQL<unknown> | Placeholder<string, any> | null | undefined' に割り当てることはできません。

エラーメッセージの中に SQLPlaceholder といった見慣れない文字が含まれていますが、これはDrizzleが「動的なSQLの注入」などを許容するための高度な型です。ここで最も重要なのは、「文字列(string)は、数値(number)や空(null)の代わりにはならない」とコンパイラがハッキリと怒ってくれている点です。

これが「型付きの式」としてSQLを扱う最大のメリットです。スキーマ定義(schema.ts)を真実の源として、「PostgreSQLのテーブル構造」が「TypeScriptの型システム」へと完全に写像(マッピング)されているため、コンパイラが「このテーブルのこのカラムに、そのデータ型はおかしい」と事前に気づいてくれるのです。カラム名のタイポ(例:naem: 'Alice')なども同様に弾かれます。

エラーを確認したら、正しい型(数値)に修正して実行してみましょう。

  // ✅ 正しいデータ型の挿入
  await db.insert(users).values({
    name: 'Alice',
    age: 20
  });
  console.log("Aliceを登録しました!");

ターミナルで npx tsx src/index.ts を実行し、成功メッセージが出ればINSERTは完了です。

実験2:SELECT時の自動型推論(手作業での型定義からの解放)

次に、先ほど挿入したデータを取得(SELECT)してみましょう。ここにもDrizzle ORMの強力な機能が隠されています。

index.ts の末尾(client.end()の前)に以下のコードを追加してください。

  // データの取得(SELECT * FROM users と同義)
  const allUsers = await db.select().from(users);

  // 取得したデータの1人目の名前を出力
  console.log(allUsers[0].name); 

従来のNode.js開発では、データベースから返ってくるデータがどのような形をしているかTypeScriptは知らないため、開発者が手作業で interface User { id: number; name: string; age: number; } といった型定義を書き、それを強制的に割り当てる(キャストする)という危険な作業が必要でした。

しかしDrizzleでは、自分で型を一切書いていないのに、allUsers 変数にカーソルを合わせると、自動的に正しい型が推論されていることがわかります。

【Drizzleによって自動推論された型】
const allUsers: { id: number; name: string; age: number | null; }[]

エディタ上で allUsers[0]. と打ち込むと、候補として id, name, age が自動補完(サジェスト)されます。スキーマ定義の時点で age: integer('age') と記述した際、notNull() をつけていなかったため、TypeScript側でも正確に number | null(数値または空)として推論されている点に注目してください。

このように、構造化されたクエリビルダーを用いることで、「書く時(INSERTなど)」も「読む時(SELECTなど)」も、データベースとプログラム間の型が完全に一致し、ミスによるバグの発生を原理的に封じ込めることができるのです。

CREATE (テーブル設計とスキーマ定義)

SQL の CREATE TABLE は、新しいテーブルをデータベースに作成する命令です。Drizzle ORM では、TypeScript でスキーマを定義することで、型安全にテーブルを設計できます。ここでは、Drizzle が提供する主なデータ型、制約(NOT NULL、PRIMARY KEY)、初期値の設定方法を解説します。

Drizzle で標準で使えるデータ型

PostgreSQL を対象としたDrizzle では、以下の主なデータ型が提供されています。

数値型

  • serial('columnName'): 自動採番される整数(1から開始)。PRIMARY KEY に向く。
  • smallserial('columnName'): 小さな自動採番整数(-32,768 〜 32,767)。
  • bigserial('columnName'): 大きな自動採番整数(-9,223,372,036,854,775,808 〜 9,223,372,036,854,775,807)。
  • integer('columnName'): 整数型(-2,147,483,648 〜 2,147,483,647)。
  • smallint('columnName'): 小さい整数型(-32,768 〜 32,767)。
  • bigint('columnName'): 大きい整数型。
  • real('columnName'): 浮動小数点数(単精度)。
  • numeric('columnName', { precision: 10, scale: 2 }): 固定小数点数。precision は全体の桁数、scale は小数点以下の桁数。

文字列型

  • text('columnName'): 可変長の文字列(容量制限なし)。
  • varchar('columnName', { length: 255 }): 可変長の文字列(最大長指定可能)。
  • char('columnName', { length: 10 }): 固定長の文字列(余白はスペースで埋まる)。

日時型

  • date('columnName'): 年月日(2025-02-24)。
  • time('columnName'): 時分秒(14:30:45)。
  • timestamp('columnName'): 年月日と時分秒(2025-02-24 14:30:45)。

その他の型

  • boolean('columnName'): 真偽値(true / false)。
  • uuid('columnName'): UUID(universally unique identifier)。
  • json('columnName'): JSON 形式のデータ。
  • jsonb('columnName'): バイナリ形式の JSON(よりパフォーマンス向上)。

NOT NULL 制約(値が必須であることを指定)

NOT NULL 制約は、そのカラムに NULL(欠損値)を格納できないことを指定します。

【生のSQL】
CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT,
  age INTEGER NOT NULL
);
【Drizzle ORM】
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email'),
  age: integer('age').notNull(),
});

name と age には .notNull() を指定しているため、これらのカラムに NULL を挿入しようとするとエラーが発生します。一方、email は .notNull() がないため、NULL を格納できます。

PRIMARY KEY(主キーの指定)

PRIMARY KEY は、テーブル内の各レコードを一意に識別するカラムの役割を担います。PRIMARY KEY に指定されたカラムは自動的に NOT NULL と UNIQUE(一意)になります。

【生のSQL】
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  price INTEGER
);
【Drizzle ORM】
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

export const products = pgTable('products', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  price: integer('price'),
});

.primaryKey() メソッドを使うことで、そのカラムを主キーとして指定します。PRIMARY KEY に指定されたカラムは自動採番されるため、INSERT 時に値を明示的に指定する必要がありません。

初期値の設定(DEFAULT)

DEFAULT を使うことで、INSERT 時に値が指定されなかった場合の初期値を定義できます。

【生のSQL】
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  status TEXT DEFAULT 'pending',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  quantity INTEGER DEFAULT 1
);
【Drizzle ORM】
import { pgTable, serial, text, timestamp, integer } from 'drizzle-orm/pg-core';

export const orders = pgTable('orders', {
  id: serial('id').primaryKey(),
  status: text('status').default('pending'),
  createdAt: timestamp('created_at').defaultNow(),
  quantity: integer('quantity').default(1),
});

重要なポイント:

  • .default('pending'): 文字列の初期値を指定。
  • .defaultNow(): 現在の日時を自動設定。SQL の CURRENT_TIMESTAMP に対応。
  • .default(1): 数値の初期値を指定。

複合的な例:複数の制約と初期値を組み合わせる

【生のSQL】
CREATE TABLE employees (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  salary NUMERIC(10, 2),
  hired_at DATE DEFAULT CURRENT_DATE,
  is_active BOOLEAN DEFAULT true
);
【Drizzle ORM】
import { pgTable, serial, text, numeric, date, boolean } from 'drizzle-orm/pg-core';

export const employees = pgTable('employees', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  salary: numeric('salary', { precision: 10, scale: 2 }),
  hiredAt: date('hired_at').defaultNow(),
  isActive: boolean('is_active').default(true),
});

CREATE 演習問題

ここまでで学んだデータ型、制約、初期値の設定を、実際にDrizzle ORMを使って定義する練習をしましょう。

Q1. 複数のデータ型を使ったテーブル定義
products テーブルを定義してください。以下のカラムを含めてください:
  • id: 自動採番される整数(主キー)
  • name: 商品名(テキスト)
  • price: 価格(固定小数点、小数点以下2桁)
  • stock: 在庫数(整数)
  • description: 商品説明(テキスト、任意)
  • created_at: 作成日時(タイムスタンプ)
解答を見る
import { pgTable, serial, text, numeric, integer, timestamp } from 'drizzle-orm/pg-core';

export const products = pgTable('products', {
  id: serial('id').primaryKey(),
  name: text('name'),
  price: numeric('price', { precision: 10, scale: 2 }),
  stock: integer('stock'),
  description: text('description'),
  createdAt: timestamp('created_at'),
});

解説:複数のデータ型を組み合わせることで、実務的なテーブル設計ができます。price には固定小数点型 numeric を使用することで、金銭データの精度を保証します。timestamp は日時データを正確に管理します。

Q2. NOT NULL 制約の指定
users テーブルを定義してください。以下のカラムを含めてください:
  • id: 自動採番される整数(主キー)
  • name: ユーザー名(テキスト、必須)
  • email: メールアドレス(テキスト、必須)
  • phone: 電話番号(テキスト、任意)
  • age: 年齢(整数、必須)
解答を見る
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull(),
  phone: text('phone'),
  age: integer('age').notNull(),
});

解説:.notNull() を指定したカラムには、NULL を挿入できません。name、email、age は必須フィールドとなり、データの整合性が保証されます。一方、phone は .notNull() が指定されていないため、NULL を格納できます。

Q3. PRIMARY KEY と初期値の設定
comments テーブルを定義してください。以下のカラムを含めてください:
  • id: 自動採番される整数(主キー)
  • content: コメント内容(テキスト、必須)
  • rating: 評価(整数、デフォルト値は 5)
  • is_verified: 検証済みかどうか(ブール値、デフォルト値は false)
  • created_at: 作成日時(タイムスタンプ、デフォルトは現在時刻)
解答を見る
import { pgTable, serial, text, integer, boolean, timestamp } from 'drizzle-orm/pg-core';

export const comments = pgTable('comments', {
  id: serial('id').primaryKey(),
  content: text('content').notNull(),
  rating: integer('rating').default(5),
  isVerified: boolean('is_verified').default(false),
  createdAt: timestamp('created_at').defaultNow(),
});

解説:.primaryKey() で id を主キーとし、.default() で各カラムのデフォルト値を指定しています。INSERT 時にこれらのカラムへの値の指定を省略した場合、自動的にデフォルト値が使用されます。これにより、アプリケーション側でのデフォルト値管理の手間が削減されます。

サロゲートキーの自動採番

サロゲートキーとは、ビジネス的な意味とは関係なく、レコードを一意に識別するために設計されるキーです。通常、serial または bigserial を使うことで、自動採番される整数値をキーとします。これにより、INSERT時に明示的にキー値を指定する必要がなく、データベースが自動的に値を割り当てます。

【生のSQL】
CREATE TABLE posts (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  content TEXT
);

-- INSERT時にidを指定しない
INSERT INTO posts (title, content) 
VALUES ('タイトル', 'コンテンツ');
-- idは自動採番されて、例えば1, 2, 3... となる
【Drizzle ORM】
import { pgTable, serial, text } from 'drizzle-orm/pg-core';

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content'),
});

// INSERT時にidを指定しない
const result = await db.insert(posts).values({
  title: 'タイトル',
  content: 'コンテンツ',
});
// idは自動採番される

大規模なデータセットを扱う場合は、serial(232万件まで)ではなく bigserial(9,223,372,036,854,775,807件まで)の使用を検討してください。

UUIDを使う場合

UUID(Universally Unique Identifier)は、世界中で一意な識別子を生成する方式です。serial のような連番ではなく、ランダムな128ビットの値を使います。マイクロサービスやクラウド環境では、UUID が主キーとして用いられることが多いです。

【生のSQL】
CREATE TABLE users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  email TEXT NOT NULL
);

-- INSERT時にidを指定しない
INSERT INTO users (name, email) 
VALUES ('太郎', 'taro@example.com');
-- idは自動生成されて、例えば 'a1b2c3d4-e5f6-4g7h-8i9j-0k1l2m3n4o5p' となる
【Drizzle ORM】
import { pgTable, uuid, text } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

export const users = pgTable('users', {
  id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
  name: text('name').notNull(),
  email: text('email').notNull(),
});

// INSERT時にidを指定しない
const result = await db.insert(users).values({
  name: '太郎',
  email: 'taro@example.com',
});
// idは自動生成される

UUID の利点は、分散システムでも競合せずに一意な識別子を生成できることです。ただし、Integerキーよりもストレージとインデックスサイズが大きくなるため、パフォーマンス面での検討が必要です。

CHECK制約

CHECK 制約は、カラムの値が特定の条件を満たすことを保証します。例えば、年齢は0以上、価格は正の数値である、といった条件をデータベースレベルで強制します。

【生のSQL】
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  price NUMERIC(10, 2) CHECK (price > 0),
  stock INTEGER CHECK (stock >= 0)
);

-- price = -100 では INSERT失敗
-- stock = -5 では INSERT失敗
【Drizzle ORM】
import { pgTable, serial, text, numeric, integer } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

export const products = pgTable('products', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  price: numeric('price', { precision: 10, scale: 2 }),
  stock: integer('stock'),
}, (table) => [
  // CHECK制約をテーブル制約として定義
  sql`CHECK (${table.price} > 0)`,
  sql`CHECK (${table.stock} >= 0)`,
]);

CHECK 制約によってデータベースレベルで値の妥当性が検証されるため、不正なデータの挿入を防ぐことができます。これにより、アプリケーション側での宛当性チェックの入れ忘れをカバーできます。

外部キー制約(FK制約):ON DELETE CASCADE

ON DELETE CASCADE は、参照元レコードが削除されたときに、参照先レコードも自動的に削除される動作です。例えば、顧客が削除されたとき、その顧客のすべての注文も自動的に削除されるケースに使用します。

【生のSQL】
CREATE TABLE customers (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  customerId INTEGER NOT NULL,
  totalAmount NUMERIC(10, 2),
  FOREIGN KEY (customerId) REFERENCES customers(id) 
    ON DELETE CASCADE
);

-- customers から id=1 を削除すると、
-- orders の customerId=1 のレコードもすべて削除される
【Drizzle ORM】
import { pgTable, serial, text, integer, numeric } from 'drizzle-orm/pg-core';

export const customers = pgTable('customers', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});

export const orders = pgTable('orders', {
  id: serial('id').primaryKey(),
  customerId: integer('customerId').notNull().references(() => customers.id, { onDelete: 'cascade' }),
  totalAmount: numeric('totalAmount', { precision: 10, scale: 2 }),
});

ON DELETE CASCADE は親レコード削除時に自動で関連レコードも削除するため、手作業で削除する手間が省けます。ただし、生かされたレコードが多い場合は削除処理が重くなるため、パフォーマンス面での検討が必要です。

外部キー制約(FK制約):ON DELETE SET NULL

ON DELETE SET NULL は、参照元レコードが削除されたときに、参照先レコードの外部キーカラムを NULL に設定する動作です。参照先レコードは削除されずに、関連性を失わせるケースに使用します。

【生のSQL】
CREATE TABLE departments (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE employees (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  departmentId INTEGER,
  FOREIGN KEY (departmentId) REFERENCES departments(id) 
    ON DELETE SET NULL
);

-- departments から id=1 を削除すると、
-- employees の departmentId=1 は NULL に設定される
-- employees のレコード自体は削除されない
【Drizzle ORM】
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

export const departments = pgTable('departments', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});

export const employees = pgTable('employees', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  departmentId: integer('departmentId').references(() => departments.id, { onDelete: 'set null' }),
});

ON DELETE SET NULL は参照先レコードを保持したいが、関連性を失わせたい場合に使用します。departmentId が NULL 許可である必要があるため、カラム定義で .notNull() を指定しません。

CREATE(追加制約)演習問題

Q4. サロゲートキーと CHECK 制約
books テーブルを定義してください。以下のカラムを含めてください:
  • id: 自動採番される整数(主キー)
  • title: 書籍名(テキスト、必須)
  • price: 価格(固定小数点、必須、かつ 0より大きい値のみ許可)
  • pages: ページ数(整数、0以上のみ許可)
解答を見る
import { pgTable, serial, text, numeric, integer } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

export const books = pgTable('books', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  price: numeric('price', { precision: 10, scale: 2 }).notNull(),
  pages: integer('pages'),
}, (table) => [
  sql`CHECK (${table.price} > 0)`,
  sql`CHECK (${table.pages} >= 0)`,
]);

解説:CHECK制約をテーブル制約として定義することで、price は常に正の値、pages は常に0以上の値に限定されます。これにより、不正なデータ(負の価格など)の挿入をデータベースレベルで防ぐことができます。

Q5. UUID を使った主キー
sessions テーブルを定義してください。以下のカラムを含めてください:
  • id: UUID(主キー、自動生成)
  • userId: ユーザーID(整数、必須)
  • token: セッショントークン(テキスト、必須)
  • createdAt: 作成日時(タイムスタンプ、デフォルトは現在時刻)
解答を見る
import { pgTable, uuid, integer, text, timestamp } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';

export const sessions = pgTable('sessions', {
  id: uuid('id').primaryKey().default(sql`gen_random_uuid()`),
  userId: integer('userId').notNull(),
  token: text('token').notNull(),
  createdAt: timestamp('createdAt').defaultNow(),
});

解説:UUID を主キーとすることで、分散システムや非同期処理でも競合のない一意な識別子が生成されます。gen_random_uuid() は PostgreSQL の関数で、INSERT 時に自動的に新しい UUID を生成します。

Q6. ON DELETE CASCADE を使った外部キー制約
categoriesitems の2つのテーブルを定義してください。itemscategories を参照しており、カテゴリが削除されるとそのカテゴリに属するすべてのアイテムも削除されるようにしてください。
  • categories: id(主キー)、name(テキスト、必須)
  • items: id(主キー)、name(テキスト、必須)、categoryId(外部キー、ON DELETE CASCADE)
解答を見る
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

export const categories = pgTable('categories', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});

export const items = pgTable('items', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  categoryId: integer('categoryId').notNull().references(() => categories.id, { onDelete: 'cascade' }),
});

解説:.references(() => categories.id, { onDelete: 'cascade' }) により、categories から id=1 を削除すると、items の categoryId=1 のレコードもすべて削除されます。これにより、孤立したデータが残らず、整合性が保たれます。

Q7. ON DELETE SET NULL を使った外部キー制約
managersstaff の2つのテーブルを定義してください。staffmanagers を参照しており、マネージャーが削除されるとそのマネージャーに割り当てられたスタッフの managerId は NULL に設定されるようにしてください。
  • managers: id(主キー)、name(テキスト、必須)
  • staff: id(主キー)、name(テキスト、必須)、managerId(外部キー、ON DELETE SET NULL)
解答を見る
import { pgTable, serial, text, integer } from 'drizzle-orm/pg-core';

export const managers = pgTable('managers', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});

export const staff = pgTable('staff', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  managerId: integer('managerId').references(() => managers.id, { onDelete: 'set null' }),
});

解説:.references(() => managers.id, { onDelete: 'set null' }) により、managers から id=1 を削除すると、staff の managerId=1 は NULL に設定されます。スタッフレコード自体は削除されず、マネージャー情報だけが失われます。managerId が NULL を許可する設定になっていることが重要です。

SELECT

これまでの講義で学んだSQLの柔軟なデータ操作(演算、CASE式、日付処理など)を、Drizzle ORMでどのように記述するのか、機能ごとに細かく確認していきます。

任意のカラム取得とエイリアスの付与

Drizzleの .select() メソッドの引数にオブジェクトを渡すことで、取得するカラムを絞り込み、かつプログラム側での変数名(エイリアス)を定義できます。

【生のSQL】
SELECT 
  item_name AS name,
  price AS unit_price
FROM orders;
【Drizzle ORM】
const result = await db.select({
  name: orders.itemName,
  unitPrice: orders.price,
}).from(orders);

定数値カラムと算術・文字列演算

sql テンプレートリテラルを使用することで、リテラル値や計算式をカラムとして追加できます。sql<型> と記述することで、計算結果の型をTypeScriptに教えることができます。

【生のSQL】
SELECT 
  UPPER(item_name),
  price * quantity AS total,
  'JPY' AS currency
FROM orders;
【Drizzle ORM】
const result = await db.select({
  upperName: sql<string>`UPPER(${orders.itemName})`,
  total: sql<number>`${orders.price} * ${orders.quantity}`,
  currency: sql<string>`'JPY'`,
}).from(orders);

CASE式による条件分岐

SQLの CASE WHEN 式も sql タグ内に記述可能です。Drizzleのカラムオブジェクトを ${} で埋め込むことで、カラム名の変更などにも追従できる安全な式が書けます。

【生のSQL】
SELECT 
  item_name,
  CASE 
    WHEN quantity >= 10 THEN 'Bulk'
    ELSE 'Retail'
  END AS type
FROM orders;
【Drizzle ORM】
const result = await db.select({
  name: orders.itemName,
  type: sql<string>`
    CASE 
      WHEN ${orders.quantity} >= 10 THEN 'Bulk'
      ELSE 'Retail'
    END
  `,
}).from(orders);

NULL値の変換 (COALESCE)

値が NULL の場合にデフォルト値を返す COALESCE 関数も、同様の手法で実装できます。

【生のSQL】
SELECT 
  COALESCE(category, 'None')
FROM orders;
【Drizzle ORM】
const result = await db.select({
  category: sql<string>`COALESCE(${orders.category}, 'None')`,
}).from(orders);

レコードの重複除去 (DISTINCT)

Drizzleには専用の .selectDistinct() メソッドが用意されており、これを使うことで DISTINCT 句を付与できます。

【生のSQL】
SELECT DISTINCT category FROM orders;
【Drizzle ORM】
const result = await db.selectDistinct({
  category: orders.category,
}).from(orders);

日付・時刻関数と現在時刻との演算

PostgreSQLの CURRENT_DATE や日付の引き算も、sql を使って型安全に扱えます。

【生のSQL】
SELECT 
  ordered_at,
  CURRENT_DATE - ordered_at::date AS days_ago
FROM orders;
【Drizzle ORM】
const result = await db.select({
  orderedAt: orders.orderedAt,
  daysAgo: sql<number>`CURRENT_DATE - ${orders.orderedAt}::date`,
}).from(orders);

SELECT 演習問題

ここまでで学んだ様々なSELECT操作を、実際にDrizzle ORMを使って記述する練習をしましょう。以下の6問に挑戦してください。

Q1. カラム・エイリアスの取得
users テーブルから、ユーザーの idname を取得し、プログラム内で変数名を userIduserName に変更してください。生のSQLでは SELECT id AS userId, name AS userName FROM users; です。
解答を見る
const result = await db.select({
  userId: users.id,
  userName: users.name,
}).from(users);

解説:.select() の引数にオブジェクト形式で、{ newName: column } と記述することで、エイリアスを付与できます。TypeScriptの自動補完も効くため、カラム名の間違いは即座に検出されます。

Q2. 定数値カラムと算術演算
orders テーブル(pricequantity カラムがあると想定)から、商品の単価と数量を取得し、合計額(price × quantity)を新しいカラムとして totalPrice という変数名で計算してください。
解答を見る
import { sql } from 'drizzle-orm';

const result = await db.select({
  price: orders.price,
  quantity: orders.quantity,
  totalPrice: sql<number>`${orders.price} * ${orders.quantity}`,
}).from(orders);

解説:sql<型> テンプレートリテラルを使うことで、任意のSQL表現を埋め込めます。${カラム名} とすることで、注入攻撃(SQLインジェクション)に強い安全なクエリが生成されます。

Q3. CASE式による条件分岐
users テーブルの age カラムを基に、ユーザーの年齢グループを分類してください。18歳未満は 'Junior'、18~65歳は 'Adult'、66歳以上は 'Senior' として、ageGroup という変数名で取得してください。
解答を見る
import { sql } from 'drizzle-orm';

const result = await db.select({
  name: users.name,
  age: users.age,
  ageGroup: sql<string>`
    CASE
      WHEN ${users.age} < 18 THEN 'Junior'
      WHEN ${users.age} <= 65 THEN 'Adult'
      ELSE 'Senior'
    END
  `,
}).from(users);

解説:複雑な条件判定は sql タグで直接SQLの CASE WHEN を記述します。Drizzleはこの計算結果の型(<string>)を把握しているため、TypeScript側での操作時にも正しい型チェックが行われます。

Q4. NULL値の変換(COALESCE)
users テーブルの city カラムは NULL の可能性があります。city が NULL であれば 'Unknown' をデフォルト値として返し、そうでなければ元の city の値を location という変数名で取得してください。
解答を見る
import { sql } from 'drizzle-orm';

const result = await db.select({
  name: users.name,
  location: sql<string>`COALESCE(${users.city}, 'Unknown')`,
}).from(users);

解説:COALESCE は最初の非NULL値を返す関数です。ここでは、city が NULL なら 'Unknown'、そうでなければ city の値が location に格納されます。

Q5. 重複除去(DISTINCT)
orders テーブルに複数の注文レコードがありますが、同じカテゴリーの注文が複数存在しています。category カラムの重複を除去して、全てのユニークなカテゴリーのリストを取得してください。
解答を見る
const result = await db.selectDistinct({
  category: orders.category,
}).from(orders);

解説:.select() の代わりに .selectDistinct() を使うことで、SQLの DISTINCT 句が自動的に付与されます。重複するカテゴリーは1件だけ返されます。

Q6. 日付・時刻関数と現在日付の演算
orders テーブルの orderedAt カラム(日付型)から、その注文がいつ前からのものなのか「今日から何日経過しているか」を日数で計算して、daysAgo という変数名で取得してください。PostgreSQLでは、日付から日付を引くと整数(日数)が返ります。
解答を見る
import { sql } from 'drizzle-orm';

const result = await db.select({
  itemName: orders.itemName,
  orderedAt: orders.orderedAt,
  daysAgo: sql<number>`CURRENT_DATE - CAST(${orders.orderedAt} AS DATE)`,
}).from(orders);

解説:PostgreSQLの CURRENT_DATE は「今日の日付」を返します。これから注文日付を引くことで、経過日数が得られます。CAST(...AS DATE) はタイムスタンプ型を日付型に変換します。

ORDER BY

データベースから取得したレコードを特定の順序で並べるには、SQL の ORDER BY 句を使います。ここでは、Drizzle ORM での様々なソート方法を解説します。

標準的なORDER BY の使い方(特定のカラムでの整列)

最も基本的な使い方は、単一のカラムで昇順(ASC)または降順(DESC)に並べることです。

【生のSQL】
SELECT * FROM users 
ORDER BY age ASC;
【Drizzle ORM】
import { asc, desc } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .orderBy(asc(users.age));

Drizzle では asc()desc() 関数を使ってソート方向を指定します。デフォルトは昇順なので、.orderBy(users.age) だけでも昇順になります。

複数ソートキーの指定

複数のカラムを組み合わせてソートする場合、優先順位順に並べて指定します。

【生のSQL】
SELECT * FROM orders 
ORDER BY category ASC, price DESC;
【Drizzle ORM】
import { asc, desc } from 'drizzle-orm';

const result = await db.select()
  .from(orders)
  .orderBy(
    asc(orders.category),
    desc(orders.price)
  );

複数のソートキーを指定する場合、.orderBy() の引数に複数の asc() / desc() 呼び出しをカンマで並べます。左から順に優先度が高くなります。

NULL値のソート位置を指定

データベースには NULL 値が存在することがあります。PostgreSQL では、NULL を昇順の最後に置くか、降順の最初に置くかを制御できます。

【生のSQL】
SELECT * FROM users 
ORDER BY city ASC NULLS FIRST;
【Drizzle ORM】
import { asc } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .orderBy(asc(users.city, { nulls: 'first' }));

asc() / desc() の第2引数にオプションオブジェクトを渡して、nulls: 'first' または nulls: 'last' を指定できます。

CASE式を利用したORDER BY句の指定

ソートの基準を複雑な条件で決めたい場合、CASE 式を使ってカスタムな並べ替え順序を定義できます。例えば、年齢グループごとに優先度を変える場合などです。

【生のSQL】
SELECT * FROM users 
ORDER BY 
  CASE 
    WHEN age < 18 THEN 0
    WHEN age <= 65 THEN 1
    ELSE 2
  END ASC;
【Drizzle ORM】
import { asc, sql } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .orderBy(
    asc(sql<number>`
      CASE 
        WHEN ${users.age} < 18 THEN 0
        WHEN ${users.age} <= 65 THEN 1
        ELSE 2
      END
    `)
  );

sql タグで任意のSQL式を asc() / desc() に渡すことで、複雑なソート基準を定義できます。このアプローチにより、業務ロジックに対応した柔軟な並べ替えが実現できます。

ORDER BY 演習問題

ここまでで学んだ様々なORDER BY操作を、実際にDrizzle ORMを使って記述する練習をしましょう。以下の4問に挑戦してください。

Q1. 標準的なORDER BY の使い方(特定のカラムでの整列)
products テーブルから全ての商品を取得し、価格(price)の昇順で並べてください。生のSQLでは SELECT * FROM products ORDER BY price ASC; です。
解答を見る
import { asc } from 'drizzle-orm';

const result = await db.select()
  .from(products)
  .orderBy(asc(products.price));

解説:.orderBy(asc(カラム)) で昇順にソートできます。降順にしたい場合は desc() を使用します。デフォルトは昇順であるため、.orderBy(products.price) と書くだけでも同じ結果になります。

Q2. 複数ソートキーの指定
employees テーブルから全ての従業員を取得し、部門(department)で昇順ソートしたあと、その部門内で給与(salary)を降順でソートしてください。
解答を見る
import { asc, desc } from 'drizzle-orm';

const result = await db.select()
  .from(employees)
  .orderBy(
    asc(employees.department),
    desc(employees.salary)
  );

解説:複数のソート条件を指定する場合、.orderBy() の引数に複数の条件をカンマで並べます。優先度は左から高くなり、まず部門でグループ分けされ、その中で給与の高い順に並びます。

Q3. NULL値のソート位置を指定
users テーブルから全てのユーザーを取得し、住所(address)で昇順ソートしてください。ただし、住所が NULL のユーザーは最初に表示させてください。
解答を見る
import { asc } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .orderBy(asc(users.address, { nulls: 'first' }));

解説:nulls: 'first' を指定すると、NULL 値が最初に表示されます。逆に最後に表示させたい場合は nulls: 'last' を使用します。このオプションは asc() / desc() 両方で使用可能です。

tasksテーブルの定義

ORDER BY で CASE 式を使う例として、tasks テーブルを用いります。このテーブルは、タスク管理システムで使用される想定です。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, text, varchar } from 'drizzle-orm/pg-core';

export const tasks = pgTable('tasks', {
  id: serial('id').primaryKey(),
  title: text('title').notNull(),
  description: text('description'),
  priority: varchar('priority', { length: 10 }).notNull(), // 'high', 'medium', 'low'
});

このテーブルは以下のカラムで構成されています:

  • id: タスクID(自動採番)
  • title: タスク名(必須)
  • description: タスクの説明(任意)
  • priority: 優先度('high'、'medium'、'low' のいずれか)

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

このコマンドが完了すると、PostgreSQL上に tasks テーブルが作成されます。

Q4. CASE式を利用したORDER BY句の指定
tasks テーブルから全てのタスクを取得し、優先度(priority)を 'high' → 'medium' → 'low' の順に並べてください。カラムの値をそのまま使うのではなく、CASE式で優先度順序を定義してください。
解答を見る
import { asc, sql } from 'drizzle-orm';
import { tasks } from './schema'; // schema.ts からtasks をインポート

const result = await db.select()
  .from(tasks)
  .orderBy(
    asc(sql<number>`
      CASE 
        WHEN ${tasks.priority} = 'high' THEN 0
        WHEN ${tasks.priority} = 'medium' THEN 1
        WHEN ${tasks.priority} = 'low' THEN 2
        ELSE 3
      END
    `)
  );

解説:CASE 式で優先度を数値に変換することで、カスタムな並べ替え順序を定義できます。'high' を 0、'medium' を 1、'low' を 2 とすることで、昇順ソートで目的の優先度順になります。このアプローチは、複雑なソート条件に対応する際に有効です。

LIMIT と OFFSET

大量のデータを含むテーブルから、一度にすべてのレコードを取得することは、メモリ消費が大きく、ネットワークトラフィックも増加するため、アプリケーションのパフォーマンスを悪化させます。このような場合、SQL の LIMIT 句で取得件数を制限し、OFFSET 句で取得開始位置をずらすことで、必要なデータだけを効率的に取得できます。特に Webアプリケーションのページネーション実装や、大規模データセットの処理において、これらの機能は非常に重要です。

基本的なLIMIT の使い方(取得件数の制限)

LIMIT 句を使うことで、クエリの結果を指定した件数に制限できます。

【生のSQL】
SELECT * FROM products
ORDER BY id ASC
LIMIT 10;
【Drizzle ORM】
const result = await db.select()
  .from(products)
  .orderBy(asc(products.id))
  .limit(10);

Drizzle では .limit(数値) メソッドを使うだけで、簡単に件数制限ができます。

OFFSET による取得開始位置の指定

OFFSET 句を使うことで、最初から何件をスキップするかを指定できます。LIMIT と組み合わせることで、ページネーション機能を実装できます。

【生のSQL】
SELECT * FROM products
ORDER BY id ASC
LIMIT 10 OFFSET 20;
【Drizzle ORM】
const result = await db.select()
  .from(products)
  .orderBy(asc(products.id))
  .limit(10)
  .offset(20);

このクエリは、20件をスキップした後、最初の10件を取得します。ページ番号が page 変数に格納されている場合、offset(page * 10) のように計算することで、各ページの相応するデータを取得できます。

ランダムに取得(ORDER BY RANDOM())

PostgreSQL の RANDOM() 関数を使うことで、レコードをランダムな順序で取得できます。

【生のSQL】
SELECT * FROM products
ORDER BY RANDOM()
LIMIT 5;
【Drizzle ORM】
import { asc, sql } from 'drizzle-orm';

const result = await db.select()
  .from(products)
  .orderBy(sql`RANDOM()`)
  .limit(5);

sql タグを使って RANDOM() 関数を orderBy() に渡すことで、ランダムなソートが実現できます。これは、広告のローテーション、推奨商品のシャッフル表示など、様々な場面で役立ちます。

実験:大量データを挿入してページネーションを検証する

LIMIT と OFFSET の効果を体感するために、テストデータを大量に挿入し、実際のページネーション処理を実装してみましょう。

まず、src/schema.ts を編集して、テスト用の products テーブルを追加します。

// src/schema.ts
import { pgTable, serial, text, integer, real, timestamp } from 'drizzle-orm/pg-core';

// 既存のテーブル定義...

// テスト用の products テーブル
export const products = pgTable('products', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  price: real('price').notNull(),
  createdAt: timestamp('created_at').defaultNow(),
});

次に、src/index.ts を編集して、1000件のテストデータを挿入し、取得するコードを書きます。

// src/index.ts の main() 関数内に追加
import { products } from './schema';
import { sql } from 'drizzle-orm';

async function main() {
  await client.connect();
  const db = drizzle(client);

  // テストデータの挿入(1000件)
  console.log("テストデータを作成中...");
  const testData = Array.from({ length: 1000 }, (_, i) => ({
    name: `Product ${i + 1}`,
    price: Math.random() * 10000,
  }));

  // バッチ挿入(一度に全部挿入)
  await db.insert(products).values(testData);
  console.log("1000件のテストデータを挿入しました!");

  async function getPaginatedProducts(pageNum: number, pageSize: number = 10) {
  const offset = (pageNum - 1) * pageSize;
  
  const result = await db.select({
    id: products.id,
    name: products.name,
    price: products.price,
  })
  .from(products)
  .orderBy(asc(products.id))
  .limit(pageSize)
  .offset(offset);

  return result;
}

// ページ1(最初の10件)を取得
const page1 = await getPaginatedProducts(1);
console.log(`ページ1: ${page1.length}件取得`);
console.log(page1);

// ページ50(491〜500件目)を取得
const page50 = await getPaginatedProducts(50);
console.log(`ページ50: ${page50.length}件取得`);
console.log(page50);

  await client.end();
}

このコードを実行すると、各ページが効率的に取得できることが確認できます。大規模なテーブルから必要な範囲だけをデータベースサーバーで絞り込むため、ネットワークトラフィックとメモリ使用量が大幅に削減されます。

ランダム取得の実装例

次に、RANDOM() を使ったランダム取得の実装例です。

// ランダムに5件の商品を取得
const randomProducts = await db.select()
  .from(products)
  .orderBy(sql`RANDOM()`)
  .limit(5);

console.log("ランダムに取得した5件の商品:");
randomProducts.forEach(p => {
  console.log(`  ${p.name}: ¥${p.price.toFixed(2)}`);
});

このアプローチは、推奨商品表示、広告ローテーション、A/B テストなど、多くの実務的なシーンで活躍します。ただし注意点として、大規模なテーブルに対して ORDER BY RANDOM() を使う場合、データベースがランダムソートのために全行をメモリに読み込む必要が生じるため、処理が重くなります。そのような場合は、更新頻度に応じて事前に計算したランダムな ID を用いるなど、工夫が必要です。

WHERE

SQL の WHERE 句は、テーブルから取得するレコードを「条件」に基づいて絞り込む機能です。データベースから膨大なレコードを取得してからプログラム側でフィルタリングするのではなく、データベース側で条件判定を行うことで、ネットワークトラフィックとメモリ消費を大幅に削減できます。

基本的なWHERE の使い方

最もシンプルな WHERE は、単一のカラムを特定の値と比較します。データ型(文字列・数値・日付)によって比較演算子の使い方は同じですが、Drizzle では型安全にこれを記述できます。

文字列型の比較

【生のSQL】
SELECT * FROM users 
WHERE name = 'Alice';
【Drizzle ORM】
import { eq } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(eq(users.name, 'Alice'));

数値型の比較

【生のSQL】
SELECT * FROM users 
WHERE age > 20;
【Drizzle ORM】
import { gt } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(gt(users.age, 20));

Drizzle では、SQL の比較演算子を関数として提供しています。主な関数としては以下があります:

  • eq(): = 等号
  • ne(): != または <> 不等号
  • gt(): > より大きい
  • gte(): >= 以上
  • lt(): < より小さい
  • lte(): <= 以下

日付型の比較

【生のSQL】
SELECT * FROM orders 
WHERE created_at > '2025-01-01';
【Drizzle ORM】
import { gt } from 'drizzle-orm';

const result = await db.select()
  .from(orders)
  .where(gt(orders.createdAt, new Date('2025-01-01')));

3値論理のNOT、AND、ORによる複合条件

複数の条件を組み合わせるには、and()or()not() 関数を使います。

AND(両方の条件を満たす)

【生のSQL】
SELECT * FROM users 
WHERE age > 20 AND name = 'Bob';
【Drizzle ORM】
import { and, gt, eq } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(
    and(
      gt(users.age, 20),
      eq(users.name, 'Bob')
    )
  );

OR(どちらかの条件を満たす)

【生のSQL】
SELECT * FROM users 
WHERE age < 18 OR age > 65;
【Drizzle ORM】
import { or, lt, gt } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(
    or(
      lt(users.age, 18),
      gt(users.age, 65)
    )
  );

NOT(条件を否定)と NULL チェック

【生のSQL】
SELECT * FROM users 
WHERE age IS NOT NULL;
【Drizzle ORM】
import { isNotNull } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(isNotNull(users.age));

IN による複数値の比較

IN 句は、指定した複数の値のいずれかに該当するレコードを取得します。単純な OR の連結と比べ、より簡潔で可読性が高いコードが書けます。

【生のSQL】
SELECT * FROM users 
WHERE id IN (1, 3, 5, 7);
【Drizzle ORM】
import { inArray } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(inArray(users.id, [1, 3, 5, 7]));

LIKE による部分一致

LIKE 句を使うことで、文字列の部分一致を検索できます。% はワイルドカード(任意の文字列)を表します。

【生のSQL】
SELECT * FROM users 
WHERE name LIKE 'A%';
【Drizzle ORM】
import { like } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(like(users.name, 'A%'));

よく使う LIKE パターン:

  • 'A%': A で始まる
  • '%A': A で終わる
  • '%A%': A を含む
  • 'A_B': A と B の間に1文字(アンダースコアは1文字を表す)

BETWEEN による範囲指定

BETWEEN 句は、指定した範囲内の値を取得します。

【生のSQL】
SELECT * FROM products 
WHERE price BETWEEN 1000 AND 5000;
【Drizzle ORM】
import { between } from 'drizzle-orm';

const result = await db.select()
  .from(products)
  .where(between(products.price, 1000, 5000));

BETWEEN min AND maxvalue >= min AND value <= max に相当します。両端の値を含む(包括的な)範囲指定です。

CASE式を利用したWHERE句の指定

複雑な条件判定が必要な場合、CASE 式を WHERE 句で使うことで、より柔軟な条件指定ができます。

【生のSQL】
SELECT * FROM users 
WHERE CASE 
  WHEN age < 18 THEN 1
  WHEN age BETWEEN 18 AND 65 THEN 0
  ELSE 1
END = 1;
【Drizzle ORM】
import { eq, sql } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(
    eq(
      sql<number>`
        CASE 
          WHEN ${users.age} < 18 THEN 1
          WHEN ${users.age} BETWEEN 18 AND 65 THEN 0
          ELSE 1
        END
      `,
      1
    )
  );

このアプローチは、複数の条件を組み合わせた複雑な論理を SQL レベルで実装する際に有効です。例えば、顧客のセグメント分類やリスク評価など、カテゴリー分けが必要な場面で活躍します。

WHERE 演習問題

ここまでで学んだ様々なWHERE操作を、実際にDrizzle ORMを使って記述する練習をしましょう。以下の7問に挑戦してください。

Q1. 標準的なWHERE の使い方(特定のカラムでの比較)
products テーブルから、価格(price)が 5000 より大きい商品を全て取得してください。生のSQLでは SELECT * FROM products WHERE price > 5000; です。
解答を見る
import { gt } from 'drizzle-orm';

const result = await db.select()
  .from(products)
  .where(gt(products.price, 5000));

解説:.where() メソッドに gt()(グレーター・ザン)関数を渡すことで、「5000より大きい」という条件を安全に指定できます。比較演算子は型安全であり、カラムのデータ型に合わない値を渡そうとするとTypeScriptエラーになります。

Q2. 基本的なWHERE の使い方(値の一致判定)
users テーブルから、ステータス(status)が 'active' に一致するユーザーを全て取得してください。生のSQLでは SELECT * FROM users WHERE status = 'active'; です。
解答を見る
import { eq } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(eq(users.status, 'active'));

解説:eq()(イコール)関数は SQL の = 演算子に対応し、値の完全一致を判定します。文字列、数値、日付など、あらゆるデータ型に対して同じ関数を使用でき、Drizzle が型チェックを行うため安全です。

Q3. 3値論理のAND と OR による複合条件
employees テーブルから、給与(salary)が 30000 以上 かつ 部門(department)が 'Sales' であるか、部門が 'Management' である従業員を取得してください。生のSQLでは SELECT * FROM employees WHERE (salary >= 30000 AND department = 'Sales') OR department = 'Management'; です。
解答を見る
import { and, or, gte, eq } from 'drizzle-orm';

const result = await db.select()
  .from(employees)
  .where(
    or(
      and(
        gte(employees.salary, 30000),
        eq(employees.department, 'Sales')
      ),
      eq(employees.department, 'Management')
    )
  );

解説:複数の条件を and() で結合し、その結果をさらに or() で別の条件と結合しています。関数の入れ子構造が SQL の括弧 () に対応し、条件の優先度を制御できます。

Q4. IN による複数値の比較
orders テーブルから、ステータス(status)が 'pending'、'processing'、'shipped のいずれか' に該当する注文を全て取得してください。生のSQLでは SELECT * FROM orders WHERE status IN ('pending', 'processing', 'shipped'); です。
解答を見る
import { inArray } from 'drizzle-orm';

const result = await db.select()
  .from(orders)
  .where(inArray(orders.status, ['pending', 'processing', 'shipped']));

解説:inArray() は SQL の IN に対応し、複数の値のいずれかに該当するデータを取得します。or(eq(...), eq(...), eq(...)) を3つ書く代わりに、配列で値をまとめて指定できるため、コードがシンプルで読みやすくなります。

Q5. LIKE による部分一致
products テーブルから、商品名(name)が 'Smart' を含む全ての商品を取得してください。生のSQLでは SELECT * FROM products WHERE name LIKE '%Smart%'; です。
解答を見る
import { like } from 'drizzle-orm';

const result = await db.select()
  .from(products)
  .where(like(products.name, '%Smart%'));

解説:like() は SQL の LIKE 演算子に対応し、文字列の部分一致を検索します。'%Smart%' は「前後に任意の文字(ワイルドカード)+ Smart + 前後に任意の文字」という意味で、'Smart' を含む全ての文字列に該当します。

Q6. BETWEEN による範囲指定
orders テーブルから、注文額(amount)が 1000 以上 10000 以下の注文を全て取得してください。生のSQLでは SELECT * FROM orders WHERE amount BETWEEN 1000 AND 10000; です。
解答を見る
import { between } from 'drizzle-orm';

const result = await db.select()
  .from(orders)
  .where(between(orders.amount, 1000, 10000));

解説:between() は SQL の BETWEEN min AND max に対応し、指定した範囲内のデータを取得します。最小値も最大値も含まれます(包括的な範囲)。gte(1000)lte(10000)and() で結合することと同義です。

Q7. CASE式を利用したWHERE句の指定
users テーブルから、年齢層(age)に基づいて「高リスク」(18歳未満 または 65歳以上)に分類されるユーザーを全て取得してください。CASE式を使って、年齢層それぞれに数値を割り当て、その結果が「1」(高リスク)に該当するデータを抽出してください。
解答を見る
import { eq, sql } from 'drizzle-orm';

const result = await db.select()
  .from(users)
  .where(
    eq(
      sql<number>`
        CASE 
          WHEN ${users.age} < 18 THEN 1
          WHEN ${users.age} >= 65 THEN 1
          ELSE 0
        END
      `,
      1
    )
  );

解説:CASE 式で「18歳未満なら1、65歳以上なら1、それ以外は0」と定義し、その結果が「1」に等しいレコードを抽出しています。ビジネスロジックを SQL レベルで実装することで、複雑なカテゴリー分類や判定ロジックをデータベースサーバーで処理でき、処理効率が向上します。

集約関数(Aggregate Functions)

集約関数は、複数のレコードに対して統計計算(合計、平均、個数のカウントなど)を行う SQL の機能です。例えば、売上の合計、商品の平均価格、顧客数など、データ全体の要約統計量を素早く計算できます。これらの計算をプログラム側で行わず、データベースサーバーで実行することで、ネットワークトラフィックとプログラムのメモリ消費を大幅に削減できます。

COUNT(レコード数のカウント)

COUNT() 関数は、指定したカラムの非NULL値の個数をカウントします。COUNT(*) を使うと、すべてのレコード数を返します。

【生のSQL】
SELECT COUNT(*) FROM orders;
【Drizzle ORM】
import { count } from 'drizzle-orm';

const result = await db.select({
  totalOrders: count(),
}).from(orders);

特定のカラムの非NULL値のみをカウントしたい場合、COUNT(columnName) と指定します。

【生のSQL】
SELECT COUNT(phone) FROM users;
【Drizzle ORM】
import { count } from 'drizzle-orm';

const result = await db.select({
  usersWithPhone: count(users.phone),
}).from(users);

SUM(合計の計算)

SUM() 関数は、指定したカラムの値の合計を計算します。NULL値は無視されます。

【生のSQL】
SELECT SUM(amount) FROM orders;
【Drizzle ORM】
import { sum } from 'drizzle-orm';

const result = await db.select({
  totalAmount: sum(orders.amount),
}).from(orders);

NULL値を特定の値(例:0)に置き換えてから合計を求める場合、COALESCE() を使用します。

【生のSQL】
SELECT SUM(COALESCE(discount, 0)) FROM orders;
【Drizzle ORM】
import { sum, sql } from 'drizzle-orm';

const result = await db.select({
  totalDiscount: sum(sql<number>`COALESCE(${orders.discount}, 0)`),
}).from(orders);

AVG(平均値の計算)

reviewsテーブルの定義

AVG関数を使う例として、reviews テーブルを用います。このテーブルは、商品レビュー機能で使用される想定です。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, integer, text, timestamp } from 'drizzle-orm/pg-core';

export const reviews = pgTable('reviews', {
  id: serial('id').primaryKey(),
  productId: integer('product_id').notNull(),
  userId: integer('user_id').notNull(),
  rating: integer('rating'), // 1-5のスコア(NULL可能)
  comment: text('comment'),
  createdAt: timestamp('created_at').defaultNow(),
});

このテーブルは以下のカラムで構成されています:

  • id: レビューID(自動採番)
  • productId: レビュー対象の商品ID
  • userId: レビューを書いたユーザーID
  • rating: 評価スコア(1〜5等、任意)
  • comment: レビューコメント(任意)
  • createdAt: レビュー作成日時

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

AVG関数での使用例

AVG() 関数は、指定したカラムの値の平均を計算します。NULL値は無視されます。

【生のSQL】
SELECT AVG(price) FROM products;
【Drizzle ORM】
import { avg } from 'drizzle-orm';

const result = await db.select({
  averagePrice: avg(products.price),
}).from(products);

NULL値を特定の値に置き換えてから平均を求める場合、SUM と同様に COALESCE() を使用します。

【生のSQL】
SELECT AVG(COALESCE(rating, 3)) FROM reviews;
【Drizzle ORM】
import { avg, sql } from 'drizzle-orm';
import { reviews } from './schema'; // schema.ts からreviews をインポート

const result = await db.select({
  averageRating: avg(sql<number>`COALESCE(${reviews.rating}, 3)`),
}).from(reviews);

解説:SUM と同様に COALESCE() を使うことで、NULL値を指定した値に置き換えてから平均を計算します。この例では、評価が未入力のレビューは3(中立)として扱われます。

MAX(最大値の取得)

MAX() 関数は、指定したカラムの最大値を返します。

【生のSQL】
SELECT MAX(salary) FROM employees;
【Drizzle ORM】
import { max } from 'drizzle-orm';

const result = await db.select({
  maxSalary: max(employees.salary),
}).from(employees);

MIN(最小値の取得)

MIN() 関数は、指定したカラムの最小値を返します。

【生のSQL】
SELECT MIN(price) FROM products;
【Drizzle ORM】
import { min } from 'drizzle-orm';

const result = await db.select({
  minPrice: min(products.price),
}).from(products);

文字列の長さ(CHAR_LENGTH または LENGTH)

文字列型のカラムに対して、その文字列の長さを計算できます。PostgreSQL では CHAR_LENGTH() または LENGTH() が使用できます。

【生のSQL】
SELECT name, CHAR_LENGTH(name) AS nameLength 
FROM users;
【Drizzle ORM】
import { sql } from 'drizzle-orm';

const result = await db.select({
  name: users.name,
  nameLength: sql<number>`CHAR_LENGTH(${users.name})`,
}).from(users);

複数の集約関数を同時に使用する場合、1つの .select() の中にオブジェクトの複数プロパティとして記述できます。

【生のSQL】
SELECT 
  COUNT(*) AS totalOrders,
  SUM(amount) AS totalAmount,
  AVG(amount) AS avgAmount,
  MAX(amount) AS maxAmount,
  MIN(amount) AS minAmount
FROM orders;
【Drizzle ORM】
import { count, sum, avg, max, min } from 'drizzle-orm';

const result = await db.select({
  totalOrders: count(),
  totalAmount: sum(orders.amount),
  avgAmount: avg(orders.amount),
  maxAmount: max(orders.amount),
  minAmount: min(orders.amount),
}).from(orders);

集約関数 演習問題

ここまでで学んだ様々な集約関数を、実際にDrizzle ORMを使って記述する練習をしましょう。以下の6問に挑戦してください。

Q1. COUNT による全レコード数のカウント
orders テーブルから、全注文の個数を取得してください。結果を totalOrders という変数名で取得してください。生のSQLでは SELECT COUNT(*) FROM orders; です。
解答を見る
import { count } from 'drizzle-orm';

const result = await db.select({
  totalOrders: count(),
}).from(orders);

解説:count() の引数を指定しない場合、SQL の COUNT(*) に対応し、全レコード数を返します。NULL値のチェックは行わず、すべてのレコードをカウントします。

Q2. COUNT(特定カラムの非NULL値のカウント)
users テーブルから、電話番号(phone)が登録されているユーザーの数を取得してください。結果を usersWithPhone という変数名で取得してください。NULL値を除外してカウントしてください。
解答を見る
import { count } from 'drizzle-orm';

const result = await db.select({
  usersWithPhone: count(users.phone),
}).from(users);

解説:count(column) にカラムを指定すると、その カラムの非NULL値のみをカウントします。phone が NULL であるユーザーは除外されます。

Q3. SUM による合計計算と NULLの置き換え
orders テーブルから、割引額(discount)の合計を計算してください。ただし、割引額が NULL の場合は 0 として扱ってください。結果を totalDiscount という変数名で取得してください。
解答を見る
import { sum, sql } from 'drizzle-orm';

const result = await db.select({
  totalDiscount: sum(sql<number>`COALESCE(${orders.discount}, 0)`),
}).from(orders);

解説:COALESCE() 関数を使うことで、NULL値を0に置き換えてから合計を計算します。これにより、割引が設定されなかった注文でも統計計算に含められます。

Q4. AVG による平均計算と NULLの置き換え
reviews テーブルから、評価(rating)の平均値を計算してください。ただし、評価が NULL の場合は 3 (中立)として扱ってください。結果を averageRating という変数名で取得してください。
解答を見る
import { avg, sql } from 'drizzle-orm';
import { reviews } from './schema'; // schema.ts からreviews をインポート

const result = await db.select({
  averageRating: avg(sql<number>`COALESCE(${reviews.rating}, 3)`),
}).from(reviews);

解説:SUM と同様に COALESCE() を使うことで、NULL値を指定した値に置き換えてから平均を計算します。これにより、評価が未入力のレビューもデータセットに含められます。

Q5. MAX による最大値の取得
employees テーブルから、最も給与の高い従業員の給与を取得してください。結果を maxSalary という変数名で取得してください。生のSQLでは SELECT MAX(salary) FROM employees; です。
解答を見る
import { max } from 'drizzle-orm';

const result = await db.select({
  maxSalary: max(employees.salary),
}).from(employees);

解説:max() 関数は、指定したカラムの最大値を返します。ウィンドウ関数と異なり、最大値を持つレコード全体を取得するのではなく、その値だけを1行で返します。

Q6. MIN による最小値の取得
products テーブルから、最も安い商品の価格を取得してください。結果を minPrice という変数名で取得してください。生のSQLでは SELECT MIN(price) FROM products; です。
解答を見る
import { min } from 'drizzle-orm';

const result = await db.select({
  minPrice: min(products.price),
}).from(products);

解説:min() 関数は、指定したカラムの最小値を返します。MAX と同様に、最小値を持つレコード全体ではなく、その値だけを1行で返します。

Q7. CHAR_LENGTH による文字列長の計算
users テーブルから、ユーザーの名前(name)とその名前の文字数を取得してください。名前と文字数をそれぞれ namenameLength という変数名で取得してください。
解答を見る
import { sql } from 'drizzle-orm';

const result = await db.select({
  name: users.name,
  nameLength: sql<number>`CHAR_LENGTH(${users.name})`,
}).from(users);

解説:CHAR_LENGTH() は文字列の長さ(文字数)を返します。PostgreSQL では LENGTH() も同じ意味で使用できます。集約関数ではなく、単一行ごとの演算で、各ユーザーの名前の長さが返されます。

GROUP BY

SQL の GROUP BY 句は、テーブルのレコードを指定したカラムの値によって「グループ分け」し、各グループごとに集約関数(COUNT、SUM、AVGなど)を適用する機能です。例えば、商品を「カテゴリー」でグループ分けして、カテゴリー別の売上合計を計算するといった用途に用いられます。

集約キー以外はSELECTに指定できない(GROUP BY の厳密な制約)

GROUP BY を使う際の最も重要なルールは、SELECT 句に指定できるカラムは「GROUP BY 句に指定したカラム」と「集約関数(COUNT、SUM など)のみ」ということです。これは、グループ化されたデータでは、グループキー以外のカラムには複数の異なる値が存在する可能性があり、どの値を返すべきかが不明確になるためです。

-- ❌ 間違い:department でグループ化しているのに、name を指定している
SELECT department, name, COUNT(*) FROM employees GROUP BY department;

-- ✅ 正しい:GROUP BY で指定したカラムと集約関数だけを SELECT
SELECT department, COUNT(*) FROM employees GROUP BY department;

単一の集約キーによるグループ化

最も基本的なグループ化では、1つのカラムでレコードを分類します。

【生のSQL】
SELECT category, COUNT(*) 
FROM products
GROUP BY category;
【Drizzle ORM】
import { count } from 'drizzle-orm';

const result = await db.select({
  category: products.category,
  productCount: count(),
})
  .from(products)
  .groupBy(products.category);

複数の集約キーを指定する

複数のカラムでグループ化することで、より細かいグループに分類できます。例えば、「部門×給与レベル」のように2つの軸で分類することで、より詳細な分析が可能になります。

【生のSQL】
SELECT department, status, COUNT(*), AVG(salary)
FROM employees
GROUP BY department, status;
【Drizzle ORM】
import { count, avg } from 'drizzle-orm';

const result = await db.select({
  department: employees.department,
  status: employees.status,
  employeeCount: count(),
  avgSalary: avg(employees.salary),
})
  .from(employees)
  .groupBy(employees.department, employees.status);

ROLLUP による小計と全体計の自動生成

ROLLUP は、GROUP BY の拡張機能で、グループごとの小計に加えて、全体計を自動的に生成します。例えば月別の売上と、全月の合計売上を同時に取得できます。

【生のSQL】
SELECT year, month, SUM(amount)
FROM orders
GROUP BY ROLLUP(year, month);
【Drizzle ORM】
import { sum, sql } from 'drizzle-orm';

const result = await db.select({
  year: orders.year,
  month: orders.month,
  totalAmount: sum(orders.amount),
})
  .from(orders)
  .groupBy(sql`ROLLUP(${orders.year}, ${orders.month})`);

GROUP BY に CASE を使う(ビジネスロジックに基づくグループ化)

CASE 式を GROUP BY と組み合わせることで、カラムの値をカテゴリーに変換してからグループ化できます。例えば、年齢を「若年層」「壮年層」「高齢層」に分類してからグループ化する場合などです。

【生のSQL】
SELECT 
  CASE 
    WHEN age < 30 THEN '20s'
    WHEN age < 50 THEN '30-40s'
    ELSE '50+'
  END AS ageGroup,
  COUNT(*)
FROM users
GROUP BY ageGroup;
【Drizzle ORM】
import { count, sql } from 'drizzle-orm';

const ageGroupExpr = sql<string>`
  CASE 
    WHEN ${users.age} < 30 THEN '20s'
    WHEN ${users.age} < 50 THEN '30-40s'
    ELSE '50+'
  END
`;

const result = await db.select({
  ageGroup: ageGroupExpr,
  userCount: count(),
})
  .from(users)
  .groupBy(ageGroupExpr);

DATE_TRUNC を使って時間軸に沿って集計処理を行う

DATE_TRUNC() は日時型のカラムを指定した単位(年、月、日、時など)に切り捨てる関数です。これを使うことで、時系列データを時間単位でグループ化できます。例えば、日次売上、月次売上などの時系列分析が可能になります。

【生のSQL】
SELECT 
  DATE_TRUNC('month', created_at) AS month,
  SUM(amount)
FROM orders
GROUP BY DATE_TRUNC('month', created_at);
【Drizzle ORM】
import { sum, sql } from 'drizzle-orm';

const monthExpr = sql<Date>`DATE_TRUNC('month', ${orders.createdAt})`;

const result = await db.select({
  month: monthExpr,
  totalAmount: sum(orders.amount),
})
  .from(orders)
  .groupBy(monthExpr);

DATE_TRUNC() の指定可能な単位:

  • 'year': 年単位
  • 'month': 月単位
  • 'week': 週単位
  • 'day': 日単位
  • 'hour': 時単位
  • 'minute': 分単位

GROUP BY 演習問題

ここまでで学んだ様々なGROUP BY操作を、実際にDrizzle ORMを使って記述する練習をしましょう。以下の5問に挑戦してください。

Q1. 基本的なGROUP BY(単一の集約キー)
orders テーブルから、各ステータス(status)ごとの注文数を取得してください。結果を statusorderCount として取得してください。
解答を見る
import { count } from 'drizzle-orm';

const result = await db.select({
  status: orders.status,
  orderCount: count(),
})
  .from(orders)
  .groupBy(orders.status);

解説:.groupBy()orders.status を指定することで、ステータスごとにレコードをグループ化します。SELECT に指定できるのは、グループキー(status)と集約関数(count())だけです。

Q2. 複数の集約キーを指定する
employees テーブルから、部門(department)と給与レベル(salaryLevel)ごとの従業員数と平均給与を取得してください。結果を departmentsalaryLevelemployeeCountavgSalary として取得してください。
解答を見る
import { count, avg } from 'drizzle-orm';

const result = await db.select({
  department: employees.department,
  salaryLevel: employees.salaryLevel,
  employeeCount: count(),
  avgSalary: avg(employees.salary),
})
  .from(employees)
  .groupBy(employees.department, employees.salaryLevel);

解説:.groupBy() に複数のカラムを指定することで、それらの組み合わせごとにグループ化します。SELECT に指定できるのは、両方のグループキーと集約関数だけです。

Q3. ROLLUP による小計・全体計の生成
orders テーブルから、年(year)と月(month)ごとの売上合計を取得してください。ただし、年ごとの小計と全体計も同時に返すようにしてください。結果を yearmonthtotalAmount として取得してください。
解答を見る
import { sum, sql } from 'drizzle-orm';

const result = await db.select({
  year: orders.year,
  month: orders.month,
  totalAmount: sum(orders.amount),
})
  .from(orders)
  .groupBy(sql`ROLLUP(${orders.year}, ${orders.month})`);

解説:ROLLUP(year, month) を使うことで、月別の集計に加えて年別の小計と全体計が自動的に生成されます。month が NULL の行は年ごとの小計、year と month の両方が NULL の行は全体計を表します。

Q4. GROUP BY に CASE を使う
products テーブルから、価格範囲(price)ごとの商品数と平均価格を取得してください。ただし、価格を「安い(1000未満)」「中程度(1000以上5000未満)」「高い(5000以上)」に分類してからグループ化してください。結果を priceRangeproductCountavgPrice として取得してください。
解答を見る
import { count, avg, sql } from 'drizzle-orm';

const priceRangeExpr = sql<string>`
  CASE 
    WHEN ${products.price} < 1000 THEN '安い'
    WHEN ${products.price} < 5000 THEN '中程度'
    ELSE '高い'
  END
`;

const result = await db.select({
  priceRange: priceRangeExpr,
  productCount: count(),
  avgPrice: avg(products.price),
})
  .from(products)
  .groupBy(priceRangeExpr);

解説:CASE 式を変数に格納してから、SELECT と groupBy の両方で使用することが重要です。これにより、CASE の計算結果を基準にグループ化できます。ビジネスロジックに基づいた複雑なグループ分けが可能になります。

Q5. DATE_TRUNC を使った時間軸での集計
orders テーブルから、月ごと(created_at を月単位で切り捨て)の売上合計と注文数を取得してください。結果を monthtotalAmountorderCount として取得してください。
解答を見る
import { sql, sum, count } from 'drizzle-orm';

const monthExpr = sql<Date>`DATE_TRUNC('month', ${orders.createdAt})`;

const result = await db.select({
  month: monthExpr,
  totalAmount: sum(orders.amount),
  orderCount: count(),
})
  .from(orders)
  .groupBy(monthExpr);

解説:DATE_TRUNC('month', ...) で日時を月単位に切り捨てることで、月ごとの売上を集計できます。日時データを時間軸で分析する際の基本的なテクニックです。orderCount の代わりに sum や avg を使うことで、より複雑な時系列分析も可能になります。

HAVING

SQL の HAVING 句は、GROUP BY でグループ化した後に、集約関数の結果に基づいて「グループを絞り込む」機能です。一見すると WHERE に似ていますが、WHERE はグループ化前に個別レコードを絞り込み、HAVING はグループ化後に集計結果を絞り込むという重要な違いがあります。

WHERE と HAVING の違い

この違いを理解することは SQL を使いこなすために非常に重要です。

WHERE HAVING
グループ化にレコードを絞り込む グループ化にグループを絞り込む
個別カラムの値で条件指定 集約関数(COUNT、SUM など)の結果で条件指定
例:年齢 > 20 のユーザーだけ集計 例:従業員数が10人以上の部門だけ表示

WHERE と HAVING の実装例

同じ「部門ごとの従業員数」を取得する場合でも、WHERE と HAVING で結果が変わります。

【WHERE を使う場合】
-- グループ化前に:年収が50000以上のみ
SELECT department, COUNT(*)
FROM employees
WHERE salary >= 50000
GROUP BY department;
【HAVING を使う場合】
-- グループ化後に:従業員数が5人以上
SELECT department, COUNT(*)
FROM employees
GROUP BY department
HAVING COUNT(*) >= 5;

Drizzle ORM での HAVING の実装

Drizzle では .having() メソッドを使用します。WHERE と同じ条件関数を使用できます。

【生のSQL】
SELECT department, COUNT(*) AS empCount
FROM employees
GROUP BY department
HAVING COUNT(*) >= 5;
【Drizzle ORM】
import { count, gte } from 'drizzle-orm';

const result = await db.select({
  department: employees.department,
  empCount: count(),
})
  .from(employees)
  .groupBy(employees.department)
  .having(gte(count(), 5));

重要なポイント:WHERE と HAVING を同時に使う場合、WHERE でグループ化前にレコードを絞り込むことで、データベースの処理が効率化されます。

【生のSQL】
SELECT department, COUNT(*) AS empCount
FROM employees
WHERE salary >= 50000
GROUP BY department
HAVING COUNT(*) >= 5;
【Drizzle ORM】
import { count, gte } from 'drizzle-orm';

const result = await db.select({
  department: employees.department,
  empCount: count(),
})
  .from(employees)
  .where(gte(employees.salary, 50000))
  .groupBy(employees.department)
  .having(gte(count(), 5));

HAVING 演習問題

WHERE と HAVING の違いを理解するため、2つの問題を用意しました。

Q1.
orders テーブルから、顧客(customerId)ごとの注文数を取得してください。ただし、注文日付(createdAt)が2025年01月01日以降の注文のみをカウントしてください。結果を customerIdorderCount として取得してください。
解答を見る
import { count, gte } from 'drizzle-orm';

const result = await db.select({
  customerId: orders.customerId,
  orderCount: count(),
})
  .from(orders)
  .where(gte(orders.createdAt, new Date('2025-01-01')))
  .groupBy(orders.customerId);

解説:WHERE を使う場合の目的は、グループ化する前に不要なレコードを除外することです。この例では、2025年01月01日より前の古い注文はカウント対象から外されます。WHERE を使うことで、データベースが処理する行数が減り、パフォーマンスが向上します。

Q2.
orders テーブルから、顧客(customerId)ごとの注文数を取得してください。ただし、同じ顧客からの注文数が3件以上の顧客のみを表示してください。結果を customerIdorderCount として取得してください。
解答を見る
import { count, gte } from 'drizzle-orm';

const result = await db.select({
  customerId: orders.customerId,
  orderCount: count(),
})
  .from(orders)
  .groupBy(orders.customerId)
  .having(gte(count(), 3));

解説:HAVING を使う場合の目的は、グループ化した後に集計結果で条件指定することです。この例では、すべての顧客の注文数を集計してから、3件以上の注文がある顧客のみをフィルタリングしています。集約関数の結果を条件として使う場合は、WHERE ではなく HAVING を使う必要があります。

サブクエリ(副問い合わせ)

サブクエリ(副問い合わせ)とは、SELECT文の中に別のSELECT文を埋め込んだ構造です。大きなSELECT文の中に、小さなSELECT文が含まれている形になります。サブクエリを使うことで、複雑な条件を表現したり、集計結果を条件として使ったりすることができます。Drizzle ORMでサブクエリを使う場合、.subQuery() メソッドを使って定義します。

WHERE句でのサブクエリ使用

WHERE句でサブクエリを使う場合、サブクエリの結果はスカラー値(単一の行、単一のカラム)である必要があります。例えば、「平均価格より高い商品」といった条件を表現できます。

【生のSQL】
SELECT id, name, price
FROM products
WHERE price > (
  SELECT AVG(price) FROM products
);
【Drizzle ORM】
import { sql } from 'drizzle-orm';

const avgPrice = db
  .select({ avg: sql`AVG(${products.price})` })
  .from(products);

const result = await db
  .select({ id: products.id, name: products.name, price: products.price })
  .from(products)
  .where(sql`${products.price} > (${avgPrice})`);

このクエリは、すべての商品の平均価格を計算し、その平均価格より高い商品だけを取得します。

SELECT句でのサブクエリ使用

SELECT句でサブクエリを使う場合、各行に対してサブクエリの結果を新しいカラムとして追加できます。例えば、各顧客について「その顧客の注文総額」を同時に取得するといった使い方ができます。

【生のSQL】
SELECT 
  c.id,
  c.name,
  (SELECT COUNT(*) FROM orders WHERE customerId = c.id) AS order_count
FROM customers c;
【Drizzle ORM】
import { sql } from 'drizzle-orm';

const orderCountSubquery = db
  .select({ count: sql`COUNT(*)` })
  .from(orders)
  .where(sql`${orders.customerId} = ${customers.id}`);

const result = await db
  .select({
    id: customers.id,
    name: customers.name,
    orderCount: sql`(${orderCountSubquery})`,
  })
  .from(customers);

このクエリは、各顧客の基本情報(id、name)と、その顧客の注文数を同時に取得します。

複数カラムの返却はエラー

サブクエリを使う際、特に WHERE や SELECT で条件値として使う場合、必ず単一のカラムのみを返す必要があります。複数のカラムを返そうとするとエラーが発生します。

【エラー例】
-- ❌ これはエラーになります
SELECT * FROM products
WHERE (id, price) IN (
  SELECT id, price FROM orders  -- 複数カラムを返している!
);
【正しい例】
-- ✅ 単一カラムのみを返す
SELECT * FROM products
WHERE id IN (
  SELECT id FROM orders  -- 単一カラムのみ!
);

サブクエリを複数カラムで使いたい場合は、サブクエリではなくJOINを使うべきです。JOINを使うことで、より効率的に複数テーブルの結合ができます。

サブクエリ 演習問題

WHERE句と SELECT句でのサブクエリの使い方を実践して学びましょう。

Q1. WHERE句でのサブクエリ
products テーブルから、在庫数(stock)が平均在庫数より多い商品をすべて取得してください。結果として、idnamestock を表示してください。
解答を見る
import { sql } from 'drizzle-orm';

const avgStock = db
  .select({ avg: sql`AVG(${products.stock})` })
  .from(products);

const result = await db
  .select({
    id: products.id,
    name: products.name,
    stock: products.stock,
  })
  .from(products)
  .where(sql`${products.stock} > (${avgStock})`);

解説:WHERE句のサブクエリを使うことで、テーブル全体の平均在庫数を動的に計算し、その結果を比較条件として使用できます。サブクエリは AVG(stock) という単一の値を返すため、WHERE句で直接比較できます。

Q2. SELECT句でのサブクエリ
customers テーブルから、各顧客の情報(idname)と、その顧客の合計購入額を取得してください。合計購入額は、orders テーブルの amount カラムを顧客ごとに合計したものです。結果を idnametotalAmount として表示してください。
解答を見る
import { sql } from 'drizzle-orm';

const result = await db
  .select({
    id: customers.id,
    name: customers.name,
    totalAmount: sql`(
      SELECT COALESCE(SUM(${orders.amount}), 0)
      FROM ${orders}
      WHERE ${orders.customerId} = ${customers.id}
    )`,
  })
  .from(customers);

解説:SELECT句のサブクエリを使うことで、外側のクエリの各行に対して、内側のサブクエリが実行されます。この例では、各顧客に対して、その顧客のすべての注文の合計額を計算しています。COALESCE(SUM(...), 0) を使うことで、注文がない顧客の場合も 0 として表示されます。

トランザクション(TRANSACTION)

トランザクション(transaction)は、複数のデータベース操作をまとめて、「すべて成功するか、すべて失敗するか」の2択にする仕組みです。例えば、顧客の口座から1,000円を引き出し、別の口座に1,000円を入金する2つの操作を行う場合、片方だけが成功してしまっては整合性が失われます。トランザクション内で両方の操作を行うことで、「両方成功」か「両方失敗」のいずれかが保証されます。

accountsテーブルの定義

トランザクションの例で使用する accounts テーブルの定義です。このテーブルは、銀行振込などの複数操作が必要な処理における一貫性を保証するために使用されます。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, varchar, numeric } from 'drizzle-orm/pg-core';

export const accounts = pgTable('accounts', {
  id: serial('id').primaryKey(),
  accountNumber: varchar('account_number', { length: 20 }).notNull().unique(),
  accountHolder: varchar('account_holder', { length: 100 }).notNull(),
  balance: numeric('balance', { precision: 15, scale: 2 }).notNull().default('0'),
});

このテーブルは以下のカラムで構成されています:

  • id: 口座ID(自動採番)
  • accountNumber: 口座番号(一意)
  • accountHolder: 口座名義人
  • balance: 残高(小数第2位まで)

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

COMMIT(確定)

COMMIT は、トランザクション内で行われた操作をデータベースに確定させます。COMMIT が実行されると、それまでの操作がデータベースに永続的に反映されます。

【生のSQL】
START TRANSACTION;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;  -- 両方の更新が確定される
【Drizzle ORM】
import { eq, sql } from 'drizzle-orm';
import { accounts } from './schema'; // schema.ts からaccounts をインポート

await db.transaction(async (tx) => {
  // トランザクション内で複数の操作を実行
  await tx.update(accounts).set({ balance: sql`balance - 1000` }).where(eq(accounts.id, 1));
  await tx.update(accounts).set({ balance: sql`balance + 1000` }).where(eq(accounts.id, 2));
  // トランザクション終了時に自動的に COMMIT
});

Drizzle ORMでは、db.transaction() 内で実行されたすべての操作が成功した場合、自動的に COMMIT されます。

ROLLBACK(キャンセル)

ROLLBACK は、トランザクション内で行われた操作をすべて取消し、トランザクション開始前の状態に戻します。エラーが発生した場合や、操作を中止したい場合に使用します。

【生のSQL】
START TRANSACTION;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
-- ここで何らかのエラーチェック
-- エラーが見つかった場合:
ROLLBACK;  -- すべての変更が取消される
-- トランザクション開始前の状態に戻る
【Drizzle ORM】
import { eq, sql } from 'drizzle-orm';
import { accounts } from './schema'; // schema.ts からaccounts をインポート

try {
  await db.transaction(async (tx) => {
    const account1 = await tx.select().from(accounts).where(eq(accounts.id, 1));
    
    if (account1[0].balance < 1000) {
      throw new Error('残高不足');  // エラーを投げる
    }
    
    await tx.update(accounts).set({ balance: sql`balance - 1000` }).where(eq(accounts.id, 1));
    await tx.update(accounts).set({ balance: sql`balance + 1000` }).where(eq(accounts.id, 2));
  });
} catch (error) {
  // エラーが発生したため、自動的に ROLLBACK される
  console.error('トランザクション失敗:', error.message);
}

Drizzle ORMでは、トランザクション内で例外がスローされた場合、自動的に ROLLBACK されます。手作業で ROLLBACK を呼び出す必要はありません。

トランザクションの重要性

トランザクションは、「複数の関連データを整合性を保って更新する」際に必須の仕組みです:

  • ACID特性: Atomicity(原子性)、Consistency(一貫性)、Isolation(独立性)、Durability(永続性)の4つの特性を保証します。
  • データの一貫性: 部分的な更新が起こらず、すべての変更が一緒に反映されるか、すべて反映されないかのどちらかになります。
  • エラーハンドリング: 途中でエラーが発生した場合、それまでの変更を安全にリセットできます。

DELETE (条件を満たすレコードの削除)

DELETE は、テーブルから特定の条件に合うレコードを削除する命令です。WHERE句を組み合わせることで、削除対象を細かく制御できます。WHERE句を指定しないと、テーブル内のすべてのレコードが削除されるため、注意が必要です。

条件を満たすレコードの削除(トランザクション内)

DELETE の最も一般的な使用方法は、WHERE句で特定の条件を指定し、その条件に合うレコードのみを削除することです。削除は破壊的な操作のため、トランザクション内で実行し、エラー時には ROLLBACK できるようにすることが推奨されます。

【生のSQL】
START TRANSACTION;
-- 作成日時が2024年より前の注文を削除
DELETE FROM orders
WHERE createdAt < '2024-01-01';
COMMIT;
【Drizzle ORM】
import { lt } from 'drizzle-orm';

try {
  await db.transaction(async (tx) => {
    // 作成日時が2024年より前の注文を削除
    const result = await tx.delete(orders)
      .where(lt(orders.createdAt, new Date('2024-01-01')));
    
    console.log(`削除されたレコード数: ${result.rowCount}`);
    // トランザクション終了時に自動的に COMMIT
  });
} catch (error) {
  console.error('削除失敗。トランザクションがROLLBACKされました:', error.message);
}

この例では:

  • db.transaction() 内で DELETE を実行
  • 削除が成功した場合、トランザクション終了時に自動 COMMIT
  • エラーが発生した場合、自動的に ROLLBACK して元の状態に戻す

複数の DELETE 操作をトランザクション内で実行

paymentsテーブルの定義

複数の DELETE 操作の例で使用する payments テーブルの定義です。このテーブルは、注文に関連する支払い情報を管理するために使用されます。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, integer, varchar, numeric, timestamp } from 'drizzle-orm/pg-core';

export const payments = pgTable('payments', {
  id: serial('id').primaryKey(),
  orderId: integer('order_id').notNull().references(() => orders.id),
  amount: numeric('amount', { precision: 12, scale: 2 }).notNull(),
  paymentMethod: varchar('payment_method', { length: 50 }).notNull(), // 'credit_card', 'bank_transfer', etc.
  createdAt: timestamp('created_at').defaultNow(),
});

このテーブルは以下のカラムで構成されています:

  • id: 支払いID(自動採番)
  • orderId: 対象の注文ID(外部キー)
  • amount: 支払額(小数第2位まで)
  • paymentMethod: 支払い方法(クレジットカード、銀行振込など)
  • createdAt: 支払い日時

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

複数の DELETE 操作を組み合わせる場合も、トランザクション内で実行することで、一貫性を保証できます。

【生のSQL】
START TRANSACTION;
-- 特定顧客の古い注文を削除
DELETE FROM orders
WHERE customerId = 5 AND createdAt < '2024-01-01';

-- その顧客に関連する支払い記録を削除
DELETE FROM payments
WHERE orderId IN (
  SELECT id FROM orders WHERE customerId = 5
);

COMMIT;
【Drizzle ORM】
import { and, eq, lt, inArray } from 'drizzle-orm';
import { orders, payments } from './schema'; // schema.ts からorders, payments をインポート

try {
  await db.transaction(async (tx) => {
    // 特定顧客の古い注文を取得
    const oldOrders = await tx.select({ id: orders.id })
      .from(orders)
      .where(and(
        eq(orders.customerId, 5),
        lt(orders.createdAt, new Date('2024-01-01'))
      ));
    
    const orderIds = oldOrders.map(o => o.id);
    
    if (orderIds.length > 0) {
      // その顧客に関連する支払い記録を削除
      await tx.delete(payments).where(inArray(payments.orderId, orderIds));
      
      // 注文を削除
      await tx.delete(orders).where(inArray(orders.id, orderIds));
    }
    
    console.log('削除完了');
  });
} catch (error) {
  console.error('トランザクション失敗。すべての変更がROLLBACKされました:', error.message);
}

DELETE 演習問題

Q1. トランザクション内での条件付き削除
sessions テーブルから、作成日時が30日以上前のセッションをすべて削除してください。トランザクション内で実行し、削除されたセッション数をコンソールに出力してください。
解答を見る
import { lt, sql } from 'drizzle-orm';

try {
  await db.transaction(async (tx) => {
    const result = await tx.delete(sessions)
      .where(lt(sessions.createdAt, sql`NOW() - INTERVAL '30 days'`));
    
    console.log(`${result.rowCount}個のセッションが削除されました`);
  });
} catch (error) {
  console.error('削除失敗:', error.message);
}

解説:lt() 関数を使って「30日以上前の」という条件を指定し、トランザクション内で DELETE を実行しています。削除が成功した場合は自動 COMMIT、エラーが発生した場合は自動 ROLLBACK されます。result.rowCount で実際に削除されたレコード数を取得できます。

UPDATE (レコードの更新)

UPDATE は、テーブルの既存レコードのカラム値を変更する命令です。WHERE句を使って更新対象を限定したり、現在の値に基づいて新しい値を計算したり、サブクエリを使った複雑な更新も可能です。

すべてのレコードの値を固定値で更新

最も単純なUPDATEは、WHERE句を指定せず、テーブル内のすべてのレコードを同じ値で更新するケースです。例えば、ステータスを一括変更する場合に使用します。

【生のSQL】
-- すべての商品の在庫を0にリセット
UPDATE products
SET stock = 0;
【Drizzle ORM】
import { eq } from 'drizzle-orm';

const result = await db.update(products)
  .set({ stock: 0 });

特定のレコードのみを更新(WHERE条件)

WHERE句を使うことで、特定の条件に合うレコードのみを更新できます。これが実務で最も頻繁に使われるパターンです。

【生のSQL】
-- id=1 の商品の価格を100に変更
UPDATE products
SET price = 100
WHERE id = 1;

-- ステータスが 'pending' の注文をすべて 'completed' に変更
UPDATE orders
SET status = 'completed'
WHERE status = 'pending';
【Drizzle ORM】
import { eq } from 'drizzle-orm';

// id=1 の商品の価格を100に変更
const result1 = await db.update(products)
  .set({ price: 100 })
  .where(eq(products.id, 1));

// ステータスが 'pending' の注文をすべて 'completed' に変更
const result2 = await db.update(orders)
  .set({ status: 'completed' })
  .where(eq(orders.status, 'pending'));

現在の値を利用した更新

現在のカラム値に基づいて新しい値を計算して更新する場合、sql を使って SQL式を直接指定します。例えば、在庫を1減らす、価格を10%値上げするといった用途に使用します。

【生のSQL】
-- 在庫を1減らす
UPDATE products
SET stock = stock - 1
WHERE id = 5;

-- 価格を10%値上げ
UPDATE products
SET price = price * 1.1
WHERE category = 'electronics';
【Drizzle ORM】
import { eq, sql } from 'drizzle-orm';

// 在庫を1減らす
const result1 = await db.update(products)
  .set({ stock: sql`${products.stock} - 1` })
  .where(eq(products.id, 5));

// 価格を10%値上げ
const result2 = await db.update(products)
  .set({ price: sql`${products.price} * 1.1` })
  .where(eq(products.category, 'electronics'));

CASE式による同時置換処理

複数の条件に基づいて異なる値で更新したい場合は、CASE式を使います。同じカラムを複数の値で置き換える場合に便利です。

【生のSQL】
-- スコアに基づいてランクを一括更新
UPDATE users
SET rank = CASE
  WHEN score >= 1000 THEN 'gold'
  WHEN score >= 500 THEN 'silver'
  WHEN score >= 100 THEN 'bronze'
  ELSE 'iron'
END;
【Drizzle ORM】
import { sql, case_ } from 'drizzle-orm';

const result = await db.update(users)
  .set({
    rank: case_()
      .when(sql`${users.score} >= 1000`, sql`'gold'`)
      .when(sql`${users.score} >= 500`, sql`'silver'`)
      .when(sql`${users.score} >= 100`, sql`'bronze'`)
      .else(sql`'iron'`)
      .end(),
  });

RETURNING (更新後の値を返す)

RETURNING を使うことで、UPDATE後のレコード内容を取得できます。更新内容を即座に確認したい場合に便利です。

【生のSQL】
-- id=1 の商品の価格を100に変更し、
-- 更新後のレコード全体を取得
UPDATE products
SET price = 100
WHERE id = 1
RETURNING *;

-- 更新後のid と新しい在庫数だけを取得
UPDATE products
SET stock = stock - 1
WHERE id = 5
RETURNING id, stock;
【Drizzle ORM】
import { eq, sql } from 'drizzle-orm';

// 更新後のレコード全体を取得
const result1 = await db.update(products)
  .set({ price: 100 })
  .where(eq(products.id, 1))
  .returning();

// 更新後のid と新しい在庫数だけを取得
const result2 = await db.update(products)
  .set({ stock: sql`${products.stock} - 1` })
  .where(eq(products.id, 5))
  .returning({ id: products.id, stock: products.stock });

サブクエリを用いた更新

サブクエリを使った更新は、別のテーブルの集計結果や条件に基づいて値を更新する場合に有効です。例えば、各顧客の最後の購入日をupdateするといった用途があります。

【生のSQL】
-- 各顧客の最後の購入日をupdateする
UPDATE customers
SET lastPurchaseDate = (
  SELECT MAX(createdAt)
  FROM orders
  WHERE orders.customerId = customers.id
);

-- その顧客の合計購入額を更新
UPDATE customers
SET totalSpent = (
  SELECT COALESCE(SUM(amount), 0)
  FROM orders
  WHERE orders.customerId = customers.id
);
【Drizzle ORM】
import { sql } from 'drizzle-orm';

// 各顧客の最後の購入日を更新
const result1 = await db.update(customers)
  .set({
    lastPurchaseDate: sql`(
      SELECT MAX(${orders.createdAt})
      FROM ${orders}
      WHERE ${orders.customerId} = ${customers.id}
    )`,
  });

// その顧客の合計購入額を更新
const result2 = await db.update(customers)
  .set({
    totalSpent: sql`(
      SELECT COALESCE(SUM(${orders.amount}), 0)
      FROM ${orders}
      WHERE ${orders.customerId} = ${customers.id}
    )`,
  });

FROM句を利用して参照用テーブルを指定する

FROM句を使うことで、別のテーブルのデータに基づいて更新条件や更新値を指定できます。JOINに似た機能と考えられます。

【生のSQL】
-- departmentsテーブルの情報を基に、
-- employeesテーブルの部門名を更新
UPDATE employees
SET departmentName = departments.name
FROM departments
WHERE employees.departmentId = departments.id;

-- ordersテーブルの顧客名を、
-- customersテーブルから取得して更新
UPDATE orders
SET customerName = customers.name
FROM customers
WHERE orders.customerId = customers.id;
【Drizzle ORM】
import { eq, sql } from 'drizzle-orm';

// departmentsテーブルの情報を基に更新
const result1 = await db.update(employees)
  .set({ departmentName: sql`${departments.name}` })
  .from(departments)
  .where(eq(employees.departmentId, departments.id));

// ordersテーブルの顧客名を更新
const result2 = await db.update(orders)
  .set({ customerName: sql`${customers.name}` })
  .from(customers)
  .where(eq(orders.customerId, customers.id));

UPDATE 演習問題

Q1. すべてのレコードの値を固定値で更新
products テーブルの全商品を対象に、在庫をすべて 0 にリセットしてください。
解答を見る
const result = await db.update(products)
  .set({ stock: 0 });

解説:WHERE句を指定しないため、テーブル内のすべてのレコードが対象になります。result.rowCount で実際に更新されたレコード数を確認できます。

Q2. 特定のレコードのみを更新(WHERE条件)
orders テーブルから、ステータスが 'pending' のすべての注文を 'processing' に変更してください。
解答を見る
import { eq } from 'drizzle-orm';

const result = await db.update(orders)
  .set({ status: 'processing' })
  .where(eq(orders.status, 'pending'));

解説:eq() を使って「status が 'pending' に等しい」という条件を指定します。条件に合うすべてのレコードが 'processing' に更新されます。

Q3. 現在の値を利用した更新
products テーブルから、id=3 の商品の在庫を1減らしてください。
解答を見る
import { eq, sql } from 'drizzle-orm';

const result = await db.update(products)
  .set({ stock: sql`${products.stock} - 1` })
  .where(eq(products.id, 3));

解説:sql を使って SQL式を直接指定することで、現在のカラム値に基づいた計算ができます。${products.stock} - 1 により、現在の在庫から1を引いた値で更新されます。

Q4. CASE式による同時置換処理
users テーブルの全ユーザーに対して、スコアに基づいてランクを更新してください:
  • score >= 1000: 'gold'
  • score >= 500: 'silver'
  • score >= 100: 'bronze'
  • その他: 'iron'
解答を見る
import { case_, sql } from 'drizzle-orm';

const result = await db.update(users)
  .set({
    rank: case_()
      .when(sql`${users.score} >= 1000`, sql`'gold'`)
      .when(sql`${users.score} >= 500`, sql`'silver'`)
      .when(sql`${users.score} >= 100`, sql`'bronze'`)
      .else(sql`'iron'`)
      .end(),
  });

解説:case_() を使って CASE式を構築し、複数の条件に基づいた一括置換を実現します。すべてのユーザーのランクが、スコアに応じて同時に更新されます。

Q5. RETURNING(更新後の値を返す)
products テーブルから、id=2 の商品の価格を150に変更し、更新後の id と price を取得してください。
解答を見る
import { eq } from 'drizzle-orm';

const result = await db.update(products)
  .set({ price: 150 })
  .where(eq(products.id, 2))
  .returning({ id: products.id, price: products.price });

解説:.returning() を使うことで、UPDATE後のレコード内容を即座に取得できます。指定したカラムのみを返すため、不要なデータを取得する無駄がありません。

Q6. サブクエリを用いた更新
customers テーブルの全顧客について、その顧客の合計購入額(orders テーブルの amount の合計)を totalSpent に更新してください。
解答を見る
import { sql } from 'drizzle-orm';

const result = await db.update(customers)
  .set({
    totalSpent: sql`(
      SELECT COALESCE(SUM(${orders.amount}), 0)
      FROM ${orders}
      WHERE ${orders.customerId} = ${customers.id}
    )`,
  });

解説:サブクエリを使うことで、別のテーブルの集計結果を基に値を更新できます。各顧客に対して、その顧客のすべての注文額が合計されて totalSpent に格納されます。注文がない顧客は COALESCE で 0 となります。

Q7. FROM句を利用して参照用テーブルを指定する
orders テーブルの全注文について、customers テーブルから対応する顧客名を取得して、orders テーブルの customerName カラムに更新してください。
解答を見る
import { eq, sql } from 'drizzle-orm';

const result = await db.update(orders)
  .set({ customerName: sql`${customers.name}` })
  .from(customers)
  .where(eq(orders.customerId, customers.id));

解説:.from() を使って参照テーブルを指定し、.where() で結合条件を定義します。orders テーブルの各レコードが customers テーブルとマッチして、顧客名が customers.name から orders.customerName に更新されます。

INSERT (レコードの挿入)

INSERT は、テーブルに新しいレコードを追加する命令です。VALUES句を使った直接的な値の挿入と、サブクエリを使った他のテーブルのデータに基づく動的な挿入の2つの主要なパターンがあります。

VALUES句を用いたリテラル挿入

VALUES句を使うことで、直接指定した値をテーブルに挿入できます。これが最も基本的で、日常的にはよく使われるパターンです。複数行を同時に挿入することも可能です。

【生のSQL】
-- 1行を挿入
INSERT INTO users (name, email, age)
VALUES ('太郎', 'taro@example.com', 25);

-- 複数行を一度に挿入
INSERT INTO products (name, price, stock)
VALUES 
  ('りんご', 100, 50),
  ('みかん', 80, 100),
  ('ぶどう', 200, 30);
【Drizzle ORM】
// 1行を挿入
const result1 = await db.insert(users).values({
  name: '太郎',
  email: 'taro@example.com',
  age: 25,
});

// 複数行を一度に挿入
const result2 = await db.insert(products).values([
  { name: 'りんご', price: 100, stock: 50 },
  { name: 'みかん', price: 80, stock: 100 },
  { name: 'ぶどう', price: 200, stock: 30 },
]);

ポイント:

  • 単一行の場合はオブジェクト、複数行の場合は配列を .values() に渡します。
  • 自動採番される PRIMARY KEY(serial など)は、値を指定しなくても自動的に割り当てられます。
  • DEFAULT値が設定されているカラムについても、値を指定しなければデフォルト値が使用されます。

サブクエリを用いた動的な挿入(INSERT SELECT)

archived_ordersテーブルの定義

サブクエリを使った INSERT の例で使用する archived_orders テーブルの定義です。このテーブルは、過去の注文データをアーカイブするために使用されます。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, integer, numeric, timestamp } from 'drizzle-orm/pg-core';

export const archived_orders = pgTable('archived_orders', {
  id: serial('id').primaryKey(),
  customerId: integer('customer_id').notNull(),
  totalAmount: numeric('total_amount', { precision: 12, scale: 2 }).notNull(),
  createdAt: timestamp('created_at').notNull(),
  archivedAt: timestamp('archived_at').defaultNow(),
});

このテーブルは以下のカラムで構成されています:

  • id: アーカイブレコードID(自動採番)
  • customerId: 顧客ID(元の注文から複製)
  • totalAmount: 注文総額(元の注文から複製)
  • createdAt: 元の注文作成日時(元の注文から複製)
  • archivedAt: アーカイブ実行日時

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

category_summaryテーブルの定義

INSERT SELECT で集計結果を記録する例で使用する category_summary テーブルの定義です。このテーブルは、カテゴリー別の商品数などの集計結果を保持するために使用されます。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, integer, varchar } from 'drizzle-orm/pg-core';

export const category_summary = pgTable('category_summary', {
  id: serial('id').primaryKey(),
  categoryId: integer('category_id').notNull(),
  categoryName: varchar('category_name', { length: 100 }),
  productCount: integer('product_count').notNull().default(0),
});

このテーブルは以下のカラムで構成されています:

  • id: 集計レコードID(自動採番)
  • categoryId: カテゴリーID
  • categoryName: カテゴリー名
  • productCount: そのカテゴリーに属する商品数

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

別のテーブルのデータをSELECTで取得し、その結果を別のテーブルに挿入する(INSERT INTO ... SELECT)ことができます。これは大量のデータを移行したり、集計結果を履歴テーブルに記録したりする場合に便利です。

【生のSQL】
-- orders テーブルから2024年以降の注文を
-- archived_orders テーブルにコピー
INSERT INTO archived_orders (customerId, totalAmount, createdAt)
SELECT customerId, totalAmount, createdAt
FROM orders
WHERE createdAt >= '2024-01-01';

-- 各カテゴリの商品数を summary テーブルに記録
INSERT INTO category_summary (categoryId, productCount)
SELECT categoryId, COUNT(*) as productCount
FROM products
GROUP BY categoryId;
【Drizzle ORM】
import { gte, count } from 'drizzle-orm';
import { orders, archived_orders, products, category_summary } from './schema'; // schema.ts からインポート

// orders テーブルから2024年以降の注文を
// archived_orders テーブルにコピー
const result1 = await db.insert(archived_orders)
  .select({
    customerId: orders.customerId,
    totalAmount: orders.totalAmount,
    createdAt: orders.createdAt,
  })
  .from(orders)
  .where(gte(orders.createdAt, new Date('2024-01-01')));

// 各カテゴリの商品数を summary テーブルに記録
const result2 = await db.insert(category_summary)
  .select({
    categoryId: products.categoryId,
    productCount: count(),
  })
  .from(products)
  .groupBy(products.categoryId);

ポイント:

  • .insert() の後に .select() を続けることで、SELECT の結果を INSERT 対象にします。
  • SELECT結果のカラム順序と型が、INSERT先のテーブルのカラム定義と一致している必要があります。
  • WHERE、GROUP BY、ORDER BY などの SELECT句の機能をそのまま使用できます。
  • この操作がまるごと1つのトランザクションになるため、すべて成功するか、すべて失敗するかのどちらかになります。

INSERT 演習問題

Q1. VALUES句を用いたリテラル挿入
users テーブルに、以下の3人のユーザーを一度に挿入してください:
  • name: '花子', email: 'hanako@example.com', age: 28
  • name: '二郎', email: 'jiro@example.com', age: 35
  • name: '美咲', email: 'misaki@example.com', age: 30
解答を見る
const result = await db.insert(users).values([
  { name: '花子', email: 'hanako@example.com', age: 28 },
  { name: '二郎', email: 'jiro@example.com', age: 35 },
  { name: '美咲', email: 'misaki@example.com', age: 30 },
]);

解説:複数行を挿入する場合は、オブジェクトの配列を .values() に渡します。すべての行が同時に挿入され、result.rowCount で挿入されたレコード数(この例では3)を確認できます。

Q2. サブクエリを用いた動的な挿入(INSERT SELECT)
orders テーブルから、2024年以降に作成されたすべての注文を archived_orders テーブルにコピーしてください。対象カラムは idcustomerIdtotalAmountcreatedAt です。
解答を見る
import { gte } from 'drizzle-orm';
import { orders, archived_orders } from './schema'; // schema.ts からorders, archived_orders をインポート

const result = await db.insert(archived_orders)
  .select({
    id: orders.id,
    customerId: orders.customerId,
    totalAmount: orders.totalAmount,
    createdAt: orders.createdAt,
  })
  .from(orders)
  .where(gte(orders.createdAt, new Date('2024-01-01')));

解説:INSERT INTO ... SELECT パターンを使うことで、WHERE条件に合う注文のみを別のテーブルにコピーできます。条件に合うすべてのレコードが一度に処理されるため、大量データの移行に効率的です。

テーブルの結合

JOIN は複数のテーブルを結合して、関連するデータを同時に取得する操作です。異なる種類の JOIN(INNER JOIN、LEFT OUTER JOIN、RIGHT OUTER JOIN、FULL OUTER JOIN、CROSS JOIN など)の使い分けが重要です。またUNIONというのもあります。

内部結合の構文(INNER JOIN と ON、エイリアス)

INNER JOIN は、結合条件を満たすレコードのみを取得します。両テーブルに対応するデータが存在する場合のみ結果に含まれます。エイリアス(別名)を使うことでテーブル名を短縮し、可読性を向上させます。

【生のSQL】
-- customers と orders を結合し、
-- 顧客名と注文総額を取得
SELECT c.id, c.name, o.totalAmount
FROM customers AS c
INNER JOIN orders AS o
  ON c.id = o.customerId;
【Drizzle ORM】
import { eq } from 'drizzle-orm';

const result = await db
  .select({
    id: customers.id,
    name: customers.name,
    totalAmount: orders.totalAmount,
  })
  .from(customers)
  .innerJoin(orders, eq(customers.id, orders.customerId));

3つ以上のテーブルの内部結合

order_itemsテーブルの定義

複数テーブルの結合例で使用する order_items テーブルの定義です。このテーブルは、注文と商品の多対多関係を管理するためのテーブルで、各注文に含まれる複数の商品を記録します。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, integer, numeric } from 'drizzle-orm/pg-core';

export const order_items = pgTable('order_items', {
  id: serial('id').primaryKey(),
  orderId: integer('order_id').notNull().references(() => orders.id),
  productId: integer('product_id').notNull().references(() => products.id),
  quantity: integer('quantity').notNull(),
  price: numeric('price', { precision: 12, scale: 2 }).notNull(),
});

このテーブルは以下のカラムで構成されています:

  • id: 注文品目ID(自動採番)
  • orderId: 対象の注文ID(外部キー)
  • productId: 注文した商品ID(外部キー)
  • quantity: 数量
  • price: 購入時の単価(小数第2位まで)

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

3つ以上のテーブルを結合する場合、JOIN を複数回連鎖させます。結合順序に注意が必要です。

【生のSQL】
SELECT c.name, o.id, p.name as productName
FROM customers AS c
INNER JOIN orders AS o ON c.id = o.customerId
INNER JOIN order_items AS oi ON o.id = oi.orderId
INNER JOIN products AS p ON oi.productId = p.id;
【Drizzle ORM】
import { eq } from 'drizzle-orm';
import { customers, orders, order_items, products } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    customerName: customers.name,
    orderId: orders.id,
    productName: products.name,
  })
  .from(customers)
  .innerJoin(orders, eq(customers.id, orders.customerId))
  .innerJoin(order_items, eq(orders.id, order_items.orderId))
  .innerJoin(products, eq(order_items.productId, products.id));

外部結合の構文(LEFT OUTER JOIN と RIGHT OUTER JOIN、エイリアス)

LEFT OUTER JOIN は、左側のテーブルのすべてのレコードを保持し、右側のテーブルに対応データがない場合は NULL で埋めます。RIGHT OUTER JOIN はその逆です。

【生のSQL】
-- 注文がない顧客も含めて取得
SELECT c.id, c.name, o.totalAmount
FROM customers AS c
LEFT OUTER JOIN orders AS o
  ON c.id = o.customerId;

-- 権利者がいない注文も含めて取得(通常は使われない)
SELECT c.name, o.id
FROM customers AS c
RIGHT OUTER JOIN orders AS o
  ON c.id = o.customerId;
【Drizzle ORM】
import { eq } from 'drizzle-orm';

// LEFT OUTER JOIN
const result1 = await db
  .select({
    id: customers.id,
    name: customers.name,
    totalAmount: orders.totalAmount,
  })
  .from(customers)
  .leftJoin(orders, eq(customers.id, orders.customerId));

// RIGHT OUTER JOIN
const result2 = await db
  .select({
    name: customers.name,
    orderId: orders.id,
  })
  .from(customers)
  .rightJoin(orders, eq(customers.id, orders.customerId));

3つ以上のテーブルの外部結合

複数の外部結合を組み合わせる場合、各結合段階でどのテーブルのレコードが保持されるかに注意が必要です。

【生のSQL】
-- すべての顧客と、その注文(あれば)、注文内の商品(あれば)を取得
SELECT c.name, o.id, p.name as productName
FROM customers AS c
LEFT OUTER JOIN orders AS o ON c.id = o.customerId
LEFT OUTER JOIN order_items AS oi ON o.id = oi.orderId
LEFT OUTER JOIN products AS p ON oi.productId = p.id;
【Drizzle ORM】
import { eq } from 'drizzle-orm';
import { customers, orders, order_items, products } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    customerName: customers.name,
    orderId: orders.id,
    productName: products.name,
  })
  .from(customers)
  .leftJoin(orders, eq(customers.id, orders.customerId))
  .leftJoin(order_items, eq(orders.id, order_items.orderId))
  .leftJoin(products, eq(order_items.productId, products.id));

内部結合と外部結合の混在

discountsテーブルの定義

内部結合と外部結合を混在させる例で使用する discounts テーブルの定義です。このテーブルは、顧客に対するディスカウント(割引)情報を管理するために使用されます。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, integer, numeric, varchar } from 'drizzle-orm/pg-core';

export const discounts = pgTable('discounts', {
  id: serial('id').primaryKey(),
  customerId: integer('customer_id').notNull(),
  rate: numeric('rate', { precision: 5, scale: 2 }).notNull(),
  code: varchar('code', { length: 50 }).notNull(),
  description: varchar('description', { length: 200 }),
});

このテーブルは以下のカラムで構成されています:

  • id: ディスカウントID(自動採番)
  • customerId: 対象の顧客ID
  • rate: ディスカウント率(0 ~ 100 の数値、小数第2位まで)
  • code: ディスカウントコード(割引クーポンコードなど)
  • description: ディスカウントの説明(オプション)

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

同じクエリ内で INNER JOIN と LEFT OUTER JOIN を混ぜることができます。JOIN の順序に応じて結果が変わるため、注意が必要です。

【生のSQL】
-- すべての顧客と、注文(左結合)、
-- ただし、注文がある場合は注文内の商品(内部結合)
SELECT c.name, o.id, p.name as productName
FROM customers AS c
LEFT OUTER JOIN orders AS o ON c.id = o.customerId
INNER JOIN products AS p ON o.totalAmount > 100;
【Drizzle ORM】
import { eq, gt } from 'drizzle-orm';

const result = await db
  .select({
    customerName: customers.name,
    orderId: orders.id,
    productName: products.name,
  })
  .from(customers)
  .leftJoin(orders, eq(customers.id, orders.customerId))
  .innerJoin(products, gt(orders.totalAmount, 100));

完全外部結合の構文(FULL OUTER JOIN)

FULL OUTER JOIN は、両テーブルのすべての레コードを保持します。対応するレコードがない場合は NULL になります。PostgreSQL では標準機能ですが、全てのデータベースが対応していません。

【生のSQL】
-- 注文がない顧客と、
-- 顧客がいない注文の両方を含める
SELECT c.id, c.name, o.id, o.totalAmount
FROM customers AS c
FULL OUTER JOIN orders AS o
  ON c.id = o.customerId;
【Drizzle ORM】
import { eq, sql } from 'drizzle-orm';

const result = await db
  .select({
    customerId: customers.id,
    customerName: customers.name,
    orderId: orders.id,
    totalAmount: orders.totalAmount,
  })
  .from(customers)
  .fullJoin(orders, eq(customers.id, orders.customerId));

直積(CROSS JOIN)

sizesテーブルの定義

直積(CROSS JOIN)の例で使用する sizes テーブルの定義です。このテーブルは、商品のサイズバリエーション(S、M、L など)を管理するために使用されます。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, varchar, text } from 'drizzle-orm/pg-core';

export const sizes = pgTable('sizes', {
  id: serial('id').primaryKey(),
  size: varchar('size', { length: 10 }).notNull(),
  description: text('description'),
});

このテーブルは以下のカラムで構成されています:

  • id: サイズID(自動採番)
  • size: サイズ値('S', 'M', 'L' など)
  • description: サイズの説明(オプション)

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

colorsテーブルの定義

CROSS JOIN の例で使用する colors テーブルの定義です。このテーブルは、商品の色バリエーション(赤、青、黒 など)を管理するために使用されます。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, varchar, text } from 'drizzle-orm/pg-core';

export const colors = pgTable('colors', {
  id: serial('id').primaryKey(),
  color: varchar('color', { length: 50 }).notNull(),
  hexCode: varchar('hex_code', { length: 7 }),
  description: text('description'),
});

このテーブルは以下のカラムで構成されています:

  • id: 色ID(自動採番)
  • color: 色名('赤', '青', '黒' など)
  • hexCode: 16進数カラーコード('#FF0000' など、オプション)
  • description: 色の説明(オプション)

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

CROSS JOIN は、2つのテーブルの直積を求めます。結合条件がないため、全組み合わせが実行される結果になります。テーブルサイズが大きい場合、結果レコード数が膨大になるため注意が必要です。

【生のSQL】
-- サイズ S, M, L の全組み合わせで、
-- 色赤、青、黒の全組み合わせの商品を作成
SELECT sz.size, c.color
FROM sizes AS sz
CROSS JOIN colors AS c;
【Drizzle ORM】
import { sizes, colors } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    size: sizes.size,
    color: colors.color,
  })
  .from(sizes)
  .crossJoin(colors);

自然結合(NATURAL JOIN)

NATURAL JOIN は、同じ名前のカラムを自動的に検出して結合条件とします。明示的に ON を指定する必要がないため、シンプルに書けますが、カラム名に依存するため、スキーマ変更に脆弱です。

【生のSQL】
-- 両テーブルに id カラムがある場合、自動的に結合される
SELECT *
FROM employees
NATURAL JOIN departments;
【Drizzle ORM】
// Drizzleは naturalJoin をサポートしていないため、
// 明示的な INNER JOIN を使用することが推奨されます
import { eq } from 'drizzle-orm';

const result = await db
  .select()
  .from(employees)
  .innerJoin(departments, eq(employees.departmentId, departments.id));

UNION

active_customersテーブルとarchived_customersテーブルの定義

UNION を使った結果の結合の例で使用する active_customersarchived_customers テーブルの定義です。これらのテーブルは、現在アクティブな顧客と、削除または非アクティブになった顧客を管理するために使用されます。以下のように src/schema.ts に定義を追加してください。

// src/schema.ts に追加
import { pgTable, serial, varchar, boolean, timestamp } from 'drizzle-orm/pg-core';

export const active_customers = pgTable('active_customers', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  email: varchar('email', { length: 100 }).notNull(),
  isActive: boolean('is_active').default(true),
  createdAt: timestamp('created_at').defaultNow(),
});

export const archived_customers = pgTable('archived_customers', {
  id: serial('id').primaryKey(),
  name: varchar('name', { length: 100 }).notNull(),
  email: varchar('email', { length: 100 }).notNull(),
  archivedAt: timestamp('archived_at').defaultNow(),
});

これらのテーブルは以下のカラムで構成されています:

  • active_customers:
    • id: 顧客ID(自動採番)
    • name: 顧客名
    • email: メールアドレス
    • isActive: アクティブ状態(デフォルト: true)
    • createdAt: 登録日時
  • archived_customers:
    • id: 顧客ID(自動採番)
    • name: 顧客名
    • email: メールアドレス
    • archivedAt: アーカイブ日時

定義を追加したら、ターミナルで以下のコマンドを実行してデータベースに反映させてください。

npx drizzle-kit push

UNION は、複数のSELECT結果を縦に結合します。UNION(重複排除)と UNION ALL(重複保持)の2種類があります。結合対象のカラム数と型が一致している必要があります。

【生のSQL】
-- アクティブなユーザーアーカイブされたユーザーの両方を取得
SELECT id, name, 'active' as status
FROM users
WHERE isActive = true
UNION
SELECT id, name, 'archived' as status
FROM archived_users;
【Drizzle ORM】
import { sql } from 'drizzle-orm';

const result = await db
  .select({
    id: users.id,
    name: users.name,
    status: sql`'active'`,
  })
  .from(users)
  .where(eq(users.isActive, true))
  .union(
    db
      .select({
        id: archived_users.id,
        name: archived_users.name,
        status: sql`'archived'`,
      })
      .from(archived_users)
  );

INTERSECT

INTERSECT は、複数のSELECT結果の共通部分(交差)を取得します。両方のクエリに存在するレコードのみが結果に含まれます。重複するレコードは自動的に排除されます。

【生のSQL】
-- プレミアム購読者とニュースレター購読者の両方に登録されているユーザーを取得
SELECT id, name, email
FROM premium_subscribers
INTERSECT
SELECT id, name, email
FROM newsletter_subscribers;
【Drizzle ORM】
const result = await db
  .select({
    id: premium_subscribers.id,
    name: premium_subscribers.name,
    email: premium_subscribers.email,
  })
  .from(premium_subscribers)
  .intersect(
    db
      .select({
        id: newsletter_subscribers.id,
        name: newsletter_subscribers.name,
        email: newsletter_subscribers.email,
      })
      .from(newsletter_subscribers)
  );

EXCEPT

EXCEPT は、第1のSELECT結果から第2のSELECT結果に含まれるレコードを除外します。つまり、最初のクエリには存在するが、2番目のクエリには存在しないレコードが返されます。重複するレコードは自動的に排除されます。

【生のSQL】
-- すべての顧客から、非アクティブな顧客を除外してアクティブなユーザーを取得
SELECT id, name, email
FROM all_customers
EXCEPT
SELECT id, name, email
FROM inactive_customers;
【Drizzle ORM】
const result = await db
  .select({
    id: all_customers.id,
    name: all_customers.name,
    email: all_customers.email,
  })
  .from(all_customers)
  .except(
    db
      .select({
        id: inactive_customers.id,
        name: inactive_customers.name,
        email: inactive_customers.email,
      })
      .from(inactive_customers)
  );

結合 演習問題

Q1. 内部結合の構文(INNER JOIN)
customersorders を結合して、顧客名(name)と注文総額(totalAmount)を取得してください。顧客に対応する注文がある場合のみ結果に含める。
解答を見る
import { eq } from 'drizzle-orm';

const result = await db
  .select({
    name: customers.name,
    totalAmount: orders.totalAmount,
  })
  .from(customers)
  .innerJoin(orders, eq(customers.id, orders.customerId));

解説:INNER JOIN により、customers と orders の両方に存在するレコード(対応する注文がある顧客)のみが結果に含まれます。

Q2. 3つ以上のテーブルの内部結合
customers, orders, order_items を結合して、顧客名(name)と注文に含まれる商品の個数(quantity)を取得してください。
解答を見る
import { eq } from 'drizzle-orm';
import { customers, orders, order_items } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    name: customers.name,
    quantity: order_items.quantity,
  })
  .from(customers)
  .innerJoin(orders, eq(customers.id, orders.customerId))
  .innerJoin(order_items, eq(orders.id, order_items.orderId));

解説:複数の innerJoin を連鎖させることで、3つ以上のテーブルを結合できます。各結合段階で結合条件を指定します。

Q3. 外部結合の構文(LEFT OUTER JOIN)
customersorders を結合して、すべての顧客を取得し、注文がある場合は注文総額を、ない場合は NULL を表示してください。
解答を見る
import { eq } from 'drizzle-orm';

const result = await db
  .select({
    name: customers.name,
    totalAmount: orders.totalAmount,
  })
  .from(customers)
  .leftJoin(orders, eq(customers.id, orders.customerId));

解説:LEFT JOIN により、左側のテーブル(customers)のすべてのレコードが保持されます。右側テーブル(orders)に対応するレコードがない場合は NULL になります。

Q4. 3つ以上のテーブルの外部結合
customers, orders, order_items を LEFT JOIN で結合して、すべての顧客と、その顧客の注文内容(あれば)を取得してください。
解答を見る
import { eq } from 'drizzle-orm';
import { customers, orders, order_items } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    name: customers.name,
    orderId: orders.id,
    quantity: order_items.quantity,
  })
  .from(customers)
  .leftJoin(orders, eq(customers.id, orders.customerId))
  .leftJoin(order_items, eq(orders.id, order_items.orderId));

解説:複数の LEFT JOIN により、顧客が存在しない場合でも顧客レコードが保持されます。注文や注文項目がない場合は NULL が返されます。

Q5. 内部結合と外部結合の混在
customers を LEFT JOIN で orders と結合し、その後 INNER JOIN で discounts テーブルを結合してください。すべての顧客と、注文がある場合のみディスカウント情報を表示してください。
解答を見る
import { eq } from 'drizzle-orm';
import { customers, orders, discounts } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    name: customers.name,
    orderId: orders.id,
    discountRate: discounts.rate,
  })
  .from(customers)
  .leftJoin(orders, eq(customers.id, orders.customerId))
  .innerJoin(discounts, eq(orders.customerId, discounts.customerId));

解説:LEFT JOIN と INNER JOIN を混ぜることで、柔軟な結合結果を作成できます。この例では、すべての顧客を保持しつつ、ディスカウント情報は注文がある顧客のみに限定されます。

Q6. 完全外部結合(FULL OUTER JOIN)
customersorders を完全外部結合して、注文がない顧客も、顧客がいない注文も、両方とも結果に含めるようにしてください。
解答を見る
import { eq } from 'drizzle-orm';

const result = await db
  .select({
    customerName: customers.name,
    orderId: orders.id,
  })
  .from(customers)
  .fullJoin(orders, eq(customers.id, orders.customerId));

解説:FULL OUTER JOIN により、両テーブルのすべてのレコードが保持されます。対応するレコードがない場合は NULL になります。PostgreSQL では標準機能ですが、すべてのデータベースが対応していません。

Q7. 直積(CROSS JOIN)
sizes テーブル(値: 'S', 'M', 'L')と colors テーブル(値: '赤', '青')を直積で結合して、すべてのサイズと色の組み合わせを取得してください。
解答を見る
import { sizes, colors } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    size: sizes.size,
    color: colors.color,
  })
  .from(sizes)
  .crossJoin(colors);

解説:CROSS JOIN は結合条件がないため、左全体の全組み合わせが生成されます。この例では 3 × 2 = 6 行の結果が返されます。結果レコード数が膨大になる可能性があるため注意が必要です。

Q8. 自然結合(NATURAL JOIN)の代替
employeesdepartments を結合して、全社員と所属部門名を取得してください。両テーブルは departmentId というカラム名で関連しています。
解答を見る
import { eq } from 'drizzle-orm';

const result = await db
  .select({
    employeeName: employees.name,
    departmentName: departments.name,
  })
  .from(employees)
  .innerJoin(departments, eq(employees.departmentId, departments.id));

解説:Drizzle ORM は自然結合をサポートしていないため、明示的な INNER JOIN を使用します。カラム名が一致していても、明示的に結合条件を指定することで、スキーマ変更に対する耐性が向上します。

Q9. UNION による結果の結合
active_customers テーブルと archived_customers テーブルをUNIONで結合して、アクティブな顧客と削除されたユーザーのIDと名前をまとめて取得してください。
解答を見る
import { active_customers, archived_customers } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    id: active_customers.id,
    name: active_customers.name,
  })
  .from(active_customers)
  .union(
    db
      .select({
        id: archived_customers.id,
        name: archived_customers.name,
      })
      .from(archived_customers)
  );

解説:UNION により、2つの SELECT 結果が縦に結合されます。重複するレコードは自動的に排除されます(UNION ALL を使うと重複が保持されます)。結合対象のカラム数と型が一致している必要があります。

Q10. INTERSECT による共通部分の抽出
premium_subscribers テーブルと newsletter_subscribers テーブルをINTERSECTで結合して、両方のリストに含まれているユーザーのIDと名前のみを取得してください。
解答を見る
import { premium_subscribers, newsletter_subscribers } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    id: premium_subscribers.id,
    name: premium_subscribers.name,
  })
  .from(premium_subscribers)
  .intersect(
    db
      .select({
        id: newsletter_subscribers.id,
        name: newsletter_subscribers.name,
      })
      .from(newsletter_subscribers)
  );

解説:INTERSECT により、2つの SELECT 結果の交差(共通部分)が返されます。両方のクエリに同じレコードが存在する場合のみが結果に含まれます。重複するレコードは自動的に排除されます。この操作は、複数の条件を満たすユーザーの抽出などに有用です。

Q11. EXCEPT による差集合の抽出
all_customers テーブルから inactive_customers テーブルに含まれるユーザーを除外して、アクティブなユーザーのIDと名前を取得してください。
解答を見る
import { all_customers, inactive_customers } from './schema'; // schema.ts からインポート

const result = await db
  .select({
    id: all_customers.id,
    name: all_customers.name,
  })
  .from(all_customers)
  .except(
    db
      .select({
        id: inactive_customers.id,
        name: inactive_customers.name,
      })
      .from(inactive_customers)
  );

解説:EXCEPT により、第1のクエリの結果から第2のクエリの結果に含まれるレコードが除外されます。つまり、最初のテーブルに存在しているが、2番目のテーブルには存在しないレコードが返されます。この操作は、除外リストを用いたデータ の絞り込みなどに有用です。

本コンテンツの作成時間:約30時間