ryota21silvaの技術ブログ

Funna(ふんな)の技術ブログ

これまで学んだ技術の備忘録。未来の自分が救われることを信じて

クラスの継承(単一テーブル継承)

クラスの継承

あるクラスを継承して、新たにクラスを作成すること。
よくある例だと、Animalクラスとそれを継承するDogクラス、Catクラスみたいな。

クラスの継承を行うことで、継承元の親クラス(スーパークラス)に定義された機能を、子クラス(サブクラス)でも利用できる。
つまり、あるメソッドを複数のクラスで使用したい、同じカラムを複数のクラスで使用したいという時に継承を使うと便利です。

単一テーブル継承(STI)とは

上記で説明したようなクラスの継承関係を、DB上のたった1つのテーブルで実現したもの。
つまり、各モデルが持っている情報は同じだから、それらを1つのテーブルでまとめて扱おうってのがSTI

(単一テーブル継承とは、共通の処理が纏められたスーパークラスと、そのスーパークラスを継承しつつ、各々特有の処理が記述されたサブクラスというクラス郡の親子関係を、1つのテーブルだけで実現したもの。 簡単に言うと、DBにある1つのテーブルを、複数のモデルで共有利用すること。)


テーブル設計の要件
  • 記事テーブルにタグ、カテゴリー、著者といったテーブルを関連付けしたい。
  • タグ、カテゴリー、著者は同じようなメソッドを使う、同じカラムを使用する。

最終的に以下のようなテーブル設計になる。

f:id:ryota21silva:20200609195822p:plain
STI


実装

まずは継承元となるTaxonomyモデルを作成し、かつ、DB上にTaxonomiesテーブルも反映させる。(ここではモデルの作成、マイグレーションファイルの反映などの説明は省略する)

上記の結果、スキーマファイルにはTaxonomiesテーブルが反映されている。
STIの説明で言及したたった1つのテーブルというのがTaxnomiesテーブルのことで、このテーブルを使ってSTIという継承関係を実現する。

スキーマにTaxonomiesテーブルが反映されている。
(db/schema.rb)
create_table "taxonomies", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC", force: :cascade do |t|
    t.string "type"
    t.string "name"
    t.string "slug"
    t.text "description"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["slug"], name: "index_taxonomies_on_slug"
    t.index ["type"], name: "index_taxonomies_on_type"
  end
Taxonomyモデル(Taxonomyクラス)
(app/models/taxonomy.rb)
class Taxonomy < ApplicationRecord
  validates :name, presence: true, uniqueness: { scope: :type }, length: { maximum: 16 }
  validates :slug, presence: true, uniqueness: { scope: :type }, length: { maximum: 64 }, slug_format: true
end
Tagモデルなどサブクラス達を実装する。

Tag < TaxonomyみたいにTaxonomyクラスを継承している。

(app/models/tag.rb)
class Tag < Taxonomy
  has_many :article_tags
  has_many :articles, through: :article_tags
end
(app/models/category.rb)
class Category < Taxonomy
  has_many :articles
end
(app/models/author.rb)
class Author < Taxonomy
  has_many :articles
end

上記の実装をまとめると

  • Taxonomyクラスは共通の処理を纏めたスーパークラスで、TaxonomiesテーブルはDB上に実在するテーブルである。
  • Category、Tag、AuthorクラスはTaxonomyクラスを継承したサブクラスだが、categories、tags、authorsテーブルというものはDB上に存在しない
    しかし、STIを使用することで、開発者はCategories、Tags、Authorsテーブルというテーブルが存在するかのようにモデルとDBを扱えるようになるのです。

注意点

継承元のテーブルにはtypeカラムを実装しておく必要がある。
どういうことかと言うと、DB上にはTaxonomyテーブルしか存在しないので、category、tag、authorのデータはtaxonomiesテーブルの中に全部ごっちゃで入っていて、どのクラスのデータかを識別するためにtypeカラムを使うって感じ。
そこで、typeカラムというRailsが標準で用意しているカラムを使用すれば、ActiveRecordがいい感じにtypeにサブクラス名を入れてくれるのです。

上記の点から、普通のカラムとしてtypeカラムを使用するのは良くない。なぜならSTIの機能と判断してしまうから。タイプを表すカラムを使いたいならuser_typeみたいにする必要がある。

コンソールで遊んでみる

Taxonomyテーブルのレコードを全件取得すると、 Category、Tag、Authorクラスのデータが全て入っていて、typeカラムにクラス名が入っってることが分かる。

[4] pry(main)> Taxonomy.all
  Taxonomy Load (0.6ms)  SELECT `taxonomies`.* FROM `taxonomies`
