mrglass-qbxml 1.0.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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +21 -0
  3. data/.travis.yml +26 -0
  4. data/Appraisals +23 -0
  5. data/Gemfile +3 -0
  6. data/LICENSE.txt +22 -0
  7. data/README.md +86 -0
  8. data/Rakefile +7 -0
  9. data/gemfiles/rails_5.0.gemfile +7 -0
  10. data/gemfiles/rails_5.0.gemfile.lock +69 -0
  11. data/gemfiles/rails_5.1.gemfile +7 -0
  12. data/gemfiles/rails_5.1.gemfile.lock +69 -0
  13. data/gemfiles/rails_5.2.gemfile +7 -0
  14. data/gemfiles/rails_5.2.gemfile.lock +69 -0
  15. data/gemfiles/rails_6.0.gemfile +7 -0
  16. data/gemfiles/rails_6.0.gemfile.lock +71 -0
  17. data/gemfiles/rails_6.1.gemfile +7 -0
  18. data/gemfiles/rails_6.1.gemfile.lock +70 -0
  19. data/lib/qbxml/hash.rb +188 -0
  20. data/lib/qbxml/qbxml.rb +124 -0
  21. data/lib/qbxml/types.rb +45 -0
  22. data/lib/qbxml/version.rb +3 -0
  23. data/lib/qbxml.rb +11 -0
  24. data/qbxml.gemspec +29 -0
  25. data/schema/qbposxmlops30.xml +8343 -0
  26. data/schema/qbxmlops100.xml +30100 -0
  27. data/schema/qbxmlops110.xml +32241 -0
  28. data/schema/qbxmlops120.xml +34129 -0
  29. data/schema/qbxmlops130.xml +35788 -0
  30. data/schema/qbxmlops140.xml +35792 -0
  31. data/schema/qbxmlops20.xml +13140 -0
  32. data/schema/qbxmlops30.xml +18229 -0
  33. data/schema/qbxmlops40.xml +19811 -0
  34. data/schema/qbxmlops41.xml +19979 -0
  35. data/schema/qbxmlops50.xml +29931 -0
  36. data/schema/qbxmlops60.xml +24555 -0
  37. data/schema/qbxmlops70.xml +29844 -0
  38. data/schema/qbxmlops80.xml +28447 -0
  39. data/schema/qbxmlopsCA30.xml +18409 -0
  40. data/spec/backwards_compatibility.rb +26 -0
  41. data/spec/spec_helper.rb +25 -0
  42. data/spec/support/requests/account_query_rq.xml +8 -0
  43. data/spec/support/requests/customer_add_rq.xml +28 -0
  44. data/spec/support/requests/customer_query_iterator_rq.xml +11 -0
  45. data/spec/support/requests/customer_query_rq.xml +9 -0
  46. data/spec/support/requests/estimate_add_rq.xml +45 -0
  47. data/spec/support/requests/invoice_add_rq.xml +40 -0
  48. data/spec/support/requests/item_inventory_add_rq.xml +22 -0
  49. data/spec/support/requests/purchase_order_query_rq.xml +14 -0
  50. data/spec/support/requests/receive_payment_add_rq.xml +35 -0
  51. data/spec/support/requests/receive_payment_query_rq.xml +9 -0
  52. data/spec/support/requests/sales_receipt_add_rq.xml +49 -0
  53. data/spec/support/responses/account_query_rs.xml +42 -0
  54. data/spec/support/responses/customer_add_rs.xml +37 -0
  55. data/spec/support/responses/customer_query_rs.xml +49 -0
  56. data/spec/support/responses/customer_query_terator_rs.xml +34 -0
  57. data/spec/support/responses/item_inventory_add_rs.xml +40 -0
  58. data/spec/support/responses/purchase_order_query_rs.xml +84 -0
  59. data/spec/support/responses/receive_payment_query_rs.xml +51 -0
  60. data/spec/support/responses/sales_receipt_add_rs.xml +89 -0
  61. data/test/unit/hash_to_xml_test.rb +78 -0
  62. data/test/unit/version_test.rb +24 -0
  63. data/test/unit/xml_to_hash_test.rb +281 -0
  64. metadata +242 -0
