STI (シングルテーブル継承) を試す

先日、読んだ本で共通な振る舞いは継承により獲得させることで、同じ記述を繰り返すようなメンテナンス性の悪さを回避することを学びました。
(同じ書籍で、モジュールでの振る舞いの獲得に関する記述もありますが、そちらは 1 回おいておきます。)

Rails で適用できるところはと考えたのですが、モデルでやるのがよさそうだと感じました。
調べると、STI(シングルテーブル継承)という名前で解説があったので試してみます。

参考

STI って結局どうなのか

STI について調べると、かなり意見が割れているようにみえます。

例えばこちらです。

4 年前の投稿ではあるものの、コメント欄の開始部分は意見真っ 2 つにみえます。

先に感想を述べると、やってみた限りのとしては便利だと感じます。
以前、別のテーブルの検索結果を一括でまとめることも試していますが、テーブルの内容と振る舞い次第では、実装が共通化できるのは利点だと感じます。
共通化したうえで、共通化前のモデルでアクセスもできますしね。

STI を試す

今回は、STI の有効なケースとして、商品の一覧を管理する Item モデルと Item モデルへの 2 種類のタグのモデルが関連づいているものとします。
2 種類のタグのうち一般のユーザーの付与できる UserTag と、管理者が付与しコメントも管理する AdminTag を考えます。

矢印の書き方間違っている可能性あります。
(PlantUML で書きましたが、なんかダサい。)

マイグレーション

以下の 2 つのファイルを作成し、マイグレーションする。

db/migrate/[数字列]_create_table_items.rb
1
2
3
4
5
6
7
8
9
class CreateTableItems < ActiveRecord::Migration[6.1]
def change
create_table :items do |t|
t.string :name
t.timestamps
end
end
end

db/migrate/[数字列]_create_table_tags.rb
1
2
3
4
5
6
7
8
9
10
11
12
class CreateTableTags < ActiveRecord::Migration[6.1]
def change
create_table :tags do |t|
t.string :item_id
t.string :type
t.string :name
t.string :comment

t.timestamps
end
end
end

クラス実装

用意するクラスは、以下の 4 ファイルです。

app/models/item.rb
1
2
3
class Item < ApplicationRecord
has_many :tags
end
app/models/tag.rb
1
2
3
class Tag < ApplicationRecord
belongs_to :item
end
app/models/user_tag.rb
1
2
class UserTag < Tag
end
app/models/admin_tag.rb
1
2
class AdminTag < Tag
end

動作確認

モデルの動作だけ見たいので、Rails コンソールで十分です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# 初めに2つのItemを作成
> Item.create(name:"A")
TRANSACTION (1.0ms) BEGIN
Item Create (1.0ms) INSERT INTO `items` (`name`, `created_at`, `updated_at`) VALUES ('A', '2021-01-16 16:28:31.574474', '2021-01-16 16:28:31.574474')
TRANSACTION (1.8ms) COMMIT
=> #<Item id: 1, name: "A", created_at: "2021-01-16 16:28:31.574474000 +0000", updated_at: "2021-01-16 16:28:31.574474000 +0000">

> Item.create(name:"B")
TRANSACTION (1.6ms) BEGIN
Item Create (1.2ms) INSERT INTO `items` (`name`, `created_at`, `updated_at`) VALUES ('B', '2021-01-16 16:28:35.344884', '2021-01-16 16:28:35.344884')
TRANSACTION (10.3ms) COMMIT
=> #<Item id: 2, name: "B", created_at: "2021-01-16 16:28:35.344884000 +0000", updated_at: "2021-01-16 16:28:35.344884000 +0000">

# UserTagを作成
> UserTag.create(item:Item.find(1), name: "nice item")
Item Load (1.3ms) SELECT `items`.* FROM `items` WHERE `items`.`id` = 1 LIMIT 1
TRANSACTION (1.4ms) BEGIN
UserTag Create (1.2ms) INSERT INTO `tags` (`item_id`, `type`, `name`, `created_at`, `updated_at`) VALUES ('1', 'UserTag', 'nice item', '2021-01-16 16:31:08.161727', '2021-01-16 16:31:08.161727')
TRANSACTION (2.3ms) COMMIT
=> #<UserTag id: 1, item_id: "1", type: "UserTag", name: "nice item", comment: nil, created_at: "2021-01-16 16:31:08.161727000 +0000", updated_at: "2021-01-16 16:31:08.161727000 +0000">

# => Typeに特段記述していないUserTagを埋めてくれている。

