• Ruby / Rails関連

RDBMSのVIEWを使ってRailsのデータアクセスをいい感じにする【銀座Rails#10】

morimorihogeです。しばらくぶりですが、この度銀座Rails#10 @リンクアンドモチベーションにて発表させていただきましたので、その内容をまとめたいと思います。
※当日は時間が足りなくて端折ってしまう部分もあるかと思うので、その補遺としての意味合いもあります

注1:本記事では分かりやすさのためにTABLEやVIEWなどのSQL予約語は大文字で記載していきます。
注2:Rails 5.2.3、PostgreSQL 11環境で検証しています

おさらい:VIEWについて

本記事におけるVIEWはRDBMSにおけるVIEWの話で、ActionViewではありません
VIEWについて使ったことがない人もいるかなと思うので、最初に軽く解説します。

VIEWは一言で言ってしまえばSELECT文の実行結果に名前を付けて、TABLEと同じようにアクセスできるものです。
例えば、以下のようなproductsテーブルとデータがあったとします。

CREATE TABLE products
(
    id             SERIAL       NOT NULL PRIMARY KEY,
    name           VARCHAR(255) NOT NULL,
    price          INTEGER      NOT NULL,
    purchase_price INTEGER      NOT NULL
);

INSERT INTO products(name, price, purchase_price)
VALUES ('安いビール', 150, 140),
       ('普通のビール', 200, 180),
       ('お高いビール', 1500, 1300);

このproductsテーブルに対して、idカラムを除いたデータを参照するview_productsを作るにはCREATE VIEW [view_name] AS [query]構文を使います。

CREATE VIEW view_products AS SELECT name, price FROM products;

こうして作られたVIEWは、通常のTABLEと同じようにSELECTすることができます

view_demo_development=# select * from products;
 id |     name     | price | purchase_price
----+--------------+-------+----------------
  1 | 安いビール   |   150 |            140
  2 | 普通のビール |   200 |            180
  3 | お高いビール |  1500 |           1300
(3 rows)

view_demo_development=# select * from view_products;
     name     | price
--------------+-------
 安いビール   |   150
 普通のビール |   200
 お高いビール |  1500
(3 rows)

VIEWの機能は基本的にはこれだけです。シンプルですね。

また、VIEWはSQL標準機能としてSQL89から存在しており、一般的なRDBMSであれば使えます
PostgreSQLであれば\dでTABLEやSEQUENCEと一緒に一覧できます。

view_demo_development=# \d
                  List of relations
 Schema |         Name         |   Type   |  Owner
--------+----------------------+----------+----------
 public | products             | table    | postgres
 public | products_id_seq      | sequence | postgres
 public | view_products        | view     | postgres
(3 rows)

RailsのActiveRecordとVIEW

RalisのORM(Object-Relational Mapper)であるActiveRecordでは、実は接続しているDBに既に作られたVIEWであれば何も考えずに参照することができます

$ bundle exec rails console
Running via Spring preloader in process 23674
Loading development environment (Rails 5.2.3)
irb(main):001:0> class ViewProduct < ApplicationRecord;end
=> nil
irb(main):002:0> PP.pp ViewProduct.all
  ViewProduct Load (0.5ms)  SELECT "view_products".* FROM "view_products"
