RailsのSTIやenumで想定外の値が入らないようにする方法

## まとめ
STIやenumに入りうる値をテーブルで持ち、外部キー制約を追加しましょう。

https://en.wikipedia.org/wiki/Reference_table

## サンプルリポジトリ

https://github.com/hanachin/iikanji_enum

## やり方

idの型とSTIのカラムやenumのカラムの型を一致させる。
外部キー制約をはる。

```db/migrate/20200627151958_create_posts.rb
class CreatePosts < ActiveRecord::Migration[6.0]
  def change
    create_table :posts do |t|
      t.string :type
      t.integer :state
      t.string :title
      t.text :body

      t.timestamps
    end

    create_table :post_states do |t|
      t.string :name

      t.timestamps
    end
    add_foreign_key :posts, :post_states, column: :state

    create_table :post_types, id: :string do |t|
      t.timestamps
    end
    add_foreign_key :posts, :post_types, column: :type
  end
end
```

こうなる

```db/schema.rb
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `rails
# db:schema:load`. When creating a new database, `rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema.define(version: 2020_06_27_160353) do

  # These are extensions that must be enabled in order to support this database
  enable_extension "plpgsql"

  create_table "post_states", force: :cascade do |t|
    t.string "name"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  create_table "post_types", id: :string, force: :cascade do |t|
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  create_table "posts", force: :cascade do |t|
    t.string "type"
    t.integer "state"
    t.string "title"
    t.text "body"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
  end

  add_foreign_key "posts", "post_states", column: "state"
  add_foreign_key "posts", "post_types", column: "type"
end
```

以下のような感じのSTI/enumを使ったクラス定義があるとき

```app/models/post.rb
class Post < ApplicationRecord
  enum state: { draft: 0, published: 1 }
end
```

```app/models/draft_post.rb
class DraftPost < Post
end
```

```app/models/published_post.rb
class PublishedPost < Post
end
```

STI/enumのカラムがとりうる値のレコードを作成しておく

```app/models/post/state.rb
class Post < ApplicationRecord
  class State < ApplicationRecord
    class << self
      def seed
        Post.states.each do |state, id|
          find_or_create_by!(id: id, name: state)
        end
      end
    end
  end
end
```


```app/models/post/type.rb
class Post < ApplicationRecord
  class Type < ApplicationRecord
    class << self
      def seed
        [PublishedPost, DraftPost].each do |klass|
          find_or_create_by!(id: klass.name)
        end
      end
    end
  end
end
```

例えばtypeカラムに存在しないクラスの名前を入れたときちゃんとエラーになって保存できない

```irb
Loading development environment (Rails 6.0.3.2)
irb(main):001:0> post = Post.first
  Post Load (0.2ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1  [["LIMIT", 1]]
irb(main):002:0> post
=> #<DraftPost id: 3, type: "DraftPost", state: "draft", title: "test", body: "test", created_at: "2020-06-27 16:14:49", updated_at: "2020-06-27 16:14:49">
irb(main):003:0> post.type = "YavayPost"
irb(main):004:0> post.save!
   (0.3ms)  BEGIN
  DraftPost Update (1.2ms)  UPDATE "posts" SET "type" = $1, "updated_at" = $2 WHERE "posts"."id" = $3  [["type", "YavayPost"], ["updated_at", "2020-06-27 16:40:40.492136"], ["id", 3]]
   (0.2ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):4
ActiveRecord::InvalidForeignKey (PG::ForeignKeyViolation: ERROR:  insert or update on table "posts" violates foreign key constraint "fk_rails_43c128f7b9")
DETAIL:  Key (type)=(YavayPost) is not present in table "post_types".
irb(main):005:0>
```

またenumカラムも同様に存在しないstateを入れたときちゃんとエラーになって保存できない

```irb
irb(main):001:0> class Post; enum state: { amasawa: 4423 }; end
=> {:state=>{:amasawa=>4423}}
irb(main):002:0> post = Post.first
  Post Load (0.2ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1  [["LIMIT", 1]]
irb(main):003:0> post
=> #<DraftPost id: 3, type: "DraftPost", state: nil, title: "test", body: "test", created_at: "2020-06-27 16:14:49", updated_at: "2020-06-27 16:14:49">
irb(main):004:0> post.state = :amasawa
irb(main):005:0> post.save!
   (0.3ms)  BEGIN
  DraftPost Update (1.3ms)  UPDATE "posts" SET "state" = $1, "updated_at" = $2 WHERE "posts"."id" = $3  [["state", 4423], ["updated_at", "2020-06-27 16:43:03.201733"], ["id", 3]]
   (0.2ms)  ROLLBACK
Traceback (most recent call last):
        1: from (irb):5
ActiveRecord::InvalidForeignKey (PG::ForeignKeyViolation: ERROR:  insert or update on table "posts" violates foreign key constraint "fk_rails_93ccb3c476")
DETAIL:  Key (state)=(4423) is not present in table "post_states".
irb(main):006:0>
```

## まとめ

データベースはべんり。

一部のinclusionも同じ手法で実装できるのでぜひやってみてください。