data/lib/qbxml/hash.rb ADDED
@@ -0,0 +1,188 @@
1
+ # XML Conversion References
2
+ #
3
+ # https://github.com/rails/rails/blob/master/activesupport/lib/active_support/core_ext/hash/conversions.rb
4
+ # https://github.com/rails/rails/blob/master/activesupport/lib/active_support/xml_mini/nokogiri.rb
5
+ #
6
+ #
7
+ class Qbxml::Hash < ::Hash
8
+ include Qbxml::Types
9
+
10
+ CONTENT_ROOT = '__content__'.freeze
11
+ ATTR_ROOT = 'xml_attributes'.freeze
12
+ IGNORED_KEYS = [ATTR_ROOT]
13
+
14
+
15
+ def self.from_hash(hash, opts = {}, &block)
16
+ key_proc = \
17
+ if opts[:camelize]
18
+ lambda { |k|
19
+ # QB wants things like ListID, not ListId. Adding inflections then using camelize can accomplish
20
+ # the same thing, but then the inflections will apply to everything the user does everywhere.
21
+ k.camelize.gsub(Qbxml::Types::ACRONYM_REGEXP) { "#{$1}#{$2.upcase}#{$3}" }
22
+ }
23
+ elsif opts[:underscore]
24
+ lambda { |k| k.underscore }
25
+ end
26
+
27
+ deep_convert(hash, opts, &key_proc)
28
+ end
29
+
30
+ def to_xml(opts = {})
31
+ self.class.to_xml(self, opts)
32
+ end
33
+
34
+ def self.to_xml(hash, opts = {})
35
+ opts[:root], hash = hash.first
36
+ opts[:attributes] = hash.delete(ATTR_ROOT)
37
+ hash_to_xml(hash, opts)
38
+ end
39
+
40
+ def self.from_xml(xml, opts = {})
41
+ from_hash(
42
+ xml_to_hash(Nokogiri::XML(xml).root, {}, opts), opts)
43
+ end
44
+
45
+ private
46
+
47
+ def self.hash_to_xml(hash, opts = {})
48
+ opts = opts.dup
49
+ opts[:indent] ||= 2
50
+ opts[:root] ||= :hash
51
+ opts[:attributes] ||= (hash.delete(ATTR_ROOT) || {})
52
+ opts[:builder] ||= Builder::XmlMarkup.new(indent: opts[:indent])
53
+ opts[:skip_types] = true unless opts.key?(:skip_types)
54
+ opts[:skip_instruct] = false unless opts.key?(:skip_instruct)
55
+ builder = opts[:builder]
56
+
57
+ unless opts.delete(:skip_instruct)
58
+ builder.instruct!(:xml, :encoding => "ISO-8859-1")
59
+ builder.instruct!(opts[:schema], version: opts[:version])
60
+ end
61
+
62
+ builder.tag!(opts[:root], opts.delete(:attributes)) do
63
+ hash.each do |key, val|
64
+ case val
65
+ when Hash
66
+ self.hash_to_xml(val, opts.merge({root: key, skip_instruct: true}))
67
+ when Array
68
+ val.map { |i|
69
+ if i.is_a?(String)
70
+ next builder.tag!(key, i, {})
71
+ end
72
+ next self.hash_to_xml(i, opts.merge({root: key, skip_instruct: true}))
73
+ }
74
+ else
75
+ builder.tag!(key, val, {})
76
+ end
77
+ end
78
+
79
+ yield builder if block_given?
80
+ end
81
+ end
82
+
83
+ def self.xml_to_hash(node, hash = {}, opts = {})
84
+ node_hash = {CONTENT_ROOT => '', ATTR_ROOT => {}}
85
+ name = node.name
86
+ schema = opts[:schema]
87
+ opts[:typecast_cache] ||= {}
88
+ opts[:is_repetitive_cache] ||= {}
89
+
90
+ # Insert node hash into parent hash correctly.
91
+ case hash[name]
92
+ when Array
93
+ hash[name] << node_hash
94
+ when Hash, String
95
+ # This parent has multiple nodes with the same name, but when we checked the first time,
96
+ # we found it is not defined as repetitive. I guess this means the schema is a liar.
97
+ hash[name] = [hash[name], node_hash]
98
+ else
99
+ # We didn't see this node name under this parent yet.
100
+ if is_repetitive?(schema, node.path, opts[:is_repetitive_cache])
101
+ hash[name] = [node_hash]
102
+ else
103
+ hash[name] = node_hash
104
+ end
105
+ end
106
+
107
+ # Handle child elements
108
+ node.children.each do |c|
109
+ if c.element?
110
+ xml_to_hash(c, node_hash, opts)
111
+ elsif c.text? || c.cdata?
112
+ node_hash[CONTENT_ROOT] << c.content
113
+ end
114
+ end
115
+
116
+ # Handle attributes
117
+ node.attribute_nodes.each { |a| node_hash[ATTR_ROOT][a.node_name] = a.value }
118
+
119
+ # TODO: Strip text
120
+ # node_hash[CONTENT_ROOT].strip!
121
+
122
+ # Format node
123
+ if node_hash.size > 2 || node_hash[ATTR_ROOT].present?
124
+ node_hash.delete(CONTENT_ROOT)
125
+ elsif node_hash[CONTENT_ROOT].present?
126
+ node_hash.delete(ATTR_ROOT)
127
+ v = schema ? typecast(schema, node.path, node_hash[CONTENT_ROOT], opts[:typecast_cache]) : node_hash[CONTENT_ROOT]
128
+ # We only updated the last element
129
+ if hash[name].is_a?(Array)
130
+ hash[name].pop
131
+ hash[name] << v
132
+ else
133
+ hash[name] = v
134
+ end
135
+ else
136
+ hash[name] = node_hash[CONTENT_ROOT]
137
+ end
138
+
139
+ hash
140
+ end
141
+
142
+
143
+ private
144
+
145
+ def self.typecast(schema, xpath, value, typecast_cache)
146
+ type_path = xpath.gsub(/\[\d+\]/,'')
147
+ # This is fairly expensive. Cache it for better performance when parsing lots of records of the same type.
148
+ type_proc = typecast_cache[type_path] ||= Qbxml::TYPE_MAP[schema.xpath(type_path).first.try(:text)]
149
+ raise "#{xpath} is not a valid type" unless type_proc
150
+ type_proc[value]
151
+ end
152
+
153
+ # Determines if the node is repetitive. Just because something is repetitive doesn't mean it always repeats.
154
+ # For example, a customer query could return 1 result or 100, but in both cases, we should be returning an
155
+ # Array.
156
+ def self.is_repetitive?(schema, xpath, is_repetitive_cache)
157
+ # Yes, we are parsing comments.
158
+ comment_path = xpath.gsub(/\[\d+\]/,'') + "/comment()"
159
+ return is_repetitive_cache[comment_path] || parse_repetitive_from_comment(schema, comment_path)
160
+ end
161
+
162
+ def self.parse_repetitive_from_comment(schema, comment_path)
163
+ comment = schema.xpath(comment_path).first
164
+ return false if comment.nil?
165
+ return comment.text.include?('may rep')
166
+ end
167
+
168
+ def self.deep_convert(hash, opts = {}, &block)
169
+ hash.inject(self.new) do |h, (k,v)|
170
+ k = k.to_s
171
+ ignored = IGNORED_KEYS.include?(k)
172
+ if ignored
173
+ h[k] = v
174
+ else
175
+ key = block_given? ? yield(k) : k
176
+ h[key] = \
177
+ case v
178
+ when Hash
179
+ deep_convert(v, &block)
180
+ when Array
181
+ v.map { |i| i.is_a?(Hash) ? deep_convert(i, &block) : i }
182
+ else v
183
+ end
184
+ end; h
185
+ end
186
+ end
187
+
188
+ end
@@ -0,0 +1,124 @@
1
+ class Qbxml
2
+ include Types
3
+
4
+ SCHEMA_PATH = File.expand_path('../../../schema', __FILE__)
5
+
6
+ SCHEMAS = {
7
+ qb: {
8
+ "2.0" => "#{SCHEMA_PATH}/qbxmlops20.xml",
9
+ "CA3.0" => "#{SCHEMA_PATH}/qbxmlopsCA30.xml",
10
+ "3.0" => "#{SCHEMA_PATH}/qbxmlops30.xml",
11
+ "4.0" => "#{SCHEMA_PATH}/qbxmlops40.xml",
12
+ "4.1" => "#{SCHEMA_PATH}/qbxmlops41.xml",
13
+ "5.0" => "#{SCHEMA_PATH}/qbxmlops50.xml",
14
+ "6.0" => "#{SCHEMA_PATH}/qbxmlops60.xml",
15
+ "7.0" => "#{SCHEMA_PATH}/qbxmlops70.xml",
16
+ "8.0" => "#{SCHEMA_PATH}/qbxmlops80.xml",
17
+ "10.0" => "#{SCHEMA_PATH}/qbxmlops100.xml",
18
+ "11.0" => "#{SCHEMA_PATH}/qbxmlops110.xml",
19
+ "12.0" => "#{SCHEMA_PATH}/qbxmlops120.xml",
20
+ "13.0" => "#{SCHEMA_PATH}/qbxmlops130.xml",
21
+ "14.0" => "#{SCHEMA_PATH}/qbxmlops140.xml"
22
+ },
23
+ qbpos: {
24
+ "3.0" => "#{SCHEMA_PATH}/qbposxmlops30.xml"
25
+ }
26
+ }.freeze
27
+
28
+ HIDE_IVARS = [:@doc].freeze
29
+
30
+ def initialize(key = :qb, version = "7.0")
31
+ @schema = key
32
+ @version = version
33
+ @doc = parse_schema(key, version)
34
+ end
35
+
36
+ # returns all xml nodes matching a specified pattern
37
+ #
38
+ def types(pattern = nil)
39
+ @types ||= @doc.xpath("//*").map { |e| e.name }.uniq
40
+
41
+ pattern ?
42
+ @types.select { |t| t =~ Regexp.new(pattern) } :
43
+ @types
44
+ end
45
+
46
+ # returns the xml node for the specified type
47
+ #
48
+ def describe(type)
49
+ @doc.xpath("//#{type}").first
50
+ end
51
+
52
+ # converts a hash to qbxml with optional validation
53
+ #
54
+ def to_qbxml(hash, opts = {})
55
+ hash = Qbxml::Hash.from_hash(hash, camelize: true)
56
+ hash = namespace_qbxml_hash(hash) unless opts[:no_namespace]
57
+ validate_qbxml_hash(hash) if opts[:validate]
58
+
59
+ Qbxml::Hash.to_xml(hash, schema: XML_DIRECTIVES[@schema], version: @version)
60
+ end
61
+
62
+ # converts qbxml to a hash
63
+ #
64
+ def from_qbxml(xml, opts = {})
65
+ hash = Qbxml::Hash.from_xml(xml, underscore: true, schema: @doc)
66
+
67
+ opts[:no_namespace] ? hash : namespace_qbxml_hash(hash)
68
+ end
69
+
70
+ # making this more sane so that it doesn't dump the whole schema doc to stdout
71
+ # every time
72
+ #
73
+ def inspect
74
+ prefix = "#<#{self.class}:0x#{self.__id__.to_s(16)} "
75
+
76
+ (instance_variables - HIDE_IVARS).each do |var|
77
+ prefix << "#{var}=#{instance_variable_get(var).inspect}"
78
+ end
79
+
80
+ return "#{prefix}>"
81
+ end
82
+
83
+ # private
84
+
85
+ def parse_schema(key, version)
86
+ File.open(select_schema(key, version)) { |f| Nokogiri::XML(f) }
87
+ end
88
+
89
+ def select_schema(schema_key, version)
90
+ # Try to handle it if a user gave us a numeric version. Assume 1 decimal.
91
+ version = '%.1f' % version if version.is_a?(Numeric)
92
+ raise "invalid schema '#{schema_key}', must be one of #{SCHEMAS.keys.inspect}" if !SCHEMAS.has_key?(schema_key)
93
+ raise "invalid version '#{version}' for schema #{schema_key}, must be one of #{SCHEMAS[schema_key].keys.inspect}" if !SCHEMAS[schema_key].has_key?(version)
94
+ return SCHEMAS[schema_key][version]
95
+ end
96
+
97
+ # hash to qbxml
98
+
99
+ def namespace_qbxml_hash(hash)
100
+ node = describe(hash.keys.first)
101
+ return hash unless node
102
+
103
+ path = node.path.split('/')[1...-1].reverse
104
+ path.inject(hash) { |h,p| Qbxml::Hash[ p => h ] }
105
+ end
106
+
107
+ def validate_qbxml_hash(hash, path = [])
108
+ hash.each do |k,v|
109
+ next if k == Qbxml::Hash::ATTR_ROOT
110
+ key_path = path.dup << k
111
+ if v.is_a?(Hash)
112
+ validate_qbxml_hash(v, key_path)
113
+ else
114
+ validate_xpath(key_path)
115
+ end
116
+ end
117
+ end
118
+
119
+ def validate_xpath(path)
120
+ xpath = "/#{path.join('/')}"
121
+ raise "#{xpath} is not a valid type" if @doc.xpath(xpath).empty?
122
+ end
123
+
124
+ end
@@ -0,0 +1,45 @@
1
+ module Qbxml::Types
2
+ require 'bigdecimal'
3
+
4
+
5
+ XML_DIRECTIVES = {
6
+ :qb => :qbxml,
7
+ :qbpos => :qbposxml
8
+ }.freeze
9
+
10
+ FLOAT_CAST = Proc.new {|d| d ? d.to_f : 0.0}
11
+ BOOL_CAST = Proc.new {|d| d ? (d.to_s.downcase == 'true' ? true : false) : false }
12
+ DATE_CAST = Proc.new {|d| d ? Date.parse(d).strftime("%Y-%m-%d") : Date.today.strftime("%Y-%m-%d") }
13
+ TIME_CAST = Proc.new {|d| d ? Time.parse(d).xmlschema : Time.now.xmlschema }
14
+ INT_CAST = Proc.new {|d| d ? Integer(d.to_i) : 0 }
15
+ STR_CAST = Proc.new {|d| d ? String(d) : ''}
16
+ BIGDECIMAL_CAST = Proc.new {|d| d ? BigDecimal(d) : 0.0}
17
+
18
+ TYPE_MAP= {
19
+ "AMTTYPE" => FLOAT_CAST,
20
+ "BOOLTYPE" => BOOL_CAST,
21
+ "DATETIMETYPE" => TIME_CAST,
22
+ "DATETYPE" => DATE_CAST,
23
+ "ENUMTYPE" => STR_CAST,
24
+ "FLOATTYPE" => FLOAT_CAST,
25
+ "GUIDTYPE" => STR_CAST,
26
+ "IDTYPE" => STR_CAST,
27
+ "INTTYPE" => INT_CAST,
28
+ "PERCENTTYPE" => FLOAT_CAST,
29
+ "PRICETYPE" => FLOAT_CAST,
30
+ "QUANTYPE" => BIGDECIMAL_CAST,
31
+ "STRTYPE" => STR_CAST,
32
+ "TIMEINTERVALTYPE" => STR_CAST
33
+ }
34
+
35
+ # Strings in tag names that should be capitalized in QB's XML
36
+ ACRONYMS = ['AP', 'AR', 'COGS', 'COM', 'UOM', 'QBXML', 'UI', 'AVS', 'ID',
37
+ 'PIN', 'SSN', 'COM', 'CLSID', 'FOB', 'EIN', 'UOM', 'PO', 'PIN', 'QB']
38
+
39
+ # Based on the regexp in ActiveSupport::Inflector.camelize
40
+ # Substring 1: Start of string, lower case letter, or slash
41
+ # Substring 2: One of the acronyms above, In Capitalized Casing
42
+ # Substring 3: End of string or capital letter
43
+ ACRONYM_REGEXP = Regexp.new("(?:(^|[a-z]|\\/))(#{ACRONYMS.map{|a| a.capitalize}.join("|")})([A-Z]|$)")
44
+
45
+ end
@@ -0,0 +1,3 @@
1
+ class Qbxml
2
+ VERSION = "1.0.0"
3
+ end
data/lib/qbxml.rb ADDED
@@ -0,0 +1,11 @@
1
+ require "qbxml/version"
2
+
3
+ require 'nokogiri'
4
+ require 'active_support/builder'
5
+ require 'active_support/core_ext/string'
6
+
7
+ class Qbxml; end
8
+
9
+ require_relative 'qbxml/types.rb'
10
+ require_relative 'qbxml/qbxml.rb'
11
+ require_relative 'qbxml/hash.rb'
data/qbxml.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'qbxml/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "mrglass-qbxml"
8
+ gem.version = Qbxml::VERSION
9
+ gem.authors = ["Alex Skryl", "Jason Barnabe"]
10
+ gem.email = ["rut216@gmail.com", "jason.barnabe@gmail.com"]
11
+ gem.description = %q{Quickbooks XML Parser}
12
+ gem.summary = %q{Quickbooks XML Parser and Validation Tool}
13
+ gem.homepage = ""
14
+
15
+ gem.files = `git ls-files`.split($/)
16
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
17
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
18
+ gem.require_paths = ["lib"]
19
+
20
+ gem.add_dependency('activesupport', '>= 5.0.0')
21
+ gem.add_dependency('nokogiri', '~> 1.5')
22
+ gem.add_dependency('builder', '~> 3.0')
23
+
24
+ gem.add_development_dependency('pry')
25
+ gem.add_development_dependency('pry-nav')
26
+ gem.add_development_dependency('rspec')
27
+ gem.add_development_dependency('rake')
28
+ gem.add_development_dependency('appraisal')
29
+ end