ruby_odata 0.1.0 → 0.1.6

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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/.coveralls.yml +1 -0
  3. data/.gitignore +7 -2
  4. data/.simplecov +10 -0
  5. data/.travis.yml +10 -4
  6. data/.yardopts +6 -0
  7. data/CHANGELOG.md +157 -0
  8. data/Guardfile +14 -0
  9. data/LICENSE +20 -22
  10. data/README.md +289 -0
  11. data/Rakefile +19 -9
  12. data/features/basic_auth.feature +3 -2
  13. data/features/batch_request.feature +7 -6
  14. data/features/cassettes/basic_auth_protected_resource.yml +57 -0
  15. data/features/cassettes/batch_request_additions.yml +69 -0
  16. data/features/cassettes/batch_request_deletes.yml +69 -0
  17. data/features/cassettes/batch_request_updates.yml +69 -0
  18. data/features/cassettes/clean_database_for_testing.yml +46 -0
  19. data/features/cassettes/cucumber_tags/basic_auth.yml +297 -0
  20. data/features/cassettes/cucumber_tags/batch_request.yml +1459 -0
  21. data/features/cassettes/cucumber_tags/complex_types.yml +326 -0
  22. data/features/cassettes/cucumber_tags/error_handling.yml +64 -0
  23. data/features/cassettes/cucumber_tags/query_builder.yml +2025 -0
  24. data/features/cassettes/cucumber_tags/service.yml +234 -0
  25. data/features/cassettes/cucumber_tags/service_manage.yml +937 -0
  26. data/features/cassettes/cucumber_tags/service_methods.yml +647 -0
  27. data/features/cassettes/cucumber_tags/ssl.yml +203 -0
  28. data/features/cassettes/cucumber_tags/type_conversion.yml +337 -0
  29. data/features/cassettes/service_manage_additions.yml +65 -0
  30. data/features/cassettes/service_manage_deletions.yml +58 -0
  31. data/features/cassettes/service_manage_deletions_2.yml +58 -0
  32. data/features/cassettes/unsecured_metadata.yml +89 -0
  33. data/features/complex_types.feature +4 -3
  34. data/features/error_handling.feature +13 -0
  35. data/features/query_builder.feature +30 -9
  36. data/features/service.feature +4 -3
  37. data/features/service_manage.feature +6 -5
  38. data/features/service_methods.feature +3 -2
  39. data/features/ssl.feature +8 -8
  40. data/features/step_definitions/service_steps.rb +32 -74
  41. data/features/support/env.rb +6 -3
  42. data/features/support/hooks.rb +3 -2
  43. data/features/support/pickle.rb +29 -18
  44. data/features/support/vcr.rb +24 -0
  45. data/features/type_conversion.feature +16 -17
  46. data/gemfiles/Gemfile.ruby187 +6 -0
  47. data/lib/ruby_odata.rb +20 -18
  48. data/lib/ruby_odata/association.rb +7 -6
  49. data/lib/ruby_odata/class_builder.rb +31 -14
  50. data/lib/ruby_odata/exceptions.rb +11 -0
  51. data/lib/ruby_odata/helpers.rb +17 -0
  52. data/lib/ruby_odata/operation.rb +5 -6
  53. data/lib/ruby_odata/property_metadata.rb +9 -7
  54. data/lib/ruby_odata/query_builder.rb +127 -63
  55. data/lib/ruby_odata/service.rb +265 -147
  56. data/lib/ruby_odata/version.rb +3 -1
  57. data/ruby_odata.gemspec +22 -13
  58. data/spec/fixtures/error_without_message.xml +5 -0
  59. data/spec/fixtures/int64_ids/edmx_boat_service.xml +19 -0
  60. data/spec/fixtures/int64_ids/edmx_car_service.xml +21 -0
  61. data/spec/fixtures/int64_ids/result_boats.xml +26 -0
  62. data/spec/fixtures/int64_ids/result_cars.xml +28 -0
  63. data/spec/fixtures/ms_system_center/edmx_ms_system_center.xml +1645 -0
  64. data/spec/fixtures/ms_system_center/edmx_ms_system_center_v2.xml +2120 -0
  65. data/spec/fixtures/ms_system_center/hardware_profiles.xml +61 -0
  66. data/spec/fixtures/ms_system_center/virtual_machines.xml +175 -0
  67. data/spec/fixtures/ms_system_center/vm_templates.xml +1193 -0
  68. data/spec/fixtures/nested_expands/edmx_northwind.xml +557 -0
  69. data/spec/fixtures/nested_expands/northwind_products_category_expands.xml +774 -0
  70. data/spec/fixtures/sample_service/result_select_categories_expand.xml +268 -0
  71. data/spec/fixtures/sample_service/result_select_categories_no_property.xml +6 -0
  72. data/spec/fixtures/sample_service/result_select_categories_travsing_no_expand.xml +6 -0
  73. data/spec/fixtures/sample_service/result_select_products_name_price.xml +92 -0
  74. data/spec/property_metadata_spec.rb +10 -10
  75. data/spec/query_builder_spec.rb +153 -14
  76. data/spec/revised_service_spec.rb +111 -6
  77. data/spec/service_spec.rb +389 -85
  78. data/spec/spec_helper.rb +3 -0
  79. data/spec/support/sample_service_matcher.rb +15 -0
  80. data/test/RubyODataService/RubyODataService/App_Data/.gitkeep +0 -0
  81. data/test/blueprints.rb +15 -9
  82. data/test/usage_samples/querying.rb +5 -1
  83. data/test/usage_samples/sample_data.rb +1 -3
  84. metadata +276 -76
  85. data/CHANGELOG.rdoc +0 -88
  86. data/README.rdoc +0 -259
