diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 00000000..9c4d4ac8 --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1 @@ +repo_token: VCzjHF1tTiWms12qHsCsrQyId4YWSQlfH diff --git a/.gemspec b/.gemspec deleted file mode 100755 index 5e0b576e..00000000 --- a/.gemspec +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env ruby -rubygems -# -*- encoding: utf-8 -*- - -Gem::Specification.new do |gem| - gem.version = '1.0.1' - gem.date = '2012-11-21' - gem.name = 'sparql-client' - gem.homepage = 'http://ruby-rdf.github.com/sparql-client/' - gem.license = 'Public Domain' if gem.respond_to?(:license=) - gem.summary = 'SPARQL client for RDF.rb.' - gem.description = %(Executes SPARQL queries and updates against a remote SPARQL 1.0 or 1.1 endpoint, - or against a local repository. Generates SPARQL queries using a simple DSL.) - gem.rubyforge_project = 'sparql-client' - - gem.authors = ['Arto Bendiken', 'Ben Lavender', 'Gregg Kellogg'] - gem.email = 'public-rdf-ruby@w3.org' - - gem.platform = Gem::Platform::RUBY - gem.files = %w(AUTHORS CREDITS README UNLICENSE VERSION) + Dir.glob('lib/**/*.rb') - gem.bindir = %q(bin) - gem.executables = %w() - gem.require_paths = %w(lib) - gem.extensions = %w() - gem.test_files = %w() - - gem.required_ruby_version = '>= 1.8.1' - gem.requirements = [] - gem.add_runtime_dependency 'rdf', '>= 1.0' - gem.add_runtime_dependency 'net-http-persistent', '2.9.4' - gem.add_runtime_dependency 'json_pure', '>= 1.4' - gem.add_development_dependency 'sparql', '>= 1.0' unless RUBY_VERSION < "1.9" - gem.add_development_dependency 'rdf-spec', '>= 1.0' - gem.add_development_dependency 'rspec', '>= 2.14' - gem.add_development_dependency 'yard' , '>= 0.8' - gem.post_install_message = nil -end diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..5d5c6c76 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,39 @@ +# This workflow runs continuous CI across different versions of ruby on all branches and pull requests to develop. + +name: CI +on: + push: + branches: [ '**' ] + pull_request: + branches: [ develop ] + workflow_dispatch: + +jobs: + tests: + name: Ruby ${{ matrix.ruby }} ${{ matrix.gemfile }} + if: "contains(github.event.commits[0].message, '[ci skip]') == false" + runs-on: ubuntu-latest + env: + CI: true + BUNDLE_GEMFILE: "${{ matrix.gemfile }}" + ALLOW_FAILURES: ${{ endsWith(matrix.ruby, 'head') }} + strategy: + fail-fast: false + matrix: + ruby: [2.6, 2.7, '3.0', 3.1, 3.2, ruby-head, jruby] + steps: + - name: Clone repository + uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + - name: Install dependencies + run: bundle install --jobs 4 --retry 3 + - name: Run tests + run: ruby --version; bundle exec rspec spec || $ALLOW_FAILURES + - name: Coveralls GitHub Action + uses: coverallsapp/github-action@v2 + if: ${{ matrix.ruby == '3.0' && matrix.gemfile == 'Gemfile' }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/generate-docs.yml b/.github/workflows/generate-docs.yml new file mode 100644 index 00000000..65aea937 --- /dev/null +++ b/.github/workflows/generate-docs.yml @@ -0,0 +1,27 @@ +name: Build & deploy documentation +on: + push: + branches: + - master + workflow_dispatch: +jobs: + build: + runs-on: ubuntu-latest + name: Update gh-pages with docs + steps: + - name: Clone repository + uses: actions/checkout@v3 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.1" + - name: Install required gem dependencies + run: gem install yard --no-document + - name: Build YARD Ruby Documentation + run: yardoc + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./doc/yard + publish_branch: gh-pages diff --git a/.gitignore b/.gitignore index ffcf86e8..2792f443 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,12 @@ .idea pkg tmp +doc +coverage +.bundle +/.byebug_history +.ruby-version -*.swp +.idea/ -# rbenv -.ruby-version +*.iml diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 357cc09a..00000000 --- a/.travis.yml +++ /dev/null @@ -1,12 +0,0 @@ -language: ruby -bundler_args: --without debug -script: "bundle exec rspec spec" -env: - - CI=true -rvm: - - 1.8.7 - - 1.9.3 - - jruby-18mode-1.7.4 - - jruby-19mode-1.7.4 - - rbx-18mode - - rbx-19mode diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..1b7b4b75 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,38 @@ +# How to contribute + +Community contributions are essential for keeping Ruby RDF great. We want to keep it as easy as possible to contribute changes that get things working in your environment. There are a few guidelines that we need contributors to follow so that we can have a chance of keeping on top of things. + +## Development + +This repository uses [Git Flow](https://github.com/nvie/gitflow) to manage development and release activity. All submissions _must_ be on a feature branch based on the _develop_ branch to ease staging and integration. + +* create or respond to an issue on the [Github Repository](https://github.com/ruby-rdf/sparql-client/issues) +* Fork and clone the repo: + `git clone git@github.com:your-username/sparql-client.git` +* Install bundle: + `bundle install` +* Create tests in RSpec and make sure you achieve at least 90% code coverage for the feature your adding or behavior being modified. +* Push to your fork and [submit a pull request][pr]. + +## Do's and Dont's +* Do your best to adhere to the existing coding conventions and idioms. +* Don't use hard tabs, and don't leave trailing whitespace on any line. + Before committing, run `git diff --check` to make sure of this. +* Do document every method you add using [YARD][] annotations. Read the + [tutorial][YARD-GS] or just look at the existing code for examples. +* Don't touch the `.gemspec` or `VERSION` files. If you need to change them, + do so on your private branch only. +* Do feel free to add yourself to the `CREDITS` file and the + corresponding list in the the `README`. Alphabetical order applies. +* Don't touch the `AUTHORS` file. If your contributions are significant + enough, be assured we will eventually add you in there. +* Do note that in order for us to merge any non-trivial changes (as a rule + of thumb, additions larger than about 15 lines of code), we need an + explicit [public domain dedication][PDD] on record from you, + which you will be asked to agree to on the first commit to a repo within the organization. + Note that the agreement applies to all repos in the [Ruby RDF](https://github.com/ruby-rdf/) organization. + +[YARD]: https://yardoc.org/ +[YARD-GS]: https://rubydoc.info/docs/yard/file/docs/GettingStarted.md +[PDD]: https://unlicense.org/#unlicensing-contributions +[pr]: https://github.com/ruby-rdf/rdf/compare/ diff --git a/CREDITS b/CREDITS index e087955d..6bebcad9 100644 --- a/CREDITS +++ b/CREDITS @@ -1,4 +1,5 @@ * Christoph Badura +* Thomas Feron * James Hetherington * Gabriel Horner * Nicholas Humfrey @@ -7,4 +8,5 @@ * Thamaraiselvan Poomalai * Michael Sokol * Yves Raimond -* Thomas Feron +* Danny Tran +* Nick Gottlieb diff --git a/Gemfile b/Gemfile index cafa8411..f61c605b 100644 --- a/Gemfile +++ b/Gemfile @@ -2,11 +2,25 @@ source "https://rubygems.org" gemspec -gem "jruby-openssl", :platforms => :jruby -gem 'cube-ruby', require: "cube" +gem 'rdf', git: "https://github.com/ruby-rdf/rdf", tag: "3.2.11" +gem 'rdf-aggregate-repo', git: "https://github.com/ruby-rdf/rdf-aggregate-repo", tag: "3.2.0" +gem 'sparql', git: "https://github.com/ruby-rdf/sparql", tag: "3.2.0" +gem "nokogiri", '~> 1.13', '>= 1.13.4' + +group :development, :test do + gem 'ebnf', git: "https://github.com/dryruby/ebnf", tag: "2.3.5" + gem 'rdf-isomorphic', git: "https://github.com/ruby-rdf/rdf-isomorphic", tag: "3.2.0" + gem 'rdf-spec', git: "https://github.com/ruby-rdf/rdf-spec", tag: "3.2.0" + gem 'rdf-turtle', git: "https://github.com/ruby-rdf/rdf-turtle", tag: "3.2.0" + gem "rdf-xsd", git: "https://github.com/ruby-rdf/rdf-xsd", tag: "3.2.0" + gem 'sxp' + gem "redcarpet", platform: :ruby + gem 'simplecov', '~> 0.21', platforms: :mri + gem 'simplecov-lcov', '~> 0.8', platforms: :mri +end group :debug do gem 'shotgun' - gem "wirble" - gem "debugger", :platforms => [:mri_19, :mri_20] + gem "byebug", platforms: :mri + gem "pry" end diff --git a/Gemfile-pure b/Gemfile-pure new file mode 100644 index 00000000..22dc4e5e --- /dev/null +++ b/Gemfile-pure @@ -0,0 +1,26 @@ +source "https://rubygems.org" + +gemspec + +gem 'rdf', git: "https://github.com/ruby-rdf/rdf", branch: "develop" +gem 'rdf-aggregate-repo', git: "https://github.com/ruby-rdf/rdf-aggregate-repo", branch: "develop" +gem 'sparql', git: "https://github.com/ruby-rdf/sparql", branch: "develop" +#gem "nokogiri", '~> 1.8' + +group :development, :test do + gem 'ebnf', git: "https://github.com/dryruby/ebnf", branch: "develop" + gem 'rdf-isomorphic', git: "https://github.com/ruby-rdf/rdf-isomorphic", branch: "develop" + gem 'rdf-spec', git: "https://github.com/ruby-rdf/rdf-spec", branch: "develop" + gem 'rdf-turtle', git: "https://github.com/ruby-rdf/rdf-turtle", branch: "develop" + gem "rdf-xsd", git: "https://github.com/ruby-rdf/rdf-xsd", branch: "develop" + gem 'sxp', git: "https://github.com/dryruby/sxp.rb", branch: "develop" + gem "redcarpet", platform: :ruby + gem 'simplecov', platforms: :mri + gem 'coveralls', '~> 0.8', platforms: :mri +end + +group :debug do + gem 'shotgun' + gem "byebug", platforms: :mri + gem "pry" +end diff --git a/README b/README deleted file mode 120000 index 42061c01..00000000 --- a/README +++ /dev/null @@ -1 +0,0 @@ -README.md \ No newline at end of file diff --git a/README.md b/README.md index dec7b87b..3cd39b7a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ -#SPARQL Client for RDF.rb +# SPARQL Client for RDF.rb This is a [Ruby][] implementation of a [SPARQL][] client for [RDF.rb][]. -* +* -[![Gem Version](https://badge.fury.io/rb/sparql-client.png)](http://badge.fury.io/rb/sparql-client) -[![Build Status](https://travis-ci.org/ruby-rdf/sparql-client.png?branch=master)](http://travis-ci.org/ruby-rdf/sparql-client) +[![Gem Version](https://badge.fury.io/rb/sparql-client.svg)](https://badge.fury.io/rb/sparql-client) +[![Build Status](https://github.com/ruby-rdf/sparql-client/workflows/CI/badge.svg?branch=develop)](https://github.com/ruby-rdf/sparql-client/actions?query=workflow%3ACI) +[![Coverage Status](https://coveralls.io/repos/ruby-rdf/sparql-client/badge.svg?branch=master&service=github)](https://coveralls.io/github/ruby-rdf/sparql-client?branch=master) +[![Gitter chat](https://badges.gitter.im/ruby-rdf/rdf.png)](https://gitter.im/ruby-rdf/rdf) -##Features +## Features * Executes queries against any SPARQL 1.0/1.1-compatible endpoint over HTTP, or against an `RDF::Queryable` instance, using the `SPARQL` gem. @@ -17,77 +19,125 @@ This is a [Ruby][] implementation of a [SPARQL][] client for [RDF.rb][]. * Supports tuple result sets in both XML, JSON, CSV and TSV formats, with JSON being the preferred default for content-negotiation purposes. * Supports graph results in any RDF serialization format understood by RDF.rb. -* Returns results using the [RDF.rb object model][RDF.rb model]. -* Supports accessing endpoints as read-only [`RDF::Repository`][RDF::Repository] - instances. +* Returns results using the RDF.rb object model. +* Supports accessing endpoints as read/write [`RDF::Repository`][RDF::Repository] + instances {SPARQL::Client::Repository}. -##Examples +## Examples ### Querying a remote SPARQL endpoint - require 'sparql/client' - sparql = SPARQL::Client.new("http://dbpedia.org/sparql") +```ruby +require 'sparql/client' +sparql = SPARQL::Client.new("http://dbpedia.org/sparql") +``` -### Querying a `RDF::Repository` instance +### Querying a remote SPARQL endpoint with a custom User-Agent +By default, SPARQL::Client adds a `User-Agent` field to requests, but applications may choose to provide their own, using the `headers` option: - require 'rdf/trig' - repository = RDF::Repository.load("http://example/dataset.trig") +```ruby +require 'sparql/client' +sparql = SPARQL::Client.new("http://dbpedia.org/sparql", headers: {'User-Agent' => 'MyBotName'}) +``` - sparql = SPARQL::Client.new(repository) +### Querying a remote SPARQL endpoint with a specified default graph -### Executing a boolean query and outputting the result +```ruby +require 'sparql/client' +sparql = SPARQL::Client.new("http://dbpedia.org/sparql", graph: "http://dbpedia.org") +``` + + +### Querying a `RDF::Repository` instance + +```ruby +require 'rdf/trig' +repository = RDF::Repository.load("http://example/dataset.trig") +sparql = SPARQL::Client.new(repository) +``` - # ASK WHERE { ?s ?p ?o } - result = sparql.ask.whether([:s, :p, :o]).true? +### Executing a boolean query and outputting the result - puts result.inspect #=> true or false +```ruby +# ASK WHERE { ?s ?p ?o } +result = sparql.ask.whether([:s, :p, :o]).true? +puts result.inspect #=> true or false +``` ### Executing a tuple query and iterating over the returned solutions - # SELECT * WHERE { ?s ?p ?o } OFFSET 100 LIMIT 10 - query = sparql.select.where([:s, :p, :o]).offset(100).limit(10) +```ruby +# SELECT * WHERE { ?s ?p ?o } OFFSET 100 LIMIT 10 +query = sparql.select.where([:s, :p, :o]).offset(100).limit(10) - query.each_solution do |solution| - puts solution.inspect - end +query.each_solution do |solution| + puts solution.inspect +end +``` ### Executing a graph query and iterating over the returned statements - # CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } LIMIT 10 - query = sparql.construct([:s, :p, :o]).where([:s, :p, :o]).limit(10) - query.each_statement do |statement| - puts statement.inspect - end +```ruby +# CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o } LIMIT 10 +query = sparql.construct([:s, :p, :o]).where([:s, :p, :o]).limit(10) + +query.each_statement do |statement| + puts statement.inspect +end +``` ### Executing an arbitrary textual SPARQL query string - result = sparql.query("ASK WHERE { ?s ?p ?o }") +```ruby +result = sparql.query("ASK WHERE { ?s ?p ?o }") + +puts result.inspect #=> true or false +``` + +### Inserting data into a graph + +```ruby +# INSERT DATA { "J. Random Hacker" .} +data = RDF::Graph.new do |graph| + graph << [RDF::URI('http://example.org/jhacker'), RDF::Vocab::FOAF.name, "J. Random Hacker"] +end +sparql.insert_data(data) +``` + +### Deleting data from a graph - puts result.inspect #=> true or false +```ruby +# DELETE DATA { "J. Random Hacker" .} +data = RDF::Graph.new do |graph| + graph << [RDF::URI('http://example.org/jhacker'), RDF::Vocab::FOAF.name, "J. Random Hacker"] +end +sparql.delete_data(data) +``` -##Documentation +## Documentation -* {SPARQL::Client} - * {SPARQL::Client::Query} - * {SPARQL::Client::Repository} +* [SPARQL::Client](https://ruby-rdf.github.io/sparql-client/SPARQL/Client) + * [SPARQL::Client::Query](https://ruby-rdf.github.io/sparql-client/SPARQL/Client/Query) + * [SPARQL::Client::Repository](https://ruby-rdf.github.io/sparql-client/SPARQL/Client/Repository) + * [SPARQL::Client::Update](https://ruby-rdf.github.io/sparql-client/SPARQL/Client/Update) -##Dependencies +## Dependencies -* [Ruby](http://ruby-lang.org/) (>= 1.8.7) or (>= 1.8.1 with [Backports][]) -* [RDF.rb](http://rubygems.org/gems/rdf) (>= 1.0) -* [Net::HTTP::Persistent](http://rubygems.org/gems/net-http-persistent) (>= 1.4) -* [JSON](http://rubygems.org/gems/json_pure) (>= 1.4) -* Soft dependency on [SPARQL](http://rubygems.org/gems/sparql) (>= 1.0) +* [Ruby](https://ruby-lang.org/) (>= 2.6) +* [RDF.rb](https://rubygems.org/gems/rdf) (~> 3.2) +* [Net::HTTP::Persistent](https://rubygems.org/gems/net-http-persistent) (~> 4.0, >= 4.0.1) +* Soft dependency on [SPARQL](https://rubygems.org/gems/sparql) (~> 3.2) +* Soft dependency on [Nokogiri](https://rubygems.org/gems/nokogiri) (>= 1.12) -##Installation +## Installation -The recommended installation method is via [RubyGems](http://rubygems.org/). +The recommended installation method is via [RubyGems](https://rubygems.org/). To install the latest official release of the `SPARQL::Client` gem, do: % [sudo] gem install sparql-client -##Download +## Download To get a local working copy of the development repository, do: @@ -96,32 +146,34 @@ To get a local working copy of the development repository, do: Alternatively, download the latest development version as a tarball as follows: - % wget http://github.com/ruby-rdf/sparql-client/tarball/master + % wget https://github.com/ruby-rdf/sparql-client/tarball/master -##Mailing List +## Mailing List -* +* -##Authors +## Authors -* [Arto Bendiken](http://github.com/bendiken) - -* [Ben Lavender](http://github.com/bhuga) - -* [Gregg Kellogg](http://github.com/gkellogg) - +* [Arto Bendiken](https://github.com/artob) - +* [Ben Lavender](https://github.com/bhuga) - +* [Gregg Kellogg](https://github.com/gkellogg) - -##Contributors +## Contributors -* [Christoph Badura](http://github.com/b4d) - -* [James Hetherington](http://github.com/jamespjh) - -* [Gabriel Horner](http://github.com/cldwalker) - -* [Nicholas Humfrey](http://github.com/njh) - -* [Fumihiro Kato](http://github.com/fumi) - -* [David Nielsen](http://github.com/drankard) - -* [Thamaraiselvan Poomalai](http://github.com/selvan) - -* [Michael Sokol](http://github.com/mikaa123) - -* [Yves Raimond](http://github.com/moustaki) - -* [Thomas Feron](http://github.com/thoferon) - +* [Christoph Badura](https://github.com/bad) - +* [James Hetherington](https://github.com/jamespjh) - +* [Gabriel Horner](https://github.com/cldwalker) - +* [Nicholas Humfrey](https://github.com/njh) - +* [Fumihiro Kato](https://github.com/fumi) - +* [David Nielsen](https://github.com/drankard) - +* [Thamaraiselvan Poomalai](https://github.com/selvan) - +* [Michael Sokol](https://github.com/mikaa123) - +* [Yves Raimond](https://github.com/moustaki) - +* [Thomas Feron](https://github.com/thoferon) - +* [Nick Gottlieb](https://github.com/ngottlieb) - -##Contributing +## Contributing +This repository uses [Git Flow](https://github.com/nvie/gitflow) to mange development and release activity. All submissions _must_ be on a feature branch based on the _develop_ branch to ease staging and integration. * Do your best to adhere to the existing coding conventions and idioms. * Don't use hard tabs, and don't leave trailing whitespace on any line. @@ -133,32 +185,32 @@ follows: list in the the `README`. Alphabetical order applies. * Do note that in order for us to merge any non-trivial changes (as a rule of thumb, additions larger than about 15 lines of code), we need an - explicit [public domain dedication][PDD] on record from you. + explicit [public domain dedication][PDD] on record from you, + which you will be asked to agree to on the first commit to a repo within the organization. + Note that the agreement applies to all repos in the [Ruby RDF](https://github.com/ruby-rdf/) organization. -##Resources +## Resources -* -* -* -* -* -* +* +* +* +* +* -##License +## License This is free and unencumbered public domain software. For more information, -see or the accompanying {file:UNLICENSE} file. - -[Ruby]: http://ruby-lang.org/ -[RDF]: http://www.w3.org/RDF/ -[SPARQL]: http://en.wikipedia.org/wiki/SPARQL -[SPARQL JSON]: http://www.w3.org/TR/rdf-sparql-json-res/ -[RDF.rb]: http://rubygems.org/gems/rdf -[RDF.rb model]: http://blog.datagraph.org/2010/03/rdf-for-ruby -[RDF::Repository]: http://rubydoc.info/github/ruby-rdf/rdf/RDF/Repository -[DSL]: http://en.wikipedia.org/wiki/Domain-specific_language +see or the accompanying {file:UNLICENSE} file. + +[Ruby]: https://ruby-lang.org/ +[RDF]: https://www.w3.org/RDF/ +[SPARQL]: https://en.wikipedia.org/wiki/SPARQL +[SPARQL JSON]: https://www.w3.org/TR/rdf-sparql-json-res/ +[RDF.rb]: https://rubygems.org/gems/rdf +[RDF::Repository]: https://ruby-rdf.github.io/rdf/RDF/Repository +[DSL]: https://en.wikipedia.org/wiki/Domain-specific_language "domain-specific language" -[YARD]: http://yardoc.org/ -[YARD-GS]: http://rubydoc.info/docs/yard/file/docs/GettingStarted.md -[PDD]: http://unlicense.org/#unlicensing-contributions -[Backports]: http://rubygems.org/gems/backports +[YARD]: https://yardoc.org/ +[YARD-GS]: https://rubydoc.info/docs/yard/file/docs/GettingStarted.md +[PDD]: https://unlicense.org/#unlicensing-contributions +[Backports]: https://rubygems.org/gems/backports diff --git a/Rakefile b/Rakefile old mode 100644 new mode 100755 index af5bf9a5..5c67c917 --- a/Rakefile +++ b/Rakefile @@ -2,13 +2,19 @@ $:.unshift(File.expand_path(File.join(File.dirname(__FILE__), 'lib'))) require 'rubygems' begin - require 'rakefile' # @see http://github.com/bendiken/rakefile + require 'rakefile' # @see https://github.com/artob/rakefile rescue LoadError => e end -require 'sparql/client' +namespace :gem do + desc "Build the sparql-client-#{File.read('VERSION').chomp}.gem file" + task :build do + sh "gem build sparql-client.gemspec && mv sparql-client-#{File.read('VERSION').chomp}.gem pkg/" + end -desc "Build the sparql-client-#{File.read('VERSION').chomp}.gem file" -task :build do - sh "gem build .gemspec" + desc "Release the sparql-client-#{File.read('VERSION').chomp}.gem file" + task :release do + sh "gem push pkg/sparql-client-#{File.read('VERSION').chomp}.gem" + end end + diff --git a/UNLICENSE b/UNLICENSE index 68a49daa..efb98088 100644 --- a/UNLICENSE +++ b/UNLICENSE @@ -21,4 +21,4 @@ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -For more information, please refer to +For more information, please refer to diff --git a/VERSION b/VERSION index 019e0cb6..be94e6f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.4.1 +3.2.2 diff --git a/dependencyci.yml b/dependencyci.yml new file mode 100644 index 00000000..0c67c1b1 --- /dev/null +++ b/dependencyci.yml @@ -0,0 +1,5 @@ +platform: + Rubygems: + rdf-isomorphic: + tests: + unmaintained: skip \ No newline at end of file diff --git a/doc/.gitignore b/doc/.gitignore deleted file mode 100644 index 62eb2288..00000000 --- a/doc/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -rdoc -yard diff --git a/etc/doap.ttl b/etc/doap.ttl index 74e61555..7ba9d176 100644 --- a/etc/doap.ttl +++ b/etc/doap.ttl @@ -1,4 +1,4 @@ -@base . +@base . @prefix rdf: . @prefix rdfs: . @prefix dc: . @@ -7,27 +7,26 @@ <> a doap:Project ; doap:name "SPARQL::Client" ; - doap:homepage ; - doap:license ; + doap:homepage ; + doap:license ; doap:shortdesc "SPARQL client for RDF.rb."@en ; doap:description """Executes SPARQL queries and updates against a remote SPARQL 1.0 or 1.1 endpoint, or against a local repository. Generates SPARQL queries using a simple DSL."""@en ; doap:created "2007-11-03" ; - doap:platform "Ruby" ; - doap:category , - ; - doap:implements , - , - , - , - ; - doap:download-page ; - doap:bug-database ; - doap:blog , , ; - doap:vendor ; - doap:developer , , ; - doap:maintainer , , ; - doap:documenter , , ; + doap:programming-language "Ruby" ; + doap:category , + ; + doap:implements , + , + , + , + ; + doap:download-page ; + doap:bug-database ; + doap:blog , ; + doap:developer , , ; + doap:maintainer ; + doap:documenter , , ; doap:helper [a foaf:Person ; foaf:name "Christoph Badura" ; foaf:mbox_sha1sum "3a9c4bbe13feec63b1c8292ebf90e8da6caa0afd"] ; @@ -61,28 +60,26 @@ doap:helper [a foaf:Person ; foaf:name "Thomas Feron" ; foaf:mbox_sha1sum "8c6ba34fcf8705a63af3858140ba0107b9ee756a"] ; - foaf:maker ; - dc:creator . + foaf:maker ; + dc:creator . - a foaf:Person ; + a foaf:Person ; foaf:name "Arto Bendiken" ; foaf:mbox ; foaf:mbox_sha1sum "a033f652c84a4d73b8c26d318c2395699dd2bdfb", "d0737cceb55eb7d740578d2db1bc0727e3ed49ce" ; - foaf:homepage ; - foaf:made <> ; - rdfs:isDefinedBy . + foaf:homepage ; + foaf:made <> . - a foaf:Person ; + a foaf:Person ; foaf:name "Ben Lavender" ; foaf:mbox ; foaf:mbox_sha1sum "dbf45f4ffbd27b67aa84f02a6a31c144727d10af" ; - foaf:homepage ; - rdfs:isDefinedBy . + foaf:homepage . - a foaf:Person ; + a foaf:Person ; foaf:name "Gregg Kellogg" ; foaf:mbox ; foaf:mbox_sha1sum "35bc44e6d0070e5ad50ccbe0d24403c96af2b9bd" ; - foaf:homepage ; - rdfs:isDefinedBy . \ No newline at end of file + foaf:homepage ; + rdfs:isDefinedBy . \ No newline at end of file diff --git a/examples/issue92.rb b/examples/issue92.rb new file mode 100644 index 00000000..dc09c622 --- /dev/null +++ b/examples/issue92.rb @@ -0,0 +1,8 @@ +require 'rdf' +require 'sparql/client' +dnbt = RDF::Vocabulary.new("https://d-nb.info/standards/elementset/dnb#") +rdf_gndid = RDF::Literal.new("https://d-nb.info/gnd/1059461498") +sparql_client = SPARQL::Client.new("http://127.0.0.1:9292/") +gndo = RDF::Vocabulary.new("http://d-nb.info/gnd/standards/elementset/gnd#") +query = sparql_client.select.where([:subject, dnbt.deprecatedUri, rdf_gndid]).where([:subject, gndo.gndIdentifier, :gndid]) +query.each_solution { |solution| new_gndid = solution[:gndid].to_s } \ No newline at end of file diff --git a/examples/issue96.rb b/examples/issue96.rb new file mode 100644 index 00000000..f21317db --- /dev/null +++ b/examples/issue96.rb @@ -0,0 +1,5 @@ +require 'sparql/client' +SPARQL::Client::Query + .select + .where(%i[s p o]) + .values(:s, RDF::URI('http://example.com/1'), RDF::URI('http://example.com/2')) diff --git a/examples/workergnome-literal-issue.rb b/examples/workergnome-literal-issue.rb new file mode 100644 index 00000000..b928a56f --- /dev/null +++ b/examples/workergnome-literal-issue.rb @@ -0,0 +1,16 @@ +#require 'rdf/turtle' +require 'sparql/client' + +sparql = SPARQL::Client.new("http://data.americanartcollaborative.org/sparql") +uri = RDF.URI("http://data.crystalbridges.org/object/2258") +label = RDF::RDFS.label + +query = sparql.construct([uri, label, :o]).where([uri, label, :o]) +query.each_statement do |s| + puts s.object.inspect +end + +# "Bison-Dance of the Mandan Indians in front of their Medicine Lodge in Mih-Tuta-Hankush" . +# "From \"Voyage dans l’intérieur de l’Amérique du Nord, executé pendant les années 1832, 1833 et 1834, par le prince Maximilien de Wied-Neuwied\" (Paris & Coblenz, 1839-1843)" . + +# " \"Bison-Dance of the Mandan Indians in front of their Medicine Lodge in Mih-Tuta-Hankush\" .\n \"From \\\"Voyage dans l\xE2\x80\x99int\xC3\xA9rieur de l\xE2\x80\x99Am\xC3\xA9rique du Nord, execut\xC3\xA9 pendant les ann\xC3\xA9es 1832, 1833 et 1834, par le prince Maximilien de Wied-Neuwied\\\" (Paris & Coblenz, 1839-1843)\" .\n" \ No newline at end of file diff --git a/lib/sparql/client.rb b/lib/sparql/client.rb index cfbeb2e7..427b4426 100644 --- a/lib/sparql/client.rb +++ b/lib/sparql/client.rb @@ -1,17 +1,20 @@ -require 'net/http/persistent' # @see http://rubygems.org/gems/net-http-persistent -require 'rdf' # @see http://rubygems.org/gems/rdf -require 'rdf/ntriples' # @see http://rubygems.org/gems/rdf -require 'json' -require 'cube' +require 'net/http/persistent' # @see https://rubygems.org/gems/net-http-persistent +require 'rdf' # @see https://rubygems.org/gems/rdf +require 'rdf/ntriples' # @see https://rubygems.org/gems/rdf +begin + require 'nokogiri' +rescue LoadError + require 'rexml/document' +end module SPARQL ## # A SPARQL 1.0/1.1 client for RDF.rb. # - # @see http://www.w3.org/TR/sparql11-query/ - # @see http://www.w3.org/TR/sparql11-protocol/ - # @see http://www.w3.org/TR/sparql11-results-json/ - # @see http://www.w3.org/TR/sparql11-results-csv-tsv/ + # @see https://www.w3.org/TR/sparql11-query/ + # @see https://www.w3.org/TR/sparql11-protocol/ + # @see https://www.w3.org/TR/sparql11-results-json/ + # @see https://www.w3.org/TR/sparql11-results-csv-tsv/ class Client autoload :Query, 'sparql/client/query' autoload :Repository, 'sparql/client/repository' @@ -26,18 +29,35 @@ class ServerError < StandardError; end RESULT_XML = 'application/sparql-results+xml'.freeze RESULT_CSV = 'text/csv'.freeze RESULT_TSV = 'text/tab-separated-values'.freeze - RESULT_PLAIN = 'text/plain'.freeze - RESULT_BOOL = 'text/boolean'.freeze # Sesame-specific + RESULT_PLAIN = 'text/plain'.freeze + RESULT_BOOL = 'text/boolean'.freeze # Sesame-specific RESULT_BRTR = 'application/x-binary-rdf-results-table'.freeze # Sesame-specific - ACCEPT_JSON = {'Accept' => RESULT_JSON}.freeze - ACCEPT_XML = {'Accept' => RESULT_XML}.freeze - ACCEPT_CSV = {'Accept' => RESULT_CSV}.freeze - ACCEPT_TSV = {'Accept' => RESULT_TSV}.freeze - ACCEPT_BRTR = {'Accept' => RESULT_BRTR}.freeze + RESULT_ALL = [ + RESULT_JSON, + RESULT_XML, + RESULT_BOOL, + "#{RESULT_TSV};q=0.8", + "#{RESULT_CSV};q=0.2", + '*/*;q=0.1' + ].join(', ').freeze + GRAPH_ALL = ( + RDF::Format.content_types.keys + + ['*/*;q=0.1'] + ).join(', ').freeze + + ACCEPT_JSON = {'Accept' => RESULT_JSON}.freeze + ACCEPT_XML = {'Accept' => RESULT_XML}.freeze + ACCEPT_CSV = {'Accept' => RESULT_CSV}.freeze + ACCEPT_TSV = {'Accept' => RESULT_TSV}.freeze + ACCEPT_BRTR = {'Accept' => RESULT_BRTR}.freeze + ACCEPT_RESULTS = {'Accept' => RESULT_ALL}.freeze + ACCEPT_GRAPH = {'Accept' => GRAPH_ALL}.freeze DEFAULT_PROTOCOL = 1.0 DEFAULT_METHOD = :post + XMLNS = {'sparql' => 'http://www.w3.org/2005/sparql-results#'}.freeze + ## # The SPARQL endpoint URL, or an RDF::Queryable instance, to use the native SPARQL engine. # @@ -67,73 +87,98 @@ class ServerError < StandardError; end # @option options [Symbol] :method (DEFAULT_METHOD) # @option options [Number] :protocol (DEFAULT_PROTOCOL) # @option options [Hash] :headers + # HTTP Request headers + # + # Defaults `Accept` header based on available reader content types if triples are expected and to SPARQL result types otherwise, to allow for content negotiation based on available readers. + # + # Defaults `User-Agent` header, unless one is specified. # @option options [Hash] :read_timeout - def initialize(url, options = {}, &block) + def initialize(url, **options, &block) @logger = options[:logger] ||= Kernel.const_defined?("LOGGER") ? Kernel.const_get("LOGGER") : Logger.new(STDOUT) @redis_cache = nil + if options[:redis_cache] @redis_cache = options[:redis_cache] end - @cube = nil - if options[:cube_options] - cube_options=options[:cube_options] - end + case url when RDF::Queryable @url, @options = url, options.dup else @url, @options = RDF::URI.new(url.to_s), options.dup -# @headers = { -# 'Accept' => [RESULT_JSON, RESULT_XML, "#{RESULT_TSV};p=0.8", "#{RESULT_CSV};p=0.2", RDF::Format.content_types.keys.map(&:to_s)].join(', ') -# }.merge(@options.delete(:headers) || {}) - @headers = { - 'Accept' => RESULT_JSON.to_s - #'Accept' => RESULT_XML.to_s - }.merge(@options.delete(:headers) || {}) + @headers = @options.delete(:headers) || {} @http = http_klass(@url.scheme) + + # Close the http connection when object is deallocated + ObjectSpace.define_finalizer(self, self.class.finalize(@http)) end if block_given? case block.arity - when 1 then block.call(self) - else instance_eval(&block) + when 1 then block.call(self) + else instance_eval(&block) + end + end + end + + # Close the http connection when object is deallocated + def self.finalize(klass) + proc do + if klass.respond_to?(:shutdown) + begin + # Attempt asynchronous shutdown + Thread.new {klass.shutdown} + rescue ThreadError + klass.shutdown + end end end end + ## + # Closes a client instance by finishing the connection. + # The client is unavailable for any further data operations; an IOError is raised if such an attempt is made. I/O streams are automatically closed when they are claimed by the garbage collector. + # @return [void] `self` + def close + @http.shutdown if @http + @http = nil + self + end + ## # Executes a boolean `ASK` query. # + # @param (see Query.ask) # @return [Query] - def ask(*args) - call_query_method(:ask, *args) + def ask(*args, **options) + call_query_method(:ask, *args, **options) end ## # Executes a tuple `SELECT` query. # - # @param [Array] args + # @param (see Query.select) # @return [Query] - def select(*args) - call_query_method(:select, *args) + def select(*args, **options) + call_query_method(:select, *args, **options) end ## # Executes a `DESCRIBE` query. # - # @param [Array] args + # @param (see Query.describe) # @return [Query] - def describe(*args) - call_query_method(:describe, *args) + def describe(*args, **options) + call_query_method(:describe, *args, **options) end ## # Executes a graph `CONSTRUCT` query. # - # @param [Array] args + # @param (see Query.construct) # @return [Query] - def construct(*args) - call_query_method(:construct, *args) + def construct(*args, **options) + call_query_method(:construct, *args, **options) end ## @@ -148,23 +193,23 @@ def construct(*args) # # @example Inserting data constructed ad-hoc # client.insert_data(RDF::Graph.new { |graph| - # graph << [:jhacker, RDF::FOAF.name, "J. Random Hacker"] + # graph << [:jhacker, RDF::Vocab::FOAF.name, "J. Random Hacker"] # }) # # @example Inserting data sourced from a file or URL - # data = RDF::Graph.load("http://rdf.rubyforge.org/doap.nt") + # data = RDF::Graph.load("https://raw.githubusercontent.com/ruby-rdf/rdf/develop/etc/doap.nt") # client.insert_data(data) # # @example Inserting data into a named graph - # client.insert_data(data, :graph => "http://example.org/") + # client.insert_data(data, graph: "http://example.org/") # - # @param [RDF::Graph] data + # @param [RDF::Enumerable] data # @param [Hash{Symbol => Object}] options # @option options [RDF::URI, String] :graph # @return [void] `self` - # @see http://www.w3.org/TR/sparql11-update/#insertData - def insert_data(data, options = {}) - self.update(Update::InsertData.new(data, options)) + # @see https://www.w3.org/TR/sparql11-update/#insertData + def insert_data(data, **options) + self.update(Update::InsertData.new(data, **options)) end ## @@ -173,19 +218,35 @@ def insert_data(data, options = {}) # This requires that the endpoint support SPARQL 1.1 Update. # # @example Deleting data sourced from a file or URL - # data = RDF::Graph.load("http://rdf.rubyforge.org/doap.nt") + # data = RDF::Graph.load("https://raw.githubusercontent.com/ruby-rdf/rdf/develop/etc/doap.nt") # client.delete_data(data) # # @example Deleting data from a named graph - # client.delete_data(data, :graph => "http://example.org/") + # client.delete_data(data, graph: "http://example.org/") # - # @param [RDF::Graph] data + # @param [RDF::Enumerable] data # @param [Hash{Symbol => Object}] options # @option options [RDF::URI, String] :graph # @return [void] `self` - # @see http://www.w3.org/TR/sparql11-update/#deleteData - def delete_data(data, options = {}) - self.update(Update::DeleteData.new(data, options)) + # @see https://www.w3.org/TR/sparql11-update/#deleteData + def delete_data(data, **options) + self.update(Update::DeleteData.new(data, **options)) + end + + ## + # Executes a `DELETE/INSERT` operation. + # + # This requires that the endpoint support SPARQL 1.1 Update. + # + # @param [RDF::Enumerable] delete_graph + # @param [RDF::Enumerable] insert_graph + # @param [RDF::Enumerable] where_graph + # @param [Hash{Symbol => Object}] options + # @option options [RDF::URI, String] :graph + # @return [void] `self` + # @see https://www.w3.org/TR/sparql11-update/#deleteInsert + def delete_insert(delete_graph, insert_graph = nil, where_graph = nil, **options) + self.update(Update::DeleteInsert.new(delete_graph, insert_graph, where_graph, **options)) end ## @@ -200,9 +261,9 @@ def delete_data(data, options = {}) # @param [Hash{Symbol => Object}] options # @option options [Boolean] :silent # @return [void] `self` - # @see http://www.w3.org/TR/sparql11-update/#clear - def clear_graph(graph_uri, options = {}) - self.clear(:graph, graph_uri, options) + # @see https://www.w3.org/TR/sparql11-update/#clear + def clear_graph(graph_uri, **options) + self.clear(:graph, graph_uri, **options) end ## @@ -228,23 +289,23 @@ def clear_graph(graph_uri, options = {}) # @option options [Boolean] :silent # @return [void] `self` # - # @overload clear(what, *arguments, options = {}) + # @overload clear(what, *arguments, **options) # @param [Symbol, #to_sym] what # @param [Array] arguments splat of other arguments to {Update::Clear}. # @param [Hash{Symbol => Object}] options # @option options [Boolean] :silent # @return [void] `self` # - # @see http://www.w3.org/TR/sparql11-update/#clear + # @see https://www.w3.org/TR/sparql11-update/#clear def clear(what, *arguments) self.update(Update::Clear.new(what, *arguments)) end ## # @private - def call_query_method(meth, *args) + def call_query_method(meth, *args, **options) client = self - result = Query.send(meth, *args) + result = Query.send(meth, *args, **options) (class << result; self; end).send(:define_method, :execute) do client.query(self) end @@ -267,81 +328,57 @@ def nodes # @option options [String] :content_type # @option options [Hash] :headers # @return [Array] - # @see http://www.w3.org/TR/sparql11-protocol/#query-operation - def query(query, options = {}) - # pat = /SELECT\s+\(\s*COUNT\(DISTINCT\s+\?id\)\s+AS\s+\?count_var\s*\)\s+FROM\s+\\s+WHERE\s+{\s+\?id\s+a\s+\\s+\.\s+}/ - # if query && (query.to_s =~ pat) != nil - # @logger.info("#{query.to_s}") - # @logger.info(caller.join("\n\t")) - # end - #TODO less intrusive ? - start = Time.now + # @raise [IOError] if connection is closed + # @see https://www.w3.org/TR/sparql11-protocol/#query-operation + def query(query, **options) unless query.respond_to?(:options) && query.options[:bypass_cache] - if @redis_cache && (query.instance_of?(SPARQL::Client::Query) || - options[:graphs]) - cache_key = nil + if @redis_cache && (query.instance_of?(SPARQL::Client::Query) || options[:graphs]) + + if options[:graphs] || query.options[:graphs] cache_key = SPARQL::Client::Query.generate_cache_key(query.to_s, - options[:graphs] || query.options[:graphs]) + options[:graphs] || query.options[:graphs]) else cache_key = query.cache_key end + cache_response = @redis_cache.get(cache_key[:query]) + if options[:reload_cache] and options[:reload_cache] == true - @redis_cache.del(cache_key[:query]) - cache_response = nil + @redis_cache.del(cache_key[:query]) + cache_response = nil end + if cache_response cache_key[:graphs].each do |g| - unless @redis_cache.sismember(g,cache_key[:query]) + unless @redis_cache.sismember(g, cache_key[:query]) @redis_cache.del(cache_key[:query]) cache_response = nil break end end if cache_response - if @cube - @cube.send("goo_cache_hit", DateTime.now, - duration_ms: ((Time.now - start)*1000).ceil) rescue nil - end return Marshal.load(cache_response) end end + options[:cache_key] = cache_key end end @op = :query - qstart = Time.now - # pat = /submissionStatus/ - # if query && (query.to_s =~ pat) != nil - # @logger.info("#{query.to_s}") - # @logger.info(caller.join("\n\t")) - # end - r = response(query, options) - query_time = Time.now - qstart - pstart = Time.now - parsed = parse_response(r, options) - parse_time = Time.now - pstart - if Thread.current[:ncbo_debug] - @logger.info("************************* Query *************************\n#{query.to_s}") - @logger.info("************************ Duration ***********************") - @logger.info("#{Time.now - start} sec.\n") - (Thread.current[:ncbo_debug][:sparql_queries] ||= []) << [query_time,parse_time] - end - # if @cube - # @cube.send("goo_query_hit", DateTime.now, - # duration_ms: ((Time.now - start)*1000).ceil, - # query: query.to_s) rescue nil - # end - return parsed - #@op = :query - #case @url - #when RDF::Queryable - # require 'sparql' unless defined?(::SPARQL::Grammar) - # SPARQL.execute(query, @url, options) - #else - # parse_response(response(query, options), options) - #end + @alt_endpoint = options[:endpoint] + case @url + when RDF::Queryable + require 'sparql' unless defined?(::SPARQL::Grammar) + begin + SPARQL.execute(query, @url, optimize: true, **options) + rescue SPARQL::MalformedQuery + $stderr.puts "error running #{query}: #{$!}" + raise + end + else + parse_response(response(query, **options), **options) + end end ## @@ -349,28 +386,25 @@ def query(query, options = {}) # # @param [String, #to_s] query # @param [Hash{Symbol => Object}] options + # @option options [String] :endpoint # @option options [String] :content_type # @option options [Hash] :headers # @return [void] `self` - # @see http://www.w3.org/TR/sparql11-protocol/#update-operation - def update(query, options = {}) + # @raise [IOError] if connection is closed + # @see https://www.w3.org/TR/sparql11-protocol/#update-operation + def update(query, **options) @op = :update - options[:op] = :update if @redis_cache && !query.options[:bypass_cache] - query_delete_cache(query) + query_delete_cache(query) end + + @alt_endpoint = options[:endpoint] case @url when RDF::Queryable require 'sparql' unless defined?(::SPARQL::Grammar) - SPARQL.execute(query, @url, options) + SPARQL.execute(query, @url, update: true, optimize: true, **options) else - start = Time.now - parse_response(response(query, options), options) - if @cube - @cube.send("sparql_write_data", DateTime.now, - duration_ms: ((Time.now - start)*1000).ceil, - type_write: query.class.name.split("::")[-1].downcase) rescue nil - end + response(query, **options) end self end @@ -384,24 +418,20 @@ def update(query, options = {}) # @option options [String] :content_type # @option options [Hash] :headers # @return [String] - def response(query, options = {}) - op = options[:op] || :query - headers = options[:headers] || {} - query_options = (query.is_a?(Query) && query.options[:query_options]) || nil - unless query_options - query_options = (query.is_a?(String) && options[:query_options]) || nil - end + # @raise [IOError] if connection is closed + def response(query, **options) + headers = options[:headers] || @headers headers['Accept'] = options[:content_type] if options[:content_type] - request(query,op,headers,query_options) do |response| + request(query, headers) do |response| case response when Net::HTTPBadRequest # 400 Bad Request - raise MalformedQuery.new(response.body) - when Net::HTTPClientError # 4xx - raise ClientError.new(response.body) - when Net::HTTPServerError # 5xx - raise ServerError.new(response.body) + raise MalformedQuery.new(response.body + " Processing query #{query}") + when Net::HTTPClientError # 4xx + raise ClientError.new(response.body + " Processing query #{query}") + when Net::HTTPServerError # 5xx + raise ServerError.new(response.body + " Processing query #{query}") when Net::HTTPSuccess # 2xx - response + response end end end @@ -439,17 +469,17 @@ def cache_invalidate_graph(graphs) end end - def query_put_cache(keys,entry) - #expiration = 1800 #1/2 hour + def query_put_cache(keys, entry) + # expiration = 1800 #1/2 hour data = Marshal.dump(entry) - if data.length > 50e6 #50MB of marshal object - #avoid large entries to go in the cache + if data.length > 50e6 # 50MB of marshal object + # avoid large entries to go in the cache return end keys[:graphs].each do |g| - @redis_cache.sadd(g,keys[:query]) + @redis_cache.sadd(g, keys[:query]) end - @redis_cache.set(keys[:query],data) + @redis_cache.set(keys[:query], data) #@redis_cache.expire(keys[:query],expiration) end @@ -457,84 +487,91 @@ def query_put_cache(keys,entry) # @param [Net::HTTPSuccess] response # @param [Hash{Symbol => Object}] options # @return [Object] - def parse_response(response, options = {}) - case content_type = options[:content_type] || response.content_type - when RESULT_BOOL # Sesame-specific - response.body == 'true' - when RESULT_JSON - result_data = self.class.parse_json_bindings(response.body, nodes) - - if options[:cache_key] - query_put_cache(options[:cache_key],result_data) - end - return result_data - when RESULT_XML - #self.class.parse_xml_nokiri(response.body, nodes) - self.class.parse_xml_bindings(response.body, nodes) - when RESULT_CSV - self.class.parse_csv_bindings(response.body, nodes) - when RESULT_TSV - self.class.parse_tsv_bindings(response.body, nodes) - when RESULT_PLAIN - self.class.parse_plain_bindings(response.body, nodes) - else - parse_rdf_serialization(response, options) + def parse_response(response, **options) + case options[:content_type] || response.content_type + when NilClass + response.body + when RESULT_BOOL # Sesame-specific + response.body == 'true' + when RESULT_JSON + result_data = self.class.parse_json_bindings(response.body, nodes) + if options[:cache_key] + query_put_cache(options[:cache_key], result_data) + end + return result_data + when RESULT_XML + self.class.parse_xml_bindings(response.body, nodes) + when RESULT_CSV + self.class.parse_csv_bindings(response.body, nodes) + when RESULT_TSV + self.class.parse_tsv_bindings(response.body, nodes) + # when RESULT_PLAIN + # self.class.parse_plain_bindings(response.body, nodes) + else + parse_rdf_serialization(response, **options) end end - ## # @param [String, Hash] json # @return [] - # @see http://www.w3.org/TR/rdf-sparql-json-res/#results + # @see https://www.w3.org/TR/rdf-sparql-json-res/#results def self.parse_json_bindings(json, nodes = {}) - json = json.force_encoding(::Encoding::UTF_8) if json.respond_to?(:force_encoding) - begin - json = JSON.parse(json.to_s) unless json.is_a?(Hash) - rescue Exception => e - json = json.split("").select { |x| x.ord > 31 }.join '' - json = JSON.parse(json.to_s) unless json.is_a?(Hash) - end + require 'json' unless defined?(::JSON) + json = JSON.parse(json.to_s) unless json.is_a?(Hash) case - when json.has_key?('boolean') - json['boolean'] - when json.has_key?('results') - solutions = json['results']['bindings'].map do |row| - row = row.inject({}) do |cols, (name, value)| - cols.merge(name.to_sym => parse_json_value(value)) - end - RDF::Query::Solution.new(row) + when json.has_key?('boolean') + json['boolean'] + when json.has_key?('results') + solutions = json['results']['bindings'].map do |row| + row = row.inject({}) do |cols, (name, value)| + cols.merge(name.to_sym => parse_json_value(value, nodes)) end - RDF::Query::Solutions.new(solutions) + RDF::Query::Solution.new(row) + end + solns = RDF::Query::Solutions.new(solutions) + + # Set variable names explicitly + if json.fetch('head', {}).has_key?('vars') + solns.variable_names = json['head']['vars'].map(&:to_sym) + end + solns end end ## # @param [Hash{String => String}] value # @return [RDF::Value] - # @see http://www.w3.org/TR/rdf-sparql-json-res/#variable-binding-results + # @see https://www.w3.org/TR/sparql11-results-json/#select-encode-terms + # @see https://www.w3.org/TR/rdf-sparql-json-res/#variable-binding-results def self.parse_json_value(value, nodes = {}) return nil if value == {} + case value['type'].to_sym - when :bnode - nodes[id = value['value']] ||= RDF::Node.new(id) - when :uri - RDF::URI.new(value['value']) - when :literal - if value['xml:lang'] or value['lang'] - RDF::Literal.new(value['value'], :language => value['xml:lang']) - else - RDF::Literal.new(value['value'], :datatype => value['datatype']) - end - when :'typed-literal' - RDF::Literal.new(value['value'], :datatype => value['datatype']) - else nil + when :bnode + nodes[id = value['value']] ||= RDF::Node.new(id) + when :uri + RDF::URI.new(value['value']) + when :literal + RDF::Literal.new(value['value'], datatype: value['datatype'], language: value['xml:lang']) + when :'typed-literal' + RDF::Literal.new(value['value'], datatype: value['datatype']) + when :triple + s = parse_json_value(value['value']['subject'], nodes) + p = parse_json_value(value['value']['predicate'], nodes) + o = parse_json_value(value['value']['object'], nodes) + RDF::Statement(s, p, o) + else nil end end + def self.parse_plain_bindings(plain, nodes = {}) + return plain + end + ## # @param [String, Array>] csv # @return [] - # @see http://www.w3.org/TR/sparql11-results-csv-tsv/ + # @see https://www.w3.org/TR/sparql11-results-csv-tsv/ def self.parse_csv_bindings(csv, nodes = {}) require 'csv' unless defined?(::CSV) csv = CSV.parse(csv.to_s) unless csv.is_a?(Array) @@ -544,10 +581,10 @@ def self.parse_csv_bindings(csv, nodes = {}) solution = RDF::Query::Solution.new row.each_with_index do |v, i| term = case v - when /^_:(.*)$/ then nodes[$1] ||= RDF::Node($1) - when /^\w+:.*$/ then RDF::URI(v) - else RDF::Literal(v) - end + when /^_:(.*)$/ then nodes[$1] ||= RDF::Node($1) + when /^\w+:.*$/ then RDF::URI(v) + else RDF::Literal(v) + end solution[vars[i].to_sym] = term end solutions << solution @@ -558,22 +595,28 @@ def self.parse_csv_bindings(csv, nodes = {}) ## # @param [String, Array>] tsv # @return [] - # @see http://www.w3.org/TR/sparql11-results-csv-tsv/ + # @see https://www.w3.org/TR/sparql11-results-csv-tsv/ def self.parse_tsv_bindings(tsv, nodes = {}) tsv = tsv.lines.map {|l| l.chomp.split("\t")} unless tsv.is_a?(Array) vars = tsv.shift.map {|h| h.sub(/^\?/, '')} solutions = RDF::Query::Solutions.new tsv.each do |row| + # Flesh out columns which may be missing + vars.each_with_index do |_, i| + row[i] ||= "" + end solution = RDF::Query::Solution.new row.each_with_index do |v, i| - term = RDF::NTriples.unserialize(v) || case v + term = case v + when "" then RDF::Literal("") when /^\d+\.\d*[eE][+-]?[0-9]+$/ then RDF::Literal::Double.new(v) when /^\d*\.\d+[eE][+-]?[0-9]+$/ then RDF::Literal::Double.new(v) when /^\d*\.\d+$/ then RDF::Literal::Decimal.new(v) when /^\d+$/ then RDF::Literal::Integer.new(v) - else - RDF::Literal(v) - end + else + RDF::NTriples.unserialize(v) || RDF::Literal(v) + end + nodes[term.id] = term if term.is_a? RDF::Node solution[vars[i].to_sym] = term end solutions << solution @@ -581,20 +624,41 @@ def self.parse_tsv_bindings(tsv, nodes = {}) solutions end - def self.parse_plain_bindings(plain, nodes = {}) - return plain - end - ## - # @param [String, REXML::Element] xml + # @param [String, IO, Nokogiri::XML::Node, REXML::Element] xml + # @param [Symbol] library (:nokogiri) + # One of :nokogiri or :rexml. # @return [] - # @see http://www.w3.org/TR/rdf-sparql-json-res/#results - def self.parse_xml_bindings(xml, nodes = {}) + # @see https://www.w3.org/TR/rdf-sparql-json-res/#results + def self.parse_xml_bindings(xml, nodes = {}, library: :nokogiri) xml.force_encoding(::Encoding::UTF_8) if xml.respond_to?(:force_encoding) - require 'rexml/document' unless defined?(::REXML::Document) - xml = REXML::Document.new(xml).root unless xml.is_a?(REXML::Element) - case + if defined?(::Nokogiri) && library == :nokogiri + xml = Nokogiri::XML(xml).root unless xml.is_a?(Nokogiri::XML::Document) + case + when boolean = xml.xpath("//sparql:boolean", XMLNS)[0] + boolean.text == 'true' + when results = xml.xpath("//sparql:results", XMLNS)[0] + solutions = results.elements.map do |result| + row = {} + result.elements.each do |binding| + name = binding.attr('name').to_sym + value = binding.elements.first + row[name] = parse_xml_value(value, nodes) if value + end + RDF::Query::Solution.new(row) + end + solns = RDF::Query::Solutions.new(solutions) + + # Set variable names explicitly + var_names = xml.xpath("//sparql:head/sparql:variable/@name", XMLNS) + solns.variable_names = var_names.map(&:to_s) + solns + end + else + # REXML + xml = REXML::Document.new(xml).root unless xml.is_a?(REXML::Element) + case when boolean = xml.elements['boolean'] boolean.text == 'true' when results = xml.elements['results'] @@ -603,29 +667,39 @@ def self.parse_xml_bindings(xml, nodes = {}) result.elements.each do |binding| name = binding.attributes['name'].to_sym value = binding.select { |node| node.kind_of?(::REXML::Element) }.first - row[name] = parse_xml_value(value, nodes) + row[name] = parse_xml_value(value, nodes) if value end RDF::Query::Solution.new(row) end - RDF::Query::Solutions.new(solutions) + solns = RDF::Query::Solutions.new(solutions) + + # Set variable names explicitly + var_names = xml.elements['head'].elements.map {|e| e.attributes['name']} + solns.variable_names = var_names.map(&:to_sym) + solns + end end end ## - # @param [REXML::Element] value + # @param [Nokogiri::XML::Element, REXML::Element] value # @return [RDF::Value] - # @see http://www.w3.org/TR/rdf-sparql-json-res/#variable-binding-results + # @see https://www.w3.org/TR/rdf-sparql-json-res/#variable-binding-results def self.parse_xml_value(value, nodes = {}) case value.name.to_sym - when :bnode - nodes[id = value.text] ||= RDF::Node.new(id) - when :uri - RDF::URI.new(value.text) - when :literal - RDF::Literal.new(value.text, { - :language => value.attributes['xml:lang'], - :datatype => value.attributes['datatype'], - }) + when :bnode + nodes[id = value.text] ||= RDF::Node.new(id) + when :uri + RDF::URI.new(value.text) + when :literal + lang = value.respond_to?(:attr) ? value.attr('xml:lang') : value.attributes['xml:lang'] + datatype = value.respond_to?(:attr) ? value.attr('datatype') : value.attributes['datatype'] + RDF::Literal.new(value.text, language: lang, datatype: datatype) + when :triple + # Note, this is order dependent + res = value.elements.map {|e| e.elements.to_a}. + flatten.map {|e| parse_xml_value(e, nodes)} + RDF::Statement(*res) else nil end end @@ -634,10 +708,12 @@ def self.parse_xml_value(value, nodes = {}) # @param [Net::HTTPSuccess] response # @param [Hash{Symbol => Object}] options # @return [RDF::Enumerable] - def parse_rdf_serialization(response, options = {}) - options = {:content_type => response.content_type} if options.empty? - if reader = RDF::Reader.for(options) + def parse_rdf_serialization(response, **options) + options = {content_type: response.content_type} unless options[:content_type] + if reader = RDF::Reader.for(**options) reader.new(response.body) + else + raise RDF::ReaderError, "no RDF reader was found for #{options}." end end @@ -649,9 +725,9 @@ def parse_rdf_serialization(response, options = {}) # @private def self.serialize_uri(uri) case uri - when String then RDF::NTriples.serialize(RDF::URI(uri)) - when RDF::URI then RDF::NTriples.serialize(uri) - else raise ArgumentError, "expected the graph URI to be a String or RDF::URI, but got #{uri.inspect}" + when String then RDF::NTriples.serialize(RDF::URI(uri)) + when RDF::URI then RDF::NTriples.serialize(uri) + else raise ArgumentError, "expected the graph URI to be a String or RDF::URI, but got #{uri.inspect}" end end @@ -659,14 +735,66 @@ def self.serialize_uri(uri) # Serializes an `RDF::Value` into SPARQL syntax. # # @param [RDF::Value] value + # @param [Boolean] use_vars (false) Use variables in place of BNodes # @return [String] # @private - def self.serialize_value(value) + def self.serialize_value(value, use_vars = false) # SPARQL queries are UTF-8, but support ASCII-style Unicode escapes, so # the N-Triples serializer is fine unless it's a variable: case + when value.nil? then RDF::Query::Variable.new.to_s when value.variable? then value.to_s - else RDF::NTriples.serialize(value) + when value.node? then (use_vars ? RDF::Query::Variable.new(value.id) : value) + else RDF::NTriples.serialize(value) + end + end + + ## + # Serializes a SPARQL predicate + # + # @param [RDF::Value, Array, String] value + # @param [Fixnum] rdepth + # @return [String] + # @private + def self.serialize_predicate(value,rdepth=0) + case value + when nil + RDF::Query::Variable.new.to_s + when String then value + when Array + s = value.map{|v|serialize_predicate(v,rdepth+1)}.join + rdepth > 0 ? "(#{s})" : s + when RDF::Value + # abbreviate RDF.type in the predicate position per SPARQL grammar + value.equal?(RDF.type) ? 'a' : serialize_value(value) + end + end + + ## + # Serializes a SPARQL graph + # + # @param [RDF::Enumerable] patterns + # @param [Boolean] use_vars (false) Use variables in place of BNodes + # @return [String] + # @private + def self.serialize_patterns(patterns, use_vars = false) + patterns.map do |pattern| + serialized_pattern = case pattern + when SPARQL::Client::QueryElement then [pattern.to_s] + else + RDF::Statement.from(pattern).to_triple.each_with_index.map do |v, i| + if i == 1 + SPARQL::Client.serialize_predicate(v) + else + sv = SPARQL::Client.serialize_value(v, use_vars) + if v.is_a?(RDF::Literal) && v.respond_to?(:original_datatype) && v.original_datatype&.to_s.eql?(RDF::XSD.string.to_s) + sv = "#{sv}^^" # 4store and Virtuoso need explicit string type + end + sv + end + end + end + serialized_pattern.join(' ') + ' .' end end @@ -690,16 +818,6 @@ def redis_cache=(redis_cache) @redis_cache = redis_cache end - def cube_options=(cube_options) - if cube_options - cube_host = cube_options[:host] || "localhost" - cube_port = cube_options[:port] || 1180 - @cube = Cube::Client.new(cube_host, cube_port) - else - @cube = nil - end - end - protected ## @@ -711,15 +829,15 @@ def cube_options=(cube_options) def http_klass(scheme) proxy_url = nil case scheme - when 'http' - value = ENV['http_proxy'] - proxy_url = URI.parse(value) unless value.nil? || value.empty? - when 'https' - value = ENV['https_proxy'] - proxy_url = URI.parse(value) unless value.nil? || value.empty? - end - klass = Net::HTTP::Persistent.new(self.class.to_s, proxy_url) - klass.keep_alive = 120 # increase to 2 minutes + when 'http' + value = ENV['http_proxy'] + proxy_url = URI.parse(value) unless value.nil? || value.empty? + when 'https' + value = ENV['https_proxy'] + proxy_url = URI.parse(value) unless value.nil? || value.empty? + end + klass = Net::HTTP::Persistent.new(name: self.class.to_s, proxy: proxy_url) + klass.keep_alive = @options[:keep_alive] || 120 klass.read_timeout = @options[:read_timeout] || 60 klass end @@ -729,36 +847,68 @@ def http_klass(scheme) # # @param [String, #to_s] query # @param [Hash{String => String}] headers + # HTTP Request headers + # + # Defaults `Accept` header based on available reader content types if triples are expected and to SPARQL result types otherwise, to allow for content negotiation based on available readers. + # + # Defaults `User-Agent` header, unless one is specified. # @yield [response] # @yieldparam [Net::HTTPResponse] response # @return [Net::HTTPResponse] - # @see http://www.w3.org/TR/sparql11-protocol/#query-operation - def request(query, headers = {}, op = :query, query_options = nil, &block) - method = (self.options[:method] || DEFAULT_METHOD).to_sym - request = send("make_#{method}_request", query,op , headers, query_options) + # @raise [IOError] if connection is closed + # @see https://www.w3.org/TR/sparql11-protocol/#query-operation + def request(query, headers = {}, &block) + # Make sure an appropriate Accept header is present + headers['Accept'] ||= if (query.respond_to?(:expects_statements?) ? + query.expects_statements? : + (query =~ /CONSTRUCT|DESCRIBE|DELETE|CLEAR/)) + GRAPH_ALL + else + RESULT_ALL + end + headers['User-Agent'] ||= "Ruby SPARQL::Client/#{SPARQL::Client::VERSION}" + + request = send("make_#{request_method(query)}_request", query, headers) request.basic_auth(url.user, url.password) if url.user && !url.user.empty? - @http.open_timeout = @http.read_timeout - @http.idle_timeout = nil - response = @http.request(url, request) - if block_given? - block.call(response) - else - response + pre_http_hook(request) if respond_to?(:pre_http_hook) + + raise IOError, "Client has been closed" unless @http + response = @http.request(::URI.parse(url.to_s), request) + + post_http_hook(response) if respond_to?(:post_http_hook) + + 10.times do + if response.kind_of? Net::HTTPRedirection + response = @http.request(::URI.parse(response['location']), request) + else + return block_given? ? block.call(response) : response + end end + raise ServerError, "Infinite redirect at #{url}. Redirected more than 10 times." + end + + ## + # Return the HTTP verb for posting this request. + # this is useful if you need to override the HTTP verb based on the request being made. + # (e.g. Marmotta 3.3.0 requires GET for DELETE requests, but can accept POST for INSERT) + def request_method(query) + (options[:method] || DEFAULT_METHOD).to_sym end + ## # Constructs an HTTP GET request according to the SPARQL Protocol. # # @param [String, #to_s] query # @param [Hash{String => String}] headers # @return [Net::HTTPRequest] - # @see http://www.w3.org/TR/sparql11-protocol/#query-via-get - def make_get_request(query,op = :query, headers = {},query_options = nil) + # @see https://www.w3.org/TR/sparql11-protocol/#query-via-get + def make_get_request(query, headers = {}) url = self.url.dup - url.query_values = (url.query_values || {}).merge(op => query.to_s) + url.query_values = (url.query_values || {}).merge(query: query.to_s) + set_url_default_graph url unless @options[:graph].nil? request = Net::HTTP::Get.new(url.request_uri, self.headers.merge(headers)) request end @@ -769,27 +919,77 @@ def make_get_request(query,op = :query, headers = {},query_options = nil) # @param [String, #to_s] query # @param [Hash{String => String}] headers # @return [Net::HTTPRequest] - # @see http://www.w3.org/TR/sparql11-protocol/#query-via-post-direct - # @see http://www.w3.org/TR/sparql11-protocol/#query-via-post-urlencoded - def make_post_request(query, headers = {}, op = :query, query_options = nil) - request = Net::HTTP::Post.new(self.url.request_uri, self.headers.merge(headers)) + # @see https://www.w3.org/TR/sparql11-protocol/#query-via-post-direct + # @see https://www.w3.org/TR/sparql11-protocol/#query-via-post-urlencoded + def make_post_request(query, headers = {}) + if @alt_endpoint.nil? + url = self.url.dup + set_url_default_graph url unless @options[:graph].nil? + endpoint = url.request_uri + else + endpoint = @alt_endpoint + end + + request = Net::HTTP::Post.new(endpoint, self.headers.merge(headers)) case (self.options[:protocol] || DEFAULT_PROTOCOL).to_s - when '1.1' - if self.options['Content-Type'] == "application/x-www-form-urlencoded" - request['Content-Type'] = "application/x-www-form-urlencoded" - form = {op => query.to_s} - form = form.merge(query_options) if query_options - request.set_form_data(form) - else - request['Content-Type'] = 'application/sparql-' + (@op || :query).to_s - request.body = query.to_s - end - when '1.0' - request.set_form_data((@op || :query) => query.to_s) + when '1.1' + if headers['Content-Type'] == "application/x-www-form-urlencoded" + request['Content-Type'] = "application/x-www-form-urlencoded" + form = { @op => query.to_s } + request.set_form_data(form) else - raise ArgumentError, "unknown SPARQL protocol version: #{self.options[:protocol].inspect}" + request['Content-Type'] = 'application/sparql-' + (@op || :query).to_s + request.body = query.to_s + end + when '1.0' + form_data = {(@op || :query) => query.to_s} + form_data.merge!( + {:'default-graph-uri' => @options[:graph]} + ) if !@options[:graph].nil? && (@op.eql? :query) + form_data.merge!( + {:'using-graph-uri' => @options[:graph]} + ) if !@options[:graph].nil? && (@op.eql? :update) + request.set_form_data(form_data) + else + raise ArgumentError, "unknown SPARQL protocol version: #{self.options[:protocol].inspect}" end request end + + ## + # Setup url query parameter to use a specified default graph + # + # @see https://www.w3.org/TR/sparql11-protocol/#query-operation + # @see https://www.w3.org/TR/sparql11-protocol/#update-operation + def set_url_default_graph url + if @options[:graph].is_a? Array + graphs = @options[:graph].map {|graph| + CGI::escape(graph) + } + else + graphs = CGI::escape(@options[:graph]) + end + case @op + when :query + url.query_values = (url.query_values || {}) + .merge(:'default-graph-uri' => graphs) + when :update + url.query_values = (url.query_values || {}) + .merge(:'using-graph-uri' => graphs) + end + end + + # A query element can be used as a component of a query. It may be initialized with a string, which is wrapped in an appropriate container depending on the type of QueryElement. Implements {#to_s} to property serialize when generating a SPARQL query. + class QueryElement + attr_reader :elements + + def initialize(*args) + @elements = args + end + + def to_s + raise NotImplemented + end + end end # Client end # SPARQL diff --git a/lib/sparql/client/query.rb b/lib/sparql/client/query.rb index 4210ba5d..1be43e58 100644 --- a/lib/sparql/client/query.rb +++ b/lib/sparql/client/query.rb @@ -1,6 +1,6 @@ -require 'digest/md5' +require 'delegate' -module SPARQL; class Client +class SPARQL::Client ## # A SPARQL query builder. # @@ -9,108 +9,146 @@ module SPARQL; class Client # class Query < RDF::Query ## - # @return [Symbol] - # @see http://www.w3.org/TR/sparql11-query/#QueryForms + # The form of the query. + # + # @return [:select, :ask, :construct, :describe] + # @see https://www.w3.org/TR/sparql11-query/#QueryForms attr_reader :form ## # @return [Hash{Symbol => Object}] attr_reader :options - ## - # @return [Array<[key, RDF::Value]>] - attr_reader :values - ## # Creates a boolean `ASK` query. # - # @param [Hash{Symbol => Object}] options + # @example ASK WHERE { ?s ?p ?o . } + # Query.ask.where([:s, :p, :o]) + # + # @param [Hash{Symbol => Object}] options (see {#initialize}) # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#ask - def self.ask(options = {}) - self.new(:ask, options) + # @see https://www.w3.org/TR/sparql11-query/#ask + def self.ask(**options) + self.new(:ask, **options) end ## # Creates a tuple `SELECT` query. # + # @example `SELECT * WHERE { ?s ?p ?o . }` + # Query.select.where([:s, :p, :o]) + # + # @example `SELECT ?s WHERE {?s ?p ?o .}` + # Query.select(:s).where([:s, :p, :o]) + # + # @example `SELECT COUNT(?uri as ?c) WHERE {?uri a owl:Class}` + # Query.select(count: {uri: :c}).where([:uri, RDF.type, RDF::OWL.Class]) + # # @param [Array] variables # @return [Query] # - # @overload self.select(*variables, options) + # @overload self.select(*variables, **options) # @param [Array] variables + # @param [Hash{Symbol => Object}] options (see {#initialize}) # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#select - def self.select(*variables) - options = variables.last.is_a?(Hash) ? variables.pop : {} - self.new(:select, options).select(*variables) + # @see https://www.w3.org/TR/sparql11-query/#select + def self.select(*variables, **options) + self.new(:select, **options).select(*variables) end ## # Creates a `DESCRIBE` query. # + # @example DESCRIBE * WHERE { ?s ?p ?o . } + # Query.describe.where([:s, :p, :o]) + # # @param [Array] variables # @return [Query] # - # @overload self.describe(*variables, options) + # @overload self.describe(*variables, **options) # @param [Array] variables + # @param [Hash{Symbol => Object}] options (see {#initialize}) # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#describe - def self.describe(*variables) - options = variables.last.is_a?(Hash) ? variables.pop : {} - self.new(:describe, options).describe(*variables) + # @see https://www.w3.org/TR/sparql11-query/#describe + def self.describe(*variables, **options) + self.new(:describe, **options).describe(*variables) end ## # Creates a graph `CONSTRUCT` query. # + # @example CONSTRUCT { ?s ?p ?o . } WHERE { ?s ?p ?o . } + # Query.construct([:s, :p, :o]).where([:s, :p, :o]) + # # @param [Array] patterns # @return [Query] # - # @overload self.construct(*variables, options) + # @overload self.construct(*variables, **options) # @param [Array] patterns - # @param [Hash{Symbol => Object}] options + # @param [Hash{Symbol => Object}] options (see {#initialize}) # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#construct - def self.construct(*patterns) - options = patterns.last.is_a?(Hash) ? patterns.pop : {} - self.new(:construct, options).construct(*patterns) # FIXME + # @see https://www.w3.org/TR/sparql11-query/#construct + def self.construct(*patterns, **options) + self.new(:construct, **options).construct(*patterns) # FIXME end ## # @param [Symbol, #to_s] form - # @overload self.construct(*variables, options) + # @overload self.construct(*variables, **options) # @param [Symbol, #to_s] form - # @param [Hash{Symbol => Object}] options + # @param [Hash{Symbol => Object}] options (see {Client#initialize}) + # @option options [Hash{Symbol => Symbol}] :count + # Contents are symbols relating a variable described within the query, + # to the projected variable. + # # @yield [query] # @yieldparam [Query] - def initialize(form = :ask, options = {}, &block) + def initialize(form = :ask, **options, &block) @subqueries = [] @form = form.respond_to?(:to_sym) ? form.to_sym : form.to_s.to_sym - super([], options, &block) + super([], **options, &block) end ## + # @example ASK WHERE { ?s ?p ?o . } + # Query.ask.where([:s, :p, :o]) + # # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#ask + # @see https://www.w3.org/TR/sparql11-query/#ask def ask @form = :ask self end ## - # @param [Array] variables + # @example `SELECT * WHERE { ?s ?p ?o . }` + # Query.select.where([:s, :p, :o]) + # + # @example `SELECT ?s WHERE {?s ?p ?o .}` + # Query.select(:s).where([:s, :p, :o]) + # + # @example `SELECT COUNT(?uri as ?c) WHERE {?uri a owl:Class}` + # Query.select(count: {uri: :c}).where([:uri, RDF.type, RDF::OWL.Class]) + # + # @param [Array, Hash{Symbol => RDF::Query::Variable}] variables # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#select + # @see https://www.w3.org/TR/sparql11-query/#select def select(*variables) - @values = variables.map { |var| [var, RDF::Query::Variable.new(var)] } + @values = if variables.length == 1 && variables.first.is_a?(Hash) + variables.to_a + else + variables.map { |var| [var, RDF::Query::Variable.new(var)] } + end self end ## + # @example DESCRIBE * WHERE { ?s ?p ?o . } + # Query.describe.where([:s, :p, :o]) + # # @param [Array] variables # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#describe + # @see https://www.w3.org/TR/sparql11-query/#describe def describe(*variables) @values = variables.map { |var| [var, var.is_a?(RDF::URI) ? var : RDF::Query::Variable.new(var)] @@ -119,40 +157,97 @@ def describe(*variables) end ## + # @example CONSTRUCT { ?s ?p ?o . } WHERE { ?s ?p ?o . } + # Query.construct([:s, :p, :o]).where([:s, :p, :o]) + # # @param [Array] patterns # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#construct + # @see https://www.w3.org/TR/sparql11-query/#construct def construct(*patterns) options[:template] = build_patterns(patterns) self end + ## + # @example SELECT * FROM WHERE \{ ?s ?p ?o . \} + # Query.select.from(RDF::URI.new(a)).where([:s, :p, :o]) + # # @param [RDF::URI] uri # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#specifyingDataset + # @see https://www.w3.org/TR/sparql11-query/#specifyingDataset def from(uri) options[:from] = uri self end - + ## + # @example SELECT * WHERE { ?s ?p ?o . } + # Query.select.where([:s, :p, :o]) + # Query.select.whether([:s, :p, :o]) + # + # @example SELECT * WHERE { { SELECT * WHERE { ?s ?p ?o . } } . ?s ?p ?o . } + # subquery = Query.select.where([:s, :p, :o]) + # Query.select.where([:s, :p, :o], subquery) + # + # @example SELECT * WHERE { { SELECT * WHERE { ?s ?p ?o . } } . ?s ?p ?o . } + # Query.select.where([:s, :p, :o]) do |q| + # q.select.where([:s, :p, :o]) + # end + # + # Block form can be used for chaining calls in addition to creating sub-select queries. + # + # @example SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o + # Query.select.where([:s, :p, :o]) do + # order(:o) + # end + # # @param [Array] patterns_queries # splat of zero or more patterns followed by zero or more queries. + # @yield [query] + # Yield form with or without argument; without an argument, evaluates within the query. + # @yieldparam [SPARQL::Client::Query] query Actually a delegator to query. Methods other than `#select` are evaluated against `self`. For `#select`, a new Query is created, and the result added as a subquery. # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#GraphPattern - def where(*patterns_queries) + # @see https://www.w3.org/TR/sparql11-query/#GraphPattern + def where(*patterns_queries, &block) subqueries, patterns = patterns_queries.partition {|pq| pq.is_a? SPARQL::Client::Query} @patterns += build_patterns(patterns) @subqueries += subqueries + + if block_given? + decorated_query = WhereDecorator.new(self) + case block.arity + when 1 then block.call(decorated_query) + else decorated_query.instance_eval(&block) + end + end self end alias_method :whether, :where + # @private + class WhereDecorator < SimpleDelegator + def select(*variables) + query = SPARQL::Client::Query.select(*variables) + __getobj__.instance_variable_get(:@subqueries) << query + query + end + end + ## + # @example SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o + # Query.select.where([:s, :p, :o]).order(:o) + # Query.select.where([:s, :p, :o]).order_by(:o) + # + # @example SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o ?p + # Query.select.where([:s, :p, :o]).order_by(:o, :p) + # + # @example SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o) DESC(?p) + # Query.select.where([:s, :p, :o]).order_by(o: :asc, p: :desc) + # # @param [Array] variables # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#modOrderBy + # @see https://www.w3.org/TR/sparql11-query/#modOrderBy def order(*variables) options[:order_by] = variables self @@ -161,9 +256,38 @@ def order(*variables) alias_method :order_by, :order ## + # @example SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o) + # Query.select.where([:s, :p, :o]).order.asc(:o) + # Query.select.where([:s, :p, :o]).asc(:o) + # + # @param [Array] var + # @return [Query] + # @see https://www.w3.org/TR/sparql11-query/#modOrderBy + def asc(var) + (options[:order_by] ||= []) << {var => :asc} + self + end + + ## + # @example SELECT * WHERE { ?s ?p ?o . } ORDER BY DESC(?o) + # Query.select.where([:s, :p, :o]).order.desc(:o) + # Query.select.where([:s, :p, :o]).desc(:o) + # + # @param [Array] var + # @return [Query] + # @see https://www.w3.org/TR/sparql11-query/#modOrderBy + def desc(var) + (options[:order_by] ||= []) << {var => :desc} + self + end + + ## + # @example SELECT ?s WHERE { ?s ?p ?o . } GROUP BY ?s + # Query.select(:s).where([:s, :p, :o]).group_by(:s) + # # @param [Array] variables # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#groupby + # @see https://www.w3.org/TR/sparql11-query/#groupby def group(*variables) options[:group_by] = variables self @@ -172,52 +296,70 @@ def group(*variables) alias_method :group_by, :group ## + # @example SELECT DISTINCT ?s WHERE { ?s ?p ?o . } + # Query.select(:s).distinct.where([:s, :p, :o]) + # # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#modDuplicates + # @see https://www.w3.org/TR/sparql11-query/#modDuplicates def distinct(state = true) options[:distinct] = state self end ## + # @example SELECT REDUCED ?s WHERE { ?s ?p ?o . } + # Query.select(:s).reduced.where([:s, :p, :o]) + # # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#modDuplicates + # @see https://www.w3.org/TR/sparql11-query/#modDuplicates def reduced(state = true) options[:reduced] = state self end ## + # @example SELECT * WHERE { GRAPH ?g { ?s ?p ?o . } } + # Query.select.graph(:g).where([:s, :p, :o]) + # # @param [RDF::Value] graph_uri_or_var # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#queryDataset + # @see https://www.w3.org/TR/sparql11-query/#queryDataset def graph(graph_uri_or_var) options[:graph] = case graph_uri_or_var - when Symbol then RDF::Query::Variable.new(graph_uri_or_var) - when String then RDF::URI(graph_uri_or_var) - when RDF::Value then graph_uri_or_var - else raise ArgumentError - end + when Symbol then RDF::Query::Variable.new(graph_uri_or_var) + when String then RDF::URI(graph_uri_or_var) + when RDF::Value then graph_uri_or_var + else raise ArgumentError + end self end ## + # @example SELECT * WHERE { ?s ?p ?o . } OFFSET 100 + # Query.select.where([:s, :p, :o]).offset(100) + # # @param [Integer, #to_i] start # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#modOffset + # @see https://www.w3.org/TR/sparql11-query/#modOffset def offset(start) slice(start, nil) end ## + # @example SELECT * WHERE { ?s ?p ?o . } LIMIT 10 + # Query.select.where([:s, :p, :o]).limit(10) + # # @param [Integer, #to_i] length # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#modResultLimit + # @see https://www.w3.org/TR/sparql11-query/#modResultLimit def limit(length) slice(nil, length) end ## + # @example SELECT * WHERE { ?s ?p ?o . } OFFSET 100 LIMIT 10 + # Query.select.where([:s, :p, :o]).slice(100, 10) + # # @param [Integer, #to_i] start # @param [Integer, #to_i] length # @return [Query] @@ -228,44 +370,149 @@ def slice(start, length) end ## - # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#prefNames - def prefix(string) - (options[:prefixes] ||= []) << string + # @overload prefix(prefix: uri) + # @example PREFIX dc: PREFIX foaf: SELECT * WHERE \{ ?s ?p ?o . \} + # Query.select. + # prefix(dc: RDF::URI("http://purl.org/dc/elements/1.1/")). + # prefix(foaf: RDF::URI("http://xmlns.com/foaf/0.1/")). + # where([:s, :p, :o]) + # + # @param [RDF::URI] uri + # @param [Symbol, String] prefix + # @return [Query] + # + # @overload prefix(string) + # @example PREFIX dc: PREFIX foaf: SELECT * WHERE \{ ?s ?p ?o . \} + # Query.select. + # prefix("dc: "). + # prefix("foaf: "). + # where([:s, :p, :o]) + # + # @param [string] string + # @return [Query] + # @see https://www.w3.org/TR/sparql11-query/#prefNames + def prefix(val) + options[:prefixes] ||= [] + if val.kind_of? String + options[:prefixes] << val + elsif val.kind_of? Hash + val.each do |k, v| + options[:prefixes] << "#{k}: <#{v}>" + end + else + raise ArgumentError, "prefix must be a kind of String or a Hash" + end self end ## + # @example SELECT * WHERE \{ ?s ?p ?o . OPTIONAL \{ ?s a ?o . ?s \ ?o . \} \} + # Query.select.where([:s, :p, :o]). + # optional([:s, RDF.type, :o], [:s, RDF::Vocab::DC.abstract, :o]) + # + # The block form can be used for adding filters: + # + # @example ASK WHERE { ?s ?p ?o . OPTIONAL { ?s ?p ?o . FILTER(regex(?s, 'Abiline, Texas'))} } + # Query.ask.where([:s, :p, :o]).optional([:s, :p, :o]) do + # filter("regex(?s, 'Abiline, Texas')") + # end + # + # @param [Array] patterns + # splat of zero or more patterns followed by zero or more queries. + # @yield [query] + # Yield form with or without argument; without an argument, evaluates within the query. + # @yieldparam [SPARQL::Client::Query] query used for creating filters on the optional patterns. # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#optionals - def optional(*patterns) + # @see https://www.w3.org/TR/sparql11-query/#optionals + def optional(*patterns, &block) (options[:optionals] ||= []) << build_patterns(patterns) + + if block_given? + # Steal options[:filters] + query_filters = options[:filters] + options[:filters] = [] + case block.arity + when 1 then block.call(self) + else instance_eval(&block) + end + options[:optionals].last.concat(options[:filters]) + options[:filters] = query_filters + end + self end ## + # Federated Queries via the SERVICE keyword. + # + # Supports limited use of the SERVICE keyword with an endpoint term, a sequence of patterns, a query, or a block. + # + # @example SELECT * WHERE \{ ?s ?p1 ?o1 . SERVICE ?l \{ ?s ?p2 ?o2 \} \} + # Query.select.where([:s, :p1, :o1]). + # service(:l, [:s, :p2, :o2]) + # + # @example SELECT * WHERE \{ ?book ?title . SERVICE ?l \{ ?book ?title . FILTER(langmatches(?title, 'en')) \} \} + # query1 = SPARQL::Client::Query.select. + # where([:book, RDF::Vocab::DC11.title, :title]). + # filter("langmatches(?title, 'en')") + # Query.select.where([:book, RDF::Vocab::DC.title, :title]).service(?l, query1) + # + # The block form can be used for more complicated queries, using the `select` form (note, use either block or argument forms, not both): + # + # @example SELECT * WHERE \{ ?book dc:title ?title \} SERVICE ?l \{ ?book dc11:title ?title \} + # query1 = SPARQL::Client::Query.select.where([:book, RDF::Vocab::DC11.title, :title]) + # Query.select.where([:book, RDF::Vocab::DC.title, :title]).service :l do |q| + # q.select. + # where([:book, RDF::Vocab::DC11.title, :title]) + # end + # + # @example SELECT * WHERE \{ ?s ?p1 ?o1 . SERVICE SILENT ?l \{ ?s ?p2 ?o2 \} \} + # Query.select.where([:s, :p1, :o1]). + # service(:l, [:s, :p2, :o2], silent: true) + # + # @param [Array] patterns + # splat of zero or more patterns followed by zero or more queries. + # @param [Boolean] silent + # @yield [query] + # Yield form with or without argument; without an argument, evaluates within the query. + # @yieldparam [SPARQL::Client::Query] query used for adding select clauses. # @return [Query] - # @see http://www.w3.org/TR/sparql11-query/#union - def union(*patterns_list) - options[:unions] ||= [] - patterns_list.each do |patterns| - options[:unions] << build_patterns(patterns) - end - self - end + # @see https://www.w3.org/TR/sparql11-federated-query/ + def service(endpoint, *patterns, silent: false, &block) + service = { + endpoint: (endpoint.is_a?(Symbol) ? RDF::Query::Variable.new(endpoint) : endpoint), + silent: silent, + query: nil + } + (options[:services] ||= []) << service - def union_with_bind_as(*pattern_list) - options[:unions_with_bind] ||= [] - pattern_list.each do |patterns,bind_value,bind_var| - options[:unions_with_bind] << [build_patterns(patterns), bind_value,bind_var] + if block_given? + raise ArgumentError, "#service requires either arguments or a block, not both." unless patterns.empty? + # Evaluate calls in a new query instance + query = self.class.select.where + case block.arity + when 1 then block.call(query) + else query.instance_eval(&block) + end + service[:query] = query + elsif patterns.all? {|p| p.is_a?(SPARQL::Client::Query)} + # With argument form, all must be patterns or queries + raise ArgumentError, "#service arguments are triple patterns or a query, not both." if patterns.length != 1 + service[:query] = patterns.first + elsif patterns.all? {|p| p.is_a?(Array)} + # With argument form, all must be patterns, or queries + service[:query] = self.class.select.where(*patterns) + else + raise ArgumentError, "#service arguments are triple patterns a query, not both." end + self end def optional_union_with_bind_as(*pattern_list) options[:optional_unions_with_bind] ||= [] - pattern_list.each do |patterns,bind,filter| + pattern_list.each do |patterns, bind, filter| options[:optional_unions_with_bind] << [build_patterns(patterns), bind, filter] end self @@ -275,10 +522,10 @@ def cache_key return nil if options[:from].nil? || options[:from].empty? from = options[:from] from = [from] unless from.instance_of?(Array) - return Query.generate_cache_key(self.to_s,from) + return Query.generate_cache_key(self.to_s, from) end - def self.generate_cache_key(string,from) + def self.generate_cache_key(string, from) from = from.map { |x| x.to_s }.uniq.sort sorted_graphs = from.join ":" digest = Digest::MD5.hexdigest(string) @@ -287,20 +534,194 @@ def self.generate_cache_key(string,from) end ## - # @private - def build_patterns(patterns) - patterns.map do |pattern| - case pattern - when RDF::Query::Pattern then pattern - else RDF::Query::Pattern.new(*pattern.to_a) + # @example SELECT * WHERE \{ ?book dc:title ?title \} UNION \{ ?book dc11:title ?title \} + # Query.select.where([:book, RDF::Vocab::DC.title, :title]). + # union([:book, RDF::Vocab::DC11.title, :title]) + # + # @example SELECT * WHERE \{ ?book dc:title ?title \} UNION \{ ?book dc11:title ?title . FILTER(langmatches(lang(?title), 'EN'))\} + # query1 = SPARQL::Client::Query.select. + # where([:book, RDF::Vocab::DC11.title, :title]). + # filter("langmatches(?title, 'en')") + # Query.select.where([:book, RDF::Vocab::DC.title, :title]).union(query1) + # + # The block form can be used for more complicated queries, using the `select` form (note, use either block or argument forms, not both): + # + # @example SELECT * WHERE \{ ?book dc:title ?title \} UNION \{ ?book dc11:title ?title . FILTER(langmatches(lang(?title), 'EN'))\} + # query1 = SPARQL::Client::Query.select.where([:book, RDF::Vocab::DC11.title, :title]).filter("langmatches(?title, 'en')") + # Query.select.where([:book, RDF::Vocab::DC.title, :title]).union do |q| + # q.select. + # where([:book, RDF::Vocab::DC11.title, :title]). + # filter("langmatches(?title, 'en')") + # end + # + # @param [Array] patterns + # splat of zero or more patterns followed by zero or more queries. + # @yield [query] + # Yield form with or without argument; without an argument, evaluates within the query. + # @yieldparam [SPARQL::Client::Query] query used for adding select clauses. + # @return [Query] + # @see https://www.w3.org/TR/sparql11-query/#alternatives + def union(*patterns, &block) + options[:unions] ||= [] + + if block_given? + raise ArgumentError, "#union requires either arguments or a block, not both." unless patterns.empty? + # Evaluate calls in a new query instance + query = self.class.select + case block.arity + when 1 then block.call(query) + else query.instance_eval(&block) end + options[:unions] << query + elsif patterns.all? {|p| p.is_a?(SPARQL::Client::Query)} + # With argument form, all must be patterns or queries + options[:unions] += patterns + elsif patterns.all? {|p| p.is_a?(Array)} + # With argument form, all must be patterns, or queries + options[:unions] << self.class.select.where(*patterns) + else + raise ArgumentError, "#union arguments are triple patterns or queries, not both." end + + self end ## - # @private + # @example SELECT * WHERE \{ ?book dc:title ?title . MINUS \{ ?book dc11:title ?title \} \} + # Query.select.where([:book, RDF::Vocab::DC.title, :title]). + # minus([:book, RDF::Vocab::DC11.title, :title]) + # + # @example SELECT * WHERE \{ ?book dc:title ?title MINUS \{ ?book dc11:title ?title . FILTER(langmatches(lang(?title), 'EN')) \} \} + # query1 = SPARQL::Client::Query.select. + # where([:book, RDF::Vocab::DC11.title, :title]). + # filter("langmatches(?title, 'en')") + # Query.select.where([:book, RDF::Vocab::DC.title, :title]).minus(query1) + # + # The block form can be used for more complicated queries, using the `select` form (note, use either block or argument forms, not both): + # + # @example SELECT * WHERE \{ ?book dc:title ?title MINUS \{ ?book dc11:title ?title . FILTER(langmatches(lang(?title), 'EN'))\} \} + # query1 = SPARQL::Client::Query.select.where([:book, RDF::Vocab::DC11.title, :title]).filter("langmatches(?title, 'en')") + # Query.select.where([:book, RDF::Vocab::DC.title, :title]).minus do |q| + # q.select. + # where([:book, RDF::Vocab::DC11.title, :title]). + # filter("langmatches(?title, 'en')") + # end + # + # @param [Array] patterns + # splat of zero or more patterns followed by zero or more queries. + # @yield [query] + # Yield form with or without argument; without an argument, evaluates within the query. + # @yieldparam [SPARQL::Client::Query] query used for adding select clauses. + # @return [Query] + # @see https://www.w3.org/TR/sparql11-query/#negation + def minus(*patterns, &block) + options[:minuses] ||= [] + + if block_given? + raise ArgumentError, "#minus requires either arguments or a block, not both." unless patterns.empty? + # Evaluate calls in a new query instance + query = self.class.select + case block.arity + when 1 then block.call(query) + else query.instance_eval(&block) + end + options[:minuses] << query + elsif patterns.all? {|p| p.is_a?(SPARQL::Client::Query)} + # With argument form, all must be patterns or queries + options[:minuses] += patterns + elsif patterns.all? {|p| p.is_a?(Array)} + # With argument form, all must be patterns, or queries + options[:minuses] << self.class.select.where(*patterns) + else + raise ArgumentError, "#minus arguments are triple patterns or queries, not both." + end + + self + end + + ## + # Specify inline data for a query + # + # @overload values + # Values returned from previous query. + # + # @return [Array] + # + # @overload values(vars, *data) + # @example single variable with multiple values + # Query.select + # .where([:s, RDF::URI('http://purl.org/dc/terms/title'), :title]) + # .values(:title, "This title", "Another title") + # + # @example multiple variables with multiple values + # Query.select + # .where([:s, RDF::URI('http://purl.org/dc/terms/title'), :title], + # [:s, RDF.type, :type]) + # .values([:type, :title], + # [RDF::URI('http://pcdm.org/models#Object'), "This title"], + # [RDF::URI('http://pcdm.org/models#Collection', 'Another title']) + # + # @example multiple variables with UNDEF + # Query.select + # .where([:s, RDF::URI('http://purl.org/dc/terms/title'), :title], + # [:s, RDF.type, :type]) + # .values([:type, :title], + # [nil "This title"], + # [RDF::URI('http://pcdm.org/models#Collection', nil]) + # + # @param [Symbol, Array] vars + # @param [Array] *data + # @return [Query] + def values(*args) + return @values if args.empty? + vars, *data = *args + vars = Array(vars).map {|var| RDF::Query::Variable.new(var)} + if vars.length == 1 + # data may be a in array form or simple form + if data.any? {|d| d.is_a?(Array)} && !data.all? {|d| d.is_a?(Array)} + raise ArgumentError, "values data must all be in array form or all simple" + end + data = data.map {|d| Array(d)} + end + + # Each data value must be an array with the same number of entries as vars + unless data.all? {|d| d.is_a?(Array) && d.all? {|dd| dd.is_a?(RDF::Value) || dd.is_a?(String) || dd.nil?}} + raise ArgumentError, "values data must each be an array of terms, strings, or nil" + end + + # Turn strings into Literals + data = data.map do |d| + d.map do |nil_literal_or_term| + case nil_literal_or_term + when nil then nil + when String then RDF::Literal(nil_literal_or_term) + when RDF::Value then nil_literal_or_term + else raise ArgumentError + end + end + end + options[:values] = [vars, *data] + self + end + + ## + # @return expects_statements? + def expects_statements? + [:construct, :describe].include?(form) + end + + ## + # @private + def build_patterns(patterns) + patterns.map {|pattern| RDF::Query::Pattern.from(pattern)} + end + + ## + # @example ASK WHERE { ?s ?p ?o . FILTER(regex(?s, 'Abiline, Texas')) } + # Query.ask.where([:s, :p, :o]).filter("regex(?s, 'Abiline, Texas')") + # @return [Query] def filter(string) - ((options[:filters] ||= []) << string) if string and not string.empty? + ((options[:filters] ||= []) << Filter.new(string)) if string and not string.empty? self end @@ -308,10 +729,10 @@ def filter(string) # @return [Boolean] def true? case result - when TrueClass, FalseClass then result - when RDF::Literal::Boolean then result.true? - when Enumerable then !result.empty? - else false + when TrueClass, FalseClass then result + when RDF::Literal::Boolean then result.true? + when Enumerable then !result.empty? + else false end end @@ -365,21 +786,21 @@ def to_s buffer = [form.to_s.upcase] case form - when :select, :describe - only_count = values.empty? and options[:count] - buffer << 'DISTINCT' if options[:distinct] and not only_count + when :select, :describe + only_count = values.empty? && options[:count] + buffer << 'DISTINCT' if options[:distinct] and not only_count buffer << 'REDUCED' if options[:reduced] - buffer << ((values.empty? and not options[:count]) ? '*' : values.map { |v| SPARQL::Client.serialize_value(v[1]) }.join(' ')) - if options[:count] - options[:count].each do |var, count, aggregate| - buffer << "( #{aggregate.to_s.upcase}(" + (options[:distinct] ? 'DISTINCT ' : '') + - (var.is_a?(String) ? var : "?#{var}") + ') AS ' + (count.is_a?(String) ? count : "?#{count}") + ' )' - end + buffer << ((values.empty? and not options[:count]) ? '*' : values.map { |v| SPARQL::Client.serialize_value(v[1]) }.join(' ')) + if options[:count] + options[:count].each do |var, count| + buffer << '( COUNT(' + (options[:distinct] ? 'DISTINCT ' : '') + + (var.is_a?(String) ? var : "?#{var}") + ') AS ' + (count.is_a?(String) ? count : "?#{count}") + ' )' end - when :construct - buffer << '{' - buffer += serialize_patterns(options[:template]) - buffer << '}' + end + when :construct + buffer << '{' + buffer += SPARQL::Client.serialize_patterns(options[:template]) + buffer << '}' end from = options[:from] @@ -391,85 +812,64 @@ def to_s end unless patterns.empty? && form == :describe - buffer << 'WHERE {' + buffer += self.to_s_ggp.unshift('WHERE') + end - if options[:graph] - buffer << 'GRAPH ' + SPARQL::Client.serialize_value(options[:graph]) - buffer << '{' - end - @subqueries.each do |sq| - buffer << "{ #{sq.to_s} } ." + if options[:unions] + buffer.pop # remove } of where + options.fetch(:unions, []).each_with_index do |query, index| + if index.zero? + buffer += query.to_s_ggp + else + buffer += query.to_s_ggp.unshift('UNION') + end end + buffer << '}' + end - def add_union_with_bind(patterns) - include_union = nil - buffer = [] - patterns.each do |pattern, options| - buffer << include_union if include_union - buffer << '{' - buffer += serialize_patterns(pattern) - if options[:filters] - buffer += options[:filters].map do |filter| - str = filter[:values].map do |val| - "?#{filter[:predicate]} = <#{val}>" - end - "FILTER(#{str.join(' || ')}) " - end - end - if options[:binds] - buffer += options[:binds].map { |bind| "BIND( \"#{bind[:value]}\" as ?#{bind[:as]})" } + def add_union_with_bind(patterns) + include_union = nil + buffer = [] + patterns.each do |pattern, options| + buffer << include_union if include_union + buffer << '{' + buffer += serialize_patterns(pattern) + if options[:filters] + buffer += options[:filters].map do |filter| + str = filter[:values].map do |val| + "?#{filter[:predicate]} = <#{val}>" + end + "FILTER(#{str.join(' || ')}) " end - - - buffer << '}' - include_union = "UNION " end - buffer - end - - buffer += serialize_patterns(patterns) - if options[:unions] - include_union = nil - options[:unions].each do |union_block| - buffer << include_union if include_union - buffer << '{' - buffer += serialize_patterns(union_block) - buffer << '} ' - include_union = "UNION " + if options[:binds] + buffer += options[:binds].map { |bind| "BIND( \"#{bind[:value]}\" as ?#{bind[:as]})" } end - end - if options[:unions_with_bind] - buffer << add_union_with_bind(options[:unions_with_bind]) - end - if options[:optional_unions_with_bind] && !options[:optional_unions_with_bind].empty? - buffer << 'OPTIONAL {' - buffer << add_union_with_bind(options[:optional_unions_with_bind]) buffer << '}' + include_union = "UNION " end + buffer + end - if options[:optionals] - options[:optionals].each do |patterns| - buffer << 'OPTIONAL {' - buffer += serialize_patterns(patterns) - # This is added to move the filters into the OPTIONAL clauses for AG compatibility - buffer += patterns.map { |pattern| "FILTER(#{pattern.options[:filter]})" if pattern.options && pattern.options[:filter] } - buffer << '}' - end - end - if options[:filters] - buffer += options[:filters].map { |filter| "FILTER(#{filter})" } - end - if options[:graph] - buffer << '}' # GRAPH - end + if options[:unions_with_bind] + buffer.pop # remove } of where + buffer << add_union_with_bind(options[:unions_with_bind]) + buffer << '}' + end - buffer << '}' # WHERE + if options[:optional_unions_with_bind] && !options[:optional_unions_with_bind].empty? + buffer.pop # remove } of where + buffer << 'OPTIONAL {' + buffer << add_union_with_bind(options[:optional_unions_with_bind]) + buffer << '}' + buffer << '}' end + if options[:group_by] buffer << 'GROUP BY' buffer += options[:group_by].map { |var| var.is_a?(String) ? var : "?#{var}" } @@ -477,7 +877,40 @@ def add_union_with_bind(patterns) if options[:order_by] buffer << 'ORDER BY' - buffer += options[:order_by].map { |var| var.is_a?(String) ? var : "?#{var}" } + options[:order_by].map { |elem| + case elem + # .order_by({ var1: :asc, var2: :desc}) + when Hash + elem.each { |key, val| + # check provided values + if !key.is_a?(Symbol) + raise ArgumentError, 'keys of hash argument must be a Symbol' + elsif !val.is_a?(Symbol) || (val != :asc && val != :desc) + raise ArgumentError, 'values of hash argument must either be `:asc` or `:desc`' + end + buffer << "#{val == :asc ? 'ASC' : 'DESC'}(?#{key})" + } + # .order_by([:var1, :asc], [:var2, :desc]) + when Array + # check provided values + if elem.length != 2 + raise ArgumentError, 'array argument must specify two elements' + elsif !elem[0].is_a?(Symbol) + raise ArgumentError, '1st element of array argument must contain a Symbol' + elsif !elem[1].is_a?(Symbol) || (elem[1] != :asc && elem[1] != :desc) + raise ArgumentError, '2nd element of array argument must either be `:asc` or `:desc`' + end + buffer << "#{elem[1] == :asc ? 'ASC' : 'DESC'}(?#{elem[0]})" + # .order_by(:var1, :var2) + when Symbol + buffer << "?#{elem}" + # .order_by('ASC(?var1) DESC(?var2)') + when String + buffer << elem + else + raise ArgumentError, 'argument provided to `order()` must either be an Array, Symbol or String' + end + } end buffer << "OFFSET #{options[:offset]}" if options[:offset] @@ -487,8 +920,65 @@ def add_union_with_bind(patterns) buffer.join(' ') end - ## + # Serialize a Group Graph Pattern # @private + def to_s_ggp + buffer = ["{"] + + if options[:graph] + buffer << 'GRAPH ' + SPARQL::Client.serialize_value(options[:graph]) + buffer << '{' + end + + @subqueries.each do |sq| + buffer << "{ #{sq.to_s} } ." + end + + buffer += SPARQL::Client.serialize_patterns(patterns) + if options[:optionals] + options[:optionals].each do |patterns| + buffer << 'OPTIONAL {' + buffer += SPARQL::Client.serialize_patterns(patterns) + buffer << '}' + end + end + if options[:filters] + buffer += options[:filters].map(&:to_s) + end + + if options[:services] + options[:services].each do |service| + buffer << 'SERVICE' + buffer << 'SILENT' if service[:silent] + buffer << SPARQL::Client.serialize_value(service[:endpoint]) + buffer << service[:query].to_s_ggp + end + end + + if options[:values] + vars = options[:values].first.map {|var| SPARQL::Client.serialize_value(var)} + buffer << "VALUES (#{vars.join(' ')}) {" + options[:values][1..-1].each do |data_block_value| + buffer << '(' + buffer << data_block_value.map do |value| + value.nil? ? 'UNDEF' : SPARQL::Client.serialize_value(value) + end.join(' ') + buffer << ')' + end + buffer << '}' + end + if options[:graph] + buffer << '}' # GRAPH + end + + options.fetch(:minuses, []).each do |query| + buffer += query.to_s_ggp.unshift('MINUS') + end + + buffer << '}' + buffer + end + def serialize_patterns(patterns) rdf_type = RDF.type patterns.map do |pattern| @@ -523,5 +1013,16 @@ def inspect! def inspect sprintf("#<%s:%#0x(%s)>", self.class.name, __id__, to_s) end + + # Allow Filters to be + class Filter < SPARQL::Client::QueryElement + def initialize(*args) + super + end + + def to_s + "FILTER(#{elements.join(' ')})" + end + end end -end; end +end diff --git a/lib/sparql/client/repository.rb b/lib/sparql/client/repository.rb index 3fc90a34..986a29fc 100644 --- a/lib/sparql/client/repository.rb +++ b/lib/sparql/client/repository.rb @@ -1,32 +1,78 @@ -module SPARQL; class Client +class SPARQL::Client ## # A read-only repository view of a SPARQL endpoint. # - # @see RDF::Repository + # @see `RDF::Repository` class Repository < RDF::Repository # @return [SPARQL::Client] attr_reader :client ## - # @param [String, #to_s] endpoint - # @param [Hash{Symbol => Object}] options - def initialize(endpoint, options = {}) - @options = options.dup - @client = SPARQL::Client.new(endpoint, options) + # @param [URI, #to_s] uri + # Endpoint of this repository + # @param [Hash{Symbol => Object}] options passed to RDF::Repository + def initialize(uri: nil, **options, &block) + raise ArgumentError, "uri is a required parameter" unless uri + @options = options.merge(uri: uri) + @update_client = SPARQL::Client.new(options.delete(:update_endpoint), **options) if options[:update_endpoint] + @client = SPARQL::Client.new(uri, **options) + super(**@options, &block) end ## + # Returns the client for the update_endpoint if specified, otherwise the + # {#client}. + # + # @return [SPARQL::Client] + def update_client + @update_client || @client + end + # Enumerates each RDF statement in this repository. # # @yield [statement] # @yieldparam [RDF::Statement] statement - # @return [Enumerator] # @see RDF::Repository#each def each(&block) - unless block_given? - RDF::Enumerator.new(self, :each) - else - client.construct([:s, :p, :o]).where([:s, :p, :o]).each_statement(&block) + client.construct([:s, :p, :o]).where([:s, :p, :o]).each_statement(&block) + end + + ## + # Iterates the given block for each RDF statement. + # + # If no block was given, returns an enumerator. + # + # The order in which statements are yielded is undefined. + # + # @overload each_statement + # @yield [statement] + # each statement + # @yieldparam [RDF::Statement] statement + # @yieldreturn [void] ignored + # @return [void] + # + # @overload each_statement + # @return [Enumerator] + def each_statement(&block) + if block_given? + # Invoke {#each} in the containing class: + each(&block) + end + enum_statement + end + + ## + # @private + # @see RDF::Enumerable#supports? + def supports?(feature) + case feature.to_sym + # statement contexts / named graphs + when :context then false + when :graph_name then false + when :inference then false # forward-chaining inference + when :validity then false + when :literal_equality then true + else false end end @@ -68,11 +114,10 @@ def has_object?(object) # @return [Enumerator] # @see RDF::Repository#each_subject? def each_subject(&block) - unless block_given? - RDF::Enumerator.new(self, :each_subject) - else - client.select(:s, :distinct => true).where([:s, :p, :o]).each { |solution| block.call(solution[:s]) } + if block_given? + client.select(:s, distinct: true).where([:s, :p, :o]).each_solution { |solution| block.call(solution[:s]) } end + enum_subject end ## @@ -83,11 +128,10 @@ def each_subject(&block) # @return [Enumerator] # @see RDF::Repository#each_predicate? def each_predicate(&block) - unless block_given? - RDF::Enumerator.new(self, :each_predicate) - else - client.select(:p, :distinct => true).where([:s, :p, :o]).each { |solution| block.call(solution[:p]) } + if block_given? + client.select(:p, distinct: true).where([:s, :p, :o]).each_solution { |solution| block.call(solution[:p]) } end + enum_predicate end ## @@ -98,11 +142,10 @@ def each_predicate(&block) # @return [Enumerator] # @see RDF::Repository#each_object? def each_object(&block) - unless block_given? - RDF::Enumerator.new(self, :each_object) - else - client.select(:o, :distinct => true).where([:s, :p, :o]).each { |solution| block.call(solution[:o]) } + if block_given? + client.select(:o, distinct: true).where([:s, :p, :o]).each_solution { |solution| block.call(solution[:o]) } end + enum_object end ## @@ -132,13 +175,11 @@ def has_statement?(statement) # @see RDF::Repository#count? def count begin - binding = client.query("SELECT COUNT(*) WHERE { ?s ?p ?o }").first.to_hash - binding[binding.keys.first].value.to_i + binding = client.query("SELECT (COUNT(*) AS ?count) WHERE { ?s ?p ?o }").first.to_h + binding[:count].value.to_i rescue 0 rescue SPARQL::Client::MalformedQuery => e # SPARQL 1.0 does not include support for aggregate functions: - count = 0 - each_statement { count += 1 } # TODO: optimize this - count + each_statement.count end end @@ -154,19 +195,96 @@ def empty? client.ask.whether([:s, :p, :o]).false? end + ## + # Returns `false` to indicate that this is a read-only repository. + # + # @return [Boolean] + # @see RDF::Mutable#mutable? + def writable? + true + end + + ## + # @private + # @see RDF::Mutable#clear + def clear_statements + update_client.clear(:all) + end + + ## + # Deletes RDF statements from `self`. + # If any statement contains an `RDF::Query::Variable`, it is + # considered to be a pattern, and used to query + # self to find matching statements to delete. + # + # @overload delete(*statements) + # @param [Array] statements + # @raise [TypeError] if `self` is immutable + # @return [self] + # + # @overload delete(statements) + # @param [Enumerable] statements + # @raise [TypeError] if `self` is immutable + # @return [self] + # + # @see RDF::Mutable#delete + def delete(*statements) + statements.map! do |value| + if value.respond_to?(:each_statement) + delete_statements(value) + nil + else + value + end + end + statements.compact! + delete_statements(statements) unless statements.empty? + return self + end + + protected + + ## + # Queries `self` using the given basic graph pattern (BGP) query, + # yielding each matched solution to the given block. + # + # Overrides Queryable::query_execute to use SPARQL::Client::query + # + # @param [RDF::Query] query + # the query to execute + # @param [Hash{Symbol => Object}] options ({}) + # Any other options passed to `query.execute` + # @yield [solution] + # @yieldparam [RDF::Query::Solution] solution + # @yieldreturn [void] ignored + # @return [void] ignored + # @see RDF::Queryable#query + # @see RDF::Query#execute + def query_execute(query, **options, &block) + return nil unless block_given? + q = SPARQL::Client::Query. + select(query.variables, **{}). + where(*query.patterns) + client.query(q, **options).each do |solution| + yield solution + end + end + ## # Queries `self` for RDF statements matching the given `pattern`. # # @example # repository.query([nil, RDF::DOAP.developer, nil]) - # repository.query(:predicate => RDF::DOAP.developer) + # repository.query({predicate: RDF::DOAP.developer}) + # + # @todo This should use basic SPARQL query mechanism. # # @param [Pattern] pattern # @see RDF::Queryable#query_pattern # @yield [statement] # @yieldparam [Statement] # @return [Enumerable] - def query_pattern(pattern, &block) + def query_pattern(pattern, **options, &block) pattern = pattern.dup pattern.subject ||= RDF::Query::Variable.new pattern.predicate ||= RDF::Query::Variable.new @@ -182,12 +300,49 @@ def query_pattern(pattern, &block) end ## - # Returns `false` to indicate that this is a read-only repository. + # Deletes the given RDF statements from the underlying storage. # - # @return [Boolean] - # @see RDF::Mutable#mutable? - def writable? - false + # Overridden here to use SPARQL/UPDATE + # + # @param [RDF::Enumerable] statements + # @return [void] + def delete_statements(statements) + constant = statements.all? do |value| + # needs to be flattened... urgh + !value.respond_to?(:each_statement) && begin + statement = RDF::Statement.from(value) + statement.constant? && !statement.has_blank_nodes? + end + end + + if constant + update_client.delete_data(statements) + else + update_client.delete_insert(statements) + end end + + ## + # Inserts the given RDF statements into the underlying storage or output + # stream. + # + # Overridden here to use SPARQL/UPDATE + # + # @param [RDF::Enumerable] statements + # @return [void] + # @since 0.1.6 + def insert_statements(statements) + raise ArgumentError, "Some statement is incomplete" if statements.any?(&:incomplete?) + update_client.insert_data(statements) + end + + ## + # @private + # @see RDF::Mutable#insert + def insert_statement(statement) + raise ArgumentError, "Statement #{statement.inspect} is incomplete" if statement.incomplete? + update_client.insert_data([statement]) + end + end -end; end +end diff --git a/lib/sparql/client/update.rb b/lib/sparql/client/update.rb index c165f918..d105b603 100644 --- a/lib/sparql/client/update.rb +++ b/lib/sparql/client/update.rb @@ -2,40 +2,152 @@ class SPARQL::Client ## # SPARQL 1.1 Update operation builders. module Update - def self.insert_data(*arguments) - InsertData.new(*arguments) + ## + # Insert statements into the graph + # + # @example INSERT DATA \{ \"J. Random Hacker\" .\} + # data = RDF::Graph.new do |graph| + # graph << [RDF::URI('http://example.org/jhacker'), RDF::Vocab::FOAF.name, "J. Random Hacker"] + # end + # insert_data(data) + # + # @example INSERT DATA \{ GRAPH \{\}\} + # insert_data(RDF::Graph.new, graph: 'http://example.org/') + # insert_data(RDF::Graph.new).graph('http://example.org/') + # + # @param (see InsertData#initialize) + def self.insert_data(*arguments, **options) + InsertData.new(*arguments, **options) end - def self.delete_data(*arguments) - DeleteData.new(*arguments) + ## + # Delete statements from the graph + # + # @example DELETE DATA \{ \"J. Random Hacker\" .\} + # data = RDF::Graph.new do |graph| + # graph << [RDF::URI('http://example.org/jhacker'), RDF::Vocab::FOAF.name, "J. Random Hacker"] + # end + # delete_data(data) + # + # @example DELETE DATA \{ GRAPH \{\}\} + # delete_data(RDF::Graph.new, graph: 'http://example.org/') + # delete_data(RDF::Graph.new).graph('http://example.org/') + # + # @param (see DeleteData#initialize) + def self.delete_data(*arguments, **options) + DeleteData.new(*arguments, **options) end - def self.load(*arguments) - Load.new(*arguments) + ## + # Load statements into the graph + # + # @example LOAD + # load(RDF::URI(http://example.org/data.rdf)) + # + # @example LOAD SILENT + # load(RDF::URI(http://example.org/data.rdf)).silent + # load(RDF::URI(http://example.org/data.rdf), silent: true) + # + # @example LOAD INTO + # load(RDF::URI(http://example.org/data.rdf)).into(RDF::URI(http://example.org/data.rdf)) + # load(RDF::URI(http://example.org/data.rdf), into: RDF::URI(http://example.org/data.rdf)) + # + # @param (see Load#initialize) + def self.load(*arguments, **options) + Load.new(*arguments, **options) end - def self.clear(*arguments) - Clear.new(*arguments) + ## + # Clear the graph + # + # @example CLEAR GRAPH + # clear.graph(RDF::URI(http://example.org/data.rdf)) + # clear(:graph, RDF::URI(http://example.org/data.rdf)) + # + # @example CLEAR DEFAULT + # clear.default + # clear(:default) + # + # @example CLEAR NAMED + # clear.named + # clear(:named) + # + # @example CLEAR ALL + # clear.all + # clear(:all) + # + # @example CLEAR SILENT ALL + # clear.all.silent + # clear(:all, silent: true) + # + # @param (see Clear#initialize) + def self.clear(*arguments, **options) + Clear.new(*arguments, **options) end - def self.create(*arguments) - Create.new(*arguments) + ## + # Create a graph + # + # @example CREATE GRAPH + # create(RDF::URI(http://example.org/data.rdf)) + # + # @example CREATE SILENT GRAPH + # create(RDF::URI(http://example.org/data.rdf)).silent + # create(RDF::URI(http://example.org/data.rdf), silent: true) + # + # @param (see Create#initialize) + def self.create(*arguments, **options) + Create.new(*arguments, **options) end - def self.drop(*arguments) - Drop.new(*arguments) + ## + # Drop a graph + # + # @example DROP GRAPH + # drop.graph(RDF::URI(http://example.org/data.rdf)) + # drop(:graph, RDF::URI(http://example.org/data.rdf)) + # + # @example DROP DEFAULT + # drop.default + # drop(:default) + # + # @example DROP NAMED + # drop.named + # drop(:named) + # + # @example DROP ALL + # drop.all + # drop(:all) + # + # @example DROP ALL SILENT + # drop.all.silent + # drop(:all, silent: true) + # + # @param (see Drop#initialize) + def self.drop(*arguments, **options) + Drop.new(*arguments, **options) end class Operation attr_reader :options - def initialize(*arguments) - @options = arguments.last.is_a?(Hash) ? arguments.pop.dup : {} + def initialize(*arguments, **options) + @options = options.dup unless arguments.empty? send(arguments.shift, *arguments) end end + ## + # Generic Update always returns statements + # + # @return [true] + def expects_statements? + true + end + + ## + # Set `silent` option def silent self.options[:silent] = true self @@ -43,40 +155,87 @@ def silent end ## - # @see http://www.w3.org/TR/sparql11-update/#insertData + # @see https://www.w3.org/TR/sparql11-update/#insertData class InsertData < Operation + # @return [RDF::Enumerable] attr_reader :data - def initialize(data, options = {}) + ## + # Insert statements into the graph + # + # @example INSERT DATA \{ \"J. Random Hacker\" .\} + # data = RDF::Graph.new do |graph| + # graph << [RDF::URI('http://example.org/jhacker'), RDF::Vocab::FOAF.name, "J. Random Hacker"] + # end + # insert_data(data) + # + # @param [Array, RDF::Enumerable] data + # @param [Hash{Symbol => Object}] options + def initialize(data, **options) @data = data - super(options) + super(**options) end + ## + # Cause data to be inserted into the graph specified by `uri` + # + # @param [RDF::URI] uri + # @return [self] def graph(uri) self.options[:graph] = uri self end + ## + # InsertData always returns result set + # + # @return [true] + def expects_statements? + false + end + def to_s - query_text = 'INSERT DATA {' - query_text += ' GRAPH ' + SPARQL::Client.serialize_uri(self.options[:graph]) + ' {' if self.options[:graph] + graph = self.options[:graph] ? " GRAPH #{SPARQL::Client.serialize_uri(self.options[:graph])} " : "" + + # use_insert_data option is because of Virtuous, remove when https://github.com/openlink/virtuoso-opensource/issues/126 closed + add_data = self.options[:use_insert_data].nil? || self.options[:use_insert_data] ? 'DATA' : '' + + query_text = "INSERT #{add_data} {" + query_text += graph + '{' if self.options[:graph] query_text += "\n" - query_text += RDF::NTriples::Writer.buffer { |writer| writer << @data } + query_text += RDF::NTriples::Writer.buffer { |writer| @data.each { |d| writer << d } } query_text += '}' if self.options[:graph] - query_text += "}\n" + query_text + "}\n" end end ## - # @see http://www.w3.org/TR/sparql11-update/#deleteData + # @see https://www.w3.org/TR/sparql11-update/#deleteData class DeleteData < Operation + # @return [RDF::Enumerable] attr_reader :data - def initialize(data, options = {}) + ## + # Delete statements from the graph + # + # @example DELETE DATA \{ \"J. Random Hacker\" .\} + # data = RDF::Graph.new do |graph| + # graph << [RDF::URI('http://example.org/jhacker'), RDF::Vocab::FOAF.name, "J. Random Hacker"] + # end + # delete_data(data) + # + # @param [Array, RDF::Enumerable] data + # @param [Hash{Symbol => Object}] options + def initialize(data, **options) @data = data - super(options) + super(**options) end + ## + # Cause data to be deleted from the graph specified by `uri` + # + # @param [RDF::URI] uri + # @return [self] def graph(uri) self.options[:graph] = uri self @@ -86,35 +245,103 @@ def to_s query_text = 'DELETE DATA {' query_text += ' GRAPH ' + SPARQL::Client.serialize_uri(self.options[:graph]) + ' {' if self.options[:graph] query_text += "\n" - query_text += RDF::NTriples::Writer.buffer { |writer| writer << @data } + query_text += RDF::NTriples::Writer.buffer { |writer| @data.each { |d| writer << d } } query_text += '}' if self.options[:graph] query_text += "}\n" end end ## - # @see http://www.w3.org/TR/sparql11-update/#deleteInsert + # @see https://www.w3.org/TR/sparql11-update/#deleteInsert class DeleteInsert < Operation + attr_reader :insert_graph + attr_reader :delete_graph + attr_reader :where_graph + + def initialize(_delete_graph, _insert_graph = nil, _where_graph = nil, **options) + @delete_graph = _delete_graph + @insert_graph = _insert_graph + @where_graph = _where_graph + super(**options) + end + + ## + # Cause data to be deleted and inserted from the graph specified by `uri` + # + # @param [RDF::URI] uri + # @return [self] + def graph(uri) + self.options[:graph] = uri + self + end + def to_s - # TODO + buffer = [] + + if self.options[:graph] + buffer << "WITH" + buffer << SPARQL::Client.serialize_uri(self.options[:graph]) + end + if delete_graph and !delete_graph.empty? + serialized_delete = SPARQL::Client.serialize_patterns delete_graph, true + buffer << "DELETE {\n" + buffer += serialized_delete + buffer << "}\n" + end + if insert_graph and !insert_graph.empty? + buffer << "INSERT {\n" + buffer += SPARQL::Client.serialize_patterns insert_graph, true + buffer << "}\n" + end + buffer << "WHERE {\n" + if where_graph + buffer += SPARQL::Client.serialize_patterns where_graph, true + elsif serialized_delete + buffer += serialized_delete + end + buffer << "}\n" + buffer.join(' ') end + end ## - # @see http://www.w3.org/TR/sparql11-update/#load + # @see https://www.w3.org/TR/sparql11-update/#load class Load < Operation attr_reader :from attr_reader :into - def initialize(from, options = {}) - options = options.dup + + ## + # Load statements into the graph + # + # @example LOAD + # load(RDF::URI(http://example.org/data.rdf)) + # + # @example LOAD SILENT + # load(RDF::URI(http://example.org/data.rdf)).silent + # load(RDF::URI(http://example.org/data.rdf), silent: true) + # + # @example LOAD INTO + # load(RDF::URI(http://example.org/data.rdf)).into(RDF::URI(http://example.org/data.rdf)) + # load(RDF::URI(http://example.org/data.rdf), into: RDF::URI(http://example.org/data.rdf)) + # @param [RDF::URI] from + # @param [Hash{Symbol => Object}] options + # @option [RDF::URI] :into + # @option [Boolean] :silent + def initialize(from, into: nil,**options) @from = RDF::URI(from) - @into = RDF::URI(options.delete(:into)) if options[:into] - super(options) + @into = RDF::URI(into) if into + super(**options) end - def into(url) - @into = RDF::URI(url) + ## + # Cause data to be loaded into graph specified by `uri` + # + # @param [RDF::URI] uri + # @return [self] + def into(uri) + @into = RDF::URI(uri) self end @@ -128,30 +355,55 @@ def to_s end ## - # @see http://www.w3.org/TR/sparql11-update/#clear + # @see https://www.w3.org/TR/sparql11-update/#clear class Clear < Operation attr_reader :uri + ## + # Cause data to be cleared from graph specified by `uri` + # + # @param [RDF::URI] uri + # @return [self] def graph(uri) @what, @uri = :graph, uri self end + ## + # Cause data to be cleared from the default graph + # + # @return [self] def default @what = :default self end + ## + # Cause data to be cleared from named graphs + # + # @return [self] def named @what = :named self end + ## + # Cause data to be cleared from all graphs + # + # @return [self] def all @what = :all self end + ## + # Clear always returns statements + # + # @return [false] + def expects_statements? + false + end + def to_s query_text = 'CLEAR ' query_text += 'SILENT ' if self.options[:silent] @@ -167,13 +419,14 @@ def to_s end ## - # @see http://www.w3.org/TR/sparql11-update/#create + # @see https://www.w3.org/TR/sparql11-update/#create class Create < Operation attr_reader :uri - def initialize(uri, options = {}) + # @param [Hash{Symbol => Object}] options + def initialize(uri, **options) @uri = RDF::URI(uri) - super(options) + super(**options) end def to_s @@ -185,7 +438,7 @@ def to_s end ## - # @see http://www.w3.org/TR/sparql11-update/#drop + # @see https://www.w3.org/TR/sparql11-update/#drop class Drop < Clear def to_s query_text = 'DROP ' @@ -202,7 +455,7 @@ def to_s end ## - # @see http://www.w3.org/TR/sparql11-update/#copy + # @see https://www.w3.org/TR/sparql11-update/#copy class Copy < Operation def to_s # TODO @@ -210,7 +463,7 @@ def to_s end ## - # @see http://www.w3.org/TR/sparql11-update/#move + # @see https://www.w3.org/TR/sparql11-update/#move class Move < Operation def to_s # TODO @@ -218,7 +471,7 @@ def to_s end ## - # @see http://www.w3.org/TR/sparql11-update/#add + # @see https://www.w3.org/TR/sparql11-update/#add class Add < Operation def to_s # TODO diff --git a/lib/sparql/client/version.rb b/lib/sparql/client/version.rb index 4f877bf6..3d16256c 100644 --- a/lib/sparql/client/version.rb +++ b/lib/sparql/client/version.rb @@ -1,4 +1,4 @@ -module SPARQL; class Client +class SPARQL::Client module VERSION FILE = File.expand_path('../../../../VERSION', __FILE__) MAJOR, MINOR, TINY, EXTRA = File.read(FILE).chomp.split('.') @@ -16,4 +16,4 @@ def self.to_str() STRING end # @return [Array(Integer, Integer, Integer)] def self.to_a() [MAJOR, MINOR, TINY] end end -end; end +end diff --git a/sparql-client.gemspec b/sparql-client.gemspec deleted file mode 120000 index f4a047df..00000000 --- a/sparql-client.gemspec +++ /dev/null @@ -1 +0,0 @@ -.gemspec \ No newline at end of file diff --git a/sparql-client.gemspec b/sparql-client.gemspec new file mode 100755 index 00000000..6c0a0207 --- /dev/null +++ b/sparql-client.gemspec @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby -rubygems +# -*- encoding: utf-8 -*- + +Gem::Specification.new do |gem| + gem.version = '3.2.2' + gem.name = 'sparql-client' + gem.homepage = 'https://github.com/ruby-rdf/sparql-client' + gem.license = 'Unlicense' + gem.summary = 'SPARQL client for RDF.rb.' + gem.description = %(Executes SPARQL queries and updates against a remote SPARQL 1.0 or 1.1 endpoint, + or against a local repository. Generates SPARQL queries using a simple DSL. + Includes SPARQL::Client::Repository, which allows any endpoint supporting + SPARQL Update to be used as an RDF.rb repository.) + gem.metadata = { + "documentation_uri" => "https://ruby-rdf.github.io/sparql-client", + "bug_tracker_uri" => "https://github.com/ruby-rdf/sparql-client/issues", + "homepage_uri" => "https://github.com/ruby-rdf/sparql-client", + "mailing_list_uri" => "https://lists.w3.org/Archives/Public/public-rdf-ruby/", + "source_code_uri" => "https://github.com/ruby-rdf/sparql-client", + } + + gem.authors = ['Arto Bendiken', 'Ben Lavender', 'Gregg Kellogg'] + gem.email = 'public-rdf-ruby@w3.org' + + gem.platform = Gem::Platform::RUBY + gem.files = %w(AUTHORS CREDITS README.md UNLICENSE VERSION) + Dir.glob('lib/**/*.rb') + gem.bindir = %q(bin) + gem.require_paths = %w(lib) + + gem.required_ruby_version = '>= 2.6' + gem.requirements = [] + gem.add_runtime_dependency 'rdf', '~> 3.2', '>= 3.2.11' + gem.add_runtime_dependency 'net-http-persistent', '~> 4.0', '>= 4.0.2' + gem.add_development_dependency 'rdf-spec', '~> 3.2' + gem.add_development_dependency 'sparql', '~> 3.2' + gem.add_development_dependency 'rspec', '~> 3.12' + gem.add_development_dependency 'rspec-its', '~> 1.3' + gem.add_development_dependency 'webmock', '~> 3.14' + gem.add_development_dependency 'yard' , '~> 0.9' + + gem.post_install_message = nil +end diff --git a/spec/client_spec.rb b/spec/client_spec.rb index 21a95783..7a2aead7 100644 --- a/spec/client_spec.rb +++ b/spec/client_spec.rb @@ -1,103 +1,524 @@ # -*- coding: utf-8 -*- -require File.join(File.dirname(__FILE__), 'spec_helper') +require_relative 'spec_helper' +require 'webmock/rspec' +require 'json' +require 'rdf/turtle' +require 'rexml/document' describe SPARQL::Client do let(:query) {'DESCRIBE ?kb WHERE { ?kb "Kevin Bacon" . }'} + let(:construct_query) {'CONSTRUCT {?kb "Kevin Bacon" . } WHERE { ?kb "Kevin Bacon" . }'} + let(:select_query) {'SELECT ?kb WHERE { ?kb "Kevin Bacon" . }'} + let(:describe_query) {'DESCRIBE ?kb WHERE { ?kb "Kevin Bacon" . }'} + let(:ask_query) {'ASK WHERE { ?kb "Kevin Bacon" . }'} + let(:update_query) {'DELETE {?s ?p ?o} WHERE {}'} + + describe "#initialize" do + it "calls block" do + expect {|b| described_class.new('http://data.linkedmdb.org/sparql', &b)}.to yield_control + described_class.new('http://data.linkedmdb.org/sparql') do |sparql| + expect(sparql).to be_a(SPARQL::Client) + end + end + end + context "when querying a remote endpoint" do subject {SPARQL::Client.new('http://data.linkedmdb.org/sparql')} def response(header) response = Net::HTTPSuccess.new '1.1', 200, 'body' - response.content_type = header - response.stub(:body).and_return('body') + response.content_type = header if header + allow(response).to receive(:body).and_return('body') response end - it "should handle successful response with plain header" do - subject.should_receive(:request).and_yield response('text/plain') - RDF::Reader.should_receive(:for).with(:content_type => 'text/plain') + describe "#ask" do + specify do + expect(subject.ask.where([:s, :p, :o]).to_s).to eq "ASK WHERE { ?s ?p ?o . }" + end + end + + describe "#select" do + specify do + expect(subject.select.where([:s, :p, :o]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . }" + end + end + + describe "#describe" do + specify do + expect(subject.describe.where([:s, :p, :o]).to_s).to eq "DESCRIBE * WHERE { ?s ?p ?o . }" + end + end + + describe "#construct" do + specify do + expect(subject.construct([:s, :p, :o]).where([:s, :p, :o]).to_s).to eq "CONSTRUCT { ?s ?p ?o . } WHERE { ?s ?p ?o . }" + end + end + + it "handles successful response with plain header" do + expect(subject).to receive(:request).and_yield response('text/plain') + expect(RDF::Reader).to receive(:for).with(content_type: 'text/plain').and_call_original subject.query(query) end - it "should handle successful response with boolean header" do - subject.should_receive(:request).and_yield response(SPARQL::Client::RESULT_BOOL) - subject.query(query).should == false + it "handles successful response with boolean header" do + expect(subject).to receive(:request).and_yield response(SPARQL::Client::RESULT_BOOL) + expect(subject.query(query)).to be_falsey end - it "should handle successful response with JSON header" do - subject.should_receive(:request).and_yield response(SPARQL::Client::RESULT_JSON) - subject.class.should_receive(:parse_json_bindings) + it "handles successful response with JSON header" do + expect(subject).to receive(:request).and_yield response(SPARQL::Client::RESULT_JSON) + expect(subject.class).to receive(:parse_json_bindings) subject.query(query) end - it "should handle successful response with XML header" do - subject.should_receive(:request).and_yield response(SPARQL::Client::RESULT_XML) - subject.class.should_receive(:parse_xml_bindings) + it "handles successful response with XML header" do + expect(subject).to receive(:request).and_yield response(SPARQL::Client::RESULT_XML) + expect(subject.class).to receive(:parse_xml_bindings) subject.query(query) end - it "should handle successful response with CSV header" do - subject.should_receive(:request).and_yield response(SPARQL::Client::RESULT_CSV) - subject.class.should_receive(:parse_csv_bindings) + it "handles successful response with CSV header" do + expect(subject).to receive(:request).and_yield response(SPARQL::Client::RESULT_CSV) + expect(subject.class).to receive(:parse_csv_bindings) subject.query(query) end - it "should handle successful response with TSV header" do - subject.should_receive(:request).and_yield response(SPARQL::Client::RESULT_TSV) - subject.class.should_receive(:parse_tsv_bindings) + it "handles successful response with TSV header" do + expect(subject).to receive(:request).and_yield response(SPARQL::Client::RESULT_TSV) + expect(subject.class).to receive(:parse_tsv_bindings) subject.query(query) end - it "should handle successful response with overridden XML header" do - subject.should_receive(:request).and_yield response(SPARQL::Client::RESULT_XML) - subject.class.should_receive(:parse_json_bindings) - subject.query(query, :content_type => SPARQL::Client::RESULT_JSON) + it "handles successful response with overridden XML header" do + expect(subject).to receive(:request).and_yield response(SPARQL::Client::RESULT_XML) + expect(subject.class).to receive(:parse_json_bindings) + subject.query(query, content_type: SPARQL::Client::RESULT_JSON) end - it "should handle successful response with overridden JSON header" do - subject.should_receive(:request).and_yield response(SPARQL::Client::RESULT_JSON) - subject.class.should_receive(:parse_xml_bindings) - subject.query(query, :content_type => SPARQL::Client::RESULT_XML) + it "handles successful response with no content type" do + expect(subject).to receive(:request).and_yield response(nil) + expect { subject.query(query) }.not_to raise_error end - it "should handle successful response with overridden plain header" do - subject.should_receive(:request).and_yield response('text/plain') - RDF::Reader.should_receive(:for).with(:content_type => 'text/turtle') - subject.query(query, :content_type => 'text/turtle') + it "handles successful response with overridden plain header" do + expect(subject).to receive(:request).and_yield response('text/plain') + expect(RDF::Reader).to receive(:for).with(content_type: 'text/turtle').and_call_original + subject.query(query, content_type: 'text/turtle') end - it "should handle successful response with custom headers" do - subject.should_receive(:request).with(anything, "Authorization" => "Basic XXX=="). + it "handles successful response with custom headers" do + expect(subject).to receive(:request).with(anything, {"Authorization" => "Basic XXX=="}). and_yield response('text/plain') - subject.query(query, :headers => {"Authorization" => "Basic XXX=="}) + subject.query(query, headers: {"Authorization" => "Basic XXX=="}) end - it "should handle successful response with initial custom headers" do - options = {:headers => {"Authorization" => "Basic XXX=="}, :method => :get} - client = SPARQL::Client.new('http://data.linkedmdb.org/sparql', options) - client.instance_variable_set :@http, double(:request => response('text/plain')) - Net::HTTP::Get.should_receive(:new).with(anything, hash_including(options[:headers])) + it "handles successful response with initial custom headers" do + options = {headers: {"Authorization" => "Basic XXX=="}, method: :get} + client = SPARQL::Client.new('http://data.linkedmdb.org/sparql', **options) + client.instance_variable_set :@http, double(request: response('text/plain')) + expect(Net::HTTP::Get).to receive(:new).with(anything, hash_including(options[:headers])) client.query(query) end - it "should support international characters in response body" do + it "enables overriding the http method" do + stub_request(:get, "http://data.linkedmdb.org/sparql?query=DESCRIBE%20?kb%20WHERE%20%7B%20?kb%20%3Chttp://data.linkedmdb.org/resource/movie/actor_name%3E%20%22Kevin%20Bacon%22%20.%20%7D"). + to_return(status: 200, body: "", headers: { 'Content-Type' => 'application/n-triples'}) + allow(subject).to receive(:request_method).with(query).and_return(:get) + expect(subject).to receive(:make_get_request).and_call_original + subject.query(query) + end + + it "supports international characters in response body" do client = SPARQL::Client.new('http://dbpedia.org/sparql') + json = { + results: { + bindings: [ + name: {type: :literal, "xml:lang" => "jp", value: "東京"} + ], + } + }.to_json + WebMock.stub_request(:any, 'http://dbpedia.org/sparql'). + to_return(body: json, status: 200, headers: { 'Content-Type' => SPARQL::Client::RESULT_JSON}) query = "SELECT ?name WHERE { ?name }" - result = client.query(query, :content_type => SPARQL::Client::RESULT_JSON).first - result[:name].to_s.should == "東京" - result = client.query(query, :content_type => SPARQL::Client::RESULT_XML).first - result[:name].to_s.should == "東京" + result = client.query(query, content_type: SPARQL::Client::RESULT_JSON).first + expect(result[:name].to_s).to eq "東京" + end + + it "handles successful response with default graph specified" do + WebMock.stub_request(:post, 'https://dbpedia.org/sparql?default-graph-uri=https://example.org/'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + client = SPARQL::Client.new('https://dbpedia.org/sparql', graph: "https://example.org/") + client.query(query) + expect(WebMock).to have_requested(:post, "https://dbpedia.org/sparql?default-graph-uri=https://example.org/") + end + + it "generates IOError when querying closed client" do + subject.close + expect{ subject.query(ask_query) }.to raise_error IOError + end + + context "Redirects" do + before do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '{}', status: 303, headers: { 'Location' => 'http://sparql.linkedmdb.org/sparql' }) + end + + it 'follows redirects' do + WebMock.stub_request(:any, 'http://sparql.linkedmdb.org/sparql'). + to_return(body: '{}', status: 200, headers: { content_type: SPARQL::Client::RESULT_JSON}) + subject.query(ask_query) + expect(WebMock).to have_requested(:post, "http://sparql.linkedmdb.org/sparql"). + with(body: 'query=ASK+WHERE+%7B+%3Fkb+%3Chttp%3A%2F%2Fdata.linkedmdb.org%2Fresource%2Fmovie%2Factor_name%3E+%22Kevin+Bacon%22+.+%7D') + end + + it 'raises an error on infinate redirects' do + WebMock.stub_request(:any, 'http://sparql.linkedmdb.org/sparql'). + to_return(body: '{}', status: 303, headers: { 'Location' => 'http://sparql.linkedmdb.org/sparql' }) + expect{ subject.query(ask_query) }.to raise_error SPARQL::Client::ServerError + end + end + + context "Accept Header" do + it "uses application/sparql-results+json for ASK" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + subject.query(ask_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql"). + with(headers: {'Accept'=>'application/sparql-results+json, application/sparql-results+xml, text/boolean, text/tab-separated-values;q=0.8, text/csv;q=0.2, */*;q=0.1'}) + end + + it "uses application/n-triples for CONSTRUCT" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '', status: 200, headers: { 'Content-Type' => 'application/n-triples'}) + subject.query(construct_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql"). + with(headers: {'Accept'=>'application/n-triples, text/plain, */*;q=0.1'}) + end + + it "uses application/n-triples for DESCRIBE" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '', status: 200, headers: { 'Content-Type' => 'application/n-triples'}) + subject.query(describe_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql"). + with(headers: {'Accept'=>'application/n-triples, text/plain, */*;q=0.1'}) + end + + it "uses application/sparql-results+json for SELECT" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + subject.query(select_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql"). + with(headers: {'Accept'=>'application/sparql-results+json, application/sparql-results+xml, text/boolean, text/tab-separated-values;q=0.8, text/csv;q=0.2, */*;q=0.1'}) + end + end + + context 'User-Agent header' do + it "uses default if not specified" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + subject.query(select_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql"). + with(headers: {'User-Agent' => "Ruby SPARQL::Client/#{SPARQL::Client::VERSION}"}) + end + + it "uses user-provided value in query" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + subject.query(select_query, headers: {'User-Agent' => 'Foo'}) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql"). + with(headers: {'User-Agent' => "Foo"}) + end + + it "uses user-provided value in initialization" do + client = SPARQL::Client.new('http://data.linkedmdb.org/sparql', headers: {'User-Agent' => 'Foo'}) + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + client.query(select_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql"). + with(headers: {'User-Agent' => "Foo"}) + end + end + + context "Alternative Endpoint" do + it "uses the default endpoint if no alternative endpoint is provided" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '', status: 200) + subject.update(update_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql") + end + + it "uses the alternative endpoint if provided" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/alternative'). + to_return(body: '', status: 200) + subject.update(update_query, endpoint: "http://data.linkedmdb.org/alternative") + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/alternative") + end + + it "does not use the alternative endpoint for a select query" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: '', status: 200) + WebMock.stub_request(:any, 'http://data.linkedmdb.org/alternative'). + to_return(body: '', status: 200) + subject.update(update_query, endpoint: "http://data.linkedmdb.org/alternative") + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/alternative") + subject.query(select_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql") + end + end + + context "with multiple Graphs" do + let(:get_graph_client){ SPARQL::Client.new('http://data.linkedmdb.org/sparql', method: 'get', graph: 'http://data.linkedmdb.org/graph1') } + let(:post_graph_client10){ SPARQL::Client.new('http://data.linkedmdb.org/sparql', method: 'post', graph: 'http://data.linkedmdb.org/graph1', protocol: '1.0') } + let(:post_graph_client11){ SPARQL::Client.new('http://data.linkedmdb.org/sparql', method: 'post', graph: 'http://data.linkedmdb.org/graph1', protocol: '1.1') } + + it "should create 'query via GET' requests" do + WebMock.stub_request(:get, 'http://data.linkedmdb.org/sparql?default-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1&query=SELECT%20%3Fkb%20WHERE%20%7B%20%3Fkb%20%3Chttp%3A%2F%2Fdata.linkedmdb.org%2Fresource%2Fmovie%2Factor_name%3E%20%22Kevin%20Bacon%22%20.%20%7D'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + get_graph_client.query(select_query) + expect(WebMock).to have_requested(:get, "http://data.linkedmdb.org/sparql?default-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1&query=SELECT%20%3Fkb%20WHERE%20%7B%20%3Fkb%20%3Chttp%3A%2F%2Fdata.linkedmdb.org%2Fresource%2Fmovie%2Factor_name%3E%20%22Kevin%20Bacon%22%20.%20%7D") + end + + it "should create 'query via URL-encoded Post' requests" do + WebMock.stub_request(:post, 'http://data.linkedmdb.org/sparql?default-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + post_graph_client10.query(select_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql?default-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1"). + with(body: "query=SELECT+%3Fkb+WHERE+%7B+%3Fkb+%3Chttp%3A%2F%2Fdata.linkedmdb.org%2Fresource%2Fmovie%2Factor_name%3E+%22Kevin+Bacon%22+.+%7D&default-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1") + end + + it "should create 'query via Post directly' requests" do + WebMock.stub_request(:post, 'http://data.linkedmdb.org/sparql?default-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1'). + to_return(body: '{}', status: 200, headers: { 'Content-Type' => 'application/sparql-results+json'}) + post_graph_client11.query(select_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql?default-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1"). + with(body: select_query) + end + + it "should create requests for 'update via URL-encoded POST'" do + WebMock.stub_request(:post, 'http://data.linkedmdb.org/sparql?using-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1'). + to_return(body: '{}', status: 200) + post_graph_client10.update(update_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql?using-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1"). + with(body: "update=DELETE+%7B%3Fs+%3Fp+%3Fo%7D+WHERE+%7B%7D&using-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1") + end + + it "should create requests for 'update via POST directly'" do + WebMock.stub_request(:post, 'http://data.linkedmdb.org/sparql?using-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1'). + to_return(body: '{}', status: 200) + post_graph_client11.update(update_query) + expect(WebMock).to have_requested(:post, "http://data.linkedmdb.org/sparql?using-graph-uri=http%3A%2F%2Fdata.linkedmdb.org%2Fgraph1"). + with(body: update_query) + end + end + + context "Error response" do + { + "bad request" => {status: 400, error: SPARQL::Client::MalformedQuery }, + "unauthorized" => {status: 401, error: SPARQL::Client::ClientError }, + "not found" => {status: 404, error: SPARQL::Client::ClientError }, + "internal server error" => {status: 500, error: SPARQL::Client::ServerError }, + "not implemented" => {status: 501, error: SPARQL::Client::ServerError }, + "service unavailable" => {status: 503, error: SPARQL::Client::ServerError }, + }.each do |test, params| + it "detects #{test}" do + WebMock.stub_request(:any, 'http://data.linkedmdb.org/sparql'). + to_return(body: 'the body', status: params[:status], headers: {'Content-Type' => 'text/plain'}) + expect { + subject.query(select_query) + }.to raise_error(params[:error], "the body Processing query #{select_query}") + end + end end end - context "when querying an RDF::Repository", :pending => ("not supported in Ruby < 1.9" if RUBY_VERSION < "1.9") do + context "when querying an RDF::Repository" do + before(:all) {require 'sparql'} let(:repo) {RDF::Repository.new} + let(:graph) {RDF::Graph.new << RDF::Statement(RDF::URI('http://example/s'), RDF::URI('http://example/p'), "o")} subject {SPARQL::Client.new(repo)} - it "should query repository" do - require 'sparql' # Can't do this lazily and get double to work - SPARQL.should_receive(:execute).with(query, repo, {}) + it "queries repository" do + expect(SPARQL).to receive(:execute).with(query, repo, any_args) subject.query(query) end + + describe "#insert_data" do + specify do + subject.insert_data(graph) + expect(repo.count).to eq 1 + end + end + + describe "#delete_data" do + specify do + subject.delete_data(graph) + expect(repo.count).to eq 0 + end + end + + describe "#delete_insert" do + specify do + expect {subject.delete_insert(graph, graph)}.not_to raise_error + end + end + + describe "#clear_graph" do + specify do + expect {subject.clear_graph('http://example/')}.to raise_error(IOError) + end + end + + describe "#clear" do + specify do + subject.clear(:all) + expect(repo.count).to eq 0 + end + end + + describe "#query" do + it "raises error on malformed query" do + expect do + expect {subject.query("Invalid SPARQL")}.to raise_error(SPARQL::MalformedQuery) + end.to write("ERROR").to(:error) + end + end + end + + context "when parsing XML" do + %i(nokogiri rexml).each do |library| + context "using #{library}" do + it "parses binding results correctly" do + xml = File.read("spec/fixtures/results.xml") + nodes = {} + solutions = SPARQL::Client::parse_xml_bindings(xml, nodes, library: library) + expected = RDF::Query::Solutions.new([ + RDF::Query::Solution.new( + x: RDF::Node.new("r2"), + hpage: RDF::URI.new("http://work.example.org/bob/"), + name: RDF::Literal.new("Bob", language: "en"), + age: RDF::Literal.new("30", datatype: "http://www.w3.org/2001/XMLSchema#integer"), + mbox: RDF::URI.new("mailto:bob@work.example.org"), + triple: RDF::Statement( + RDF::URI('http://work.example.org/s'), + RDF::URI('http://work.example.org/p'), + RDF::URI('http://work.example.org/o')), + ) + ]) + expect(solutions.variable_names).to eq expected.variable_names + expect(solutions).to eq expected + expect(solutions[0]["x"]).to eq nodes["r2"] + expect(solutions.variable_names).to eq %i(x hpage name age mbox triple) + end + + it "parses results missing variables" do + xml = File.read("spec/fixtures/results2.xml") + nodes = {} + solutions = SPARQL::Client::parse_xml_bindings(xml, nodes, library: library) + expected = RDF::Query::Solutions.new([ + RDF::Query::Solution.new(v: RDF::Literal(1)) + ]) + expected.variable_names = %i(v w) + expect(solutions.variable_names).to eq %i(v w) + expect(solutions).to eq expected + end + + it "parses boolean true results correctly" do + xml = File.read("spec/fixtures/bool_true.xml") + expect(SPARQL::Client::parse_xml_bindings(xml, library: library)).to eq true + end + + it "parses boolean false results correctly" do + xml = File.read("spec/fixtures/bool_false.xml") + expect(SPARQL::Client::parse_xml_bindings(xml, library: library)).to eq false + end + end + end + end + + context "when parsing JSON" do + it "parses binding results correctly" do + json = File.read("spec/fixtures/results.json") + nodes = {} + solutions = SPARQL::Client::parse_json_bindings(json, nodes) + expect(solutions).to eq RDF::Query::Solutions.new([ + RDF::Query::Solution.new( + x: RDF::Node.new("r2"), + hpage: RDF::URI.new("http://work.example.org/bob/"), + name: RDF::Literal.new("Bob", language: "en"), + age: RDF::Literal.new("30", datatype: "http://www.w3.org/2001/XMLSchema#integer"), + mbox: RDF::URI.new("mailto:bob@work.example.org"), + triple: RDF::Statement( + RDF::URI('http://work.example.org/s'), + RDF::URI('http://work.example.org/p'), + RDF::URI('http://work.example.org/o')), + ) + ]) + expect(solutions[0]["x"]).to eq nodes["r2"] + expect(solutions.variable_names).to eq %i(x hpage name age mbox triple) + end + + it "parses boolean true results correctly" do + json = '{"boolean": true}' + expect(SPARQL::Client::parse_json_bindings(json)).to eq true + end + + it "parses boolean true results correctly" do + json = '{"boolean": false}' + expect(SPARQL::Client::parse_json_bindings(json)).to eq false + end + end + + context "when parsing CSV" do + it "parses binding results correctly" do + csv = File.read("spec/fixtures/results.csv") + nodes = {} + solutions = SPARQL::Client::parse_csv_bindings(csv, nodes) + expect(solutions).to eq RDF::Query::Solutions.new([ + RDF::Query::Solution.new(x: RDF::URI("http://example/x"), literal: RDF::Literal('String')), + RDF::Query::Solution.new(x: RDF::URI("http://example/x"), + literal: RDF::Literal('String-with-dquote"')), + RDF::Query::Solution.new(x: RDF::Node.new("b0"), literal: RDF::Literal("Blank node")), + RDF::Query::Solution.new(x: RDF::Literal(""), literal: RDF::Literal("Missing 'x'")), + RDF::Query::Solution.new(x: RDF::Literal(""), literal: RDF::Literal("")), + RDF::Query::Solution.new(x: RDF::URI("http://example/x"), literal: RDF::Literal('')), + RDF::Query::Solution.new(x: RDF::Node.new("b1"), literal: RDF::Literal("String-with-lang")), + RDF::Query::Solution.new(x: RDF::Node.new("b2"), literal: RDF::Literal("123")), + ]) + expect(solutions[2]["x"]).to eq nodes["b0"] + expect(solutions[6]["x"]).to eq nodes["b1"] + expect(solutions[7]["x"]).to eq nodes["b2"] + expect(solutions.variable_names).to eq %i(x literal) + end + end + + context "when parsing TSV" do + it "parses binding results correctly" do + tsv = File.read("spec/fixtures/results.tsv") + nodes = {} + solutions = SPARQL::Client::parse_tsv_bindings(tsv, nodes) + expect(solutions).to eq RDF::Query::Solutions.new([ + RDF::Query::Solution.new(x: RDF::URI("http://example/x"), literal: RDF::Literal('String')), + RDF::Query::Solution.new(x: RDF::URI("http://example/x"), + literal: RDF::Literal('String-with-dquote"')), + RDF::Query::Solution.new(x: RDF::Node.new("b0"), literal: RDF::Literal("Blank node")), + RDF::Query::Solution.new(x: RDF::Literal(""), literal: RDF::Literal("Missing 'x'")), + RDF::Query::Solution.new(x: RDF::Literal(""), literal: RDF::Literal("")), + RDF::Query::Solution.new(x: RDF::URI("http://example/x"), literal: RDF::Literal('')), + RDF::Query::Solution.new(x: RDF::Node.new("b1"), literal: RDF::Literal("String-with-lang", language: :en)), + RDF::Query::Solution.new(x: RDF::Node.new("b2"), literal: RDF::Literal(123)), + RDF::Query::Solution.new(x: RDF::Node.new("b3"), literal: RDF::Literal::Decimal.new(123.0)), + RDF::Query::Solution.new(x: RDF::Node.new("b4"), literal: RDF::Literal(123.0e1)), + RDF::Query::Solution.new(x: RDF::Node.new("b5"), literal: RDF::Literal(0.1e1)), + ]) + expect(solutions[2]["x"]).to eq nodes["b0"] + expect(solutions[6]["x"]).to eq nodes["b1"] + expect(solutions[7]["x"]).to eq nodes["b2"] + expect(solutions[8]["x"]).to eq nodes["b3"] + expect(solutions[9]["x"]).to eq nodes["b4"] + expect(solutions[10]["x"]).to eq nodes["b5"] + expect(solutions.variable_names).to eq %i(x literal) + end end end diff --git a/spec/fixtures/bool_false.xml b/spec/fixtures/bool_false.xml new file mode 100644 index 00000000..2ba5bc5f --- /dev/null +++ b/spec/fixtures/bool_false.xml @@ -0,0 +1,6 @@ + + + + + false + diff --git a/spec/fixtures/bool_true.xml b/spec/fixtures/bool_true.xml new file mode 100644 index 00000000..712b9165 --- /dev/null +++ b/spec/fixtures/bool_true.xml @@ -0,0 +1,6 @@ + + + + + true + diff --git a/spec/fixtures/results.csv b/spec/fixtures/results.csv new file mode 100644 index 00000000..b755cefc --- /dev/null +++ b/spec/fixtures/results.csv @@ -0,0 +1,9 @@ +x,literal +http://example/x,String +http://example/x,"String-with-dquote""" +_:b0,Blank node +,Missing 'x' +, +http://example/x, +_:b1,String-with-lang +_:b2,123 diff --git a/spec/fixtures/results.json b/spec/fixtures/results.json new file mode 100644 index 00000000..f97182f5 --- /dev/null +++ b/spec/fixtures/results.json @@ -0,0 +1,60 @@ +{ + "head": { + "link": [ + "http://www.w3.org/TR/rdf-sparql-XMLres/example.rq" + ], + "vars": [ + "x", + "hpage", + "name", + "age", + "mbox", + "triple" + ] + }, + "results": { + "bindings": [ + { + "x": { + "type": "bnode", + "value": "r2" + }, + "hpage": { + "type": "uri", + "value": "http://work.example.org/bob/" + }, + "name": { + "type": "literal", + "value": "Bob", + "xml:lang": "en" + }, + "age": { + "type": "typed-literal", + "value": "30", + "datatype": "http://www.w3.org/2001/XMLSchema#integer" + }, + "mbox": { + "type": "uri", + "value": "mailto:bob@work.example.org" + }, + "triple": { + "type": "triple", + "value": { + "subject": { + "type": "uri", + "value": "http://work.example.org/s" + }, + "predicate": { + "type": "uri", + "value": "http://work.example.org/p" + }, + "object": { + "type": "uri", + "value": "http://work.example.org/o" + } + } + } + } + ] + } +} diff --git a/spec/fixtures/results.tsv b/spec/fixtures/results.tsv new file mode 100644 index 00000000..381ac02f --- /dev/null +++ b/spec/fixtures/results.tsv @@ -0,0 +1,12 @@ +x literal + String + "String-with-dquote\"" +_:b0 Blank node + Missing 'x' + + +_:b1 "String-with-lang"@en +_:b2 123 +_:b3 123.0 +_:b4 123.0e1 +_:b5 .1e1 \ No newline at end of file diff --git a/spec/fixtures/results.xml b/spec/fixtures/results.xml new file mode 100644 index 00000000..47c773a2 --- /dev/null +++ b/spec/fixtures/results.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + r2 + + + http://work.example.org/bob/ + + + Bob + + + 30 + + + mailto:bob@work.example.org + + + + + http://work.example.org/s + http://work.example.org/p + http://work.example.org/o + + + + + + diff --git a/spec/fixtures/results2.xml b/spec/fixtures/results2.xml new file mode 100644 index 00000000..5b948dbf --- /dev/null +++ b/spec/fixtures/results2.xml @@ -0,0 +1,17 @@ + + + + + + + + + + 1 + + + + diff --git a/spec/fixtures/triples.ru b/spec/fixtures/triples.ru new file mode 100644 index 00000000..c60cc8fe --- /dev/null +++ b/spec/fixtures/triples.ru @@ -0,0 +1,88 @@ +PREFIX dc: +PREFIX doap: +PREFIX foaf: +PREFIX rdfs: + +CLEAR ALL; +INSERT DATA { + a doap:Project; + doap:name "RDF.rb"; + doap:homepage ; + doap:license ; + doap:shortdesc "A Ruby library for working with Resource Description Framework (RDF) data."@en; + doap:description "RDF.rb is a pure-Ruby library for working with Resource Description Framework (RDF) data."@en; + doap:created "2007-10-23"; + doap:platform "Ruby"; + doap:category , + ; + doap:implements , + , + ; + doap:download-page ; + doap:bug-database ; + doap:blog ; + foaf:maker ; + dc:creator ; + doap:developer , + , + ; + doap:maintainer , + , + ; + doap:documenter , + , + ; + doap:helper [ + a foaf:Person; foaf:name "Călin Ardelean"; foaf:mbox_sha1sum "274bd18402fc773ffc0606996aa1fb90b603aa29" + ], [ + a foaf:Person; foaf:name "Danny Gagne"; foaf:mbox_sha1sum "6de43e9cf7de53427fea9765706703e4d957c17b" + ], [ + a foaf:Person; foaf:name "Joey Geiger"; foaf:mbox_sha1sum "f412d743150d7b27b8468d56e69ca147917ea6fc" + ], [ + a foaf:Person; foaf:name "Fumihiro Kato"; foaf:mbox_sha1sum "d31fdd6af7a279a89bf09fdc9f7c44d9d08bb930" + ], [ + a foaf:Person; foaf:name "Naoki Kawamukai"; foaf:mbox_sha1sum "5bdcd8e2af4f5952aaeeffbdd371c41525ec761d" + ], [ + a foaf:Person; foaf:name "Hellekin O. Wolf"; foaf:mbox_sha1sum "c69f3255ff0639543cc5edfd8116eac8df16fab8" + ], [ + a foaf:Person; foaf:name "John Fieber"; foaf:mbox_sha1sum "f7653fc1ac0e82ebb32f092389bd5fc728eaae12" + ], [ + a foaf:Person; foaf:name "Keita Urashima"; foaf:mbox_sha1sum "2b4247b6fd5bb4a1383378f325784318680d5ff9" + ], [ + a foaf:Person; foaf:name "Pius Uzamere"; foaf:mbox_sha1sum "bedbbf2451e5beb38d59687c0460032aff92cd3c" + ] . + + a foaf:Person; + foaf:name "Arto Bendiken"; + foaf:mbox ; + foaf:mbox_sha1sum "a033f652c84a4d73b8c26d318c2395699dd2bdfb", + "d0737cceb55eb7d740578d2db1bc0727e3ed49ce"; + foaf:homepage ; + foaf:made ; + rdfs:isDefinedBy . + + a foaf:Person; + foaf:name "Ben Lavender"; + foaf:mbox ; + foaf:mbox_sha1sum "dbf45f4ffbd27b67aa84f02a6a31c144727d10af"; + foaf:homepage ; + rdfs:isDefinedBy . + + a foaf:Person; + foaf:name "Gregg Kellogg"; + foaf:mbox ; + foaf:mbox_sha1sum "35bc44e6d0070e5ad50ccbe0d24403c96af2b9bd"; + foaf:homepage ; + rdfs:isDefinedBy . + + "1"^^ . + "1"^^ . + "01"^^ . + "1.0e0"^^ . + "1.0"^^ . + "1"^^ . + "zzz"^^ . + "zzz" . + "1" . + . +} diff --git a/spec/query_spec.rb b/spec/query_spec.rb index dcccd257..9497c81a 100644 --- a/spec/query_spec.rb +++ b/spec/query_spec.rb @@ -1,146 +1,387 @@ -require File.join(File.dirname(__FILE__), 'spec_helper') +require_relative 'spec_helper' describe SPARQL::Client::Query do - before :each do - @query = SPARQL::Client::Query - end + subject {SPARQL::Client::Query} context "when building queries" do - it "should support ASK queries" do - @query.should respond_to(:ask) + it "supports ASK queries" do + expect(subject).to respond_to(:ask) end - it "should support SELECT queries" do - @query.should respond_to(:select) + it "supports SELECT queries" do + expect(subject).to respond_to(:select) end - it "should support DESCRIBE queries" do - @query.should respond_to(:describe) + it "supports DESCRIBE queries" do + expect(subject).to respond_to(:describe) end - it "should support CONSTRUCT queries" do - @query.should respond_to(:construct) + it "supports CONSTRUCT queries" do + expect(subject).to respond_to(:construct) end end context "when building ASK queries" do - it "should support basic graph patterns" do - @query.ask.where([:s, :p, :o]).to_s.should == "ASK WHERE { ?s ?p ?o . }" - @query.ask.whether([:s, :p, :o]).to_s.should == "ASK WHERE { ?s ?p ?o . }" + context "basic graph patterns" do + context "where" do + it "supports simple pattern" do + expect(subject.ask.where([:s, :p, :o]).to_s).to eq "ASK WHERE { ?s ?p ?o . }" + end + it "supports multiple patterns" do + dbpo = RDF::URI("http://dbpedia.org/ontology/") + grs = RDF::URI("http://www.georss.org/georss/") + patterns = [ + [:city, RDF.type, dbpo + "Place"], + [:city, RDF::RDFS.label, :name], + [:city, dbpo + "country", :country], + [:city, dbpo + "abstract", :abstact], + [:city, grs + "point", :coords] + ] + where = [ + "?city a .", + "?city ?name .", + "?city ?country .", + "?city ?abstact .", + "?city ?coords ." + ].join(" ") + expect(subject.ask.where(*patterns).to_s).to eq "ASK WHERE { #{where} }" + end + end + + it "supports whether as an alias for where" do + expect(subject.ask.whether([:s, :p, :o]).to_s).to eq "ASK WHERE { ?s ?p ?o . }" + end + + it "expects results not statements" do + expect(subject.ask.where([:s, :p, :o])).not_to be_expects_statements + end + + it "supports block with no argument for chaining" do + expected = "ASK WHERE { ?s ?p ?o . FILTER(regex(?s, 'Abiline, Texas')) }" + expect(subject.ask.where([:s, :p, :o]) {filter("regex(?s, 'Abiline, Texas')")}.to_s).to eq expected + end + + it "supports block with argument for chaining" do + expected = "ASK WHERE { ?s ?p ?o . FILTER(regex(?s, 'Abiline, Texas')) }" + expect(subject.ask.where([:s, :p, :o]) {|q| q.filter("regex(?s, 'Abiline, Texas')")}.to_s).to eq expected + end + + context "filter" do + it "supports filter as a string argument" do + expected = "ASK WHERE { ?s ?p ?o . FILTER(regex(?s, 'Abiline, Texas')) }" + expect(subject.ask.where([:s, :p, :o]).filter("regex(?s, 'Abiline, Texas')").to_s).to eq expected + end + it "supports multiple string filters" do + expected = "ASK WHERE { ?s ?p ?o . FILTER(regex(?s, 'Abiline, Texas')) FILTER(langmatches(lang(?o), 'EN')) }" + expect(subject.ask.where([:s, :p, :o]). + filter("regex(?s, 'Abiline, Texas')"). + filter("langmatches(lang(?o), 'EN')"). + to_s + ).to eq expected + end + end end end context "when building SELECT queries" do - it "should support basic graph patterns" do - @query.select.where([:s, :p, :o]).to_s.should == "SELECT * WHERE { ?s ?p ?o . }" + it "supports basic graph patterns" do + expect(subject.select.where([:s, :p, :o]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . }" end - it "should support projection" do - @query.select(:s).where([:s, :p, :o]).to_s.should == "SELECT ?s WHERE { ?s ?p ?o . }" - @query.select(:s, :p).where([:s, :p, :o]).to_s.should == "SELECT ?s ?p WHERE { ?s ?p ?o . }" - @query.select(:s, :p, :o).where([:s, :p, :o]).to_s.should == "SELECT ?s ?p ?o WHERE { ?s ?p ?o . }" + it "supports projection" do + expect(subject.select(:s).where([:s, :p, :o]).to_s).to eq "SELECT ?s WHERE { ?s ?p ?o . }" + expect(subject.select(:s, :p).where([:s, :p, :o]).to_s).to eq "SELECT ?s ?p WHERE { ?s ?p ?o . }" + expect(subject.select(:s, :p, :o).where([:s, :p, :o]).to_s).to eq "SELECT ?s ?p ?o WHERE { ?s ?p ?o . }" end - it "should support FROM" do + it "supports FROM" do uri = "http://example.org/dft.ttl" - @query.select.from(RDF::URI.new(uri)).where([:s, :p, :o]).to_s.should == - "SELECT * FROM <#{uri}> WHERE { ?s ?p ?o . }" + expect(subject.select.from(RDF::URI.new(uri)).where([:s, :p, :o]).to_s).to eq "SELECT * FROM <#{uri}> WHERE { ?s ?p ?o . }" + end + + it "supports DISTINCT" do + expect(subject.select(:s, distinct: true).where([:s, :p, :o]).to_s).to eq "SELECT DISTINCT ?s WHERE { ?s ?p ?o . }" + expect(subject.select(:s).distinct.where([:s, :p, :o]).to_s).to eq "SELECT DISTINCT ?s WHERE { ?s ?p ?o . }" + expect(subject.select.distinct.where([:s, :p, :o]).to_s).to eq "SELECT DISTINCT * WHERE { ?s ?p ?o . }" + end + + it "supports REDUCED" do + expect(subject.select(:s, reduced: true).where([:s, :p, :o]).to_s).to eq "SELECT REDUCED ?s WHERE { ?s ?p ?o . }" + expect(subject.select(:s).reduced.where([:s, :p, :o]).to_s).to eq "SELECT REDUCED ?s WHERE { ?s ?p ?o . }" end - it "should support DISTINCT" do - @query.select(:s, :distinct => true).where([:s, :p, :o]).to_s.should == "SELECT DISTINCT ?s WHERE { ?s ?p ?o . }" - @query.select(:s).distinct.where([:s, :p, :o]).to_s.should == "SELECT DISTINCT ?s WHERE { ?s ?p ?o . }" + it "supports GRAPH" do + expect(subject.select.graph(:g).where([:s, :p, :o]).to_s).to eq "SELECT * WHERE { GRAPH ?g { ?s ?p ?o . } }" + expect(subject.select.graph('http://example.org/').where([:s, :p, :o]).to_s).to eq "SELECT * WHERE { GRAPH { ?s ?p ?o . } }" end - it "should support REDUCED" do - @query.select(:s, :reduced => true).where([:s, :p, :o]).to_s.should == "SELECT REDUCED ?s WHERE { ?s ?p ?o . }" - @query.select(:s).reduced.where([:s, :p, :o]).to_s.should == "SELECT REDUCED ?s WHERE { ?s ?p ?o . }" + it "supports COUNT" do + expect(subject.select(count: { s: :c }).where([:s, :p, :o]).to_s).to eq "SELECT ( COUNT(?s) AS ?c ) WHERE { ?s ?p ?o . }" + expect(subject.select(count: { s: :c }, distinct: true).where([:s, :p, :o]).to_s).to eq "SELECT ( COUNT(DISTINCT ?s) AS ?c ) WHERE { ?s ?p ?o . }" + expect(subject.select(count: { s: '?c' }).where([:s, :p, :o]).to_s).to eq "SELECT ( COUNT(?s) AS ?c ) WHERE { ?s ?p ?o . }" + expect(subject.select(count: { '?s' => '?c' }).where([:s, :p, :o]).to_s).to eq "SELECT ( COUNT(?s) AS ?c ) WHERE { ?s ?p ?o . }" + expect(subject.select(:o, count: { s: :c }).where([:s, :p, :o]).to_s).to eq "SELECT ?o ( COUNT(?s) AS ?c ) WHERE { ?s ?p ?o . }" end - it "should support GRAPH" do - @query.select.graph(:g).where([:s, :p, :o]).to_s.should == "SELECT * WHERE { GRAPH ?g { ?s ?p ?o . } }" - @query.select.graph('http://example.org/').where([:s, :p, :o]).to_s.should == "SELECT * WHERE { GRAPH { ?s ?p ?o . } }" + it "supports VALUES" do + expect(subject.select(:s).where([:s, :p, :o]).values(:o, "Object").to_s).to eq 'SELECT ?s WHERE { ?s ?p ?o . VALUES (?o) { ( "Object" ) } }' + expect(subject.select(:s).where([:s, :p, :o]).values(:o, "1", "2").to_s).to eq 'SELECT ?s WHERE { ?s ?p ?o . VALUES (?o) { ( "1" ) ( "2" ) } }' + expect(subject.select(:s).where([:s, :p, :o]).values([:o, :p], ["Object", "Predicate"]).to_s).to eq 'SELECT ?s WHERE { ?s ?p ?o . VALUES (?o ?p) { ( "Object" "Predicate" ) } }' + expect(subject.select(:s).where([:s, :p, :o]).values([:o, :p], ["1", "2"], ["3", "4"]).to_s).to eq 'SELECT ?s WHERE { ?s ?p ?o . VALUES (?o ?p) { ( "1" "2" ) ( "3" "4" ) } }' + expect(subject.select(:s).where([:s, :p, :o]).values([:o, :p], [nil, "2"], ["3", nil]).to_s).to eq 'SELECT ?s WHERE { ?s ?p ?o . VALUES (?o ?p) { ( UNDEF "2" ) ( "3" UNDEF ) } }' + expect(subject.select.where.values(:s, RDF::URI('a'), RDF::URI('b')).to_s).to eq 'SELECT * WHERE { VALUES (?s) { ( ) ( ) } }' end - it "should support COUNT" do - @query.select(:count => { :s => :c }).where([:s, :p, :o]).to_s.should == "SELECT ( COUNT(?s) AS ?c ) WHERE { ?s ?p ?o . }" - @query.select(:count => { :s => :c }, :distinct => true).where([:s, :p, :o]).to_s.should == "SELECT ( COUNT(DISTINCT ?s) AS ?c ) WHERE { ?s ?p ?o . }" - @query.select(:count => { :s => '?c' }).where([:s, :p, :o]).to_s.should == "SELECT ( COUNT(?s) AS ?c ) WHERE { ?s ?p ?o . }" - @query.select(:count => { '?s' => '?c' }).where([:s, :p, :o]).to_s.should == "SELECT ( COUNT(?s) AS ?c ) WHERE { ?s ?p ?o . }" - @query.select(:o, :count => { :s => :c }).where([:s, :p, :o]).to_s.should == "SELECT ?o ( COUNT(?s) AS ?c ) WHERE { ?s ?p ?o . }" + it "supports GROUP BY" do + expect(subject.select(:s).where([:s, :p, :o]).group_by(:s).to_s).to eq "SELECT ?s WHERE { ?s ?p ?o . } GROUP BY ?s" + expect(subject.select(:s).where([:s, :p, :o]).group_by('?s').to_s).to eq "SELECT ?s WHERE { ?s ?p ?o . } GROUP BY ?s" end - it "should support GROUP BY" do - @query.select(:s).where([:s, :p, :o]).group_by(:s).to_s.should == "SELECT ?s WHERE { ?s ?p ?o . } GROUP BY ?s" - @query.select(:s).where([:s, :p, :o]).group_by('?s').to_s.should == "SELECT ?s WHERE { ?s ?p ?o . } GROUP BY ?s" + it "supports ORDER BY" do + expect(subject.select.where([:s, :p, :o]).order_by(:o).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o" + expect(subject.select.where([:s, :p, :o]).order_by(:o, :p).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o ?p" + expect(subject.select.where([:s, :p, :o]).order_by('?o').to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o" + expect(subject.select.where([:s, :p, :o]).order_by('ASC(?o)').to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o)" + expect(subject.select.where([:s, :p, :o]).order_by('DESC(?o)').to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY DESC(?o)" + expect(subject.select.where([:s, :p, :o]).order_by(o: :asc).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o)" + expect(subject.select.where([:s, :p, :o]).order_by(o: :desc).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY DESC(?o)" + expect(subject.select.where([:s, :p, :o]).order_by(o: :asc, p: :desc).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o) DESC(?p)" + expect(subject.select.where([:s, :p, :o]).order_by([:o, :asc]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o)" + expect(subject.select.where([:s, :p, :o]).order_by([:o, :desc]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY DESC(?o)" + expect(subject.select.where([:s, :p, :o]).order_by([:o, :asc], [:p, :desc]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o) DESC(?p)" + expect { subject.select.where([:s, :p, :o]).order_by(42).to_s }.to raise_error(ArgumentError) + expect { subject.select.where([:s, :p, :o]).order_by(:o, 42).to_s }.to raise_error(ArgumentError) + expect { subject.select.where([:s, :p, :o]).order_by([:o]).to_s }.to raise_error(ArgumentError) + expect { subject.select.where([:s, :p, :o]).order_by([:o, :csa]).to_s }.to raise_error(ArgumentError) + expect { subject.select.where([:s, :p, :o]).order_by([:o, :asc, 42]).to_s }.to raise_error(ArgumentError) + expect { subject.select.where([:s, :p, :o]).order_by(o: 42).to_s }.to raise_error(ArgumentError) + expect { subject.select.where([:s, :p, :o]).order_by(42 => :asc).to_s }.to raise_error(ArgumentError) end - it "should support ORDER BY" do - @query.select.where([:s, :p, :o]).order_by(:o).to_s.should == "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o" - @query.select.where([:s, :p, :o]).order_by('?o').to_s.should == "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o" - # @query.select.where([:s, :p, :o]).order_by(:o => :asc).to_s.should == "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o ASC" - @query.select.where([:s, :p, :o]).order_by('?o ASC').to_s.should == "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o ASC" - # @query.select.where([:s, :p, :o]).order_by(:o => :desc).to_s.should == "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o DESC" - @query.select.where([:s, :p, :o]).order_by('?o DESC').to_s.should == "SELECT * WHERE { ?s ?p ?o . } ORDER BY ?o DESC" + it "supports ORDER BY ASC" do + expect(subject.select.where([:s, :p, :o]).order.asc(:o).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o)" + expect(subject.select.where([:s, :p, :o]).asc(:o).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY ASC(?o)" + expect { subject.select.where([:s, :p, :o]).order.asc(:o, :p).to_s }.to raise_error(ArgumentError) end - it "should support OFFSET" do - @query.select.where([:s, :p, :o]).offset(100).to_s.should == "SELECT * WHERE { ?s ?p ?o . } OFFSET 100" + it "supports ORDER BY DESC" do + expect(subject.select.where([:s, :p, :o]).order.desc(:o).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY DESC(?o)" + expect(subject.select.where([:s, :p, :o]).desc(:o).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } ORDER BY DESC(?o)" + expect { subject.select.where([:s, :p, :o]).order.desc(:o, :p).to_s }.to raise_error(ArgumentError) end - it "should support LIMIT" do - @query.select.where([:s, :p, :o]).limit(10).to_s.should == "SELECT * WHERE { ?s ?p ?o . } LIMIT 10" + it "supports OFFSET" do + expect(subject.select.where([:s, :p, :o]).offset(100).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } OFFSET 100" end - it "should support OFFSET with LIMIT" do - @query.select.where([:s, :p, :o]).offset(100).limit(10).to_s.should == "SELECT * WHERE { ?s ?p ?o . } OFFSET 100 LIMIT 10" - @query.select.where([:s, :p, :o]).slice(100, 10).to_s.should == "SELECT * WHERE { ?s ?p ?o . } OFFSET 100 LIMIT 10" + it "supports LIMIT" do + expect(subject.select.where([:s, :p, :o]).limit(10).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } LIMIT 10" end - it "should support PREFIX" do + it "supports OFFSET with LIMIT" do + expect(subject.select.where([:s, :p, :o]).offset(100).limit(10).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } OFFSET 100 LIMIT 10" + expect(subject.select.where([:s, :p, :o]).slice(100, 10).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } OFFSET 100 LIMIT 10" + end + + it "supports string PREFIX" do prefixes = ["dc: ", "foaf: "] - @query.select.prefix(prefixes[0]).prefix(prefixes[1]).where([:s, :p, :o]).to_s.should == - "PREFIX #{prefixes[0]} PREFIX #{prefixes[1]} SELECT * WHERE { ?s ?p ?o . }" + expect(subject.select.prefix(prefixes[0]).prefix(prefixes[1]).where([:s, :p, :o]).to_s).to eq "PREFIX dc: PREFIX foaf: SELECT * WHERE { ?s ?p ?o . }" + end + + it "supports hash PREFIX" do + prefixes = [{dc: RDF::URI("http://purl.org/dc/elements/1.1/")}, {foaf: RDF::URI("http://xmlns.com/foaf/0.1/")}] + expect(subject.select.prefix(prefixes[0]).prefix(prefixes[1]).where([:s, :p, :o]).to_s).to eq "PREFIX dc: PREFIX foaf: SELECT * WHERE { ?s ?p ?o . }" + end + + it "supports multiple values in PREFIX hash" do + expect(subject.select.prefix(dc: RDF::URI("http://purl.org/dc/elements/1.1/"), foaf: RDF::URI("http://xmlns.com/foaf/0.1/")).where([:s, :p, :o]).to_s).to eq "PREFIX dc: PREFIX foaf: SELECT * WHERE { ?s ?p ?o . }" + end + + it "raises an ArgumentError for invalid PREFIX type" do + inavlid_prefix_types = [RDF::URI('missing prefix hash'), 0, []] + inavlid_prefix_types.each do |invalid_arg| + expect { subject.select.prefix(invalid_arg) }.to raise_error ArgumentError, "prefix must be a kind of String or a Hash" + end + end + + it "supports OPTIONAL" do + expect(subject.select.where([:s, :p, :o]).optional([:s, RDF.type, :o], [:s, RDF::URI("http://purl.org/dc/terms/abstract"), :o]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . OPTIONAL { ?s a ?o . ?s ?o . } }" + end + + it "supports OPTIONAL with filter in block" do + expect(subject.select.where([:s, :p, :o]).optional([:s, RDF.value, :o]) {filter("langmatches(lang(?o), 'en')")}.to_s).to eq "SELECT * WHERE { ?s ?p ?o . OPTIONAL { ?s ?o . FILTER(langmatches(lang(?o), 'en')) . } }" + end + + it "supports multiple OPTIONALs" do + expect(subject.select.where([:s, :p, :o]).optional([:s, RDF.type, :o]).optional([:s, RDF::URI("http://purl.org/dc/terms/abstract"), :o]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . OPTIONAL { ?s a ?o . } OPTIONAL { ?s ?o . } }" + end + + it "supports subqueries" do + subquery = subject.select.where([:s, :p, :o]) + expect(subject.select.where(subquery).where([:s, :p, :o]).to_s).to eq "SELECT * WHERE { { SELECT * WHERE { ?s ?p ?o . } } . ?s ?p ?o . }" + end + + it "supports subqueries using block" do + expect(subject.select.where([:s, :p, :o]) {select.where([:s, :p, :o])}.to_s).to eq "SELECT * WHERE { { SELECT * WHERE { ?s ?p ?o . } } . ?s ?p ?o . }" + end + + it "expects results not statements" do + expect(subject.select.where([:s, :p, :o])).not_to be_expects_statements end - it "should support OPTIONAL" do - @query.select.where([:s, :p, :o]).optional([:s, RDF.type, :o], [:s, RDF::DC.abstract, :o]).to_s.should == - "SELECT * WHERE { ?s ?p ?o . OPTIONAL { ?s a ?o . ?s <#{RDF::DC.abstract}> ?o . } }" + context "with property paths" do + it "supports the InversePath expression" do + expect(subject.select.where([:s, ["^",RDF::RDFS.subClassOf], :o]).to_s).to eq "SELECT * WHERE { ?s ^<#{RDF::RDFS.subClassOf}> ?o . }" + end + it "supports the SequencePath expression" do + expect(subject.select.where([:s, [RDF.type,"/",RDF::RDFS.subClassOf], :o]).to_s).to eq "SELECT * WHERE { ?s a/<#{RDF::RDFS.subClassOf}> ?o . }" + end + it "supports the AlternativePath expression" do + expect(subject.select.where([:s, [RDF.type,"|",RDF::RDFS.subClassOf], :o]).to_s).to eq "SELECT * WHERE { ?s a|<#{RDF::RDFS.subClassOf}> ?o . }" + end + it "supports the ZeroOrMore expression" do + expect(subject.select.where([:s, [RDF::RDFS.subClassOf,"*"], :o]).to_s).to eq "SELECT * WHERE { ?s <#{RDF::RDFS.subClassOf}>* ?o . }" + end + it "supports the OneOrMore expression" do + expect(subject.select.where([:s, [RDF::RDFS.subClassOf,"+"], :o]).to_s).to eq "SELECT * WHERE { ?s <#{RDF::RDFS.subClassOf}>+ ?o . }" + end + it "supports the ZeroOrOne expression" do + expect(subject.select.where([:s, [RDF::RDFS.subClassOf,"?"], :o]).to_s).to eq "SELECT * WHERE { ?s <#{RDF::RDFS.subClassOf}>? ?o . }" + end + it "supports the NegatedPropertySet expression" do + expect(subject.select.where([:s, ["!",[RDF::RDFS.subClassOf,"|",RDF.type]], :o]).to_s).to eq "SELECT * WHERE { ?s !(<#{RDF::RDFS.subClassOf}>|a) ?o . }" + end end - it "should support multiple OPTIONALs" do - @query.select.where([:s, :p, :o]).optional([:s, RDF.type, :o]).optional([:s, RDF::DC.abstract, :o]).to_s.should == - "SELECT * WHERE { ?s ?p ?o . OPTIONAL { ?s a ?o . } OPTIONAL { ?s <#{RDF::DC.abstract}> ?o . } }" + context "with unions" do + it "supports pattern arguments" do + #expect(subject.select.where([:s, :p, :o]).union([:s, :p, :o]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . } UNION { ?s ?p ?o . }" # original expect + # NCBO UNION expect + expect(subject.select.where([:s, :p, :o]).union([:s, :p, :o]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . { ?s ?p ?o . } }" + end + + it "supports query arguments" do + subquery = subject.select.where([:s, :p, :o]) + # NCBO UNION expect + expect(subject.select.where([:s, :p, :o]).union(subquery).to_s).to eq "SELECT * WHERE { ?s ?p ?o . { ?s ?p ?o . } }" + end + + it "supports block" do + # NCBO UNION expect + expect(subject.select.where([:s, :p, :o]).union {|q| q.where([:s, :p, :o])}.to_s).to eq "SELECT * WHERE { ?s ?p ?o . { ?s ?p ?o . } }" + end + + it "rejects mixed arguments" do + subquery = subject.select.where([:s, :p, :o]) + expect {subject.select.where([:s, :p, :o]).union([:s, :p, :o], subquery)}.to raise_error(ArgumentError) + end + + it "rejects arguments and block" do + expect {subject.select.where([:s, :p, :o]).union([:s, :p, :o]) {|q| q.where([:s, :p, :o])}}.to raise_error(ArgumentError) + end end - it "should support subqueries" do - subquery = @query.select.where([:s, :p, :o]) - @query.select.where(subquery).where([:s, :p, :o]).to_s.should == - "SELECT * WHERE { { SELECT * WHERE { ?s ?p ?o . } } . ?s ?p ?o . }" + context "with minus" do + it "supports pattern arguments" do + expect(subject.select.where([:s, :p, :o]).minus([:s, :p, :o]).to_s).to eq "SELECT * WHERE { ?s ?p ?o . MINUS { ?s ?p ?o . } }" + end + + it "supports query arguments" do + subquery = subject.select.where([:s, :p, :o]) + expect(subject.select.where([:s, :p, :o]).minus(subquery).to_s).to eq "SELECT * WHERE { ?s ?p ?o . MINUS { ?s ?p ?o . } }" + end + + it "supports block" do + expect(subject.select.where([:s, :p, :o]).minus {|q| q.where([:s, :p, :o])}.to_s).to eq "SELECT * WHERE { ?s ?p ?o . MINUS { ?s ?p ?o . } }" + end + + it "rejects mixed arguments" do + subquery = subject.select.where([:s, :p, :o]) + expect {subject.select.where([:s, :p, :o]).minus([:s, :p, :o], subquery)}.to raise_error(ArgumentError) + end + + it "rejects arguments and block" do + expect {subject.select.where([:s, :p, :o]).minus([:s, :p, :o]) {|q| q.where([:s, :p, :o])}}.to raise_error(ArgumentError) + end + end + + context "with SERVICE" do + it "supports pattern arguments" do + expect(subject.select.where([:s, :p1, :o1]).service(:l, [:s, :p2, :o2]).to_s).to eq "SELECT * WHERE { ?s ?p1 ?o1 . SERVICE ?l { ?s ?p2 ?o2 . } }" + end + + it "supports pattern arguments with URI endpoint" do + expect(subject.select.where([:s, :p1, :o1]).service(RDF::URI("http://example.com/endpoint"), [:s, :p2, :o2]).to_s).to eq "SELECT * WHERE { ?s ?p1 ?o1 . SERVICE { ?s ?p2 ?o2 . } }" + end + + it "supports SILENT option" do + expect(subject.select.where([:s, :p1, :o1]).service(:l, [:s, :p2, :o2], silent: true).to_s).to eq "SELECT * WHERE { ?s ?p1 ?o1 . SERVICE SILENT ?l { ?s ?p2 ?o2 . } }" + end + + it "supports query arguments" do + subquery = subject.select.where([:s, :p, :o]) + expect(subject.select.where([:s, :p, :o]).service(:l, subquery).to_s).to eq "SELECT * WHERE { ?s ?p ?o . SERVICE ?l { ?s ?p ?o . } }" + end + + it "supports block" do + expect(subject.select.where([:s, :p, :o]).service(:l) {|q| q.where([:s, :p, :o])}.to_s).to eq "SELECT * WHERE { ?s ?p ?o . SERVICE ?l { ?s ?p ?o . } }" + end + + it "errors with no subqueries" do + expect {subject.select.where([:s, :p, :o]).service(:l)}.to raise_error(ArgumentError) + end + + it "rejects mixed arguments" do + subquery = subject.select.where([:s, :p, :o]) + expect {subject.select.where([:s, :p, :o]).service(?l, [:s, :p, :o], subquery)}.to raise_error(ArgumentError) + end + + it "rejects arguments and block" do + expect {subject.select.where([:s, :p, :o]).service(?l[:s, :p, :o]) {|q| q.where([:s, :p, :o])}}.to raise_error(ArgumentError) + end end end context "when building DESCRIBE queries" do - it "should support basic graph patterns" do - @query.describe.where([:s, :p, :o]).to_s.should == "DESCRIBE * WHERE { ?s ?p ?o . }" + it "supports basic graph patterns" do + expect(subject.describe.where([:s, :p, :o]).to_s).to eq "DESCRIBE * WHERE { ?s ?p ?o . }" end - it "should support projection" do - @query.describe(:s).where([:s, :p, :o]).to_s.should == "DESCRIBE ?s WHERE { ?s ?p ?o . }" - @query.describe(:s, :p).where([:s, :p, :o]).to_s.should == "DESCRIBE ?s ?p WHERE { ?s ?p ?o . }" - @query.describe(:s, :p, :o).where([:s, :p, :o]).to_s.should == "DESCRIBE ?s ?p ?o WHERE { ?s ?p ?o . }" + it "supports projection" do + expect(subject.describe(:s).where([:s, :p, :o]).to_s).to eq "DESCRIBE ?s WHERE { ?s ?p ?o . }" + expect(subject.describe(:s, :p).where([:s, :p, :o]).to_s).to eq "DESCRIBE ?s ?p WHERE { ?s ?p ?o . }" + expect(subject.describe(:s, :p, :o).where([:s, :p, :o]).to_s).to eq "DESCRIBE ?s ?p ?o WHERE { ?s ?p ?o . }" end - it "should support RDF::URI arguments" do + it "supports RDF::URI arguments" do uris = ['http://www.bbc.co.uk/programmes/b007stmh#programme', 'http://www.bbc.co.uk/programmes/b00lg2xb#programme'] - @query.describe(RDF::URI.new(uris[0]),RDF::URI.new(uris[1])).to_s.should == - "DESCRIBE <#{uris[0]}> <#{uris[1]}>" + expect(subject.describe(RDF::URI.new(uris[0]),RDF::URI.new(uris[1])).to_s).to eq "DESCRIBE <#{uris[0]}> <#{uris[1]}>" + end + + it "expects statements not results" do + expect(subject.describe(:s).where([:s, :p, :o])).to be_expects_statements end end context "when building CONSTRUCT queries" do - it "should support basic graph patterns" do - @query.construct([:s, :p, :o]).where([:s, :p, :o]).to_s.should == "CONSTRUCT { ?s ?p ?o . } WHERE { ?s ?p ?o . }" + it "supports basic graph patterns" do + expect(subject.construct([:s, :p, :o]).where([:s, :p, :o]).to_s).to eq "CONSTRUCT { ?s ?p ?o . } WHERE { ?s ?p ?o . }" + end + + it "expects statements not results" do + expect(subject.construct([:s, :p, :o]).where([:s, :p, :o])).to be_expects_statements + end + end + + context "issues" do + it "issue #96" do + expect { + require 'sparql/client' + SPARQL::Client::Query + .select + .where(%i[s p o]) + .values(:s, RDF::URI('http://example.com/1'), RDF::URI('http://example.com/2')) + }.not_to raise_error end end end diff --git a/spec/repository_spec.rb b/spec/repository_spec.rb new file mode 100644 index 00000000..1667d89f --- /dev/null +++ b/spec/repository_spec.rb @@ -0,0 +1,24 @@ +require_relative 'spec_helper' +require 'webmock/rspec' +require 'rdf/spec/repository' + +describe SPARQL::Client::Repository do + before :all do + @base_repo = RDF::Repository.new + end + + # @see lib/rdf/spec/repository.rb in RDF-spec + it_behaves_like 'an RDF::Repository' do + let(:repository) { SPARQL::Client::Repository.new(uri: @base_repo) } + end + + context "Problematic Tests", skip: true do + subject {SPARQL::Client::Repository.new(uri: @base_repo)} + before :each do + @statements = RDF::Spec.quads + + @base_repo.insert(*@statements) + end + its(:count) {is_expected.to eql @statements.size} + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 255d4f75..6cdc154f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,7 +1,32 @@ require "bundler/setup" -require 'sparql/client' +require 'rspec/its' require 'rdf/spec' +require 'pry' + +begin + require 'simplecov' + require 'simplecov-lcov' + + SimpleCov::Formatter::LcovFormatter.config do |config| + #Coveralls is coverage by default/lcov. Send info results + config.report_with_single_file = true + config.single_report_path = 'coverage/lcov.info' + end + + SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ + SimpleCov::Formatter::HTMLFormatter, + SimpleCov::Formatter::LcovFormatter + ]) + SimpleCov.start do + add_filter "/spec/" + end +rescue LoadError => e + STDERR.puts "Coverage Skipped: #{e.message}" +end +require 'sparql/client' RSpec.configure do |config| config.include(RDF::Spec::Matchers) + config.filter_run focus: true + config.run_all_when_everything_filtered = true end diff --git a/spec/update_spec.rb b/spec/update_spec.rb index 35957299..2a54b9ef 100644 --- a/spec/update_spec.rb +++ b/spec/update_spec.rb @@ -1,217 +1,236 @@ -require File.join(File.dirname(__FILE__), 'spec_helper') +require_relative 'spec_helper' describe SPARQL::Client::Update do - before :each do - @sparql = SPARQL::Client::Update - end + subject {SPARQL::Client::Update} context "when building queries" do - it "should support INSERT DATA operations" do - @sparql.should respond_to(:insert_data) + it "supports INSERT DATA operations" do + expect(subject).to respond_to(:insert_data) end - it "should support DELETE DATA operations" do - @sparql.should respond_to(:delete_data) + it "supports DELETE DATA operations" do + expect(subject).to respond_to(:delete_data) end - it "should support DELETE/INSERT operations" do - #@sparql.should respond_to(:what) - #@sparql.should respond_to(:delete) - #@sparql.should respond_to(:insert) + it "supports DELETE/INSERT operations", pending: true do + expect(subject).to respond_to(:what) + expect(subject).to respond_to(:delete) + expect(subject).to respond_to(:insert) end - it "should support LOAD operations" do - @sparql.should respond_to(:load) + it "supports LOAD operations" do + expect(subject).to respond_to(:load) end - it "should support CLEAR operations" do - @sparql.should respond_to(:clear) + it "supports CLEAR operations" do + expect(subject).to respond_to(:clear) end - it "should support CREATE operations" do - @sparql.should respond_to(:create) + it "supports CREATE operations" do + expect(subject).to respond_to(:create) end - it "should support DROP operations" do - @sparql.should respond_to(:drop) + it "supports DROP operations" do + expect(subject).to respond_to(:drop) end - it "should support COPY operations" do - #@sparql.should respond_to(:copy) # TODO + it "supports COPY operations", pending: true do + expect(subject).to respond_to(:copy) # TODO end - it "should support MOVE operations" do - #@sparql.should respond_to(:move) # TODO + it "supports MOVE operations", pending: true do + expect(subject).to respond_to(:move) # TODO end - it "should support ADD operations" do - #@sparql.should respond_to(:add) # TODO + it "supports ADD operations", pending: true do + expect(subject).to respond_to(:add) # TODO end end context "when building INSERT DATA queries" do - it "should support empty input" do - @sparql.insert_data(RDF::Graph.new).to_s.should == "INSERT DATA {\n}\n" + it "supports empty input" do + expect(subject.insert_data(RDF::Graph.new).to_s).to eq "INSERT DATA {\n}\n" + end + + it "expects results not statements" do + expect(subject.insert_data(RDF::Graph.new)).not_to be_expects_statements end - it "should support non-empty input" do + it "supports non-empty input" do data = RDF::Graph.new do |graph| - graph << [RDF::URI('http://example.org/jhacker'), RDF::FOAF.name, "J. Random Hacker"] + graph << [RDF::URI('http://example.org/jhacker'), RDF::URI("http://xmlns.com/foaf/0.1/name"), "J. Random Hacker"] end - @sparql.insert_data(data).to_s.should == - "INSERT DATA {\n \"J. Random Hacker\" .\n}\n" + expect(subject.insert_data(data).to_s).to eq "INSERT DATA {\n \"J. Random Hacker\" .\n}\n" end - it "should support the GRAPH modifier" do - [@sparql.insert_data(RDF::Graph.new, :graph => 'http://example.org/'), - @sparql.insert_data(RDF::Graph.new).graph('http://example.org/')].each do |example| - example.to_s.should == "INSERT DATA { GRAPH {\n}}\n" + it "supports the GRAPH modifier" do + [subject.insert_data(RDF::Graph.new, graph: 'http://example.org/'), + subject.insert_data(RDF::Graph.new).graph('http://example.org/')].each do |example| + expect(example.to_s).to eq "INSERT DATA { GRAPH {\n}}\n" end end end context "when building DELETE DATA queries" do - it "should support empty input" do - @sparql.delete_data(RDF::Graph.new).to_s.should == "DELETE DATA {\n}\n" + it "supports empty input" do + expect(subject.delete_data(RDF::Graph.new).to_s).to eq "DELETE DATA {\n}\n" + end + + it "expects statements not results" do + expect(subject.delete_data(RDF::Graph.new)).to be_expects_statements end - it "should support non-empty input" do + it "supports non-empty input" do data = RDF::Graph.new do |graph| - graph << [RDF::URI('http://example.org/jhacker'), RDF::FOAF.name, "J. Random Hacker"] + graph << [RDF::URI('http://example.org/jhacker'), RDF::URI("http://xmlns.com/foaf/0.1/name"), "J. Random Hacker"] end - @sparql.delete_data(data).to_s.should == - "DELETE DATA {\n \"J. Random Hacker\" .\n}\n" + expect(subject.delete_data(data).to_s).to eq "DELETE DATA {\n \"J. Random Hacker\" .\n}\n" end - it "should support the GRAPH modifier" do - [@sparql.delete_data(RDF::Graph.new, :graph => 'http://example.org/'), - @sparql.delete_data(RDF::Graph.new).graph('http://example.org/')].each do |example| - example.to_s.should == "DELETE DATA { GRAPH {\n}}\n" + it "supports the GRAPH modifier" do + [subject.delete_data(RDF::Graph.new, graph: 'http://example.org/'), + subject.delete_data(RDF::Graph.new).graph('http://example.org/')].each do |example| + expect(example.to_s).to eq "DELETE DATA { GRAPH {\n}}\n" end end end context "when building INSERT/DELETE queries" do - # TODO + it "should do something" end context "when building LOAD queries" do - it "should require a source URI" do - from_url = 'http://example.org/data.rdf' - @sparql.load(from_url).to_s.should == "LOAD <#{from_url}>" + let(:from_url) {'http://example.org/data.rdf'} + + it "requires a source URI" do + expect(subject.load(from_url).to_s).to eq "LOAD <#{from_url}>" + end + + it "expects statements not results" do + expect(subject.load(from_url)).to be_expects_statements end - it "should support the SILENT modifier" do - from_url = 'http://example.org/data.rdf' - [@sparql.load(from_url).silent, - @sparql.load(from_url, :silent => true)].each do |example| - example.to_s.should == "LOAD SILENT <#{from_url}>" + it "supports the SILENT modifier" do + [subject.load(from_url).silent, + subject.load(from_url, silent: true)].each do |example| + expect(example.to_s).to eq "LOAD SILENT <#{from_url}>" end end - it "should support the INTO GRAPH modifier" do - from_url = 'http://example.org/data.rdf' - [@sparql.load(from_url).into(from_url), - @sparql.load(from_url, :into => from_url)].each do |example| - example.to_s.should == "LOAD <#{from_url}> INTO GRAPH <#{from_url}>" + it "supports the INTO GRAPH modifier" do + [subject.load(from_url).into(from_url), + subject.load(from_url, into: from_url)].each do |example| + expect(example.to_s).to eq "LOAD <#{from_url}> INTO GRAPH <#{from_url}>" end end end context "when building CLEAR queries" do - it "should support the CLEAR GRAPH operation" do + it "supports the CLEAR GRAPH operation" do graph_uri = 'http://example.org/' - [@sparql.clear.graph(graph_uri), - @sparql.clear(:graph, graph_uri)].each do |example| - example.to_s.should == "CLEAR GRAPH <#{graph_uri}>" + [subject.clear.graph(graph_uri), + subject.clear(:graph, graph_uri)].each do |example| + expect(example.to_s).to eq "CLEAR GRAPH <#{graph_uri}>" end end - it "should support the CLEAR DEFAULT operation" do - [@sparql.clear.default, @sparql.clear(:default)].each do |example| - example.to_s.should == "CLEAR DEFAULT" + it "supports the CLEAR DEFAULT operation" do + [subject.clear.default, subject.clear(:default)].each do |example| + expect(example.to_s).to eq "CLEAR DEFAULT" end end - it "should support the CLEAR NAMED operation" do - [@sparql.clear.named, @sparql.clear(:named)].each do |example| - example.to_s.should == "CLEAR NAMED" + it "supports the CLEAR NAMED operation" do + [subject.clear.named, subject.clear(:named)].each do |example| + expect(example.to_s).to eq "CLEAR NAMED" end end - it "should support the CLEAR ALL operation" do - [@sparql.clear.all, @sparql.clear(:all)].each do |example| - example.to_s.should == "CLEAR ALL" + it "supports the CLEAR ALL operation" do + [subject.clear.all, subject.clear(:all)].each do |example| + expect(example.to_s).to eq "CLEAR ALL" end end - it "should support the SILENT modifier" do - [@sparql.clear(:all).silent, - @sparql.clear(:all, :silent => true)].each do |example| - example.to_s.should == "CLEAR SILENT ALL" + it "expects results not statements" do + expect(subject.clear.all).not_to be_expects_statements + end + + it "supports the SILENT modifier" do + [subject.clear(:all).silent, + subject.clear(:all, silent: true)].each do |example| + expect(example.to_s).to eq "CLEAR SILENT ALL" end end end context "when building CREATE queries" do - it "should require a graph URI" do - graph_uri = 'http://example.org/' - @sparql.create(graph_uri).to_s.should == "CREATE GRAPH <#{graph_uri}>" + let(:graph_uri) {'http://example.org/'} + + it "requires a graph URI" do + expect(subject.create(graph_uri).to_s).to eq "CREATE GRAPH <#{graph_uri}>" end - it "should support the SILENT modifier" do - graph_uri = 'http://example.org/' - [@sparql.create(graph_uri).silent, - @sparql.create(graph_uri, :silent => true)].each do |example| - example.to_s.should == "CREATE SILENT GRAPH <#{graph_uri}>" + it "supports the SILENT modifier" do + [subject.create(graph_uri).silent, + subject.create(graph_uri, silent: true)].each do |example| + expect(example.to_s).to eq "CREATE SILENT GRAPH <#{graph_uri}>" end end + + it "expects statements not results" do + expect(subject.create(graph_uri)).to be_expects_statements + end end context "when building DROP queries" do - it "should support the DROP GRAPH operation" do + it "supports the DROP GRAPH operation" do graph_uri = 'http://example.org/' - [@sparql.drop.graph(graph_uri), - @sparql.drop(:graph, graph_uri)].each do |example| - example.to_s.should == "DROP GRAPH <#{graph_uri}>" + [subject.drop.graph(graph_uri), + subject.drop(:graph, graph_uri)].each do |example| + expect(example.to_s).to eq "DROP GRAPH <#{graph_uri}>" end end - it "should support the DROP DEFAULT operation" do - [@sparql.drop.default, @sparql.drop(:default)].each do |example| - example.to_s.should == "DROP DEFAULT" + it "supports the DROP DEFAULT operation" do + [subject.drop.default, subject.drop(:default)].each do |example| + expect(example.to_s).to eq "DROP DEFAULT" end end - it "should support the DROP NAMED operation" do - [@sparql.drop.named, @sparql.drop(:named)].each do |example| - example.to_s.should == "DROP NAMED" + it "supports the DROP NAMED operation" do + [subject.drop.named, subject.drop(:named)].each do |example| + expect(example.to_s).to eq "DROP NAMED" end end - it "should support the DROP ALL operation" do - [@sparql.drop.all, @sparql.drop(:all)].each do |example| - example.to_s.should == "DROP ALL" + it "supports the DROP ALL operation" do + [subject.drop.all, subject.drop(:all)].each do |example| + expect(example.to_s).to eq "DROP ALL" end end - it "should support the SILENT modifier" do - [@sparql.drop(:all).silent, - @sparql.drop(:all, :silent => true)].each do |example| - example.to_s.should == "DROP SILENT ALL" + it "expects results not statements" do + expect(subject.drop.all).not_to be_expects_statements + end + + it "supports the SILENT modifier" do + [subject.drop(:all).silent, + subject.drop(:all, silent: true)].each do |example| + expect(example.to_s).to eq "DROP SILENT ALL" end end end context "when building COPY queries" do - # TODO + it "should do something" end context "when building MOVE queries" do - # TODO + it "should do something" end context "when building ADD queries" do - # TODO + it "should do something" end end diff --git a/spec/version_spec.rb b/spec/version_spec.rb index d4740e25..d0b72507 100644 --- a/spec/version_spec.rb +++ b/spec/version_spec.rb @@ -1,7 +1,7 @@ -require File.join(File.dirname(__FILE__), 'spec_helper') +require_relative 'spec_helper' describe 'SPARQL::Client::VERSION' do it "matches the VERSION file" do - SPARQL::Client::VERSION.to_s.should == File.read(File.join(File.dirname(__FILE__), '..', 'VERSION')).chomp + expect(SPARQL::Client::VERSION.to_s).to eq File.read(File.join(File.dirname(__FILE__), '..', 'VERSION')).chomp end end