ActiveRecordって本当に便利ですよね。SQLを隠蔽して、データベースへのCRUD操作をオブジェクトとメソッドで完結させることが出来ます。一例として、users
テーブルから権限(authority)がadmin
のレコード一覧を取得する操作は以下のようになります。
(※分かり易さのため、authorityはstring型にしてあります)
usersテーブル
カラム名 | 型 | 表記名 |
---|---|---|
user_id | int8(primary key) | ユーザーID |
name | varchar | 名前 |
gender | int4 | 性別 |
authority | string | 権限 |
created_at | timestamp | 作成日時 |
updated_at | timestamp | 更新日時 |
User.where(authority: 'admin')
この操作によって発行されるSQLは以下です。SQLの確認にはto_sql
を使用しています。
SELECT users.* FROM users WHERE authority = 'admin'
ご覧の通り、SELECT
句にはusers.*
が宣言されているためusers
テーブルの全てのカラムの情報を取得します。今回の例では、カラム数がせいぜい6つで大した問題ではありませんが、本番稼働しているサービスではカラム数が数十個になるのはよくあることで、カラムを全て明示的に指定する必要のないワイルドカードの*
が使用されています。
これ...アンチパターン...
ただ書籍「SQLアンチパターン」ではこのような全カラムの取得はアンチパターンとして紹介されています。理由としては、大量のカラムの取得によるパファーマンス低下、INSERT
文への影響、同じカラムを持つテーブル同士を結合した際に問題が発生する...などが記載されています。
第18章: インプリシットカラム(暗黙の列)より
過去にSQLアンチパターンを読んだ時に書いた記事
www.okb-shelf.work
自分としては必要な値だけを取得するべきだと考えています。データベースの設計は運用と共に変化するため、特定テーブルに多くのカラムが追加されていきます。そのような状態でSELECT * FROM table_name
が発行され続けると塵も積もれば山となり、パフォーマンスの悪化をもたらします。
全カラム取得を避けるには
ではActiveRecordではどのようにSELECT users.*
のような全カラム取得を避ければ良いでしょうか。結論としてはselect
関数を使うことで回避が出来ます。
rails/query_methods.rb at 843bdef6684692fbf30b729123496d94ccdb0dfe · rails/rails · GitHub
User.select(:user_id, :name).where(authority: 'admin') # sql: SELECT users.user_id, users.name FROM users WHERE authority = 'admin'
SELECT * FROM table_name
を回避するという目標はこれで達成出来ました。
select関数の問題
目標は達成出来たものの、Qittaの記事でも述べられているように、この方法には問題があります。
- 取得されていると思っていたカラムが実は
select
関数が呼ばれていたことで取得されていない - 毎回
select
関数を呼び出すのが手間 - 特定のカラムだけを取得しないクエリはSQLに実装されておらず、カラム数が多いテーブルでは
select
関数に取得したい全てのカラムを指定する必要がある
つまり、select
関数を使った一部カラムの取得は非常に保守性が悪いと言わざるを得ません。
解決方法
SELECT * FROM table_name
の全撲滅は難しいですが、局所的に問題となる箇所を狙い撃ちすることを考えます。
取得したいカラムと取得したくないカラムでは多くのケースで、取得したいカラム数の方が取得したくないカラム数に比べて多いため、取得しないカラムを指定する方が楽そうです。また、指定する項目が少ないというのもヒューマンエラーを防ぐことに繋がります。
そうすれば、パフォーマンスが気になる箇所では数個の取得したくない不必要なカラムを指定するだけで、簡単にSELECT * FROM table_name
を排除することが出来ます。
ということでselect
関数の逆! 取得したくないカラムを指定するunselect
関数を実装してみました。実装はmodels/user.rb
のscope
機能を使ってActiveRecord
を拡張する形式にしました。こうすることでUser.unselect
のような記述が出来ます。また、簡単のため引数はシンボルの複数指定形式にのみ対応しています。
# models/user.rb scope :unselect, -> (*args) do # シンボル形式で指定された値を全カラムから除去 # [user_id, name ...., updated_at] - [updated_at] = [user_id, name, ...] select_columns = User.column_names - args.map(&:to_s) # 残ったカラムをsqlに変換するため"users.user_id, users.name, ..."のようなsqlのSELECT句に変換 select_query = select_columns.map { |column| "#{User.table_name}.#{column}" }.join(', ') select(select_query) end
呼び出しと発行されるSQL
User.unselect(:created_at, :updated_at) # sql: SELECT user.user_id, user.name, user.gender, user.authority FROM users
いい感じにできました。
ただ、この時点ではUser
モデルでしかunselect
を呼び出すことが出来ないため、ApplicationRecord
を継承している全てのモデルでunselect
が呼び出せるように実装箇所をmodels/user.rb
からmodels/application_record.rb
に移動させます。またUser
と記述した箇所をself
に変更します。
※実装によっては移動先がapplication_record.rb
ではないかもしれません。self.abstract_class = true
の指定があるファイルが対象です。
scope :unselect, -> (*args) do select_columns = self.column_names - args.map(&:to_s) select_query = select_columns.map { |column| "#{self.table_name}.#{column}" }.join(', ') select(select_query) end
これでApplicationRecord
を継承している全てのモデルクラスからunselect
が実行出来るようになりました。
User.unselect(:name, gender) Post.unselect(:created_at, :updated_at) # 仮想のPostモデル Comment.unselect(:user_id) # 仮想のCommentモデル