🎯 この章で学ぶこと

  • テーブル・行・列の構造と、SQLiteの主なデータ型(INTEGER・TEXT・REAL・BLOB)
  • NULLとは何か —「空文字」でも「0」でもない、「値がない」という状態
  • 主キーの役割と、「氏名を主キーにしてはいけない」理由。自然キーとサロゲートキー
  • 外部キーで表現する1対多の関係、中間テーブルで表現する多対多の関係
  • ER図の読み方と、CREATE TABLEの制約(NOT NULL・UNIQUE・DEFAULT・CHECK・FOREIGN KEY)

2.1 テーブル・行・列とデータ型

第1章で見たとおり、リレーショナルデータベースはすべてのデータをテーブル(表)で持ちます。行(レコード)が1件のデータ、列(カラム)がデータの項目です。Excelと決定的に違うのは、列ごとに「入れられるデータの種類(データ型)」をあらかじめ決めておくことです。Excelのセルには数値でも文字でも日付でも何でも入りますが、データベースでは「salary列は整数」と宣言したら、原則としてそこには整数しか入りません。この「入り口の厳しさ」が整合性の第一歩です。

SQLiteの主なデータ型は次の4つです。

意味
INTEGER整数社員ID、在庫数、給与(円単位)
TEXT文字列氏名、メールアドレス、'2024-04-01' のような日付文字列
REAL浮動小数点数(小数)身長 172.5、割引率 0.08
BLOBバイナリデータ画像や添付ファイルそのもの(実務では避けることが多い)
💡 他のDBMSでは型がもっと細かい

MySQLやPostgreSQLには VARCHAR(50)(最大50文字の文字列)、DATE(日付専用)、DECIMAL(金額計算向けの正確な小数)など、より細かい型があります。SQLiteは日付専用型を持たないため、このコースでは日付を '2024-04-01' という形式のTEXTで扱います。「列に型を決める」という考え方自体はどのDBMSでも共通です。

NULL —「値がない」という特別な状態

どの型の列にも入りうる特別な状態が NULL(ヌル)です。NULLは「0」でも「空文字」でもなく、「値がまだない・分からない」という状態そのものを表します。たとえば「配属先が未定の新入社員」の部署IDは、0でも空文字でもなくNULLであるべきです。0なら「0番という部署」に見えてしまい、空文字は数値列にはそもそも入れられません。

⚠️ NULLは比較演算が効かない

NULLは「値がない」状態なので、= NULL のような普通の比較では判定できません(第3章で IS NULL という専用の書き方を学びます)。NULLを軽く見ていると集計結果が狂う事故につながるため、設計段階から「この列はNULLを許すのか?」を意識してください。

2.2 主キー — 行を一意に特定する

テーブルには「この値を見れば行が1つに決まる」列が必要です。これを主キー(PRIMARY KEY)と呼びます。「社員番号3番の給与を更新して」「注文ID 1052をキャンセルして」——データベースへの依頼は、必ず主キーで対象を指定します。主キーがなければ、更新も削除も「どの行のことか」を確実に指せません。

なぜ「氏名」を主キーにしてはいけないのか

初学者が最初にやりがちな設計が「氏名を主キーにする」ことです。これは2つの理由で破綻します。

つまり主キーに向くのは、①絶対に重複せず、②一度決めたら変わらない値です。氏名はどちらの条件も満たしません。

自然キーとサロゲートキー

主キーの選び方には2つの流儀があります。

種類説明注意点
自然キー業務上もともと存在する識別子をそのまま主キーにする社員番号、ISBN、商品コード「変わらない」と思っていた番号が制度変更で変わることがある
サロゲートキー(代理キー)業務とは無関係の連番をデータベース側で振るid列(1, 2, 3, …)業務的な意味を持たないため、値そのものからは何も読み取れない

