stockboy 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
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,155 @@
1
+ require 'stockboy/reader'
2
+ require 'stockboy/string_pool'
3
+
4
+ module Stockboy::Readers
5
+
6
+ # Extract data from XML
7
+ #
8
+ # This works great with SOAP, probably not fully-featured yet for various XML
9
+ # formats. The SOAP provider returns a hash, because it takes care of
10
+ # extracting the envelope body already, so this reader supports options for
11
+ # reading elements from a nested hash too.
12
+ #
13
+ # Backed by the Nori gem from Savon, see nori for full options.
14
+ #
15
+ class XML < Stockboy::Reader
16
+ include Stockboy::StringPool
17
+
18
+ # Override source encoding
19
+ #
20
+ # @!attribute [rw] encoding
21
+ # @return [String]
22
+ #
23
+ dsl_attr :encoding
24
+
25
+ # Element nesting to traverse, the last one should represent the record
26
+ # instances that contain tags for each attribute.
27
+ #
28
+ # @!attribute [rw] elements
29
+ # @return [Array]
30
+ # @example
31
+ # elements ["allItemsResponse", "itemList", "recordItem"]
32
+ #
33
+ dsl_attr :elements, attr_accessor: false
34
+
35
+ # Removes namespace prefixes from tag names, default true.
36
+ #
37
+ # @!attribute [rw] strip_namespaces
38
+ # @return [Boolean]
39
+ #
40
+ dsl_attr :strip_namespaces, attr_accessor: false
41
+
42
+ # Change tag formatting, e.g. underscore if it happens to match your actual
43
+ # record attributes
44
+ #
45
+ # @!attribute [rw] convert_tags_to
46
+ # @return [Proc]
47
+ # @example
48
+ # convert_tags_to ->(tag) { tag.underscore }
49
+ #
50
+ dsl_attr :convert_tags_to, attr_accessor: false
51
+
52
+ # Detects input tag types and tries to extract dates, times, etc. from the data.
53
+ # Normally this is handled by the attribute map.
54
+ #
55
+ # @!attribute [rw] advanced_typecasting
56
+ # @return [Boolean]
57
+ #
58
+ dsl_attr :advanced_typecasting, attr_accessor: false
59
+
60
+ # Defaults to Nokogiri. Why would you change it?
61
+ #
62
+ # @!attribute [rw] parser
63
+ # @return [Symbol]
64
+ #
65
+ dsl_attr :parser, attr_accessor: false
66
+
67
+ [:strip_namespaces, :convert_tags_to, :advanced_typecasting, :parser].each do |opt|
68
+ define_method(opt) { @xml_options[opt] }
69
+ define_method(:"#{opt}=") { |value| @xml_options[opt] = value }
70
+ end
71
+
72
+ def elements
73
+ convert_tags_to ? @elements.map(&convert_tags_to) : @elements
74
+ end
75
+
76
+ def elements=(schema)
77
+ return @elements = [] unless schema
78
+ raise(ArgumentError, "expected an array of XML tag strings") unless schema.is_a? Array
79
+ @elements = schema.map(&:to_s)
80
+ end
81
+
82
+ # @!endgroup
83
+
84
+ # Initialize a new XML reader
85
+ #
86
+ def initialize(opts={}, &block)
87
+ super
88
+ self.elements = opts.delete(:elements)
89
+ @xml_options = opts
90
+ DSL.new(self).instance_eval(&block) if block_given?
91
+ end
92
+
93
+ # XML options passed to the underlying Nori instance
94
+ #
95
+ # @!attribute [r] options
96
+ # @return [Hash]
97
+ #
98
+ def options
99
+ @xml_options
100
+ end
101
+
102
+ def parse(data)
103
+ hash = if data.is_a? Hash
104
+ data
105
+ else
106
+ if data.respond_to? :to_xml
107
+ data.to_xml("UTF-8")
108
+ nori.parse(data)
109
+ elsif data.respond_to? :to_hash
110
+ data.to_hash
111
+ else
112
+ data.encode!("UTF-8", encoding) if encoding
113
+ nori.parse(data)
114
+ end
115
+ end
116
+
117
+ with_string_pool do
118
+ remap_keys hash
119
+ extract hash
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def nori
126
+ @nori ||= Nori.new(options)
127
+ end
128
+
129
+ def extract(hash)
130
+ result = elements.inject hash do |memo, key|
131
+ return [] if memo[key].nil?
132
+ memo[key]
133
+ end
134
+
135
+ result = [result] unless result.is_a? Array
136
+ result.compact!
137
+ result
138
+ end
139
+
140
+ def remap_keys(node)
141
+ mapper = convert_tags_to || ->(tag) { tag }
142
+ case node
143
+ when Hash
144
+ node.keys.each do |k|
145
+ tag = string_pool(mapper.call(k))
146
+ node[tag] = remap_keys(node.delete(k))
147
+ end
148
+ when Array
149
+ node.each { |value| remap_keys(value) }
150
+ end
151
+ node
152
+ end
153
+
154
+ end
155
+ end
@@ -0,0 +1,42 @@
1
+ module Stockboy
2
+
3
+ # Holds a collection of registered classes for convenient reference by
4
+ # symbolic name
5
+ #
6
+ module Registry
7
+
8
+ def self.extended(base)
9
+ base.class_eval do
10
+ @registry = {}
11
+ end
12
+ end
13
+
14
+ # Register a class under a convenient symbolic name
15
+ #
16
+ # @param [Symbol] key Symbolic name of the class
17
+ # @param [Class] provider Class to be returned when requested
18
+ #
19
+ def register(key, provider)
20
+ @registry[key] = provider
21
+ end
22
+
23
+ # Look up a class and return it by symbolic name
24
+ #
25
+ # @param [Symbol] key
26
+ # @return [Class]
27
+ #
28
+ def find(key)
29
+ @registry[key]
30
+ end
31
+ alias_method :[], :find
32
+
33
+ # List all named classes in the registry
34
+ #
35
+ # @return [Hash]
36
+ #
37
+ def all
38
+ @registry
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,43 @@
1
+ require 'stockboy/mapped_record'
2
+
3
+ module Stockboy
4
+
5
+ # This represents the raw "input" side of a {CandidateRecord}
6
+ #
7
+ # It provides access to the original field values before mapping or
8
+ # translation as hash keys.
9
+ #
10
+ # @example
11
+ # input = SourceRecord.new(
12
+ # {check_in: "2012-12-12"},
13
+ # {"RawCheckIn" => "2012-12-12"})
14
+ #
15
+ # input["RawCheckIn"] # => "2012-12-12"
16
+ # input.check_in # => "2012-12-12"
17
+ #
18
+ class SourceRecord < MappedRecord
19
+
20
+ # Initialize a new instance
21
+ #
22
+ # @param [Hash{Symbol=>Object}] mapped_fields
23
+ # Represents the raw values mapped to the final attribute names
24
+ # @param [Hash] data_fields
25
+ # The raw input fields with original key values
26
+ #
27
+ def initialize(mapped_fields, data_fields)
28
+ @data_fields = data_fields
29
+ super(mapped_fields)
30
+ end
31
+
32
+ # Access a raw field value by the original input field name
33
+ #
34
+ # @param [String] key
35
+ #
36
+ def [](key)
37
+ key = key.to_s if key.is_a? Symbol
38
+ @data_fields[key]
39
+ end
40
+
41
+ end
42
+
43
+ end
@@ -0,0 +1,35 @@
1
+ module Stockboy
2
+
3
+ # Holds frozen strings for shared lookup between different object instances
4
+ #
5
+ # @visibility private
6
+ #
7
+ module StringPool
8
+
9
+ # Pass a block to yield a new string pool context around a group of
10
+ # actions that should share the same string key instances
11
+ #
12
+ # @yield
13
+ #
14
+ def with_string_pool
15
+ @string_pool = []
16
+ result = yield
17
+ @string_pool = []
18
+ result
19
+ end
20
+
21
+ # Look up duplicate strings and return the shared frozen string
22
+ #
23
+ # @return [String]
24
+ #
25
+ def string_pool(name)
26
+ if i = @string_pool.index(name)
27
+ @string_pool[i]
28
+ else
29
+ @string_pool << name.freeze
30
+ name
31
+ end
32
+ end
33
+
34
+ end
35
+ end
@@ -0,0 +1,44 @@
1
+ require 'stockboy/configuration'
2
+
3
+ module Stockboy
4
+
5
+ # Find and read template files from the configured load paths
6
+ #
7
+ module TemplateFile
8
+
9
+ # Read template file contents for defining a new job
10
+ #
11
+ # @param [String] template_name
12
+ # The file basename of a predefined template
13
+ # @return [String] Job template DSL or nil if nothing is found
14
+ #
15
+ def self.read(template_name)
16
+ return template_name.read if template_name.is_a? File
17
+ return unless path = find(template_name)
18
+
19
+ File.read(path)
20
+ end
21
+
22
+ # Find a named DSL template from configuration.template_load_paths
23
+ #
24
+ # @param [String] filename Template basename to be searched from load paths
25
+ # @return [String] The full path to the first matched filename if found
26
+ #
27
+ def self.find(filename)
28
+ sources = template_file_paths(filename)
29
+ Dir.glob(sources).first
30
+ end
31
+
32
+ # Potential locations for finding a template file
33
+ #
34
+ # @param [String] filename Template basename
35
+ # @return [Array] filename on each possible load path
36
+ #
37
+ def self.template_file_paths(filename)
38
+ filename = "#{filename}.rb" unless filename =~ /\.rb$/
39
+ load_paths = Array(Stockboy.configuration.template_load_paths)
40
+ load_paths.map { |d| File.join(d, filename) }
41
+ end
42
+
43
+ end
44
+ end
@@ -0,0 +1,70 @@
1
+ require 'stockboy/exceptions'
2
+ require 'stockboy/translator'
3
+
4
+ module Stockboy
5
+
6
+ # Registry of available {Translator} classes for lookup by symbolic name in the
7
+ # job template DSL.
8
+ #
9
+ module Translations
10
+
11
+ @registry ||= {}
12
+
13
+ # Register a translator under a convenient symbolic name
14
+ #
15
+ # @param [Symbol] name
16
+ # Symbolic name of the class
17
+ # @param [Translator, #call] callable
18
+ # Translator class or any callable object
19
+ #
20
+ def self.register(name, callable)
21
+ if callable.respond_to?(:call) or callable < Stockboy::Translator
22
+ @registry[name.to_sym] = callable
23
+ else
24
+ raise ArgumentError, "Registered translators must be callable"
25
+ end
26
+ end
27
+
28
+ # Calls a named translator for the raw value
29
+ #
30
+ # @param [Symbol, Translator, #call] func_name
31
+ # Symbol representing a registered translator, or an actual translator
32
+ # @param [SourceRecord, MappedRecord, Hash, String] context
33
+ # Collection of fields or the raw value to which the translation is applied
34
+ #
35
+ def self.translate(func_name, context)
36
+ translator_for(:value, func_name).call(context)
37
+ end
38
+
39
+ # Prepare a translator for a given attribute
40
+ #
41
+ # @param [Symbol] attr
42
+ # Name of the mapped record attribute to address for translation
43
+ # @param [Symbol, #call] lookup
44
+ # Symbolic translator name or callable object
45
+ # @return [Translator] instance
46
+ #
47
+ def self.translator_for(attr, lookup)
48
+ if lookup.respond_to?(:call)
49
+ lookup
50
+ elsif tr = self[lookup]
51
+ tr.is_a?(Class) && tr < Stockboy::Translator ? tr.new(attr) : tr
52
+ else
53
+ ->(context) { context.public_send attr } # no-op
54
+ end
55
+ end
56
+
57
+ # Look up a translation and return it by symbolic name
58
+ #
59
+ # @param [Symbol] func_name
60
+ # @return [Translator]
61
+ #
62
+ def self.find(func_name)
63
+ @registry[func_name]
64
+ end
65
+ class << self
66
+ alias_method :[], :find
67
+ end
68
+
69
+ end
70
+ end
@@ -0,0 +1,58 @@
1
+ require 'stockboy/translator'
2
+
3
+ module Stockboy::Translations
4
+
5
+ # Convert common false-like and true-like values to proper boolean +true+ or
6
+ # +false+.
7
+ #
8
+ # Returns nil for indeterminate values. This should be chained with a
9
+ # default value translator like [DefaultFalse] or [DefaultTrue].
10
+ #
11
+ # == Job template DSL
12
+ #
13
+ # Registered as +:boolean+. Use with:
14
+ #
15
+ # attributes do
16
+ # active as: :boolean
17
+ # end
18
+ #
19
+ # @example
20
+ # bool = Stockboy::Translator::Boolean.new
21
+ #
22
+ # record.active = 't'
23
+ # bool.translate(record, :active) # => true
24
+ #
25
+ # record.active = 'f'
26
+ # bool.translate(record, :active) # => false
27
+ #
28
+ # record.active = '1'
29
+ # bool.translate(record, :active) # => true
30
+ #
31
+ # record.active = '0'
32
+ # bool.translate(record, :active) # => false
33
+ #
34
+ # record.active = 'y'
35
+ # bool.translate(record, :active) # => true
36
+ #
37
+ # record.active = 'n'
38
+ # bool.translate(record, :active) # => false
39
+ #
40
+ # record.active = '?'
41
+ # bool.translate(record, :active) # => nil
42
+ #
43
+ class Boolean < Stockboy::Translator
44
+ TRUTHY_VALUES = [true, 1, '1', 't', 'T', 'true', 'TRUE', 'y', 'Y', 'yes', 'YES']
45
+ FALSY_VALUES = [false, 0, '0', 'f', 'F', 'false', 'FALSE', 'n', 'N', 'no', 'NO']
46
+
47
+ # @return [Boolean]
48
+ #
49
+ def translate(context)
50
+ value = field_value(context, field_key)
51
+
52
+ return true if TRUTHY_VALUES.include?(value)
53
+ return false if FALSY_VALUES.include?(value)
54
+ return nil
55
+ end
56
+
57
+ end
58
+ end