Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

複雑なクエリーをActive Recordのモデルとして定義する方法

概要

以下の accountsprofiles というテーブルから、WITH句で users テーブルを作り、この users テーブルを Active Record のモデルとして利用できるようにします。
Railsのバージョンは7を用いましたが6でも動作すると思います。

  • accounts テーブル
列名 概要
id int PK
email string ログインメールアドレス
password string ログインパスワード
  • profiles テーブル
列名 概要
id int PK
account_id int FK
first_name string
last_name string
  • users 仮想テーブル
列名 概要
id int アカウントIDと同じ
email int ログインメールアドレス
first_name string
last_name string

WITH句

SQLではWITH句を用いて任意のサブクエリーに対して名前をつけることができます。
SELECT * FROM (複雑なサブクエリー)WITH any_name AS (複雑なサブクエリー) SELECT * FROM any_name のように記述でき、可読性の向上かが期待でき、再利用も可能となります。
WITH句では、(複雑なサブクエリー)の部分を CTE (Common Table Expression) と呼びます

今回は users 仮想テーブルでは以下のSQLの実行を目標とします

WITH "users" AS (
  SELECT
    "accounts"."id",
    "accounts"."email",
    "profiles"."first_name",
    "profiles"."last_name"
  FROM "accounts"
         INNER JOIN "profiles" 
         ON "profiles"."account_id" = "accounts"."id"
)
SELECT
  "users".*
FROM "users"

Arel::Nodes::With

Arelには Arel::Nodes::With が用意されていて、以下のような実装で With句 を生成できます

users = Arel::Table.new('users')
Arel::Nodes::With.new([Arel::Nodes::As.new(users, cte)])

WITH "users" AS ( cte で定義したSQL )

ただしWITH句単体を生成する必要はなく、通常は Arel::SelectManager#with を用いることになります

users = Arel::Table.new('users')
Arel::SelectManager
  .new(users)
  .project(users[Arel.ster])
  .with(Arel::Nodes::As.new(users, cte))

WITH "users" AS ( cte で定義したSQL ) SELECT "users".* FROM "users"

cte は Account とProfile のRailsWayな内部結合となりますので

accounts = Account.arel_table
profiles = Profile.arel_table
cte = Account.joins(:profile).select(
  accounts[:id],
  accounts[:email],
  profiles[:first_name],
  profiles[:last_name]
).arel

とすれば目標とするSQLが生成できます

ApplicationRecord.relation

このメソッドをオーバーライドしたモデルでは任意のSQL実行結果を得ることができます。

以下の例では(まるで不適切ですが) Profile.allAccount.all の結果を返す例になります

class Profile < ApplicationRecord  
  def self.relation
    Account.all
  end
end

Profile.all
#=> [#<Account:0x00000001094c66e8 id: 1, email: "taro-yamada@email.com", ...

ActiveRecord::Relation.build_arel

このメソッドで(正確には ActiveRecord::Relationincludeする ActiveRecord::QueryMethods で実装されている)発行するSQLの元となる Arel::SelectManager が生成されます。

このメソッドをオーバーライドすることで任意のSQLを実行することができます。
以下の例では(こちらも不適切ですが) 任意のリレーションが常に Account.all の結果を返します

profiles = Profile.where(id: 0)
profiles.class_eval do
  def build_arel(...)
    Account.all.arel
  end
  self
end
profiles.all
#=> [#<Account:0x00000001094c66e8 id: 1, email: "taro-yamada@email.com", ...

存在しないテーブルの偽装

Active Record はモデルロード時にDBのカラム定義から自動的に属性を生成するという強力なライブラリであるため、裏返せば対応するテーブルが存在しないとActive Recordは使えないことになります。

詳しい解説は割愛しますが、 ApplicationRecord.table_exists?ApplicationRecord.load_schema! をオーバーライドすることでモデルにテーブルが存在することを偽装することができます。筆末のコードをご参照ください

User仮想テーブルモデル

これまでで以下の技術を見てきました。

  • With句を用いてサブクエリーに名前をつける
  • Arel::SelectManager に WITH句 を付加する
  • モデルが生成するSQLを任意に変更する
  • モデルにテーブルがあることを偽装する

これらを組み合わせてusers仮想テーブルモデルが完成となります
以下がコード例となります

class User < ApplicationRecord
  # 必須ではないが属性を定義する
  attribute :id, :integer
  attribute :email, :string
  attribute :first_name, :string
  attribute :last_name, :string

  # 更新はしないのでreadonlyにしておく
  def readonly?
    true
  end

  # CTEを定義する
  def self.cte
    accounts = Account.arel_table
    profiles = Profile.arel_table

    Account.joins(:profile).select(
      accounts[:id],
      accounts[:email],
      profiles[:first_name],
      profiles[:last_name]
    ).arel
  end

  class << self
    # テーブルが存在することにする
    def table_exists?
      true
    end

    # スキーマロード時にカラム情報の格納場所を確保しておく
    def load_schema!
      connection.schema_cache.instance_exec(table_name) do |table_name|
        @columns[table_name] = [] unless @columns.key?(table_name)
      end

      super
    end

    # 生成されるSQLにWITH句をつける
    def relation
      super.tap do |relation|
        relation.class_eval do
          def build_arel(...)
            super.with(Arel::Nodes::As.new(arel_table, klass.cte))
          end
        end
      end
    end
  end
end


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。