概要
以下の accounts
と profiles
というテーブルから、WITH句で users
テーブルを作り、この users
テーブルを Active Record のモデルとして利用できるようにします。
Railsのバージョンは7を用いましたが6でも動作すると思います。
accounts
テーブル
列名 | 型 | 概要 |
---|---|---|
id | int | PK |
string | ログインメールアドレス | |
password | string | ログインパスワード |
profiles
テーブル
列名 | 型 | 概要 |
---|---|---|
id | int | PK |
account_id | int | FK |
first_name | string | 名 |
last_name | string | 姓 |
users
仮想テーブル
列名 | 型 | 概要 |
---|---|---|
id | int | アカウントIDと同じ |
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.all
が Account.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::Relation
がinclude
する 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