@@ -1,17 +1,17 @@
1
1
  module OData
2
-
2
+ # The main service class, also known as a *Context*
3
3
  class Service
4
- attr_reader :classes, :class_metadata, :options, :collections, :edmx, :function_imports
4
+ attr_reader :classes, :class_metadata, :options, :collections, :edmx, :function_imports, :response
5
5
  # Creates a new instance of the Service class
6
6
  #
7
- # ==== Required Attributes
8
- # - service_uri: The root URI of the OData service
9
- # ==== Options in options hash
10
- # - username: username for http basic auth
11
- # - password: password for http basic auth
12
- # - verify_ssl: false if no verification, otherwise mode (OpenSSL::SSL::VERIFY_PEER is default)
13
- # - additional_params: a hash of query string params that will be passed on all calls
14
- # - eager_partial: true (default) if queries should consume partial feeds until the feed is complete, false if explicit calls to next must be performed
7
+ # @param [String] service_uri the root URI of the OData service
8
+ # @param [Hash] options the options to pass to the service
9
+ # @option options [String] :username for http basic auth
10
+ # @option options [String] :password for http basic auth
11
+ # @option options [Object] :verify_ssl false if no verification, otherwise mode (OpenSSL::SSL::VERIFY_PEER is default)
12
+ # @option options [Hash] :rest_options a hash of rest-client options that will be passed to all RestClient::Resource.new calls
13
+ # @option options [Hash] :additional_params a hash of query string params that will be passed on all calls
14
+ # @option options [Boolean, true] :eager_partial true if queries should consume partial feeds until the feed is complete, false if explicit calls to next must be performed
15
15
  def initialize(service_uri, options = {})
16
16
  @uri = service_uri.gsub!(/\/?$/, '')
17
17
  set_options! options
@@ -20,13 +20,11 @@ class Service
20
20
  build_collections_and_classes
21
21
  end
22
22
 
23
- # Handles the dynamic AddTo<EntityName> methods as well as the collections on the service
23
+ # Handles the dynamic `AddTo<EntityName>` methods as well as the collections on the service
24
24
  def method_missing(name, *args)
25
25
  # Queries
26
26
  if @collections.include?(name.to_s)
27
- root = "/#{name.to_s}"
28
- root << "(#{args.join(',')})" unless args.empty?
29
- @query = QueryBuilder.new(root, @additional_params)
27
+ @query = build_collection_query_object(name,@additional_params, *args)
30
28
  return @query
31
29
  # Adds
32
30
  elsif name.to_s =~ /^AddTo(.*)/
@@ -45,31 +43,29 @@ class Service
45
43
 
46
44
  # Queues an object for deletion. To actually remove it from the server, you must call save_changes as well.
47
45
  #
48
- # ==== Required Attributes
49
- # - obj: The object to mark for deletion
46
+ # @param [Object] obj the object to mark for deletion
50
47
  #
51
- # Note: This method will throw an exception if the +obj+ isn't a tracked entity
48
+ # @raise [NotSupportedError] if the `obj` isn't a tracked entity
52
49
  def delete_object(obj)
53
50
  type = obj.class.to_s
54
51
  if obj.respond_to?(:__metadata) && !obj.send(:__metadata).nil?
55
52
  @save_operations << Operation.new("Delete", type, obj)
56
53
  else
57
- raise "You cannot delete a non-tracked entity"
54
+ raise OData::NotSupportedError.new "You cannot delete a non-tracked entity"
58
55
  end
59
56
  end
60
57
 
61
58
  # Queues an object for update. To actually update it on the server, you must call save_changes as well.
62
59
  #
63
- # ==== Required Attributes
64
- # - obj: The object to queue for update
60
+ # @param [Object] obj the object to queue for update
65
61
  #
66
- # Note: This method will throw an exception if the +obj+ isn't a tracked entity
62
+ # @raise [NotSupportedError] if the `obj` isn't a tracked entity
67
63
  def update_object(obj)
68
64
  type = obj.class.to_s
69
65
  if obj.respond_to?(:__metadata) && !obj.send(:__metadata).nil?
70
66
  @save_operations << Operation.new("Update", type, obj)
71
67
  else
72
- raise "You cannot update a non-tracked entity"
68
+ raise OData::NotSupportedError.new "You cannot update a non-tracked entity"
73
69
  end
74
70
  end
75
71
 
@@ -79,23 +75,34 @@ class Service
79
75
 
80
76
  result = nil
81
77
 
82
- if @save_operations.length == 1
83
- result = single_save(@save_operations[0])
84
- else
85
- result = batch_save(@save_operations)
86
- end
78
+ begin
79
+ if @save_operations.length == 1
80
+ result = single_save(@save_operations[0])
81
+ else
82
+ result = batch_save(@save_operations)
83
+ end
87
84
 
88
- # TODO: We should probably perform a check here
89
- # to make sure everything worked before clearing it out
90
- @save_operations.clear
85
+ # TODO: We should probably perform a check here
86
+ # to make sure everything worked before clearing it out
87
+ @save_operations.clear
91
88
 
92
- return result
89
+ return result
90
+ rescue Exception => e
91
+ handle_exception(e)
92
+ end
93
93
  end
94
94
 
