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.
@@ -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,17 @@
1
+ module AbstractImporter
2
+ class ImportPlan
3
+
4
+ def initialize
5
+ @plan = {} # <-- requires Ruby 1.9's ordered hash
6
+ end
7
+
8
+ def to_h
9
+ @plan.dup
10
+ end
11
+
12
+ def method_missing(plural, &block)
13
+ @plan[plural] = block
14
+ end
15
+
16
+ end
17
+ 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,3 @@
1
+ module AbstractImporter
2
+ VERSION = "1.0.0"
3
+ end
@@ -0,0 +1,2 @@
1
+ require 'abstract_importer/base'
2
+ require 'abstract_importer/version'
@@ -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
@@ -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