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,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