stockboy 0.5.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 (112) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +9 -0
  3. data/.rspec +5 -0
  4. data/.yardopts +7 -0
  5. data/CHANGELOG.md +24 -0
  6. data/Gemfile +12 -0
  7. data/Guardfile +10 -0
  8. data/LICENSE +21 -0
  9. data/README.md +293 -0
  10. data/Rakefile +30 -0
  11. data/lib/stockboy.rb +80 -0
  12. data/lib/stockboy/attribute.rb +11 -0
  13. data/lib/stockboy/attribute_map.rb +74 -0
  14. data/lib/stockboy/candidate_record.rb +130 -0
  15. data/lib/stockboy/configuration.rb +62 -0
  16. data/lib/stockboy/configurator.rb +176 -0
  17. data/lib/stockboy/dsl.rb +68 -0
  18. data/lib/stockboy/exceptions.rb +3 -0
  19. data/lib/stockboy/filter.rb +58 -0
  20. data/lib/stockboy/filter_chain.rb +41 -0
  21. data/lib/stockboy/filters.rb +11 -0
  22. data/lib/stockboy/filters/missing_email.rb +37 -0
  23. data/lib/stockboy/job.rb +241 -0
  24. data/lib/stockboy/mapped_record.rb +59 -0
  25. data/lib/stockboy/provider.rb +238 -0
  26. data/lib/stockboy/providers.rb +11 -0
  27. data/lib/stockboy/providers/file.rb +135 -0
  28. data/lib/stockboy/providers/ftp.rb +205 -0
  29. data/lib/stockboy/providers/http.rb +123 -0
  30. data/lib/stockboy/providers/imap.rb +290 -0
  31. data/lib/stockboy/providers/soap.rb +120 -0
  32. data/lib/stockboy/railtie.rb +28 -0
  33. data/lib/stockboy/reader.rb +59 -0
  34. data/lib/stockboy/readers.rb +11 -0
  35. data/lib/stockboy/readers/csv.rb +115 -0
  36. data/lib/stockboy/readers/fixed_width.rb +121 -0
  37. data/lib/stockboy/readers/spreadsheet.rb +144 -0
  38. data/lib/stockboy/readers/xml.rb +155 -0
  39. data/lib/stockboy/registry.rb +42 -0
  40. data/lib/stockboy/source_record.rb +43 -0
  41. data/lib/stockboy/string_pool.rb +35 -0
  42. data/lib/stockboy/template_file.rb +44 -0
  43. data/lib/stockboy/translations.rb +70 -0
  44. data/lib/stockboy/translations/boolean.rb +58 -0
  45. data/lib/stockboy/translations/date.rb +41 -0
  46. data/lib/stockboy/translations/decimal.rb +33 -0
  47. data/lib/stockboy/translations/default_empty_string.rb +38 -0
  48. data/lib/stockboy/translations/default_false.rb +41 -0
  49. data/lib/stockboy/translations/default_nil.rb +38 -0
  50. data/lib/stockboy/translations/default_true.rb +41 -0
  51. data/lib/stockboy/translations/default_zero.rb +41 -0
  52. data/lib/stockboy/translations/integer.rb +33 -0
  53. data/lib/stockboy/translations/string.rb +33 -0
  54. data/lib/stockboy/translations/time.rb +41 -0
  55. data/lib/stockboy/translations/uk_date.rb +51 -0
  56. data/lib/stockboy/translations/us_date.rb +51 -0
  57. data/lib/stockboy/translator.rb +66 -0
  58. data/lib/stockboy/version.rb +3 -0
  59. data/spec/fixtures/.gitkeep +0 -0
  60. data/spec/fixtures/files/a_garbage.csv +1 -0
  61. data/spec/fixtures/files/test_data-20120101.csv +1 -0
  62. data/spec/fixtures/files/test_data-20120202.csv +1 -0
  63. data/spec/fixtures/files/z_garbage.csv +1 -0
  64. data/spec/fixtures/jobs/test_job.rb +1 -0
  65. data/spec/fixtures/soap/get_list/fault.xml +8 -0
  66. data/spec/fixtures/soap/get_list/success.xml +18 -0
  67. data/spec/fixtures/spreadsheets/test_data.xls +0 -0
  68. data/spec/fixtures/spreadsheets/test_row_options.xls +0 -0
  69. data/spec/fixtures/xml/body.xml +14 -0
  70. data/spec/spec_helper.rb +28 -0
  71. data/spec/stockboy/attribute_map_spec.rb +59 -0
  72. data/spec/stockboy/attribute_spec.rb +11 -0
  73. data/spec/stockboy/candidate_record_spec.rb +150 -0
  74. data/spec/stockboy/configuration_spec.rb +28 -0
  75. data/spec/stockboy/configurator_spec.rb +127 -0
  76. data/spec/stockboy/filter_chain_spec.rb +40 -0
  77. data/spec/stockboy/filter_spec.rb +41 -0
  78. data/spec/stockboy/filters/missing_email_spec.rb +26 -0
  79. data/spec/stockboy/filters_spec.rb +38 -0
  80. data/spec/stockboy/job_spec.rb +238 -0
  81. data/spec/stockboy/mapped_record_spec.rb +30 -0
  82. data/spec/stockboy/provider_spec.rb +34 -0
  83. data/spec/stockboy/providers/file_spec.rb +116 -0
  84. data/spec/stockboy/providers/ftp_spec.rb +143 -0
  85. data/spec/stockboy/providers/http_spec.rb +94 -0
  86. data/spec/stockboy/providers/imap_spec.rb +76 -0
  87. data/spec/stockboy/providers/soap_spec.rb +107 -0
  88. data/spec/stockboy/providers_spec.rb +38 -0
  89. data/spec/stockboy/readers/csv_spec.rb +68 -0
  90. data/spec/stockboy/readers/fixed_width_spec.rb +52 -0
  91. data/spec/stockboy/readers/spreadsheet_spec.rb +121 -0
  92. data/spec/stockboy/readers/xml_spec.rb +94 -0
  93. data/spec/stockboy/readers_spec.rb +30 -0
  94. data/spec/stockboy/source_record_spec.rb +19 -0
  95. data/spec/stockboy/template_file_spec.rb +30 -0
  96. data/spec/stockboy/translations/boolean_spec.rb +48 -0
  97. data/spec/stockboy/translations/date_spec.rb +38 -0
  98. data/spec/stockboy/translations/decimal_spec.rb +23 -0
  99. data/spec/stockboy/translations/default_empty_string_spec.rb +32 -0
  100. data/spec/stockboy/translations/default_false_spec.rb +25 -0
  101. data/spec/stockboy/translations/default_nil_spec.rb +32 -0
  102. data/spec/stockboy/translations/default_true_spec.rb +25 -0
  103. data/spec/stockboy/translations/default_zero_spec.rb +32 -0
  104. data/spec/stockboy/translations/integer_spec.rb +22 -0
  105. data/spec/stockboy/translations/string_spec.rb +22 -0
  106. data/spec/stockboy/translations/time_spec.rb +27 -0
  107. data/spec/stockboy/translations/uk_date_spec.rb +37 -0
  108. data/spec/stockboy/translations/us_date_spec.rb +37 -0
  109. data/spec/stockboy/translations_spec.rb +55 -0
  110. data/spec/stockboy/translator_spec.rb +27 -0
  111. data/stockboy.gemspec +32 -0
  112. metadata +305 -0
