abstract_importer 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +168 -0
- data/Rakefile +9 -0
- data/abstract_importer.gemspec +33 -0
- data/lib/abstract_importer/base.rb +170 -0
- data/lib/abstract_importer/collection.rb +4 -0
- data/lib/abstract_importer/collection_importer.rb +211 -0
- data/lib/abstract_importer/id_map.rb +47 -0
- data/lib/abstract_importer/import_options.rb +35 -0
- data/lib/abstract_importer/import_plan.rb +17 -0
- data/lib/abstract_importer/reporter.rb +146 -0
- data/lib/abstract_importer/version.rb +3 -0
- data/lib/abstract_importer.rb +2 -0
- data/test/callback_test.rb +95 -0
- data/test/importer_test.rb +106 -0
- data/test/support/mock_data_source.rb +32 -0
- data/test/support/mock_objects.rb +37 -0
- data/test/support/schema.rb +45 -0
- data/test/test_helper.rb +67 -0
- metadata +225 -0
@@ -0,0 +1,35 @@
|
|
1
|
+
module AbstractImporter
|
2
|
+
class ImportOptions
|
3
|
+
attr_reader :finder_callback,
|
4
|
+
:rescue_callback,
|
5
|
+
:before_build_callback,
|
6
|
+
:before_create_callback,
|
7
|
+
:after_create_callback,
|
8
|
+
:on_complete_callback
|
9
|
+
|
10
|
+
def finder(sym=nil, &block)
|
11
|
+
@finder_callback = sym || block
|
12
|
+
end
|
13
|
+
|
14
|
+
def before_build(sym=nil, &block)
|
15
|
+
@before_build_callback = sym || block
|
16
|
+
end
|
17
|
+
|
18
|
+
def before_create(sym=nil, &block)
|
19
|
+
@before_create_callback = sym || block
|
20
|
+
end
|
21
|
+
|
22
|
+
def after_create(sym=nil, &block)
|
23
|
+
@after_create_callback = sym || block
|
24
|
+
end
|
25
|
+
|
26
|
+
def rescue(sym=nil, &block)
|
27
|
+
@rescue_callback = sym || block
|
28
|
+
end
|
29
|
+
|
30
|
+
def on_complete(sym=nil, &block)
|
31
|
+
@on_complete_callback = sym || block
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,146 @@
|
|
1
|
+
module AbstractImporter
|
2
|
+
class Reporter
|
3
|
+
|
4
|
+
def initialize(io, production)
|
5
|
+
@io = io
|
6
|
+
@notices = {}
|
7
|
+
@errors = {}
|
8
|
+
@production = production
|
9
|
+
@invalid_params = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
attr_reader :io, :invalid_params
|
13
|
+
|
14
|
+
|
15
|
+
|
16
|
+
def production?
|
17
|
+
@production
|
18
|
+
end
|
19
|
+
|
20
|
+
|
21
|
+
|
22
|
+
def start_all(importer)
|
23
|
+
status "Importing #{importer.describe_source} to #{importer.describe_destination}\n"
|
24
|
+
end
|
25
|
+
|
26
|
+
def finish_all(importer, ms)
|
27
|
+
print_invalid_params
|
28
|
+
status "\n\nFinished in #{distance_of_time(ms)}"
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
|
33
|
+
def finish_setup(ms)
|
34
|
+
status "Setup took #{distance_of_time(ms)}\n"
|
35
|
+
end
|
36
|
+
|
37
|
+
|
38
|
+
|
39
|
+
def start_collection(collection)
|
40
|
+
status "\n#{("="*80)}\nImporting #{collection.name}\n#{("="*80)}\n"
|
41
|
+
@notices = {}
|
42
|
+
@errors = {}
|
43
|
+
end
|
44
|
+
|
45
|
+
def finish_collection(collection, summary)
|
46
|
+
ms = summary[5]
|
47
|
+
elapsed = distance_of_time(ms)
|
48
|
+
stat "\n #{summary[0]} #{collection.name} were found"
|
49
|
+
if summary[0] > 0
|
50
|
+
stat "#{summary[3]} #{collection.name} were imported previously"
|
51
|
+
stat "#{summary[1]} #{collection.name} would create duplicates and will not be imported"
|
52
|
+
stat "#{summary[4]} #{collection.name} were invalid"
|
53
|
+
stat "#{summary[2]} #{collection.name} were imported"
|
54
|
+
end
|
55
|
+
stat "#{elapsed} elapsed" << (summary[0] > 0 ? " (#{(ms / summary[0]).to_i}ms each)" : "")
|
56
|
+
|
57
|
+
print_messages(@notices, "Notices")
|
58
|
+
print_messages(@errors, "Errors")
|
59
|
+
end
|
60
|
+
|
61
|
+
|
62
|
+
|
63
|
+
def record_created(record)
|
64
|
+
io.print "." unless production?
|
65
|
+
end
|
66
|
+
|
67
|
+
def record_failed(record)
|
68
|
+
io.print "×" unless production?
|
69
|
+
|
70
|
+
error_messages = invalid_params[record.class.name] ||= {}
|
71
|
+
record.errors.full_messages.each do |error_message|
|
72
|
+
error_messages[error_message] = hash unless error_messages.key?(error_message)
|
73
|
+
count_error(error_message)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
|
78
|
+
|
79
|
+
def status(s)
|
80
|
+
io.puts s
|
81
|
+
end
|
82
|
+
|
83
|
+
def stat(s)
|
84
|
+
io.puts " #{s}"
|
85
|
+
end
|
86
|
+
alias :info :stat
|
87
|
+
|
88
|
+
def file(s)
|
89
|
+
io.puts s.inspect
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
|
94
|
+
def count_notice(message)
|
95
|
+
return if production?
|
96
|
+
@notices[message] = (@notices[message] || 0) + 1
|
97
|
+
end
|
98
|
+
|
99
|
+
def count_error(message)
|
100
|
+
@errors[message] = (@errors[message] || 0) + 1
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
|
105
|
+
private
|
106
|
+
|
107
|
+
def print_invalid_params
|
108
|
+
return if invalid_params.empty?
|
109
|
+
status "\n\n\n#{("="*80)}\nExamples of invalid hashes\n#{("="*80)}"
|
110
|
+
invalid_params.each do |model_name, errors|
|
111
|
+
status "\n\n--#{model_name}#{("-"*(78 - model_name.length))}"
|
112
|
+
errors.each do |error_message, hash|
|
113
|
+
status "\n #{error_message}:\n #{hash.inspect}"
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
def print_messages(array, caption)
|
119
|
+
return if array.empty?
|
120
|
+
status "\n--#{caption}#{("-"*(78-caption.length))}\n\n"
|
121
|
+
array.each do |message, count|
|
122
|
+
stat "#{count} × #{message}"
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def distance_of_time(milliseconds)
|
127
|
+
milliseconds = milliseconds.to_i
|
128
|
+
seconds = milliseconds / 1000
|
129
|
+
milliseconds %= 1000
|
130
|
+
minutes = seconds / 60
|
131
|
+
seconds %= 60
|
132
|
+
hours = minutes / 60
|
133
|
+
minutes %= 60
|
134
|
+
days = hours / 24
|
135
|
+
hours %= 24
|
136
|
+
|
137
|
+
time = []
|
138
|
+
time << "#{days} days" unless days.zero?
|
139
|
+
time << "#{hours} hours" unless hours.zero?
|
140
|
+
time << "#{minutes} minutes" unless minutes.zero?
|
141
|
+
time << "#{seconds}.#{milliseconds.to_s.rjust(3, "0")} seconds"
|
142
|
+
time.join(", ")
|
143
|
+
end
|
144
|
+
|
145
|
+
end
|
146
|
+
end
|
@@ -0,0 +1,95 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
|
4
|
+
class CallbackTest < ActiveSupport::TestCase
|
5
|
+
|
6
|
+
|
7
|
+
|
8
|
+
context "before_build" do
|
9
|
+
setup do
|
10
|
+
plan do |import|
|
11
|
+
import.students do |options|
|
12
|
+
options.before_build { |attrs| attrs.merge(name: attrs[:name][/(\S+)/, 1]) }
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
should "should be invoked on the incoming attributes" do
|
18
|
+
import!
|
19
|
+
assert_equal ["Harry", "Ron", "Hermione"], account.students.pluck(:name)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
|
25
|
+
context "before_create" do
|
26
|
+
setup do
|
27
|
+
plan do |import|
|
28
|
+
import.students do |options|
|
29
|
+
options.before_create { |student| student.house = "Gryffindor" }
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
should "should be invoked on imported records before they are saved" do
|
35
|
+
import!
|
36
|
+
assert_equal ["Gryffindor"], account.students.pluck(:house).uniq
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
|
42
|
+
context "after_create" do
|
43
|
+
setup do
|
44
|
+
plan do |import|
|
45
|
+
import.students do |options|
|
46
|
+
options.after_create :callback
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
should "should be invoked after the record is created" do
|
52
|
+
mock(importer).callback({name: "Harry Potter"}, satisfy(&:persisted?)).once
|
53
|
+
mock(importer).callback({name: "Ron Weasley"}, satisfy(&:persisted?)).once
|
54
|
+
mock(importer).callback({name: "Hermione Granger"}, satisfy(&:persisted?)).once
|
55
|
+
import!
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
|
60
|
+
|
61
|
+
context "rescue" do
|
62
|
+
setup do
|
63
|
+
plan do |import|
|
64
|
+
import.locations do |options|
|
65
|
+
options.rescue { |location| location.slug = location.slug.gsub(/[^a-z0-9\-]/, "") }
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
should "should be given a chance to amend an invalid record" do
|
71
|
+
import!
|
72
|
+
assert_equal ["godrics-hollow", "azkaban"], account.locations.pluck(:slug)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
|
77
|
+
|
78
|
+
context "on_complete" do
|
79
|
+
setup do
|
80
|
+
plan do |import|
|
81
|
+
import.students do |options|
|
82
|
+
options.on_complete :callback
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
should "should be invoked after the collection has been imported" do
|
88
|
+
mock(importer).callback.once
|
89
|
+
import!
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
|
94
|
+
|
95
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
require "test_helper"
|
2
|
+
|
3
|
+
|
4
|
+
class ImporterTest < ActiveSupport::TestCase
|
5
|
+
|
6
|
+
|
7
|
+
|
8
|
+
context "with a simple data source" do
|
9
|
+
setup do
|
10
|
+
plan do |import|
|
11
|
+
import.students
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
should "import the given records" do
|
16
|
+
import!
|
17
|
+
assert_equal ["Harry Potter", "Ron Weasley", "Hermione Granger"], account.students.pluck(:name)
|
18
|
+
end
|
19
|
+
|
20
|
+
should "record their legacy_id" do
|
21
|
+
import!
|
22
|
+
assert_equal [456, 457, 458], account.students.pluck(:legacy_id)
|
23
|
+
end
|
24
|
+
|
25
|
+
should "not import existing records twice" do
|
26
|
+
account.students.create!(name: "Ron Weasley", legacy_id: 457)
|
27
|
+
import!
|
28
|
+
assert_equal 3, account.students.count
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
|
34
|
+
context "with a complex data source" do
|
35
|
+
setup do
|
36
|
+
plan do |import|
|
37
|
+
import.students
|
38
|
+
import.parents
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
should "preserve mappings" do
|
43
|
+
import!
|
44
|
+
harry = account.students.find_by_name("Harry Potter")
|
45
|
+
assert_equal ["James Potter", "Lily Potter"], harry.parents.pluck(:name)
|
46
|
+
end
|
47
|
+
|
48
|
+
should "preserve mappings event when a record was previously imported" do
|
49
|
+
harry = account.students.create!(name: "Harry Potter", legacy_id: 456)
|
50
|
+
import!
|
51
|
+
assert_equal ["James Potter", "Lily Potter"], harry.parents.pluck(:name)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
|
57
|
+
context "when a finder is specified" do
|
58
|
+
setup do
|
59
|
+
plan do |import|
|
60
|
+
import.students do |options|
|
61
|
+
options.finder { |attrs| parent.students.find_by_name(attrs[:name]) }
|
62
|
+
end
|
63
|
+
import.parents
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
should "not import redundant records" do
|
68
|
+
account.students.create!(name: "Ron Weasley", legacy_id: nil)
|
69
|
+
import!
|
70
|
+
assert_equal 3, account.students.count
|
71
|
+
end
|
72
|
+
|
73
|
+
should "preserve mappings" do
|
74
|
+
harry = account.students.create!(name: "Harry Potter", legacy_id: nil)
|
75
|
+
import!
|
76
|
+
assert_equal ["James Potter", "Lily Potter"], harry.parents.pluck(:name)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
|
81
|
+
|
82
|
+
context "with a more complex data source" do
|
83
|
+
setup do
|
84
|
+
plan do |import|
|
85
|
+
import.students
|
86
|
+
import.subjects do |options|
|
87
|
+
options.before_build do |attributes|
|
88
|
+
attributes.merge(:student_ids => attributes[:student_ids].map do |student_id|
|
89
|
+
map_foreign_key(student_id, :subjects, :student_id, :students)
|
90
|
+
end)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
import.grades
|
94
|
+
end
|
95
|
+
end
|
96
|
+
|
97
|
+
should "preserve mappings" do
|
98
|
+
import!
|
99
|
+
ron = account.students.find_by_name "Ron Weasley"
|
100
|
+
assert_equal ["Advanced Potions: Acceptable", "History of Magic: Troll"], ron.report_card
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
|
105
|
+
|
106
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
class MockDataSource
|
2
|
+
|
3
|
+
def students
|
4
|
+
yield id: 456, name: "Harry Potter"
|
5
|
+
yield id: 457, name: "Ron Weasley"
|
6
|
+
yield id: 458, name: "Hermione Granger"
|
7
|
+
end
|
8
|
+
|
9
|
+
def parents
|
10
|
+
yield id: 88, name: "James Potter", student_id: 456
|
11
|
+
yield id: 89, name: "Lily Potter", student_id: 456
|
12
|
+
end
|
13
|
+
|
14
|
+
def locations
|
15
|
+
yield id: 5, slug: "godric's-hollow" # <-- invalid
|
16
|
+
yield id: 6, slug: "azkaban"
|
17
|
+
end
|
18
|
+
|
19
|
+
def subjects
|
20
|
+
yield id: 49, name: "Care of Magical Creatures", student_ids: [456]
|
21
|
+
yield id: 50, name: "Advanced Potions", student_ids: [456, 457]
|
22
|
+
yield id: 51, name: "History of Magic", student_ids: [457]
|
23
|
+
yield id: 52, name: "Arithmancy", student_ids: [458]
|
24
|
+
yield id: 53, name: "Study of Ancient Runes", student_ids: [458]
|
25
|
+
end
|
26
|
+
|
27
|
+
def grades
|
28
|
+
yield id: 500, subject_id: 50, student_id: 457, value: "Acceptable"
|
29
|
+
yield id: 501, subject_id: 51, student_id: 457, value: "Troll"
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
class Student < ActiveRecord::Base
|
2
|
+
has_and_belongs_to_many :subjects
|
3
|
+
has_many :grades
|
4
|
+
has_many :parents
|
5
|
+
|
6
|
+
def report_card
|
7
|
+
subjects.map do |subject|
|
8
|
+
grade = grades.find_by_subject_id(subject.id)
|
9
|
+
"#{subject.name}: #{grade.value if grade}"
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
class Parent < ActiveRecord::Base
|
15
|
+
belongs_to :student
|
16
|
+
end
|
17
|
+
|
18
|
+
class Location < ActiveRecord::Base
|
19
|
+
validates :slug, format: {with: /\A[a-z0-9\-]+\z/}
|
20
|
+
end
|
21
|
+
|
22
|
+
class Subject < ActiveRecord::Base
|
23
|
+
has_and_belongs_to_many :students
|
24
|
+
end
|
25
|
+
|
26
|
+
class Grade < ActiveRecord::Base
|
27
|
+
belongs_to :student
|
28
|
+
belongs_to :subject
|
29
|
+
end
|
30
|
+
|
31
|
+
class Account < ActiveRecord::Base
|
32
|
+
has_many :parents
|
33
|
+
has_many :students
|
34
|
+
has_many :subjects
|
35
|
+
has_many :grades
|
36
|
+
has_many :locations
|
37
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
ActiveRecord::Schema.define(:version => 1) do
|
2
|
+
|
3
|
+
create_table "accounts", :force => true do |t|
|
4
|
+
end
|
5
|
+
|
6
|
+
create_table "students", :force => true do |t|
|
7
|
+
t.integer "account_id"
|
8
|
+
t.integer "legacy_id"
|
9
|
+
t.string "name"
|
10
|
+
t.string "house"
|
11
|
+
end
|
12
|
+
|
13
|
+
create_table "parents", :force => true do |t|
|
14
|
+
t.integer "account_id"
|
15
|
+
t.integer "student_id"
|
16
|
+
t.integer "legacy_id"
|
17
|
+
t.string "name"
|
18
|
+
end
|
19
|
+
|
20
|
+
create_table "locations", :force => true do |t|
|
21
|
+
t.integer "account_id"
|
22
|
+
t.integer "legacy_id"
|
23
|
+
t.string "slug"
|
24
|
+
end
|
25
|
+
|
26
|
+
create_table "students_subjects", :force => true do |t|
|
27
|
+
t.integer "student_id"
|
28
|
+
t.integer "subject_id"
|
29
|
+
end
|
30
|
+
|
31
|
+
create_table "subjects", :force => true do |t|
|
32
|
+
t.integer "account_id"
|
33
|
+
t.integer "legacy_id"
|
34
|
+
t.string "name"
|
35
|
+
end
|
36
|
+
|
37
|
+
create_table "grades", :force => true do |t|
|
38
|
+
t.integer "account_id"
|
39
|
+
t.integer "subject_id"
|
40
|
+
t.integer "student_id"
|
41
|
+
t.integer "legacy_id"
|
42
|
+
t.string "value"
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
require "rubygems"
|
2
|
+
|
3
|
+
require "simplecov"
|
4
|
+
SimpleCov.start do
|
5
|
+
add_filter "test/"
|
6
|
+
end
|
7
|
+
|
8
|
+
require "rails"
|
9
|
+
require "rails/test_help"
|
10
|
+
require "turn"
|
11
|
+
require "pry"
|
12
|
+
require "rr"
|
13
|
+
require "database_cleaner"
|
14
|
+
require "abstract_importer"
|
15
|
+
require "shoulda/context"
|
16
|
+
require "active_record"
|
17
|
+
require "support/mock_data_source"
|
18
|
+
require "support/mock_objects"
|
19
|
+
|
20
|
+
|
21
|
+
|
22
|
+
ActiveRecord::Base.establish_connection(
|
23
|
+
:adapter => "sqlite3",
|
24
|
+
:database => ":memory:",
|
25
|
+
:verbosity => "quiet")
|
26
|
+
|
27
|
+
load File.join(File.dirname(__FILE__), "support", "schema.rb")
|
28
|
+
|
29
|
+
|
30
|
+
|
31
|
+
DatabaseCleaner.strategy = :transaction
|
32
|
+
$io = ENV['VERBOSE'] ? $stderr : File.open("/dev/null", "w")
|
33
|
+
|
34
|
+
|
35
|
+
|
36
|
+
class ActiveSupport::TestCase
|
37
|
+
|
38
|
+
setup do
|
39
|
+
DatabaseCleaner.start
|
40
|
+
|
41
|
+
@data_source = MockDataSource.new
|
42
|
+
@klass = Class.new(AbstractImporter::Base)
|
43
|
+
@account = Account.create!
|
44
|
+
end
|
45
|
+
|
46
|
+
teardown do
|
47
|
+
DatabaseCleaner.clean
|
48
|
+
@importer = nil
|
49
|
+
end
|
50
|
+
|
51
|
+
protected
|
52
|
+
|
53
|
+
attr_reader :account, :results
|
54
|
+
|
55
|
+
def plan(&block)
|
56
|
+
@klass.import(&block)
|
57
|
+
end
|
58
|
+
|
59
|
+
def import!
|
60
|
+
@results = importer.perform!
|
61
|
+
end
|
62
|
+
|
63
|
+
def importer
|
64
|
+
@importer ||= @klass.new(@account, @data_source, io: $io)
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|