はじめに
あるプロジェクトで、電話番号が未登録のユーザーを検索するコードをレビューしていた。WHERE phone = NULL——一見正しく見えるこのクエリは、常に0件を返していた。データは確実に存在する。クエリもシンプル。では何が問題なのか。
答えはSQLの3値論理にあった。通常の比較演算はTRUEかFALSEを返すが、SQLにはUNKNOWN(不明)という第3の真偽値がある。NULLは「値が不明」を意味するため、NULL = NULLは「不明 = 不明」となり、結果もUNKNOWNになる。WHERE句はTRUEの行しか返さないから、UNKNOWNは暗黙にFALSE扱いされ、結果は常に0件になる。
この問題は『SQLアンチパターン』で「Fear of the Unknown」として解説されている。本記事ではRust + sqlxでの実装パターンに焦点を当てる。
こういう妄想の仕様と実際の仕様には違いがある。「おい、類推するな」というブログで書いたので時間がある時に読んでほしい。
sqlxの型マッピング
RustにはOption<T>という型がある。これは「値があるかもしれないし、ないかもしれない」を表現する型だ。Some(値)が「値あり」、Noneが「値なし」を意味する。SQLのNULLに相当するのがこのNoneだ。
let phone: Option<String> = Some("090-1234-5678".to_string()); // 値あり let phone: Option<String> = None; // 値なし(NULL相当)
sqlxはPostgreSQLのNULLをこのOption<T>に自動マッピングする。
| PostgreSQL | Rust (NULLable) | Rust (NOT NULL) |
|---|---|---|
| VARCHAR, TEXT | Option\<String> | String |
| INTEGER | Option\<i32> | i32 |
| BIGINT | Option\<i64> | i64 |
| UUID | Option\<Uuid> | Uuid |
| DECIMAL | Option\<Decimal> | Decimal |
| TIMESTAMPTZ | Option\<DateTime\<Utc>> | DateTime\<Utc> |
NULLableカラムをOption<T>以外にマッピングすると、NULLが返された時点で実行時エラーになる。私も一度やった。「NULLなんて来ないだろう」と思っていたカラムが、特定の条件でNULLを返し、深夜にSlackが鳴った。
#[derive(Debug, sqlx::FromRow)] struct User { id: Uuid, email: String, // NOT NULL → 必ず値がある name: String, // NOT NULL → 必ず値がある phone: Option<String>, // NULLable → Option型で「値があるかもしれないし、ないかもしれない」を表現 bio: Option<String>, // NULLable → Noneが「値なし」、Some("値")が「値あり」 created_at: DateTime<Utc>, // NOT NULL → 必ず値がある }
パターン1:検索フィルターでのNULL
// NG: NoneがNULLにバインドされ、phone = NULLは常にUNKNOWN // query_as::<_, User>の説明: // ::<_, User> は戻り値の型を指定するRustの記法(turbofish構文) // _ はデータベースの種類をコンパイラに推論させる部分 // User は「検索結果をUser構造体に変換して」という指定 let users = sqlx::query_as::<_, User>( "SELECT * FROM users WHERE phone = $1" // $1はプレースホルダ(SQLインジェクション対策) ) .bind(¶ms.phone) // bind()で$1に値を埋め込む。NoneはNULLになる .fetch_all(&pool) // 全件取得 .await?; // 非同期処理の完了を待つ。?はエラー時に早期リターン
// OK: 条件分岐でクエリを切り替える // match式: Option型の中身に応じて処理を分岐(switch文のようなもの) let users = match ¶ms.phone { Some(phone) => { // Some(値): 値がある場合 sqlx::query_as::<_, User>("SELECT * FROM users WHERE phone = $1") .bind(phone) .fetch_all(&pool) .await? } None => { // None: 値がない場合 → IS NULLを使う sqlx::query_as::<_, User>("SELECT * FROM users WHERE phone IS NULL") .fetch_all(&pool) .await? } };
// OK: IS NOT DISTINCT FROMで1クエリにまとめる(PostgreSQL固有) // NULLを普通の値として比較できる(NULL同士も「等しい」と判定) let users = sqlx::query_as::<_, User>( "SELECT * FROM users WHERE phone IS NOT DISTINCT FROM $1" ) .bind(¶ms.phone) .fetch_all(&pool) .await?;
パターン2:COUNTの挙動
// r#"..."# は生文字列リテラル(raw string literal) // 複数行のSQLを書きやすく、エスケープも不要な記法 sqlx::query_as( r#" SELECT COUNT(*) as total_users, -- 全行数(NULLを含む) COUNT(coupon_code) as users_with_coupon, -- NULLでない行数 COUNT(*) - COUNT(coupon_code) as users_without_coupon FROM users "# )
空文字列とNULLが混在している場合は注意が必要。
// NG: 空文字列のみマッチ、NULLはマッチしない "SELECT * FROM users WHERE coupon_code = ''" // OK: 両方を考慮 "SELECT * FROM users WHERE coupon_code IS NULL OR coupon_code = ''" // OK: NULLIFで正規化 "SELECT * FROM users WHERE NULLIF(coupon_code, '') IS NULL"
パターン3:フォーム送信での空文字列
フロントエンドから{ "phone": "" }が送られると、Option<String>ではSome("")になる。データベースには空文字列が保存され、NULLにはならない。
// Rustレイヤーで正規化 // filter(): 条件を満たさない場合はNoneに変換するメソッド // |s| !s.is_empty() はクロージャ(無名関数): sが空でなければtrue let phone = req.phone.filter(|s| !s.is_empty()); // Some("") → None, Some("090") → Some("090") let bio = req.bio.filter(|s| !s.is_empty()); sqlx::query("UPDATE users SET phone = $1, bio = $2 WHERE id = $3") .bind(&phone) // NoneはNULLとしてバインドされる .bind(&bio) .bind(user_id) .execute(&pool) // execute(): SELECT以外のクエリ実行 .await?;
// SQLレイヤーで正規化 sqlx::query( r#" UPDATE users SET phone = NULLIF(TRIM($1), ''), -- TRIM: 空白除去, NULLIF: ''ならNULLに bio = NULLIF(TRIM($2), '') WHERE id = $3 "# )
パターン4:LEFT JOINでのOption必須
LEFT JOINは左側のテーブル(例: users)の全行を返す。右側のテーブル(例: orders)に一致する行がない場合、右側のカラムはすべてNULLで埋められる。だから注文がないユーザーの場合、o.created_atはNULLになり、MAX(o.created_at)の結果もNULLになる。
// NG: 注文がないユーザーでMAX(o.created_at)がNULLになり、実行時エラー struct UserWithLastOrder { last_order_date: DateTime<Utc>, // NULLを受け付けない型 } // OK: Option<T>でNULLを許容する struct UserWithLastOrder { last_order_date: Option<DateTime<Utc>>, // NULLならNone、値があればSome(値) }
LEFT JOINや集約関数(MAX, AVG, SUM等)の結果は常にNULLになりうる。迷ったらOption<T>を使う。
パターン5:NOT INの罠
// NG: category_idがNULLの行は削除されない sqlx::query( r#" DELETE FROM products WHERE category_id NOT IN ( SELECT id FROM categories WHERE active = true ) "# )
なぜNULLの行が削除されないのか。NOT INは内部でx <> 1 AND x <> 2 AND ...に展開される。ここでcategory_idがNULLだとどうなるか。NULL <> 1はUNKNOWNを返す。NULL <> 2もUNKNOWN。ANDの3値論理ではTRUE AND UNKNOWN = UNKNOWNだから、条件全体がUNKNOWNになる。WHERE句はTRUEの行しか処理しないため、NULLを含む行は削除対象から外れてしまう。
// OK: NOT EXISTSを使う // NULLの行も正しく処理される(サブクエリが0行ならTRUE) sqlx::query( r#" DELETE FROM products p WHERE NOT EXISTS ( SELECT 1 FROM categories c WHERE c.id = p.category_id AND c.active = true ) "# )
パターン6:query_as!マクロ
これまでのパターンで使っていたquery_as()は実行時に型チェックを行う。一方query_as!()はマクロで、コンパイル時にデータベースへ接続してスキーマを確認し、型の不整合をビルドエラーとして検出する。NULLになりうるカラムをOption<T>以外でマッピングしようとすると、実行前にエラーを発見できる。
// NG: AVG(rating)はNULLを返す可能性があり、コンパイルエラー struct ProductSummary { average_rating: f64, // f64はNULLを受け付けない } sqlx::query_as!( ProductSummary, "SELECT name, AVG(rating) as average_rating FROM products GROUP BY name" ) // コンパイルエラー: AVGの結果がNULLになりうるのにOption<f64>ではない
// OK: Option<T>を使う struct ProductSummary { average_rating: Option<f64>, } // OK: COALESCEと"!"サフィックスでNOT NULLを保証 sqlx::query_as!( ProductSummary, r#" SELECT name, COALESCE(AVG(rating), 0)::FLOAT8 -- NULLなら0、FLOAT8にキャスト as "average_rating!" -- "!"でNOT NULLを宣言 FROM products GROUP BY name "# )
| サフィックス | 意味 |
|---|---|
! |
NOT NULLを強制(Option\<T>ではなくT) |
? |
NULLを許容(TではなくOption\<T>) |
まとめ
冒頭のWHERE phone = NULLは、WHERE phone IS NULLに書き換えて5分で解決した。3値論理を知っているかどうか——それだけの差だった。
NULLの問題はバグではなく、SQLの仕様だ。Rust/sqlxでは以下を守れば大半の問題は防げる。
- NULLableカラムは
Option<T>にマッピング = NULLではなくIS NULLを使うNOT INではなくNOT EXISTSを使う- 空文字列とNULLを混在させない
迷ったらOption<T>を使う。後からOptionを外すのは簡単だが、NULLが返ってきたときのパニックを本番で見るのは心臓に悪い。
そもそもNULLableカラムを減らす設計(NOT NULL制約のデフォルト化、別テーブルへの分離)も検討に値する。3値論理の詳細は『SQLアンチパターン』の「Fear of the Unknown」章を参照してほしいです。
このブログが良ければ読者になったり、nwiizoのXやGithubをフォローしてくれると嬉しいです。