importu 0.1.0 → 0.2.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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +15 -0
  3. data/.github/workflows/ci.yml +48 -0
  4. data/.gitignore +4 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +311 -0
  7. data/.simplecov +14 -0
  8. data/.yardstick.yml +36 -0
  9. data/Appraisals +22 -0
  10. data/CHANGELOG.md +51 -0
  11. data/CONTRIBUTING.md +86 -0
  12. data/Gemfile +5 -1
  13. data/LICENSE +21 -0
  14. data/README.md +435 -52
  15. data/Rakefile +71 -0
  16. data/UPGRADING.md +188 -0
  17. data/gemfiles/rails_7_2.gemfile +11 -0
  18. data/gemfiles/rails_7_2.gemfile.lock +268 -0
  19. data/gemfiles/rails_8_0.gemfile +11 -0
  20. data/gemfiles/rails_8_0.gemfile.lock +271 -0
  21. data/gemfiles/rails_8_1.gemfile +11 -0
  22. data/gemfiles/rails_8_1.gemfile.lock +269 -0
  23. data/gemfiles/standalone.gemfile +8 -0
  24. data/gemfiles/standalone.gemfile.lock +197 -0
  25. data/importu.gemspec +41 -22
  26. data/lib/importu/backends/active_record.rb +171 -0
  27. data/lib/importu/backends/middleware/duplicate_manager_proxy.rb +41 -0
  28. data/lib/importu/backends/middleware/enforce_allowed_actions.rb +52 -0
  29. data/lib/importu/backends/middleware.rb +11 -0
  30. data/lib/importu/backends.rb +103 -0
  31. data/lib/importu/config_dsl.rb +381 -0
  32. data/lib/importu/converter_context.rb +94 -0
  33. data/lib/importu/converters.rb +119 -64
  34. data/lib/importu/definition.rb +23 -0
  35. data/lib/importu/duplicate_manager.rb +88 -0
  36. data/lib/importu/exceptions.rb +135 -4
  37. data/lib/importu/importer.rb +183 -96
  38. data/lib/importu/record.rb +138 -102
  39. data/lib/importu/sources/csv.rb +122 -0
  40. data/lib/importu/sources/json.rb +106 -0
  41. data/lib/importu/sources/ruby.rb +46 -0
  42. data/lib/importu/sources/xml.rb +133 -0
  43. data/lib/importu/sources.rb +13 -0
  44. data/lib/importu/summary.rb +277 -0
  45. data/lib/importu/version.rb +3 -1
  46. data/lib/importu.rb +45 -9
  47. data/spec/fixtures/books-duplicates/README.md +7 -0
  48. data/spec/fixtures/books-duplicates/infile.csv +7 -0
  49. data/spec/fixtures/books-duplicates/model.json +23 -0
  50. data/spec/fixtures/books-duplicates/summary.json +10 -0
  51. data/spec/fixtures/books-valid/README.md +13 -0
  52. data/spec/fixtures/books-valid/infile.csv +4 -0
  53. data/spec/fixtures/books-valid/infile.json +23 -0
  54. data/spec/fixtures/books-valid/infile.xml +21 -0
  55. data/spec/fixtures/books-valid/model.json +23 -0
  56. data/spec/fixtures/books-valid/record.json +26 -0
  57. data/spec/fixtures/books-valid/summary.json +8 -0
  58. data/spec/fixtures/source-empty-file/infile.csv +0 -0
  59. data/spec/fixtures/source-empty-file/infile.json +0 -0
  60. data/spec/fixtures/source-empty-file/infile.xml +0 -0
  61. data/spec/fixtures/source-empty-records/infile.csv +3 -0
  62. data/spec/fixtures/source-empty-records/infile.json +1 -0
  63. data/spec/fixtures/source-empty-records/infile.xml +6 -0
  64. data/spec/fixtures/source-malformed/infile.csv +1 -0
  65. data/spec/fixtures/source-malformed/infile.json +1 -0
  66. data/spec/fixtures/source-malformed/infile.xml +3 -0
  67. data/spec/fixtures/source-no-records/infile.csv +1 -0
  68. data/spec/fixtures/source-no-records/infile.json +1 -0
  69. data/spec/fixtures/source-no-records/infile.xml +3 -0
  70. data/spec/lib/importu/backends/active_record_spec.rb +150 -0
  71. data/spec/lib/importu/backends/middleware/duplicate_manager_proxy_spec.rb +70 -0
  72. data/spec/lib/importu/backends/middleware/enforce_allowed_actions_spec.rb +70 -0
  73. data/spec/lib/importu/backends_spec.rb +170 -0
  74. data/spec/lib/importu/converters_spec.rb +184 -141
  75. data/spec/lib/importu/definition_spec.rb +248 -0
  76. data/spec/lib/importu/duplicate_manager_spec.rb +92 -0
  77. data/spec/lib/importu/exceptions_spec.rb +69 -16
  78. data/spec/lib/importu/import_context_spec.rb +199 -0
  79. data/spec/lib/importu/importer_spec.rb +95 -0
  80. data/spec/lib/importu/integration_spec.rb +221 -0
  81. data/spec/lib/importu/record_spec.rb +130 -80
  82. data/spec/lib/importu/sources/csv_spec.rb +29 -0
  83. data/spec/lib/importu/sources/importer_source_examples.rb +175 -0
  84. data/spec/lib/importu/sources/json_spec.rb +29 -0
  85. data/spec/lib/importu/sources/ruby_spec.rb +102 -0
  86. data/spec/lib/importu/sources/xml_spec.rb +70 -0
  87. data/spec/lib/importu/summary_spec.rb +186 -0
  88. data/spec/spec_helper.rb +91 -7
  89. data/spec/support/active_record.rb +20 -0
  90. data/spec/support/book_importer.rb +31 -0
  91. data/spec/support/dummy_backend.rb +50 -0
  92. data/spec/support/fixtures_helper.rb +43 -0
  93. data/spec/support/matchers/delegate_matcher.rb +14 -8
  94. metadata +173 -100
  95. data/lib/importu/core_ext/array/deep_freeze.rb +0 -7
  96. data/lib/importu/core_ext/deep_freeze.rb +0 -3
  97. data/lib/importu/core_ext/hash/deep_freeze.rb +0 -7
  98. data/lib/importu/core_ext/object/deep_freeze.rb +0 -6
  99. data/lib/importu/core_ext.rb +0 -3
  100. data/lib/importu/dsl.rb +0 -127
  101. data/lib/importu/importer/csv.rb +0 -52
  102. data/lib/importu/importer/json.rb +0 -45
  103. data/lib/importu/importer/xml.rb +0 -55
  104. data/spec/factories/importer.rb +0 -12
  105. data/spec/factories/importer_record.rb +0 -13
  106. data/spec/factories/json_importer.rb +0 -14
  107. data/spec/factories/xml_importer.rb +0 -12
  108. data/spec/lib/importu/dsl_spec.rb +0 -26
  109. data/spec/lib/importu/importer/json_spec.rb +0 -37
  110. data/spec/lib/importu/importer/xml_spec.rb +0 -14
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/backends/active_record"
5
+ require "importu/importer"
6
+ require "importu/sources/csv"
7
+
8
+ RSpec.describe "ActiveRecord Backend", :active_record do
9
+ let(:source) { Importu::Sources::CSV.new(infile("books-valid", :csv)) }
10
+ subject(:importer) { importer_class.new(source) }
11
+
12
+ let!(:model) do
13
+ stub_const("Book", Class.new(ActiveRecord::Base) do
14
+ serialize :authors, type: Array
15
+ validates :title, :authors, :isbn10, :release_date, presence: true
16
+ validates :isbn10, length: { is: 10 }, uniqueness: true
17
+ end)
18
+ end
19
+
20
+ let(:importer_class) do
21
+ Class.new(BookImporter) do
22
+ model "Book"
23
+ end
24
+ end
25
+
26
+ around(:each) do |example|
27
+ require "database_cleaner-active_record"
28
+ DatabaseCleaner.cleaning { example.run }
29
+ end
30
+
31
+ describe "#import!" do
32
+ let(:models_json) do
33
+ serialized = Book.all.to_json(except: [:id, :created_at, :updated_at])
34
+ JSON.parse(serialized)
35
+ end
36
+
37
+ it "imports new book records" do
38
+ expect { importer.import! }.to change { Book.count }.by(3)
39
+ end
40
+
41
+ it "correctly summarizes import statistics" do
42
+ summary = importer.import!
43
+ expected_summary_json!("books-valid", summary)
44
+ end
45
+
46
+ it "correctly saves imported data in the model" do
47
+ importer.import!
48
+ expect(models_json).to match_array expected_model_json("books-valid")
49
+ end
50
+
51
+ context "when definition includes non-abstract fields not on model" do
52
+ let(:importer_class) { Class.new(super()) { fields(:foo, :bar) { "blah" } } }
53
+
54
+ it "raises an UnassignableFields error" do
55
+ expect { importer.import! }.to raise_error(Importu::UnassignableFields)
56
+ end
57
+ end
58
+
59
+ context "when model has validation errors" do
60
+ let(:importer_class) { Class.new(super()) { field(:isbn10) { "foo" } } }
61
+
62
+ it "marks each record creation as invalid" do
63
+ summary = importer.import!
64
+ expect(summary.created).to eq 0
65
+ expect(summary.invalid).to eq 3
66
+ end
67
+
68
+ it "includes validation errors in summary" do
69
+ summary = importer.import!
70
+ expect(summary.validation_errors.any? {|msg, _| msg =~ /isbn10/i }).to be true
71
+ end
72
+ end
73
+
74
+ context "when creating records" do
75
+ context "when create actions are not allowed" do
76
+ let(:importer_class) { Class.new(super()) { allow_actions :update } }
77
+
78
+ it "marks each record creation as inavlid" do
79
+ summary = importer.import!
80
+ expect(summary.created).to eq 0
81
+ expect(summary.invalid).to eq 3
82
+ end
83
+ end
84
+
85
+ context "when duplicate records in source file" do
86
+ let(:source) { Importu::Sources::CSV.new(infile("books-duplicates", :csv)) }
87
+
88
+ it "marks the three duplicate records as invalid" do
89
+ summary = importer.import!
90
+ expected_summary_json!("books-duplicates", summary)
91
+ end
92
+ end
93
+ end
94
+
95
+ context "when updating records" do
96
+ before { importer.import! }
97
+
98
+ context "and there are no changes" do
99
+ it "marks each record as unchanged" do
100
+ summary = importer.import!
101
+ expect(summary.unchanged).to eq 3
102
+ end
103
+ end
104
+
105
+ context "when updates actions are not allowed" do
106
+ let(:importer_class) { Class.new(super()) { allow_actions :create } }
107
+
108
+ it "marks each record update as inavlid" do
109
+ summary = importer.import!
110
+ expect(summary.updated).to eq 0
111
+ expect(summary.invalid).to eq 3
112
+ end
113
+ end
114
+
115
+ context "when a find_by block is used" do
116
+ let(:importer_class) do
117
+ Class.new(super()) do
118
+ find_by do |record|
119
+ find_by(title: record[:title])
120
+ end
121
+ end
122
+ end
123
+
124
+ it "executes the find_by block in context of the model" do
125
+ expect(Book)
126
+ .to receive(:find_by).with(title: anything)
127
+ .at_least(3).times
128
+ .and_call_original
129
+
130
+ summary = importer.import!
131
+ expect(summary.unchanged).to eq 3
132
+ end
133
+ end
134
+ end
135
+
136
+ context "when a before_save callback is defined" do
137
+ let(:importer_class) do
138
+ Class.new(super()) do
139
+ before_save { object.title = object.title.upcase }
140
+ end
141
+ end
142
+
143
+ it "runs callback before saving" do
144
+ importer.import!
145
+ Book.first.title == Book.first.title.upcase
146
+ end
147
+ end
148
+ end
149
+
150
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/backends/middleware/duplicate_manager_proxy"
5
+ require "importu/definition"
6
+
7
+ RSpec.describe Importu::Backends::Middleware::DuplicateManagerProxy do
8
+ subject(:middleware) { described_class.new(dummy_backend, **backend_config) }
9
+
10
+ let(:dummy_backend) { DummyBackend.new(**backend_config) }
11
+ let(:backend_config) { definition.config[:backend] }
12
+
13
+ let(:definition) do
14
+ Class.new(Importu::Definition) do
15
+ fields :foo, :bar, :baz
16
+ find_by :foo, :bar
17
+ end
18
+ end
19
+
20
+ describe "#create" do
21
+ it "returns the status and object from the backend" do
22
+ status, object = middleware.create(foo: 1, bar: 1, baz: 1)
23
+ expect(status).to eq :created
24
+ expect(object).to include(foo: 1, bar: 1, baz: 1)
25
+ end
26
+
27
+ it "perform duplicate detection on the record" do
28
+ middleware.create(foo: 1, bar: 1, baz: 1)
29
+ expect { middleware.create(foo: 2, bar: 1, baz: 2) } # :baz is a dupe find_by
30
+ .to raise_error(Importu::DuplicateRecord)
31
+ end
32
+
33
+ it "records created object for subsequent dupe detection" do
34
+ middleware.create(foo: 1, bar: 1, baz: 1)
35
+ expect { middleware.create(foo: 2, bar: 1, baz: 2) }
36
+ .to raise_error(Importu::DuplicateRecord)
37
+ end
38
+ end
39
+
40
+ describe "#update" do
41
+ it "returns the status and object from the backend" do
42
+ _, object = dummy_backend.create(foo: 1, bar: 1, baz: 1)
43
+ status, new_object = middleware.update({foo: 2, bar: 2, baz: 2}, object)
44
+
45
+ expect(status).to eq :updated
46
+ expect(new_object).to include(foo: 2, bar: 2, baz: 2)
47
+ end
48
+
49
+ it "perform duplicate detection on the record" do
50
+ _status, object = middleware.create(foo: 1, bar: 1, baz: 1)
51
+
52
+ expect(dummy_backend).to_not receive(:update)
53
+ expect { middleware.update({foo: 1, bar: 2, baz: 2}, object) } # :foo is a dupe
54
+ .to raise_error(Importu::DuplicateRecord)
55
+ end
56
+
57
+ it "performs duplicate detection on object" do
58
+ # Existing db record, never accessed through dupe detector
59
+ _status, object = dummy_backend.create(foo: 1, bar: 1, baz: 1)
60
+
61
+ # Update via :foo, first encounter of k/v and first encounter of object
62
+ expect { middleware.update({foo: 1, bar: 2, baz: 3}, object) }.to_not raise_error
63
+
64
+ # Update via :bar, first encounter of k/v, but second encounter of object
65
+ expect { middleware.update({foo: 2, bar: 1, baz: 3}, object) }
66
+ .to raise_error(Importu::DuplicateRecord)
67
+ end
68
+ end
69
+
70
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/backends/middleware/enforce_allowed_actions"
5
+ require "importu/definition"
6
+
7
+ RSpec.describe Importu::Backends::Middleware::EnforceAllowedActions do
8
+ subject(:middleware) { described_class.new(dummy_backend, **backend_config) }
9
+
10
+ let(:dummy_backend) { DummyBackend.new(**backend_config) }
11
+ let(:backend_config) { definition.config[:backend] }
12
+
13
+ let(:definition) do
14
+ Class.new(Importu::Definition) do
15
+ allow_actions nil
16
+ fields :foo, :bar, :baz
17
+ end
18
+ end
19
+
20
+ describe "#create" do
21
+ context "when :create action is allowed" do
22
+ let(:definition) { Class.new(super()) { allow_actions :create } }
23
+
24
+ it "returns the status and object from the backend" do
25
+ status, object = middleware.create(foo: 1, bar: 1, baz: 1)
26
+ expect(status).to eq :created
27
+ expect(object).to include(foo: 1, bar: 1, baz: 1)
28
+ end
29
+ end
30
+
31
+ context "when :create action is not allowed" do
32
+ it "raises an InvalidError exception" do
33
+ expect { middleware.create(foo: 1, bar: 1, baz: 1) }
34
+ .to raise_error(Importu::InvalidRecord)
35
+ end
36
+
37
+ it "includes guidance in the error message" do
38
+ expect { middleware.create(foo: 1, bar: 1, baz: 1) }
39
+ .to raise_error(/allow_actions :create/)
40
+ end
41
+ end
42
+ end
43
+
44
+ describe "#update" do
45
+ let(:object) { _, object = dummy_backend.create(foo: 1, bar: 1, baz: 1); object }
46
+
47
+ context "when :update action is allowed" do
48
+ let(:definition) { Class.new(super()) { allow_actions :update } }
49
+
50
+ it "returns the status and object from the backend" do
51
+ status, new_object = middleware.update({foo: 2, bar: 2, baz: 2}, object)
52
+ expect(status).to eq :updated
53
+ expect(new_object).to include(foo: 2, bar: 2, baz: 2)
54
+ end
55
+ end
56
+
57
+ context "when :update action is not allowed" do
58
+ it "raises an InvalidError exception" do
59
+ expect { middleware.update({foo: 2}, object) }
60
+ .to raise_error(Importu::InvalidRecord)
61
+ end
62
+
63
+ it "includes guidance in the error message" do
64
+ expect { middleware.update({foo: 2}, object) }
65
+ .to raise_error(/allow_actions :create, :update/)
66
+ end
67
+ end
68
+ end
69
+
70
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ require "importu/backends"
5
+ require "importu/definition"
6
+
7
+ RSpec.describe Importu::Backends do
8
+ subject(:registry) { described_class.new }
9
+
10
+ describe ".registry" do
11
+ it "returns a registry singleton" do
12
+ expect(described_class.registry).to be described_class.registry
13
+ end
14
+
15
+ it "returns a registry-like object" do
16
+ expect(described_class.registry).to respond_to("register")
17
+ expect(described_class.registry).to respond_to("lookup")
18
+ end
19
+ end
20
+
21
+ describe "#from_config!" do
22
+ let!(:model) { stub_const("MyModelGuest", Class.new) }
23
+ let(:config) { definition.config[:backend] }
24
+
25
+ context "when a model backend is specified" do
26
+ let(:definition) do
27
+ Class.new(Importu::Definition) do
28
+ model "MyModelGuest", backend: :cherry_scones
29
+ end
30
+ end
31
+
32
+ context "and the backend is registered" do
33
+ let!(:backend) { registry.register(:cherry_scones, Class.new) }
34
+
35
+ it "returns the backend" do
36
+ expect(registry.from_config!(**config)).to eq backend
37
+ end
38
+ end
39
+
40
+ context "and the backend is not registered" do
41
+ it "raises a BackendNotRegistered error" do
42
+ expect { registry.from_config!(**config) }
43
+ .to raise_error(Importu::BackendNotRegistered)
44
+ end
45
+ end
46
+ end
47
+
48
+ context "when backend is :auto" do
49
+ let(:supported) { Class.new { def self.supported_by_model?(*); true; end } }
50
+
51
+ let(:definition) do
52
+ Class.new(Importu::Definition) do
53
+ model "MyModelGuest", backend: :auto
54
+ end
55
+ end
56
+
57
+ before { registry.register(:backend1, supported) }
58
+
59
+ it "auto-detects the backend" do
60
+ expect(registry.from_config!(**config)).to eq supported
61
+ end
62
+ end
63
+
64
+ context "when a model backend is not specified" do
65
+ let(:supported) { Class.new { def self.supported_by_model?(*); true; end } }
66
+ let(:unsupported) { Class.new { def self.supported_by_model?(*); false; end } }
67
+
68
+ let(:definition) do
69
+ Class.new(Importu::Definition) do
70
+ model "MyModelGuest"
71
+ end
72
+ end
73
+
74
+ context "and no backends support the model" do
75
+ before { registry.register(:backend1, unsupported) }
76
+
77
+ it "raises a BackendMatch error" do
78
+ expect { registry.from_config!(**config) }
79
+ .to raise_error(Importu::BackendMatchError)
80
+ end
81
+ end
82
+
83
+ context "and exactly one backend supports the model" do
84
+ before do
85
+ registry.register(:backend1, unsupported)
86
+ registry.register(:backend2, supported)
87
+ end
88
+
89
+ it "returns the supported backend" do
90
+ expect(registry.from_config!(**config)).to eq supported
91
+ end
92
+ end
93
+
94
+ context "and multiple backends support the model" do
95
+ before do
96
+ registry.register(:backend1, unsupported)
97
+ registry.register(:backend2, supported)
98
+ registry.register(:backend3, supported)
99
+ end
100
+
101
+ it "raises a BackendMatch error" do
102
+ expect { registry.from_config!(**config) }
103
+ .to raise_error(Importu::BackendMatchError)
104
+ end
105
+ end
106
+
107
+ context "and a backend raises an exception during checking" do
108
+ let(:broken) { Class.new { def self.supported_by_model?(*); raise :hell; end } }
109
+
110
+ before do
111
+ registry.register(:backend1, broken)
112
+ registry.register(:backend2, unsupported)
113
+ registry.register(:backend3, supported)
114
+ end
115
+
116
+ it "ignores the backend" do
117
+ expect(registry.from_config!(**config)).to eq supported
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ describe "#lookup" do
124
+ let(:marvelous_impl) { Class.new }
125
+ before { registry.register(:marvelous, marvelous_impl) }
126
+
127
+ it "raises a BackendNotRegistered exception if backend not found" do
128
+ expect { registry.lookup(:foo) }
129
+ .to raise_error(Importu::BackendNotRegistered)
130
+ end
131
+
132
+ it "supports lookups by symbol-based name" do
133
+ expect(registry.lookup(:marvelous)).to eq marvelous_impl
134
+ end
135
+ it "supports lookups by string-based name" do
136
+ expect(registry.lookup("marvelous")).to eq marvelous_impl
137
+ end
138
+ end
139
+
140
+ describe "#names" do
141
+ it "returns the names of all registered backends" do
142
+ registry.register(:backend1, Class.new)
143
+ registry.register(:backend2, Class.new)
144
+ registry.register(:backend3, Class.new)
145
+ expect(registry.names).to include(:backend1, :backend2, :backend3)
146
+ end
147
+ end
148
+
149
+ describe "#register" do
150
+ let(:backend1) { Class.new }
151
+ let(:backend2) { Class.new }
152
+
153
+ it "registers the backend" do
154
+ registry.register(:backend1, backend1)
155
+ expect(registry.lookup(:backend1)).to eq backend1
156
+ end
157
+
158
+ it "returns the backend that was registered" do
159
+ expect(registry.register(:backend1, backend1)).to eq backend1
160
+ end
161
+
162
+ it "allows registering multiple backends" do
163
+ registry.register(:backend1, backend1)
164
+ registry.register(:backend2, backend2)
165
+ expect(registry.lookup(:backend1)).to eq backend1
166
+ expect(registry.lookup(:backend2)).to eq backend2
167
+ end
168
+ end
169
+
170
+ end