@@ -0,0 +1,40 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/filter_chain'
3
+
4
+ module Stockboy
5
+ describe FilterChain do
6
+
7
+ let(:filter1) { double("Filter") }
8
+ let(:filter2) { double("Filter", reset: true) }
9
+
10
+ it "initializes keys and values from a hash" do
11
+ chain = FilterChain.new(no_angels: filter1, no_daleks: filter2)
12
+ chain.keys.should == [:no_angels, :no_daleks]
13
+ chain.values.should == [filter1, filter2]
14
+ end
15
+
16
+ describe "#reset" do
17
+ let(:chain) { FilterChain.new(no_angels: filter1, no_daleks: filter2) }
18
+
19
+ it "calls reset on all members" do
20
+ filter2.should_receive(:reset)
21
+ chain.reset
22
+ end
23
+
24
+ it "returns a hash of filter keys to empty arrays" do
25
+ empty_records = chain.reset
26
+ empty_records.should == {no_angels: [], no_daleks: []}
27
+ end
28
+ end
29
+
30
+ describe "#prepend" do
31
+ it "adds filters to the front of the chain" do
32
+ chain = FilterChain.new(filter1: double)
33
+ chain.prepend(filter0: double)
34
+ chain.keys.should == [:filter0, :filter1]
35
+ end
36
+ end
37
+
38
+ end
39
+ end
40
+
@@ -0,0 +1,41 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/filter'
3
+
4
+ class FishFilter < Stockboy::Filter
5
+ def filter(raw, translated)
6
+ return true if raw.species =~ /ichtus/
7
+ return true if translated.species =~ /fish/
8
+ return nil
9
+ end
10
+ end
11
+
12
+ module Stockboy
13
+ describe Filter do
14
+
15
+ describe "#call" do
16
+ let(:empty_values) { double.as_null_object }
17
+ subject(:filter) { FishFilter.new }
18
+
19
+ context "matching raw value" do
20
+ it "returns true for match" do
21
+ filter.call(double(species:"babylichtus"), empty_values).should be_true
22
+ end
23
+
24
+ it "returns false for no match" do
25
+ filter.call(double(species:"triceratops"), empty_values).should be_false
26
+ end
27
+ end
28
+
29
+ context "matching translated value" do
30
+ it "returns true for match" do
31
+ filter.call(empty_values, double(species:"babelfish")).should be_true
32
+ end
33
+
34
+ it "returns false for no match" do
35
+ filter.call(empty_values, double(species:"rhinoceros")).should be_false
36
+ end
37
+ end
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,26 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/filters/missing_email'
3
+
4
+ describe Stockboy::Filters::MissingEmail do
5
+ subject(:filter) { described_class.new(:e) }
6
+ it 'allows email addresses' do
7
+ record = OpenStruct.new(e: 'me@example.com')
8
+ filter.call(record, record).should be_false
9
+ end
10
+
11
+ it 'catches empty strings' do
12
+ record = OpenStruct.new(e: '')
13
+ filter.call(record, record).should be_true
14
+ end
15
+
16
+ it 'catches hyphen placeholders' do
17
+ record = OpenStruct.new(e: '-')
18
+ filter.call(record, record).should be_true
19
+ end
20
+
21
+ it 'uses translated output value' do
22
+ input = OpenStruct.new(e: '', other: 'me@example.com')
23
+ output = OpenStruct.new(e: input.other)
24
+ filter.call(input, output).should be_false
25
+ end
26
+ end
@@ -0,0 +1,38 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/filters'
3
+
4
+ module Stockboy
5
+ describe Filters do
6
+
7
+ let(:filter) { double("filter") }
8
+
9
+ describe ".register" do
10
+ it "registers a key and class" do
11
+ Filters.register(:invalid, filter).should === filter
12
+ end
13
+ end
14
+
15
+ describe ".find" do
16
+ it "returns a filter class" do
17
+ Filters.register(:invalid, filter)
18
+ Filters.find(:invalid).should === filter
19
+ end
20
+ end
21
+
22
+ describe ".[]" do
23
+ it "returns a filter class" do
24
+ Filters.register(:invalid, filter)
25
+ Filters[:invalid].should === filter
26
+ end
27
+ end
28
+
29
+ describe ".all" do
30
+ it "returns all registered filters" do
31
+ Filters.register(:invalid, filter)
32
+ Filters.register(:semivalid, filter)
33
+ Filters.all.should include(invalid: filter, semivalid: filter)
34
+ end
35
+ end
36
+
37
+ end
38
+ end
@@ -0,0 +1,238 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/job'
3
+
4
+ module Stockboy
5
+ describe Job do
6
+ let(:jobs_path) { RSpec.configuration.fixture_path.join('jobs') }
7
+ let(:provider_stub) { double(:ftp).as_null_object }
8
+ let(:reader_stub) { double(:csv).as_null_object }
9
+
10
+ let(:job_template) {
11
+ <<-END.gsub(/^ {6}/,'')
12
+ provider :ftp do
13
+ username 'foo'
14
+ password 'bar'
15
+ host 'ftp.example.com'
16
+ end
17
+ format :csv
18
+ filter :blank_name do |r|
19
+ false if r.name.blank?
20
+ end
21
+ attributes do
22
+ name from: 'userName'
23
+ email from: 'email'
24
+ updated_at from: 'statusDate', as: [:date]
25
+ end
26
+ on :cleanup do |job|
27
+ job.provider.delete_data
28
+ end
29
+ on :cleanup do |job|
30
+ "log: " << job.all_records.size
31
+ end
32
+ END
33
+ }
34
+
35
+ before do
36
+ Stockboy.configuration.template_load_paths = [jobs_path]
37
+
38
+ allow(Stockboy::Providers).to receive(:find) { provider_stub }
39
+ allow(Stockboy::Readers).to receive(:find) { reader_stub }
40
+ end
41
+
42
+ its(:filters) { should be_a Hash }
43
+
44
+ describe "#define" do
45
+ before do
46
+ allow(File).to receive(:read)
47
+ .with("#{jobs_path}/test_job.rb")
48
+ .and_return job_template
49
+ end
50
+
51
+ it "returns an instance of Job" do
52
+ Job.define("test_job").should be_a Job
53
+ end
54
+
55
+ it "yields the defined job" do
56
+ yielded = nil
57
+ job = Job.define("test_job") { |j| yielded = j }
58
+ job.should be_a Job
59
+ job.should be yielded
60
+ end
61
+
62
+ it "should read a file from a path" do
63
+ File.should_receive(:read).with("#{jobs_path}/test_job.rb")
64
+ Job.define("test_job")
65
+ end
66
+
67
+ it "assigns a registered provider from a symbol" do
68
+ job = Job.define("test_job")
69
+ job.provider.should == provider_stub
70
+ end
71
+
72
+ it "assigns a registered reader from a symbol" do
73
+ Stockboy::Readers.should_receive(:find)
74
+ .with(:csv)
75
+ .and_return(reader_stub)
76
+ job = Job.define("test_job")
77
+ job.reader.should == reader_stub
78
+ end
79
+
80
+ it "assigns attributes from a block" do
81
+ job = Job.define("test_job")
82
+ job.attributes.map(&:to).should == [:name, :email, :updated_at]
83
+ end
84
+
85
+ it "assigns triggers into their associated array from a block" do
86
+ job = Job.define("test_job")
87
+ job.triggers[:cleanup].size.should == 2
88
+ job.triggers[:cleanup].each { |t| t.should be_a Proc }
89
+ end
90
+ end
91
+
92
+ describe "#process" do
93
+ let(:attribute_map) { AttributeMap.new { name } }
94
+
95
+ subject(:job) do
96
+ Job.new(provider: double(:provider, data:"", errors:[]),
97
+ attributes: attribute_map)
98
+ end
99
+
100
+ it "records total received record count" do
101
+ job.reader = double(parse: [{"name"=>"A"},{"name"=>"B"}])
102
+
103
+ job.process
104
+ job.total_records.should == 2
105
+ end
106
+
107
+ it "partitions records by filter" do
108
+ job.reader = double(parse: [{"name"=>"A"},{"name"=>"B"}])
109
+ job.filters = {alpha: proc{ |r| r.name =~ /A/ }}
110
+
111
+ job.process
112
+ job.records[:alpha].length.should == 1
113
+ end
114
+
115
+ it "keeps unfiltered_records" do
116
+ job.reader = double(parse: [{"name"=>"A"}])
117
+ job.filters = {zeta: proc{ |r| r.name =~ /Z/ }}
118
+
119
+ job.process
120
+ job.unfiltered_records.length.should == 1
121
+ end
122
+
123
+ it "keeps all_records" do
124
+ job.reader = double(parse: [{"name"=>"A"},{"name"=>"Z"}])
125
+ job.filters = {alpha: proc{ |r| r.name =~ /A/ }}
126
+
127
+ job.process
128
+ job.all_records.length.should == 2
129
+ end
130
+
131
+ it "resets filters between runs" do
132
+
133
+ class CountingFilter
134
+ attr_reader :matches
135
+ define_method(:initialize) { |pattern| @pattern, @matches = /A/, 0 }
136
+ define_method(:call) { |_, output| @matches += 1 if output.name =~ @pattern }
137
+ define_method(:reset) { @matches = 0 }
138
+ end
139
+
140
+ job.reader = double(parse: [{"name"=>"A"},{"name"=>"Z"}])
141
+ job.filters = {alpha: counter = CountingFilter.new(/A/)}
142
+
143
+ counter.matches.should == 0
144
+ 2.times { job.process }
145
+ counter.matches.should == 1
146
+ end
147
+
148
+ it "has empty partitions" do
149
+ job.filters = {alpha: proc{ |r| r.name =~ /A/ }, beta: proc{ |r| r.name =~ /B/ }}
150
+ job.records.should == {alpha: [], beta: []}
151
+ end
152
+ end
153
+
154
+ describe "#record_counts" do
155
+ let(:attribute_map) { AttributeMap.new { name } }
156
+
157
+ subject(:job) do
158
+ Job.new(provider: double(:provider, data:"", errors:[]),
159
+ attributes: attribute_map)
160
+ end
161
+
162
+ context "before processing" do
163
+ it "should be empty" do
164
+ job.record_counts.should == {}
165
+ end
166
+ end
167
+
168
+ it "returns a hash of counts by filtered record partition" do
169
+ job.filters = {
170
+ alpha: proc{ |r| r.name =~ /^A/ },
171
+ zeta: proc{ |r| r.name =~ /^Z/ }
172
+ }
173
+
174
+ job.reader = double(parse: [{"name"=>"Arthur"}, {"name"=>"Abc"}, {"name"=>"Zaphod"}])
175
+ job.process
176
+
177
+ job.record_counts.should == {alpha: 2, zeta: 1}
178
+ end
179
+ end
180
+
181
+ describe "#processed?" do
182
+ subject(:job) do
183
+ Job.new(provider: provider_stub,
184
+ reader: reader_stub,
185
+ attributes: AttributeMap.new)
186
+ end
187
+
188
+ it "indicates if the job has been processed" do
189
+ job.processed?.should be_false
190
+ job.process
191
+ job.processed?.should be_true
192
+ end
193
+ end
194
+
195
+ describe "#trigger" do
196
+
197
+ let(:provider_stub) { double(delete_data: true) }
198
+
199
+ subject(:job) do
200
+ Job.new(
201
+ provider: provider_stub,
202
+ triggers: {
203
+ success: [proc { |j| j.provider.delete_data },
204
+ proc { |j, stats| stats[:count] = 1 if stats }]
205
+ }
206
+ )
207
+ end
208
+
209
+ it "should yield itself to each trigger" do
210
+ provider_stub.should_receive(:delete_data).once
211
+ job.trigger(:success)
212
+ end
213
+
214
+ it "should yield args to each trigger" do
215
+ stats = {}
216
+ job.trigger(:success, stats)
217
+ stats[:count].should == 1
218
+ end
219
+
220
+ end
221
+
222
+ describe "#method_missing" do
223
+
224
+ subject(:job) { Job.new(triggers: {cleanup: proc{|_|}})}
225
+
226
+ it "should call a named trigger" do
227
+ expect(job).to receive(:trigger).with(:cleanup, "trash")
228
+ job.cleanup("trash")
229
+ end
230
+
231
+ it "should raise an error for unknown trigger keys" do
232
+ expect { job.wobble }.to raise_error NoMethodError
233
+ end
234
+
235
+ end
236
+
237
+ end
238
+ end
@@ -0,0 +1,30 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/mapped_record'
3
+
4
+ module Stockboy
5
+ describe MappedRecord do
6
+ subject(:record) do
7
+ MappedRecord.new(:full_name => 'Arthur Dent')
8
+ end
9
+
10
+ it "accesses initialized fields from hash" do
11
+ record.full_name.should == 'Arthur Dent'
12
+ end
13
+
14
+ it "does not redefine accessor methods" do
15
+ record1 = MappedRecord.new(:full_name => 'Arthur Dent')
16
+ record2 = MappedRecord.new(:full_name => 'Arthur Dent')
17
+
18
+ record1.method(:full_name).owner.should ==
19
+ record2.method(:full_name).owner
20
+ end
21
+
22
+ it "only has its own accessor methods" do
23
+ record1 = MappedRecord.new(:first_name => 'Arthur')
24
+ record2 = MappedRecord.new(:last_name => 'Dent')
25
+
26
+ record1.should_not respond_to(:last_name)
27
+ record2.should_not respond_to(:first_name)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ require 'spec_helper'
2
+ require 'stockboy/provider'
3
+
4
+ class ProviderSubclass < Stockboy::Provider
5
+ attr_accessor :foo
6
+ def validate
7
+ errors.add_on_empty(:foo, "Foo is empty")
8
+ end
9
+ end
10
+
11
+ module Stockboy
12
+ describe Provider do
13
+
14
+ describe "#errors" do
15
+ its(:errors) { should be_empty }
16
+ end
17
+
18
+ describe "#logger" do
19
+ its(:logger) { should respond_to :error }
20
+ end
21
+
22
+ describe "abstract method" do
23
+ subject { Class.new(Provider).new }
24
+
25
+ it "raises error for unimplemented #validate" do
26
+ expect{ subject.send :validate }.to raise_error(NoMethodError)
27
+ end
28
+
29
+ it "raises error for unimplemented #fetch_data" do
30
+ expect{ subject.send :fetch_data }.to raise_error(NoMethodError)
31
+ end
32
+ end
33
+ end
34
+ end