仮定と問題
今からおよそ1万年前、紀元前8,000年といえば世の中は新石器時代、文明の発祥は紀元前4,000年から3,000年ころらしいので、それよりもずっと前の頃です。
そのころから毎日、その日の天気を記録し、データベースのとあるテーブルにレコードを登録し続けてきた団体があるとします。1年を365日であるとすると、365×10,000 = 三百六十五万件のレコードが登録されているでしょう。
そしてこの団体が世界の10ヶ所で同様に天気を記録していたとすると、そのテーブルには合計で三千六百五十万件のレコードが登録されていることになります。このすばらしい歴史的な資料も、件数が多いと扱いづらくて困ります。
Railsで開発する
この謎の団体は年間の農作物の収穫高も毎年記録していたとして、以下のようなテーブル構成であるとします。
これらのテーブルのレコードを参照したり登録したりする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
に対してモデルクラスを定義することはできそうです。
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を使いたい
モジュールの中身は気にしないことにして、以下のように使います。まず、分割される側から。
class DailyWeatherReport < ApplicationRecord
include Majikiri
belongs_to :location
majikiri_divided_by :location, attr_name: :location_cd
end
class AnnualCrop < ApplicationRecord
include Majikiri
belongs_to :location
majikiri_divided_by :location, attr_name: :location_cd
end
分割する側は以下のようにします。
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_reports
、tokyo_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モジュールのメソッドを呼び出す機能を追加します。
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
メソッドを使います。
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.x.majikiri.auto_divide = true
これで、モデルにmajikiri~と書いてあっても、test環境以外では無効になります。
おわりに
レコードを登録した時にcreateコールバックが動かないんだが、とか、新規登録以外の機能がないんだが、とか、2段階の継承ができないんだが、とか、いろいろ実用的ではないMajikiriモジュールですが、Railsにテーブルの継承を組み込めるようになるといいと思います。