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
data/lib/importu/dsl.rb DELETED
@@ -1,127 +0,0 @@
1
- require 'active_support/core_ext/module/delegation'
2
- require 'active_support/core_ext/hash/deep_dup'
3
- require 'active_support/core_ext/hash/keys'
4
- require 'active_support/concern'
5
-
6
- require 'importu/core_ext/deep_freeze'
7
-
8
- # importer definition examples:
9
- # allow_actions :create
10
- # allow_actions :create, :update
11
- #
12
- # find_by :id # match against a single field, :id (default)
13
- # find_by [:name, :date] # match against multiple fields
14
- # find_by :id, [:name, :date] # try name/date combo if no id match
15
- # find_by nil # never try to look up records, assume :create
16
- # find_by do |record|
17
- # scoped.where(:foo => record[:name].downcase)
18
- # end
19
- #
20
- # field :field1, :label => 'Field 1'
21
- # fields :field1, :field2, :field3
22
- # fields :field1, :field2, convert_to(:integer)
23
- # fields :field1, :field2 do |data,definition|
24
- # Time.strptime(data[definition[:label]], '%d/%m/%Y')
25
- # end
26
- #
27
- # allow actions:
28
- # :create - if an existing record can't be found, we can create it
29
- # :update - if an existing record found, update its attributes
30
- #
31
- # field(s) definition options:
32
- # :label - header/label/key/element name used in input file (default: field name)
33
- # :required - must be present in input file (values can be blank, default: true)
34
-
35
- require 'active_support/concern'
36
-
37
- module Importu::Dsl
38
- extend ActiveSupport::Concern
39
-
40
- included do
41
- config_dsl :record_class, :default => Importu::Record
42
- config_dsl :model, :description
43
- config_dsl :allowed_actions, :default => [:create]
44
- config_dsl :finder_fields, :default => [[:id]]
45
- config_dsl :definitions, :default => {}
46
- config_dsl :preprocessor, :postprocessor
47
- config_dsl :converters, :default => {}
48
- end
49
-
50
- module ClassMethods
51
- def allow_actions(*actions)
52
- @allowed_actions = actions
53
- end
54
-
55
- def find_by(*field_groups, &block)
56
- @finder_fields = block ? [block] : field_groups.map {|g|g&&[*g]}.compact
57
- end
58
-
59
- def fields(*fields, &block)
60
- block = fields.pop if fields.last.kind_of?(Proc)
61
- options = fields.extract_options!.symbolize_keys!
62
-
63
- @definitions ||= definitions.deep_dup
64
- fields.compact.each do |field_name|
65
- definition = (@definitions[field_name]||{}).merge(options)
66
-
67
- definition[:name] = field_name
68
- definition[:label] ||= (options['label'] || field_name).to_s
69
- definition[:required] = true unless definition.key?(:required)
70
- definition[:create] = true unless definition.key?(:create)
71
- definition[:update] = true unless definition.key?(:update)
72
-
73
- definition[:converter] = block if block
74
- definition[:converter] ||= converters[:clean]
75
-
76
- @definitions[field_name] = definition
77
- end
78
-
79
- return
80
- end
81
-
82
- alias_method :field, :fields
83
-
84
- def preprocess(&block)
85
- # gets executed just before record converted to object
86
- @preprocessor = block
87
- end
88
-
89
- def postprocess(&block)
90
- # gets executed just after record converted to object
91
- @postprocessor = block
92
- end
93
-
94
- def converter(name, &block)
95
- @converters = converters.merge(name => block)
96
- end
97
-
98
- def convert_to(type, options = {})
99
- converters[type] # FIXME: raise error if not found?
100
- end
101
-
102
- def config_dsl(*methods)
103
- options = methods.extract_options!
104
- options.assert_valid_keys(:default)
105
- default = (options[:default] || nil).deep_freeze
106
-
107
- methods.each do |m|
108
- instance_variable_set("@#{m}", default)
109
-
110
- singleton_class.send(:define_method, m) do |*args,&block|
111
- if block || !args.empty?
112
- val = (block ? instance_eval(&block) : args[0])
113
- instance_variable_set("@#{m}", val.deep_freeze)
114
- else
115
- instance_variable_defined?("@#{m}") \
116
- ? instance_variable_get("@#{m}")
117
- : superclass.send(m)
118
- end
119
- end
120
- end
121
-
122
- # make dsl methods available to importer instances
123
- delegate *methods, :to => :singleton_class
124
- end
125
- end
126
-
127
- end
@@ -1,52 +0,0 @@
1
- require 'csv'
2
-
3
- class Importu::Importer::Csv < Importu::Importer
4
- def initialize(infile, options = {})
5
- super
6
-
7
- @csv_options = {
8
- :headers => true,
9
- :return_headers => true,
10
- :write_headers => true,
11
- :skip_blanks => true,
12
- }.merge(options[:csv_options]||{})
13
-
14
- @reader = ::CSV.new(@infile, @csv_options)
15
- @header = @reader.readline
16
- @data_pos = @infile.pos
17
- end
18
-
19
- def records
20
- @infile.pos = @data_pos
21
- Enumerator.new do |yielder|
22
- @reader.each do |row|
23
- yielder.yield record_class.new(self, row.to_hash, row)
24
- end
25
- end
26
- end
27
-
28
- def import_record(record, finder_scope, &block)
29
- begin
30
- super
31
- rescue Importu::MissingField => e
32
- # if one record missing field, all are, major error
33
- raise Importu::InvalidInput, "missing required field: #{e.message}"
34
- rescue Importu::InvalidRecord => e
35
- write_error(record.raw_data, e.message)
36
- end
37
- end
38
-
39
-
40
- private
41
-
42
- def write_error(data, msg)
43
- unless @writer
44
- @writer = ::CSV.new(outfile, @csv_options)
45
- @header['_errors'] = '_errors'
46
- @writer << @header
47
- end
48
-
49
- data['_errors'] = msg
50
- @writer << data
51
- end
52
- end
@@ -1,45 +0,0 @@
1
- require 'multi_json'
2
-
3
- class Importu::Importer::Json < Importu::Importer
4
- def initialize(infile, options = {})
5
- super
6
-
7
- begin
8
- infile.rewind
9
- @reader = MultiJson.load(infile.read)
10
- rescue MultiJson::DecodeError => e
11
- raise Importu::InvalidInput, e.message
12
- end
13
- end
14
-
15
- def import!(finder_scope = nil, &block)
16
- result = super
17
- outfile.write(JSON.pretty_generate(@error_records)) if @invalid > 0
18
- result
19
- end
20
-
21
- def records(&block)
22
- enum = Enumerator.new do |yielder|
23
- @reader.each_with_index do |data,idx|
24
- yielder.yield record_class.new(self, data, data)
25
- end
26
- end
27
- end
28
-
29
- def import_record(record, finder_scope, &block)
30
- begin
31
- super
32
- rescue Importu::InvalidRecord => e
33
- write_error(record.raw_data, e.message)
34
- end
35
- end
36
-
37
-
38
- private
39
-
40
- def write_error(data, msg)
41
- @error_records ||= []
42
- @error_records << data.merge('_errors' => msg)
43
- end
44
-
45
- end
@@ -1,55 +0,0 @@
1
- require 'nokogiri'
2
-
3
- class Importu::Importer::Xml < Importu::Importer
4
- config_dsl :records_xpath
5
-
6
- def initialize(infile, options = {})
7
- super
8
-
9
- xml_options = {}.merge(options[:xml_options]||{})
10
- if reader.errors.any?
11
- raise Importu::InvalidInput, reader.errors.join("\n")
12
- end
13
- end
14
-
15
- def reader
16
- @reader ||= Nokogiri::XML(infile)
17
- end
18
-
19
- def import!(finder_scope = nil, &block)
20
- reader.xpath('//_errors').remove
21
- result = super
22
- outfile.write(reader) if @invalid > 0
23
- result
24
- end
25
-
26
- def records
27
- Enumerator.new do |yielder|
28
- reader.xpath(records_xpath).each do |xml|
29
- data = Hash[xml.elements.map {|e| [e.name, e.content]}]
30
- yielder.yield record_class.new(self, data, xml)
31
- end
32
- end
33
- end
34
-
35
- def import_record(record, finder_scope, &block)
36
- begin
37
- super
38
- record.raw_data.remove
39
- rescue Importu::InvalidRecord => e
40
- add_xml_record_error(record.raw_data, e.message)
41
- end
42
- end
43
-
44
-
45
- private
46
-
47
- def add_xml_record_error(xml, text)
48
- unless node = xml.xpath('./_errors').first
49
- node = Nokogiri::XML::Node.new '_errors', reader
50
- xml.add_child(node)
51
- end
52
- node.content = text + ','
53
- end
54
-
55
- end
@@ -1,12 +0,0 @@
1
- FactoryGirl.define do
2
- factory :importer, :class => Importu::Importer do
3
- initialize_with do
4
- Importu::Importer.new(infile, options)
5
- end
6
-
7
- ignore do
8
- infile { StringIO.new }
9
- options { Hash.new }
10
- end
11
- end
12
- end
@@ -1,13 +0,0 @@
1
- FactoryGirl.define do
2
- factory :importer_record, :class => Importu::Record do
3
- initialize_with do
4
- Importu::Record.new(importer, data, raw_data)
5
- end
6
-
7
- ignore do
8
- importer { build(:importer) }
9
- data { Hash.new }
10
- raw_data { Hash.new }
11
- end
12
- end
13
- end
@@ -1,14 +0,0 @@
1
- FactoryGirl.define do
2
- factory :json_importer, :class => Importu::Importer::Json do
3
- initialize_with do
4
- infile = StringIO.new(data) if data
5
- Importu::Importer::Json.new(infile, options)
6
- end
7
-
8
- ignore do
9
- data nil # string version of input file
10
- infile { StringIO.new("[]") }
11
- options { Hash.new }
12
- end
13
- end
14
- end
@@ -1,12 +0,0 @@
1
- FactoryGirl.define do
2
- factory :xml_importer, :class => Importu::Importer::Xml do
3
- initialize_with do
4
- Importu::Importer::Xml.new(infile, options)
5
- end
6
-
7
- ignore do
8
- infile { StringIO.new("<r/>") }
9
- options { Hash.new }
10
- end
11
- end
12
- end
@@ -1,26 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe Importu::Importer do
4
- describe "::record_class" do
5
- it "returns Importu::Record by default" do
6
- Importu::Importer.record_class.should eq Importu::Record
7
- end
8
-
9
- it "can be overridden globally" do
10
- custom_record_class = Class.new(Importu::Record)
11
- orig = Importu::Importer.record_class
12
- Importu::Importer.record_class custom_record_class
13
- Importu::Importer.record_class.should eq custom_record_class
14
- Importu::Importer.record_class orig
15
- end
16
-
17
- it "can be overridden in a subclass" do
18
- custom_record_class = Class.new(Importu::Record)
19
- klass = Class.new(Importu::Importer) do
20
- record_class custom_record_class
21
- end
22
-
23
- klass.record_class.should eq custom_record_class
24
- end
25
- end
26
- end
@@ -1,37 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe Importu::Importer::Json do
4
- subject(:importer) { build(:json_importer, :data => data) }
5
-
6
- context "input file is blank" do
7
- let(:data) { "" }
8
-
9
- it "raises an InvalidInput exception" do
10
- expect { importer }.to raise_error Importu::InvalidInput
11
- end
12
- end
13
-
14
- context "non-array root elements" do
15
- %w({}, "foo", 3, 3.7, false, nil).each do |data|
16
- it "raises InvalidInput exception if root is #{data}" do
17
- expect { build(:json_importer, :data => "") }.to raise_error Importu::InvalidInput
18
- end
19
- end
20
- end
21
-
22
- context "input file is []" do
23
- let(:data) { "[]" }
24
-
25
- it "treats file as having 0 records" do
26
- importer.records.should have(0).items
27
- end
28
- end
29
-
30
- context "input file is [{},{}]" do
31
- let(:data) { "[{},{}]" }
32
-
33
- it "treats file as having 2 records" do
34
- importer.records.should have(2).items
35
- end
36
- end
37
- end
@@ -1,14 +0,0 @@
1
- require 'spec_helper'
2
-
3
- describe Importu::Importer::Xml do
4
- subject(:importer) { build(:xml_importer, :infile => infile) }
5
-
6
- context "input file is blank" do
7
- let(:infile) { StringIO.new }
8
-
9
- it "raises an InvalidInput exception" do
10
- expect { importer }.to raise_error Importu::InvalidInput
11
- end
12
- end
13
-
14
- end