【提案】tsvファイルのバリデーション やり方

## やったこと
json-schemaにバリデーションを任せることにしました。
https://github.com/ruby-json-schema/json-schema

（今回tsvファイルですが、csvファイルでも考え方は同じでできるはずです。）

## 前提（例）
### 要件
スプレッドシートで管理しているタスクをtsvで流し込みたい。
日本語テンプレートとフランス語テンプレートがあるから、どちらにも対応して欲しい。

### Taskモデル
```ruby
class Task
  enum priority: [:high, :middle, :low]

  # ...
end
```
### tasksのスキーマ
```ruby
  create_table "tasks", force: :cascade do |t|
    t.date "deadline", null: false
    t.string "content", null: false
    t.integer "priority", null: false
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.index ["user_id"], name: "index_tasks_on_user_id"
  end
```

### タスクのスプレッドシート
#### カラム名
|    | deadline | content | priority |
| ---- | :---: | :---: | :---: |
| 日本語テンプレート | 期限日 | 内容 | 優先度 |
| フランス語テンプレート | Date limite | Contenu | Priorité |

#### 優先度のセルの値
|    | high | middle | low |
| ---- | :---: | :---: | :---: |
| 日本語テンプレート | 高 | 中 | 低 |
| フランス語テンプレート | Haut | Moyen | Bas |


## バリデートしたいこと
- 期限日は日付のフォーマットになっているか？
- 各言語テンプレートの
  - 指定のカラム名になっているか？
  - 優先度を表すセルの値は指定の値になっているか？

##

## ゴール

