Skip to content

Commit 45b5eaa

Browse files
committed
Happy path 😸
1 parent c6129d9 commit 45b5eaa

File tree

5 files changed

+260
-1
lines changed

5 files changed

+260
-1
lines changed

Gemfile

+2
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ source 'https://rubygems.org'
44
gemspec
55

66
gem 'rspec'
7+
gem 'guard-rspec'
8+
gem 'activemodel'

csv-importer.gemspec

+2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ Gem::Specification.new do |spec|
2323
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
2424
spec.require_paths = ["lib"]
2525

26+
spec.add_dependency "virtus"
27+
2628
spec.add_development_dependency "bundler", "~> 1.8"
2729
spec.add_development_dependency "rake", "~> 10.0"
2830
end

lib/csv_importer.rb

+180-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,184 @@
11
require "csv_importer/version"
2+
require "csv"
3+
require "virtus"
24

35
module CSVImporter
4-
# Your code goes here...
6+
class CSVReader
7+
include Virtus.model
8+
9+
attribute :content, String
10+
11+
def csv_rows
12+
@csv_rows ||= CSV.parse(content)
13+
end
14+
15+
def header
16+
@header ||= csv_rows.first
17+
end
18+
19+
def rows
20+
@rows ||= csv_rows[1..-1]
21+
end
22+
end
23+
24+
class Column
25+
include Virtus.model
26+
27+
attribute :name, Symbol
28+
attribute :to
29+
attribute :required, Boolean
30+
31+
def attribute
32+
to || name
33+
end
34+
end
35+
36+
class Header
37+
include Virtus.model
38+
39+
attribute :columns_config, Array[Column]
40+
attribute :row, Array[String]
41+
42+
def columns
43+
row.map(&:to_sym)
44+
end
45+
end
46+
47+
class Row
48+
include Virtus.model
49+
50+
attribute :header, Header
51+
attribute :row_array, Array[String]
52+
attribute :model_klass
53+
54+
def model
55+
@model ||= begin
56+
model = model_klass.new
57+
set_attributes(model)
58+
model
59+
end
60+
end
61+
62+
def csv_attributes
63+
@csv_attributes ||= Hash[header.columns.zip(row_array)]
64+
end
65+
66+
def set_attributes(model)
67+
header.columns_config.each do |column|
68+
csv_value = csv_attributes[column.name]
69+
attribute = column.attribute
70+
if attribute.is_a?(Proc)
71+
attribute.call(csv_value, model)
72+
else
73+
model.public_send("#{attribute}=", csv_value)
74+
end
75+
end
76+
end
77+
end
78+
79+
class Report
80+
include Virtus.model
81+
82+
attribute :created_rows, Array[Row]
83+
attribute :updated_rows, Array[Row]
84+
attribute :failed_to_create_rows, Array[Row]
85+
attribute :failed_to_update_rows, Array[Row]
86+
87+
def valid_rows
88+
created_rows + updated_rows
89+
end
90+
91+
def invalid_rows
92+
failed_to_create_rows + failed_to_update_rows
93+
end
94+
end
95+
96+
class Runner
97+
def self.call(*args)
98+
new(*args).call
99+
end
100+
101+
include Virtus.model
102+
103+
attribute :rows, Array[Row]
104+
105+
def call
106+
report = Report.new
107+
108+
rows.each do |row|
109+
if row.model.persisted?
110+
if row.model.save
111+
report.updated_rows << row
112+
else
113+
report.failed_to_update_rows << row
114+
end
115+
else
116+
if row.model.save
117+
report.created_rows << row
118+
else
119+
report.failed_to_create_rows << row
120+
end
121+
end
122+
end
123+
124+
report
125+
end
126+
end
127+
128+
class Config
129+
include Virtus.model
130+
131+
attribute :model
132+
attribute :columns, Array[Column], default: proc { [] }
133+
attribute :identifier, Symbol
134+
attribute :when_invalid, Symbol, default: proc { :skip }
135+
end
136+
137+
module Dsl
138+
def model(model_klass)
139+
csv_importer_config.model = model_klass
140+
end
141+
142+
def column(name, options={})
143+
csv_importer_config.columns << Column.new(options.merge(name: name))
144+
end
145+
146+
def identifier(identifier)
147+
csv_importer_config.identifier = identifier
148+
end
149+
150+
def when_invalid(action)
151+
csv_importer_config.when_invalid = action
152+
end
153+
end
154+
155+
def self.included(klass)
156+
klass.extend(Dsl)
157+
klass.define_singleton_method(:csv_importer_config) do
158+
@csv_importer_config ||= Config.new
159+
end
160+
end
161+
162+
def initialize(*args)
163+
@csv = CSVReader.new(*args)
164+
end
165+
166+
attr_reader :csv, :report
167+
168+
def header
169+
@header ||= Header.new(columns_config: config.columns, row: csv.header)
170+
end
171+
172+
def config
173+
self.class.csv_importer_config
174+
end
175+
176+
def rows
177+
csv.rows.map { |row_array| Row.new(header: header, row_array: row_array, model_klass: config.model) }
178+
end
179+
180+
def run!
181+
@report = Runner.call(rows: rows)
182+
end
183+
5184
end

spec/csv_importer_spec.rb

+74
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,78 @@
44
it 'has a version number' do
55
expect(CSVImporter::VERSION).not_to be nil
66
end
7+
8+
class User
9+
include Virtus.model
10+
include ActiveModel::Model
11+
12+
attribute :email
13+
attribute :f_name
14+
attribute :l_name
15+
attribute :confirmed_at
16+
17+
validates_presence_of :email
18+
validates_format_of :email, with: /[^@]+@[^@]/ # contains one @ symbol
19+
20+
def valid?
21+
email
22+
end
23+
24+
def persisted?
25+
@persisted ||= false
26+
end
27+
28+
def save
29+
@persisted = true
30+
end
31+
end
32+
33+
class ImportUserCSV
34+
include CSVImporter
35+
36+
model User
37+
38+
column :email
39+
column :first_name, to: :f_name
40+
column :last_name, to: :l_name
41+
column :confirmed, to: ->(confirmed, model) { model.confirmed_at = Time.new(2012) if confirmed == "true" }
42+
43+
identifier :email # will find_or_update via
44+
45+
when_invalid :skip # or :abort
46+
end
47+
48+
CSV_CONTENT = "email,confirmed,first_name,last_name
49+
[email protected],true,bob,,"
50+
51+
52+
it 'imports' do
53+
import = ImportUserCSV.new(content: CSV_CONTENT)
54+
expect(import.rows.size).to eq(1)
55+
56+
row = import.rows.first
57+
58+
expect(row.csv_attributes).to eq(
59+
{
60+
61+
first_name: "bob",
62+
last_name: nil,
63+
confirmed: "true"
64+
}
65+
)
66+
67+
import.run!
68+
69+
expect(import.report.valid_rows.size).to eq(1)
70+
expect(import.report.created_rows.size).to eq(1)
71+
72+
model = import.report.valid_rows.first.model
73+
expect(model).to be_persisted
74+
expect(model).to have_attributes(
75+
76+
f_name: "bob",
77+
l_name: nil,
78+
confirmed_at: Time.new(2012)
79+
)
80+
end
781
end

spec/spec_helper.rb

+2
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2+
require 'active_model'
3+
24
require 'csv_importer'

0 commit comments

Comments
 (0)