95
- # Performs query operations (Read) against the server, returns an array of record instances.
95
+ # Performs query operations (Read) against the server.
96
+ # Typically this returns an array of record instances, except in the case of count queries
97
+ # @raise [ServiceError] if there is an error when talking to the service
96
98
  def execute
97
- result = RestClient::Resource.new(build_query_uri, @rest_options).get
98
- handle_collection_result(result)
99
+ begin
100
+ @response = RestClient::Resource.new(build_query_uri, @rest_options).get
101
+ rescue Exception => e
102
+ handle_exception(e)
103
+ end
104
+ return Integer(@response) if @response =~ /^\d+$/
105
+ handle_collection_result(@response)
99
106
  end
100
107
 
101
108
  # Overridden to identify methods handled by method_missing
@@ -118,7 +125,7 @@ class Service
118
125
  end
119
126
  end
120
127
 
121
- # Retrieves the next resultset of a partial result (if any). Does not honor the :eager_partial option.
128
+ # Retrieves the next resultset of a partial result (if any). Does not honor the `:eager_partial` option.
122
129
  def next
123
130
  return if not partial?
124
131
  handle_partial
@@ -131,14 +138,13 @@ class Service
131
138
 
132
139
  # Lazy loads a navigation property on a model
133
140
  #
134
- # ==== Required Attributes
135
- # - obj: The object to fill
136
- # - nav_prop: The navigation property to fill
141
+ # @param [Object] obj the object to fill
142
+ # @param [String] nav_prop the navigation property to fill
137
143
  #
138
- # Note: This method will throw an exception if the +obj+ isn't a tracked entity
139
- # Note: This method will throw an exception if the +nav_prop+ isn't a valid navigation property
144
+ # @raise [NotSupportedError] if the `obj` isn't a tracked entity
145
+ # @raise [ArgumentError] if the `nav_prop` isn't a valid navigation property
140
146
  def load_property(obj, nav_prop)
141
- raise ArgumentError, "You cannot load a property on an entity that isn't tracked" if obj.send(:__metadata).nil?
147
+ raise NotSupportedError, "You cannot load a property on an entity that isn't tracked" if obj.send(:__metadata).nil?
142
148
  raise ArgumentError, "'#{nav_prop}' is not a valid navigation property" unless obj.respond_to?(nav_prop.to_sym)
143
149
  raise ArgumentError, "'#{nav_prop}' is not a valid navigation property" unless @class_metadata[obj.class.to_s][nav_prop].nav_prop
144
150
  results = RestClient::Resource.new(build_load_property_uri(obj, nav_prop), @rest_options).get
@@ -148,28 +154,77 @@ class Service
148
154
 
149
155
  # Adds a child object to a parent object's collection
150
156
  #
151
- # ==== Required Attributes
152
- # - parent: The parent object
153
- # - nav_prop: The name of the navigation property to add the child to
154
- # - child: The child object
157
+ # @param [Object] parent the parent object
158
+ # @param [String] nav_prop the name of the navigation property to add the child to
159
+ # @param [Object] child the child object
160
+ # @raise [NotSupportedError] if the `parent` isn't a tracked entity
161
+ # @raise [ArgumentError] if the `nav_prop` isn't a valid navigation property
162
+ # @raise [NotSupportedError] if the `child` isn't a tracked entity
155
163
  def add_link(parent, nav_prop, child)
156
- raise ArgumentError, "You cannot add a link on an entity that isn't tracked (#{parent.class})" if parent.send(:__metadata).nil?
164
+ raise NotSupportedError, "You cannot add a link on an entity that isn't tracked (#{parent.class})" if parent.send(:__metadata).nil?
157
165
  raise ArgumentError, "'#{nav_prop}' is not a valid navigation property for #{parent.class}" unless parent.respond_to?(nav_prop.to_sym)
158
166
  raise ArgumentError, "'#{nav_prop}' is not a valid navigation property for #{parent.class}" unless @class_metadata[parent.class.to_s][nav_prop].nav_prop
159
- raise ArgumentError, "You cannot add a link on a child entity that isn't tracked (#{child.class})" if child.send(:__metadata).nil?
167
+ raise NotSupportedError, "You cannot add a link on a child entity that isn't tracked (#{child.class})" if child.send(:__metadata).nil?
160
168
  @save_operations << Operation.new("AddLink", nav_prop, parent, child)
161
169
  end
162
170
 
163
171
  private
164
172
 
173
+ # Constructs a QueryBuilder instance for a collection using the arguments provided.
174
+ #
175
+ # @param [String] name the name of the collection
176
+ # @param [Hash] additional_parameters the additional parameters
177
+ # @param [Array] args the arguments to use for query
178
+ def build_collection_query_object(name, additional_parameters, *args)
179
+ root = "/#{name.to_s}"
180
+ if args.empty?
181
+ #nothing to add
182
+ elsif args.size == 1
183
+ if args.first.to_s =~ /\d+/
184
+ id_metadata = find_id_metadata(name.to_s)
185
+ root << build_id_path(args.first, id_metadata)
186
+ else
187
+ root << "(#{args.first})"
188
+ end
189
+ else
190
+ root << "(#{args.join(',')})"
191
+ end
192
+ QueryBuilder.new(root, additional_parameters)
193
+ end
194
+
195
+ # Finds the metadata associated with the given collection's first id property
196
+ # Remarks: This is used for single item lookup queries using the ID, e.g. Products(1), not complex primary keys
197
+ #
198
+ # @param [String] collection_name the name of the collection
199
+ def find_id_metadata(collection_name)
200
+ collection_data = @collections.fetch(collection_name)
201
+ class_metadata = @class_metadata.fetch(collection_data[:type].to_s)
202
+ key = class_metadata.select{|k,h| h.is_key }.collect{|k,h| h.name }[0]
203
+ class_metadata[key]
204
+ end
205
+
206
+ # Builds the ID expression of a given id for query
207
+ #
208
+ # @param [Object] id_value the actual value to be used
209
+ # @param [PropertyMetadata] id_metadata the property metadata object for the id
210
+ def build_id_path(id_value, id_metadata)
211
+ if id_metadata.type == "Edm.Int64"
212
+ "(#{id_value}L)"
213
+ else
214
+ "(#{id_value})"
215
+ end
216
+ end
217
+
165
218
  def set_options!(options)