```ruby
class AttendanceSchedulesController < ApplicationController
  def import_tsv
    tsv_manager = TsvManager.new(params[:tsv])
    tsv_manager.strategy = TasksTsvValidateStrategy.new(lang)
    if tsv_manager.validate_tsv
      # ...
    else
      # ...
    end
  end
end
```
今回、tsvのバリデーションにフォーカスするため言語判定の処理は除きます。
実際には、[Linugo](http://detectlanguage.com/)というAPIを使って判定しています。


## やったこと
### step1
[json-schema](https://github.com/ruby-json-schema/json-schema)を作る。

```json
{
  "$schema": "http://json-schema.org/draft-04/schema",
  "type": "object",
  "required": ["fields"],
  "properties": {
    "fields": {
      "type": "array",
      "minItems": 1,
      "uniqueItems": true,
      "items": {
        "type": "object",
        "required": ["#{deadline_col_name}", "#{content_col_name}", "#{priority_col_name}"],
        "properties": {
          "#{deadline_col_name}": {
            "type": "string",
            "format": "date"
          },
          "#{content_col_name}": {
            "type": "string"
          },
          "#{priority_col_name}": {
            "type": "string",
            "pattern": "^#{pattern}$"
          },
        }
      }
    }
  }
}

```
#### step1 解説
Rubyのテンプレートリテラルっぽく記述してある箇所は、言語（日本語/フランス語）に応じて動的に変わる箇所です。


### step2
TsvManagerを作る。

```ruby
require 'csv'

class TsvManager
  attr_reader :fields
  attr_writer :validate_strategy

  def initialize(tsv_data)
    table = CSV.read(tsv_data.path, col_sep: "\t", headers: true)
    @fields = table.by_row.map do |row|
      row = row.to_h
      row.each{|key, val| row[key] = "" if val.nil? }
    end
  end

  def validate_tsv
    @validate_strategy.validate(fields)
  end
end
```
#### step2 解説
##### @&#xFFF0;fields
json-schemaでバリデートする時に渡してあげるために用意します。
中身は、「カラム名：セルの値」のペアになるハッシュの集まりです。

```ruby
# @fieldsイメージ

[
  {
    "期限日" => "2021/03/30",
    "内容" => "２７歳で死なない",
    "優先度" => "高"
  },
  # ...
]

# ぼくの誕生日は3月30日で、今この記事を書いている時の年齢は27歳です。
# 「ロックスターは27歳で死ぬ」という伝説に憧れて、一時期27歳で死ねることを切に願っていました。
# でも、もう死にたくないです。勘弁してください。
```
##### validate_strategy
今回タスクのtsvということですが、別のコンテキストのtsvインポートの依頼が来た時もTsvManagerを再利用できるよう[ストラテジーパターン](https://refactoring.guru/design-patterns/strategy/ruby/example)を採用しました。

### step3
ストラテジーを作る。

#### 抽象クラス
```ruby
class TsvValidateStrategy
  def validate(_fields)
    raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
  end
end
```

#### 具体クラス
```ruby
class TasksTsvValidateStrategy < TsvValidateStrategy
  attr_reader(
    :table_schema,
    :lang,
    :deadline_col_name,
    :content_col_name,
    :priority_col_name
    :vals_for_priority,
  )

  def initialize(lang)
    @lang = lang
    @deadline_col_name = define_deadline_col_name
    @content_col_name = define_content_col_name
    @priority_col_name = define_priority_col_name
    @vals_for_priority = define_vals_for_priority
    initialize_table_schema
  end

  def validate(fields)
    JSON::Validator.validate(table_schema, { "fields" => fields })
  end

  private

  def define_deadline_col_name
    case lang
    when :ja
      "期限日"
    when :fr
      "Date limite"
    end
  end

  def define_content_col_name
    case lang
    when :ja
      "内容"
    when :fr
      "Contenu"
    end
  end

  def define_content_col_name
    case lang
    when :ja
      "優先度"
    when :fr
      "Priorité"
    end
  end

  def define_vals_for_priority
    case lang
    when :ja
      ["高", "中", "低"]
    when :fr
      ["Haut", "Moyen", "Bas"]
    end
  end

  def initialize_table_schema
    # json-schemaをlangに応じて書き換えます。
    @table_schema = JSON.load(File.open("#{json-schema-path}"))
    keys_to_dig = %w[properties fields items]

    # requiredなキーを@deadline_col_nameと@content_col_nameと@priority_col_nameに入れ替えます。
    @table_schema.dig(*keys_to_dig, "required").replace([deadline_col_name, content_col_name, priority_col_name])

    # 上記で変更されたrequiredなキーを各カラム名に入れ換えます。
    keys_to_dig.push("properties")
    {
      '#{deadline_col_name}' => deadline_col_name,
      '#{content_col_name}' => content_col_name,
      '#{priority_col_name}' => priority_col_name,
    }
      .each do |old_key, new_key|
        @table_schema.dig(*keys_to_dig)[new_key] = @table_schema.dig(*keys_to_dig).delete(old_key)
      end

    # 優先度を表す値が指定の値になっているかを確かめる正規表現を書き換えます。
    keys_to_dig.push(*[priority_col_name, "pattern"])
    @table_schema.dig(*keys_to_dig).gsub!('#{pattern}', vals_for_priority.join("|"))
  end

end
```

### ゴール（再）
```ruby
class AttendanceSchedulesController < ApplicationController
  def import_tsv
    tsv_manager = TsvManager.new(params[:tsv])
    tsv_manager.strategy = TasksTsvValidateStrategy.new(lang)
    if tsv_manager.validate_tsv
      # ...
    else
      # ...
    end
  end
end
```


## 思いついた経緯
最初はコントローラにゴリゴリ処理を書いてました。
その後に、tsvのバリデーションはコントローラの関心ごとではないと思いつき、コンサーンに切り出し。
しかし、コントローラという文脈を考えるとコンサーンも正しくない気がする。
tsvマネージャーみたいな箱があって、そいつを利用するだけ、そいつを通してやるとバリデートしてくれるみたいなものがあればいいと考えた結果、json-schemaでやればいいと思いつきました。
苦労した点としては、言語に応じて確認すべき値を動的にする必要があったので、それをどうスマートに綺麗に処理するかというところでした。