🃏

SELECT同名列とPDOの後勝ち — 「DBの生の値」を信じて根本原因を読み違えた話

に公開

はじめに

AIエージェントと一緒にバグ調査をしていて、地味だけれど嵌まると厄介な罠を踏みました。SELECT t.* とJOIN先の列を混ぜて取得していると、同名の列ができることがあります。そして PDO の FETCH_ASSOC でこれを連想配列として受け取ると、後に出てきた列が前の列を静かに上書きします(last-wins)。結果、「DBに入っている生の値」と「アプリが実際に読む値」が食い違い、その食い違いに気づかないまま根本原因を読み違える、という事故です。

先に手触りを書いてしまうと、

「DBの生の列値」は根本原因の証拠にならない。アプリが実際に読むのは fetch 後の連想配列の値であって、JOIN と列順とfetchモード次第で、それは生の値と平気で食い違う。断定する前に「アプリが読む値」を見よ。

今回は、ある画面の選択肢リストが空になる、という症状から始まりました。最初の見立ては「別の列の値が 0 だから、価格判定で全部スキップされて空になっている」というものでした。一見もっともらしく、DBを引くと確かにその列は 0 でした。ところが実機で追い直すと、原因はまったく別のところにあり、しかも「DBで 0 だった」その値すら、アプリ側では別の値に化けていました。二重に「生の値 ≠ 読まれる値」だったわけです。

題材の具体は抽象化しますが、SELECT t.* を使う PHP / PDO(Laravel 含む)のコードなら、どこでも踏みうる罠です。


1. 症状 — 子拠点の選択肢リストが空になる

舞台は、親組織が複数の子拠点を束ねるマルチテナント的な構造です。ユーザーがある子拠点を利用すると、その子拠点に紐づく「選択肢リスト」(拠点ごとに違うオプションの一覧)が画面に出る、という導線がありました。

ここで、子拠点を利用しているのに、その選択肢リストが 0 件で空になるという症状が見つかりました。コードを追うと、おおよそこういう流れです(抽象化したコードです)。

// あるユーザーのアクティブな利用情報を1行取得する共通メソッド
$detail = $repository->getActiveDetailByUserId($userId);

// その利用情報の拠点IDで、拠点ごとの選択肢リストを引く
$options = $optionRepository->getListByBranchId($detail['branch_id']);

// さらに、会員種別ごとの価格が設定されているものだけ表示する
foreach ($options as $opt) {
    if ($opt['is_price' . $detail['member_type_id']]) {
        // 表示対象に積む
    }
}

この時点では、自分(と AI エージェント)は「getActiveDetailByUserId は利用中の行をそのまま返している」と素朴に思い込んでいました。返ってくる連想配列の branch_id は利用先の子拠点、member_type_id は会員の種別、というつもりです。


2. 最初の誤った見立て — DBの生の値だけ見て断定した

調査を AI に任せたところ、最初に出てきた根本原因はこうでした。

「アクティブな利用行の member_type_id0 になっている。is_price0 という列は設定されていないことが多いので、価格判定(if ($opt['is_price' . 0]))で全部スキップされ、結果リストが空になっている。member_type_id を記録するときの配線漏れが根本原因だ。」

裏付けとして、実際にDBを引いていました。

SELECT branch_id, member_type_id FROM sales WHERE ... ;
-- branch_id = 294 (利用先の子拠点)
-- member_type_id = 0

確かに member_type_id は 0 です。ストーリーとしてもよくできています。「0 という値 → is_price0 という存在しない列 → 全スキップ → 空」。自分も一度は「なるほど」と受け取りかけました。

しかしこの見立ては誤りでした。DBの生の列値を見て、アプリがその値を読んでいると断定したのが間違いの入口です。

学び: 「DBにこの値が入っている」は「アプリがこの値を読んでいる」を意味しない。両者の間には SELECT・JOIN・fetch という変換が挟まる。


3. 実機で追い直したら値が化けていた

別の視点(別セッションの AI)で、今度はDBの生の値ではなく、getActiveDetailByUserId が実際に投げている SQL と、その fetch 結果を追い直しました。すると SQL がこういう形をしていました(抽象化しています)。

