LoginSignup
2
0

More than 3 years have passed since last update.

テーブルの継承をしていてもFactoryBotを使いたい

Last updated at Posted at 2019-12-14

仮定と問題

今からおよそ1万年前、紀元前8,000年といえば世の中は新石器時代、文明の発祥は紀元前4,000年から3,000年ころらしいので、それよりもずっと前の頃です。

そのころから毎日、その日の天気を記録し、データベースのとあるテーブルにレコードを登録し続けてきた団体があるとします。1年を365日であるとすると、365×10,000 = 三百六十五万件のレコードが登録されているでしょう。

そしてこの団体が世界の10ヶ所で同様に天気を記録していたとすると、そのテーブルには合計で三千六百五十万件のレコードが登録されていることになります。このすばらしい歴史的な資料も、件数が多いと扱いづらくて困ります。

Railsで開発する

この謎の団体は年間の農作物の収穫高も毎年記録していたとして、以下のようなテーブル構成であるとします。

テーブル.png

これらのテーブルのレコードを参照したり登録したりするWebアプリケーションをRailsで開発することにします。

さて、前述のとおり、日次天気記録テーブルはレコードの件数が多くなります。ついでに年間収穫量テーブルも、作物の種類の数によってはレコード数がずいぶん多くなりそうです。

せめて記録した場所ごとにテーブルが分かれていればましというものでしょう。世界のあちこちの天気の記録をまぜこぜにして分析する、という用途もあるでしょうが、場所ごとで分析する用途の方が多い気もします。

そこで登場するのはPostgreSQLの、テーブルの継承、もしくは分割です。

日次天気記録テーブルと、年間収穫量テーブルは、テーブルの継承を行い、場所ごとにテーブルを作成するものとします。

場所は場所テーブルで管理しており、場所が増えると日次天気記録テーブルの数も増えます。はて、テーブルが動的に増える、なんて状況を、Railsのモデルで表現できるのでしょうか?

世の中ではテーブルを作成するごとにActiveRecord::Baseクラスを継承してモデルクラスを生成したりするようですが、名前が固定されていないモデルというのは、個人的には扱いづらそうに思います。どうしたものか。

日次天気記録テーブルの定義と、継承の定義が以下のようであったとします。tokyoという場所用のテーブルであるとします。

