abstract_importer 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|