実務では「意味を持たない連番なら絶対に変更する理由が生まれない」という安心感から、サロゲートキーが広く使われます。SQLiteでは INTEGER PRIMARY KEY と宣言した列に、値を指定せずINSERTすると自動で連番が振られます

sqlite> CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
sqlite> INSERT INTO members (name) VALUES ('佐藤 花子');
sqlite> INSERT INTO members (name) VALUES ('田中 実');
sqlite> SELECT * FROM members;
1|佐藤 花子
2|田中 実

idを一切書いていないのに、1、2と自動で採番されました。MySQLの AUTO_INCREMENT、PostgreSQLの SERIAL も同じ発想の機能です。

2.3 外部キーとリレーション

第1章の社員テーブルには dept_id という列がありました。これは「部署テーブル(departments)のid列の値を入れる」列で、このように他のテーブルの主キーを参照する列を外部キー(FOREIGN KEY)と呼びます。

部署1つに対して社員は何人もいます。逆に、1人の社員が所属する部署は1つです。このような関係を1対多(いちたいた)の関係と呼び、リレーショナルデータベースで最も頻出する形です。ポイントは、外部キーは必ず「多」の側に置くこと。社員(多)の側に dept_id を持たせるのであって、部署の側に「社員1, 社員2, 社員3…」と列を増やすのではありません。

CREATE TABLE departments (
  id   INTEGER PRIMARY KEY,
  name TEXT NOT NULL
);

CREATE TABLE employees (
  id      INTEGER PRIMARY KEY,
  name    TEXT NOT NULL,
  dept_id INTEGER REFERENCES departments(id)  -- 外部キー
);
⚠️ SQLiteでは外部キー制約が初期状態でオフ

歴史的な事情で、SQLiteは外部キー制約のチェックがデフォルトで無効です。接続するたびに PRAGMA foreign_keys = ON; を実行して有効化してください。有効にすると、「存在しない部署IDを持つ社員」の登録をDBMSがエラーで拒否してくれます。MySQLやPostgreSQLでは最初から有効です。

sqlite> PRAGMA foreign_keys = ON;
sqlite> INSERT INTO departments VALUES (10, '総務部');
sqlite> INSERT INTO employees VALUES (1, '佐藤 花子', 10);   -- 総務部は存在するのでOK
sqlite> INSERT INTO employees VALUES (2, '鈴木 一郎', 99);   -- 99番という部署は存在しない
Runtime error: FOREIGN KEY constraint failed (19)

存在しない部署への配属が、入り口で止められました。これが外部キー制約の価値です。「あとで気づいて直す」のではなく、間違ったデータをそもそも入れないのです。

多対多の関係と中間テーブル

では「社員とプロジェクト」の関係はどうでしょう。1人の社員は複数のプロジェクトを掛け持ちし、1つのプロジェクトには複数の社員が参加します。これは多対多の関係で、外部キー1本では表現できません。そこで、2つのテーブルの間に「組み合わせだけを記録するテーブル」を挟みます。これを中間テーブル(関連テーブル)と呼びます。

CREATE TABLE projects (
  id   INTEGER PRIMARY KEY,
  name TEXT NOT NULL
);

-- 中間テーブル:「誰が」「どのプロジェクトに」参加しているかの組み合わせ
CREATE TABLE employee_projects (
  employee_id INTEGER NOT NULL REFERENCES employees(id),
  project_id  INTEGER NOT NULL REFERENCES projects(id),
  PRIMARY KEY (employee_id, project_id)
);

「佐藤さんが基幹システム更改とWebサイト刷新に参加している」なら、中間テーブルに (1, 1) (1, 2) という2行を入れるだけです。多対多は「1対多を2つ組み合わせたもの」に分解できる——この発想は、受講者と講座、商品と注文など、実務のあらゆる場面で再登場します。

2.4 ER図を読む・描く