SELECT
    s.*,                      -- sales の全カラム(branch_id, member_type_id を含む)
    b.id AS branch_id,        -- JOIN したマスタの id を branch_id として「再エイリアス」
    m.member_type_id          -- JOIN した会員テーブルの member_type_id
FROM sales s
INNER JOIN members m  ON s.member_id = m.id
INNER JOIN branches b ON m.home_branch_id = b.id   -- 会員の「所属拠点」で JOIN
WHERE ...

ここに罠が二つ仕込まれています。

  • s.*sales テーブルの全カラムを展開します。この中に branch_id(利用が発生した子拠点 = 294)と member_type_id(= 0)が含まれます。
  • ところが後段で、b.id AS branch_idm.member_type_id という、同じ名前の列をもう一度 SELECT しています。b は会員の「所属拠点」(= 親拠点 292)、m.member_type_id は会員レコード側の種別(= 1)です。

つまり結果セットには branch_id が 2 回、member_type_id が 2 回現れます。そして FETCH_ASSOC で連想配列にすると——

$detail = $stmt->fetch(PDO::FETCH_ASSOC);
$detail['branch_id'];       // 294 ではなく 292(会員の所属拠点 = 親)
$detail['member_type_id'];  // 0 ではなく 1(会員レコード側の値)

$detail['branch_id'] は、利用先の子拠点 294 ではなく、JOIN先の親拠点 292 に化けていました。だから getListByBranchId(292) が呼ばれ、親拠点には選択肢が 0 件 → リストが空。これが本当の原因でした。

そして象徴的なのは member_type_id です。DBの生の値は 0 でしたが、アプリが実際に読むのは後勝ちした m.member_type_id = 1 でした。価格判定(is_price0)の行にはそもそも到達していなかったのです。最初の見立てが「証拠」にしていたDBの 0 という値は、アプリ側では一度も読まれていませんでした。

学び: 同名列があると、FETCH_ASSOC の連想配列は後に現れた列で上書きされる。コードを読んで思い描く値と、実際に fetch される値がずれる。


4. なぜ後勝ちになるのか — PDO FETCH_ASSOC の仕様

理由はシンプルです。PDO::FETCH_ASSOC列名をキーにした連想配列 を返します。連想配列は同じキーを 1 つしか持てないので、結果セットの中に同名の列が複数あると、後に出てきた値で上書きされます(多くの環境で last-wins)。

最小再現はこうです。

$pdo = new PDO('mysql:host=...;dbname=...', $user, $pass);

$sql = "SELECT 1 AS x, 2 AS x";   // 同名列 x が 2 つ
$row = $pdo->query($sql)->fetch(PDO::FETCH_ASSOC);

var_dump($row);
// array(1) { ["x"]=> string(1) "2" }   ← 後の 2 だけが残る

x というキーは 1 つしか残らず、値は後ろの 2 です。前の 1 はどこにも出てきません。エラーにも警告にもなりません。ここが厄介なところで、黙って消えるのです。

ちなみに FETCH_NUM(数値添字)や FETCH_BOTH を使うと、両方の値を取れます。

$row = $pdo->query("SELECT 1 AS x, 2 AS x")->fetch(PDO::FETCH_NUM);
var_dump($row);
// array(2) { [0]=> string(1) "1" [1]=> string(1) "2" }   ← 両方残る

FETCH_ASSOC だけがキーの衝突で値を落とします。そして Laravel のクエリビルダや Eloquent のデフォルトは連想配列ベースなので、select('s.*', 'b.id as branch_id') のような書き方で素直にこの罠に入れます。

学び: FETCH_ASSOC は同名列を 1 キーに畳む。落ちた値は警告も出さない。FETCH_NUM で引き直すと衝突の有無を確認できる。


5. なぜ見抜きにくいのか

この罠が厄介なのは、各段階が単独では「正しそう」に見えるからです。

  • s.* は便利なので多用される。 「とりあえず全カラム取っておく」は日常的な書き方です。そこに後から JOIN とエイリアスを足すと、知らないうちに同名列が生まれます。
  • JOIN先の idxxx_id として再エイリアスするのもよくある。 マスタを引いて使いたいときに b.id AS branch_id と書くのは自然です。それが s.* の中の branch_id と衝突するとは、書いている時点では気づきにくい。
  • DBを直接引くと「正しい生の値」が見える。 だから調査では生の値を証拠にしてしまう。でもアプリが読むのは fetch 後の値で、両者は別物です。
  • 症状(空リスト)から逆算したストーリーが、たまたま生の値と整合してしまった。 member_type_id = 0 という生の値は、「0 だからスキップされた」という筋書きにぴったり合いました。整合してしまうと、それ以上は疑わなくなります。