166
219
  @options = options
167
220
  if @options[:eager_partial].nil?
168
221
  @options[:eager_partial] = true
169
222
  end
170
223
  @rest_options = { :verify_ssl => get_verify_mode, :user => @options[:username], :password => @options[:password] }
224
+ @rest_options.merge!(options[:rest_options] || {})
171
225
  @additional_params = options[:additional_params] || {}
172
226
  @namespace = options[:namespace]
227
+ @json_type = options[:json_type] || :json
173
228
  end
174
229
 
175
230
  def default_instance_vars!
@@ -179,16 +234,16 @@ class Service
179
234
  @has_partial = false
180
235
  @next_uri = nil
181
236
  end
182
-
237
+
183
238
  def set_namespaces
184
239
  @edmx = Nokogiri::XML(RestClient::Resource.new(build_metadata_uri, @rest_options).get)
185
- @ds_namespaces = {
240
+ @ds_namespaces = {
186
241
  "m" => "http://schemas.microsoft.com/ado/2007/08/dataservices/metadata",
187
242
  "edmx" => "http://schemas.microsoft.com/ado/2007/06/edmx",
188
243
  "ds" => "http://schemas.microsoft.com/ado/2007/08/dataservices",
189
244
  "atom" => "http://www.w3.org/2005/Atom"
190
245
  }
191
-
246
+
192
247
  # Get the edm namespace from the edmx
193
248
  edm_ns = @edmx.xpath("edmx:Edmx/edmx:DataServices/*", @namespaces).first.namespaces['xmlns'].to_s
194
249
  @ds_namespaces.merge! "edm" => edm_ns
@@ -232,7 +287,7 @@ class Service
232
287
  entity_type = c["EntityType"]
233
288
  @collections[c["Name"]] = { :edmx_type => entity_type, :type => convert_to_local_type(entity_type) }
234
289
  end
235
-
290
+
236
291
  build_function_imports
237
292
  end
238
293
 
@@ -241,7 +296,18 @@ class Service
241
296
  # Fill in the function imports
242
297
  functions = @edmx.xpath("//edm:EntityContainer/edm:FunctionImport", @ds_namespaces)
243
298
  functions.each do |f|
244
- http_method = f.xpath("@m:HttpMethod", @ds_namespaces).first.content
299
+ http_method_attribute = f.xpath("@m:HttpMethod", @ds_namespaces).first # HttpMethod is no longer required http://www.odata.org/2011/10/actions-in-odata/
300
+ is_side_effecting_attribute = f.xpath("@edm:IsSideEffecting", @ds_namespaces).first
301
+
302
+ http_method = 'POST' # default to POST
303
+
304
+ if http_method_attribute
305
+ http_method = http_method_attribute.content
306
+ elsif is_side_effecting_attribute
307
+ is_side_effecting = is_side_effecting_attribute.content
308
+ http_method = is_side_effecting ? 'POST' : 'GET'
309
+ end
310
+
245
311
  return_type = f["ReturnType"]
246
312
  inner_return_type = nil
247
313
  unless return_type.nil?
@@ -258,9 +324,9 @@ class Service
258
324
  parameters[p["Name"]] = p["Type"]
259
325
  end
260
326
  end
261
- @function_imports[f["Name"]] = {
262
- :http_method => http_method,
263
- :return_type => return_type,
327
+ @function_imports[f["Name"]] = {
328
+ :http_method => http_method,
329
+ :return_type => return_type,
264
330
  :inner_return_type => inner_return_type,
265
331
  :parameters => parameters }
266
332
  end
@@ -284,17 +350,19 @@ class Service
284
350
  end
285
351
 
286
352
  # Builds the metadata need for each property for things like feed customizations and navigation properties
287
- def build_property_metadata(props)
353
+ def build_property_metadata(props, keys=[])
288
354
  metadata = {}
289
355
  props.each do |property_element|
290
356
  prop_meta = PropertyMetadata.new(property_element)
357
+ prop_meta.is_key = keys.include?(prop_meta.name)
358
+
291
359
  # If this is a navigation property, we need to add the association to the property metadata
292
360
  prop_meta.association = Association.new(property_element, @edmx) if prop_meta.nav_prop
293
361
  metadata[prop_meta.name] = prop_meta
294
362
  end
295
363
  metadata
296
364
  end
297
-
365
+
298
366
  # Handle parsing of OData Atom result and return an array of Entry classes
299
367
  def handle_collection_result(result)
300
368
  results = build_classes_from_result(result)
@@ -303,11 +371,28 @@ class Service
303
371
  end
304
372
  results
305
373
  end
306
-
374
+
375
+ # Handles errors from the OData service
376
+ def handle_exception(e)
377
+ raise e unless defined? e.response
378
+
379
+ code = e.http_code
380
+ error = Nokogiri::XML(e.response)
381
+
382
+ message = if error.xpath("m:error/m:message", @ds_namespaces).first
383
+ error.xpath("m:error/m:message", @ds_namespaces).first.content
384
+ else
385
+ "Server returned error but no message."
386
+ end
387
+ raise ServiceError.new(code), message
388
+ end
389
+
307
390
  # Loops through the standard properties (non-navigation) for a given class and returns the appropriate list of methods
308
391
  def collect_properties(klass_name, element, doc)
309
392
  props = element.xpath(".//edm:Property", @ds_namespaces)
310
- @class_metadata[klass_name] = build_property_metadata(props)
393
+ key_elemnts = element.xpath(".//edm:Key//edm:PropertyRef", @ds_namespaces)
394
+ keys = key_elemnts.collect { |k| k['Name'] }
395
+ @class_metadata[klass_name] = build_property_metadata(props, keys)
311
396
  methods = props.collect { |p| p['Name'] }
312
397
  unless element["BaseType"].nil?
313
398
  base = element["BaseType"].split(".").last()
@@ -318,7 +403,7 @@ class Service
318
403
  end
319
404
  methods
320
405
  end
321
-
406
+
322
407
  # Similar to +collect_properties+, but handles the navigation properties
323
408
  def collect_navigation_properties(klass_name, element, doc)
324
409
  nav_props = element.xpath(".//edm:NavigationProperty", @ds_namespaces)
@@ -329,14 +414,14 @@ class Service
329
414
  # Helper to loop through a result and create an instance for each entity in the results
330
415
  def build_classes_from_result(result)
331
416
  doc = Nokogiri::XML(result)
332
-
417
+
333
418
  is_links = doc.at_xpath("/ds:links", @ds_namespaces)
334
419
  return parse_link_results(doc) if is_links
335
-
420
+
336
421
  entries = doc.xpath("//atom:entry[not(ancestor::atom:entry)]", @ds_namespaces)
337
-
422
+
338
423
  extract_partial(doc)
339
-
424
+
340
425
  results = []
341
426
  entries.each do |entry|
342
427
  results << entry_to_class(entry)
@@ -348,24 +433,20 @@ class Service
348
433
  def entry_to_class(entry)
349
434
  # Retrieve the class name from the fully qualified name (the last string after the last dot)
350
435
  klass_name = entry.xpath("./atom:category/@term", @ds_namespaces).to_s.split('.')[-1]
351
-
436
+
352
437
  # Is the category missing? See if there is a title that we can use to build the class
353
438
  if klass_name.nil?
354
439
  title = entry.xpath("./atom:title", @ds_namespaces).first
355
440
  return nil if title.nil?
356
441
  klass_name = title.content.to_s
357
442
  end
358
-
443
+
359
444
  return nil if klass_name.nil?
360
445
 
361
- # If we are working against a child (inline) entry, we need to use the more generic xpath because a child entry WILL
362
- # have properties that are ancestors of m:inline. Check if there is an m:inline child to determine the xpath query to use
363
- has_inline = entry.xpath(".//m:inline", @ds_namespaces).any?
364
- properties_xpath = has_inline ? ".//m:properties[not(ancestor::m:inline)]/*" : ".//m:properties/*"
365
- properties = entry.xpath(properties_xpath, @ds_namespaces)
446
+ properties = entry.xpath("./atom:content/m:properties/*", @ds_namespaces)
366
447
 
367
448
  klass = @classes[qualify_class_name(klass_name)].new
368
-
449
+
369
450
  # Fill metadata
370
451
  meta_id = entry.xpath("./atom:id", @ds_namespaces)[0].content
371
452
  klass.send :__metadata=, { :uri => meta_id }
@@ -373,9 +454,9 @@ class Service
373
454
  # Fill properties
374
455
  for prop in properties
375
456
  prop_name = prop.name
376
- klass.send "#{prop_name}=", parse_value(prop)
457
+ klass.send "#{prop_name}=", parse_value_xml(prop)
377
458
  end
378
-
459
+
379
460
  # Fill properties represented outside of the properties collection
380
461
  @class_metadata[qualify_class_name(klass_name)].select { |k,v| v.fc_keep_in_content == false }.each do |k, meta|
381
462
  if meta.fc_target_path == "SyndicationTitle"
@@ -386,19 +467,18 @@ class Service
386
467
  klass.send "#{meta.name}=", summary.content
387
468
  end
388
469
  end
389
-
470
+
390
471
  inline_links = entry.xpath("./atom:link[m:inline]", @ds_namespaces)
391
-
392
- for link in inline_links
393
- inline_entries = link.xpath(".//atom:entry", @ds_namespaces)
394
472
 
395
- # TODO: Use the metadata's associations to determine the multiplicity instead of this "hack"
473
+ for link in inline_links
474
+ # TODO: Use the metadata's associations to determine the multiplicity instead of this "hack"
396
475
  property_name = link.attributes['title'].to_s
397
- if inline_entries.length == 1 && singular?(property_name)
398
- inline_klass = build_inline_class(klass, inline_entries[0], property_name)
476
+ if singular?(property_name)
477
+ inline_entry = link.xpath("./m:inline/atom:entry", @ds_namespaces).first
478
+ inline_klass = build_inline_class(klass, inline_entry, property_name)
399
479
  klass.send "#{property_name}=", inline_klass
400
480
  else
401
- inline_classes = []
481
+ inline_classes, inline_entries = [], link.xpath("./m:inline/atom:feed/atom:entry", @ds_namespaces)
402
482
  for inline_entry in inline_entries
403
483
  # Build the class
404
484
  inline_klass = entry_to_class(inline_entry)
@@ -412,17 +492,21 @@ class Service
412
492
  klass.send "#{property_name}=", inline_classes
413
493
  end
414
494
  end
415
-
495
+
416
496
  klass
417
497
  end
418
-
498
+
419
499
  # Tests for and extracts the next href of a partial
420
500
  def extract_partial(doc)
421
501
  next_links = doc.xpath('//atom:link[@rel="next"]', @ds_namespaces)
422
502
  @has_partial = next_links.any?
423
- @next_uri = next_links[0]['href'] if @has_partial
503
+ if @has_partial
504
+ uri = Addressable::URI.parse(next_links[0]['href'])
505
+ uri.query_values = uri.query_values.merge @additional_params unless @additional_params.empty?
506
+ @next_uri = uri.to_s
507
+ end
424
508
  end
425
-
509
+
426
510
  def handle_partial
427
511
  if @next_uri
428
512
  result = RestClient::Resource.new(@next_uri, @rest_options).get
@@ -430,7 +514,7 @@ class Service
430
514
  end
431
515
  results
432
516
  end
433
-
517
+
434
518
  # Handle link results
435
519
  def parse_link_results(doc)
436
520
  uris = doc.xpath("/ds:links/ds:uri", @ds_namespaces)
@@ -440,7 +524,7 @@ class Service
440
524
  results << URI.parse(link)
441
525
  end
442
526
  results
443
- end
527
+ end
444
528
 
445
529
  # Build URIs
446
530
  def build_metadata_uri
@@ -457,23 +541,23 @@ class Service
457
541
  uri
458
542
  end
459
543
  def build_add_link_uri(operation)
460
- uri = "#{operation.klass.send(:__metadata)[:uri]}"
544
+ uri = operation.klass.send(:__metadata)[:uri].dup
461
545
  uri << "/$links/#{operation.klass_name}"
462
546
  uri << "?#{@additional_params.to_query}" unless @additional_params.empty?
463
547
  uri
464
548
  end
465
549
  def build_resource_uri(operation)
466
- uri = operation.klass.send(:__metadata)[:uri]
550
+ uri = operation.klass.send(:__metadata)[:uri].dup
467
551
  uri << "?#{@additional_params.to_query}" unless @additional_params.empty?
468
552
  uri
469
553
  end
470
554
  def build_batch_uri
471
555
  uri = "#{@uri}/$batch"
472
556
  uri << "?#{@additional_params.to_query}" unless @additional_params.empty?
473
- uri
557
+ uri
474
558
  end
475
559
  def build_load_property_uri(obj, property)
476
- uri = obj.__metadata[:uri]
560
+ uri = obj.__metadata[:uri].dup
477
561
  uri << "/#{property}"
478
562
  uri
479
563
  end
@@ -483,11 +567,11 @@ class Service
483
567
  uri << "?#{params.to_query}" unless params.empty?
484
568
  uri
485
569
  end
486
-
570
+
487
571
  def build_inline_class(klass, entry, property_name)
488
572
  # Build the class
489
573
  inline_klass = entry_to_class(entry)
490
-
574
+
491
575
  # Add the property
492
576
  klass.send "#{property_name}=", inline_klass
493
577
  end
@@ -516,12 +600,12 @@ class Service
516
600
  if operation.kind == "Add"
517
601
  save_uri = build_save_uri(operation)
518
602
  json_klass = operation.klass.to_json(:type => :add)
519
- post_result = RestClient::Resource.new(save_uri, @rest_options).post json_klass, {:content_type => :json}
603
+ post_result = RestClient::Resource.new(save_uri, @rest_options).post json_klass, {:content_type => @json_type}
520
604
  return build_classes_from_result(post_result)
521
605
  elsif operation.kind == "Update"
522
606
  update_uri = build_resource_uri(operation)
523
607
  json_klass = operation.klass.to_json
524
- update_result = RestClient::Resource.new(update_uri, @rest_options).put json_klass, {:content_type => :json}
608
+ update_result = RestClient::Resource.new(update_uri, @rest_options).put json_klass, {:content_type => @json_type}
525
609
  return (update_result.code == 204)
526
610
  elsif operation.kind == "Delete"
527
611
  delete_uri = build_resource_uri(operation)
@@ -530,7 +614,7 @@ class Service
530
614
  elsif operation.kind == "AddLink"
531
615
  save_uri = build_add_link_uri(operation)
532
616
  json_klass = operation.child_klass.to_json(:type => :link)
533
- post_result = RestClient::Resource.new(save_uri, @rest_options).post json_klass, {:content_type => :json}
617
+ post_result = RestClient::Resource.new(save_uri, @rest_options).post json_klass, {:content_type => @json_type}
534
618
 
535
619
  # Attach the child to the parent
536
620
  link_child_to_parent(operation) if (post_result.code == 204)
@@ -543,20 +627,20 @@ class Service
543
627
  def generate_guid
544
628
  rand(36**12).to_s(36).insert(4, "-").insert(9, "-")
545
629
  end
546
- def batch_save(operations)
630
+ def batch_save(operations)
547
631
  batch_num = generate_guid
548
632
  changeset_num = generate_guid
549
633
  batch_uri = build_batch_uri
550
-
634
+
551
635
  body = build_batch_body(operations, batch_num, changeset_num)
552
636
  result = RestClient::Resource.new( batch_uri, @rest_options).post body, {:content_type => "multipart/mixed; boundary=batch_#{batch_num}"}
553
637
 
554
- # TODO: More result validation needs to be done.
638
+ # TODO: More result validation needs to be done.
555
639
  # The result returns HTTP 202 even if there is an error in the batch
556
640
  return (result.code == 202)
557
641
  end
558
642
  def build_batch_body(operations, batch_num, changeset_num)
559
- # Header
643
+ # Header
560
644
  body = "--batch_#{batch_num}\n"
561
645
  body << "Content-Type: multipart/mixed;boundary=changeset_#{changeset_num}\n\n"
562
646
 
@@ -565,115 +649,149 @@ class Service
565
649
  body << build_batch_operation(operation, changeset_num)
566
650
  body << "\n"
567
651
  end
568
-
569
- # Footer
652
+
653
+ # Footer
570
654
  body << "\n\n--changeset_#{changeset_num}--\n"
571
655
  body << "--batch_#{batch_num}--"
572
-
656
+
573
657
  return body
574
658
  end
575
659
  def build_batch_operation(operation, changeset_num)
576
660
  accept_headers = "Accept-Charset: utf-8\n"
577
661
  accept_headers << "Content-Type: application/json;charset=utf-8\n" unless operation.kind == "Delete"
578
662
  accept_headers << "\n"
579
-
663
+
580
664
  content = "--changeset_#{changeset_num}\n"
581
665
  content << "Content-Type: application/http\n"
582
666
  content << "Content-Transfer-Encoding: binary\n\n"
583
-
584
- if operation.kind == "Add"
667
+
668
+ if operation.kind == "Add"
585
669
  save_uri = "#{@uri}/#{operation.klass_name}"
586
670
  json_klass = operation.klass.to_json(:type => :add)
587
-
671
+
588
672
  content << "POST #{save_uri} HTTP/1.1\n"
589
673
  content << accept_headers
590
674
  content << json_klass
591
675
  elsif operation.kind == "Update"
592
676
  update_uri = operation.klass.send(:__metadata)[:uri]
593
677
  json_klass = operation.klass.to_json
594
-
678
+
595
679
  content << "PUT #{update_uri} HTTP/1.1\n"
596
680
  content << accept_headers
597
681
  content << json_klass
598
682
  elsif operation.kind == "Delete"
599
683
  delete_uri = operation.klass.send(:__metadata)[:uri]
600
-
684
+
601
685
  content << "DELETE #{delete_uri} HTTP/1.1\n"
602
686
  content << accept_headers
603
687
  elsif
604
688
  save_uri = build_add_link_uri(operation)
605
689
  json_klass = operation.child_klass.to_json(:type => :link)
606
-
690
+
607
691
  content << "POST #{save_uri} HTTP/1.1\n"
608
692
  content << accept_headers
609
693
  content << json_klass
610
694
  link_child_to_parent(operation)
611
695
  end
612
-
696
+
613
697
  return content
614
698
  end
615
699
 
616
700
  # Complex Types
617
701
  def complex_type_to_class(complex_type_xml)
618
- klass_name = qualify_class_name(complex_type_xml.attr('type').split('.')[-1])
619
- klass = @classes[klass_name].new
702
+ type = Helpers.get_namespaced_attribute(complex_type_xml, 'type', 'm')
703
+
704
+ is_collection = false
705
+ # Extract the class name in case this is a Collection
706
+ if type =~ /\(([^)]*)\)/m
707
+ type = $~[1]
708
+ is_collection = true
709
+ collection = []
710
+ end
711
+
712
+ klass_name = qualify_class_name(type.split('.')[-1])
713
+
714
+ if is_collection
715
+ # extract the elements from the collection
716
+ elements = complex_type_xml.xpath(".//d:element", @namespaces)
717
+ elements.each do |e|
718
+ if type.match(/^Edm/)
719
+ collection << parse_value(e.content, type)
720
+ else
721
+ element = @classes[klass_name].new
722
+ fill_complex_type_properties(e, element)
723
+ collection << element
724
+ end
725
+ end
726
+ return collection
727
+ else
728
+ klass = @classes[klass_name].new
729
+ # Fill in the properties
730
+ fill_complex_type_properties(complex_type_xml, klass)
731
+ return klass
732
+ end
733
+ end
620
734
 
621
- # Fill in the properties
735
+ # Helper method for complex_type_to_class
736
+ def fill_complex_type_properties(complex_type_xml, klass)
622
737
  properties = complex_type_xml.xpath(".//*")
623
738
  properties.each do |prop|
624
- klass.send "#{prop.name}=", parse_value(prop)
739
+ klass.send "#{prop.name}=", parse_value_xml(prop)
625
740
  end
626
-
627
- return klass
628
741
  end
629
742
 
630
743
  # Field Converters
631
-
744
+
632
745
  # Handles parsing datetimes from a string
633
746
  def parse_date(sdate)
634
747
  # Assume this is UTC if no timezone is specified
635
748
  sdate = sdate + "Z" unless sdate.match(/Z|([+|-]\d{2}:\d{2})$/)
636
-
749
+
637
750
  # This is to handle older versions of Ruby (e.g. ruby 1.8.7 (2010-12-23 patchlevel 330) [i386-mingw32])
638
751
  # See http://makandra.com/notes/1017-maximum-representable-value-for-a-ruby-time-object
639
752
  # In recent versions of Ruby, Time has a much larger range
640
753
  begin
641
- result = Time.parse(sdate)
754
+ result = Time.parse(sdate)
642
755
  rescue ArgumentError
643
756
  result = DateTime.parse(sdate)
644
757
  end
645
-
758
+
646
759
  return result
647
760
  end
648
-
761
+
649
762
  # Parses a value into the proper type based on an xml property element
650
- def parse_value(property_xml)
651
- property_type = property_xml.attr('type')
652
- property_null = property_xml.attr('null')
763
+ def parse_value_xml(property_xml)
764
+ property_type = Helpers.get_namespaced_attribute(property_xml, 'type', 'm')
765
+ property_null = Helpers.get_namespaced_attribute(property_xml, 'null', 'm')
653
766
 
654
- # Handle a nil property type, this is a string
655
- return property_xml.content if property_type.nil?
767
+ if property_type.nil? || (property_type && property_type.match(/^Edm/))
768
+ return parse_value(property_xml.content, property_type, property_null)
769
+ end
656
770
 
771
+ complex_type_to_class(property_xml)
772
+ end
773
+
774
+ def parse_value(content, property_type = nil, property_null = nil)
657
775
  # Handle anything marked as null
658
776
  return nil if !property_null.nil? && property_null == "true"
659
777
 
660
- # Handle complex types
661
- return complex_type_to_class(property_xml) if !property_type.match(/^Edm/)
778
+ # Handle a nil property type, this is a string
779
+ return content if property_type.nil?
662
780
 
663
781
  # Handle integers
664
- return property_xml.content.to_i if property_type.match(/^Edm.Int/)
782
+ return content.to_i if property_type.match(/^Edm.Int/)
665
783
 
666
784
  # Handle decimals
667
- return property_xml.content.to_d if property_type.match(/Edm.Decimal/)
785
+ return content.to_d if property_type.match(/Edm.Decimal/)
668
786
 
669
787
  # Handle DateTimes
670
788
  # return Time.parse(property_xml.content) if property_type.match(/Edm.DateTime/)
671
- return parse_date(property_xml.content) if property_type.match(/Edm.DateTime/)
789
+ return parse_date(content) if property_type.match(/Edm.DateTime/)
672
790
 
673
791
  # If we can't parse the value, just return the element's content
674
- property_xml.content
792
+ content
675
793
  end
676
-
794
+
677
795
  # Parses a value into the proper type based on a specified return type
678
796
  def parse_primative_type(value, return_type)
679
797
  return value.to_i if return_type == Fixnum
@@ -681,7 +799,7 @@ class Service
681
799
  return parse_date(value.to_s) if return_type == Time
682
800
  return value.to_s
683
801
  end
684
-
802
+
685
803
  # Converts an edm type (string) to a ruby type
686
804
  def edm_to_ruby_type(edm_type)
687
805
  return String if edm_type =~ /Edm.String/
@@ -690,30 +808,30 @@ class Service
690
808
  return Time if edm_type =~ /Edm.DateTime/
691
809
  return String
692
810
  end
693
-
811
+
694
812
  # Method Missing Handlers
695
-
813
+
696
814
  # Executes an import function
697
815
  def execute_import_function(name, *args)
698
816
  func = @function_imports[name]
699
-
817
+
700
818
  # Check the args making sure that more weren't passed in than the function needs
701
819
  param_count = func[:parameters].nil? ? 0 : func[:parameters].count
702
820
  arg_count = args.nil? ? 0 : args[0].count
703
821
  if arg_count > param_count
704
822
  raise ArgumentError, "wrong number of arguments (#{arg_count} for #{param_count})"
705
823
  end
706
-
824
+
707
825
  # Convert the parameters to a hash
708
826
  params = {}
709
827
  func[:parameters].keys.each_with_index { |key, i| params[key] = args[0][i] } unless func[:parameters].nil?
710
-
828
+
711
829
  function_uri = build_function_import_uri(name, params)
712
830
  result = RestClient::Resource.new(function_uri, @rest_options).send(func[:http_method].downcase, {})
713
-
831
+
714
832
  # Is this a 204 (No content) result?
715
833
  return true if result.code == 204
716
-
834
+
717
835
  # No? Then we need to parse the results. There are 4 kinds...
718
836
  if func[:return_type] == Array
719
837
  # a collection of entites
@@ -726,19 +844,19 @@ class Service
726
844
  end
727
845
  return results
728
846
  end
729
-
847
+
730
848
  # a single entity
731
849
  if @classes.include?(func[:return_type].to_s)
732
850
  entry = Nokogiri::XML(result).xpath("atom:entry[not(ancestor::atom:entry)]", @ds_namespaces)
733
851
  return entry_to_class(entry)
734
852
  end
735
-
853
+
736
854
  # or a single native type
737
855
  unless func[:return_type].nil?
738
856
  e = Nokogiri::XML(result).xpath("/*").first
739
857
  return parse_primative_type(e.content, func[:return_type])
740
858
  end
741
-
859
+
742
860
  # Nothing could be parsed, so just return if we got a 200 or not
743
861
  return (result.code == 200)
744
862
  end
@@ -749,4 +867,4 @@ class Service
749
867
  end
750
868
  end
751
869
 
752
- end # module OData
870
+ end # module OData