🐷
テンプレートパターンとStarategyパターンを使ったCSVインポートのruby実装
gitlabのCSVインポートを参考に実装する。
主な仕様は以下の通り
- CSVインポートの項目名は日本語タイトルとする。
- タイトルキーのみ必須として、それ以外のパラメータは任意とする。
- 任意パラメータをCSVカラムに指定しない場合はなにもしない
# csv.rb
require 'active_record'
require 'sqlite3'
# ロギング
ActiveRecord::Base.logger = Logger.new($stdout)
# データベース接続の設定
ActiveRecord::Base.establish_connection(
adapter: 'sqlite3',
database: 'db/development.sqlite3'
)
# テーブルの作成
ActiveRecord::Schema.define do
drop_table :issues, if_exists: true
drop_table :milestones, if_exists: true
end
ActiveRecord::Schema.define do
create_table :milestones do |t|
t.string :title
t.timestamps
end
create_table :issues do |t|
t.references :milestone, foreign_key: true
t.string :title
t.string :description
t.date :due_date
t.integer :status, default: 0
t.timestamps
end
end
class Milestone < ActiveRecord::Base
end
class Issue < ActiveRecord::Base
validates :title, presence: true, length: { maximum: 100 }
enum :status, { open: 0, close: 1 }
end
require 'csv'
module Issues
class CsvService
class CsvError < StandardError; end
def initialize(csv_content)
@csv_content = csv_content
@errors = []
@imported_count = 0
@failed_count = 0
end
def import
CSV.parse(@csv_content, headers: true).each_with_index do |row, index|
begin
line_number = index + 2
process_csv(row)
@imported_count += 1
rescue CsvError => e
@failed_count += 1
@errors << "行#{line_number}: #{e.message}"
rescue ActiveRecord::RecordInvalid => e
@failed_count += 1
@errors << "行#{line_number}: #{e.record.errors.full_messages.join(', ')}"
rescue StandardError => e
@failed_count += 1
@errors << "行#{line_number}: 予期せぬエラーが発生しました - #{e.message}"
end
end
{
imported_count: @imported_count,
failed_count: @failed_count,
errors: @errors
}
rescue CSV::MalformedCSVError => e
raise CsvError, "CSVフォーマットが不正です: #{e.message}"
end
def process_csv(row)
attributes = attributes_for(row)
issue = Issue.find_or_initialize_by(id: row["ID"])
issue.attributes = attributes
issue.save!
end
def attributes_for(row)
attributes = {
'title' => row['タイトル']
}
attributes['description'] = row['説明'] if row['説明']
attributes['due_date'] = parse_date(row['期限']) if row['期限']
attributes['milestone_id'] = find_by_milestone_id(row['マイルストーン']) if row['マイルストーン']
attributes['status'] = parse_status(row['ステータス']) if row['ステータス']
attributes
end
def parse_date(date)
Date.parse(date)
rescue ArgumentError
raise CsvError, "日付のフォーマットが不正です: #{date}"
end
def find_by_milestone_id(milestone_title)
Milestone.find_by!(title: milestone_title).id
rescue ActiveRecord::RecordNotFound
raise CsvError, "マイルストーンが見つかりません: #{milestone_title}"
end
def parse_status(status)
case status
when 'オープン'
:open
when 'クローズ'
:close
else
raise CsvError, "不正なステータスです: #{status}"
end
end
end
end
Milestone.create(title: 'Milestone 1')
# 新規作成
csv_text = <<~CSV_TEXT
タイトル
Issue 1
Issue 2
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
# 更新
csv_text = <<~CSV_TEXT
ID,タイトル,説明,期限,マイルストーン
1,Issue 1-Update,Issue 1の説明,2021-01-01,Milestone 1
2,Issue 2-Update,Issue 2の説明,エラー,Milestone 1
3,Issue 3-Update,Issue 3の説明,2021-01-03,Milestone 2
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
puts Issue.first.attributes
# issue validation
csv_text = <<~CSV_TEXT
ID,タイトル
1,Issue 1-Update ああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
puts Issue.first.attributes
# status validation
csv_text = <<~CSV_TEXT
ID,タイトル,ステータス
1,Issue 1-CLOSE,クローズ
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
puts Issue.first.attributes
継承を使ってテンプレートパターンを利用すると下記の通りとなる。
require 'csv'
module Issues
class BaseCsvService
class CsvError < StandardError; end
def initialize(csv_content)
@csv_content = csv_content
@errors = []
@imported_count = 0
@failed_count = 0
end
def import
CSV.parse(@csv_content, headers: true).each_with_index do |row, index|
begin
line_number = index + 2
process_csv(row)
@imported_count += 1
rescue CsvError => e
@failed_count += 1
@errors << "行#{line_number}: #{e.message}"
rescue ActiveRecord::RecordInvalid => e
@failed_count += 1
@errors << "行#{line_number}: #{e.record.errors.full_messages.join(', ')}"
rescue StandardError => e
@failed_count += 1
@errors << "行#{line_number}: 予期せぬエラーが発生しました - #{e.message}"
end
end
{
imported_count: @imported_count,
failed_count: @failed_count,
errors: @errors
}
rescue CSV::MalformedCSVError => e
raise CsvError, "CSVフォーマットが不正です: #{e.message}"
end
end
end
module Issues
class CsvService < BaseCsvService
def process_csv(row)
attributes = attributes_for(row)
issue = Issue.find_or_initialize_by(id: row["ID"])
issue.attributes = attributes
issue.save!
end
def attributes_for(row)
attributes = {
'title' => row['タイトル']
}
attributes['description'] = row['説明'] if row['説明']
attributes['due_date'] = parse_date(row['期限']) if row['期限']
attributes['milestone_id'] = find_by_milestone_id(row['マイルストーン']) if row['マイルストーン']
attributes['status'] = parse_status(row['ステータス']) if row['ステータス']
attributes
end
def parse_date(date)
Date.parse(date)
rescue ArgumentError
raise CsvError, "日付のフォーマットが不正です: #{date}"
end
def find_by_milestone_id(milestone_title)
Milestone.find_by!(title: milestone_title).id
rescue ActiveRecord::RecordNotFound
raise CsvError, "マイルストーンが見つかりません: #{milestone_title}"
end
def parse_status(status)
case status
when 'オープン'
:open
when 'クローズ'
:close
else
raise CsvError, "不正なステータスです: #{status}"
end
end
end
end
Strategyパターンを使った実装は下記の通り。
require 'csv'
module Issues
class CsvError < StandardError; end
end
module Issues
class CsvProcessor
def initialize(csv_content)
@csv_content = csv_content
@errors = []
@imported_count = 0
@failed_count = 0
end
def import
CSV.parse(@csv_content, headers: true).each_with_index do |row, index|
begin
line_number = index + 2
yield(row)
@imported_count += 1
rescue CsvError => e
@failed_count += 1
@errors << "行#{line_number}: #{e.message}"
rescue ActiveRecord::RecordInvalid => e
@failed_count += 1
@errors << "行#{line_number}: #{e.record.errors.full_messages.join(', ')}"
rescue StandardError => e
@failed_count += 1
@errors << "行#{line_number}: 予期せぬエラーが発生しました - #{e.message}"
end
end
{
imported_count: @imported_count,
failed_count: @failed_count,
errors: @errors
}
rescue CSV::MalformedCSVError => e
raise CsvError, "CSVフォーマットが不正です: #{e.message}"
end
end
end
module Issues
class CsvService
def initialize(csv_content)
@csv_content = csv_content
end
def import
processor = CsvProcessor.new(@csv_content)
processor.import do |row|
process_csv(row)
end
end
def process_csv(row)
attributes = attributes_for(row)
issue = Issue.find_or_initialize_by(id: row["ID"])
issue.attributes = attributes
issue.save!
end
def attributes_for(row)
attributes = {
'title' => row['タイトル']
}
attributes['description'] = row['説明'] if row['説明']
attributes['due_date'] = parse_date(row['期限']) if row['期限']
attributes['milestone_id'] = find_by_milestone_id(row['マイルストーン']) if row['マイルストーン']
attributes['status'] = parse_status(row['ステータス']) if row['ステータス']
attributes
end
def parse_date(date)
Date.parse(date)
rescue ArgumentError
raise CsvError, "日付のフォーマットが不正です: #{date}"
end
def find_by_milestone_id(milestone_title)
Milestone.find_by!(title: milestone_title).id
rescue ActiveRecord::RecordNotFound
raise CsvError, "マイルストーンが見つかりません: #{milestone_title}"
end
def parse_status(status)
case status
when 'オープン'
:open
when 'クローズ'
:close
else
raise CsvError, "不正なステータスです: #{status}"
end
end
end
end
継承を使うとメソッドを定義して必要な処理をoverideする必要があるが、Strategyパターンは具体的な処理はCsvServiceに寄せられるためコードの見通しがよいと思う。
最終盤
Stategyパターンでの実装の最終盤。
複数エラー発生時にすべてエラーハンドリングするように修正。
# csv.rb
require 'active_record'
require 'sqlite3'
# ロギング
ActiveRecord::Base.logger = Logger.new($stdout)
# データベース接続の設定
ActiveRecord::Base.establish_connection(
adapter: 'sqlite3',
database: 'db/development.sqlite3'
)
# テーブルの作成
ActiveRecord::Schema.define do
drop_table :issues, if_exists: true
drop_table :milestones, if_exists: true
end
ActiveRecord::Schema.define do
create_table :milestones do |t|
t.string :title
t.timestamps
end
create_table :issues do |t|
t.references :milestone, foreign_key: true
t.string :title
t.string :description
t.date :due_date
t.integer :status, default: 0
t.timestamps
end
end
class Milestone < ActiveRecord::Base
end
class Issue < ActiveRecord::Base
validates :title, presence: true, length: { maximum: 100 }
enum :status, { open: 0, close: 1 }
end
require 'csv'
module Issues
class CsvError < StandardError; end
end
module Issues
class CsvProcessor
def initialize(csv_content)
@csv_content = csv_content
@errors = []
@imported_count = 0
@failed_count = 0
end
def import
CSV.parse(@csv_content, headers: true).each_with_index do |row, index|
begin
line_number = index + 2
yield(row)
@imported_count += 1
rescue CsvError => e
@failed_count += 1
@errors << "行#{line_number}: #{e.message}"
rescue StandardError => e
@failed_count += 1
@errors << "行#{line_number}: 予期せぬエラーが発生しました - #{e.message}"
end
end
{
imported_count: @imported_count,
failed_count: @failed_count,
errors: @errors
}
rescue CSV::MalformedCSVError => e
raise CsvError, "CSVフォーマットが不正です: #{e.message}"
end
end
end
module Issues
class CsvService
def initialize(csv_content)
@csv_content = csv_content
@errors = []
end
def import
processor = CsvProcessor.new(@csv_content)
processor.import do |row|
process_csv(row)
end
end
def process_csv(row)
attributes = attributes_for(row)
issue = Issue.find_or_initialize_by(id: row["ID"])
issue.attributes = attributes
@errors << issue.errors.full_messages.join(', ') if issue.invalid?
raise CsvError, @errors if @errors.present?
issue.save!
end
def attributes_for(row)
attributes = {
'title' => row['タイトル']
}
attributes['description'] = row['説明'] if row['説明']
attributes['due_date'] = parse_date(row['期限']) if row['期限']
attributes['milestone_id'] = find_by_milestone_id(row['マイルストーン']) if row['マイルストーン']
attributes['status'] = parse_status(row['ステータス']) if row['ステータス']
attributes
end
def parse_date(date)
Date.parse(date)
rescue ArgumentError
@errors << "日付のフォーマットが不正です: #{date}"
end
def find_by_milestone_id(milestone_title)
Milestone.find_by!(title: milestone_title).id
rescue ActiveRecord::RecordNotFound
@errors << "マイルストーンが見つかりません: #{milestone_title}"
end
def parse_status(status)
case status
when 'オープン'
:open
when 'クローズ'
:close
else
@errors << "不正なステータスです: #{status}"
end
end
end
end
Milestone.create(title: 'Milestone 1')
# 新規作成
csv_text = <<~CSV_TEXT
タイトル
Issue 1
Issue 2
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
# 更新
csv_text = <<~CSV_TEXT
ID,タイトル,説明,期限,マイルストーン
1,Issue 1-Update,Issue 1の説明,2021-01-01,Milestone 1
2,Issue 2-Update,Issue 2の説明,エラー,Milestone 1
3,Issue 3-Update,Issue 3の説明,2021-01-03,Milestone 2
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
puts Issue.first.attributes
# issue validation
csv_text = <<~CSV_TEXT
ID,タイトル
1,Issue 1-Update ああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
puts Issue.first.attributes
# status validation
csv_text = <<~CSV_TEXT
ID,タイトル,ステータス
1,Issue 1-CLOSE,クローズ
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
puts Issue.first.attributes
# 複数エラーパターン
csv_text = <<~CSV_TEXT
タイトル,説明,期限,マイルストーン
Issue 1-Update ああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ,Issue 1の説明,エラー,Milestone 2
CSV_TEXT
puts Issues::CsvService.new(csv_text).import
メモ
yield に渡された値はブロック記法において | と | の間にはさまれた変数(ブロックパラメータ)に代入されます。
Discussion