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