はじめに
くずばです。
現在、Ruby on Railsエンジニアとして3年半ほど、Webアプリケーションの開発・運用に携わっています。
先日、Railsのアプリケーションで遭遇した「期待通りのカラムの値が取れていない」という問題について、記事を書きたいと思います。
結論、JOINしたテーブルで同じカラム名があると、後から指定した方で上書きされるという挙動が原因だったのですが、オブジェクト指向言語でORMを使っていると起こりそうな問題だと思ったので、自分の理解を深めるためにも検証してみました。
同じようにハマった方の参考になれば幸いです。
この記事で出てくるキーワード整理
ActiveRecord
├── Relation(クエリの設計図)
│ ├── select(取得するカラムを指定)
│ ├── joins(テーブルを結合)
│ └── to_sql(発行されるSQLを確認)
├── 遅延評価(実行するまでSQLが発行されない)
└── カラム名衝突(同じ名前だと後勝ち)
検証用の環境
実際に起こったことを、簡単な例で再現してみます。
Taroさんは数学が得意(90点)で国語が苦手(35点)という設定です。
ポイントは、両方のテーブルに score という同じ名前のカラムがあることです。
students math_scores japanese_scores
+----+------+ +----+------------+-----+ +----+------------+-----+
| id | name | | id | student_id |score| | id | student_id |score|
+----+------+ +----+------------+-----+ +----+------------+-----+
| 1 | Taro | | 1 | 1 | 90 | | 1 | 1 | 35 |
+----+------+ +----+------------+-----+ +----+------------+-----+
ぜひirbなどで試してみてください!
検証用スクリプト(クリックで展開)
require "active_record"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.connection.execute("CREATE TABLE students (id INTEGER PRIMARY KEY, name VARCHAR(50))")
ActiveRecord::Base.connection.execute("CREATE TABLE math_scores (id INTEGER PRIMARY KEY, student_id INTEGER, score INTEGER)")
ActiveRecord::Base.connection.execute("CREATE TABLE japanese_scores (id INTEGER PRIMARY KEY, student_id INTEGER, score INTEGER)")
ActiveRecord::Base.connection.execute("INSERT INTO students VALUES (1, 'Taro')")
ActiveRecord::Base.connection.execute("INSERT INTO math_scores VALUES (1, 1, 90)")
ActiveRecord::Base.connection.execute("INSERT INTO japanese_scores VALUES (1, 1, 35)")
class Student < ActiveRecord::Base
has_one :math_score
has_one :japanese_score
end
class MathScore < ActiveRecord::Base
belongs_to :student
end
class JapaneseScore < ActiveRecord::Base
belongs_to :student
end
※ 事前に gem install activerecord sqlite3 が必要です
同じカラム名があると何が起きる?
以下では、Taroさんの数学の点数を取得しようとしています。
しかし、期待通りの値が取れていませんでした。
query = Student
.joins(:math_score, :japanese_score)
.select("math_scores.score")
...
result = query.first
result.score
#=> 35 # 数学の90点を取得したかったはずが、国語の35点が返ってしまった
調べてみると、...の処理が別ファイルに書かれており、こんなことがおこっていました。
query = Student
.joins(:math_score, :japanese_score)
.select("math_scores.score")
# 国語のscoreも後続で必要だったので追加
query = query.select("japanese_scores.score")
result = query.first
result.score
このselectするカラムの追加により、カラム名衝突が起こり、result.score の値が上書きされてしまいました。
SELECT math_scores.score, japanese_scores.score FROM ...
└─── 90 ───┘ └─── 35 ───┘
↑
同じ「score」という名前なので
result.score の値が上書きされる
以下でもう少し詳しく説明していきます。
なぜ上書きされたのか? ActiveRecordの仕組み
selectはクエリの設計図を作る
select を呼んでも、この時点ではまだSQLは発行されていません。
query = Student
.joins(:math_score)
.select("math_scores.score")
query.class
#=> Student::ActiveRecord_Relation
返ってくるのは ActiveRecord::Relation というオブジェクトで、これはクエリの設計図のようなものです。
┌─────────────────────────────────────────┐
│ ActiveRecord::Relation │
│ ┌───────────────────────────────────┐ │
│ │ SELECT math_scores.score │ │
│ │ FROM students │ │
│ │ INNER JOIN math_scores ON ... │ │
│ └───────────────────────────────────┘ │
│ ↑ まだ実行されていない │
└─────────────────────────────────────────┘
実際にSQLが発行されるのは、.first や .to_a などを呼んだ時です。これを遅延評価と呼びます。
selectをチェーンするとSELECT句に追加される
ActiveRecord::Relationオブジェクト はメソッドを繋げて呼び出せます(メソッドチェーン)。後から select を追加すると、SELECT句にカラムが追加されます。
query = Student
.joins(:math_score, :japanese_score)
.select("math_scores.score")
query = query.select("japanese_scores.score")
query.to_sql
#=> SELECT "math_scores"."score", "japanese_scores"."score"
# FROM "students"
# INNER JOIN ...
# ↑2つの"score"がselectされるクエリが発行されている
結果はハッシュ的に扱われる
ActiveRecordは結果を attributes というハッシュで管理しています。
しかし、同じキー名があると一方しか保持できません。
result = query.first
result.attributes
#=> {"score"=>35, ...} # scoreが1つしかない
DBからは両方の値が返ってきていますが、attributesに登録する際に上書きされます。
# 内部的にはこんなイメージ
row = {}
row["score"] = 90 # math_scores.score
row["score"] = 35 # japanese_scores.score で上書き
だから result.score が35になってしまったのです。
全体の流れ
1. selectでRelationに登録
┌─────────────────────────────────────┐
│ ActiveRecord::Relation │
│ SELECT: [math_scores.score, │
│ japanese_scores.score] │
└─────────────────────────────────────┘
↓
2. .first でクエリ発行
SELECT math_scores.score, japanese_scores.score
FROM students ...
↓
3. DBから結果が返ってくる
| score | score |
| 90 | 35 |
↓
4. モデルインスタンスにハッシュとして格納(ここで上書きが起きる)
row["score"] = 90 ← math_scores.score
row["score"] = 35 ← japanese_scores.score で上書き
↓
5. result.score #=> 35
解決策
AS句でエイリアスを付けることで、カラム名を明示的に区別できます。
query = Student
.joins(:math_score, :japanese_score)
.select("math_scores.score AS sugaku")
query = query.select("japanese_scores.score AS kokugo")
result = query.first
result.sugaku #=> 90
result.kokugo #=> 35
両方正しく取れるようになりました。
発行されるSQLはこうなります。
SELECT math_scores.score AS sugaku,
japanese_scores.score AS kokugo
FROM students
INNER JOIN ...
まとめ
Q. なぜ違う値が返ってきた?
A. JOINしたテーブルに同じカラム名があり、result.score の値が上書きされたため
Q. selectをチェーンすると何が起きる?
A. SELECT句にカラムが追加される。同じ名前だと後勝ちになる
Q. selectしてもすぐSQLが発行されない?
A. ActiveRecordは遅延評価。.first や .to_a を呼ぶまでSQLは発行されない
参考文献
間違いや補足があれば、コメントいただけると嬉しいです。