abstract_importer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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