ActiveRecordにてSELECT * FROM table_nameを避ける方法について

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アンチパターン

SQLアンチパターン

Amazon

過去にSQLアンチパターンを読んだ時に書いた記事
www.okb-shelf.work

自分としては必要な値だけを取得するべきだと考えています。データベースの設計は運用と共に変化するため、特定テーブルに多くのカラムが追加されていきます。そのような状態でSELECT * FROM table_nameが発行され続けると塵も積もれば山となり、パフォーマンスの悪化をもたらします。

全カラム取得を避けるには

ではActiveRecordではどのようにSELECT users.*のような全カラム取得を避ければ良いでしょうか。結論としてはselect関数を使うことで回避が出来ます。

railsguides.jp

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関数を使った一部カラムの取得は非常に保守性が悪いと言わざるを得ません。

qiita.com

解決方法

SELECT * FROM table_nameの全撲滅は難しいですが、局所的に問題となる箇所を狙い撃ちすることを考えます。
取得したいカラムと取得したくないカラムでは多くのケースで、取得したいカラム数の方が取得したくないカラム数に比べて多いため、取得しないカラムを指定する方が楽そうです。また、指定する項目が少ないというのもヒューマンエラーを防ぐことに繋がります。

そうすれば、パフォーマンスが気になる箇所では数個の取得したくない不必要なカラムを指定するだけで、簡単にSELECT * FROM table_nameを排除することが出来ます。

ということでselect関数の逆! 取得したくないカラムを指定するunselect関数を実装してみました。実装はmodels/user.rbscope機能を使って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モデル

参考文献