テーブル同士の関係を図にしたものが ER図(Entity-Relationship Diagram)です。実務では設計書の中心になる図で、「読める」ことはIT部門の必須スキルです。ここまでの社員・部署・プロジェクトの関係をテキストで描くと、こうなります。

+-------------+          +--------------+          +--------------------+          +-----------+
| departments |          |  employees   |          | employee_projects  |          | projects  |
+-------------+          +--------------+          +--------------------+          +-----------+
| id (PK)     |─────────<| id (PK)      |─────────<| employee_id (FK)   |>─────────| id (PK)   |
| name        |  1     多| name         |  1     多| project_id  (FK)   |多      1 | name      |
+-------------+          | dept_id (FK) |          +--------------------+          +-----------+
                         +--------------+

読み方のポイントは線の両端です。─< のように線が枝分かれしている側(鳥の足に見えるので「カラスの足」と呼ばれます)が「多」、枝分かれしていない側が「1」です。上の図は左から「1つの部署に多くの社員」「1人の社員に多くの参加記録」「多くの参加記録が1つのプロジェクトに属する」と読めます。PKは主キー、FKは外部キーの略です。

✅ 設計に迷ったら、まず日本語の文にする

「1つの◯◯には複数の△△がある。1つの△△が属する◯◯は1つ」と声に出して言えれば、それは1対多です。両方向とも「複数」なら多対多で、中間テーブルの出番です。図を描く前に日本語で関係を言語化する——これが遠回りに見えて最短の設計手順です。

2.5 CREATE TABLEで設計を形にする — 制約という防波堤

設計をSQLに落とすとき、主キー・外部キー以外にも「間違ったデータを入り口で止める仕組み」を宣言できます。これらを総称して制約(constraint)と呼びます。

制約意味使いどころの例
NOT NULLNULLを禁止する(必須項目にする)氏名、商品名など「無いと業務が成り立たない」列
UNIQUE重複を禁止するメールアドレス、社員番号(サロゲートキー併用時)
DEFAULT値を省略したときの既定値を決める登録日時、ステータスの初期値 '在職'
CHECK条件式を満たす値だけ許可する給与は0以上、評価は1〜5のみ
FOREIGN KEY参照先テーブルに存在する値だけ許可するdept_id は departments に実在する部署のみ

全部入りの例を見てみましょう。第3章から使う社員テーブルの「制約強化版」です。

CREATE TABLE employees (
  id        INTEGER PRIMARY KEY,
  name      TEXT    NOT NULL,
  email     TEXT    UNIQUE,
  dept_id   INTEGER REFERENCES departments(id),
  hire_date TEXT    DEFAULT (date('now')),
  salary    INTEGER CHECK (salary >= 0)
);

制約に違反するデータを入れようとすると、DBMSが即座に拒否します。

sqlite> INSERT INTO employees (id, name, salary) VALUES (7, '山本 剛', -500);
Runtime error: CHECK constraint failed: salary >= 0 (275)
sqlite> INSERT INTO employees (id, salary) VALUES (8, 300000);
Runtime error: NOT NULL constraint failed: employees.name (1299)
💡 制約は「アプリでチェックしているから不要」ではない

「入力画面でチェックしているからDBの制約は要らない」という意見を現場で聞くことがあります。しかしデータベースには、Web画面以外にもバッチ処理・データ移行ツール・管理者の手作業SQLなど、複数の経路からデータが入ってきます。すべての経路が最後に通る場所はデータベースだけです。だから最後の防波堤としての制約には、アプリのチェックとは別の価値があります。

🛡️ セキュリティ・運用の視点 — 設計段階で決まる「漏えい時の被害額」

テーブル設計は、セキュリティ対策の最初の分岐点でもあります。「あとで使うかもしれないから」と、氏名・住所・電話番号・生年月日・家族構成……を1つのテーブルにとりあえず全部入れる設計は、漏えい事故が起きたときの被害を最大化します。逆に、業務に本当に必要な列だけを持ち、機微な情報(たとえば健康情報)は別テーブルに分離してアクセス経路を絞っておけば、同じ侵入を許しても漏れる範囲は小さくなります。個人情報保護の世界ではこれを「データ最小化」と呼びます。持っていないデータは絶対に漏れない——設計者が使える最強のセキュリティ対策は、実は「列を増やさない勇気」なのです。