特に最後の点が効きました。AI に調査させると、症状に合う筋書きを素早く組み立ててくれます。それが「DBの生の値」と整合していると、いかにも裏付けが取れたように見える。けれど整合は偶然で、実際にアプリが読む値はまったく別だった——という落とし穴でした。実機で fetch 結果を確認するまでは、生の値はただの傍証にすぎなかったわけです。

学び: 各段階が単独で「正しそう」だと、合成された誤りは検出されにくい。症状に合うストーリーが生の値と整合しても、それは根拠の確定ではない。


6. 対策

今回踏まえて、効く対策を 4 つ書きます。

対策 1: 同名列を作らない(再エイリアスは衝突しない名前で)

JOIN先の列を再エイリアスするときは、s.* に含まれる名前と衝突しないユニークな名前にします。

-- NG: s.branch_id と衝突する
SELECT s.*, b.id AS branch_id FROM sales s INNER JOIN branches b ON ...

-- OK: 衝突しない名前にする
SELECT s.*, b.id AS resolved_branch_id FROM sales s INNER JOIN branches b ON ...

意図も明確になります。「これは利用拠点ではなく会員の所属拠点だ」という区別が、列名に出ます。

対策 2: t.* と JOIN 列の混在を避け、必要な列を明示列挙する

そもそも s.* をやめて必要な列だけ並べれば、同名列は混入しません。SELECT * を避けるという昔ながらの作法は、こういう衝突の予防としても効きます。

対策 3: 値を断定する前に「fetch 後の連想配列」を見る

調査で「この変数はこの値のはず」と断定する前に、実際に fetch された連想配列をそのまま出力して確認します。DBを直接引いた生の値ではなく、アプリが受け取った値を真実とする、という順序です。

$detail = $repository->getActiveDetailByUserId($userId);
logger()->debug('fetched detail', $detail);   // 生の値ではなく、これを見る

ここで branch_id が想定と違えば、その時点で「SELECT に同名列があるのでは?」と疑えます。

対策 4: レビュー観点に「SELECTの同名列」を入れる

s.* + JOIN列のエイリアスというパターンを見たら、「同名列が生まれていないか」を機械的にチェックする観点をレビューに入れておきます。FETCH_NUM で引いて列数と中身を確認すれば、衝突は一発でわかります。静的に検出する仕組みに落とせれば、なお良いです。

学び: 衝突は「作らない(命名・列の明示)」「気づく(fetch 結果の確認・レビュー観点)」の両輪で潰す。


7. まとめ — 「生の値」ではなく「アプリが読む値」を真実とする

今回の根本原因は、技術的には設定や命名のレベルの小さな話です。s.*b.id AS branch_id で同名列ができ、FETCH_ASSOC の後勝ちで branch_id が別の拠点に化けていた。それだけです。

けれど調査の教訓としては、もう少し普遍的なものが残りました。

「DBにこの値が入っている」と「アプリがこの値を読んでいる」は別物。両者の間には SELECT・JOIN・fetchモードという変換が挟まり、同名列の後勝ちのような形で静かに食い違う。根本原因を断定する前に、生の値ではなく fetch 後の値——アプリが実際に読む値——を確認する。

これは PHP / PDO に限った話ではありません。どの言語・どのORMでも、「ストレージに入っている値」と「コードが手にする値」の間には変換層があり、そこで値は化けます。デバッグで生のストレージを引いて「ほら、この値だ」と裏付けにするのは、その変換層を飛ばして証拠にしてしまう行為です。今回はたまたま、飛ばした生の値が症状のストーリーと整合してしまったために、誤った見立てがもっともらしく見えました。

幸い、今回は別の視点で実機の fetch 結果を追い直したことで、本番反映の前に正しい原因にたどり着けました。地味な罠ですが、SELECT * を多用する現場ほど踏みやすいので、書き残しておきます。


関連記事

GitHubで編集を提案

Discussion