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