2
2

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-03-16

やったこと

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

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

前提(例)

要件

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

Taskモデル

class Task
  enum priority: [:high, :middle, :low]

  # ...
end

tasksのスキーマ

  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

バリデートしたいこと

  • 期限日は日付のフォーマットになっているか?
  • 各言語テンプレートの
    • 指定のカラム名になっているか?
    • 優先度を表すセルの値は指定の値になっているか?

ゴール

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というAPIを使って判定しています。

やったこと

step1

json-schemaを作る。

{
  "$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を作る。

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 解説

@￰fields

json-schemaでバリデートする時に渡してあげるために用意します。
中身は、「カラム名:セルの値」のペアになるハッシュの集まりです。

# @fieldsイメージ

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

# ぼくの誕生日は3月30日で、今この記事を書いている時の年齢は27歳です。
# 「ロックスターは27歳で死ぬ」という伝説に憧れて、一時期27歳で死ねることを切に願っていました。
# でも、もう死にたくないです。勘弁してください。
validate_strategy

今回タスクのtsvということですが、別のコンテキストのtsvインポートの依頼が来た時もTsvManagerを再利用できるようストラテジーパターンを採用しました。

step3

ストラテジーを作る。

抽象クラス

class TsvValidateStrategy
  def validate(_fields)
    raise NotImplementedError, "#{self.class} has not implemented method '#{__method__}'"
  end
end

具体クラス

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

ゴール(再)

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でやればいいと思いつきました。
苦労した点としては、言語に応じて確認すべき値を動的にする必要があったので、それをどうスマートに綺麗に処理するかというところでした。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2