# 続けてAdminTagを作成
> AdminTag.create(item:Item.find(2), name: "best proceeds",comment: "Good prospect" )
Item Load (1.6ms) SELECT `items`.* FROM `items` WHERE `items`.`id` = 2 LIMIT 1
TRANSACTION (1.4ms) BEGIN
AdminTag Create (1.1ms) INSERT INTO `tags` (`item_id`, `type`, `name`, `comment`, `created_at`, `updated_at`) VALUES ('2', 'AdminTag', 'best proceeds', 'Good prospect', '2021-01-16 16:35:18.154575', '2021-01-16 16:35:18.154575')
TRANSACTION (2.1ms) COMMIT
=> #<AdminTag id: 2, item_id: "2", type: "AdminTag", name: "best proceeds", comment: "Good prospect", created_at: "2021-01-16 16:35:18.154575000 +0000", updated_at: "2021-01-16 16:35:18.154575000 +0000">

# => こちらもTypeに特段記述していないAdminTagを埋めてくれる。

# UserTagをselectしてみる
> UserTag.all.to_sql
=> "SELECT `tags`.* FROM `tags` WHERE `tags`.`type` = 'UserTag'"

# .allで全件取得をしただけだが、WHERE `tags`.`type` = 'UserTag'"を設定して実行してくれる。賢い。

# Tagで全件取得
irb(main):015:0> Tag.all.to_sql
=> "SELECT `tags`.* FROM `tags`"

# => UserTagもAdminTagも両方まとめて取得できる

# オブジェクトのクラスを確認
Tag.find(1).class
Tag Load (1.5ms) SELECT `tags`.* FROM `tags` WHERE `tags`.`id` = 1 LIMIT 1
=> UserTag(id: integer, item_id: string, type: string, name: string, comment: string, created_at: datetime, updated_at: datetime)
Tag.find(2).class
Tag Load (1.5ms) SELECT `tags`.* FROM `tags` WHERE `tags`.`id` = 2 LIMIT 1
=> AdminTag(id: integer, item_id: string, type: string, name: string, comment: string, created_at: datetime, updated_at: datetime)

#=>それぞれUserTagとAdminTagであることを判定できている。賢い。

# commentカラムは、AdminTag専用であることを意図しているが、UserTagで設定してみる
> UserTag.create(item:Item.find(2), name: "bat item",comment: "fragile" )
Item Load (1.3ms) SELECT `items`.* FROM `items` WHERE `items`.`id` = 2 LIMIT 1
TRANSACTION (1.3ms) BEGIN
UserTag Create (1.4ms) INSERT INTO `tags` (`item_id`, `type`, `name`, `comment`, `created_at`, `updated_at`) VALUES ('2', 'UserTag', 'bat item', 'fragile', '2021-01-16 16:48:52.369284', '2021-01-16 16:48:52.369284')
TRANSACTION (2.2ms) COMMIT
=> #<UserTag id: 3, item_id: "2", type: "UserTag", name: "bat item", comment: "fragile", created_at: "2021-01-16 16:48:52.369284000 +0000", updated_at: "2021-01-16 16:48:52.369284000 +0000">

# => 設定できてしまう。

STI とても賢いですね。
ただし、Tags テーブルに持っている comment カラムが AdminTag 専用であることは伝わっていないので、UserTag でも設定できます。

以下の対策ができたので、記載します。

対策 1.before_save でチェックするパターン

app/models/user_tag.rb
1
2
3
4
5
6
7
class UserTag < Tag
before_save :test

def test
throw(:abort) unless self.comment.nil?
end
end

対策 2.validates でチェックするパターン

app/models/user_tag.rb
1
2
3
class UserTag < Tag
validates :comment, absence: true
end

対策 3.スーパークラス側でチェックするパターン

app/models/tag.rb
1
2
3
4
5
6
7
8
9
class Tag < ApplicationRecord
belongs_to :item

validates :comment, absence: true, if: :discommentable?

def discommentable?
self.class.to_s != "AdminTag"
end
end

(kind_of?など試したがうまくいかず、一度クラス名を文字列に変換)

対策 4.チェックはスーパークラスで行うが、Comment を使用することを特化クラス側で宣言させるパターン

app/models/tag.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
class Tag < ApplicationRecord
belongs_to :item

validates :comment, absence: true, if: :discommentable?

def discommentable?
# commentable?メソッドが無い
# commentable?の結果がfalse
# だとcommentカラムがnilであることをチェックする
return true unless self.respond_to?(:commentable?)
!self.commentable?
end
end
app/models/admin_tag.rb
1
2
3
4
5
6
class AdminTag < Tag
def commentable?
true
end
# def commentable? = true のように1行メソッドでも書ける
end

これらの方法だと、comment カラムの使用を AdminTag クラスだけに限定できます。
4 っつ目の方法が、依存関係が少なくて良さそうに感じます。


ではでは。