=> [#<Author:0x00007f8 id: 1, type: "Author", name: "山田 太郎", slug: "tarou_yamada", description: "", created_at: Mon, 18 May 2020 12:29:46 JST +09:00, updated_at: Mon, 18 May 2020 12:29:46 JST +09:00>,
 #<Tag:0x00007f8 id: 2, type: "Tag", name: "日記", slug: "diary", description: nil, created_at: Mon, 18 May 2020 12:29:55 JST +09:00, updated_at: Mon, 18 May 2020 12:29:55 JST +09:00>,
 #<Category:0x00007f8 id: 4, type: "Category", name: "朝活", slug: "moring", description: nil, created_at: Mon, 18 May 2020 12:30:34 JST +09:00, updated_at: Mon, 18 May 2020 12:30:34 JST +09:00>,
 #<Author:0x00007f8 id: 5, type: "Author", name: "花子", slug: "hanako", description: "", created_at: Mon, 18 May 2020 13:57:41 JST +09:00, updated_at: Mon, 25 May 2020 17:03:22 JST +09:00>,

typeカラムがTagのレコードを全件取得。

[5] pry(main)> Taxonomy.where(type: "Tag")
  Taxonomy Load (0.5ms)  SELECT `taxonomies`.* FROM `taxonomies` WHERE `taxonomies`.`type` = 'Tag'
=> [#<Tag:0x00007f8fe20428a0 id: 2, type: "Tag", name: "日記", slug: "diary", description: nil, created_at: Mon, 18 May 2020 12:29:55 JST +09:00, updated_at: Mon, 18 May 2020 12:29:55 JST +09:00>,
 #<Tag:0x00007f8fe2042670 id: 3, type: "Tag", name: "エンジニア", slug: "engineer", description: nil, created_at: Mon, 18 May 2020 12:29:59 JST +09:00, updated_at: Mon, 18 May 2020 12:29:59 JST +09:00>,
 #<Tag:0x00007f8fe2042468 id: 6, type: "Tag", name: "毎日", slug: "every_day", description: nil, created_at: Mon, 18 May 2020 13:57:56 JST +09:00, updated_at: Mon, 18 May 2020 13:57:56 JST +09:00>]

Tagのデータを全件取得する。
ここで走っているSQL文はTaxonomyテーブルを対象にしていることが分かる。Tagsテーブルなんて存在しないからね。
でもSTIを使えば、Tagsテーブルが存在するかのように、ActiveRecordの検索メソッドを使えるのです。

[6] pry(main)> Tag.all
  Tag Load (0.5ms)  SELECT `taxonomies`.* FROM `taxonomies` WHERE `taxonomies`.`type` IN ('Tag')
=> [#<Tag:0x00007f8 id: 2, type: "Tag", name: "日記", slug: "diary", description: nil, created_at: Mon, 18 May 2020 12:29:55 JST +09:00, updated_at: Mon, 18 May 2020 12:29:55 JST +09:00>,
 #<Tag:0x00007f8 id: 3, type: "Tag", name: "エンジニア", slug: "engineer", description: nil, created_at: Mon, 18 May 2020 12:29:59 JST +09:00, updated_at: Mon, 18 May 2020 12:29:59 JST +09:00>,
 #<Tag:0x00007f8 id: 6, type: "Tag", name: "毎日", slug: "every_day", description: nil, created_at: Mon, 18 May 2020 13:57:56 JST +09:00, updated_at: Mon, 18 May 2020 13:57:56 JST +09:00>]

関連テーブルからタグの情報を簡単に取ってこれる。
find_by(type: "Tag")みたいにゴチャゴチャ書く必要がなく、特に意識せずサブクラスのデータを扱える。
SQLではWHERE 'taxonomies'.'type' IN ('Tag')みたいにTaxonomiesテーブルを、where句の中でtypeカラムを条件に絞り込む感じ)

[6] pry(main)> Article.first.tags
  Article Load (0.5ms)  SELECT  `articles`.* FROM `articles` ORDER BY `articles`.`id` ASC LIMIT 1
  Tag Load (0.7ms)  SELECT `taxonomies`.* FROM `taxonomies` INNER JOIN `article_tags` ON `taxonomies`.`id` = `article_tags`.`tag_id` WHERE `taxonomies`.`type` IN ('Tag') AND `article_tags`.`article_id` = 1
=> [#<Tag:0x00007f8fe0de99b0 id: 3, type: "Tag", name: "エンジニア", slug: "engineer", description: nil, created_at: Mon, 18 May 2020 12:29:59 JST +09:00, updated_at: Mon, 18 May 2020 12:29:59 JST +09:00>]

STIを実装したER図

最後に説明を図にまとめておく!

f:id:ryota21silva:20200609193101p:plain
STIの図

参照先