CREATE TABLE daily_weather_reports (
  id BIGSERIAL NOT NULL PRIMARY KEY
  ,location_id BIGINT NOT NULL DEFAULT 0
  ,report_at TIMESTAMPTZ NOT NULL
  ,weather_id BIGINT NOT NULL DEFAULT 0
  ,highest_temperature NUMERIC NOT NULL DEFAULT 0.0
  ,lowest_temperature NUMERIC NOT NULL DEFAULT 0.0
  ,created_at TIMESTAMPTZ NOT NULL
  ,updated_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS tokyo_daily_weather_reports (
  LIKE daily_weather_reports INCLUDING ALL
) INHERITS (daily_weather_reports);

継承後のテーブルは場所によってテーブル名が変わりますが、継承元のテーブルは名前が変わりません。単純には、継承元のテーブルdaily_weather_reportsに対してモデルクラスを定義することはできそうです。

app/models/daily_weather_report.rb
class DailyWeatherReport < ApplicationRecord
  belongs_to :location
end
$ bundle exec rails console -e test
Loading test environment (Rails 6.0.1)

irb(main):001:0> dwr = DailyWeatherReport.new({ location_id: 1, report_at: Time.now, weather_id: 1 })
=> #<DailyWeatherReport id: nil, report_at: "2019-12-14 09:46:34", location_id: 1, weather_id: 1, highest_temperature: 0.0, lowest_temperature: 0.0, created_at: nil, updated_at: nil>

irb(main):002:0> dwr.save
   (0.6ms)  BEGIN
  Location Load (0.9ms)  SELECT "locations".* FROM "locations" WHERE "locations"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]
  DailyWeatherReport Create (5.6ms)  INSERT INTO "daily_weather_reports" ("report_at", "location_id", "weather_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["report_at", "2019-12-14 18:46:34.143271"], ["location_id", 1], ["weather_id", 1], ["created_at", "2019-12-14 18:46:40.080393"], ["updated_at", "2019-12-14 18:46:40.080393"]]
   (6.0ms)  COMMIT
=> true

しかしこのままでは、当然ながらレコードは継承元のテーブルdaily_weather_reportsに保存されてしまいます。レコードを保存したい先は継承後のテーブルtokyo_daily_weather_reportsです。うーむ。

継承後の子テーブルにレコードを保存する

モデルクラスに、他のモデルのいずれかの値でテーブルが分割されることを指定しておくと、自動的に子テーブルにレコードを登録する仕組みが提供されるようになる仕組みを考えてみます。

テーブルの分割、というと、PostgreSQLの分割の機能と混同してしまうので、名前を変えて、ここではテーブルの「間仕切り」と呼ぶことにしましょう!...いろいろセンスなくてすみません。

Majikiriモジュールを書いてみました。

majikiri.rb: テーブルの継承をしていてもFactoryBotを使いたい

モジュールの中身は気にしないことにして、以下のように使います。まず、分割される側から。

app/models/daily_weather_report.rb
class DailyWeatherReport < ApplicationRecord
  include Majikiri
  belongs_to :location
  majikiri_divided_by :location, attr_name: :location_cd
end
app/models/annual_crop.rb
class AnnualCrop < ApplicationRecord
  include Majikiri
  belongs_to :location
  majikiri_divided_by :location, attr_name: :location_cd
end

分割する側は以下のようにします。

app/models/location.rb
class Location < ApplicationRecord
  include Majikiri
  majikiri_divide :daily_weather_report, attr_name: :location_cd
  majikiri_divide :annual_crop, attr_name: :location_cd
end

場所テーブルにレコードを登録してみます。

$ bundle exec rails console -e test
Loading test environment (Rails 6.0.1)

irb(main):001:0> location = Location.new({ location_cd: 'tokyo', location_nm: 'Tokyo' })
=> #<Location id: nil, location_cd: "tokyo", location_nm: "Tokyo", created_at: nil, updated_at: nil>

irb(main):002:0> location.save
   (0.8ms)  BEGIN
  Location Create (1.4ms)  INSERT INTO "locations" ("location_cd", "location_nm", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id"  [["location_cd", "tokyo"], ["location_nm", "Tokyo"], ["created_at", "2019-12-14 17:44:23.759893"], ["updated_at", "2019-12-14 17:44:23.759893"]]
  Location Load (132.6ms)            CREATE TABLE IF NOT EXISTS tokyo_daily_weather_reports (
            LIKE daily_weather_reports INCLUDING ALL
          ) INHERITS (daily_weather_reports);

  Location Load (55.4ms)            CREATE TABLE IF NOT EXISTS tokyo_annual_crops (
            LIKE annual_crops INCLUDING ALL
          ) INHERITS (annual_crops);

   (22.8ms)  COMMIT
=> true

日次天気記録テーブルと、年間収穫量テーブルに、自動的に子テーブルtokyo_daily_weather_reportstokyo_annual_cropsが作成されました。

Tokyoという場所用の日次天気記録テーブルにレコードを登録してみます。

irb(main):003:0> dwr = DailyWeatherReport.new({ location_id: location.id, report_at: Time.now })
=> #<DailyWeatherReport id: nil, report_at: "2019-12-14 08:46:38", location_id: 50, weather_id: 0, highest_temperature: 0.0, lowest_temperature: 0.0, created_at: nil, updated_at: nil>

irb(main):004:0> dwr.majikiri_save
  Location Load (1.0ms)  SELECT "locations".* FROM "locations" WHERE "locations"."id" = $1 LIMIT $2  [["id", 50], ["LIMIT", 1]]
  DailyWeatherReport Load (47.6ms)        INSERT INTO tokyo_daily_weather_reports (
        report_at,location_id,weather_id,highest_temperature,lowest_temperature,created_at,updated_at
      ) VALUES (
        '2019-12-14 17:46:38 +0900',50,0,0.0,0.0,'now()','now()'
      ) RETURNING
        id,report_at,location_id,weather_id,highest_temperature,lowest_temperature,created_at,updated_at

=> true

tokyo_daily_weather_reportsテーブルにレコードが登録されました。

RSpecで使ってみる

何がしたかったかといえば、RSpecでテストコードを書く際に、継承を利用しているテーブルでも、そうでないテーブルと似た書き方で事前条件となるデータの登録ができるようにしたかったのです。

テストに使うものなのに、モデルに直接手を入れるのはどうなのかとは思いますが、多めに見ていただければ幸いです。

RSpecにMakijiriモジュールのメソッドを呼び出す機能を追加します。

spec/support/majikiri_util.rb
module MajikiriUtil
  def majikiri_create(model_name, **attrs)
    item = build(model_name, attrs)
    item.majikiri_create
  end

  def majikiri_create_list(model_name, amount, **attrs)
    amount.times.map do |idx|
      majikiri_create(model_name, attrs)
    end
  end
end

RSpec.configure do |config|
  config.include MajikiriUtil
end

Requestスペックから使ってみます。一覧表示のアクションのテストで、Tokyo用の日次天気記録テーブルに3件のレコードを登録してみます。FactoryBotではcreate_listメソッドで作成しますが、Majikiriモジュールの機能を呼び出す、majikiri_create_listメソッドを使います。

spec/requests/daily_weather_reports_spec.rb
require 'rails_helper'

RSpec.describe "DailyWeatherReports", type: :request do
  let (:weather) { create(:weather) }
  let (:location) { create(:location, { location_cd: 'tokyo' }) }

  describe "GET /daily_weather_reports" do
    let! (:dwr) { majikiri_create_list(:daily_weather_report, 3, {
      weather_id: weather.id,
      location_id: location.id
    }) }

    it "works!" do
      get daily_weather_reports_path
      expect(response).to have_http_status(200)
    end
  end

  describe "POST /daily_weather_reports" do
    let (:dwr) { build(:daily_weather_report, {
      weather_id: weather.id,
      location_id: location.id
    }) }

    it "works!" do
      post daily_weather_reports_path, params: { daily_weather_report: dwr.attributes }
      actual = DailyWeatherReport.order(:id).last
      expect(response).to redirect_to(daily_weather_report_path(actual.id))
    end
  end
end

一方、新規登録のアクションのテストでは、登録するレコードの内容を、buildメソッドで生成します。こちらは通常のFactoryBotのメソッドです。モデルを継承元のテーブルに対して定義しているので、インスタンスを生成するだけなら通常のモデルと同様にできます。

$ bundle exec rspec spec/requests/daily_weather_reports_spec.rb 
..

Finished in 0.90132 seconds (files took 3.53 seconds to load)
2 examples, 0 failures

どちらのテストもパスしました。

使うのはテストの時だけにしたいかも

Majikiriモジュールによって何やら怪しげなメソッドをモデルに追加したのですが、怪しいので有効になるのはテストの時だけにしたい、と思うかもしれません。

Railsに、Majikiriモジュールを有効にするかどうかの設定を行います。test環境の設定に以下の行を追加します。

config/environments/test.rb
  config.x.majikiri.auto_divide = true

これで、モデルにmajikiri~と書いてあっても、test環境以外では無効になります。

おわりに

レコードを登録した時にcreateコールバックが動かないんだが、とか、新規登録以外の機能がないんだが、とか、2段階の継承ができないんだが、とか、いろいろ実用的ではないMajikiriモジュールですが、Railsにテーブルの継承を組み込めるようになるといいと思います。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0