[#<ViewProduct:0x000055717a1b21c8 name: "安いビール", price: 150>,
 #<ViewProduct:0x000055717a1b10e8 name: "普通のビール", price: 200>,
 #<ViewProduct:0x000055717a1b0fa8 name: "お高いビール", price: 1500>]
=> #<IO:/dev/pts/5>

※後述しますが、migration systemで管理する場合はGemの導入が必要です

#save系メソッドなどの更新系については可能なVIEWと不可能なVIEWがあり、更新可能なカラムもVIEWの定義によって変わります。更新可能なVIEWの条件についてはPostgreSQLのCRAETE VIEWのドキュメントが詳しいです。
※他のRDBMSでも基本的な条件は近いと思いますが、実装依存もあると思うので実際に使う場合は利用しているRDBMSのドキュメントを参照してください。

ここでは、VIEWはSELECT文を実行する観点においてTABLEと同一視することができるということを抑えてくれればOKです。

AciveRecordでVIEWをいい感じに使う

では、実際に具体的な事例を用いてVIEWを使った事例を紹介していきます。
説明のためにusersテーブルを定義します。一般的なメールアドレス+パスワードでログインするようなECサイトのようなものを想像してください。

class User < ActiveRecord::Migration[5.2]
  def change
    create_table :users do |t|
      t.string :email, null: false, unique: true, comment: 'メールアドレス'
      t.string :name, null: false, comment: '公開ユーザー名'
      t.text :description, null: false, default: '', comment: '公開用紹介文'
      t.string :password, null: false, comment: 'パスワードハッシュ'
      t.datetime :confirmed_at, comment: 'メール認証日時'

      t.timestamps
    end
  end
end

要件1:有効なユーザーだけを取得したい

users.confirmed_atでメール認証を行うことが読み取れますが、逆に言えばメール認証されていないユーザーはまだ有効なユーザーとして扱いたくないわけです。
具体的にはusers.confirmed_at IS NOT NULLなユーザーだけを取得したいとします。

一般的なRails Wayに従うのであれば、scopeを利用するのが妥当でしょう。

# app/models/user.rb
class User < ApplicationRecord
  scope :confirmed, -> { where.not(confirmed_at: nil) }
end

ここではconfimedscopeを定義しました。あとはController等から呼び出すときにscoped chainを使って実装すれば良いわけですね。

# 全件取得
@users = User.all

# confirmedなものだけ取得
@users = User.confirmed

一般的にはこれで問題ないかと思うのですが、scoped chainはアプリケーションが複雑になっていくと以下のような問題が発生しがちです。

  • scoped chainが増えてくると付け忘れや使い分けのミスが発生しやすい
    • 今はメールアドレス確認だけだが、アカウントBANフラグ、退会済みフラグ、論理削除などの要件が増えてくるとscoped chainが複雑になる
    • 特に、ある程度サービスが育った後に全体に影響するscopeを追加すると、影響範囲が広く検証が大変になる
  • 「ほとんどいつも使う」scopeの取り扱い問題
    • いつもscopeを書くのは冗長で面倒、かつ付け忘れてしまう事故の温床になる
    • default_scopeを使うという手もあるが、初期化時の副作用やunscopedしたときに想定外のものまでunscopedされてしまったりといった事故につながる可能性があり、やりたくない

では、VIEWを使った実装を見てみます。

まずはconfirmed_usersというconfirmedなユーザーだけを取得するVIEWを作成します。

CREATE VIEW confirmed_users AS
  SELECT * FROM users WHERE confirmed_at IS NOT NULL;

その上で、confirmed_usersを参照するModelを作成します

# app/models/confirmed_user.rb
class ConfirmedUser < ApplicationRecord
end

あとは、データを取り出すときにUserモデルではなくConfirmedUserモデルから取得すればOKです。

# 全件取得
@users = User.all

# confimedなものだけ取得
@confirmed_users = ConfirmedUser.all

このように、scope代わりのfilterとしてVIEWを使うのは、以下のような特徴があります。

  • 利点
    • 複雑化したscoped chain hellから脱却できる
    • 将来的に「有効なユーザー」の定義が変わっても、VIEWのmigrationだけで対応できる(unscopedするコードがあってもVIEWに定義されていないデータはアクセスできないので問題ない)
    • default_scopeが嫌いな人達も😊
  • 欠点
    • 引数付きscopeなど、複雑なアプリケーションロジックが必要なscopeは置き換えが難しい

次行きましょう。

要件2:公開情報用のカラムを追加し、ユーザーごとにどの情報を公開できるか設定できるようにしたい

SNS系のサービスではよくあるやつですね。ここでは、以下の3つのカラムを定義し、それぞれis_public_#{FIELD_NAME}というboolean値を持ち、それがtrueであれば公開OKとします。

  • description: 自己紹介文
  • twitter_id: TwitterのID
  • facebook_id: FacebookのID

追加のmigrationは以下のようになりました。

class AddIsPublic < ActiveRecord::Migration[5.2]
  def change
    # descriptionは既に作成済み
    add_column :users, :is_public_description, :boolean, null: false, default: false

    add_column :users, :twitter_id, :string
    add_column :users, :is_public_twitter_id, :boolean, null: false, default: false
    add_column :users, :facebook_id, :string
    add_column :users, :is_public_facebook_id, :boolean, null: false, default: false

    # scenicによるVIEW更新用の設定(後半で解説します)
    update_view :confirmed_users, version: 2, revert_to_version: 1
  end
end

# 公開用データはテーブル分けた方がいいんじゃないかとか、正規化してもいいんじゃない?とかそういう話もあると思いますが、ここでは一旦置いておきます。

では、この状態でView(こっちはActionView)で表示する際の出し分けをどうするか、Rails Wayに乗るのであれば、以下のようなケースが考えられます。

  • 愚直にViewの中で <%= @user.description if @user.is_public_description %> などと書いていく
    • とても事故りやすいのでオススメしません。とりあえず品質は無視して作り捨てたいプロトタイピングなど以外ではやらない方が良いでしょう。
  • ModelをViewに渡すときにModel本体ではなく、Decorator(またはPresenter)系のGemを利用してラップされたものを渡す
    • DraperActiveDecoratorを使う想定になります。
    • 既にプロジェクト全体でこうしたGemが利用されていれば「Modelを直接Viewに渡さない」というルールが徹底されているので問題ないと思いますが、使われていないプロジェクトで新たに導入したり、Decoratorを使ったことのないメンバーが加入したりするとDecoratorを通し忘れて生のModelを渡してしまうといったミスが発生する危険性があります。
  • そもそもActiveRecordは指定しない限りはSELECT *で全カラムを取り出してしまうので、本来そのリクエストで取得する必要のない情報もDBから取り出してしまうという問題がある
    • #inspect などをすると見えてしまうため、重要な個人情報などはアプリケーションログやエラーメッセージも含めて注意しないと、思わぬところから非公開のつもりだったカラムが見えてしまう、という可能性はあります。

では、VIEWを使って解決してみます。
VIEWでやる場合、今度はconfirmed_users VIEWから公開情報だけを取り出したpublic_users VIEWを作成します。

CREATE VIEW public_users AS
SELECT id,
       email,
       name,
       CASE is_public_description
           WHEN true THEN description
           ELSE NULL
           END AS description,
       CASE is_public_twitter_id
           WHEN true THEN twitter_id
           ELSE NULL
           END AS twitter_id,
       CASE is_public_facebook_id
           WHEN true THEN facebook_id
           ELSE NULL
           END AS facebook_id
FROM confirmed_users;

突然CASE文が出てきましたが、これによって各カラムが「is_public_#{FIELD_NAME}がtrueなら#{FIELD_NAME}の内容を表示、falseならNULLを返す」ようにしています。
こうすることで、DBからデータを取ってきた時点で既にマスクされたデータを作ることができます。

データを取り出すには、先ほどのConfirmedUserと同じようにPublicUser Modelを作成すればOKです。

# app/models/public_user.rb
class PublicUser < ApplicationRecord
end

取り出す際は何も考えず出せば良いですね。

# Controller
@user = PublicUser.find(params[:id])
<dl>
  <dt>自己紹介</dt><dd><%= @user.description %></dd>
  <dt>Twitter ID</dt><dd><%= @user.twitter_id %></dd>
  <dt>Facebook ID</dt><dd><%= @user.facebook_id %></dd>
</dl>

View側は実際には設定されてなければ非表示にしたりなどするかと思いますが、少なくとも表示・非表示のロジックをViewにもControllerにも書かずにマスクできています。

このように、特定用途向けにデータを加工したVIEWを使う方法には、以下の特徴があります。

  • 利点
    • 加工前の生データはRails側のメモリにすら乗らないので、アプリケーションコードのミスで漏れるということが(参照するModelを間違えない限りは)ない。
      • 大規模CMSなどでデータ管理用のRailsアプリとフロント用のRailsアプリが分離している場合などに特にうまく働きます(フロントアプリには表示して良いVIEWしか見せないことができる)。
      • 他社にDBの一部を参照させたい場合にも、VIEWを使って見せてよい部分だけ公開・加工することでデータの公開・閲覧範囲を絞ることができる
    • SQL関数をうまく使うことで、一部のデータ変換や文字列操作をRDBに任せることができる
      • Railsアプリケーション側のメモリが節約できる(RDB側の負荷は上がる可能性がある)
    • 非RailsシステムのDBを参照する場合でも、Rails Wayに則った名前に変換して利用することができる
      • 堅い業務システムなどではD01SHIMEIのようなとても分かりにくい名前がついていることがありますが、ASでリネームしてやることで、Railsの世界ではまともな名前を使うことができる。ただし、同様のことがしたければalias_attributeでもできるので、DB(SQL)レベルで名前を隠蔽したいかどうかはプロジェクトによって検討の余地がある。
  • 欠点
    • 機能ごとにVIEWを作り続けていると、VIEWの数が増え続けてVIEW沼に陥る可能性がある
    • 複雑なSQLは読み書きが難しくなってくるため、プロジェクトチームメンバーのSQL習熟度によってはチーム内の一部のSQLに強いメンバに管理が俗人化していまう(その人がDBA(DataBase Administrator)化する)

さて、最後にかなり強力なもの(だと僕が思っている)を紹介します。

要件3:関連を含めた複雑なテーブル構造の中で、様々なテーブルの項目を横断して検索したい

今度はusersテーブルの外に目を向けます。
実はこのサイトはECサイトで、以下のようなテーブルを持っているとします。

  • users: 会員ユーザー
  • tags: 会員種別タグ(例: WEB会員、店舗会員など)
  • orders: 注文情報(レシート1枚に相当)
  • order_details: 注文明細(レシート内の1行1行に相当)
  • products: 商品情報
  • categories: 商品カテゴリ(例:高額商品、安価商品など)

さらにそれらの中間テーブルを含めて以下のような図になっているとします。

こうしたテーブル構造の中で「ユーザーごと、商品ごとに何をどれだけ買ったのか集計したい」「高額商品を買ってくれているユーザーを一覧したい」「ユーザー・カテゴリごとの商品販売数を一覧したい」などを要望されたとしましょう。

Rails Wayでは複数テーブルをまたいだ条件を記述するのには#joins#mergeを駆使して集計していくことになるのではないかと思います。
乱暴に一旦丸っとメモリに展開して#each#selectで頑張るということはできないわけではありませんが、とても重いですしかなり早い段階で破綻することが多いです。
1-3テーブル程度であれば問題ないのですが、OUTER JOINが入ったり関連の遠いテーブルも持ってきたりしていくと、Arelのコードだと合っているのかどうかが不安になり、#to_sqlして確かめているうちに「生SQL書いた方が早くね?」と思ってQuery Objectを作り始めたり集計用のクラスを作ったりし始めるのは割とあるあるなのではないでしょうか。

参考: Qiita: Rails における内部結合、外部結合まとめ

他にも、検索・ソート・絞り込みではransackなんかが使えるとお手軽なのですが、複雑なJOINやJOIN先のテーブルで絞り込みを利用するにはransackable_scopesなどを使ってゴリゴリカスタマイズしないといけなくなったりします。お手軽ではなくなりとても辛い。

ここでVIEWの出番です。ここのキモはActiveRecordで単純に集計しやすいVIEWを作ることです。
以下のようなVIEWを作ります。

CREATE VIEW order_summaries AS
SELECT users.id                           AS user_id,
       users.name                         AS user_name,
       ARRAY(SELECT t.name
             FROM tags t
                      INNER JOIN tags_users tu ON t.id = tu.tag_id
             WHERE tu.user_id = users.id) AS user_tag_names,
       products.id                        AS product_id,
       products.name                      AS product_name,
       ARRAY(SELECT c.name
             FROM categories c
                      INNER JOIN categories_products cp ON c.id = cp.category_id
                      INNER JOIN products p ON p.id = cp.product_id
             WHERE p.id = products.id
           )                              AS category_names,
       SUM(order_details.total_price)     AS order_total_price,
       SUM(order_details.amount)          AS order_amount
FROM users
         LEFT JOIN orders ON users.id = orders.user_id
         INNER JOIN order_details ON orders.id = order_details.order_id
         INNER JOIN products ON products.id = order_details.product_id
GROUP BY users.id, products.id;

VIEW定義だとわかりにくいので、適当なデータを突っ込んでSELECTしたものが以下です。

# SELECT * FROM order_summaries ORDER BY user_id, product_id;
 user_id |      user_name      |   user_tag_names   | product_id | product_name | category_names | order_total_price | order_amount
---------+---------------------+--------------------+------------+--------------+----------------+-------------------+--------------
       5 | テストユーザーその1 | {WEB会員,無料会員} |          1 | 高いビール   | {高い商品}     |             22500 |           15
       5 | テストユーザーその1 | {WEB会員,無料会員} |          3 | 高い日本酒   | {高い商品}     |             15000 |            3
       6 | テストユーザーその2 | {店舗会員}         |          2 | 安いビール   | {安い商品}     |               400 |            2
       6 | テストユーザーその2 | {店舗会員}         |          4 | 安い日本酒   | {安い商品}     |              4000 |            2
       8 | テストユーザーその4 | {店舗会員}         |          1 | 高いビール   | {高い商品}     |              1500 |            1
       8 | テストユーザーその4 | {店舗会員}         |          2 | 安いビール   | {安い商品}     |               200 |            1
       8 | テストユーザーその4 | {店舗会員}         |          3 | 高い日本酒   | {高い商品}     |              5000 |            1
       8 | テストユーザーその4 | {店舗会員}         |          4 | 安い日本酒   | {安い商品}     |              2000 |            1
(8 rows)

ユーザーごと、商品ごとに販売数・総額を出し、タグやカテゴリはARRAY型の中に入れたデータになっています({}で囲まれた部分)。
ではここでこのorder_summaries VIEWから「高い商品」カテゴリに属する商品の売上をユーザー別に集計して昇順に取り出すには、以下のようにします。
※OrderSummary Modelの作成は省略します

OrderSummary.select('user_id, user_name, SUM(order_total_price) AS total')
    .where("'高い商品' = ANY(category_names)")
    .group(:user_id, :user_name)
    .order('total DESC')

どうでしょうか?集計を行ったりPostgreSQLのARRAY型を使っている関係で多少は煩雑ですが、この処理自体はorder_summaries VIEWにしかアクセスしないので、wheregroupなどがとてもシンプルになっています。
というわけで、多数のテーブルをまとめた検索用VIEWを使うことには以下の特徴があります。

  • 利点
    • VIEWさえできていれば、内部は複雑なクエリでもRailsエンジニアは通常のテーブルと同じように検索・参照できる
      • つぎはぎ開発で複雑怪奇なテーブル構造になってしまったようなケースでは特に有効
      • 1テーブルを前提として作られているGemはそのまま使えるので、Railsのエコシステムを使うことができる
      • 純粋にデータアクセス部分の開発難易度が下がるので、開発経験が浅めのエンジニアでも開発を進められるようになる
    • 取得したオブジェクトはAR::Relationなので、ActiveRecord拡張のGemがそのまま使える
      • AR::Base.executeなどの生SQLから取得したデータと違い、普通のActiveRecordの書き方で利用できる
  • 欠点
    • VIEWを作るためにそれなりにSQLに長けたエンジニアが必要になる
    • VIEWの定義によっては重いクエリになるので、DBサーバーの負荷が想定外に増えることがある

以上、3ケースに分けて代表的と思われるユースケースを紹介してみました。

VIEWを使うときの注意点

実際にVIEWを使った実装を進めていく中で気を付けないといけない点をまとめます。

migration管理にはscenic gemが必要

定義済みのVIEWを参照する分には追加Gemは不要ですが、VIEWの作成や更新をmigration管理したい場合には標準ではできません。
Scenicを使うことでVIEWもmigration管理できるようになるため、実質必須になると思います。
Scenicの扱い自体はそれほど難しくないので、公式READMEを読めばわかると思います。

また、SELECT *で記述されたVIEWはVIEWを作成した瞬間のカラムリストに展開されるので、元テーブルのカラムが変更された場合はVIEWも更新する必要があります

VIEW作成文は生SQLなので、Railsのデータ変換層を通らない

具体的にはVIEW定義内で時刻系ロジックを記述する場合にTimezoneの扱いに注意が必要です。Railsの世界ではTimeオブジェクトを渡すように作る限り問題ありませんが、VIEW定義するときだけはRailsの世界から離れてRDBMSの目線で考える必要があります。

アプリケーションロジックがVIEWとRailsに分散する

これが一番問題になると思います。
Rails Wayならscopeで実装するようなものもVIEW定義で書くことができるため、乱用するとVIEWとアプリケーション側にロジックが分散します
プロジェクトの開発責任者は何をVIEWに出して何をRails側のロジックコードとして書くべきなのかを考えながらバランスを取っていく必要があるでしょう。

参照専用のVIEWであればreadonlyにすべき

ScenicのREADMEにも記載がありますが、以下のようにreadonly?メソッドがtrueを返すように定義してやると、誤った#saveを防止できます。

class OrderSummary < ApplicationRecord
  def readonly?
    true
  end
end

発表スライド

まとめ

ここに書ききれなかったユースケースもたくさんありますが、VIEWを知らない人に知ってもらうにはこれくらいのボリュームが良いかなと思うので、ここまでにしたいと思います。
本記事の反響があれば続きを書こうと思いますので、Twitterやはてブ等でコメントいただければ幸いです。
※Twitterは「TechRacho」という文字列を含めてもらえれば追いかけます

それでは皆様、良いWeb開発ライフをお送りください :)


CONTACT

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