🐷

テンプレートパターンとStarategyパターンを使ったCSVインポートのruby実装

2024/12/15に公開

https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/issuable/import_csv/base_service.rb

gitlabのCSVインポートを参考に実装する。

主な仕様は以下の通り

  1. CSVインポートの項目名は日本語タイトルとする。
  2. タイトルキーのみ必須として、それ以外のパラメータは任意とする。
  3. 任意パラメータを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 に渡された値はブロック記法において | と | の間にはさまれた変数(ブロックパラメータ)に代入されます。
https://docs.ruby-lang.org/ja/latest/doc/spec=2fcall.html#yield

Discussion