まとめ

練習問題

問題 2-1

ある会社で「社員のメールアドレスを主キーにしよう。全員違う値だし、必ず持っているから」という提案が出ました。この設計の問題点を、2.2節の「主キーに向く値の2条件」に照らして説明してください。

解答を見る

2条件のうち「①重複しない」は当面満たしますが、「②変わらない」を満たしません。改姓・部署異動・ドメイン変更などでメールアドレスは普通に変わります。主キーは他テーブルの外部キーから参照されるため、値を変更するとすべての参照先を更新する必要があり、更新漏れは即データ不整合につながります。また、退職者のアドレスが将来別人に再割り当てされると「①重複しない」も破られます。メールアドレスは UNIQUE 制約付きの通常の列にして、主キーは意味を持たない連番(サロゲートキー)にするのが安全です。

問題 2-2

図書室の貸出管理システムを設計します。管理したい情報は「書籍(タイトル・著者)」「利用者(氏名・所属部署名)」「貸出(誰が・どの本を・いつ借りて・いつ返したか)」です。必要なテーブルと、それぞれの主キー・外部キーを日本語で挙げ、テーブル間の関係(1対多)を説明してください。

解答を見る

解答例:テーブルは3つです。

  • books(書籍) — 主キー id。列はタイトル、著者
  • users(利用者) — 主キー id。列は氏名、所属部署名
  • loans(貸出) — 主キー id。外部キー book_id(books.idを参照)と user_id(users.idを参照)。列は貸出日、返却日(未返却ならNULL)

関係は「1冊の書籍に多くの貸出記録」「1人の利用者に多くの貸出記録」という1対多が2本です。書籍と利用者は貸出テーブルを介した多対多の関係になっており、loansが実質的な中間テーブル(+貸出日などの付加情報)の役割を果たしています。「返却日がNULL=いま貸出中」と表現できるのが、NULLの正しい使いどころです。

問題 2-3

問題2-2の設計を、制約付きのCREATE TABLE文にしてください。条件:タイトル・著者・氏名は必須。貸出日は省略したら当日の日付。外部キー制約を必ず付けること。書けたらsqlite3で実際に実行し、「存在しない書籍IDの貸出」がエラーになることを確かめてください。

解答を見る

解答例:

PRAGMA foreign_keys = ON;

CREATE TABLE books (
  id     INTEGER PRIMARY KEY,
  title  TEXT NOT NULL,
  author TEXT NOT NULL
);

CREATE TABLE users (
  id        INTEGER PRIMARY KEY,
  name      TEXT NOT NULL,
  dept_name TEXT
);

CREATE TABLE loans (
  id          INTEGER PRIMARY KEY,
  book_id     INTEGER NOT NULL REFERENCES books(id),
  user_id     INTEGER NOT NULL REFERENCES users(id),
  loan_date   TEXT NOT NULL DEFAULT (date('now')),
  return_date TEXT
);

動作確認の例:

sqlite> INSERT INTO books VALUES (1, 'SQL実践入門', '技術 太郎');
sqlite> INSERT INTO users VALUES (1, '佐藤 花子', '総務部');
sqlite> INSERT INTO loans (book_id, user_id) VALUES (1, 1);   -- 正常に登録できる
sqlite> INSERT INTO loans (book_id, user_id) VALUES (999, 1); -- 存在しない書籍
Runtime error: FOREIGN KEY constraint failed (19)

存在しない書籍の貸出が入り口で止まれば成功です。return_date にNOT NULLを付けていない(貸出中はNULLになる)点にも注目してください。