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,11 @@
1
+ module Stockboy
2
+
3
+ # Struct-like value object for holding mapping & translation details from
4
+ # input data fields
5
+ #
6
+ class Attribute < Struct.new(:to, :from, :translators)
7
+ def inspect
8
+ "#<Stockboy::Attribute to=#{to.inspect}, from=#{from.inspect}, translators=#{translators}>"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,74 @@
1
+ require 'stockboy/attribute'
2
+ require 'stockboy/translations'
3
+
4
+ module Stockboy
5
+
6
+ # Table of attributes for finding corresponding field/attribute translations
7
+ #
8
+ class AttributeMap
9
+
10
+ include Enumerable
11
+
12
+ # @visibility private
13
+ #
14
+ class DSL
15
+ def initialize(instance)
16
+ @instance = instance
17
+ @map = @instance.instance_variable_get(:@map)
18
+ end
19
+
20
+ def method_missing(attr, *args)
21
+ opts = args.first || {}
22
+ to = attr.to_sym
23
+ from = opts.fetch(:from, attr)
24
+ from = from.to_s.freeze if from.is_a? Symbol
25
+ translators = Array(opts[:as]).map { |t| Translations.translator_for(to, t) }
26
+ @map[attr] = Attribute.new(to, from, translators)
27
+ define_attribute_method(attr)
28
+ end
29
+
30
+ def define_attribute_method(attr)
31
+ (class << @instance; self end).send(:define_method, attr) { @map[attr] }
32
+ end
33
+ end
34
+
35
+ # Initialize a new attribute map
36
+ #
37
+ def initialize(rows={}, &block)
38
+ @map = rows
39
+ @unmapped = Hash.new
40
+ if block_given?
41
+ DSL.new(self).instance_eval(&block)
42
+ end
43
+ freeze
44
+ end
45
+
46
+ # Retrieve an attribute by symbolic name
47
+ #
48
+ # @param [Symbol] key
49
+ # @return [Attribute]
50
+ #
51
+ def [](key)
52
+ @map[key]
53
+ end
54
+
55
+ # Fetch the attribute corresponding to the source field name
56
+ #
57
+ # @param [String] key
58
+ # @return [Attribute]
59
+ #
60
+ def attribute_from(key)
61
+ find { |a| a.from == key } or @unmapped[key] ||= Attribute.new(nil, key, nil)
62
+ end
63
+
64
+ # Enumerate over attributes
65
+ #
66
+ # @return [Enumerator]
67
+ # @yieldparam [Attribute]
68
+ #
69
+ def each(*args, &block)
70
+ @map.values.each(*args, &block)
71
+ end
72
+
73
+ end
74
+ end
@@ -0,0 +1,130 @@
1
+ require 'stockboy/attribute_map'
2
+ require 'stockboy/mapped_record'
3
+ require 'stockboy/source_record'
4
+ require 'stockboy/translations'
5
+
6
+ module Stockboy
7
+
8
+ # Joins the raw data values to an attribute mapping to allow comparison of
9
+ # input/output values, conversion, and filtering
10
+ #
11
+ class CandidateRecord
12
+
13
+ # Initialize a new candidate record
14
+ #
15
+ # @param [Hash] attrs Raw key-values from source data
16
+ # @param [AttributeMap] map Mapping and translations
17
+ #
18
+ def initialize(attrs, map)
19
+ @map = map
20
+ @table = use_frozen_keys(attrs, map)
21
+ @tr_table = Hash.new
22
+ freeze
23
+ end
24
+
25
+ # Convert the mapped output to a hash
26
+ #
27
+ # @return [Hash]
28
+ #
29
+ def to_hash
30
+ Hash.new.tap do |out|
31
+ @map.each { |col| out[col.to] = translate(col) }
32
+ end
33
+ end
34
+ alias_method :attributes, :to_hash
35
+
36
+ # Return the original values mapped to attribute keys
37
+ #
38
+ # @return [Hash]
39
+ #
40
+ def raw_hash
41
+ Hash.new.tap do |out|
42
+ @map.each { |col| out[col.to] = @table[col.from] }
43
+ end
44
+ end
45
+ alias_method :raw_attributes, :raw_hash
46
+
47
+ # Wrap the mapped attributes in a new ActiveModel or ActiveRecord object
48
+ #
49
+ # @param [Class] model ActiveModel class
50
+ # @return [Class] ActiveModel class
51
+ #
52
+ def to_model(model)
53
+ model.new(attributes)
54
+ end
55
+
56
+ # Find the filter key that captures this record
57
+ #
58
+ # @param [FilterChain] filters List of filters to apply
59
+ # @return [Symbol] Name of the matched filter
60
+ #
61
+ def partition(filters={})
62
+ input, output = self.input, self.output
63
+ filters.each_pair do |filter_key, f|
64
+ if f.call(input, output)
65
+ return filter_key
66
+ end
67
+ end
68
+ nil
69
+ end
70
+
71
+ # Data structure representing the record's raw input values
72
+ #
73
+ # Values can be accessed like hash keys, or attribute names that correspond
74
+ # to a +:from+ attribute mapping option
75
+ #
76
+ # @return [SourceRecord]
77
+ # @example
78
+ # input = candidate.input
79
+ # input["RawEmail"] # => "ME@EXAMPLE.COM "
80
+ # input.email # => "ME@EXAMPLE.COM "
81
+ #
82
+ def input
83
+ SourceRecord.new(self.raw_hash, @table)
84
+ end
85
+
86
+ # Data structure representing the record's mapped & translated output values
87
+ #
88
+ # @return [MappedRecord]
89
+ # @example
90
+ # input = candidate.output
91
+ # output.email # => "me@example.com"
92
+ #
93
+ def output
94
+ MappedRecord.new(self.to_hash)
95
+ end
96
+
97
+ private
98
+
99
+ def translate(col)
100
+ return sanitize(@table[col.from]) if col.translators.empty?
101
+ return @tr_table[col.to] if @tr_table.has_key? col.to
102
+ fields = self.raw_hash.dup
103
+ translated = col.translators.inject(input) do |m,t|
104
+ begin
105
+ new_value = t.call(m)
106
+ rescue
107
+ fields[col.to] = nil
108
+ break SourceRecord.new(fields, @table)
109
+ end
110
+
111
+ fields[col.to] = new_value
112
+ SourceRecord.new(fields, @table)
113
+ end
114
+ @tr_table[col.to] = translated.public_send(col.to)
115
+ end
116
+
117
+ def sanitize(value)
118
+ value.is_a?(String) ? value.to_s : value
119
+ end
120
+
121
+ def use_frozen_keys(attrs, map)
122
+ attrs.reduce(Hash.new) do |new_hash, (field, value)|
123
+ key = map.attribute_from(field).from
124
+ new_hash[key] = value
125
+ new_hash
126
+ end
127
+ end
128
+
129
+ end
130
+ end
@@ -0,0 +1,62 @@
1
+ module Stockboy
2
+
3
+ # Global Stockboy configuration options
4
+ #
5
+ class Configuration
6
+
7
+ # Directories where Stockboy job template files can be found.
8
+ #
9
+ # Needs to be configured with your own paths if running standalone.
10
+ # When running with Rails, includes +config/stockboy_jobs+ by default.
11
+ #
12
+ # @return [Array<String>]
13
+ #
14
+ attr_accessor :template_load_paths
15
+
16
+ # Path for storing tempfiles during processing
17
+ #
18
+ # @return [String]
19
+ #
20
+ attr_accessor :tmp_dir
21
+
22
+ # Default logger
23
+ #
24
+ # @return [Logger]
25
+ #
26
+ attr_accessor :logger
27
+
28
+ # Initialize a set of global configuration options
29
+ #
30
+ # @yield self for configuration
31
+ #
32
+ def initialize
33
+ @template_load_paths = []
34
+ @logger = Logger.new(STDOUT)
35
+ @tmp_dir = Dir.tmpdir
36
+ yield self if block_given?
37
+ end
38
+ end
39
+
40
+ class << self
41
+
42
+ # Stockboy configuration block
43
+ #
44
+ # @example
45
+ # Stockboy.configure do |config|
46
+ # config.template_load_paths << "config/my_templates"
47
+ # end
48
+ #
49
+ # @scope class
50
+ # @yield self for configuration
51
+ # @return [Configuration]
52
+ #
53
+ def configure
54
+ @configuration ||= Configuration.new
55
+ yield @configuration if block_given?
56
+ @configuration
57
+ end
58
+ alias_method :configuration, :configure
59
+
60
+ end
61
+
62
+ end
@@ -0,0 +1,176 @@
1
+ require 'stockboy/job'
2
+ require 'stockboy/providers'
3
+ require 'stockboy/readers'
4
+ require 'stockboy/filters'
5
+ require 'stockboy/attribute_map'
6
+
7
+ module Stockboy
8
+
9
+ # Context for evaluating DSL templates and capturing job options for
10
+ # initializing a job.
11
+ #
12
+ # Wraps up the DSL methods called in job templates and handles the construction
13
+ # of the job's +provider+, +reader+, +attributes+, and +filters+.
14
+ #
15
+ class Configurator
16
+
17
+ # Captured job configuration options
18
+ #
19
+ # @return [Hash]
20
+ #
21
+ attr_reader :config
22
+
23
+ # Evaluate DSL and capture configuration for building a job
24
+ #
25
+ # @overload new(dsl, file=__FILE__)
26
+ # Evaluate DSL from a string
27
+ # @param [String] dsl job template language for evaluation
28
+ # @param [String] file path to original file for reporting errors
29
+ # @overload new(&block)
30
+ # Evaluate DSL in a block
31
+ #
32
+ def initialize(dsl='', file=__FILE__, &block)
33
+ @config = {}
34
+ @config[:triggers] = Hash.new { |hash, key| hash[key] = [] }
35
+ @config[:filters] = {}
36
+ if block_given?
37
+ instance_eval(&block)
38
+ else
39
+ instance_eval(dsl, file)
40
+ end
41
+ end
42
+
43
+ # DSL method for configuring the provider
44
+ #
45
+ # The optional block is evaluated in the provider's own DSL context.
46
+ #
47
+ # @param [Symbol, Class, Provider] provider_class
48
+ # The registered symbol name for the provider, or actual provider
49
+ # @param [Hash] opts
50
+ # Provider-specific options passed to the provider initializer
51
+ #
52
+ # @example
53
+ # provider :file, file_dir: "/downloads/@client" do
54
+ # file_name "example.csv"
55
+ # end
56
+ #
57
+ # @return [Provider]
58
+ #
59
+ def provider(provider_class, opts={}, &block)
60
+ raise ArgumentError unless provider_class
61
+
62
+ @config[:provider] = case provider_class
63
+ when Symbol
64
+ Providers.find(provider_class).new(opts, &block)
65
+ when Class
66
+ provider_class.new(opts, &block)
67
+ else
68
+ provider_class
69
+ end
70
+ end
71
+ alias_method :connection, :provider
72
+
73
+ # DSL method for configuring the reader
74
+ #
75
+ # @param [Symbol, Class, Reader] reader_class
76
+ # The registered symbol name for the reader, or actual reader
77
+ # @param [Hash] opts
78
+ # Provider-specific options passed to the provider initializer
79
+ #
80
+ # @example
81
+ # reader :csv do
82
+ # col_sep "|"
83
+ # end
84
+ #
85
+ # @return [Reader]
86
+ #
87
+ def reader(reader_class, opts={}, &block)
88
+ raise ArgumentError unless reader_class
89
+
90
+ @config[:reader] = case reader_class
91
+ when Symbol
92
+ Readers.find(reader_class).new(opts, &block)
93
+ when Class
94
+ reader_class.new(opts, &block)
95
+ else
96
+ reader_class
97
+ end
98
+ end
99
+ alias_method :format, :reader
100
+
101
+ # DSL method for configuring the attribute map in a block
102
+ #
103
+ # @example
104
+ # attributes do
105
+ # first_name as: ->(raw){ raw["FullName"].split(" ").first }
106
+ # email from: "RawEmail", as: [:string]
107
+ # check_in from: "RawCheckIn", as: [:date]
108
+ # end
109
+ #
110
+ def attributes(&block)
111
+ raise ArgumentError unless block_given?
112
+
113
+ @config[:attributes] = AttributeMap.new(&block)
114
+ end
115
+
116
+ # DSL method to add a filter to the filter chain
117
+ #
118
+ # * Must be called with either a callable argument (proc) or a block.
119
+ # * Must be called in the order that filters should be applied.
120
+ #
121
+ # @example
122
+ # filter :missing_email do |raw, out|
123
+ # raw["RawEmail"].empty?
124
+ # end
125
+ # filter :past_due do |raw, out|
126
+ # out.check_in < Date.today
127
+ # end
128
+ # filter :under_age, :check_id
129
+ # filter :update, proc{ true } # capture all remaining items
130
+ #
131
+ def filter(key, callable=nil, *args, &block)
132
+ raise ArgumentError unless key
133
+ if callable.is_a?(Symbol)
134
+ callable = Filters.find(callable)
135
+ callable = callable.new(*args) if callable.is_a? Class
136
+ end
137
+ raise ArgumentError unless callable.respond_to?(:call) ^ block_given?
138
+
139
+ @config[:filters][key] = block || callable
140
+ end
141
+
142
+ # DSL method to register a trigger to notify the job of an event
143
+ #
144
+ # Useful for adding generic control over the job's resources from your app.
145
+ # For example, if you need to record stats or clean up data after your
146
+ # application has successfully processed the records, these actions can be
147
+ # defined within the context of each job template.
148
+ #
149
+ # @param [Symbol] key Name of the trigger
150
+ # @param [Trigger, Proc, #call] trigger_class
151
+ #
152
+ # @example
153
+ # trigger :cleanup do |job, *args|
154
+ # job.provider.delete_data
155
+ # end
156
+ #
157
+ # # elsewhere:
158
+ # if MyProjects.find(123).import_records(job.records[:valid])
159
+ # job.cleanup
160
+ # end
161
+ #
162
+ def on(key, &block)
163
+ raise(ArgumentError, "no block given") unless block_given?
164
+ @config[:triggers][key] << block
165
+ end
166
+
167
+ # Initialize a new job with the captured options
168
+ #
169
+ # @return [Job]
170
+ #
171
+ def to_job
172
+ Job.new(@config)
173
+ end
174
+
175
+ end
176
+ end