NGW開発BLOG
← 記事一覧へ
·#016

空文字で検索したら、消したはずの行が返ってきたMySQLの型変換

新規登録画面で、IDがまだ空なのに既定の行が選択済みになる。犯人はSQLの暗黙型変換で、空文字が数値0に化けて残骸行を掴んでいた話。

保存していないのに、もう選ばれている

ある入力画面で妙な不具合が報告された。新規登録のフォームを開いただけ、つまりまだ何も保存していない状態なのに、初期メンバーの欄に意図しない行が最初から選択済みで表示される、というものだ。

新規なのだから、参照すべきIDはまだ存在しない。画面のロジックとしては「このIDに紐づくメンバーを引いてくる」だけで、IDが無ければ結果は空、選択済みの行など出てくるはずがない。ところが実際には、誰も選んでいないのに特定の1行が居座っている。しかもその行は、正規のメンバーではなく、過去のデータ不整合で生まれた素性の怪しい行だった。

空文字が、静かに 0 になる

メンバー一覧を引くSQLは、単純化すると次のような形だった。

SELECT * FROM member WHERE some_id = '$id';

新規登録では、まだ主キーが採番されていないので $id は空文字だ。文字列として組み立てられるから、実際に発行されるのはこうなる。

SELECT * FROM member WHERE some_id = '';

ここで some_idINT カラムである。MySQLは比較の両辺で型が食い違うと、片方を無言で数値へ寄せる。文字列 '' を数値の文脈に置くと、その値は 0 になる。つまり実際に評価されるのは次の条件だ。

SELECT * FROM member WHERE some_id = 0;

空でIDを持たないつもりの検索が、「some_id が 0 の行を探せ」という全く別の意味にすり替わっていた。暗黙変換の挙動を短く並べるとこうなる。

'' = 0      -- 真
'abc' = 0   -- 真('abc' は数値化すると 0)
'12ab' = 12 -- 真(先頭の数値部分だけ拾われる)

そして運の悪いことに、このテーブルには some_id = 0 の行が実在した。過去の不整合で生まれた、いわば残骸行だ。新規画面はIDが空だと信じて問い合わせ、MySQLはそれを0と読み替え、0番の残骸を拾い上げて「選択済み」として返す。症状の全体像がここでつながった。

入口でガードし、出口で掃除する

対策は2つ入れた。ロジック側とデータ側の両方だ。

ひとつは、そもそも問い合わせないこと。IDが空、あるいは0のときはメンバー検索SQLを発行しない。

if (IDが空でも0でもない) {
    // このときだけメンバーを問い合わせる
}

新規状態では検索そのものを走らせなければ、0番だろうが何番だろうが残骸を掴みようがない。ゼロコストで最も効く防御は、余計なクエリを撃たないことだった。

もうひとつは、根を断つこと。some_id = 0 の残骸行をデータ側から掃除する。ガードは今回の画面を守るが、同じ0番を別の経路が拾う可能性は残る。不正な行が存在し続ける限り、いつか誰かがまた踏む。

なお、プリペアドステートメントにすれば直る話ではない。バインドはSQLインジェクションを防ぐが、INT カラムに空文字相当を渡せば、型の強制は依然として起きる。バインドと型変換は別の問題だ。空を空として扱う責任は、あくまでアプリケーション側にある。

AIとの協働作業での学び

緩い型のシステムでは、「空文字」と「0」は同一視されうる。人間にとって「まだ何も無い」と「ゼロという値がある」は明確に違うが、暗黙変換はその区別を平気で溶かす。

IDのような識別子は、空を空のまま扱うのが鉄則だ。うっかり数値の0へ化けさせると、既定行やゼロ埋めレコードといった「0番に潜む何か」を静かに掴んでしまう。だから守るべきは入口だ。空のときは比較に持ち込まず、そもそも問い合わせない。

暗黙の型変換は、比較の両辺の型を意識していないと、エラーも警告も出さずに間違った行を返す。返ってきた行が正しいかどうかは、SQLではなく、その手前で決まっている。