diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..b91ad07 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,39 @@ +module.exports = { + "extends": "standard", + "installedESLint": true, + "plugins": [ + "standard", + "promise" + ], + 'rules': { + 'linebreak-style': [ + 'error', + 'unix' + ], + 'no-unused-vars': [ + 'error', { + 'vars': 'all', + "argsIgnorePattern": '^_', + } + ], + 'semi': [ + 'error', + 'always' + ], + 'space-before-function-paren': [ + 'error', { + 'named': 'never', + 'anonymous': 'always', + 'asyncArrow': 'always' + } + ], + }, + 'globals': { + describe: false, + it: false, + beforeEach: false, + afterEach: false, + expect: false, + fail: false, + } +}; diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..10bd4aa --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: node_js +node_js: + - "0.12" + - "0.11" + - "0.10" + - "6" + - "8" + - "10" + - "iojs" diff --git a/HISTORY.md b/HISTORY.md index 1e232bf..46d4fa8 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,55 @@ +0.6.1 / 2016-11-16 +================== + + * Fixed erroneous IRIREF production [#8](https://github.com/thomasfr/node-sparql-client/issues/6)—thanks [paulwilton](https://github.com/paulwilton)! + +0.6.0 / 2016-04-25 +================== + + * Add `defaultParams` and `requestDefaults` options + +0.5.1 / 2016-04-24 +================== + + * Fixed crash when response contains empty body. + +0.5.0 / 2016-04-24 +================== + + * Added support for alternate query and update endpoints + (specified on the client as `{ updateEndpoint: '...' }` + +0.4.2 / 2016-02-24 +================== + + * Added explicit support for HTTP Error messages. Errors are patched + with `.httpStatus` to indicate status code. + +0.4.0 / 2015-07-01 🍁 +==================== + + * Query mechanism refactored. + * `Query#bind()` is very exception happy method; the opinion of this module is anything that _looks_ unsafe should be dealt with immediately. + * Tried darnedest not to throw weird errors on `execute()` from within the module. + * Better mapping of JavaScript numbers to SPARQL doubles (or `xsd:double`). + * Added `SPARQL` template tag (ECMAScript 2015). + +0.3.0 / 2015-06-20 +================== + + * Fixed [#6](https://github.com/thomasfr/node-sparql-client/issues/6)—thanks [pheyvaer](https://github.com/pheyvaer)! + * Fixed [#11](https://github.com/thomasfr/node-sparql-client/issues/11)—thanks [dkrantsberg](https://github.com/dkrantsberg)! + * Fixed erroneously turning a query (`ASK`, `SELECT`, `CONSTRUCT`, `DESCRIBE`) into an update if `DELETE` or `INSERT DATA` is present _anywhere_ in the query. + * Updated API (breaks backwards-compatibility): + - Add URI prefixes: both globally and per query using `#register()` and `#registerCommon()` + - Escape bindings to _attempt_ to prevent SPARQL injection! + - Apply formatting with `{format: { resource: 'binding_name' } }` instead of `{format: 'resource', resource: 'binding_name'}` + - Multiple binds supported in one call to `#bind()` + - Support binding [SPARQL 1.1 literals](http://www.w3.org/TR/2013/REC-sparql11-query-20130321/#QSynLiterals); breaks backwards compatibility since this is dependent on the type of arguments, whereas before, everything would be silently coerced into a string. + - `#execute` supports Promises/A+ API. + - API has a "fail-fast" attitude and complains bitterly if given suspicious input. + * Added a whackload of Jasmine specs, though by no means does this constitute a comprehensive test suite. + 0.2.0 / 2014-12-09 ================== diff --git a/LICENSE b/LICENSE index c6918f4..864b6e1 100644 --- a/LICENSE +++ b/LICENSE @@ -4,6 +4,7 @@ Copyright (c) 2012 Thomas Fritz Contributors - Martin Franke (@MtnFranke) + - Eddie Antonio Santos (@eddieantonio) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -22,4 +23,4 @@ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 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. \ No newline at end of file +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md index 683b1e7..f7868ce 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,403 @@ sparql-client ============= -A simple sparql client written for [Node.js](http://nodejs.org/) (with compatibility for [Apache Fuseki](http://jena.apache.org/documentation/serving_data/)). +[![Build Status](https://travis-ci.org/eddieantonio/node-sparql-client.svg?branch=master)](https://travis-ci.org/eddieantonio/node-sparql-client) +[![npm Version](https://img.shields.io/npm/v/sparql-client-2.svg)](https://www.npmjs.com/package/sparql-client-2) -Version 0.2.0 +# THIS PACKAGE IS NO LONGER MAINTAINED -Usage -===== +This package is currently unmaintained. If you would like to adopt this +package, feel free to [open an issue](https://github.com/eddieantonio/node-sparql-client/issues/new) and get in touch with me! + +--- + +A SPARQL 1.1 client for JavaScript. -###Querying### ```javascript +const {SparqlClient, SPARQL} = require('sparql-client-2'); +const client = + new SparqlClient('http://dbpedia.org/sparql') + .register({ + db: 'http://dbpedia.org/resource/', + dbo: 'http://dbpedia.org/ontology/', + }); + +function fetchCityLeader(cityName) { + return client + .query(SPARQL` + SELECT ?leaderName + WHERE { + ${{db: cityName}} dbo:leaderName ?leaderName + }`) + .execute() + // Get the item we want. + .then(response => Promise.resolve(response.results.bindings[0].leaderName.value)); +} + +fetchCityLeader('Vienna') + .then(leader => console.log(`${leader} is a leader of Vienna`)); +``` + + +Table of Contents +================= + + * [sparql-client](#sparql-client) + * [Use](#use) + * [Using `SPARQL` Tagged Template and Promises (ECMAScript 2015/ES 6)](#using-sparql-tagged-template-and-promises-ecmascript-2015es-6) + * [Using "Traditional" Node Callbacks](#using-traditional-node-callbacks) + * [Registering URI Prefixes](#registering-uri-prefixes) + * [Registering common prefixes](#registering-common-prefixes) + * [Registering custom prefixes](#registering-custom-prefixes) + * [Binding variables](#binding-variables) + * [Explicitly, using `#bind()`](#explicitly-using-bind) + * [Using the SPARQL template tag](#using-the-sparql-template-tag) + * [Updates](#updates) + * [Specifying a different update endpoint](#specifying-a-different-update-endpoint) + * [Errors](#errors) + * [Result Formatting](#result-formatting) + * [License](#license) + +Use +=== + +## Using `SPARQL` [Tagged Template][TT] and [Promises][] (ECMAScript 2015/ES 6) + +You may use the `SPARQL` template tag to interpolate variables into the +query. All values are automatically converted into their SPARQL literal +form, and any unsafe strings are escaped. + +```javascript +const SparqlClient = require('sparql-client-2'); +const SPARQL = SparqlClient.SPARQL; +const endpoint = 'http://dbpedia.org/sparql'; + +const city = 'Vienna'; -var SparqlClient = require('sparql-client'); -var util = require('util'); -var endpoint = 'http://dbpedia.org/sparql'; +// Get the leaderName(s) of the given city +const query = + SPARQL`PREFIX db: + PREFIX dbpedia: + SELECT ?leaderName + FROM + WHERE { + ${{db: city}} dbpedia:leaderName ?leaderName + } + LIMIT 10`; + +const client = new SparqlClient(endpoint) + .register({db: 'http://dbpedia.org/resource/'}) + .register({dbpedia: 'http://dbpedia.org/property/'}); + +client.query(query) + .execute() + .then(function (results) { + console.dir(results, {depth: null}); + }) + .catch(function (error) { + // Oh noes! 🙀 + }); +``` + +Results in: + +```javascript +{ head: { link: [], vars: [ 'leaderName' ] }, + results: + { distinct: false, + ordered: true, + bindings: + [ { leaderName: { type: 'literal', 'xml:lang': 'en', value: 'Maria Vassilakou ,' } }, + { leaderName: { type: 'literal', 'xml:lang': 'en', value: 'Michael Häupl' } }, + { leaderName: { type: 'literal', 'xml:lang': 'en', value: 'Renate Brauner ;' } } ] } } +``` + +[TT]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/template_strings#Tagged_template_strings +[Promises]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise + +## Using "Traditional" Node Callbacks + +You are not forced to use promises; traditional `(err, results)` +callbacks work too. You may also use `#bind()` to replace `?variables` +in the query with sanitized values: + +```javascript +// Get the leaderName(s) of the 10 cities +var query = "SELECT * FROM WHERE { " + + "?city ?leaderName " + + "} LIMIT 10"; +var client = new SparqlClient( 'http://dbpedia.org/sparql') + .register({db: 'http://dbpedia.org/resource/'}); -// Get the leaderName(s) of the given citys -// if you do not bind any city, it returns 10 random leaderNames -var query = "SELECT * FROM WHERE { - ?city ?leaderName -} LIMIT 10"; -var client = new SparqlClient(endpoint); -console.log("Query to " + endpoint); -console.log("Query: " + query); client.query(query) - //.bind('city', 'db:Chicago') - //.bind('city', 'db:Tokyo') - //.bind('city', 'db:Casablanca') - .bind('city', '') + .bind('city', {db: 'Vienna'}) .execute(function(error, results) { - process.stdout.write(util.inspect(arguments, null, 20, true)+"\n");1 + console.dir(arguments, {depth: null}); }); +``` +## Registering URI Prefixes + +### Registering common prefixes + +Often, SPARQL queries have many prefixes to register. + +Common prefixes include: + +Prefix | URI +-------|---- +`rdf` | +`rdfs` | +`xsd` | +`fn` | +`sfn` | + + +You may register any of the above by passing them to +`#registerCommon()`. This may be done per-query: + +```javascript +new SparqlClient(endpoint) + .query(`SELECT ...`) + .registerCommon('xsd', 'sfn') + // Will have PREFIX xsd and sfn to this query only. + .execute(); +``` + +Or on the client, affecting every subsequent query: + +```javascript +client + .registerCommon('rdfs', 'xsd'); +// Will add prefix rdfs and xsd. +client.query('...').execute(); +``` + +### Registering custom prefixes + +Using `#register()` on either the client or the query, you can register +any arbitrary prefix: + +```javascript +var client = new SparqlClient(endpoint) + // Can register one at a time: + .register('ns', 'http://example.org/ns#') + // Can register in bulk, as an object: + .register({ + db: 'http://dbpedia.org/resource/', + dbpedia: 'http://dbpedia.org/property/' + }) + // Can register a BASE (empty prefix): + .register('http://example.org/books/'); +``` + +## Binding variables + +### Explicitly, using `#bind()` + +It's inadvisable to concatenate strings in order to write a query, +especially if data is coming from untrusted sources. `#bind()` allows +you to pass values to queries that will be converted into a safe SPARQL +term. + +Say you have a statement like this: + +```javascript +var text = 'INSERT DATA {' + + ' [] rdfs:label ?creature ;' + + ' dbfr:paws ?paws ;' + + ' dbfr:netWorth ?netWorth ;' + + ' dbfr:weight ?weight ;' + + ' dbfr:grumpy ?grumpy ;' + + ' dbfr:derrivedFrom ?derrivedFrom .' + + '}'; ``` -###Formatting### +Each of the `?questionMarked` fields can be bound to JavaScript +values while querying using `#bind()`: -From version 0.2.0 it is possible to add options regarding the formating of the results. -For example, we execute the following query (to retrieve all books and their genres). +```javascript +client.query(text) + // Bind one at a time... + .bind('grumpy', true) + // Use a third argument to provide options. + .bind('derrivedFrom', 'http://fr.wikipedia.org/wiki/Grumpy_Cat?oldid=94581698', {type:'uri'}) + // Or bind multiple values at once using an object: + .bind({ + creature: {value: 'chat', lang: 'fr'}, + paws: {value: 4, type: 'integer'}, + netWorth: {value: '16777216.25', type: 'decimal'}, // francs + weight: 3.18, // kg + }); ``` + +### Using the `SPARQL` template tag + +Any value that can be bound using `#bind()` can equally be interpolated +using the `SPARQL` template tag: URIs, strings, booleans, language +tagged strings, doubles, literals with custom types­anything! Note the +doubled curly-braces (`${{value: ...}}`) when passing an object. + +```javascript +var text = SPARQL` + INSERT DATA { + ${{dc: 'eddieantonio'}} ns:favouriteGame ${{db: 'Super_Metroid'}} ; + rdfs:label ${'@eddieantonio'} ; + ns:prettyCheekyM8 ${true} ; + rdfs:label ${{value: 'エディ', lang: 'jp'}} ; + ns:favoriteConstant ${Math.PI} ; + ns:favoriteColor ${{value: 'blue', datatype: {ns: 'Color'}}} . + }`; +``` + +Then `text` would be the string: + +``` + INSERT DATA { + dc:eddieantonio ns:favouriteGame db:Super_Metroid ; + rdfs:label '@eddieantonio' ; + ns:prettyCheekyM8 true ; + rdfs:label 'エディ'@jp ; + ns:favoriteConstant 3.141592653589793e0 ; + ns:favoriteColor 'blue'^^ns:Color . + } +``` + +## Updates + +There's no need to specify anything special; `LOAD`, `CLEAR`, `DROP`, +`ADD`, `MOVE`, `COPY`, `INSERT DATA`, and `DELETE DATA` are +automatically requested as updates. Just write these statements like any +other: + +```javascript +new SparqlClient(endpoint).query(SPARQL` + INSERT DATA { + ${{pkmn: 'Lotad'}} pkdx:evolvesTo ${{pkmn: 'Lombre'}} + ${{pkmn: 'Lombre'}} pkdx:evolvesTo ${{pkmn: 'Ludicolo'}} + }`) + .execute(); +``` + +### Specifying a different update endpoint + +Some servers have different endpoints for queries and updates. Specify +the alternate options when starting the client: + +```javascript +var client = new SparqlClient('http://example.org/query', { + updateEndpoint: 'http://example.org/update' +}); +``` + +You may use the client subsequently: + +```javascript +// Will be sent to http://example.org/update +client.query(SPARQL` + INSERT DATA { + ${{pkmn: 'Lotad'}} pkdx:evolvesTo ${{pkmn: 'Lombre'}} + ${{pkmn: 'Lombre'}} pkdx:evolvesTo ${{pkmn: 'Ludicolo'}} + }`) + .execute(); + +// Will be sent to http://example.org/query +client.query(SPARQL` + SELECT { + ${{pkmn: 'Lombre'}} pkdx:evolvesTo ?evolution + }`) + .execute() + .then(response => { + // Prints Ludicolo + console.log(response.results.bindings[0].evolution.value) + }); +``` + +### Overriding request defaults + +You can override the request defaults by passing them in the options +object of the constructor. `defaultParams` are the default parameters in +the request, and `requestDefaults` are the default _request options_. +This distinction is a little confusing, so here are some examples: + +For example, say you have a graph database that expects `format: 'json'` +as a param rather than the default `format: +'application/sparql-results+json'`. You can override the default when +constructing your client like so: + +```js +var client = new SparqlClient('http://example.org/query', { + defaultParameters: { + format: 'json' + } +}); +``` + +Similarly, let's say you want to specify your client's user agent +string. You can pass this, and other headers, as part of +a `requestDefaults` option. + +```js +var client = new SparqlClient('http://example.org/query', { + requestDefaults: { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/sparql-results+json,application/json', + 'User-Agent': 'My Totally Sweet App - 1.0' + } + } +}); +``` + +## Errors + +If an error occurs, such as when submitting a query with a syntax error, +the first argument to `execute()` will be an `Error` object and have +the `.httpStatus` attribute with the associated HTTP status code. +Usually this is `400` when there is a syntax error, or `500` when the +server refuses to process the request (such as when a timeout occurs). +This status code is defined by the particular SPARQL server used. + +```javascript +new SparqlClient(endpoint).query(` + SELECT ?name + WHERE { ?x foaf:name ?name + ORDER BY ?name + `) + .execute(function (err, data) { + console.log(err.httpStatus); + // logs '400' + console.log(err); + // logs 'HTTP Error: 400 Bad Request' + }); +``` + +This also works with promises: + +```javascript +new SparqlClient(endpoint).query(` + SELECT ?name + WHERE { ?x foaf:name ?name + ORDER BY ?name + `) + .execute() + .then(function () { + // will never reach here! + }) + .catch(function (err) { + console.log(err.httpStatus); + // logs '400' + console.log(err); + // logs 'HTTP Error: 400 Bad Request' + }); +``` + +## Result Formatting + +We may want to execute the following query (to retrieve all books and +their genres). + +```sparql PREFIX dbpedia-owl: SELECT ?book ?genre WHERE { ?book dbpedia-owl:literaryGenre ?genre @@ -47,50 +406,68 @@ SELECT ?book ?genre WHERE { The *default* formatting (when no options are provided) results, for the bindings (limited to two results in our example), in ```javascript -[{ book : - { - type: 'uri', - value: 'http://live.dbpedia.org/page/A_Game_of_Thrones' +[ + { + book: { + type: 'uri', + value: 'http://dbpedia.org/resource/A_Game_of_Thrones' }, - genre : { - type: 'uri', - value: 'http://live.dbpedia.org/page/Fantasy' + genre: { + type: 'uri', + value: 'http://dbpedia.org/resource/Fantasy' } -}, { book : - { - type: 'uri', - value: 'http://live.dbpedia.org/page/A_Game_of_Thrones' + }, + { + book: { + type: 'uri', + value: 'http://dbpedia.org/resource/A_Game_of_Thrones' }, - genre : { - type: 'uri', - value: 'http://live.dbpedia.org/page/Political_strategy' + genre: { + type: 'uri', + value: 'http://dbpedia.org/resource/Political_strategy' } -}] + } +] ``` -Using the format option *resource* with the resource option set to *book* results in + +Using the format option *resource* with the resource option set to +*book* like so: ```javascript -[{ book : - { - type: 'uri', - value: 'http://live.dbpedia.org/page/A_Game_of_Thrones' +query.execute({format: {resource: 'book'}}, function(error, results) { + // ... +}); +``` + +Results in: + +```javascript +[ + { + book: { + type: 'uri', + value: 'http://dbpedia.org/resource/A_Game_of_Thrones' }, - genre : [{ + genre: [ + { type: 'uri', - value: 'http://live.dbpedia.org/page/Fantasy' - }, { + value: 'http://dbpedia.org/resource/Fantasy' + }, + { type: 'uri', - value: 'http://live.dbpedia.org/page/Political_strategy' - }] -}] + value: 'http://dbpedia.org/resource/Political_strategy' + } + ] + } +] ``` This makes it easier to process the results later (in the callback), because all the genres are connected to one book (in one binding), and not spread over several bindings. Calling the *execute* function will look something like this ```javascript -execute({format: 'resource', resource: 'book'}, function(error, results) { - process.stdout.write(util.inspect(arguments, null, 20, true)+"\n"); +query.execute({format: {resource: 'book'}}, function(error, results) { + console.dir(arguments, {depth: null}); }); ``` @@ -98,12 +475,14 @@ License ======= The MIT License -Copyright © 2014 Thomas Fritz +Copyright © 2014 Thomas Fritz +
Copyright © 2015, 2016 Eddie Antonio Santos Contributors - Martin Franke (@MtnFranke) - Pieter Heyvaert ([@PHaDventure](https://twitter.com/PHaDventure)) +- Eddie Antonio Santos ([@eddieantonio](http://eddieantonio.ca/)) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/index.js b/index.js index e1250b0..cc2d559 100644 --- a/index.js +++ b/index.js @@ -1 +1,8 @@ -module.exports = require('./lib/client'); +var SparqlClient = module.exports = require('./lib/client'); + +/* + * Create an alias to itself so that CoffeeScript and Harmony users can: + * {SparqlClient} = require('sparql-client'); + */ +SparqlClient.SparqlClient = SparqlClient; +SparqlClient.SPARQL = require('./lib/sparql-tag'); diff --git a/lib/client.js b/lib/client.js index 0e2b5b1..d7ac24e 100644 --- a/lib/client.js +++ b/lib/client.js @@ -1,161 +1,125 @@ -var request = require('request'); +var http = require('http'); var querystring = require('querystring'); -var _ = require('lodash'); -var formatter = require('./formatter.js'); -var SparqlClient = module.exports = function (endpoint, options) { - var slice = Array.prototype.slice; +var request = require('request'); +var denodeify = require('denodeify'); + +var defaults = require('lodash/defaults'); +var assign = require('lodash/assign'); + +var Query = require('./query'); +var formatter = require('./formatter'); + + +/** + * The main client class. + */ +var SparqlClient = module.exports = function SparqlClient(endpoint, options) { var requestDefaults = { url: endpoint, method: 'POST', encoding: 'utf8', headers: { 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/sparql-results+json' + 'Accept': 'application/sparql-results+json,application/json', + 'User-Agent': 'node-sparql-client/' + require('../package.json').version } }; var defaultParameters = { format: 'application/sparql-results+json', 'content-type': 'application/sparql-results+json' }; - var doRequest = request.defaults(requestDefaults); - var emptyFn = function emptyFn() { - }; + if (options && options.requestDefaults) { + assign(requestDefaults, options.requestDefaults); + } + if (options && options.defaultParameters) { + assign(defaultParameters, options.defaultParameters); + } - var that = this; + var doRequest = denodeify(request.defaults(requestDefaults)); - var nextTick = function nextTick(callback, args, scope) { - scope = scope || this; - return function nextTickCallback() { - process.nextTick(function nextTickWrapper() { - callback.apply(scope, args); - }); - return scope; - } - }; + var updateEndpoint = endpoint; + if (options && options.updateEndpoint) { + updateEndpoint = options.updateEndpoint; + delete options.updateEndpoint; + } - var responseHandler = function responseHandler(error, response, responseBody, callback) { - var continuation = emptyFn; - if (error || response.statusCode >= 300) { - var err; - - if (error && error.code == "ECONNREFUSED") { - err = "Could not connect to SPARQL endpoint."; - } - else { - err = "SparQL query failed."; - } - - continuation = nextTick(callback, [ - new Error(err), - null - ], that); - } - else { - try { - responseBody = JSON.parse(responseBody); - } - catch (e) { - continuation = nextTick(callback, [ - new Error("Could not parse responseBody."), - null - ], that); - } - - /* if (this.currentOptions && this.currentOptions.structure == 'default') { - responseBody = structureResources(responseBody); - } - */ - - if (this.currentOptions) { - formatter.format(responseBody, this.currentOptions); - } - - continuation = nextTick(callback, [ - null, - responseBody - ]); - } - return continuation(); - }; + var that = this; - var sparqlRequest = function sparqlRequest(query, callback) { - if (query.indexOf("INSERT DATA") == -1 && query.indexOf("DELETE") == -1) { - var requestBody = _.extend(defaultParameters, { - query: query - }); - delete defaultParameters.update; - } - else { - var requestBody = _.extend(defaultParameters, { - update: query + var sparqlRequest = function sparqlRequest(query, queryOptions) { + var requestOptions = + (query.isUpdate) ? + { form: { update: query.text }, url: updateEndpoint } : + { form: { query: query.text } }; + defaults(requestOptions.form, defaultParameters); + + return doRequest(requestOptions) + .then(function (response) { + var responseBody, error; + if (response.statusCode >= 300) { + error = new Error(formatErrorMessage(response)); + /* Patch .httpStatus onto the object. */ + error.httpStatus = response.statusCode; + + throw error; + } + + // According to the spec, the response can have no content; + // (hence, parsing JSON would just crash here). + // https://www.w3.org/TR/2013/REC-sparql11-http-rdf-update-20130321/#http-post + responseBody = maybeParseJSON(response.body); + + if (queryOptions) { + formatter.format(responseBody, queryOptions); + } + return responseBody; + + function maybeParseJSON(body) { + // Catch invalid JSON + try { + return JSON.parse(body); + } catch (ex) { + return null; + } + } + + function formatErrorMessage(res) { + var code = res.statusCode; + var statusMessage = res.statusMessage || + http.STATUS_CODES[code]; + + return 'HTTP Error: ' + code + ' ' + statusMessage; + } + }) + .catch(function (error) { + if (error.code === "ECONNREFUSED") { + throw new Error("Could not connect to SPARQL endpoint."); + } + /* Rethrow the raw error. */ + throw error; }); - delete defaultParameters.query; - } - - var opts = { - body: querystring.stringify(requestBody) - }; - doRequest(opts, function requestCallback() { - var args = slice.call(arguments, 0); - - //if an error occurs only the error is provided, with the reponse and reponsebody - //so we need to add 2 dummy arguments 'null', so that the callback is the 4th - //argument of the responseHandler. - if (args.length == 1) { - args = args.concat([ - null, - null - ]); - } - - responseHandler.apply(that, args.concat(callback)); - }); }; this.defaultParameters = defaultParameters; - this.requestDefaults = _.extend(requestDefaults, options); - this.request = request; + this.requestDefaults = assign(requestDefaults, options); this.sparqlRequest = sparqlRequest; - this.currentQuery = null; - this.currentOptions = null; -}; - -SparqlClient.prototype.query = function query(query, callback) { - if (callback) { - this.sparqlRequest(query, callback); - return this; - } - else { - this.currentQuery = query; - return this; - } -}; -SparqlClient.prototype.bind = function (placeholder, value) { - var query = this.currentQuery; - var pattern = new RegExp('\\?' + placeholder + '[\\s\\n\\r\\t]+', 'g'); - query = query.replace(pattern, value + " "); - this.currentQuery = query; - return this; + /* PREFIX xyz: <...> and BASE <...> stuff: */ + this.prefixes = Object.create(null); }; -SparqlClient.prototype.execute = function () { - var callback; +/* SparqlClient uses #register() and #registerCommon. */ +SparqlClient.prototype = Object.create(require('./registerable')); - if (arguments.length == 0 || arguments.length > 2) { - throw "Wrong number of arguments used."; - } +SparqlClient.prototype.query = function query(userQuery, callback) { + var statement = new Query(this, userQuery, { + prefixes: this.prefixes + }); - if (arguments.length == 1) { - callback = arguments[0]; - } - else { - callback = arguments[1]; - this.currentOptions = arguments[0]; + if (callback) { + return statement.execute(callback); + } else { + return statement; } - - this.sparqlRequest(this.currentQuery, callback); - return this; }; diff --git a/lib/formatter.js b/lib/formatter.js index 1e92eb7..210ec48 100644 --- a/lib/formatter.js +++ b/lib/formatter.js @@ -1,11 +1,7 @@ //this method formats the reponse (in place), using @options var format = function format(response, options) { - if (options.format == "resource") { - if (!options.resource) { - throw 'Formatting using "resource" failed, because the variable of the resource was not specified.'; - } - - formatUsingResource(response, options.resource); + if (options.format && options.format.resource) { + formatUsingResource(response, options.format.resource); } }; @@ -19,7 +15,7 @@ var formatUsingResource = function format(response, resourceVar) { var index = indexResource(resourceVar, b, results); if (index > -1) { //already found before - results[index] = mergeResourceBinding(results[index], b, nonResourceVars) + results[index] = mergeResourceBinding(results[index], b, nonResourceVars); } else { //first time with this base value diff --git a/lib/iri.js b/lib/iri.js new file mode 100644 index 0000000..52ae1ef --- /dev/null +++ b/lib/iri.js @@ -0,0 +1,106 @@ +/** + * An IRI is like a URI but forbids spaces. + */ + +var Term = require('./term'); + +module.exports = IRI; + +/** + * Base IRI. + */ +function IRI() { + Term.call(this); +} + +IRI.prototype = Object.create(Term.prototype, { + type: { value: 'uri', enumerable: true } +}); + +/** + * Returns an IRI for whatever is passed in. + */ +IRI.create = function (value) { + if (typeof value === 'object') { + return IRI.createFromObject(value); + } else if (typeof value === 'string') { + return new IRIReference(value); + } else { + throw new TypeError('Invalid IRI'); + } +}; + +/** + * Returns an IRI object or null if none can be created. + */ +IRI.createFromObject = function (object) { + var namespace; + var value; + var keys = Object.keys(object); + if (keys.length !== 1) { + throw new Error('Invalid prefixed IRI.'); + } + + namespace = keys[0]; + value = object[namespace]; + + if (typeof value !== 'string') { + throw new TypeError('Invalid prefixed IRI.'); + } + + /* TODO: This is NOT a sufficient regex! */ + if (!/^[^\s;.,<|$]+$/.test(value)) { + throw new Error('Invalid IRI identifier'); + } + + return new PrefixedNameIRI(namespace, value); +}; + +/** + * A Prefixed Name like: + * book:book1 + * or + * :book1 + */ +function PrefixedNameIRI(namespace, identifier) { + IRI.call(this); + this.namespace = namespace; + this.id = identifier; +} + +PrefixedNameIRI.prototype = Object.create(IRI.prototype, { + value: { + get: function () { return 'INVALID!' + this.format(); }, + enumerable: true + } +}); + +PrefixedNameIRI.prototype.format = function () { + return this.namespace + ':' + this.id; +}; + +/** + * An IRI reference like: + * + * or . + */ +function IRIReference(iri) { + IRI.call(this); + /* + * IRIREF is defined here: + * http://www.w3.org/TR/2013/REC-sparql11-query-20130321/#rIRIREF + */ + if (!/^[^<>"{}|^`\\\u0000-\u0020]*$/.test(iri)) { + throw new Error('Invalid IRI: ' + iri); + } + this.iri = iri; +} + +IRIReference.prototype = Object.create(IRI.prototype, { + value: { get: function () { return this.iri; }, enumerable: true } +}); + +IRIReference.prototype.format = function () { + return '<' + this.iri + '>'; +}; + diff --git a/lib/query.js b/lib/query.js new file mode 100644 index 0000000..80a84ae --- /dev/null +++ b/lib/query.js @@ -0,0 +1,169 @@ +/** + * Query class. + */ + +var assert = require('assert'); + +var assign = require('lodash/assign'); +var cloneDeep = require('lodash/cloneDeep'); +var forEach = require('lodash/forEach'); +var transform = require('lodash/transform'); +var nodeify = require('promise-nodeify'); + +var Term = require('./term'); +var IRI = require('./iri'); + +module.exports = Query; + +function Query(client, text, options) { + this.client = client; + this.originalText = text; + + /* Inherit prefixes from the parent. */ + this.prefixes = cloneDeep(options.prefixes); + + /* Create an empty set of bindings! */ + this.bindings = Object.create(null); +} + +/* Query uses #register() and #registerCommon. */ +Query.prototype = Object.create(require('./registerable')); + +Query.prototype.bind = function (subject, predicate, options) { + if (arguments.length === 1) { + assign(this.bindings, prepareBindings(subject)); + } else if (arguments.length <= 3) { + this.bindings[subject] = prepareBinding(predicate, options); + } else { + throw new Error('Invalid invocation for #bind()'); + } + return this; +}; + +Query.prototype.execute = function () { + var callback, options, query, preamble, body; + + if (arguments.length === 1) { + if (typeof arguments[0] === 'function') { + callback = arguments[0]; + } else { + options = arguments[0]; + } + } else if (arguments.length === 2) { + options = arguments[0]; + callback = arguments[1]; + } else if (arguments.length > 2) { + throw new Error("Wrong number of arguments used."); + } + + preamble = makePreamble(this.prefixes); + body = formatQuery(this.originalText, this.bindings); + query = { + text: (preamble) ? preamble + '\n' + body : body, + isUpdate: statementIsUpdate(body) + }; + + return nodeify(this.client.sparqlRequest(query, options), callback); +}; + +/* Helpers. */ + +function makePreamble(prefixes) { + var preamble = ''; + + /* Adds `BASE ` */ + if (prefixes['']) { + preamble += 'BASE <' + prefixes[''] + '>\n'; + } + + /* Note: Assuming the prototype chain does NOT contain Object.prototype, + * and hence all enumerable properties (things for-in will loop over) are + * actual prefixes. */ + forEach(prefixes, function (uri, prefix) { + if (prefix === '') { + return; + } + + preamble += 'PREFIX ' + prefix + ': <' + prefixes[prefix] + '>\n'; + }); + + return preamble; +} + +function formatQuery(query, bindings) { + if (Object.keys(bindings).length < 1) { + /* No bindings were created! */ + return query; + } + + var pattern = createPlaceholderRegex(bindings); + + return query.replace(pattern, function (str, name) { + var binding = bindings[name]; + return formatBinding(binding); + }); +} + +function createPlaceholderRegex(bindings) { + var names = Object.keys(bindings); + var alternatives = names.map(escapeRegExp).join('|'); + return new RegExp('\\?(' + alternatives + ')\\b', 'g'); +} + +/** + * Formats the value for printing. + */ +function formatBinding(binding) { + assert(binding.formatted !== undefined); + return binding.formatted; +} +/** + * See "The Long Answer" there: + * http://stackoverflow.com/a/6969486 + */ +function escapeRegExp(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); +} + +/** + * Returns a term. Throws if the term is invalid in some form or another. + */ +function prepareBinding(value, options) { + var term = Term.create(value, options); + /* Format RIGHT NOW so that queries fail during execute. */ + return {original: term, formatted: term.format()}; +} + +/** + * Creates multiple terms. + */ +function prepareBindings(bindings) { + return transform(bindings, function (results, value, key) { + results[key] = prepareBinding(value); + }); +} + +/** + * Does a rough parse of the statement to determine if it's a query or an + * update. SPARQL endpoints care about this because... they do. + * + * See: + * http://www.w3.org/TR/2013/REC-sparql11-protocol-20130321/#update-operation + */ +function statementIsUpdate(text) { + /* Regex derived using info from: + * http://www.w3.org/TR/sparql11-query/#rQueryUnit */ + var pattern = /^(?:\s*(?:PREFIX|BASE)[^<]+<[^>]+>)*\s*(?!PREFIX|BASE)(\w+)/i; + var update = { + LOAD:1, CLEAR:1, DROP:1, CREATE:1, ADD:1, MOVE: 1, COPY:1, + INSERT:1, DELETE:1, WITH:1 + }; + + var match = pattern.exec(text); + if (!match) { + throw new Error('Malformed query: ' + text); + } + var keyword = match[1].toUpperCase(); + + return keyword in update; +} diff --git a/lib/registerable.js b/lib/registerable.js new file mode 100644 index 0000000..ebf7dda --- /dev/null +++ b/lib/registerable.js @@ -0,0 +1,99 @@ +/** + * The methods in Registerable should sit somewhere in the prototype chain by + * anything that needs the methods: + * + * #regsiter() + * #registerCommon() + * + * Note: the constructor of any object MUST declare `this.prefixes` as an + * object. + */ + +var forEach = require('lodash/forEach'); +var assert = require('assert'); + +/** + * From: http://www.w3.org/TR/2013/REC-sparql11-query-20130321/#docNamespaces + */ +var COMMON_PREFIXES = { + rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + rdfs: 'http://www.w3.org/2000/01/rdf-schema#', + xsd: 'http://www.w3.org/2001/XMLSchema#', + fn: 'http://www.w3.org/2005/xpath-functions#', + sfn: 'http://www.w3.org/ns/sparql#' +}; + +var RegisterablePrototype = module.exports = { + register: fluent(function register(subject, predicate) { + assert(typeof this.prefixes === 'object' && this.prefixes !== null); + + if (arguments.length === 1) { + switch(typeof subject) { + case 'string': + /* Set the base. */ + return addPrefix(this.prefixes, '', subject); + case 'object': + /* Add several prefixes. */ + return addPrefixes(this.prefixes, subject); + } + } else if (arguments.length === 2) { + /* Add a single prefix. */ + return addPrefix(this.prefixes, subject, predicate); + } + + throw new Error('Invalid arguments for #register()'); + }), + + registerCommon: fluent(function () { + assert(typeof this.prefixes === 'object' && this.prefixes !== null); + + /* Add ALL the prefixes. */ + if (arguments.length === 0) { + return addPrefixes(this.prefixes, COMMON_PREFIXES); + } + + var prefixes = {}; + for (var i in arguments) { + var prefix = arguments[i]; + var uri = COMMON_PREFIXES[prefix]; + if (prefix === undefined) { + throw new Error('`' + prefix + '` is not a known prefix.'); + } + prefixes[prefix] = uri; + } + + addPrefixes(this.prefixes, prefixes); + }) +}; + +/* Helpers. */ + +function addPrefix(current, prefix, uri) { + current[prefix] = ensureSafeURI(uri); +} + +function addPrefixes(current, newPrefixes) { + forEach(newPrefixes, function (uri, prefix) { + addPrefix(current, prefix, uri); + }); +} + +/** + * Throws an error when the URI is... uncouth. Otherwise, return it + * untouched. + */ +function ensureSafeURI(uri) { + if (uri.match(/[\u0000\s>]/)) { + throw new Error('Refusing to add prefix with suspicious URI.'); + } + return uri; +} + +/* Wraps a method, making it fluent (i.e., it returns `this`). */ +function fluent(method) { + return function () { + var result = method.apply(this, arguments); + assert(result === undefined); + return this; + }; +} diff --git a/lib/sparql-tag.js b/lib/sparql-tag.js new file mode 100644 index 0000000..0abf0d4 --- /dev/null +++ b/lib/sparql-tag.js @@ -0,0 +1,19 @@ +/** + * Implements an ECMAScript 2015 template tag in ECMAScript 5... + */ +var Term = require('./term'); + +module.exports = SPARQL; + +function SPARQL(template) { + // This would be easier in ES6: + // function SPARQL(template, ...subsitutions) { ... } + var substitutions = [].slice.call(arguments, 1); + var result = template[0]; + + substitutions.forEach(function (value, i) { + result += Term.create(value).format() + template[i + 1]; + }); + + return result; +} diff --git a/lib/term/blank-node.js b/lib/term/blank-node.js new file mode 100644 index 0000000..9716c94 --- /dev/null +++ b/lib/term/blank-node.js @@ -0,0 +1,16 @@ +/** + * TODO + */ +module.exports = BlankNode; + +var Term = require('../term'); + +function BlankNode(identifier) { + if (identifier === undefined || identifier === null) { + // TODO: random node name. + } +} + +BlankNode.prototype = Object.create(Term.prototype, { + type: { value: 'bnode', enumerable: true } +}); diff --git a/lib/term/index.js b/lib/term/index.js new file mode 100644 index 0000000..fcaf6d2 --- /dev/null +++ b/lib/term/index.js @@ -0,0 +1,165 @@ +/** + * Base class for representing RDF Terms. + * + * Terms are required to provide a #format() method. + * + * http://www.w3.org/TR/2013/REC-sparql11-query-20130321/#sparqlBasicTerms + */ + +var assign = require('lodash/assign'); +var assert = require('assert'); + +module.exports = Term; +/* Ensure the following are required *after* module.exports assignment due to + * circular dependency to Term.prototype. */ +var IRI = require('./iri'); +var Literal = require('./literal'); +var BlankNode = require('./blank-node'); + +/** + * XSD datatypes. SPARQL has literals for these. + */ +var KNOWN_DATATYPES = { + boolean: 1, decimal: 1, double: 1, integer: 1 +}; + + +/** + * An RDF Term. IRIs, blank nodes, and literals are all RDF terms. + */ +function Term() { +} + +/** + * Returns a term suitable for replacement in a SPARQL query. + */ +Term.prototype.format = function dummyFormat() { + assert(false, 'term MUST implement a #format method!'); +}; + +Term.prototype.toString = function () { + return JSON.stringify(this, 'type value datatype xml:lang'.split(/\s+/)); +}; + +/** + * Creates a term from an arbitrary value, and options, if any. + * Valid options: + * + * - lang: Sets the language tag for the value. + * Relevant only if value is a string. + * - xml:lang: Same as lang. + * - datatype: Sets the datatype for a literal. + * - type: Can be an SPARQL term type 'literal', 'uri', 'bnode'; + * the value will be interpreted as in the SPARQL spec. + * Additionally, can be 'integer', 'double', 'decimal'. + */ +Term.create = function create(value, options) { + if (options) { + return createTerm(assign({}, options, {value: value})); + } + return createTerm(value); +}; + +/* Helpers. */ + +function createTerm(value) { + var type = determineType(value); + + switch (type) { + case 'string': + return Literal.create(value); + case 'number': + /* + 'e0' to look like a SPARQL double literal. */ + return Literal.createWithDataType(value, {xsd: 'double'}); + case 'boolean': + return Literal.createWithDataType(value, {xsd: 'boolean'}); + case 'object': + return createTermFromObject(value); + } + + throw new TypeError('Cannot bind ' + type + ' value: ' + value); +} + +function createTermFromObject(object) { + var value, type; + + /* Check if it's a short URI object. */ + if (Object.keys(object).length === 1) { + return IRI.createFromObject(object); + } + + value = object.value; + + if (value === undefined) { + throw new Error('Binding must contain property called `value`. ' + + "If you're trying to bind a URI, do so explicitly by " + + "writing {value: {prefix: name}, type: 'uri', ...} " + + "rather than {prefix: name, ...}"); + } + + resolveDataTypeShortcuts(object); + + type = determineType(value); + switch (true) { + case object.type === 'uri': + return IRI.create(value); + case object.lang !== undefined: + return Literal.createWithLangaugeTag(value, object.lang); + case object['xml:lang'] !== undefined: + return Literal.createWithLangaugeTag(value, object['xml:lang']); + case object.datatype !== undefined: + return Literal.createWithDataType(value, object.datatype); + } + throw new Error('Could not bind object: ' + + require('util').inspect(object)); +} + +/** + * The value `type` can be one of the XSD types, but this is just a shortcut + * for {type: 'literal', datatype: givenType}. + * + * This patches the object, such that type is moved to + */ +function resolveDataTypeShortcuts(object) { + var TYPES = { + bnode: 1, literal: 1, uri: 1 + }; + var datatype, type = object.type; + + if (type === undefined || type in TYPES) { + /* Nothing to resolve. */ + return object; + } + + if (type in KNOWN_DATATYPES) { + datatype = {xsd: type}; + } else { + datatype = type; + } + + object.type = 'literal'; + object.datatype = datatype; + + return object; +} + +/** + * Returns a string of: + * * 'null' = With type: 'bnode' is a blank node + * * 'undefined' + * * 'number' => An xsd:double; can be coreced with 'type' to + * xsd:integer or xsd:decimal. + * * 'boolean' => An xsd:boolean + * * 'string' => A plain literal; can add an xml:lang property + * with type 'uri', is considered a fully-qualified IRI. + * * 'object' => If length 1, a URI. Else, must contain 'value' and pass + * rest of the properties as options. + * * 'function' + */ +function determineType(unknown) { + var value = (unknown === null || unknown === undefined) ? + unknown : + unknown.valueOf(); + + return (value === null) ? 'null' : typeof value; +} diff --git a/lib/term/iri.js b/lib/term/iri.js new file mode 100644 index 0000000..bf0769b --- /dev/null +++ b/lib/term/iri.js @@ -0,0 +1,5 @@ +/** + * "Symlink" to the real IRI module, but uses Node's require mechanism + * instead. + */ +module.exports = require('../iri'); diff --git a/lib/term/literal.js b/lib/term/literal.js new file mode 100644 index 0000000..2be27de --- /dev/null +++ b/lib/term/literal.js @@ -0,0 +1,239 @@ +/** + * Literal RDF terms. Strings and other primitive datatypes. + */ + +module.exports = Literal; + +var assert = require('assert'); + +var Term = require('../term'); +var IRI = require('./iri'); + +var SPARQL_LITERAL_PATTERNS = { + boolean: /true|false/, + integer: /^[-+]?[0-9]+$/, + double: /^[-+]?(?:[0-9]+.[0-9]*|.[0-9]+|[0-9]+)[eE][+-]?[0-9]+$/, + decimal: /^[-+]?[0-9]*.[0-9]+$/ +}; + +function Literal(value, datatype) { + this.value = assertSafeString(''+value); + if (datatype !== undefined) { + try { + this.datatype = IRI.create(datatype); + } catch (e) { + // TODO: Ensure we're getting the right error. + throw new Error('Datatype must be string or single-valued ' + + 'object. Got ' + datatype + ' instead'); + } + } +} + +Literal.prototype = Object.create(Term.prototype, { + type: { value: 'literal', enumerable: true } +}); + +Literal.prototype.format = function () { + var term; + + if (knownDatatype(this.datatype)) { + term = tryFormatType(this.value, this.datatype.id); + if (term !== undefined) { + return term.asString ? + formatStringWithDataType(term.literal, this.datatype) : + term.literal; + } + } + + return formatStringWithDataType(this.value, this.datatype); +}; + +/** + * Creates a literal with no datatype. + */ +Literal.create = function (value) { + return new StringLiteral(value); +}; + +/** + * Creates a literal with an explicit language tag. + */ +Literal.createWithLangaugeTag = function (value, languageTag) { + if (typeof languageTag !== 'string') { + throw new TypeError('Term as written must specify a language tag.'); + } + return new StringLiteral(value, languageTag); +}; + +/** + * Creates a literal with an explicit datatype. + */ +Literal.createWithDataType = function (value, datatype) { + if (datatype === undefined) { + throw new TypeError('Undefined datatype provided.'); + } + return new Literal(value, datatype); +}; + + +/** + * Ensures U+0000 is not in the string. + */ +function assertSafeString(value) { + if (/\u0000/.test(value)) { + throw new Error('Refusing to encode string with null-character'); + } + return value; +} + +/** + * Escapes all special characters in a string. + */ +var escapeCharacterMapping = { + '\t': 't', + '\n': 'n', + '\r': 'r', + '\b': 'b', + '\f': 'f' +}; +function escapeString(str) { + /* From: http://www.w3.org/TR/2013/REC-sparql11-query-20130321/#grammarEscapes */ + var escapableCodePoints = /[\\'"\t\n\r\b\f]/g; + return str.replace(escapableCodePoints, function (character) { + character = escapeCharacterMapping[character] || character; + return '\\' + character; + }); +} + +/** + * Format the string part of a string. + */ +function formatString(value) { + var stringified = ''+value; + var escaped = escapeString(stringified); + var hasSingleQuote = /'/.test(stringified); + var hasDoubleQuote = /"/.test(stringified); + var hasNewline = /\n/.test(stringified); + + var delimiter; + + if (hasNewline || (hasSingleQuote && hasDoubleQuote)) { + delimiter = '"""'; + } else if (hasSingleQuote) { + delimiter = '"'; + } else { + delimiter = "'"; + } + + assert(!(new RegExp('(?!\\\\)' + delimiter).test(escaped)), + 'found `' + delimiter + '` in `' + escaped + '`' + ); + return delimiter + escaped + delimiter; +} + +/** + * + */ +function formatStringWithDataType(value, datatype) { + var term = formatString(value); + + if (datatype !== undefined) { + return term + '^^' + datatype.format(); + } + return term; +} + +function knownDatatype(iri) { + if (!iri || iri.namespace !== 'xsd') { + return false; + } + + return true; +} + +/** + * Returns formatted value of built in xsd types. Returns undefined if the + * given value does not match the pattern. + */ +function tryFormatType(value, type) { + var stringifiedValue = '' + value; + assert(SPARQL_LITERAL_PATTERNS[type] !== undefined); + + if (type === 'double') { + return tryFormatDouble(value); + } + + if (SPARQL_LITERAL_PATTERNS[type].test(stringifiedValue)) { + return {literal: stringifiedValue}; + } +} + +/** + * Tries to coerce the given value into looking like a SPARQL double literal. + * Returns the original value if it fails. + * + * Although not SPARQL string literals, the special values are converted into + * their XSD equivalents[1]: + * + * JS xsd + * ======================== + * NaN => NaN + * Infinity => INF + * -Infinity => -INF + * + * [1]: http://www.w3.org/TR/xmlschema-2/#double-lexical-representation + */ +function tryFormatDouble(value) { + var pattern = SPARQL_LITERAL_PATTERNS.double; + var stringified = '' + value; + + /* Special cases for +/-Infinity: */ + if (Math.abs(+value) === Infinity) { + stringified = ((value < 0) ? '-' : '') + 'INF'; + return {literal: stringified, asString: true}; + } + + /* Try to make the given double look like a SPARQL double literal. */ + if (pattern.test(stringified)) { + return {literal: stringified}; + } + + stringified += 'e0'; + + if (pattern.test(stringified)) { + return {literal: stringified}; + } +} + +function StringLiteral(value, languageTag) { + Literal.call(this, value); + + if (languageTag !== undefined) { + this['xml:lang'] = assertSafeLanguageTag(languageTag); + } +} + +StringLiteral.prototype = Object.create(Literal.prototype, { + languageTag: { get: function () { return this['xml:lang']; }} +}); + +StringLiteral.prototype.format = function () { + var term = formatString(this.value); + + if (this.languageTag !== undefined) { + term += '@' + this.languageTag; + } + return term; +}; + +/** + * Raises an error if the language tag seems malformed. + */ +function assertSafeLanguageTag(tag) { + /* See: http://www.w3.org/TR/2013/REC-sparql11-query-20130321/#rLANGTAG */ + if (/^[a-zA-Z]+(?:-[a-zA-Z0-9]+)*$/.test(tag)) { + return tag; + } + + throw new Error('Invalid langauge tag: ' + tag); +} diff --git a/package.json b/package.json index cdaa6e7..f160841 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,28 @@ { - "author": "Thomas Fritz (http://fritzthomas.com)", + "name": "sparql-client-2", + "version": "0.6.3", + "author": "Eddie Antonio Santos (http://eddieantonio.ca/)", "contributors": [ - "Martin Franke (http://semiwa.org)", - "Pieter Heyvaert (http://semweb.mmlab.be/)" + "Martin Franke (http://semiwa.org)", + "Pieter Heyvaert (http://semweb.mmlab.be/)", + "Thomas Fritz (http://fritzthomas.com)" ], "dependencies": { - "lodash": "^2.4.1", + "denodeify": "^1.2.1", + "lodash": "^4.0.1", + "promise-nodeify": "^0.1.0", "request": "^2.40.0" }, - "description": "Simple SPARQL Client for node.js", - "devDependencies": {}, + "description": "SPARQL Client for JavaScript", + "devDependencies": { + "eslint": "^3.11.0", + "eslint-config-standard": "^6.2.1", + "eslint-plugin-promise": "^3.4.0", + "eslint-plugin-standard": "^2.0.1", + "jasmine": "^2.3.1", + "nock": "^2.5.0", + "traceur": "0.0.90" + }, "engines": { "node": ">= 0.10.0" }, @@ -18,21 +31,19 @@ "rdf" ], "main": "index.js", - "name": "sparql-client", "repository": { "type": "git", - "url": "https://github.com/thomasfr/node-sparql-client.git" + "url": "git@github.com:eddieantonio/node-sparql-client.git" }, - "version": "0.2.0", "bugs": { - "url": "https://github.com/thomasfr/node-sparql-client/issues" + "url": "https://github.com/eddieantonio/node-sparql-client/issues" }, - "homepage": "https://github.com/thomasfr/node-sparql-client", + "homepage": "https://github.com/eddieantonio/node-sparql-client", "directories": { - "test": "tests" + "test": "spec" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "jasmine" }, "license": "MIT" } diff --git a/spec/dbpedia-spec.js b/spec/dbpedia-spec.js new file mode 100644 index 0000000..f89cc43 --- /dev/null +++ b/spec/dbpedia-spec.js @@ -0,0 +1,91 @@ +var nock = require('nock'); +var SparqlClient = require('../'); + +var ENDPOINT = 'http://dbpedia.org/sparql'; + +/* Since this accesses an external resource, ignore it. */ +describe('Querying DBPedia', function () { + + beforeAll(function () { + nock.disableNetConnect(); + }); + + it('should yield a list of cities', function (done) { + var scope = nock(host(ENDPOINT)) + .post(path(ENDPOINT)) + .reply(200, require('./fixtures/cities.raw')); + + var client = new SparqlClient(ENDPOINT); + var query = + "SELECT ?city ?leaderName " + + "FROM " + + "WHERE {" + + " ?city ?leaderName } " + + "LIMIT 10"; + + client.query(query) + .execute({format: {resource: 'city'}}, function (error, results) { + expect(results).toEqual(require('./fixtures/cities')); + scope.done(); + done(); + }); + }); + + it('should yield, binding to a URI', function (done) { + var scope = nock(host(ENDPOINT)) + .post(path(ENDPOINT)) + .reply(200, require('./fixtures/tokyo')); + + var client = new SparqlClient(ENDPOINT); + var query = + "SELECT ?postalCode " + + "FROM " + + "WHERE { ?city ?postalCode } "; + + client.query(query) + .bind('city', 'http://dbpedia.org/resource/Tokyo', {type:'uri'}) + .execute(function (error, results) { + expect(results).toEqual(require('./fixtures/tokyo')); + scope.done(); + done(); + }); + }); + + it('should yield, binding to a prefixed URI', function (done) { + var scope = nock(host(ENDPOINT)) + .post(path(ENDPOINT)) + .reply(200, require('./fixtures/chicago')); + + var client = new SparqlClient(ENDPOINT); + var query = + "PREFIX db: " + + "PREFIX dbpedia-owl: " + + "SELECT ?foundingDate " + + "FROM " + + "WHERE { ?city dbpedia-owl:foundingDate ?foundingDate } "; + + client.query(query) + .bind('city', {db: 'Chicago'}) + .execute(function (error, results) { + expect(results).toEqual(require('./fixtures/chicago')); + scope.done(); + done(); + }); + }); + + it('should yield a list of concepts', function (done) { + var scope = nock(host(ENDPOINT)) + .post(path(ENDPOINT)) + .reply(200, require('./fixtures/concepts')); + + var client = new SparqlClient(ENDPOINT); + var query = 'select distinct ?Concept from ' + + 'where {[] a ?Concept} limit 100'; + + client.query(query, function (error, results) { + expect(results).toEqual(require('./fixtures/concepts')); + scope.done(); + done(); + }); + }); +}); diff --git a/spec/fixtures/book-genres.json b/spec/fixtures/book-genres.json new file mode 100644 index 0000000..6728f3d --- /dev/null +++ b/spec/fixtures/book-genres.json @@ -0,0 +1,2022 @@ +{ + "head": { + "link": [], + "vars": [ + "book" + ] + }, + "results": { + "distinct": false, + "ordered": true, + "bindings": [ + { + "genre": { + "type": "uri", + "value": "http://dbpedia.org/resource/Novel" + }, + "book": [ + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Glimpse_of_Tiger" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/About_Us_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Blood_Sisters" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dickon_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Grief_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Hey_Nostradamus!" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Ignorance_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/In_Custody_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Letty_Fox:_Her_Luck" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Seizure_(Cook_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Some_Like_It_Hot_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Suffer_the_Children_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Deceivers_(Aiello_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Delivery_Man_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Flight_of_the_Phoenix" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Help" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Lost_Weekend_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Moths_(short_story)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Tough_Love_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Tuvalu_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Word_of_Honor_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Adolphe" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Beautiful_Stranger_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Puberty_Blues_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Lamplighter" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Looking_Glass_Wars" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Low_Road_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Rosary_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/To_Have_and_to_Hold" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Savage_Messiah_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Blank_Page" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Girls_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Little_Book_(Edwards_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Weight_Loss_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bellwether_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Der_Untertan" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Fortress_of_Solitude_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_King_of_the_Golden_River" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Man_Without_Qualities" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/City_of_Night" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Female_American" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Koolaids:_The_Art_of_War" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Los_Sangurimas" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Rogue_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Unleavened_Bread" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Season_of_Ash" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mornings_in_Jenin" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Forbidden_Tree" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Twenty-Second_Day" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Juan_Masili:_Ang_Pinuno_ng_Tulisan" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Loving_Sabotage" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mondomanila_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Titser" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/%C5%BDivot_a_d%C3%ADlo_skladatele_Folt%C3%BDna" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/An_Englishwoman's_Love-letters" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Watching_the_Climbers_on_the_Mountain" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Other_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/2_Girls" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Lost_Prince_(Edwards_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Tom_Grogan" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/An_Evening_of_Long_Goodbyes" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/New_World_Waiting" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/%C3%8Ddolos_rotos" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Prince's_Act" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Anne_of_Green_Gables" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Imitation_of_Life_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Body_(novella)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Forsyte_Saga" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Do%C3%B1a_B%C3%A1rbara" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/East_Liberty_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Eye_of_the_Storm_(Ringo_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Fail-Safe_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Headlong_(Williams_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Queens_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Devil's_Advocate_(Morris_West_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Education_of_Little_Tree" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Elegance_of_the_Hedgehog" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Lost_Honour_of_Katharina_Blum" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Water_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Albertine_(2001_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Americana_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Blink_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Caballero:_A_Historical_Novel" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Cosmopolis_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Gangster_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Heliopolis_(Scudamore_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Millicent_Min,_Girl_Genius" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Nowhere_Man_(Hemon_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Ransom_(Steel_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Snow_Country" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Special_Delivery_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Sugar_Rush_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Ballantyne_Novels" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Efficiency_Expert_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Inheritors_(William_Golding)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Young_Lions" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Veracity_(Laura_Bynum_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/On_Beauty" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Puttering_About_in_a_Small_Land" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Never_Tease_a_Siamese" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Shell_Shaker" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Case_of_Jennie_Brice" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Cat_Who_Played_Post_Office" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Cosmology_of_Bing" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Family_Fang" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Blankety_Blank:_A_Memoir_of_Vulgaria" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Harimau!_Harimau!" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/L.A._Confidential" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Goals_in_the_Air" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Nude_Men" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Pubis_Angelical" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Accounting" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Confusions_of_Young_T%C3%B6rless" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Man_Who_Loved_Dirty_Books" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Then_We_Came_to_the_End" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Upon_Some_Midnights_Clear" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/An_Episode_in_the_Life_of_a_Landscape_Painter" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Le_Livre_des_fuites" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Peony_in_Love" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Steel_Ashes" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Einstein_Girl" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Research_Magnificent" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Luha_ng_Babae" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dogsong" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Living_Other_Lives" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Lucinda_Pierce_Mystery_series" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Poor_Fellow_My_Country" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Cat_Who_Lived_High" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Impersonators" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Story_of_Egmo" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Transall_Saga" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Vespers_in_Vienna" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Whispers_in_the_Wind" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Yonnondio:_From_the_Thirties" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Z%C3%BCndels_Abgang" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Fraction_of_the_Whole" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Chain_of_Evidence" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Fifteen_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Love_Medicine" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Last_Summer_(of_You_and_Me)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Woods_Runner" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Daluyong" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Seidman_and_Son" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dancing_on_Coral" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Cabin_Faced_West" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Room_Overlooking_the_Nile" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Disquiet_Heart" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Never_Sorry" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Saheb_Bibi_Golam" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Dark_Volume" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_End_of_Mr._Y" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Retaliators" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Echo_Maker" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Yiddish_Policemen's_Union" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Feeling_Sorry_for_Celia" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Memoirs_of_Emma_Courtney" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Risk_Pool" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bye-Bye_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/D%C3%A9molir_Nisard" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/La_628-E8" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Matrka:_Voices_Within" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Irish_Famine_(book)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Ciske_de_Rat" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Playing_for_Pizza" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Gesture_Life" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Cop_This!" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Visitation_of_Spirits" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Harpsong" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Horse_of_Air" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Flower_Net" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Snow_Flower_and_the_Secret_Fan" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Fermata" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Unknown_Industrial_Prisoner" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Benang" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Glade_Within_the_Grove" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Long_Trial_of_Nolan_Dugatti" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Heart_So_White" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bing_Crosby's_Last_Song" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Hocus_Corpus" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Never_Preach_Past_Noon" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Slow_Water" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Diagnosis_of_Love" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Submerged_Cathedral_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Three_Dog_Night_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Tragic_Wand" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Generation_A" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Night_Train_to_Lisbon" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Faint_Cold_Fear" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Satanas_sa_Lupa" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Cold_in_July_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Autumn_Laing" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Stories_We_Could_Tell_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Awakening_Land_trilogy" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Children_of_Dynmouth" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mrs._Eckdorf_in_O'Neill's_Hotel" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_School_for_Atheists" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Carry_Me_Across_the_Water" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Time_in_Between" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/1632_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/1633_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/2150_AD" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/69_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Closed_Book" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Dark_Night's_Passing" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Day_No_Pigs_Would_Die" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Frolic_of_His_Own" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Gift_Upon_the_Shore" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Kingdom_of_Dreams" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Pale_View_of_Hills" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Summons_to_Memphis" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Time_for_Judas" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Virtuous_Woman" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Yellow_Raft_in_Blue_Water" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Abduction_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/All_Fall_Down_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/All_Families_Are_Psychotic" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Almost_Transparent_Blue" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/And_Quiet_Flows_the_Don" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Annie_John" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Another_Roadside_Attraction" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/At_Swim-Two-Birds" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Autobiography_of_a_Brown_Buffalo" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Back_in_Black_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Back_to_Life_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Beach_Music_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Beard's_Roman_Women" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bee_Season" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Black_Alice_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Blackeyes" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bleachers_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Blindness_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Blonde_Ambition_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Brain_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Brave_New_Girl_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Breakfast_on_Pluto" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Buck_Rogers:_A_Life_in_the_Future" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Cat's_Eye_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Charlie_Chan_Carries_On" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Children_of_Gebelawi" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Cities_of_the_Red_Night" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Clotel" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Comfort_Food_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Company_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Cracking_India" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Crusade_in_Jeans" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dandelion_Wine" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Das_falsche_Buch" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Deafening" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Death_of_a_Whaler" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Decipher_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dicey's_Song" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Disgrace_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Doctor_Sax" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dorothea_Dreams" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dream_Story" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dred:_A_Tale_of_the_Great_Dismal_Swamp" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Drowning_Ruth" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Effi_Briest" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Electric_Brae_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Ellen_Foster" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Emperor_of_America" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/End_of_Term" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/English,_August" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Everything_Is_Illuminated" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Extremely_Loud_and_Incredibly_Close" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Falconer's_Lure" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Fever_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Fierce_Invalids_Home_from_Hot_Climates" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Finding_Cassie_Crazy" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Flight_of_Eagles" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Foreign_Affairs_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Gai-Jin_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Generation_X:_Tales_for_an_Accelerated_Culture" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Geomancer_(Well_of_Echoes)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/George_Passant" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Girlfriend_in_a_Coma_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Godplayer_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Gun,_with_Occasional_Music" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Half-Life_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Half_Asleep_in_Frog_Pajamas" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Hannibal's_Children" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Harmful_Intent_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/High_Society_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Hope_and_Other_Dangerous_Pursuits" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Hotel_du_Lac" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/I,_Fatty" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/I_Know_This_Much_Is_True" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/I_Married_a_Communist" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Icelander_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Icy_Sparks" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Idiots_in_the_Machine" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/In_Another_Light" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Interface_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Invisible_Monsters" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Jasmine_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Jewel_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Jim_the_Boy" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Jitterbug_Perfume" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/John_Macnab" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Join_My_Cult" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Journey_by_Moonlight_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Joy_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Kalimantaan" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Katrina_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Killshot" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/King's_Gold" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/King_of_the_Wind" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Kira-Kira" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Kushiel's_Avatar" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Larry's_Party" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Life_&_Times_of_Michael_K" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Little_Boy_Lost_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Lost_City" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Lucky_Wander_Boy" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Marker_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mazes_and_Monsters_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Midwives_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mistress_of_Spices" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Molloy_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Molon_Labe!" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Morning_Star_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mortal_Fear_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Morvern_Callar" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mother_of_Pearl_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mrs._Kimble" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/My_Ishmael" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/My_Present_Age" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Mystic_River_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Native_Son" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Neanderthal_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Not_Without_Laughter" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Notable_American_Women" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/One_Hundred_Years_of_Solitude" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Open_House_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Out_of_This_Furnace" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Palace_Walk" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Payasos_en_la_lavadora" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Peace_Breaks_Out" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Peculiar_Chris" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Plain_Truth" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Politics_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/PopCo" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Porno_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Portrait_of_Lozana:_The_Lusty_Andalusian_Woman" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Power_Without_Glory" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Primal_Fear_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Princess_Diana's_Revenge" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Psycho_II_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Rainbow_Six_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Rant_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Read_Between_the_Lies" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Reading_in_the_Dark" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Reaper's_Gale" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Rebecca's_Tale" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Retribution_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/River,_Cross_My_Heart" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Roots:_The_Saga_of_an_American_Family" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Ruled_Britannia" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Salvage_for_the_Saint" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Sarah_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Season_of_the_Jew" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Shampoo_Planet" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Shantaram_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Shock_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Shohola_Falls" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Sideways_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Slow_Man" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/So_Long_a_Letter" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Socialite_Evenings" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Songs_in_Ordinary_Time" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Specimen_Days" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Sphinx_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Still_Life_with_Woodpecker" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Stones_from_the_River" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Stray_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Sula_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Swami_and_Friends" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Tandia" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Tara_Road" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Ten_Men" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Abortion:_An_Historical_Romance_1966" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Bedroom_Secrets_of_the_Master_Chefs" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Bishop's_Mantle" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Black_Dahlia_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Blackwater_Lightship" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Boy_Who_Kicked_Pigs" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Bunce" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Clayhanger_Family" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Deep_End_of_the_Ocean" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Dissolution_of_Nicholas_Dee" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Door_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Fourth_K" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Gang_That_Couldn't_Shoot_Straight" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Garden_of_Unearthly_Delights" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Gospel_According_to_Adam" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Grave_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Great_Gatsby" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Harafish" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Healthy_Dead" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Heather_Blazing" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Hollywood_Takes" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Holy" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Hours_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Human_Stain" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Inheritance_of_Loss" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Insult" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Journey_of_Ibn_Fattouma" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Known_World" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Last_Burden" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Last_Open_Road" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Leopard" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Lovely_Bones" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Mahdi" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Mammaries_of_the_Welfare_State" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Master_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Mermaid_Chair" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Moth_Diaries" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Neon_Bible" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Old_Man_and_the_Sea" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_People_of_Paper" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Pilot's_Wife" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Posthumous_Memoirs_of_Bras_Cubas" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Process_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Pyramid_(Kadare)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Rapture_of_Canaan" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Rebel_Angels" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Return_of_John_MacNab" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Royal_Family_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Rule_of_Four" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Sand_Child" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Sea_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Search_for_the_Dice_Man" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Season_of_the_Witch" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Shipping_News" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_South_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Steep_Approach_to_Garbadale" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Stones_of_Summer" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Story_of_B" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Story_of_the_Night" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Taqwacores" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Tenor_Wore_Tapshoes" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Third_Witch" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Twins_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Two_Deaths_of_Quincas_Wateryell" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Uncomfortable_Dead" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Well_of_Loneliness" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Wind-Up_Bird_Chronicle" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Wives_of_Bath" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Yacoubian_Building" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Things_My_Girlfriend_and_I_Have_Argued_About" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Toxin_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Trans-Atlantyk" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Trojan_Odyssey" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Truth_and_Bright_Water" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Valley_of_the_Squinting_Windows" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Villa_Incognito" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Vinegar_Hill_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Vinland_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Vital_Signs_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Waiting_for_the_Barbarians" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Watch_Your_Mouth" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Whale_Song_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/What_Happened_to_Mr._Forster%3F" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/What_Looks_Like_Crazy_on_an_Ordinary_Day" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/When_They_Lay_Bare" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Where_Rainbows_End" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/While_I_Was_Gone" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Without_Remorse" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Year_of_the_Intern_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/You_Shall_Know_Our_Velocity" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_White_Peacock" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/And_Ladies_of_the_Club" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Pinball,_1973" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Running_Dog_(novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bachelors_Anonymous" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bracebridge_Hall" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Metal_F%C4%B1rt%C4%B1na" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Le_Contrat_de_mariage" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Family_of_Pascual_Duarte" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Der_Wehrwolf" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Keziah_Dane" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Ledfeather" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Spring_to_Come" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Witch_of_Portobello" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Your_Face_Tomorrow_Volume_1:_Fever_and_Spear" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Delectable_Country" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Glass_Room" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Rocksburg_Railroad_Murders" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bealby" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Bottom_Liner_Blues" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Elegy_for_Sam_Emerson" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Felicia's_Journey" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/That_Deadman_Dance" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Day_We_Had_Hitler_Home" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Under_Western_Eyes" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Dark_Room_(Narayan_novel)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Place_of_Dead_Roads" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Western_Lands" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Dating_Hamlet" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/The_Slow_Natives" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/spec/fixtures/chicago.json b/spec/fixtures/chicago.json new file mode 100644 index 0000000..4902b2c --- /dev/null +++ b/spec/fixtures/chicago.json @@ -0,0 +1,21 @@ +{ + "head": { + "link": [], + "vars": [ + "foundingDate" + ] + }, + "results": { + "distinct": false, + "ordered": true, + "bindings": [ + { + "foundingDate": { + "type": "typed-literal", + "datatype": "http://www.w3.org/2001/XMLSchema#date", + "value": "1837-03-04+02:00" + } + } + ] + } +} diff --git a/spec/fixtures/cities.json b/spec/fixtures/cities.json new file mode 100644 index 0000000..eb27f10 --- /dev/null +++ b/spec/fixtures/cities.json @@ -0,0 +1,108 @@ +{ + "head": { + "link": [], + "vars": [ + "leaderName" + ] + }, + "results": { + "distinct": false, + "ordered": true, + "bindings": [ + { + "city": { + "type": "uri", + "value": "http://dbpedia.org/resource/Gwynedd_(fictional)" + }, + "leaderName": [{ + "type": "uri", + "value": "http://dbpedia.org/resource/Kelson_Haldane" + }] + }, + { + "city": { + "type": "uri", + "value": "http://dbpedia.org/resource/Ixhuatl%C3%A1n_de_Madero" + }, + "leaderName": [{ + "type": "literal", + "xml:lang": "en", + "value": "Elías Benítez Hernández" + }] + }, + { + "city": { + "type": "uri", + "value": "http://dbpedia.org/resource/L%C3%A9ry,_Quebec" + }, + "leaderName": [{ + "type": "uri", + "value": "http://dbpedia.org/resource/Ch%C3%A2teauguay_(provincial_electoral_district)" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Ch%C3%A2teauguay%E2%80%94Saint-Constant" + }, + { + "type": "literal", + "xml:lang": "en", + "value": "Yvon Mailhot" + }] + }, + { + "city": { + "type": "uri", + "value": "http://dbpedia.org/resource/Nelson_Township,_Lee_County,_Illinois" + }, + "leaderName": [{ + "type": "literal", + "xml:lang": "en", + "value": "Marlin Jensen" + }] + }, + { + "city": { + "type": "uri", + "value": "http://dbpedia.org/resource/Osh" + }, + "leaderName": [{ + "type": "uri", + "value": "http://dbpedia.org/resource/Aitmamat_Kadyrbaev" + }] + }, + { + "city": { + "type": "uri", + "value": "http://dbpedia.org/resource/Pineda_Trasmonte" + }, + "leaderName": [{ + "type": "literal", + "xml:lang": "en", + "value": "Basi Núñez Angulo" + }] + }, + { + "city": { + "type": "uri", + "value": "http://dbpedia.org/resource/Pineda_de_Mar" + }, + "leaderName": [{ + "type": "literal", + "xml:lang": "en", + "value": "Xavier Amor i Martín" + }] + }, + { + "city": { + "type": "uri", + "value": "http://dbpedia.org/resource/Santa_Rosa_District,_Chiclayo" + }, + "leaderName": [{ + "type": "literal", + "xml:lang": "en", + "value": "Andres Palma Gordillo" + }] + } + ] + } +} diff --git a/spec/fixtures/cities.raw.json b/spec/fixtures/cities.raw.json new file mode 100644 index 0000000..17d56b5 --- /dev/null +++ b/spec/fixtures/cities.raw.json @@ -0,0 +1,12 @@ +{ "head": { "link": [], "vars": ["city", "leaderName"] }, + "results": { "distinct": false, "ordered": true, "bindings": [ + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/Gwynedd_(fictional)" } , "leaderName": { "type": "uri", "value": "http://dbpedia.org/resource/Kelson_Haldane" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/Ixhuatl%C3%A1n_de_Madero" } , "leaderName": { "type": "literal", "xml:lang": "en", "value": "El\u00EDas Ben\u00EDtez Hern\u00E1ndez" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/L%C3%A9ry,_Quebec" } , "leaderName": { "type": "uri", "value": "http://dbpedia.org/resource/Ch%C3%A2teauguay_(provincial_electoral_district)" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/L%C3%A9ry,_Quebec" } , "leaderName": { "type": "uri", "value": "http://dbpedia.org/resource/Ch%C3%A2teauguay%E2%80%94Saint-Constant" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/L%C3%A9ry,_Quebec" } , "leaderName": { "type": "literal", "xml:lang": "en", "value": "Yvon Mailhot" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/Nelson_Township,_Lee_County,_Illinois" } , "leaderName": { "type": "literal", "xml:lang": "en", "value": "Marlin Jensen" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/Osh" } , "leaderName": { "type": "uri", "value": "http://dbpedia.org/resource/Aitmamat_Kadyrbaev" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/Pineda_Trasmonte" } , "leaderName": { "type": "literal", "xml:lang": "en", "value": "Basi N\u00FA\u00F1ez Angulo" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/Pineda_de_Mar" } , "leaderName": { "type": "literal", "xml:lang": "en", "value": "Xavier Amor i Mart\u00EDn" }}, + { "city": { "type": "uri", "value": "http://dbpedia.org/resource/Santa_Rosa_District,_Chiclayo" } , "leaderName": { "type": "literal", "xml:lang": "en", "value": "Andres Palma Gordillo" }} ] } } diff --git a/spec/fixtures/concepts.json b/spec/fixtures/concepts.json new file mode 100644 index 0000000..2f988f5 --- /dev/null +++ b/spec/fixtures/concepts.json @@ -0,0 +1,614 @@ +{ + "head": { + "link": [], + "vars": [ + "Concept" + ] + }, + "results": { + "distinct": false, + "ordered": true, + "bindings": [ + { + "Concept": { + "type": "uri", + "value": "http://www.w3.org/2004/02/skos/core#Concept" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://xmlns.com/foaf/0.1/Person" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://schema.org/Person" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://wikidata.dbpedia.org/resource/Q215627" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.w3.org/2002/07/owl#Thing" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://wikidata.dbpedia.org/resource/Q5" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#Agent" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#NaturalPerson" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Agent" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Athlete" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Person" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/TennisPlayer" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://schema.org/CreativeWork" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#InformationEntity" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Software" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/VideoGame" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Work" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://umbel.org/umbel/rc/ComputerGameProgram" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://umbel.org/umbel/rc/SoftwareObject" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Train" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#DesignedArtifact" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/MeanOfTransportation" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#PhysicalBody" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/CelestialBody" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Planet" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://umbel.org/umbel/rc/Planet" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Asteroid" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://schema.org/Organization" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.ontologydesignpatterns.org/ont/dul/DUL.owl#SocialPerson" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/MilitaryUnit" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Organisation" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.w3.org/2003/01/geo/wgs84_pos#SpatialThing" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://schema.org/Place" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.ontologydesignpatterns.org/ont/d0.owl#Location" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Place" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/PopulatedPlace" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Settlement" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Village" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Wikidata:Q532" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/AdministrativeDistrict108491826" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/District108552138" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Location100027167" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Object100002684" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Region108630985" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Site108651247" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Tract108673395" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/YagoGeoEntity" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/YagoLegalActorGeo" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/GeographicalArea108574314" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/PhysicalEntity100001930" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/YagoPermanentlyLocatedEntity" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/PopulatedPlacesInFriesland" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://www.opengis.net/gml/_Feature" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/PopulatedPlacesInGroningen(province)" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://schema.org/MusicGroup" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/Artist" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/MusicalArtist" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/306RecordsArtists" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Artist109812338" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/CanadianCountryGuitarists" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/CanadianCountrySingers" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/CanadianMaleSingers" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/CausalAgent100007347" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Creator109614315" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Entertainer109616922" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Guitarist110151760" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/LivingThing100004258" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Musician110339966" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Musician110340312" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Organism100004475" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Performer110415638" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Person100007846" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Singer110599806" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Whole100003553" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/MusiciansFromAlberta" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/PeopleFromWoodBuffalo,Alberta" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/YagoLegalActor" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/LivingPeople" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Sculptor110566072" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/FrenchSculptors" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Commune108541609" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/CommunesOfPas-de-Calais" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/ontology/PoliticalParty" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Abstraction100002137" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Group100031264" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Organization108008335" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Party108256968" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/SocialGroup107950920" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/MunicipalPoliticalPartiesInQuebecCity" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/UnitedReligiousFrontPoliticians" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Women'sInternationalZionistOrganizationPoliticians" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Adult109605289" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Educator110045713" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Leader109623038" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Politician110451263" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Professional110480253" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Woman110787470" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/PeopleFromFlore%C5%9FtiDistrict" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/Female109619168" + } + }, + { + "Concept": { + "type": "uri", + "value": "http://dbpedia.org/class/yago/IsraeliEducators" + } + } + ] + } +} \ No newline at end of file diff --git a/spec/fixtures/got-formatted.json b/spec/fixtures/got-formatted.json new file mode 100644 index 0000000..a78dc22 --- /dev/null +++ b/spec/fixtures/got-formatted.json @@ -0,0 +1,39 @@ +{ + "head": { + "link": [], + "vars": [ + "genre" + ] + }, + "results": { + "distinct": false, + "ordered": true, + "bindings": [ + { + "book": { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Game_of_Thrones" + }, + "genre": [ + { + "type": "uri", + "value": "http://dbpedia.org/resource/Political_strategy" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/High_fantasy" + }, + { + "type": "uri", + "value": "http://dbpedia.org/resource/Fantasy" + } + ] + } + ] + }, + "request": { + "query": "SELECT ?book ?genre { }", + "format": "application/sparql-results+json", + "content-type": "application/sparql-results+json" + } +} \ No newline at end of file diff --git a/spec/fixtures/got-genres.json b/spec/fixtures/got-genres.json new file mode 100644 index 0000000..dddb23b --- /dev/null +++ b/spec/fixtures/got-genres.json @@ -0,0 +1,35 @@ +{ + "head": { + "link": [], + "vars": [ + "book", + "genre" + ] + }, + "results": { + "distinct": false, + "ordered": true, + "bindings": [ + { + "book": { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Game_of_Thrones" + }, + "genre": { + "type": "uri", + "value": "http://dbpedia.org/resource/Fantasy" + } + }, + { + "book": { + "type": "uri", + "value": "http://dbpedia.org/resource/A_Game_of_Thrones" + }, + "genre": { + "type": "uri", + "value": "http://dbpedia.org/resource/Political_strategy" + } + } + ] + } +} diff --git a/spec/fixtures/leader-names.json b/spec/fixtures/leader-names.json new file mode 100644 index 0000000..4f32b1d --- /dev/null +++ b/spec/fixtures/leader-names.json @@ -0,0 +1,35 @@ +{ + "head": { + "link": [], + "vars": [ + "leaderName" + ] + }, + "results": { + "distinct": false, + "ordered": true, + "bindings": [ + { + "leaderName": { + "type": "literal", + "xml:lang": "en", + "value": "Maria Vassilakou ," + } + }, + { + "leaderName": { + "type": "literal", + "xml:lang": "en", + "value": "Michael Häupl" + } + }, + { + "leaderName": { + "type": "literal", + "xml:lang": "en", + "value": "Renate Brauner ;" + } + } + ] + } +} \ No newline at end of file diff --git a/spec/fixtures/tokyo.json b/spec/fixtures/tokyo.json new file mode 100644 index 0000000..be6df94 --- /dev/null +++ b/spec/fixtures/tokyo.json @@ -0,0 +1,21 @@ +{ + "head": { + "link": [], + "vars": [ + "postalCode" + ] + }, + "results": { + "distinct": false, + "ordered": true, + "bindings": [ + { + "postalCode": { + "type": "literal", + "xml:lang": "en", + "value": "JP-13" + } + } + ] + } +} diff --git a/spec/helpers/nock-helper.js b/spec/helpers/nock-helper.js new file mode 100644 index 0000000..cb4001f --- /dev/null +++ b/spec/helpers/nock-helper.js @@ -0,0 +1,31 @@ +/** + * Helper that creates a single-use nock SPARQL endpoint. + */ +global.nockEndpoint = function (status, data, options) { + var nock = require('nock'); + var querystring = require('querystring'); + + // Assuming no-one will need status 0 (non-existent) + status = status || 200; + options = options || {}; + + var endpoint = options.endpoint || 'http://example.org/sparql'; + var scope = nock(host(endpoint)) + .post(path(endpoint)) + .reply(status, function (uri, requestBody) { + /* Standard SPARQL JSON response... */ + data = data || { + head: { link: [], vars: []}, + results: { distinct: false, ordered: true, bindings: [] }, + }; + /* ...but append the request body. */ + data.request = querystring.parse(requestBody); + return data; + }); + + /* Tack on the endpoint on to the scope so that clients can use the proper + * mocked URI. */ + scope.endpoint = endpoint; + + return scope; +}; diff --git a/spec/helpers/toHavePrefix.js b/spec/helpers/toHavePrefix.js new file mode 100644 index 0000000..4ad15b9 --- /dev/null +++ b/spec/helpers/toHavePrefix.js @@ -0,0 +1,41 @@ +var matchers = global.customMatchers = global.customMatchers || {}; + +/** + * Given a string, interprets it as a query, and ensures that it has the given + * The given URI can be passed as `true` to match *any* URI. + */ +matchers.toHavePrefix = function (util, customEqualityTesters) { + return { + compare: function (actual, expected) { + var result = {}; + var prefix = Object.keys(expected)[0]; + var uri = expected[prefix]; + + var uriPattern = (uri === true) ? '[^>]*' : escapeRegExp(uri); + + var pattern = new RegExp('PREFIX\\s+' + + escapeRegExp(prefix) + + ':\\s+<' + + uriPattern + + '>'); + + result.pass = !!actual.match(pattern); + if (result.pass) { + result.message = 'Expected `' + actual + + '` to not declare the prefix ' + prefix + + ' as ' + uri; + } else { + result.message = 'Expected `' + actual + + '` to declare the prefix ' + prefix + + ' as ' + uri; + } + + return result; + } + }; +}; + +/* From: http://stackoverflow.com/a/6969486 */ +function escapeRegExp(str) { + return str.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); +} diff --git a/spec/helpers/uri-helpers.js b/spec/helpers/uri-helpers.js new file mode 100644 index 0000000..390cd35 --- /dev/null +++ b/spec/helpers/uri-helpers.js @@ -0,0 +1,13 @@ +/** + * Adds host() and path() to make extracting each from URLs slightly easier. + */ +var url = require('url'); + +global.host = function (uri) { + var result = url.parse(uri, false, true); + return result.protocol + '//' + result.host; +}; + +global.path = function (uri) { + return url.parse(uri).pathname; +}; diff --git a/spec/helpers/use-es6.js b/spec/helpers/use-es6.js new file mode 100644 index 0000000..46e536f --- /dev/null +++ b/spec/helpers/use-es6.js @@ -0,0 +1,14 @@ +/** + * Loads Traceur, if needed. + */ +try { + eval('`I like to have ${0/0} with my curry.`'); +} catch (e) { + if (e instanceof SyntaxError) { + var traceur = require('traceur'); + traceur.require.makeDefault(function(filename) { + // don't transpile our dependencies, just our app + return filename.indexOf('node_modules') === -1; + }); + } +} diff --git a/spec/primary-api-spec.js b/spec/primary-api-spec.js new file mode 100644 index 0000000..59b1e27 --- /dev/null +++ b/spec/primary-api-spec.js @@ -0,0 +1,725 @@ +var SparqlClient = require('../'); + +describe('SPARQL API', function () { + + beforeEach(function () { + jasmine.addMatchers(customMatchers); + require('nock').cleanAll(); + }); + + describe('SparqlClient', function () { + describe('constructor', function () { + it('should connect to an endpoint via a URL', function () { + var client = new SparqlClient('http://localhost:8080'); + + expect(client).toEqual(jasmine.any(SparqlClient)); + }); + }); + + describe('#query()', function () { + it('should return a new SPARQLQuery instance', function () { + var client = new SparqlClient('http://example.org/sparql'); + var query = client.query('SELECT ("Hello, World" as ?unused) {}'); + + /* Check that it has some core methods. */ + expect(query).toEqual(jasmine.objectContaining({ + bind: jasmine.any(Function), + execute: jasmine.any(Function) + })); + }); + }); + + describe('#register()', function () { + it('should register a single prefix for use in all new queries', function (done) { + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + client.register('rdfs', 'http://www.w3.org/2000/01/rdf-schema#'); + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.execute(function (err, data) { + var rawQuery = data.request.query; + expect(rawQuery).toHavePrefix({rdfs: 'http://www.w3.org/2000/01/rdf-schema#'}); + done(); + }); + }); + + it('should register a multiple prefixes for use in all new queries', function (done) { + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + client.register({ + rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + rdfs: 'http://www.w3.org/2000/01/rdf-schema#', + xsd: 'http://www.w3.org/2001/XMLSchema#', + dc: 'http://purl.org/dc/elements/1.1/' + }); + + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.execute(function (err, data) { + var rawQuery = data.request.query; + + expect(rawQuery).toHavePrefix({rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'}); + expect(rawQuery).toHavePrefix({rdfs: 'http://www.w3.org/2000/01/rdf-schema#'}); + expect(rawQuery).toHavePrefix({xsd: 'http://www.w3.org/2001/XMLSchema#'}); + expect(rawQuery).not.toHavePrefix({owl: true}); + expect(rawQuery).toHavePrefix({dc: 'http://purl.org/dc/elements/1.1/'}); + done(); + }); + }); + + it('should register the base prefix for use in all new queries', function (done) { + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + client.register('http://dbpedia.org/resource/'); + + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.execute(function (err, data) { + var rawQuery = data.request.query; + + expect(rawQuery).toMatch(/\bBASE\s+<.+dbpedia.org.+>/); + done(); + }); + }); + + it('should present a fluent interface', function () { + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + var result = client.register({ + rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', + rdfs: 'http://www.w3.org/2000/01/rdf-schema#', + xsd: 'http://www.w3.org/2001/XMLSchema#', + owl: 'http://www.w3.org/2002/07/owl#' + }); + + expect(result).toEqual(jasmine.any(SparqlClient)); + }); + }); + + describe('#registerCommon()', function () { + it('should register at least one prefix', function (done) { + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + client.registerCommon('rdfs'); + + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.execute(function (err, data) { + var rawQuery = data.request.query; + + expect(rawQuery).toHavePrefix({rdfs: 'http://www.w3.org/2000/01/rdf-schema#'}); + expect(rawQuery).not.toHavePrefix({rdf: true}); + done(); + }); + }); + + it('should register at several prefixes simultaneously', function (done) { + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + client.registerCommon('rdf', 'rdfs', 'xsd'); + + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.execute(function (err, data) { + var rawQuery = data.request.query; + + expect(rawQuery).toHavePrefix({xsd: 'http://www.w3.org/2001/XMLSchema#'}); + expect(rawQuery).toHavePrefix({rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'}); + expect(rawQuery).toHavePrefix({rdfs: 'http://www.w3.org/2000/01/rdf-schema#'}); + expect(rawQuery).not.toHavePrefix({fn: true}); + expect(rawQuery).not.toHavePrefix({sfn: true}); + done(); + }); + }); + + it('should register at all prefixes found in the SPARQL 1.1 query spec.', function (done) { + // http://www.w3.org/TR/2013/REC-sparql11-query-20130321/#docConventions + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + client.registerCommon(); + + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.execute(function (err, data) { + var rawQuery = data.request.query; + + expect(rawQuery).toHavePrefix({rdf: true}); + expect(rawQuery).toHavePrefix({rdfs: true}); + expect(rawQuery).toHavePrefix({xsd: true}); + expect(rawQuery).toHavePrefix({fn: true}); + expect(rawQuery).toHavePrefix({sfn: true}); + done(); + }); + }); + + it('should present a fluent interface', function () { + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + var result = client.registerCommon('rdf', 'rdfs'); + + expect(result).toEqual(jasmine.any(SparqlClient)); + }); + }); + }); + + + describe('Query', function () { + describe('#register()', function() { + it('should register the given prefix', function (done) { + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.register('rdfs', 'http://www.w3.org/2000/01/rdf-schema#'); + + query.execute(function (err, data) { + var rawQuery = data.request.query; + expect(rawQuery).toHavePrefix({rdfs: 'http://www.w3.org/2000/01/rdf-schema#'}); + done(); + }); + }); + + it('should override prefixes in inherited from SparqlClient', function (done) { + var newPrefix = 'http://example.org/fake#dummy'; + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + client.registerCommon('rdfs'); + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.register('rdfs', newPrefix); + + query.execute(function (err, data) { + var rawQuery = data.request.query; + expect(rawQuery).not.toHavePrefix({rdfs: 'http://www.w3.org/2000/01/rdf-schema#'}); + expect(rawQuery).toHavePrefix({rdfs: newPrefix}); + done(); + }); + }); + + it('should affect only this query', function (done) { + var newPrefix = 'http://example.org/fake#dummy'; + var scope = nockEndpoint(); + var client = new SparqlClient(scope.endpoint); + client.registerCommon('rdfs'); + + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + query.register('dc', 'http://purl.org/dc/elements/1.1/'); + query.register('rdfs', newPrefix); + + var apresQuery = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + apresQuery.execute(function (err, data) { + var rawQuery = data.request.query; + expect(rawQuery).toHavePrefix({rdfs: 'http://www.w3.org/2000/01/rdf-schema#'}); + expect(rawQuery).not.toHavePrefix({dc: true}); + done(); + }); + }); + + it('should throw an error when binding suspicious URIs', function () { + var client = new SparqlClient('http://example.org/sparql'); + var query = client.query('SELECT ?s ?o WHERE { ?s rdfs:label ?o }'); + + /* This one is evil. */ + expect(function () { + query.register('dc', 'http://purl.org/dc/\u0000elements/1.1/'); + }).toThrow(); + + /* This is the same one, but less evil. */ + expect(function () { + query.register('dc', 'http://purl.org/dc/elements/1.1/'); + }).not.toThrow(); + + /* This is one is not a valid IRI: */ + expect(function () { + query.register('hw', 'http://example.org/hello>world'); + }).toThrow(); + + /* But the user can always explicitly encode it: */ + expect(function () { + query.register('hw', encodeURIComponent('http://example.org/hello>world')); + }).not.toThrow(); + }); + }); + + describe('#bind() [single]', function() { + + it('should bind a string literal', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .registerCommon('rdfs') + .query('SELECT ?s { ?s rdfs:label ?label }'); + query.bind('label', 'chat'); + + query.execute(function (error, data) { + var query = data.request.query; + expect(query).toMatch(/\?s\s+rdfs:label/); + /* Match any kind of string delimiter: ('|"|'''|""") ... \1 */ + expect(query).toMatch(/rdfs:label\s+('|"|'''|""")chat\1/); + done(); + }); + }); + + it('should bind a string literal with a language tag', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .registerCommon('rdfs') + .query('ASK { ?article rdfs:label ?label }') + .bind('label', 'chat', {lang: 'fr'}) + .bind('article', {value: 'die', lang: 'de'}); + + query.execute(function (error, data) { + var query = data.request.query; + /* Match any kind of string delimiter: ('|"|'''|""") ... \1 */ + expect(query).toMatch(/('|"|'''|""")die\1@de\s+rdfs:label/); + expect(query).toMatch(/rdfs:label\s+('|"|'''|""")chat\1@fr/); + done(); + }); + }); + + it('should reject invalid langauge tags', function () { + var query = new SparqlClient('http://example.org/sparql') + .registerCommon('rdfs') + .query('ASK { ?article rdfs:label ?label }'); + expect(function () { + query.bind('label', 'chat', {lang: ''}); + }).toThrow(); + expect(function () { + query.bind('label', {value: 'chat', lang: 'fr CA'}); + }).toThrow(); + }); + + it('should bind a literal with an arbitrary datatype, expressed as a URI', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .query('ASK { [] ex:v1 ?literal ; ex:v2 ?lateral }'); + query.bind('literal', 'xyz', {datatype: 'http://example.org/ns/userDatatype'}); + query.bind('lateral', {value: 'abc', datatype: 'http://example.org/ns/userDatatype'}); + + query.execute(function (error, data) { + var query = data.request.query; + /* Match any kind of string delimiter: ('|"|'''|""") ... \1 */ + expect(query).toMatch(/ex:v1\s+('|"|'''|""")xyz\1\^\^.+userDatatype\b/); + expect(query).toMatch(/ex:v2\s+('|"|'''|""")abc\1\^\^.+userDatatype\b/); + done(); + }); + }); + + it('should bind a literal with an arbitrary datatype, expressed as a prefixed URI', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .register({appNS: 'http://example.org/ns/'}) + .query('SELECT ?s { [] ?p ?literal }') + .bind('literal', 'xyz', {datatype: {appNS: 'appDataType'}}); + + query.execute(function (error, data) { + var query = data.request.query; + /* Match any kind of string delimiter: ('|"|'''|""") ... \1 */ + expect(query).toMatch(/\?p\s+('|"|'''|""")xyz\1\^\^appNS:appDataType\b/); + done(); + }); + }); + + it('should bind an integer literal to a query', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .register('ns', 'http://example.org/ns#') + .query('SELECT ?s { ?s ns:philsophy ?x ; ns:appendages ?y ; ns:termites ?z }') + .bind('x', {value: 42, type: 'integer' }) + .bind('y', '13', {datatype: {xsd: 'integer'}}) + .bind('z', -7, {type: 'integer'}); + + query.execute(function (error, data) { + var query = data.request.query; + expect(query).toMatch(/ns:philsophy\s+42\b/); + expect(query).toMatch(/ns:appendages\s+13\b/); + expect(query).toMatch(/ns:termites\s+-7\b/); + done(); + }); + }); + + it('should bind decimal literals to a query', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .register('db', 'http://example.org/dragonball#') + .query('ASK WHERE { ?s db:powerLevel ?level ; db:frappuchinoCost ?frap . FILTER ( ?level > ?power )') + /* Note that decimals MUST be passed as strings! */ + .bind('power', '9000.0', {datatype: {xsd: 'decimal'}}) + .bind('frap', '-4.75', {type: 'decimal'}); + + query.execute(function (error, data) { + var query = data.request.query; + + expect(query).toMatch(/\?level\s+>\s+9000.0\b/); + expect(query).toMatch(/db:frappuchinoCost\s+-4.75\b/); + done(); + }); + }); + + it('should bind a double literal to a query', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .register('ns', 'http://example.org/ns#') + .query('ASK WHERE { ?s ns:favouriteConstant ' + + ' ?google | ?pi_trunc | ?NaN | ?inf | ?unidentity ; }') + .bind('google', 1e100) + .bind('pi_trunc', 3.1415) + .bind('unidentity', -1) + .bind('NaN', NaN) + .bind('inf', -Infinity); + + query.execute(function (error, data) { + var query = data.request.query; + + expect(query).toMatch(/ns:favouriteConstant\s+1e\+?100\b/); + expect(query).toMatch(/\|\s+3.1415e\+?0\b/); + expect(query).toMatch(/\|\s+-1e\+?0\b/); + expect(query).toMatch(/\|\s('|")NaN\1\^\^xsd:double\b/); + expect(query).toMatch(/\|\s('|")-INF\1\^\^xsd:double\b/); + done(); + }); + }); + + it('should bind a URI to a query', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .query('ASK { [] ex:v1 ?literal ; ex:v2 ?lateral }'); + query.bind('literal', 'http://example.org/#thing', {type: 'uri'}); + query.bind('lateral', {value: 'http://example.org/#thang', type: 'uri'}); + + query.execute(function (error, data) { + var query = data.request.query; + expect(query).toMatch(/ex:v1\s+<.+#thing>/); + expect(query).toMatch(/ex:v2\s+<.+#thang>/); + done(); + }); + }); + + it('should bind a prefixed-URI to a query', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .register({appNS: 'http://example.org/ns/'}) + .query('SELECT ?s { [] a ?type }') + .bind('type', {appNS: 'thang'}); + + query.execute(function (error, data) { + var query = data.request.query; + expect(query).toMatch(/a\s+appNS:thang/); + expect(query).toHavePrefix({appNS: 'http://example.org/ns/'}); + done(); + }); + }); + + it('should bind booleans to a query', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .register({dinder: 'http://example.org/dinder/'}) + .query('SELECT ?s { ?s dinder:hasCats ?cats ; dinder:likesMichaelBolton ?bolton }') + .bind('cats', true) + .bind('bolton', false); + + query.execute(function (error, data) { + var query = data.request.query; + expect(query).toMatch(/dinder:hasCats\s+true/); + expect(query).toMatch(/dinder:likesMichaelBolton\s+false/); + // Swiping right is left as an exercise for the reader. + done(); + }); + }); + + it('should properly escape simple strings', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .registerCommon('rdfs') + .query('ASK { ?s rdfs:label ?value }') + .bind('value', '"' + "\\" + "'"); + + query.execute(function (error, data) { + var query = data.request.query; + /* Match any kind of string delimiter: ('|"|'''|""") ... \1 */ + expect(query).toMatch(/rdfs:label\s+('|"|'''|""")\\"\\\\\\'\1/); + done(); + }); + }); + + it('should properly escape multi-line strings', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .query('SELECT ?s {?s rdfs:label ?value}') + // NOTE: Escaped characters list: https://www.w3.org/TR/sparql11-query/#grammarEscapes + .bind('value', '"""' + "'''" + "\t\n\r\b\f" + "\\"); + + query.execute(function (error, data) { + var query = data.request.query; + /* I applogize for this regex... */ + expect(query).toMatch(/rdfs:label\s+('''|""")\\"\\"\\"\\'\\'\\'\\t\\n\\r\\b\\f\\\\\1/); + done(); + }); + }); + + it('should reject malformed IRIs', function () { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .query('SELECT ?s {?s rdfs:label ?value}'); + + /* There are A LOT of things that are not allowed in IRIs. */ + expect(function () { + query.bind('value', {rdfs: 'herp derp'}); + }).toThrow(); + + expect(function () { + query.bind('value', 'http://example.org/hello world', {type: 'uri'}); + }).toThrow(); + + expect(function () { + query.bind('value', encodeURIComponent('http://example.org/hello world')); + query.bind('value', encodeURIComponent('http://example.org/💩')); + }).not.toThrow(); + }); + + it('should not bind within already-bound strings', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .query('ASK WHERE { ?s a ?a ; rdfs:label ?b}') + .bind('a', '?b') + .bind('b', 'foo'); + + query.execute(function (error, data) { + var query = data.request.query; + /* Match any kind of string delimiter: ('|"|'''|""") ... \1 */ + expect(query).not.toMatch(/a\s+((?:'|"|'''|""")?)foo\1/); + expect(query).toMatch(/\ba\s+('|"|'''|""")\?b\1/); + expect(query).toMatch(/rdfs:label\s+('|"|'''|""")foo\1/); + done(); + }); + }); + + it('should reject encoding a null-terminator in strings', function () { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .query('SELECT ?s {?s ?p ?value}'); + + expect(function () { + query.bind('value', "Literally any\u0000where in the string"); + }).toThrow(); + }); + + it('should present a fluent interface', function () { + var query = new SparqlClient('http://example.org/sparql') + .query('SELECT ?s { ?s rdfs:label ?label }') + .bind('label', 'chat'); + + /* Check that it has some core methods. */ + expect(query).toEqual(jasmine.objectContaining({ + bind: jasmine.any(Function), + execute: jasmine.any(Function) + })); + }); + }); + + + describe('#bind() [multiple]', function () { + it('should bind an object of single bindings', function (done) { + var scope = nockEndpoint(); + var originalQuery = 'SELECT DISTINCT ?type\n' + + ' WHERE {\n' + + ' [] rdfs:label ?creature ;\n' + + ' dbfr:paws ?paws ;\n' + + ' dbfr:netWorth ?netWorth ;\n' + + ' dbfr:weight ?weight ;\n' + + ' dbfr:grumpy ?grumpy ;\n' + + ' dbfr:derrivedFrom ?derrivedFrom .\n' + + '}'; + var query = new SparqlClient(scope.endpoint) + .register('dbfr', 'http://fr.dbpedia.org/') + .query(originalQuery) + .bind({ + creature: {value: 'chat', lang: 'fr'}, + paws: {value: 4, type: 'integer'}, + netWorth: {value: '16777216.25', type: 'decimal'}, // francs + weight: 3.18, // kg + grumpy: true, + derrivedFrom: {value:'http://fr.wikipedia.org/wiki/Grumpy_Cat?oldid=94581698', type:'uri'}, + }); + + query.execute(function (error, data) { + var query = data.request.query; + + /* Match any kind of string delimiter: ('|"|'''|""") ... \1 */ + expect(query).toMatch(/rdfs:label\s+('|"|'''|""")chat\1@fr\b/); + expect(query).toMatch(/dbfr:paws\s+4\b/); + expect(query).toMatch(/dbfr:netWorth\s+16777216.25\b/); + expect(query).toMatch(/dbfr:weight\s+3.18e\+?0\b/); + expect(query).toMatch(/dbfr:grumpy\s+true\b/); + expect(query).toMatch(/dbfr:derrivedFrom\s+/); + + done(); + }); + }); + }); + + describe('#execute()', function() { + it('should execute a simple query', function(done) { + var scope = nockEndpoint(); + var originalQuery = 'SELECT DISTINCT ?type { [] a ?type ; rdf:label ?p }'; + + new SparqlClient(scope.endpoint) + .query(originalQuery) + .execute(function (error, results) { + expect(error).toBeFalsy(); + expect(results).toBeTruthy(); + expect(results.request.query).toBe(originalQuery); + done(); + }); + }); + + it('should execute a query with bindings', function (done) { + var scope = nockEndpoint(); + var originalQuery = 'SELECT DISTINCT ?s { ?s a ?type }'; + new SparqlClient(scope.endpoint) + .registerCommon('xsd') + .query(originalQuery) + .bind({ + type: {xsd: 'integer'} + }) + .execute(function (error, results) { + var query = results.request.query; + expect(query).toHavePrefix({xsd: true}); + expect(query).toContain('xsd:integer'); + expect(query).toMatch(/\?s\s+a\s+xsd:integer\b/); + done(); + }); + }); + + it('should execute a query with update keywords', function (done) { + var scope = nockEndpoint(); + var query = 'SELECT ("""INSERT DATA and DELETE""" as ?update) { }'; + + new SparqlClient(scope.endpoint) + .query(query) + .execute(function (error, results) { + expect(results.request.update).toBeUndefined(); + expect(results.request.query).toContain(query); + done(); + }); + }); + + it('should accept arbitrary options', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .query('SELECT ("hello" as ?var) { }') + .execute({format: {resource: 'book'}}, function (err, data) { + done(); + }); + }); + + it('should allow for alternate query and update endpoints', function (done) { + var queryScope = nockEndpoint(); + var updateScope = nockEndpoint(null, null, { + endpoint: 'http://example.org/update' + }); + + var client = new SparqlClient(queryScope.endpoint, { + updateEndpoint: updateScope.endpoint + }); + + // The query + client.query('SELECT ("""INSERT DATA and DELETE""" as ?update) { }') + .execute() + .then(function (results) { + expect(results.request.update).toBeUndefined(); + }) + .catch(function (err) { + fail(err); + }) + // The update + .then(function () { + return client + .query('INSERT DATA { pkmn:Lotad pkdx:evolvesTo pkmn:Lombre }') + .execute(); + }) + .then(function (results) { + expect(results.request.update).toBeTruthy(); + done(); + }) + .catch(function (err) { + fail(err); + }); + }); + + it('should allow for empty response bodies', function (done) { + var queryScope = nockEndpoint(204, ' '); + + var client = new SparqlClient(queryScope.endpoint); + + // The query + client.query('INSERT DATA { pkmn:Lotad pkdx:evolvesTo pkmn:Lombre }') + .execute() + .then(function (results) { + expect(results).toBeNull(); + done(); + }) + .catch(function (err) { + fail(err); + }); + }); + + it('should return a error message on request failure', function (done) { + /* Based on + * https://www.w3.org/TR/rdf-sparql-protocol/#select-malformed + */ + var errorStatus = 400; + var content = "4:syntax error, unexpected ORDER, expecting '}'"; + var scope = nockEndpoint(errorStatus); + + new SparqlClient(scope.endpoint) + .query('SELECT ?name WHERE { ?x foaf:name ?name ORDER BY ?name }') + .execute(function (err, data) { + expect(err).toBeDefined(); + expect(err.message).toMatch(/\b400\b/); + expect(err.message).toMatch(/\bBad Request\b/i); + + /* It should also return the http status. */ + expect(err.httpStatus).toBe(400); + done(); + }); + }); + + it('should return a promise', function (done) { + var scope = nockEndpoint(200, {hello: 'world'}); + var promise = new SparqlClient(scope.endpoint) + .query('SELECT ("hello" as ?var) { }') + .execute(); + + expect(promise).toBeDefined(); + expect(promise.then).toBeDefined(); + promise.then(function (data) { + expect(data.hello).toEqual('world'); + done(); + }); + }); + + it('should handle return a failed promise', function (done) { + var scope = nockEndpoint(400); + var promise = new SparqlClient(scope.endpoint) + .query('SELECT ("hello" as ') + .execute(); + + promise + .then(function () { + fail('Must not fulfill promise; should reject instead'); + done(); + }) + .catch(function (error) { + expect(error.httpStatus).toBe(400); + done(); + }); + }); + + it('should accept arbitrary options when called as a promise', function (done) { + var scope = nockEndpoint(200, require('./fixtures/got-genres')); + var query = new SparqlClient(scope.endpoint) + .query('SELECT ?book ?genre { }') + .execute({format: {resource: 'book'}}) + .then(done) + .catch(function (error) { + fail('Must not reject promise; must fulfill instead'); + done(); + }); + }); + }); + }); +}); +/*globals jasmine,describe,it,expect,beforeEach,fail*/ +/*globals nockEndpoint*/ diff --git a/spec/readme-spec.js b/spec/readme-spec.js new file mode 100644 index 0000000..fefedb9 --- /dev/null +++ b/spec/readme-spec.js @@ -0,0 +1,126 @@ +/** + * These test the README examples. + */ + +var nock = require('nock'); + +describe('The README examples', function () { + + describe('basic usage', function() { + /* Install the Nock endpoint. */ + beforeEach(function () { + nock.cleanAll(); + this.endpoint = nockEndpoint(200, require('./fixtures/leader-names'), { + endpoint: 'http://dbpedia.org/sparql' + }); + }); + + it('should work with node-style callbacks', function (done) { + var SparqlClient = require('../'); + var endpoint = 'http://dbpedia.org/sparql'; + + // Get the leaderName(s) of the given cities + // if you do not bind any city, it returns 10 random leaderNames + var query = "SELECT * FROM WHERE { " + + " ?city ?leaderName " + + "} LIMIT 10"; + var client = new SparqlClient(endpoint) + .register({db: 'http://dbpedia.org/resource/'}); + + client.query(query) + .bind('city', {db: 'Vienna'}) + .execute(function(error, results) { + expect(error).toBeFalsy(); + + expect(results.head) + .toEqual(require('./fixtures/leader-names').head); + expect(results.results) + .toEqual(require('./fixtures/leader-names').results); + + done(); + }); + }); + + it('should work with promises', function (done) { + var SparqlClient = require('../'); + var endpoint = 'http://dbpedia.org/sparql'; + + // Get the leaderName(s) of the given cities + // if you do not bind any city, it returns 10 random leaderNames + var query = "SELECT * FROM WHERE { " + + " ?city ?leaderName " + + "} LIMIT 10"; + var client = new SparqlClient(endpoint) + .register({db: 'http://dbpedia.org/resource/'}); + + client.query(query) + .bind('city', {db: 'Vienna'}) + .execute() + .then(function (results) { + expect(results.head) + .toEqual(require('./fixtures/leader-names').head); + expect(results.results) + .toEqual(require('./fixtures/leader-names').results); + + done(); + }) + .catch(function (error) { + fail('Control should never reach here.'); + done(); + }); + }); + }); + + describe('Formatting style', function () { + + /* Install the Nock endpoint. */ + beforeEach(function () { + nock.cleanAll(); + this.endpoint = nockEndpoint(200, require('./fixtures/got-genres'), { + endpoint: 'http://dbpedia.org/sparql' + }); + }); + + it('should format the results', function (done) { + var SparqlClient = require('../'); + var endpoint = 'http://dbpedia.org/sparql'; + + // Get the leaderName(s) of the given cities + // if you do not bind any city, it returns 10 random leaderNames + var query = "SELECT ?book ?genre WHERE { ?book dbpedia-owl:literaryGenre ?genre . }"; + var client = new SparqlClient(endpoint) + .register({'dbpedia': 'http://dbpedia.org/resource/'}) + .register({'dbpedia-owl': 'http://dbpedia.org/ontology/'}); + + client.query(query) + .bind({got: {dbpedia: 'A_Game_of_Thrones'}}) + .execute({format: {resource: 'book'}}) + .then(function (results) { + expect(results.results.bindings).toEqual([ + { + book: { + type: 'uri', + value: 'http://dbpedia.org/resource/A_Game_of_Thrones' + }, + genre: [ + { + type: 'uri', + value: 'http://dbpedia.org/resource/Fantasy' + }, + { + type: 'uri', + value: 'http://dbpedia.org/resource/Political_strategy' + } + ] + } + ]); + + done(); + }) + .catch(function (error) { + fail('Control should never reach here.'); + done(); + }); + }); + }); +}); diff --git a/spec/regressions-spec.js b/spec/regressions-spec.js new file mode 100644 index 0000000..bb6a853 --- /dev/null +++ b/spec/regressions-spec.js @@ -0,0 +1,84 @@ +var nock = require('nock'); + +var SparqlClient = require('../'); + +describe('GitHub Issues', function () { + describe('thomasfr/node-sparql-client#6', function () { + it('should not crash on HTTP response error', function (done) { + var host = 'http://example.org'; + var endpoint = 'http://example.org/sparql'; + + nock(host) + .post('/sparql') + .reply(503, { result: {} }); + + var client = new SparqlClient(endpoint); + client + .query('SELECT ("hello" as ?var) { }') + .execute(function (err, _response) { + expect(err).toBeTruthy(); + done(); + }); + }); + }); + + describe('thomasfr/node-sparql-client#11', function () { + it('should not maintain state after doing an update', function (done) { + var host = 'http://example.org'; + var endpoint = 'http://example.org/sparql'; + + var scope = nock(host) + .post('/sparql') + .twice() + .reply(200, replyWithArgsPresent); + + var client = new SparqlClient(endpoint); + client + .query('INSERT DATA { [] rdfs:label "hello" }') + .execute(function (err1, data) { + expect(err1).toBeFalsy(); + expect(data.update).toBeTruthy(); + expect(data.query).toBeFalsy(); + + client + .query('SELECT ("hello" as ?var) { }') + .execute(function (err2, data) { + expect(err2).toBeFalsy(); + expect(data.update).toBeFalsy(); + expect(data.query).toBeTruthy(); + + scope.done(); + done(); + }); + }); + + function replyWithArgsPresent(uri, body) { + return { + update: !!body.match(/update=/), + query: !!body.match(/query=/) + }; + } + }); + }); + + describe('#8', function () { + it('should accept dashes in IRIs', function (done) { + var scope = nockEndpoint(); + var query = new SparqlClient(scope.endpoint) + .query('ASK { [] ex:v1 ?literal ; [] ex:v1 ?lateral }'); + + // This IRI has a dash in it: + query.bind('literal', 'http://example.org/this-is-valid', {type: 'uri'}); + // This is an internationalized URI, with dashes! + query.bind('literal', 'http://💩.la/this-is-valid', {type: 'uri'}); + + query.execute(function (error, data) { + expect(error).toBeFalsy(); + expect(data).toBeTruthy(); + done(); + }); + }); + }); +}); + +/* global nockEndpoint */ diff --git a/spec/support/jasmine.json b/spec/support/jasmine.json new file mode 100644 index 0000000..a5f2932 --- /dev/null +++ b/spec/support/jasmine.json @@ -0,0 +1,9 @@ +{ + "spec_dir": "spec", + "spec_files": [ + "**/*[sS]pec.js" + ], + "helpers": [ + "helpers/**/*.js" + ] +} diff --git a/spec/tagged-template-spec.js b/spec/tagged-template-spec.js new file mode 100644 index 0000000..e226d13 --- /dev/null +++ b/spec/tagged-template-spec.js @@ -0,0 +1,54 @@ +/** + * Note: This source is meant to work on the current version of io.js (2.3.1, + * as of July 1, 2015). That is, this should run without a transpiler (like + * traceur). As of now, this means no object destructuring: + * + * const {SPARQL} = require('sparql-client'); + */ + +const SPARQL = require('../').SPARQL; + +describe('SPARQL (tagged templates)', function () { + + it('should not change a standard query', function () { + var query; + expect(function () { + query = SPARQL`BASE + PREFIX ns: + + SELECT ?foo + FROM + WHERE { :book a ?foo } + LIMIT 10`; + }).not.toThrow(); + + expect(query.split(/\s+/)).toEqual([ + 'BASE', '', + 'PREFIX', 'ns:', '', + 'SELECT', '?foo', + 'FROM', '', + 'WHERE', '{', ':book', 'a', '?foo', '}', + 'LIMIT', '10' + ]); + + }); + + it('should interpolate literals', function () { + var reasonableInput = `They said, "Howdy, y'all."`; + var query = + SPARQL`BASE + PREFIX auth: + PREFIX ns: + + INSERT DATA { + :book ns:quote ${reasonableInput} ; + ns:author ${{auth: 'Some_Dude_I_guess'}} + ns:price ${{value: '4.60', type: 'decimal'}} + ns:rating ${-Infinity} + }`; + expect(query).toMatch(/ns:quote\s+('''|""").+?\\"Howdy, y\\'all.\\"\1/); + expect(query).toMatch(/ns:author\s+auth:Some_Dude_I_guess\b/); + expect(query).toMatch(/ns:price\s+4.60\b/); + expect(query).toMatch(/ns:rating\s+(['"])-INF\1/); + }); +}); diff --git a/tests/dbpedia-formatting1.js b/tests/dbpedia-formatting1.js deleted file mode 100644 index 12826d6..0000000 --- a/tests/dbpedia-formatting1.js +++ /dev/null @@ -1,17 +0,0 @@ -var util = require('util'); - -var SparqlClient = require('../'); -var endpoint = 'http://dbpedia.org/sparql'; - -// Get a list of books including their genres. The list will be formatted or grouped by genres -var query = "SELECT ?book ?genre WHERE { ?book ?genre } LIMIT 500"; -var client = new SparqlClient(endpoint); -console.log("Query to " + endpoint); -console.log("Query: " + query); -client.query(query) - .execute({ - format: 'resource', - resource: 'genre' - }, function (error, results) { - process.stdout.write(util.inspect(arguments, null, 20, true) + "\n"); - }); diff --git a/tests/dbpedia-query1.js b/tests/dbpedia-query1.js deleted file mode 100644 index 9585a7b..0000000 --- a/tests/dbpedia-query1.js +++ /dev/null @@ -1,11 +0,0 @@ -var util = require('util'); - -var SparqlClient = require('../'); -var endpoint = 'http://dbpedia.org/sparql'; -var query = 'select distinct ?Concept from where {[] a ?Concept} limit 100'; -var client = new SparqlClient(endpoint); -console.log("Query to " + endpoint); -console.log("Query: " + query); -client.query(query, function (error, results) { - process.stdout.write(util.inspect(arguments, null, 20, true) + "\n"); -}); diff --git a/tests/dbpedia-query2.js b/tests/dbpedia-query2.js deleted file mode 100644 index fd9852c..0000000 --- a/tests/dbpedia-query2.js +++ /dev/null @@ -1,23 +0,0 @@ -var util = require('util'); - -var SparqlClient = require('../'); -var endpoint = 'http://dbpedia.org/sparql'; - -// Get the leaderName(s) of the given citys -// if you do not bind any city, it returns 10 random leaderNames -var query = "SELECT ?city ?leaderName FROM WHERE { ?city ?leaderName } LIMIT 1000"; -var client = new SparqlClient(endpoint); -console.log("Query to " + endpoint); -console.log("Query: " + query); -client.query(query) - //.bind('city', 'db:Chicago') - //.bind('city', '') - //.bind('city', 'db:Casablanca') - //.bind('city', '') - //.bind('city', '') - .execute({ - format: 'default', - resource: 'city' - }, function (error, results) { - process.stdout.write(util.inspect(arguments, null, 20, true) + "\n"); - }); diff --git a/tests/poolparty-query1.js b/tests/poolparty-query1.js deleted file mode 100644 index a53c022..0000000 --- a/tests/poolparty-query1.js +++ /dev/null @@ -1,11 +0,0 @@ -var SparqlClient = require('../'); -var util = require('util'); -var endpoint = 'http://pp.punkt.at/PoolParty/sparql/GeoTestThesaurus'; -var query = "select distinct * where {?country ?narrower}"; -var country = ""; -var client = new SparqlClient(endpoint); -console.log("Query to " + endpoint); -console.log("Query: " + query); -client.query(query).bind('country', country).execute(function (error, results) { - process.stdout.write(util.inspect(arguments, null, 20, true) + "\n"); -}); \ No newline at end of file