diff --git a/CHANGELOG.rdoc b/CHANGELOG.rdoc index 177e2fe..059f6ff 100644 --- a/CHANGELOG.rdoc +++ b/CHANGELOG.rdoc @@ -72,4 +72,5 @@ === 0.1.0 * New Features - * Added support for single layer inheritance (thanks to http://odetocode.com/Blogs/scott/archive/2010/07/11/odata-and-ruby.aspx) \ No newline at end of file + * Added support for single layer inheritance (thanks to http://odetocode.com/Blogs/scott/archive/2010/07/11/odata-and-ruby.aspx) + * Added support for querying links (see https://github.com/visoft/ruby_odata/issues/10) \ No newline at end of file diff --git a/README.rdoc b/README.rdoc index 082e5ea..6408dd4 100644 --- a/README.rdoc +++ b/README.rdoc @@ -165,6 +165,11 @@ Top allows you only retrieve the top X number of records when querying. This is svc.Products.top(5) products = svc.execute # => returns only the first 5 items +=== Navigation Property Links Only Query +OData allows you to {query navigation properties and only return the links for the entities}[http://www.odata.org/developers/protocols/uri-conventions#AddressingLinksBetweenEntries] (instead of the data) + svc.Categories(1).links("Products") + product_links = svc.execute # => returns URIs for the products under the Category with an ID of 1 + === Partial feeds OData allows services to do server-side paging in Atom by defining a next link. The default behavior is to repeatedly consume partial feeds until the result set is complete. diff --git a/features/query_builder.feature b/features/query_builder.feature index fc01293..bbe6a6b 100644 --- a/features/query_builder.feature +++ b/features/query_builder.feature @@ -146,8 +146,18 @@ Scenario: Top should be able to be used along with skip for paging | Product 4 | - - - - - +# Links +@current +Scenario: Navigation Properties should be able to represented as links + Given I call "AddToCategories" on the service with a new "Category" object with Name: "Test Category" + And I save changes + And the following Products exist: + | Name | Category | + | Product 1 | @@LastSave.first | + | Product 2 | @@LastSave.first | + | Product 3 | @@LastSave.first | + When I call "Categories" on the service with args: "1" + And I ask for the links for "Products" + And I run the query + Then the result count should be 3 + Then the method "path" on the result object should equal: "/SampleService/Entities.svc/Products(1)" diff --git a/features/step_definitions/service_steps.rb b/features/step_definitions/service_steps.rb index ccebec1..1bc77d4 100644 --- a/features/step_definitions/service_steps.rb +++ b/features/step_definitions/service_steps.rb @@ -9,6 +9,45 @@ def first(results) BASICAUTH_URL = "http://#{WEBSERVER}:#{HTTP_PORT_NUMBER}/SampleService/BasicAuth/Entities.svc" HTTPS_BASICAUTH_URL = "https://#{WEBSERVER}:#{HTTPS_PORT_NUMBER}/SampleService/BasicAuth/Entities.svc" +# Helper methods +def handle_last_save_fields(val) + if val =~ /^@@LastSave.first$/ + val = @saved_result.first + end + if val =~ /^@@LastSave$/ + val = @saved_result + end + val +end + +def parse_fields_string(fields) + fields_hash = {} + + if !fields.nil? + fields.split(', ').each do |field| + if field =~ /^(?:(\w+): "(.*)")$/ + key = $1 + val = handle_last_save_fields($2) + + fields_hash.merge!({key => val}) + end + end + end + fields_hash +end + +def parse_fields_hash(fields) + fields_hash = {} + + if !fields.nil? + fields.each do |key, val| + val = handle_last_save_fields(val) + fields_hash.merge!({key => val}) + end + end + fields_hash +end + When /^(.*) first (.*)$/ do |step, results| first(results) { When step } end @@ -89,6 +128,10 @@ def first(results) @service_result.send(method.to_sym).to_s.should == value end +Then /^the method "([^\"]*)" on the result object should equal: "([^\"]*)"$/ do |method, value| + @service_result.first.send(method.to_sym).to_s.should == value +end + Then /^the method "([^\"]*)" on the result should be nil$/ do |method| @service_result.send(method.to_sym).should == nil end @@ -121,30 +164,17 @@ def first(results) @service_query.top(top) end +When /^I ask for the links for "([^\"]*)"$/ do |nav_prop| + @service_query.links(nav_prop) +end + Then /^the method "([^\"]*)" on the result should be of type "([^\"]*)"$/ do |method, type| result = @service_result.send(method.to_sym) result.class.to_s.should == type end Given /^I call "([^\"]*)" on the service with a new "([^\"]*)" object(?: with (.*))?$/ do |method, object, fields| - fields_hash = {} - - if !fields.nil? - fields.split(', ').each do |field| - if field =~ /^(?:(\w+): "(.*)")$/ - key = $1 - val = $2 - if val =~ /^@@LastSave.first$/ - val = @saved_result.first - end - if val =~ /^@@LastSave$/ - val = @saved_result - end - - fields_hash.merge!({ key => val }) - end - end - end + fields_hash = parse_fields_string(fields) obj = object.constantize.send(:make, fields_hash) @service.send(method.to_sym, obj) @@ -197,11 +227,12 @@ def first(results) results.should == [] end + Given /^the following (.*) exist:$/ do |plural_factory, table| # table is a Cucumber::Ast::Table factory = plural_factory.singularize table.hashes.map do |hash| - obj = factory.constantize.send(:make, hash) + obj = factory.constantize.send(:make, parse_fields_hash(hash)) @service.send("AddTo#{plural_factory}", obj) @service.save_changes end @@ -326,4 +357,12 @@ def first(results) else @service_result.send(methods[0]).send(methods[1]).xmlschema(3).should == @stored_query_result.send(methods[0]).send(methods[1]).xmlschema(3) end +end + +Then /^show me the results$/ do + puts @service_result +end + +Then /^the result count should be (\d+)$/ do |expected_count| + @service_result.count.should eq expected_count.to_i end \ No newline at end of file diff --git a/lib/ruby_odata/query_builder.rb b/lib/ruby_odata/query_builder.rb index 72409bd..ef748e1 100644 --- a/lib/ruby_odata/query_builder.rb +++ b/lib/ruby_odata/query_builder.rb @@ -19,6 +19,7 @@ def initialize(root, additional_params = {}) @order_bys = [] @skip = nil @top = nil + @links = nil @additional_params = additional_params end @@ -89,10 +90,28 @@ def top(num) self end + # Used to return links instead of actual objects + # ==== Required Attributes + # - navigation_property: The NavigationProperty name to retrieve the links for + # + # ==== Example + # svc.Categories(1).links("Products") + # product_links = svc.execute # => returns URIs for the products under the Category with an ID of 1 + def links(navigation_property) + @navigation_property = navigation_property + self + end + # Builds the query URI (path, not including root) incorporating expands, filters, etc. # This is used internally when the execute method is called on the service def query q = @root.clone + + # Handle links queries, this isn't just a standard query option + if @navigation_property + q << "/$links/#{@navigation_property}" + end + query_options = [] query_options << "$expand=#{@expands.join(',')}" unless @expands.empty? query_options << "$filter=#{@filters.join('+and+')}" unless @filters.empty? diff --git a/lib/ruby_odata/service.rb b/lib/ruby_odata/service.rb index 11985c9..93d34d2 100644 --- a/lib/ruby_odata/service.rb +++ b/lib/ruby_odata/service.rb @@ -223,6 +223,10 @@ def collect_properties(klass_name, edm_ns, element, doc) # Helper to loop through a result and create an instance for each entity in the results def build_classes_from_result(result) doc = Nokogiri::XML(result) + + is_links = doc.at_xpath("/ds:links", "ds" => "http://schemas.microsoft.com/ado/2007/08/dataservices") + return parse_link_results(doc) if is_links + entries = doc.xpath("//atom:entry[not(ancestor::atom:entry)]", "atom" => "http://www.w3.org/2005/Atom") extract_partial(doc) @@ -320,7 +324,17 @@ def handle_partial end results end - + + # Handle link results + def parse_link_results(doc) + uris = doc.xpath("/ds:links/ds:uri", "ds" => "http://schemas.microsoft.com/ado/2007/08/dataservices") + results = [] + uris.each do |uri_el| + link = uri_el.content + results << URI.parse(link) + end + results + end # Build URIs def build_metadata_uri diff --git a/spec/fixtures/edmx_categories_products.xml b/spec/fixtures/edmx_categories_products.xml new file mode 100644 index 0000000..b8bb17a --- /dev/null +++ b/spec/fixtures/edmx_categories_products.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/spec/fixtures/links/result_links_query.xml b/spec/fixtures/links/result_links_query.xml new file mode 100644 index 0000000..f68dd88 --- /dev/null +++ b/spec/fixtures/links/result_links_query.xml @@ -0,0 +1,6 @@ + + + http://test.com/SampleService/Entities.svc/Products(1) + http://test.com/SampleService/Entities.svc/Products(2) + http://test.com/SampleService/Entities.svc/Products(3) + \ No newline at end of file diff --git a/spec/query_builder_spec.rb b/spec/query_builder_spec.rb index e0c80b0..1bd83fe 100644 --- a/spec/query_builder_spec.rb +++ b/spec/query_builder_spec.rb @@ -18,6 +18,17 @@ module OData builder.top(10) builder.query.should eq "Products?$top=10&x=1&y=2" end + it "should properly handle queries for links" do + builder = QueryBuilder.new 'Categories(1)' + builder.links('Products') + builder.query.should eq "Categories(1)/$links/Products" + end + it "should properly handle queries for links with additional operations" do + builder = QueryBuilder.new 'Categories(1)' + builder.links('Products') + builder.top(5) + builder.query.should eq "Categories(1)/$links/Products?$top=5" + end end end end \ No newline at end of file diff --git a/spec/service_spec.rb b/spec/service_spec.rb index 6621107..0e7300b 100644 --- a/spec/service_spec.rb +++ b/spec/service_spec.rb @@ -293,50 +293,73 @@ module OData course.Category.should_not be_nil end end -end - describe "handling partial collections" do - before(:each) do - # Metadata - stub_request(:get, "http://test.com/test.svc/$metadata"). - with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). - to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/partial/partial_feed_metadata.xml", __FILE__)), :headers => {}) - - # Content - Partial - stub_request(:get, "http://test.com/test.svc/Partials"). - with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). - to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/partial/partial_feed_part_1.xml", __FILE__)), :headers => {}) - - stub_request(:get, "http://test.com/test.svc/Partials?$skiptoken='ERNSH'"). - with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). - to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/partial/partial_feed_part_2.xml", __FILE__)), :headers => {}) + describe "handling partial collections" do + before(:each) do + # Metadata + stub_request(:get, "http://test.com/test.svc/$metadata"). + with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). + to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/partial/partial_feed_metadata.xml", __FILE__)), :headers => {}) - stub_request(:get, "http://test.com/test.svc/Partials?$skiptoken='ERNSH2'"). - with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). - to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/partial/partial_feed_part_3.xml", __FILE__)), :headers => {}) - - end - - it "should return the whole collection by default" do - svc = OData::Service.new "http://test.com/test.svc/" - svc.Partials - results = svc.execute - results.count.should == 3 + # Content - Partial + stub_request(:get, "http://test.com/test.svc/Partials"). + with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). + to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/partial/partial_feed_part_1.xml", __FILE__)), :headers => {}) + + stub_request(:get, "http://test.com/test.svc/Partials?$skiptoken='ERNSH'"). + with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). + to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/partial/partial_feed_part_2.xml", __FILE__)), :headers => {}) + + stub_request(:get, "http://test.com/test.svc/Partials?$skiptoken='ERNSH2'"). + with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). + to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/partial/partial_feed_part_3.xml", __FILE__)), :headers => {}) + + end + + it "should return the whole collection by default" do + svc = OData::Service.new "http://test.com/test.svc/" + svc.Partials + results = svc.execute + results.count.should == 3 + end + + it "should return only the partial when specified by options" do + svc = OData::Service.new("http://test.com/test.svc/", :eager_partial => false) + svc.Partials + results = svc.execute + results.count.should == 1 + svc.should be_partial + while svc.partial? + results.concat svc.next + end + results.count.should == 3 + end end - - it "should return only the partial when specified by options" do - svc = OData::Service.new("http://test.com/test.svc/", :eager_partial => false) - svc.Partials - results = svc.execute - results.count.should == 1 - svc.should be_partial - while svc.partial? - results.concat svc.next + + describe "link queries" do + before(:each) do + # Required for the build_classes method + stub_request(:get, /http:\/\/test\.com\/test\.svc\/\$metadata(?:\?.+)?/). + with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). + to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/edmx_categories_products.xml", __FILE__)), :headers => {}) + + stub_request(:get, "http://test.com/test.svc/Categories(1)/$links/Products"). + with(:headers => {'Accept'=>'*/*; q=0.5, application/xml', 'Accept-Encoding'=>'gzip, deflate'}). + to_return(:status => 200, :body => File.new(File.expand_path("../fixtures/links/result_links_query.xml", __FILE__)), :headers => {}) + end + it "should be able to parse the results of a links query" do + svc = OData::Service.new "http://test.com/test.svc/" + svc.Categories(1).links('Products') + results = svc.execute + results.count.should eq 3 + results.first.should be_a_kind_of(URI) + results[0].path.should eq "/SampleService/Entities.svc/Products(1)" + results[1].path.should eq "/SampleService/Entities.svc/Products(2)" + results[2].path.should eq "/SampleService/Entities.svc/Products(3)" end - results.count.should == 3 end end - + describe_private OData::Service do describe "parse value" do before(:each) do