├── conf.json ├── public ├── favicon.ico ├── GitHub-Mark-120px-plus.png ├── style.css └── app.js ├── .babelrc ├── .travis.yml ├── webpack.dev.config.js ├── test ├── bob.js ├── alice.js └── setup.js ├── webpack.prod.config.js ├── src ├── index.js ├── rdf │ ├── quad.js │ ├── node.spec.js │ ├── node.js │ ├── serialize.js │ ├── graph.js │ ├── graph.spec.js │ └── serialize.spec.js ├── util.js ├── util.spec.js ├── backends │ ├── in-memory-backend.js │ ├── backend.spec.js │ ├── backend.js │ ├── web-backend.js │ ├── in-memory-backend.spec.js │ └── web-backend.spec.js ├── lang │ ├── tokens.js │ ├── ast.js │ ├── lexer.spec.js │ ├── lexer.js │ ├── parser.js │ └── parser.spec.js ├── web.spec.js ├── web.js ├── errors.js ├── query.spec.js └── query.js ├── webpack.base.config.js ├── .gitignore ├── LICENSE ├── index.html ├── grammar.txt ├── package.json └── README.md /conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["node_modules/jsdoc-strip-async-await"] 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deiu/twinql/master/public/favicon.ico -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env"], 3 | "plugins": ["transform-runtime", "transform-object-rest-spread"] 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 7.3.0 4 | after_success: 5 | - npm run coverage:coveralls 6 | -------------------------------------------------------------------------------- /public/GitHub-Mark-120px-plus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deiu/twinql/master/public/GitHub-Mark-120px-plus.png -------------------------------------------------------------------------------- /webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | const config = require('./webpack.base.config') 2 | 3 | module.exports = Object.assign({}, config, { 4 | devtool: 'inline-source-map' 5 | }) 6 | -------------------------------------------------------------------------------- /test/bob.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | @prefix foaf: . 3 | 4 | <#bob> a foaf:Person 5 | ; foaf:name "Bob" 6 | ; foaf:knows 7 | . 8 | ` 9 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | 3 | const config = require('./webpack.base.config') 4 | 5 | module.exports = Object.assign({}, config, { 6 | plugins: [ 7 | new webpack.optimize.UglifyJsPlugin() 8 | ] 9 | }) 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import query from './query' 2 | import Backend from './backends/backend' 3 | import InMemoryBackend from './backends/in-memory-backend' 4 | import WebBackend from './backends/web-backend' 5 | 6 | /** 7 | * The main entrypoint. Exports the query function and available backends. 8 | * @module 9 | */ 10 | 11 | export { query, Backend, InMemoryBackend, WebBackend } 12 | -------------------------------------------------------------------------------- /src/rdf/quad.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | 3 | /** 4 | * Provides functionality for dealing with RDF Quads. 5 | * @module 6 | */ 7 | 8 | /** 9 | * An Immutable.Record to represent RDF Quads 10 | * @class 11 | * @extends external:Immutable.Record 12 | */ 13 | const Quad = Immutable.Record({ 14 | subject: null, 15 | predicate: null, 16 | object: null, 17 | graph: null 18 | }) 19 | 20 | export default Quad 21 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | 3 | /** 4 | * Iterates over key, value pairs in an object 5 | * @param {Object} obj 6 | * @returns {Iterator} an iterator yielding arrays of the form [k, v] 7 | */ 8 | export function * iterObj (obj) { 9 | for (let k of Object.keys(obj)) { 10 | yield [k, obj[k]] 11 | } 12 | } 13 | 14 | export const immutableHashCode = (...vals) => Immutable.List(vals).hashCode() 15 | -------------------------------------------------------------------------------- /webpack.base.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: ['./src/index.js'], 5 | output: { 6 | filename: 'bundle.js', 7 | path: path.resolve(__dirname, 'build'), 8 | library: 'twinql', 9 | libraryTarget: 'umd' 10 | }, 11 | module: { 12 | loaders: [ 13 | { 14 | test: /\.js$/, 15 | exclude: /(node_modules)/, 16 | loader: 'babel-loader' 17 | } 18 | ] 19 | }, 20 | externals: { 21 | child_process: 'child_process', 22 | fs: 'fs' 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/util.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { iterObj } from './util' 3 | 4 | describe('util', () => { 5 | describe('iterObj', () => { 6 | it('iterates over the key/value pairs in plain objects', () => { 7 | const obj = { 8 | one: 'ONE', 9 | two: 2, 10 | three: x => x * 2 11 | } 12 | const foundKeyValPairs = [] 13 | for (let kvp of iterObj(obj)) { 14 | foundKeyValPairs.push(kvp) 15 | } 16 | expect(Object.keys(obj)).to.eql(foundKeyValPairs.map(([k, v]) => k)) 17 | expect(Object.values(obj)).to.eql(foundKeyValPairs.map(([k, v]) => v)) 18 | }) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/alice.js: -------------------------------------------------------------------------------- 1 | export default ` 2 | @prefix foaf: . 3 | @prefix dbo: . 4 | @prefix pim: . 5 | @prefix solid: . 6 | @prefix xsd: . 7 | 8 | <#alice> a foaf:Person 9 | ; foaf:name "Alice" 10 | ; foaf:knows 11 | ; foaf:knows <#spot> 12 | ; foaf:age "24"^^xsd:integer 13 | ; foaf:based_near "Estados Unidos"@es 14 | ; pim:storage 15 | ; solid:publicTypeIndex 16 | . 17 | 18 | <#spot> a dbo:Dog 19 | ; foaf:name "Spot" 20 | ; foaf:knows <#alice> 21 | . 22 | ` 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | .DS_Store 40 | build 41 | docs 42 | lib 43 | -------------------------------------------------------------------------------- /src/backends/in-memory-backend.js: -------------------------------------------------------------------------------- 1 | import Backend from './backend' 2 | 3 | /** 4 | * Implements a backend for a single in-memory graph. 5 | * @module 6 | */ 7 | 8 | /** 9 | * A backend for an in-memory graph 10 | * @extends {module:backends/backend~Backend} 11 | */ 12 | class InMemoryBackend extends Backend { 13 | /** 14 | * Create an InMemoryBackend 15 | * @param {module:rdf/graph~Graph} graph - the local graph 16 | */ 17 | constructor (graph) { 18 | super() 19 | this.graph = graph 20 | } 21 | 22 | async getObjects (subject, predicate) { 23 | return this.graph.match({ subject, predicate }) 24 | } 25 | 26 | async getSubjects (predicate, object, namedGraph) { 27 | return this.graph.match({ 28 | predicate, 29 | object, 30 | graph: namedGraph || null 31 | }) 32 | } 33 | } 34 | 35 | export default InMemoryBackend 36 | -------------------------------------------------------------------------------- /src/backends/backend.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { spy } from 'sinon' 3 | 4 | import Backend from './backend' 5 | 6 | describe('backend', () => { 7 | describe('Backend', () => { 8 | it('has an abstract getObjects method', () => { 9 | return expect(new Backend().getObjects()) 10 | .to.be.rejectedWith(/not implemented/) 11 | }) 12 | 13 | it('has an abstract getSubjects method', () => { 14 | return expect(new Backend().getSubjects()) 15 | .to.be.rejectedWith(/not implemented/) 16 | }) 17 | 18 | describe('events', () => { 19 | it('can register and trigger event handlers', () => { 20 | const spy1 = spy() 21 | const spy2 = spy() 22 | const spy3 = spy() 23 | const backend = new Backend() 24 | backend.on('myEvent', spy1) 25 | backend.on('myEvent', spy2) 26 | backend.on('someOtherEvent', spy3) 27 | backend.trigger('myEvent') 28 | expect(spy1).to.have.been.calledOnce 29 | expect(spy2).to.have.been.calledOnce 30 | expect(spy3).not.to.have.been.called 31 | }) 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Daniel Friedman 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lang/tokens.js: -------------------------------------------------------------------------------- 1 | // An enumeration of language tokens 2 | export const tokenTypes = { 3 | URI: 'URI', 4 | PREFIXED_URI: 'PREFIXED_URI', 5 | NAME: 'NAME', 6 | LPAREN: 'LPAREN', 7 | RPAREN: 'RPAREN', 8 | LBRACE: 'LBRACE', 9 | RBRACE: 'RBRACE', 10 | LSQUARE: 'LSQUARE', 11 | RSQUARE: 'RSQUARE', 12 | STRLIT: 'STRLIT', 13 | ARROW: 'ARROW', 14 | PREFIX: 'PREFIX', 15 | EOF: 'EOF' 16 | } 17 | 18 | // A mapping from token text to token types 19 | export const keywords = { 20 | '=>': tokenTypes.ARROW, 21 | '@prefix': tokenTypes.PREFIX 22 | } 23 | 24 | // A mapping from symbols to token types 25 | export const symbols = { 26 | '(': tokenTypes.LPAREN, 27 | ')': tokenTypes.RPAREN, 28 | '{': tokenTypes.LBRACE, 29 | '}': tokenTypes.RBRACE, 30 | '[': tokenTypes.LSQUARE, 31 | ']': tokenTypes.RSQUARE 32 | } 33 | 34 | export const NAME_REGEX = /^[a-zA-Z]+[a-zA-Z0-9_-]*$/ 35 | 36 | // Captures two groups. The first is the prefixed name and the second is the 37 | // path. 38 | export const PREFIXED_URI_REGEX = /^([a-zA-Z]+[a-zA-Z0-9_-]*):(.+)$/ 39 | 40 | export class Token { 41 | constructor (type, value, line, column) { 42 | this.type = type 43 | this.value = value 44 | this.line = line 45 | this.column = column 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/rdf/node.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import Immutable from 'immutable' 3 | 4 | import { Node, nodeSet } from './node' 5 | 6 | describe('node', () => { 7 | describe('Node', () => { 8 | it('constructs an immutable record', () => { 9 | expect(Node()).to.be.an.instanceof(Immutable.Record) 10 | }) 11 | 12 | it('creates default fields for `termType`, `value`, `language`, and `datatype`', () => { 13 | const node = Node() 14 | expect(node.termType).to.be.null 15 | expect(node.value).to.be.null 16 | expect(node.language).to.be.null 17 | expect(node.datatype).to.be.null 18 | expect(node.notRealField).to.be.undefined 19 | }) 20 | }) 21 | 22 | describe('nodeSet', () => { 23 | it('creates an immutable set', () => { 24 | expect(nodeSet()).to.be.an.instanceof(Immutable.Set) 25 | }) 26 | 27 | it('converts its iterable argument to Nodes', () => { 28 | const ns = nodeSet([ 29 | { termType: 'NamedNode', value: 'https://example.com/' } 30 | ]) 31 | const first = ns.first() 32 | expect(first).to.be.an.instanceof(Immutable.Record) 33 | expect(first.termType).to.equal('NamedNode') 34 | expect(first.value).to.equal('https://example.com/') 35 | }) 36 | }) 37 | }) 38 | -------------------------------------------------------------------------------- /src/web.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import nock from 'nock' 3 | 4 | import { HttpError } from './errors' 5 | import { fetchGraph } from './web' 6 | import Graph from './rdf/graph' 7 | 8 | describe('web', () => { 9 | describe('fetchGraph', () => { 10 | const DOMAIN = 'http://localhost:8000' 11 | 12 | afterEach(() => { 13 | nock.cleanAll() 14 | }) 15 | 16 | it('rejects on non-2XX statuses', () => { 17 | nock(DOMAIN) 18 | .get('/graph') 19 | .reply(400) 20 | 21 | return expect(fetchGraph(`${DOMAIN}/graph`)) 22 | .to.be.rejectedWith('Bad Request') 23 | }) 24 | 25 | it('rejects on timeout', () => { 26 | nock(DOMAIN) 27 | .get('/graph') 28 | .reply((uri, requestBody, cb) => setTimeout(() => cb( 29 | null, 30 | [200, '', { 'Content-Type': 'text/turtle' }] 31 | ), 10)) 32 | 33 | return expect(fetchGraph(`${DOMAIN}/graph`, { timeout: 0 })) 34 | .to.be.rejectedWith('Request timed out') 35 | }) 36 | 37 | it('resolves to the indexed graph stored at the given URI', () => { 38 | nock(DOMAIN) 39 | .get('/graph') 40 | .reply(200, '', { 'Content-Type': 'text/turtle' }) 41 | 42 | return expect(fetchGraph(`${DOMAIN}/graph`)) 43 | .to.eventually.eql(new Graph()) 44 | }) 45 | }) 46 | }) 47 | -------------------------------------------------------------------------------- /src/rdf/node.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | 3 | /** 4 | * Provides immutable datatypes for dealing with RDF nodes. 5 | * @module 6 | */ 7 | 8 | /** 9 | * @external Immutable 10 | * @see https://facebook.github.io/immutable-js/docs/#/ 11 | */ 12 | 13 | /** 14 | * @class Set 15 | * @memberof external:Immutable 16 | * @see https://facebook.github.io/immutable-js/docs/#/Set 17 | */ 18 | 19 | /** 20 | * @class Record 21 | * @memberof external:Immutable 22 | * @see https://facebook.github.io/immutable-js/docs/#/Record 23 | */ 24 | 25 | /** 26 | * A union type for nodes in the graph 27 | * @typedef {(module:parsetree.Uri|module:parsetree.StringLiteral|module:rdf/node.NodeRecord)} NodeLike 28 | */ 29 | 30 | /** 31 | * An immutable set of Nodes 32 | * @typedef NodeSet {external:Immutable.Set} 33 | */ 34 | 35 | /** 36 | * An Immutable.Record to represent RDF Nodes 37 | * @class 38 | * @extends external:Immutable.Record 39 | */ 40 | export const Node = Immutable.Record({ 41 | termType: null, 42 | value: null, 43 | language: null, 44 | datatype: null 45 | }) 46 | 47 | /** 48 | * Constructs an {@link external:Immutable.Set} of {@link module:rdf/node.NodeRecord}s. 49 | * 50 | * @param {Array} [nodes=[]] - A list of RDF Nodes 51 | * @return {NodeSet} the set of nodes 52 | */ 53 | export function nodeSet (nodes = []) { 54 | return Immutable.Set(nodes.map(Node)) 55 | } 56 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | twinql pad 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |

13 | twinql pad 14 | Github 15 |

16 | 24 |
25 |
26 |

Query

27 | 28 | 29 |
30 |
31 |
32 |

Response

33 |

34 |       
35 |
36 | 37 | 38 | -------------------------------------------------------------------------------- /src/web.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch' 2 | 3 | import { HttpError, RdfParseError } from './errors' 4 | import { parseNQuads } from './rdf/serialize' 5 | 6 | const TIMEOUT = 5000 7 | 8 | /** 9 | * Fetches a named graph over HTTP, parses the body, and returns the 10 | * corresponding graph 11 | * @param {String} graphName - the name (URI) of the graph to fetch 12 | * @param {String} [proxyUri=''] - the URI of a Solid agent 13 | * @param {Object} headers - headers to add to the request 14 | * @returns {Promise} the fetched graph 15 | * @throws {module:errors~HttpError} An {@link module:errors~HttpError} may be 16 | * thrown if a non-2XX status code is returned 17 | */ 18 | export async function fetchGraph (graphName, { proxyUri = '', headers = {}, timeout = TIMEOUT } = {}) { 19 | let response 20 | try { 21 | response = await Promise.race([ 22 | fetch(proxyUri + graphName, { headers: {...headers, 'accept': 'text/turtle' } }) // eslint-disable-line 23 | .then(throwIfBadStatus), 24 | new Promise((resolve, reject) => setTimeout(() => reject(new Error('Request timed out')), timeout)) 25 | ]) 26 | } catch (e) { 27 | throw e.name === 'HttpError' 28 | ? e 29 | : new HttpError({ statusText: e.message, status: 0 }) 30 | } 31 | const text = await response.text() 32 | try { 33 | return parseNQuads(text, graphName) 34 | } catch (e) { 35 | throw new RdfParseError(e.message) 36 | } 37 | } 38 | 39 | function throwIfBadStatus (response) { 40 | if (response.status >= 200 && response.status < 300) { 41 | return response 42 | } else { 43 | throw new HttpError(response) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /grammar.txt: -------------------------------------------------------------------------------- 1 | QUERY 2 | : PREFIX_LIST CONTEXT CONTEXT_SENSITIVE_QUERY 3 | 4 | PREFIX_LIST 5 | : PREFIX PREFIX_LIST 6 | | 7 | 8 | PREFIX 9 | : @prefix NAME FULLY_SPECIFIED_URI 10 | 11 | CONTEXT 12 | : IDENTIFIER 13 | 14 | CONTEXT_SENSITIVE_QUERY 15 | : NODE_SPECIFIER TRAVERSAL 16 | 17 | NODE_SPECIFIER 18 | : MATCHING_NODE_SPECIFIER 19 | | EMPTY_NODE_SPECIFIER 20 | 21 | MATCHING_NODE_SPECIFIER 22 | : CONTEXT_TYPE ( MATCH_LIST ) 23 | 24 | EMPTY_NODE_SPECIFIER 25 | : 26 | 27 | CONTEXT_TYPE 28 | : SUBJECT_CONTEXT_TYPE 29 | | GRAPH_CONTEXT_TYPE 30 | 31 | SUBJECT_CONTEXT_TYPE 32 | : 33 | 34 | GRAPH_CONTEXT_TYPE 35 | : => 36 | 37 | MATCH_LIST 38 | : MATCH MATCH_LIST 39 | | MATCH 40 | 41 | MATCH 42 | : LEAF_MATCH 43 | | INTERMEDIATE_MATCH 44 | 45 | LEAF_MATCH 46 | : PREDICATE VALUE 47 | 48 | INTERMEDIATE_MATCH 49 | : PREDICATE NODE_SPECIFIER 50 | 51 | PREDICATE 52 | : IDENTIFIER 53 | 54 | TRAVERSAL 55 | : { DIRECTIVE_LIST } 56 | 57 | DIRECTIVE_LIST 58 | : DIRECTIVE DIRECTIVE_LIST 59 | | DIRECTIVE 60 | 61 | DIRECTIVE 62 | : SELECTOR 63 | 64 | SELECTOR 65 | : LEAF_SELECTOR 66 | | INTERMEDIATE_SELECTOR 67 | 68 | LEAF_SELECTOR 69 | : EDGE 70 | 71 | INTERMEDIATE_SELECTOR 72 | : EDGE CONTEXT_SENSITIVE_QUERY 73 | 74 | EDGE 75 | : SINGLE_EDGE 76 | : MULTI_EDGE 77 | 78 | SINGLE_EDGE 79 | : IDENTIFIER 80 | 81 | MULTI_EDGE 82 | : [ IDENTIFIER ] 83 | 84 | IDENTIFIER 85 | : URI 86 | | PREFIXED_URI 87 | 88 | URI 89 | : 90 | 91 | PREFIXED_URI 92 | : NAME@LEGAL_URI_CHARSET 93 | 94 | LEGAL_URI_CHARSET 95 | : 96 | 97 | NAME 98 | : [a-zA-Z]+[a-zA-Z0-9_-]+ 99 | -------------------------------------------------------------------------------- /public/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --main-fg-color: #080808; 3 | --main-bg-color: #f9f9f9; 4 | --loading-color: #e4e4e4; 5 | --success-color: #bbf5bb; 6 | --failure-color: #ffe8e8; 7 | --visited-color: #42255a; 8 | --common-spacing: 5px; 9 | } 10 | 11 | body { 12 | font-family: Verdana, Geneva, sans-serif; 13 | background-color: var(--main-bg-color); 14 | color: var(--main-fg-color); 15 | } 16 | 17 | a:link { 18 | color: var(--main-fg-color); 19 | } 20 | 21 | a:visited { 22 | color: var(--visited-color); 23 | } 24 | 25 | h2 { 26 | display: inline-block; 27 | padding: var(--common-spacing); 28 | } 29 | 30 | ul { 31 | display: flex; 32 | flex-wrap: wrap; 33 | justify-content: space-around; 34 | } 35 | 36 | .center { 37 | text-align: center; 38 | } 39 | 40 | ul > li { 41 | display: inline; 42 | padding: var(--common-spacing); 43 | } 44 | 45 | #main { 46 | display: flex; 47 | justify-content: space-between; 48 | flex-wrap: wrap; 49 | } 50 | 51 | .code-box { 52 | outline: 1px solid; 53 | height: 350px; 54 | margin: var(--common-spacing); 55 | padding: var(--common-spacing); 56 | } 57 | 58 | @media all and (max-width: 999px) { 59 | .code-container { 60 | width: 100%; 61 | } 62 | } 63 | 64 | @media all and (min-width: 1000px) { 65 | .code-container { 66 | width: 50%; 67 | } 68 | } 69 | 70 | #query-agent-uri { 71 | margin-left: 10px; 72 | } 73 | 74 | #response-area { 75 | overflow: scroll; 76 | } 77 | 78 | #response-area.loading { 79 | background-color: var(--loading-color); 80 | } 81 | 82 | #response-area.success { 83 | background-color: var(--success-color); 84 | } 85 | 86 | #response-area.error { 87 | background-color: var(--failure-color); 88 | } 89 | -------------------------------------------------------------------------------- /src/backends/backend.js: -------------------------------------------------------------------------------- 1 | import { NotImplementedError } from '../errors' 2 | 3 | /** 4 | * A backend is an interface for the query engine to speak to a quadstore. It 5 | * abstracts away the particular details of where the data is stored, and what 6 | * interface that data store implements from the query engine. 7 | * 8 | * Because the data may exist remotely, all operations return Promises. 9 | * @module 10 | */ 11 | 12 | /** 13 | * The abstract interface which all backends must implement 14 | */ 15 | class Backend { 16 | constructor () { 17 | this.eventHandlers = {} 18 | } 19 | 20 | get className () { 21 | return this.constructor.name 22 | } 23 | 24 | /** 25 | * Get all nodes pointed to by the given subject and predicate. 26 | * @param {module:rdf/node.Node} subject 27 | * @param {module:rdf/node.Node} predicate 28 | * @returns {Promise} 29 | */ 30 | async getObjects (subject, predicate) { 31 | throw new NotImplementedError('getObjects', this.className) 32 | } 33 | 34 | /** 35 | * Get all subject nodes pointing to the given object by the given predicate 36 | * from the given named graph. 37 | * @param {module:rdf/node.Node} predicate 38 | * @param {module:rdf/node.Node} object 39 | * @param {module:rdf/node.Node} namedGraph 40 | * @returns {Promise} 41 | */ 42 | async getSubjects (predicate, object, namedGraph) { 43 | throw new NotImplementedError('getSubjects', this.className) 44 | } 45 | 46 | on (eventName, handler) { 47 | if (this.eventHandlers[eventName]) { 48 | this.eventHandlers[eventName].push(handler) 49 | } else { 50 | this.eventHandlers[eventName] = [handler] 51 | } 52 | } 53 | 54 | trigger (eventName) { 55 | return (this.eventHandlers[eventName] || []) 56 | .map(cb => cb.call(this)) 57 | } 58 | } 59 | 60 | export default Backend 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "twinql", 3 | "version": "0.11.2", 4 | "description": "A graph query language for the semantic web", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib", 8 | "build" 9 | ], 10 | "scripts": { 11 | "start": "npm run demo", 12 | "lint": "standard --verbose 'src/**/*.js'", 13 | "mocha": "mocha --require babel-register test/setup.js 'src/**/*.js'", 14 | "pretest": "npm run lint", 15 | "test": "cross-env NODE_ENV=test nyc npm run mocha", 16 | "test:dev": "npm run mocha -- --watch --growl", 17 | "docs": "jsdoc src -r -c conf.json -d docs", 18 | "demo": "http-server . -o", 19 | "build": "npm run build:lib && npm run build:umd", 20 | "build:lib": "babel src -d lib", 21 | "build:dev": "webpack --config webpack.dev.config.js --watch", 22 | "build:umd": "webpack --config webpack.prod.config.js", 23 | "coverage:report": "nyc report", 24 | "coverage:coveralls": "nyc report --reporter=text-lcov | coveralls", 25 | "postversion": "git push --follow-tags", 26 | "prepublish": "npm run build" 27 | }, 28 | "author": "Daniel Friedman", 29 | "license": "MIT", 30 | "devDependencies": { 31 | "babel-cli": "^6.23.0", 32 | "babel-loader": "^6.4.1", 33 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 34 | "babel-plugin-transform-runtime": "^6.23.0", 35 | "babel-preset-env": "^1.3.2", 36 | "babel-register": "^6.24.1", 37 | "chai": "^3.5.0", 38 | "chai-as-promised": "^6.0.0", 39 | "chai-immutable": "^1.6.0", 40 | "coveralls": "^2.13.0", 41 | "cross-env": "^4.0.0", 42 | "http-server": "^0.9.0", 43 | "jsdoc": "^3.4.3", 44 | "jsdoc-strip-async-await": "^0.1.0", 45 | "mocha": "^3.2.0", 46 | "nock": "^9.0.13", 47 | "nyc": "^10.2.0", 48 | "proxyquire": "^1.7.11", 49 | "sinon": "^2.1.0", 50 | "sinon-chai": "^2.9.0", 51 | "standard": "^10.0.2", 52 | "webpack": "^2.3.2" 53 | }, 54 | "dependencies": { 55 | "babel-runtime": "^6.23.0", 56 | "immutable": "^4.0.0-rc.2", 57 | "isomorphic-fetch": "^2.2.1", 58 | "n3": "^0.9.1", 59 | "valid-url": "^1.0.9" 60 | }, 61 | "nyc": { 62 | "reporter": [ 63 | "html", 64 | "text" 65 | ], 66 | "include": [ 67 | "src/**/*.js" 68 | ], 69 | "exclude": [ 70 | "src/**/*.spec.js" 71 | ] 72 | }, 73 | "standard": { 74 | "ignore": [ 75 | "src/**/*.spec.js" 76 | ] 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/backends/web-backend.js: -------------------------------------------------------------------------------- 1 | import Graph from '../rdf/graph' 2 | import InMemoryBackend from './in-memory-backend' 3 | import { fetchGraph } from '../web' 4 | 5 | /** 6 | * Implements a backend which follows links on the web. 7 | * @module 8 | */ 9 | 10 | /** 11 | * A backend for the semantic web where each web page is considered a named 12 | * graph that can be dereferenced over HTTP. 13 | * @extends {module:backends/in-memory-backend~InMemoryBackend} 14 | */ 15 | class WebBackend extends InMemoryBackend { 16 | /** 17 | * Create an WebBackend 18 | * @param {module:rdf/graph~Graph} [graph = new {@link module:rdf/graph~Graph}] 19 | * @param {Object} options - options object 20 | * @param {String} [options.proxyUri=''] - the URI of a Solid agent used for 21 | * fetching RDF resources 22 | * @param {Object} [options.headers={}] - headers to send with each request 23 | */ 24 | constructor ({ graph = new Graph(), proxyUri = '', headers = {} } = {}) { 25 | super(graph) 26 | this.proxyUri = proxyUri 27 | this.headers = headers 28 | this.loadingGraphs = {} 29 | this.on('queryDone', () => { this.loadingGraphs = {} }) 30 | } 31 | 32 | async getObjects (subject, predicate) { 33 | await this.ensureGraphLoaded(getGraphName(subject)) 34 | return super.getObjects(subject, predicate) 35 | } 36 | 37 | async getSubjects (predicate, object, graphName) { 38 | if (graphName) { 39 | await this.ensureGraphLoaded(getGraphName(graphName)) 40 | } 41 | return super.getSubjects(predicate, object, graphName) 42 | } 43 | 44 | /** 45 | * Load the named graph for the given node's URL (if not already loaded) 46 | * into the backend's graph graph. 47 | * @param {String} graphName 48 | */ 49 | async ensureGraphLoaded (graphName) { 50 | if (!graphName) { 51 | return false 52 | } 53 | if (this.loadingGraphs[graphName]) { 54 | return this.loadingGraphs[graphName] 55 | } 56 | const { proxyUri, headers } = this 57 | this.loadingGraphs[graphName] = fetchGraph(graphName, { proxyUri, headers }) 58 | .then(graph => { 59 | this.graph = this.graph.union(graph) 60 | }) 61 | return this.loadingGraphs[graphName] 62 | } 63 | } 64 | 65 | /** 66 | * Gets the resource URL for an RDF node. 67 | * @param {module:rdf/node.Node} node 68 | * @returns {String} the URL to the given resource 69 | */ 70 | function getGraphName (node) { 71 | return node.termType === 'NamedNode' 72 | ? node.value.split('#')[0] 73 | : null 74 | } 75 | 76 | export default WebBackend 77 | -------------------------------------------------------------------------------- /src/backends/in-memory-backend.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import Graph from '../rdf/graph' 3 | import { Node, nodeSet } from '../rdf/node' 4 | import InMemoryBackend from './in-memory-backend' 5 | 6 | describe('in-memory-backend', () => { 7 | describe('InMemoryBackend', () => { 8 | it('finds objects based on subject and predicate', () => { 9 | const GRAPH = 'https://example.com/graph' 10 | const subject = Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }) 11 | const predicate = Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }) 12 | const object = Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }) 13 | const graph = Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 14 | const backend = new InMemoryBackend(Graph.fromQuads([{ subject, predicate, object, graph }])) 15 | return expect(backend.getObjects(subject, predicate)) 16 | .to.eventually.equal(nodeSet([object])) 17 | }) 18 | 19 | it('finds subjects based on object and predicate', () => { 20 | const GRAPH = 'https://example.com/graph' 21 | const subject = Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }) 22 | const predicate = Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }) 23 | const object = Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }) 24 | const graph = Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 25 | const backend = new InMemoryBackend(Graph.fromQuads([{ subject, predicate, object, graph }])) 26 | return expect(backend.getSubjects(predicate, object)) 27 | .to.eventually.equal(nodeSet([subject])) 28 | }) 29 | 30 | it('finds subjects based on object, predicate, and graph', () => { 31 | const GRAPH = 'https://example.com/graph' 32 | const subject = Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }) 33 | const predicate = Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }) 34 | const object = Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }) 35 | const graph = Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 36 | const backend = new InMemoryBackend(Graph.fromQuads([{ subject, predicate, object, graph }])) 37 | return expect(backend.getSubjects(predicate, object, graph)) 38 | .to.eventually.equal(nodeSet([subject])) 39 | }) 40 | }) 41 | }) 42 | -------------------------------------------------------------------------------- /src/lang/ast.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Contains definitions for nodes in the abstract syntax tree 3 | * @module 4 | */ 5 | 6 | /** 7 | * The class for abstract syntax tree nodes 8 | */ 9 | export class AST { 10 | constructor ({ type, ...attrs }) { 11 | if (!type) { 12 | throw new Error('Must provide a `type` when constructing a AST') 13 | } 14 | this.type = type 15 | Object.assign(this, attrs) 16 | } 17 | } 18 | 19 | export const queryNode = ({ prefixList, context, contextSensitiveQuery }) => new AST({ 20 | type: 'query', 21 | prefixList, 22 | context, 23 | contextSensitiveQuery 24 | }) 25 | 26 | export const prefixNode = ({ name, uri }) => new AST({ 27 | type: 'prefix', 28 | name, 29 | uri 30 | }) 31 | 32 | export const contextSensitiveQueryNode = ({ nodeSpecifier, traversal }) => new AST({ 33 | type: 'contextSensitiveQuery', 34 | nodeSpecifier, 35 | traversal 36 | }) 37 | 38 | export const matchingNodeSpecifierNode = ({ contextType, matchList }) => new AST({ 39 | type: 'matchingNodeSpecifier', 40 | contextType, 41 | matchList 42 | }) 43 | 44 | export const emptyNodeSpecifierNode = () => new AST({ 45 | type: 'emptyNodeSpecifier' 46 | }) 47 | 48 | export const leafMatchNode = ({ predicate, value }) => new AST({ 49 | type: 'leafMatch', 50 | predicate, 51 | value 52 | }) 53 | 54 | export const intermediateMatchNode = ({ predicate, nodeSpecifier }) => new AST({ 55 | type: 'intermediateMatch', 56 | predicate, 57 | nodeSpecifier 58 | }) 59 | 60 | export const traversalNode = ({ selectorList }) => new AST({ 61 | type: 'traversal', 62 | selectorList 63 | }) 64 | 65 | export const leafSelectorNode = ({ edge }) => new AST({ 66 | type: 'leafSelector', 67 | edge 68 | }) 69 | 70 | export const intermediateSelectorNode = ({ edge, contextSensitiveQuery }) => new AST({ 71 | type: 'intermediateSelector', 72 | edge, 73 | contextSensitiveQuery 74 | }) 75 | 76 | export const singleEdgeNode = ({ predicate }) => new AST({ 77 | type: 'singleEdge', 78 | predicate 79 | }) 80 | 81 | export const multiEdgeNode = ({ predicate }) => new AST({ 82 | type: 'multiEdge', 83 | predicate 84 | }) 85 | 86 | export const uriNode = ({ value }) => new AST({ 87 | type: 'uri', 88 | value 89 | }) 90 | 91 | export const prefixedUriNode = ({ prefix, path }) => new AST({ 92 | type: 'prefixedUri', 93 | prefix, 94 | path 95 | }) 96 | 97 | export const nameNode = ({ value }) => new AST({ 98 | type: 'name', 99 | value 100 | }) 101 | 102 | export const stringLiteralNode = ({ value }) => new AST({ 103 | type: 'stringLiteral', 104 | value 105 | }) 106 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | import chai from 'chai' 2 | import chaiAsPromised from 'chai-as-promised' 3 | import chaiImmutable from 'chai-immutable' 4 | import sinonChai from 'sinon-chai' 5 | 6 | import { AST } from '../src/lang/ast' 7 | import { Node, nodeSet } from '../src/rdf/node' 8 | import { immutableHashCode } from '../src/util' 9 | 10 | global.expect = chai.expect 11 | 12 | // Plugins 13 | chai.use(chaiImmutable) 14 | chai.use(chaiAsPromised) 15 | chai.use(sinonChai) 16 | 17 | // RDF helpers 18 | chai.use((_chai, utils) => { 19 | const { Assertion } = _chai 20 | 21 | Assertion.addProperty('node', function () { 22 | const obj = this._obj 23 | this.assert( 24 | obj instanceof Node, 25 | 'expected #{this} to be a Node', 26 | 'expected #{this} not to be a Node' 27 | ) 28 | }) 29 | 30 | Assertion.addMethod('value', function (value) { 31 | const obj = this._obj 32 | this.assert( 33 | obj.value === value, 34 | 'expected #{this} to have value #{exp}, but got #{act}', 35 | 'expected #{this} not to have value #{exp}, but got #{act}', 36 | value, 37 | obj.value 38 | ) 39 | }) 40 | 41 | Assertion.addMethod('termType', function (termType) { 42 | const obj = this._obj 43 | this.assert( 44 | obj.termType === termType, 45 | 'expected #{this} to have termType #{exp}, but got #{act}', 46 | 'expected #{this} not to have termType #{exp}, but got #{act}', 47 | termType, 48 | obj.termType 49 | ) 50 | }) 51 | 52 | Assertion.addMethod('language', function (language) { 53 | const obj = this._obj 54 | this.assert( 55 | obj.language === language, 56 | 'expected #{this} to have language #{exp}, but got #{act}', 57 | 'expected #{this} not to have language #{exp}, but got #{act}', 58 | language, 59 | obj.language 60 | ) 61 | }) 62 | 63 | Assertion.addMethod('datatype', function (datatype) { 64 | const obj = this._obj 65 | this.assert( 66 | obj.datatype === datatype, 67 | 'expected #{this} to have datatype #{exp}, but got #{act}', 68 | 'expected #{this} not to have datatype #{exp}, but got #{act}', 69 | datatype, 70 | obj.datatype 71 | ) 72 | }) 73 | }) 74 | 75 | // Parser helpers 76 | chai.use((_chai, utils) => { 77 | const { Assertion } = _chai 78 | 79 | Assertion.addMethod('ast', function (type) { 80 | const obj = this._obj 81 | new Assertion(obj).to.be.instanceof(AST) 82 | new Assertion(obj.type).to.equal(type) 83 | }) 84 | 85 | Assertion.addMethod('withValue', function (value) { 86 | const obj = this._obj 87 | new Assertion(obj.value).to.equal(value) 88 | }) 89 | 90 | Assertion.addMethod('listOf', function (type) { 91 | const obj = this._obj 92 | new Assertion(obj).to.be.instanceof(Array) 93 | obj.map(child => new Assertion(child).to.be.ast(type)) 94 | }) 95 | }) 96 | 97 | // Graph helpers 98 | chai.use((_chai, utils) => { 99 | const { Assertion } = _chai 100 | 101 | Assertion.addMethod('index', function (index) { 102 | utils.flag(this, 'index', index) 103 | }) 104 | 105 | Assertion.addMethod('map', function (...list) { 106 | utils.flag(this, 'indexKey', immutableHashCode(...list)) 107 | }) 108 | 109 | Assertion.addMethod('nodes', function (nodes) { 110 | const obj = this._obj 111 | const index = obj[utils.flag(this, 'index')] 112 | const actualValue = index.get(utils.flag(this, 'indexKey')) 113 | new Assertion(actualValue).to.equal(nodeSet(nodes)) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /src/rdf/serialize.js: -------------------------------------------------------------------------------- 1 | import N3 from 'n3' 2 | 3 | import Graph from './graph' 4 | import { Node } from './node' 5 | import Quad from './quad' 6 | import { iterObj } from '../util' 7 | 8 | const STR_TYPE = 'http://www.w3.org/2001/XMLSchema#string' 9 | 10 | /** 11 | * Parses a list of quads from a text buffer. Currently assumes the buffer is 12 | * in N3. 13 | * @param {String} text - the text of the graph in N3 14 | * @param {String} [graphName] - the name of the graph (the URI of the graph) 15 | * @returns {Promise} a graph from the parsed quads 16 | */ 17 | export function parseNQuads (text, graphName = null) { 18 | return new Promise((resolve, reject) => { 19 | const parser = graphName 20 | ? N3.Parser({ documentIRI: graphName }) 21 | : N3.Parser() 22 | const quads = [] 23 | parser.parse(text, function (error, quad) { 24 | if (error) { reject(error) } 25 | if (quad) { 26 | const newQuad = {} 27 | for (let [whichNode, n3NodeText] of iterObj(quad)) { 28 | let nodeVal 29 | if (N3.Util.isIRI(n3NodeText)) { 30 | nodeVal = Node({ termType: 'NamedNode', value: n3NodeText }) 31 | } else if (N3.Util.isLiteral(n3NodeText)) { 32 | const language = (N3.Util.getLiteralLanguage(n3NodeText)) 33 | const datatype = (N3.Util.getLiteralType(n3NodeText)) 34 | const metaData = { 35 | language: language || null, 36 | datatype: datatype !== STR_TYPE 37 | ? datatype 38 | : null 39 | } 40 | nodeVal = Node({ termType: 'Literal', value: N3.Util.getLiteralValue(n3NodeText), ...metaData }) 41 | } else if (N3.Util.isBlank(n3NodeText)) { 42 | nodeVal = Node({ termType: 'BlankNode', value: n3NodeText }) 43 | } 44 | newQuad[whichNode] = nodeVal 45 | } 46 | if (graphName && !newQuad.graph) { 47 | newQuad.graph = Node({ termType: 'NamedNode', value: graphName }) 48 | } 49 | quads.push(Quad(newQuad)) 50 | } else { 51 | resolve(Graph.fromQuads(quads)) 52 | } 53 | }) 54 | }) 55 | } 56 | 57 | /** 58 | * Serializes a graph to N-Quads (N-Triples) 59 | * 60 | * @param {module:rdf/graph.Graph} graph - the graph to serialize 61 | * @returns {Promise} - the graph serialized to N-Quads 62 | */ 63 | export function serializeNQuads (graph) { 64 | return new Promise((resolve, reject) => { 65 | const { quads } = graph 66 | // same as n-quads as far as n3.js is concerned 67 | const writer = N3.Writer({ format: 'N-Triples' }) 68 | quads.forEach(quad => 69 | writer.addTriple( 70 | toNQuadTerm(quad.get('subject')), 71 | toNQuadTerm(quad.get('predicate')), 72 | toNQuadTerm(quad.get('object')), 73 | quad.get('graph') ? toNQuadTerm(quad.get('graph')) : undefined 74 | ) 75 | ) 76 | writer.end((err, results) => { 77 | if (err) { reject(err) } 78 | resolve(results) 79 | }) 80 | }) 81 | } 82 | 83 | /** 84 | * Serializes a node to it's N-Quads representation. 85 | * 86 | * @param {module:rdf/graph} 87 | */ 88 | const toNQuadTerm = term => { 89 | switch (term.get('termType')) { 90 | case 'NamedNode': 91 | return term.get('value') 92 | case 'Literal': 93 | const serialized = `"${term.get('value')}"` 94 | if (term.get('datatype')) { 95 | return serialized + '^^' + term.get('datatype') 96 | } 97 | if (term.get('language')) { 98 | return serialized + '@' + term.get('language') 99 | } 100 | return serialized 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /public/app.js: -------------------------------------------------------------------------------- 1 | const edit = ace.edit('editor') 2 | edit.session.setTabSize(2) 3 | edit.setFontSize(14) 4 | edit.setBehavioursEnabled(true) 5 | edit.setTheme('ace/theme/dawn') 6 | 7 | const SERVER_URL = 'https://databox.me/,query' 8 | 9 | const queryDictionary = { 10 | 'profile': 11 | `@prefix foaf http://xmlns.com/foaf/0.1/ 12 | 13 | https://dan-f.databox.me/profile/card#me { 14 | foaf:name 15 | } 16 | `, 17 | 'social': `@prefix foaf http://xmlns.com/foaf/0.1/ 18 | 19 | https://dan-f.databox.me/profile/card#me { 20 | foaf:name 21 | [ foaf:knows ] { 22 | foaf:title 23 | foaf:name 24 | } 25 | } 26 | `, 27 | 'data-discovery': 28 | `@prefix book http://www.w3.org/2002/01/bookmark# 29 | @prefix dc http://purl.org/dc/elements/1.1/ 30 | @prefix rdf http://www.w3.org/1999/02/22-rdf-syntax-ns# 31 | @prefix ldp http://www.w3.org/ns/ldp# 32 | @prefix solid http://www.w3.org/ns/solid/terms# 33 | 34 | https://dan-f.databox.me/profile/card#me { 35 | solid:publicTypeIndex => ( rdf:type solid:TypeRegistration 36 | solid:forClass book:Bookmark ) { 37 | solid:instanceContainer { 38 | [ ldp:contains ] => ( rdf:type book:Bookmark ) { 39 | dc:title 40 | dc:description 41 | book:recalls 42 | [ book:hasTopic ] 43 | } 44 | } 45 | } 46 | } 47 | `, 48 | 'error-handling': 49 | `@prefix dc http://purl.org/dc/terms/ 50 | @prefix ldp http://www.w3.org/ns/ldp# 51 | @prefix rdf http://www.w3.org/1999/02/22-rdf-syntax-ns# 52 | @prefix sioc http://rdfs.org/sioc/ns# 53 | @prefix solid http://solid.github.io/vocab/solid-terms.ttl# 54 | 55 | https://deiu.me/profile#me { 56 | solid:publicTypeIndex => ( rdf:type solid:TypeRegistration 57 | solid:forClass sioc:Post ) { 58 | solid:instanceContainer => ( rdf:type ldp:Resource ) { 59 | dc:title 60 | sioc:content 61 | } 62 | } 63 | } 64 | ` 65 | } 66 | 67 | const throughAgent = queryText => 68 | fetch(SERVER_URL, { 69 | method: 'POST', 70 | headers: { 'content-type': 'text/plain' }, 71 | body: queryText 72 | }).then(response => response.json()) 73 | 74 | const onTheClient = queryText => { 75 | const backend = new twinql.WebBackend({ proxyUri: 'https://databox.me/,proxy?uri=' }) 76 | return twinql.query(backend, queryText) 77 | } 78 | 79 | const responseArea = document.getElementById('response-area') 80 | 81 | document.querySelectorAll('.query-example').forEach(exampleLink => { 82 | exampleLink.addEventListener('click', (event) => { 83 | event.preventDefault() 84 | edit.setValue(queryDictionary[event.target.hash.substr(1)]) 85 | edit.clearSelection() 86 | responseArea.innerText = '' 87 | responseArea.classList.remove('success', 'error') 88 | }) 89 | }) 90 | 91 | document.querySelector('#query-agent-uri').addEventListener('click', (event) => { 92 | event.preventDefault() 93 | runQuery(throughAgent) 94 | }) 95 | 96 | document.querySelector('#query-client-side').addEventListener('click', (event) => { 97 | event.preventDefault() 98 | runQuery(onTheClient) 99 | }) 100 | 101 | function runQuery (queryFn) { 102 | const queryText = edit.getValue() 103 | responseArea.innerText = '' 104 | responseArea.classList.remove('success', 'error') 105 | responseArea.classList.add('loading') 106 | queryFn(queryText) 107 | .then(json => { 108 | responseArea.innerText = JSON.stringify(json, null, 2) 109 | responseArea.classList.remove('loading', 'error') 110 | responseArea.classList.add('success') 111 | }) 112 | .catch(err => { 113 | responseArea.innerText = err 114 | responseArea.classList.remove('success', 'loading') 115 | responseArea.classList.add('error') 116 | throw err 117 | }) 118 | } 119 | -------------------------------------------------------------------------------- /src/rdf/graph.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable' 2 | 3 | import { GraphError } from '../errors' 4 | import { nodeSet } from './node' 5 | import { immutableHashCode } from '../util' 6 | 7 | /** 8 | * Provides functionality for dealing with RDF graphs. 9 | * @module 10 | */ 11 | 12 | /** 13 | * Type for a graph index 14 | * @typedef Index {external:Immutable.Map, external:Immutable.Set>} 15 | */ 16 | 17 | /** 18 | * Implements an immutable RDF graph optimized for twinql querying patterns 19 | */ 20 | class Graph { 21 | /** 22 | * Creates a Graph with (subject, predicate), (predicate, object), and 23 | * (predicate, object, graph) indices. 24 | * @param {module:rdf/graph~Index} spIndex - the (subject, predicate) index 25 | * @param {module:rdf/graph~Index} poIndex - the (predicate, object) index 26 | * @param {module:rdf/graph~Index} pogIndex - the (predicate, object, graph) index 27 | * @param {external:Immutable.Set} quads - the set of quads 28 | */ 29 | constructor (spIndex, poIndex, pogIndex, quads) { 30 | this.spIndex = spIndex || new Immutable.Map() 31 | this.poIndex = poIndex || new Immutable.Map() 32 | this.pogIndex = pogIndex || new Immutable.Map() 33 | this.quads = quads || new Immutable.Set() 34 | } 35 | 36 | /** 37 | * Construct a graph from a sequence of quads 38 | * @static 39 | * @param {Iterable} quads - the iterable sequence of quads 40 | * @returns {module:rdf/graph~Graph} the resulting graph 41 | */ 42 | static fromQuads (quads) { 43 | let spIndex = Immutable.Map() 44 | let poIndex = Immutable.Map() 45 | let pogIndex = Immutable.Map() 46 | for (let { subject, predicate, object, graph } of quads) { 47 | spIndex = spIndex.update(immutableHashCode(subject, predicate), nodes => 48 | nodes ? nodes.add(object) : nodeSet([object]) 49 | ) 50 | poIndex = poIndex.update(immutableHashCode(predicate, object), nodes => 51 | nodes ? nodes.add(subject) : nodeSet([subject]) 52 | ) 53 | pogIndex = pogIndex.update(immutableHashCode(predicate, object, graph), nodes => 54 | nodes ? nodes.add(subject) : nodeSet([subject]) 55 | ) 56 | } 57 | return new Graph(spIndex, poIndex, pogIndex, Immutable.Set(quads)) 58 | } 59 | 60 | /** 61 | * Finds a set of nodes which match the given nodes. 62 | * - When given subject and predicate, gives all matching objects 63 | * - When given predicate and object, gives all matching subjects 64 | * - When given predicate, object, and graph, gives all matching subjects 65 | * @param {module:rdf/node.Node} subject - the subject 66 | * @param {module:rdf/node.Node} predicate - the predicate 67 | * @param {module:rdf/node.Node} object - the object 68 | * @param {module:rdf/node.Node} graph - the named graph 69 | * @returns {module:rdf/node.NodeSet} the matched nodes 70 | */ 71 | match ({ subject, predicate, object, graph }) { 72 | if (subject && predicate) { 73 | return this.spIndex.get(immutableHashCode(subject, predicate), nodeSet([])) 74 | } 75 | if (predicate && object && !graph) { 76 | return this.poIndex.get(immutableHashCode(predicate, object), nodeSet([])) 77 | } 78 | if (predicate && object && graph) { 79 | return this.pogIndex.get(immutableHashCode(predicate, object, graph), nodeSet([])) 80 | } 81 | throw new GraphError( 82 | 'Unsupported graph match. ' + 83 | 'Must provide either { subject, predicate } or { predicate, object[, graph] }' 84 | ) 85 | } 86 | 87 | /** 88 | * Returns a new graph containing all the quads of this graph and the other 89 | * @param {module:rdf/graph~Graph} other - the other graph 90 | * @returns {module:rdf/graph~Graph} the unioned graph 91 | */ 92 | union (other) { 93 | return new Graph( 94 | this.spIndex.mergeDeep(other.spIndex), 95 | this.poIndex.mergeDeep(other.poIndex), 96 | this.pogIndex.mergeDeep(other.pogIndex), 97 | this.quads.union(other.quads) 98 | ) 99 | } 100 | } 101 | 102 | export default Graph 103 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twinql 2 | 3 | [![Build Status](https://travis-ci.org/dan-f/twinql.svg?branch=master)](https://travis-ci.org/dan-f/twinql) 4 | [![Coverage Status](https://coveralls.io/repos/github/dan-f/twinql/badge.svg)](https://coveralls.io/github/dan-f/twinql) 5 | 6 | A graph query language for the semantic web. Not [SPARQL](https://www.w3.org/TR/sparql11-query/). 7 | 8 | 9 | ## Use Cases 10 | 11 | twinql was designed with the goal of scratching a particular itch: fetching 12 | linked data over [LDP](https://www.w3.org/TR/2015/REC-ldp-20150226/) without 13 | having to imperatively follow every link and handle every error in an ad-hoc 14 | manner in [Solid](https://solid.mit.edu) applications. 15 | 16 | The main idea behind twinql is that queries select subgraphs by starting at a 17 | particular node and traversing outwards. The query and the response have a 18 | similar recursive tree structure so that the shape of the response can be 19 | inferred from the shape of the request. 20 | 21 | It is currently a hobby project and quite limited in scope. It cannot do many 22 | of the things that SPARQL can. However, it attempts to be more ergonomic than 23 | SPARQL for common use cases. 24 | 25 | ### Examples 26 | Here's how you would query a WebID for profile data and data of that person's 27 | friends: 28 | 29 | ``` 30 | @prefix foaf http://xmlns.com/foaf/0.1/ 31 | 32 | https://dan-f.databox.me/profile/card#me { 33 | foaf:name 34 | foaf:img 35 | [ foaf:knows ] { 36 | foaf:name 37 | foaf:img 38 | } 39 | } 40 | ``` 41 | Response: 42 | ```js 43 | { 44 | "@context": { 45 | "foaf": "http://xmlns.com/foaf/0.1/" 46 | }, 47 | "@id": "https://dan-f.databox.me/profile/card#me", 48 | "foaf:name": { "@value": "Daniel Friedman" }, 49 | "foaf:img": { "@value": "https://dan-f.databox.me/profile/me.jpg"}, 50 | "foaf:knows": [ 51 | { 52 | "@id": "https://deiu.me/profile#me", 53 | "foaf:name": {"@value": "Andrei Vlad Sambra" }, 54 | "foaf:img": {"@value": "https://deiu.me/avatar.jpg" } 55 | }, 56 | { 57 | /* ... */ 58 | } 59 | ] 60 | } 61 | ``` 62 | 63 | 64 | ## Goals 65 | 66 | - Be declarative 67 | - Work well with existing standards and tools 68 | - Make app-building easier 69 | - Support multiple persistence layers (in-memory, link following, SPARQL, etc) 70 | - Be implemented eventually on the server to improve performance when querying 71 | within a single domain and to reduce data being sent over the network 72 | 73 | 74 | ## Known Challenges 75 | 76 | - Link-following is slow 77 | - Not all linked data actually links as well as it should 78 | - LDP doesn't care how much data you want from a particular graph; it gives you 79 | the whole thing 80 | - Just about no operation on the semantic web is atomic 81 | 82 | 83 | ## Roadmap 84 | 85 | - ordering and pagination 86 | - response streaming 87 | - Mutation API 88 | 89 | ### Other cool things 90 | 91 | - Create higher level tooling for offline-first querying and realtime updates 92 | - Create bindings for common UI libraries 93 | - e.g. connected React component 94 | 95 | ## Development 96 | 97 | This reference implementation of twinql happens to be built in JS for quick 98 | prototyping, but a safer language is recommended when implementing for a 99 | production use case. 100 | 101 | ## Contributing 102 | 103 | If you want to contribute to this reference implementation, first reach out by 104 | creating a Github Issue to make sure we're on the same page :smile: 105 | 106 | Assuming you want to mess with the code, just do the following: 107 | 108 | 0) Make sure you have node >=7.x and npm installed. 109 | 110 | 1) Clone the repo 111 | 112 | ```bash 113 | $ git clone https://github.com/dan-f/twinql.git # (or your fork) 114 | ``` 115 | 116 | 2) Install the dependencies 117 | 118 | ```bash 119 | $ cd twinql && npm install 120 | ``` 121 | 122 | 3) Run the demo site 123 | 124 | ```bash 125 | $ npm start 126 | ``` 127 | 128 | 4) Build the lib 129 | 130 | ```bash 131 | # You can run webpack in watch mode to rebuild the UMD bundle on file changes. 132 | # This is useful when prototyping with the demo site. 133 | $ npm run build:dev 134 | 135 | # To test the minified UMD build: 136 | $ npm run build:umd 137 | 138 | # To transpile the library to CommonJS ES5 139 | $ npm run build:lib 140 | ``` 141 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Error classes used throughout the library 3 | * @module 4 | */ 5 | 6 | /** 7 | * Class of errors that occur during lexing 8 | */ 9 | class LexError extends Error { 10 | constructor (message, line, col) { 11 | super(`${line}:${col}: ${message}`) 12 | this.name = 'LexError' 13 | } 14 | } 15 | 16 | /** 17 | * Class of errors for when the lexer encounters an illegal character 18 | * @extends {module:errors~LexError} 19 | */ 20 | export class IllegalCharacterError extends LexError { 21 | constructor (...args) { 22 | super(...args) 23 | this.name = 'IllegalCharacterError' 24 | } 25 | } 26 | 27 | /** 28 | * Class of errors for when the lexer encounters an uncompleted token 29 | * @extends {module:errors~LexError} 30 | */ 31 | export class UnterminatedTokenError extends LexError { 32 | constructor (...args) { 33 | super(...args) 34 | this.name = 'UnterminatedTokenError' 35 | } 36 | } 37 | 38 | /** 39 | * Class of errors for when the lexer encounters a token which doesn't match the 40 | * syntax 41 | */ 42 | export class UnrecognizedTokenError extends LexError { 43 | constructor (...args) { 44 | super(...args) 45 | this.name = 'UnrecognizedTokenError' 46 | } 47 | } 48 | 49 | /** 50 | * Class of errors for errors occurring during parsing 51 | */ 52 | class ParseError extends Error { 53 | constructor (...args) { 54 | super(...args) 55 | this.name = 'ParseError' 56 | } 57 | } 58 | 59 | /** 60 | * Class of errors for when the parser sees a token that doesn't follow the 61 | * grammar 62 | * @extends {module:errors~ParseError} 63 | */ 64 | export class UnexpectedTokenError extends ParseError { 65 | /** 66 | * Create a UnexpectedTokenError 67 | * @param {Array} expectedTokenTypes 68 | * @param {String} receivedToken 69 | */ 70 | constructor (expectedTokenTypes, receivedToken) { 71 | super( 72 | `Expected a token of type(s) [${expectedTokenTypes.join(', ')}], ` + 73 | `but got token '${receivedToken.value}' of type ${receivedToken.type} ` + 74 | `at (${receivedToken.line}:${receivedToken.column})` 75 | ) 76 | this.name = 'UnexpectedTokenError' 77 | } 78 | } 79 | 80 | /** 81 | * Class of errors for errors occurring at query time 82 | */ 83 | export class QueryError extends Error { 84 | constructor (...args) { 85 | super(...args) 86 | this.name = 'QueryError' 87 | } 88 | } 89 | 90 | /** 91 | * Class of errors that occur during HTTP requests. Can be thrown when a 92 | * response is not a 2XX status. 93 | */ 94 | export class HttpError extends Error { 95 | constructor (response) { 96 | super(response.statusText) 97 | this.name = 'HttpError' 98 | this.status = response.status 99 | this.response = response 100 | } 101 | } 102 | 103 | /** 104 | * Class of errors that occur when parsing text as RDF. 105 | */ 106 | export class RdfParseError extends Error { 107 | constructor (...args) { 108 | super(...args) 109 | this.name = 'RdfParseError' 110 | } 111 | } 112 | 113 | export class GraphError extends Error { 114 | constructor (...args) { 115 | super(...args) 116 | this.name = 'GraphError' 117 | } 118 | } 119 | 120 | /** 121 | * Class of errors for when an abstract method is called 122 | */ 123 | export class NotImplementedError extends Error { 124 | /** 125 | * Create a NotImplementedError 126 | * @param {String} methodName 127 | * @param {String} className 128 | */ 129 | constructor (methodName, className) { 130 | super(`${methodName} not implemented on ${className}`) 131 | this.name = 'NotImplementedError' 132 | } 133 | } 134 | 135 | /** 136 | * Describes the set of errors which should be "inlined" (included at node 137 | * level) in the response 138 | */ 139 | const ERRORS_TO_BE_INLINED = new Set(['HttpError', 'RdfParseError']) 140 | 141 | /** 142 | * Returns whether the given error should be included in the query response 143 | * @param {Error} error 144 | */ 145 | export function isInlineError (error) { 146 | return ERRORS_TO_BE_INLINED.has(error.name) 147 | } 148 | 149 | /** 150 | * Formats a given error as a plain object to be included in the query response 151 | * @param {Error} error 152 | */ 153 | export function formatErrorForResponse (error) { 154 | const type = error.name 155 | const message = error.message 156 | const formatted = { 157 | type, 158 | message 159 | } 160 | switch (type) { 161 | case 'HttpError': 162 | return { ...formatted, status: error.status } 163 | default: 164 | return formatted 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/backends/web-backend.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import nock from 'nock' 3 | import proxyquire from 'proxyquire' 4 | import { spy } from 'sinon' 5 | 6 | import { Node, nodeSet } from '../rdf/node' 7 | import { fetchGraph } from '../web' 8 | 9 | import aliceTtl from '../../test/alice' 10 | 11 | describe('web-backend', () => { 12 | describe('WebBackend', () => { 13 | let WebBackend 14 | let fetchGraphSpy 15 | 16 | beforeEach(() => { 17 | fetchGraphSpy = spy(fetchGraph) 18 | WebBackend = proxyquire('./web-backend', { 19 | '../web': { fetchGraph: fetchGraphSpy } 20 | }).default 21 | }) 22 | 23 | afterEach(() => { 24 | nock.cleanAll() 25 | }) 26 | 27 | describe('getObjects', () => { 28 | it('loads the graph of the given subject before finding the objects', () => { 29 | nock('https://alice.com/') 30 | .get('/graph') 31 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 32 | 33 | const backend = new WebBackend() 34 | const alice = Node({ termType: 'NamedNode', value: 'https://alice.com/graph#alice' }) 35 | const knows = Node({ termType: 'NamedNode', value: 'http://xmlns.com/foaf/0.1/knows' }) 36 | const bob = Node({ termType: 'NamedNode', value: 'https://bob.com/graph#bob' }) 37 | const spot = Node({ termType: 'NamedNode', value: 'https://alice.com/graph#spot' }) 38 | return expect(backend.getObjects(alice, knows)) 39 | .to.eventually.equal(nodeSet([bob, spot])) 40 | }) 41 | }) 42 | 43 | describe('getSubjects', () => { 44 | it('loads the graph, if provided, before finding the subjects', () => { 45 | nock('https://alice.com/') 46 | .get('/graph') 47 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 48 | 49 | const backend = new WebBackend() 50 | const graphName = 'https://alice.com/graph' 51 | const alice = Node({ termType: 'NamedNode', value: `${graphName}#alice` }) 52 | const knows = Node({ termType: 'NamedNode', value: 'http://xmlns.com/foaf/0.1/knows' }) 53 | const bob = Node({ termType: 'NamedNode', value: 'https://bob.com/graph#bob' }) 54 | const graph = Node({ termType: 'NamedNode', value: graphName }) 55 | return expect(backend.getSubjects(knows, bob, graph)) 56 | .to.eventually.equal(nodeSet([alice])) 57 | }) 58 | 59 | it('does not load any graph if none is provided', () => { 60 | const backend = new WebBackend() 61 | const knows = Node({ termType: 'NamedNode', value: 'http://xmlns.com/foaf/0.1/knows' }) 62 | const bob = Node({ termType: 'NamedNode', value: 'https://bob.com/graph#bob' }) 63 | return expect(backend.getSubjects(knows, bob)) 64 | .to.eventually.equal(nodeSet()) 65 | }) 66 | }) 67 | 68 | describe('ensureGraphLoaded', () => { 69 | it('loads a graph which has not yet been loaded', () => { 70 | nock('https://alice.com/') 71 | .get('/graph') 72 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 73 | 74 | const graphName = 'https://alice.com/graph' 75 | const backend = new WebBackend() 76 | expect(backend.loadingGraphs).not.to.have.property(graphName) 77 | const concurrentLoadGraphs = Promise.all([ 78 | backend.ensureGraphLoaded(graphName), 79 | backend.ensureGraphLoaded(graphName) 80 | ]) 81 | return expect(concurrentLoadGraphs).to.be.fulfilled 82 | .then(() => { 83 | expect(backend.loadingGraphs).to.have.property(graphName) 84 | const alice = Node({ termType: 'NamedNode', value: `${graphName}#alice` }) 85 | const knows = Node({ termType: 'NamedNode', value: 'http://xmlns.com/foaf/0.1/knows' }) 86 | const bob = Node({ termType: 'NamedNode', value: 'https://bob.com/graph#bob' }) 87 | const spot = Node({ termType: 'NamedNode', value: 'https://alice.com/graph#spot' }) 88 | expect(backend.graph.match({ subject: alice, predicate: knows})) 89 | .to.equal(nodeSet([bob, spot])) 90 | // Even though we asked for the graph to be loaded concurrently, 91 | // `fetchGraph` should only be called once, since the request were 92 | // for the same resource. 93 | expect(fetchGraphSpy).to.have.been.calledOnce 94 | }) 95 | }) 96 | 97 | it('resets the list of loaded graphs at the end of a query', () => { 98 | const backend = new WebBackend() 99 | const graphName = 'https://example.com/graph' 100 | backend.loadingGraphs[graphName] = Promise.resolve(true) 101 | backend.trigger('queryDone') 102 | expect(backend.loadingGraphs).not.to.have.property(graphName) 103 | expect(Object.keys(backend.loadingGraphs).length).to.equal(0) 104 | }) 105 | }) 106 | }) 107 | }) 108 | -------------------------------------------------------------------------------- /src/lang/lexer.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { IllegalCharacterError } from '../errors' 3 | import lex from './lexer' 4 | 5 | describe('lexer', () => { 6 | describe('lex', () => { 7 | const exhaust = str => { 8 | const items = [] 9 | for (let item of lex(str)) { 10 | items.push(item) 11 | } 12 | return items 13 | } 14 | 15 | const eof = (line, column) => ({ 16 | type: 'EOF', 17 | value: null, 18 | line, 19 | column 20 | }) 21 | 22 | it('returns a generator', () => { 23 | expect(lex('')).to.be.a('generator') 24 | }) 25 | 26 | it('throws an error if it sees an unallowed character', () => { 27 | expect(() => exhaust('~')) 28 | .to.throw(/Illegal character/) 29 | }) 30 | 31 | it('throws an error if it reaches EOF without closing a keyword', () => { 32 | expect(() => exhaust('=')) 33 | .to.throw(/Unterminated keyword/) 34 | expect(() => exhaust('@prefi')) 35 | .to.throw(/Unterminated keyword/) 36 | }) 37 | 38 | it('throws an error if it reaches EOF without closing a literal', () => { 39 | expect(() => exhaust('"hello')) 40 | .to.throw(/Unterminated string literal/) 41 | }) 42 | 43 | it(`throws an error if it doesn't recognize a token`, () => { 44 | expect(() => exhaust('a~sdf')) 45 | .to.throw(/Unrecognized token/) 46 | }) 47 | 48 | it('just returns EOF for an empty query', () => { 49 | expect(exhaust('')) 50 | .to.eql([eof(1, 0)]) 51 | }) 52 | 53 | describe('recognizes a', () => { 54 | it('URI', () => { 55 | expect(exhaust('https://example.com/')).to.eql([ 56 | { 57 | line: 1, 58 | column: 0, 59 | type: 'URI', 60 | value: 'https://example.com/' 61 | }, 62 | eof(1, 20) 63 | ]) 64 | }) 65 | 66 | it('prefixed URI', () => { 67 | expect(exhaust('ex:something')).to.eql([ 68 | { 69 | line: 1, 70 | column: 0, 71 | type: 'PREFIXED_URI', 72 | value: { 73 | prefix: 'ex', 74 | path: 'something' 75 | } 76 | }, 77 | eof(1, 12) 78 | ]) 79 | }) 80 | 81 | it('name', () => { 82 | expect(exhaust('foo')).to.eql([ 83 | { 84 | line: 1, 85 | column: 0, 86 | type: 'NAME', 87 | value: 'foo' 88 | }, 89 | eof(1, 3) 90 | ]) 91 | }) 92 | 93 | it('string literal', () => { 94 | expect(exhaust('"foo"')).to.eql([ 95 | { 96 | line: 1, 97 | column: 0, 98 | type: 'STRLIT', 99 | value: 'foo' 100 | }, 101 | eof(1, 5) 102 | ]) 103 | }) 104 | 105 | it('EOF', () => { 106 | expect(exhaust('\n\n ')).to.eql([ 107 | eof(3, 2) 108 | ]) 109 | }) 110 | 111 | describe('symbols', () => { 112 | ;[ 113 | ['parens', 'LPAREN', '(', 'RPAREN', ')'], 114 | ['braces', 'LBRACE', '{', 'RBRACE', '}'], 115 | ['square brackets', 'LSQUARE', '[', 'RSQUARE', ']'], 116 | ].map(([ symbolName, lSymbol, lValue, rSymbol, rValue ]) => { 117 | it(symbolName, () => { 118 | expect(exhaust(`${lValue}${rValue}`)).to.eql([ 119 | { 120 | line: 1, 121 | column: 0, 122 | type: lSymbol, 123 | value: lValue 124 | }, 125 | { 126 | line: 1, 127 | column: 1, 128 | type: rSymbol, 129 | value: rValue 130 | }, 131 | eof(1, 2) 132 | ]) 133 | }) 134 | }) 135 | }) 136 | 137 | describe('keywords', () => { 138 | it('arrow', () => { 139 | expect(exhaust('=>')).to.eql([ 140 | { 141 | line: 1, 142 | column: 0, 143 | type: 'ARROW', 144 | value: '=>' 145 | }, 146 | eof(1, 2) 147 | ]) 148 | }) 149 | 150 | it('prefix', () => { 151 | expect(exhaust('@prefix')).to.eql([ 152 | { 153 | line: 1, 154 | column: 0, 155 | type: 'PREFIX', 156 | value: '@prefix' 157 | }, 158 | eof(1, 7) 159 | ]) 160 | }) 161 | }) 162 | }) 163 | 164 | describe('token breaks', () => { 165 | it('are signified by whitespace', () => { 166 | expect(exhaust('foo bar =>\n(')).to.eql([ 167 | { 168 | line: 1, 169 | column: 0, 170 | type: 'NAME', 171 | value: 'foo' 172 | }, 173 | { 174 | line: 1, 175 | column: 4, 176 | type: 'NAME', 177 | value: 'bar' 178 | }, 179 | { 180 | line: 1, 181 | column: 8, 182 | type: 'ARROW', 183 | value: '=>' 184 | }, 185 | { 186 | line: 2, 187 | column: 0, 188 | type: 'LPAREN', 189 | value: '(' 190 | }, 191 | eof(2, 1) 192 | ]) 193 | }) 194 | }) 195 | }) 196 | }) 197 | -------------------------------------------------------------------------------- /src/lang/lexer.js: -------------------------------------------------------------------------------- 1 | import { isWebUri } from 'valid-url' 2 | 3 | import { 4 | IllegalCharacterError, 5 | UnrecognizedTokenError, 6 | UnterminatedTokenError 7 | } from '../errors' 8 | import { 9 | keywords, 10 | NAME_REGEX, 11 | PREFIXED_URI_REGEX, 12 | symbols, 13 | Token, 14 | tokenTypes 15 | } from './tokens' 16 | 17 | // LIMITATIONS: 18 | // - For now, assuming \n for newlines and no unicode. 19 | 20 | // States for the DFA 21 | const states = { 22 | START: 'START', 23 | ID: 'ID', 24 | STRING: 'STRING', 25 | SYMBOL: 'SYMBOL', 26 | KEYWORD: 'KEYWORD' 27 | } 28 | 29 | // Characters with significance to lexing logic but no token significance 30 | const chars = { 31 | NEWLINE: '\n', 32 | QUOTE: '"' 33 | } 34 | 35 | /** 36 | * Returns an iterable over the token stream of its input. 37 | */ 38 | function * lex (input) { 39 | let line = 1 // the line we're on 40 | let col = -1 // the column we're on 41 | let curVal // the value of the token we're reading 42 | let state // the state of our DFA 43 | let stateCol = 0 // the column from where we entered the last state 44 | 45 | // It would be nice to use a for (let char of input) {...} loop, but we don't 46 | // always want to proceed to the next character at the end of the loop. 47 | const iter = input[Symbol.iterator]() 48 | let cur 49 | 50 | // Some stateful helper functions to control the imperative mutative algorithm 51 | const advance = () => { 52 | col++ 53 | cur = iter.next() 54 | } 55 | const setState = newState => { 56 | state = newState 57 | stateCol = col 58 | } 59 | const reset = () => { 60 | curVal = '' 61 | setState(states.START) 62 | } 63 | 64 | reset() 65 | advance() 66 | 67 | while (!cur.done) { 68 | const char = cur.value 69 | switch (state) { 70 | case states.START: 71 | if (isSpace(char)) { 72 | if (char === chars.NEWLINE) { 73 | col = -1 74 | line++ 75 | } 76 | } else if (starts(char, symbols)) { 77 | curVal += char 78 | setState(states.SYMBOL) 79 | } else if (starts(char, keywords)) { 80 | curVal += char 81 | setState(states.KEYWORD) 82 | } else if (char === chars.QUOTE) { 83 | setState(states.STRING) 84 | } else if (isAlpha(char)) { 85 | curVal += char 86 | setState(states.ID) 87 | } else { 88 | throw new IllegalCharacterError(`Illegal character '${char}'`, line, col) 89 | } 90 | advance() 91 | break 92 | case states.ID: 93 | if (isSpace(char)) { 94 | yield id(curVal, line, stateCol) 95 | reset() 96 | } else { 97 | curVal += char 98 | advance() 99 | } 100 | break 101 | case states.STRING: 102 | if (char === chars.QUOTE) { 103 | yield new Token(tokenTypes.STRLIT, curVal, line, stateCol) 104 | advance() 105 | reset() 106 | } else { 107 | curVal += char 108 | advance() 109 | } 110 | break 111 | case states.SYMBOL: 112 | if (starts(curVal + char, symbols)) { 113 | curVal += char 114 | advance() 115 | } else { 116 | yield new Token(symbols[curVal], curVal, line, stateCol) 117 | reset() 118 | } 119 | break 120 | case states.KEYWORD: 121 | if (isSpace(char)) { 122 | yield new Token(keywords[curVal], curVal, line, stateCol) 123 | reset() 124 | } else if (starts(curVal + char, keywords)) { 125 | curVal += char 126 | advance() 127 | } 128 | break 129 | } 130 | } 131 | 132 | // Handle what happens if we're not in the start state at the end of input 133 | switch (state) { 134 | case states.SYMBOL: 135 | if (symbols[curVal]) { 136 | yield new Token(symbols[curVal], curVal, line, stateCol) 137 | } else { 138 | throw new UnterminatedTokenError(`Unterminated symbol '${curVal}'`, line, col) 139 | } 140 | break 141 | case states.KEYWORD: 142 | if (keywords[curVal]) { 143 | yield new Token(keywords[curVal], curVal, line, stateCol) 144 | } else { 145 | throw new UnterminatedTokenError(`Unterminated keyword '${curVal}'`, line, col) 146 | } 147 | break 148 | case states.STRING: 149 | throw new UnterminatedTokenError(`Unterminated string literal '${curVal}'`, line, col) 150 | case states.ID: 151 | yield id(curVal, line, stateCol) 152 | break 153 | } 154 | yield new Token(tokenTypes.EOF, null, line, col) 155 | } 156 | 157 | // Helper functions 158 | 159 | function isSpace (char) { 160 | return char === ' ' || 161 | char === '\n' || 162 | char === '\r' || 163 | char === '\t' 164 | } 165 | 166 | function isAlpha (char) { 167 | return /[a-z]/i.test(char) 168 | } 169 | 170 | function starts (char, tokenGroup) { 171 | return Object.keys(tokenGroup) 172 | .filter(tokenText => tokenText.startsWith(char)) 173 | .length > 0 174 | } 175 | 176 | function id (val, line, col) { 177 | if (NAME_REGEX.test(val)) { 178 | return new Token(tokenTypes.NAME, val, line, col) 179 | } 180 | if (isWebUri(val)) { 181 | return new Token(tokenTypes.URI, val, line, col) 182 | } 183 | if (PREFIXED_URI_REGEX.test(val)) { 184 | const [ _, prefix, path ] = PREFIXED_URI_REGEX.exec(val) // eslint-disable-line 185 | return new Token(tokenTypes.PREFIXED_URI, { prefix, path }, line, col) 186 | } 187 | throw new UnrecognizedTokenError(`Unrecognized token: ${val}`, line, col) 188 | } 189 | 190 | export default lex 191 | -------------------------------------------------------------------------------- /src/rdf/graph.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import Immutable from 'immutable' 3 | 4 | import Graph from './graph' 5 | import { Node, nodeSet } from './node' 6 | import Quad from './quad' 7 | 8 | describe('graph', () => { 9 | describe('Graph', () => { 10 | const GRAPH = 'https://example.com/graph' 11 | 12 | it('is immutable', () => { 13 | const graph = Graph.fromQuads([]) 14 | expect(graph.spIndex).to.be.an.instanceof(Immutable.Map) 15 | expect(graph.poIndex).to.be.an.instanceof(Immutable.Map) 16 | expect(graph.pogIndex).to.be.an.instanceof(Immutable.Map) 17 | }) 18 | 19 | it('can be constructed from an iterable of quads', () => { 20 | const quad = Quad({ 21 | subject: Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }), 22 | predicate: Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }), 23 | object: Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }), 24 | graph: Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 25 | }) 26 | const graph = Graph.fromQuads([quad]) 27 | expect(graph) 28 | .index('spIndex').to.map(quad.subject, quad.predicate).to.nodes([quad.object]) 29 | .index('poIndex').to.map(quad.predicate, quad.object).to.nodes([quad.subject]) 30 | .index('pogIndex').to.map(quad.predicate, quad.object, quad.graph).to.nodes([quad.subject]) 31 | expect(graph.quads).to.eql(Immutable.Set.of(Immutable.fromJS(quad))) 32 | }) 33 | 34 | it('can union with another graph', () => { 35 | const subject = Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }) 36 | const graph = Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 37 | const quad1 = Quad({ 38 | subject, 39 | predicate: Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }), 40 | object: Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }), 41 | graph 42 | }) 43 | const quad2 = Quad({ 44 | subject, 45 | predicate: Node({ termType: 'NamedNode', value: `${GRAPH}#isAlso`, language: null, datatype: null }), 46 | object: Node({ termType: 'Literal', value: 'bar', language: null, datatype: null }), 47 | graph 48 | }) 49 | const graph1 = Graph.fromQuads([quad1]) 50 | const graph2 = Graph.fromQuads([quad2]) 51 | const unioned = graph1.union(graph2) 52 | expect(unioned) 53 | .index('spIndex').to.map(subject, quad1.predicate).to.nodes([quad1.object]) 54 | .index('spIndex').to.map(subject, quad2.predicate).to.nodes([quad2.object]) 55 | .index('poIndex').to.map(quad1.predicate, quad1.object).to.nodes([subject]) 56 | .index('poIndex').to.map(quad2.predicate, quad2.object).to.nodes([subject]) 57 | .index('pogIndex').to.map(quad1.predicate, quad1.object, graph).to.nodes([subject]) 58 | .index('pogIndex').to.map(quad2.predicate, quad2.object, graph).to.nodes([subject]) 59 | expect(unioned.quads).to.eql(Immutable.Set([ 60 | Immutable.fromJS(quad1), 61 | Immutable.fromJS(quad2) 62 | ])) 63 | }) 64 | 65 | describe('matching', () => { 66 | it('can find all objects based on subject and predicate', () => { 67 | const subject = Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }) 68 | const predicate = Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }) 69 | const object = Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }) 70 | const graph = Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 71 | const quad = { subject, predicate, object, graph } 72 | expect(Graph.fromQuads([quad]).match({ subject, predicate })) 73 | .to.equal(nodeSet([object])) 74 | }) 75 | 76 | it('can find all subjects based on predicate and object', () => { 77 | const subject = Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }) 78 | const predicate = Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }) 79 | const object = Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }) 80 | const graph = Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 81 | const quad = { subject, predicate, object, graph } 82 | expect(Graph.fromQuads([quad]).match({ predicate, object })) 83 | .to.equal(nodeSet([subject])) 84 | }) 85 | 86 | it('can find all subjects based on predicate, object, and graph', () => { 87 | const subject = Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }) 88 | const predicate = Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }) 89 | const object = Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }) 90 | const graph = Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 91 | const quad = { subject, predicate, object, graph } 92 | expect(Graph.fromQuads([quad]).match({ predicate, object, graph })) 93 | .to.equal(nodeSet([subject])) 94 | }) 95 | 96 | it(`returns an empty node set when it can't find any match`, () => { 97 | const subject = Node({ termType: 'NamedNode', value: `${GRAPH}#subj`, language: null, datatype: null }) 98 | const predicate = Node({ termType: 'NamedNode', value: `${GRAPH}#is`, language: null, datatype: null }) 99 | const object = Node({ termType: 'Literal', value: 'foo', language: null, datatype: null }) 100 | const graph = Node({ termType: 'NamedNode', value: GRAPH, language: null, datatype: null }) 101 | const quad = { subject, predicate, object, graph } 102 | expect(Graph.fromQuads([quad]).match({ subject: {}, predicate: {} })) 103 | .to.equal(nodeSet()) 104 | }) 105 | 106 | it(`doesn't match for unsupported patterns`, () => { 107 | expect(() => Graph.fromQuads([]).match({ subject: {}, object: {} })) 108 | .to.throw(/Unsupported graph match/) 109 | }) 110 | }) 111 | }) 112 | }) 113 | -------------------------------------------------------------------------------- /src/lang/parser.js: -------------------------------------------------------------------------------- 1 | import { UnexpectedTokenError } from '../errors' 2 | import lex from './lexer' 3 | import * as AST from './ast' 4 | import { tokenTypes } from './tokens' 5 | 6 | // Helper for navigating the token stream 7 | 8 | const ANY = '*' 9 | 10 | class TokenStream { 11 | constructor (iterator) { 12 | this.iterator = iterator 13 | this.buffer = [] 14 | this.advance() 15 | } 16 | 17 | advance () { 18 | this.current = this.buffer.length 19 | ? this.buffer.shift() 20 | : this._pull() 21 | } 22 | 23 | lookahead (k) { 24 | const needed = k - this.buffer.length 25 | for (let i = 0; i < needed; i++) { 26 | this.buffer.push(this._pull()) 27 | } 28 | return this.buffer.slice(0, k) 29 | } 30 | 31 | expect (...tokenTypes) { 32 | const tokenTypeSet = new Set(tokenTypes) 33 | if (tokenTypeSet.has(ANY) || (tokenTypeSet.has(this.current.type))) { 34 | return true 35 | } 36 | throw new UnexpectedTokenError( 37 | tokenTypes, this.current 38 | ) 39 | } 40 | 41 | when (tokenTypesToHandlers) { 42 | const tokenTypes = Object.keys(tokenTypesToHandlers) 43 | const tokenTypeSet = new Set(tokenTypes) 44 | this.expect(...tokenTypes) 45 | if (tokenTypeSet.has(this.current.type)) { 46 | return tokenTypesToHandlers[this.current.type]() 47 | } 48 | return tokenTypesToHandlers[ANY]() 49 | } 50 | 51 | _pull () { 52 | return this.iterator.next().value 53 | } 54 | } 55 | 56 | // Parse functions 57 | 58 | export default function parse (input, parseFn = query) { 59 | return parseFn(new TokenStream(lex(input))) 60 | } 61 | 62 | export function query (t) { 63 | const { EOF } = tokenTypes 64 | const _queryNode = AST.queryNode({ 65 | prefixList: prefixList(t), 66 | context: context(t), 67 | contextSensitiveQuery: contextSensitiveQuery(t) 68 | }) 69 | t.expect(EOF) 70 | return _queryNode 71 | } 72 | 73 | export function prefixList (t) { 74 | const { PREFIX, URI, PREFIXED_URI } = tokenTypes 75 | t.expect(PREFIX, URI, PREFIXED_URI) 76 | const prefixes = [] 77 | while (t.current.type === PREFIX) { 78 | prefixes.push(prefix(t)) 79 | } 80 | return prefixes 81 | } 82 | 83 | export function prefix (t) { 84 | const { PREFIX } = tokenTypes 85 | t.expect(PREFIX) 86 | t.advance() 87 | return AST.prefixNode({ 88 | name: name(t), 89 | uri: uri(t) 90 | }) 91 | } 92 | 93 | export const context = id 94 | 95 | export function contextSensitiveQuery (t) { 96 | return AST.contextSensitiveQueryNode({ 97 | nodeSpecifier: nodeSpecifier(t), 98 | traversal: traversal(t) 99 | }) 100 | } 101 | 102 | export function nodeSpecifier (t) { 103 | const { ARROW, LPAREN, RPAREN } = tokenTypes 104 | return t.when({ 105 | [ARROW]: () => { 106 | t.advance() 107 | t.expect(LPAREN) 108 | t.advance() 109 | const nodeSpec = AST.matchingNodeSpecifierNode({ 110 | contextType: 'graph', 111 | matchList: matchList(t) 112 | }) 113 | t.expect(RPAREN) 114 | t.advance() 115 | return nodeSpec 116 | }, 117 | [LPAREN]: () => { 118 | t.advance() 119 | const nodeSpec = AST.matchingNodeSpecifierNode({ 120 | contextType: 'subject', 121 | matchList: matchList(t) 122 | }) 123 | t.expect(RPAREN) 124 | t.advance() 125 | return nodeSpec 126 | }, 127 | [ANY]: () => AST.emptyNodeSpecifierNode() 128 | }) 129 | } 130 | 131 | export function matchList (t) { 132 | const { URI, PREFIXED_URI } = tokenTypes 133 | const beginningMatchTokens = new Set([URI, PREFIXED_URI]) 134 | const matches = [] 135 | while (beginningMatchTokens.has(t.current.type)) { 136 | matches.push(match(t)) 137 | } 138 | return matches 139 | } 140 | 141 | export function match (t) { 142 | const { URI, PREFIXED_URI, STRLIT, LPAREN } = tokenTypes 143 | const pred = predicate(t) 144 | return t.when({ 145 | [URI]: () => AST.leafMatchNode({ 146 | predicate: pred, 147 | value: uri(t) 148 | }), 149 | [PREFIXED_URI]: () => AST.leafMatchNode({ 150 | predicate: pred, 151 | value: prefixedUri(t) 152 | }), 153 | [STRLIT]: () => AST.leafMatchNode({ 154 | predicate: pred, 155 | value: string(t) 156 | }), 157 | [LPAREN]: () => AST.intermediateMatchNode({ 158 | predicate: pred, 159 | nodeSpecifier: nodeSpecifier(t) 160 | }) 161 | }) 162 | } 163 | 164 | const predicate = id 165 | 166 | export function id (t) { 167 | const { URI, PREFIXED_URI } = tokenTypes 168 | return t.when({ 169 | [URI]: () => uri(t), 170 | [PREFIXED_URI]: () => prefixedUri(t) 171 | }) 172 | } 173 | 174 | export function uri (t) { 175 | const { URI } = tokenTypes 176 | const _uri = t.when({ 177 | [URI]: () => AST.uriNode({ value: t.current.value }) 178 | }) 179 | t.advance() 180 | return _uri 181 | } 182 | 183 | export function prefixedUri (t) { 184 | const { PREFIXED_URI } = tokenTypes 185 | const _uri = t.when({ 186 | [PREFIXED_URI]: () => { 187 | const { prefix, path } = t.current.value 188 | return AST.prefixedUriNode({ prefix, path }) 189 | } 190 | }) 191 | t.advance() 192 | return _uri 193 | } 194 | 195 | export function name (t) { 196 | const { NAME } = tokenTypes 197 | t.expect(NAME) 198 | const _name = AST.nameNode({ value: t.current.value }) 199 | t.advance() 200 | return _name 201 | } 202 | 203 | export function string (t) { 204 | const { STRLIT } = tokenTypes 205 | const _string = t.when({[STRLIT]: () => AST.stringLiteralNode({ value: t.current.value })}) 206 | t.advance() 207 | return _string 208 | } 209 | 210 | export function traversal (t) { 211 | const { LBRACE, RBRACE } = tokenTypes 212 | t.expect(LBRACE) 213 | t.advance() 214 | const selectors = selectorList(t) 215 | t.expect(RBRACE) 216 | t.advance() 217 | return AST.traversalNode({selectorList: selectors}) 218 | } 219 | 220 | export function selectorList (t) { 221 | const { LSQUARE, URI, PREFIXED_URI } = tokenTypes 222 | const edgeTypes = new Set([LSQUARE, URI, PREFIXED_URI]) 223 | const selectors = [] 224 | while (edgeTypes.has(t.current.type)) { 225 | selectors.push(selector(t)) 226 | } 227 | return selectors 228 | } 229 | 230 | export function selector (t) { 231 | const { ARROW, LPAREN, LBRACE } = tokenTypes 232 | const _edge = edge(t) 233 | return t.when({ 234 | [LPAREN]: () => AST.intermediateSelectorNode({ 235 | edge: _edge, 236 | contextSensitiveQuery: contextSensitiveQuery(t) 237 | }), 238 | [LBRACE]: () => AST.intermediateSelectorNode({ 239 | edge: _edge, 240 | contextSensitiveQuery: contextSensitiveQuery(t) 241 | }), 242 | [ARROW]: () => AST.intermediateSelectorNode({ 243 | edge: _edge, 244 | contextSensitiveQuery: contextSensitiveQuery(t) 245 | }), 246 | [ANY]: () => AST.leafSelectorNode({edge: _edge}) 247 | }) 248 | } 249 | 250 | export function edge (t) { 251 | const { LSQUARE, URI, PREFIXED_URI } = tokenTypes 252 | return t.when({ 253 | [LSQUARE]: () => { 254 | t.advance() 255 | const _predicate = id(t) 256 | t.advance() 257 | return AST.multiEdgeNode({ predicate: _predicate }) 258 | }, 259 | [URI]: () => AST.singleEdgeNode({ predicate: id(t) }), 260 | [PREFIXED_URI]: () => AST.singleEdgeNode({ predicate: id(t) }) 261 | }) 262 | } 263 | -------------------------------------------------------------------------------- /src/rdf/serialize.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import Immutable from 'immutable' 3 | 4 | import Graph from './graph' 5 | import { Node } from './node' 6 | import { parseNQuads, serializeNQuads } from './serialize' 7 | import Quad from './quad' 8 | 9 | describe('serialize', () => { 10 | describe('parseNQuads', () => { 11 | const GRAPH = 'https://example.com/graph' 12 | 13 | it('rejects on a parse error', () => { 14 | const badN3 = 'missingPrefix:someone foaf:knows missingPrefix:someoneElse' 15 | return expect(parseNQuads(badN3, GRAPH)) 16 | .to.eventually.be.rejectedWith(/Undefined prefix/) 17 | }) 18 | 19 | it('parses an empty graph', () => { 20 | return expect(parseNQuads('', GRAPH)) 21 | .to.eventually.be.an.instanceOf(Graph) 22 | .with.property('quads', Immutable.Set()) 23 | }) 24 | 25 | it('parses named nodes', () => { 26 | const n3 = ` 27 | @prefix graph: <${GRAPH}#> . 28 | graph:thing graph:has graph:property . 29 | ` 30 | return expect(parseNQuads(n3, GRAPH)) 31 | .to.eventually.be.an.instanceOf(Graph) 32 | .then(g => { 33 | expect(g.quads).to.have.size(1) 34 | const quad = g.quads.first() 35 | expect(quad.get('subject')) 36 | .to.be.a.node 37 | .with.termType('NamedNode') 38 | .with.value(`${GRAPH}#thing`) 39 | .with.language(null) 40 | .with.datatype(null) 41 | expect(quad.get('predicate')) 42 | .to.be.a.node 43 | .with.termType('NamedNode') 44 | .with.value(`${GRAPH}#has`) 45 | .with.language(null) 46 | .with.datatype(null) 47 | expect(quad.get('object')) 48 | .to.be.a.node 49 | .with.termType('NamedNode') 50 | .with.value(`${GRAPH}#property`) 51 | .with.language(null) 52 | .with.datatype(null) 53 | expect(quad.get('graph')) 54 | .to.be.a.node 55 | .with.termType('NamedNode') 56 | .with.value(GRAPH) 57 | .with.language(null) 58 | .with.datatype(null) 59 | }) 60 | }) 61 | 62 | it('parses literals', () => { 63 | const n3 = ` 64 | @prefix graph: <${GRAPH}#> . 65 | @prefix foaf: . 66 | graph:person foaf:name "Person McPherson" . 67 | ` 68 | return expect(parseNQuads(n3, GRAPH)) 69 | .to.eventually.be.an.instanceOf(Graph) 70 | .then(g => { 71 | expect(g.quads).to.have.size(1) 72 | const quad = g.quads.first() 73 | expect(quad.get('subject')) 74 | .to.be.a.node 75 | .with.termType('NamedNode') 76 | .with.value(`${GRAPH}#person`) 77 | .with.language(null) 78 | .with.datatype(null) 79 | expect(quad.get('predicate')) 80 | .to.be.a.node 81 | .with.termType('NamedNode') 82 | .with.value('http://xmlns.com/foaf/0.1/name') 83 | .with.language(null) 84 | .with.datatype(null) 85 | expect(quad.get('object')) 86 | .to.be.a.node 87 | .with.termType('Literal') 88 | .with.value('Person McPherson') 89 | .with.language(null) 90 | .with.datatype(null) 91 | expect(quad.get('graph')) 92 | .to.be.a.node 93 | .with.termType('NamedNode') 94 | .with.value(GRAPH) 95 | .with.language(null) 96 | .with.datatype(null) 97 | }) 98 | }) 99 | 100 | it('parses blank nodes', () => { 101 | const n3 = ` 102 | [ a ] . 103 | ` 104 | return expect(parseNQuads(n3, GRAPH)) 105 | .to.eventually.be.an.instanceOf(Graph) 106 | .then(g => { 107 | expect(g.quads).to.have.size(1) 108 | const quad = g.quads.first() 109 | expect(quad.get('subject')) 110 | .to.be.a.node 111 | .with.termType('BlankNode') 112 | .with.language(null) 113 | .with.datatype(null) 114 | .and.to.have.property('value') 115 | expect(quad.get('predicate')) 116 | .to.be.a.node 117 | .with.termType('NamedNode') 118 | .with.value('http://www.w3.org/1999/02/22-rdf-syntax-ns#type') 119 | .with.language(null) 120 | .with.datatype(null) 121 | expect(quad.get('object')) 122 | .to.be.a.node 123 | .with.termType('NamedNode') 124 | .with.value('http://xmlns.com/foaf/0.1/Person') 125 | .with.language(null) 126 | .with.datatype(null) 127 | expect(quad.get('graph')) 128 | .to.be.a.node 129 | .with.termType('NamedNode') 130 | .with.value(GRAPH) 131 | .with.language(null) 132 | .with.datatype(null) 133 | }) 134 | }) 135 | }) 136 | 137 | describe('serializeNQuads', () => { 138 | it('serializes an empty graph', () => { 139 | expect(serializeNQuads(new Graph())) 140 | .to.eventually.equal('') 141 | }) 142 | 143 | it('serializes a triple', () => { 144 | const quads = [Quad({ 145 | subject: Node({ termType: 'NamedNode', value: 'https://example.com/graph#subj' }), 146 | predicate: Node({ termType: 'NamedNode', value: 'https://example.com/vocab#term' }), 147 | object: Node({ termType: 'Literal', value: 'nice' }) 148 | })] 149 | expect(serializeNQuads(Graph.fromQuads(quads))) 150 | .to.eventually.equal( 151 | ` "nice".\n` 152 | ) 153 | }) 154 | 155 | it('serializes a quad', () => { 156 | const quads = [Quad({ 157 | subject: Node({ termType: 'NamedNode', value: 'https://example.com/graph#subj' }), 158 | predicate: Node({ termType: 'NamedNode', value: 'https://example.com/vocab#term' }), 159 | object: Node({ termType: 'Literal', value: 'nice' }), 160 | graph: Node({ termType: 'NamedNode', value: 'https://example.com/graph' }) 161 | })] 162 | expect(serializeNQuads(Graph.fromQuads(quads))) 163 | .to.eventually.equal( 164 | ` "nice" .\n` 165 | ) 166 | }) 167 | 168 | it('serializes a literal with datatype', () => { 169 | const quads = [Quad({ 170 | subject: Node({ termType: 'NamedNode', value: 'https://example.com/graph#subj' }), 171 | predicate: Node({ termType: 'NamedNode', value: 'https://example.com/vocab#term' }), 172 | object: Node({ termType: 'Literal', value: '123', datatype: 'http://www.w3.org/2001/XMLSchema#integer' }), 173 | graph: Node({ termType: 'NamedNode', value: 'https://example.com/graph' }) 174 | })] 175 | expect(serializeNQuads(Graph.fromQuads(quads))) 176 | .to.eventually.equal( 177 | ` "123"^^ .\n` 178 | ) 179 | }) 180 | 181 | it('serializes a literal with language', () => { 182 | const quads = [Quad({ 183 | subject: Node({ termType: 'NamedNode', value: 'https://example.com/graph#subj' }), 184 | predicate: Node({ termType: 'NamedNode', value: 'https://example.com/vocab#term' }), 185 | object: Node({ termType: 'Literal', value: 'hola', language: 'es' }), 186 | graph: Node({ termType: 'NamedNode', value: 'https://example.com/graph' }) 187 | })] 188 | expect(serializeNQuads(Graph.fromQuads(quads))) 189 | .to.eventually.equal( 190 | ` "hola"@es .\n` 191 | ) 192 | }) 193 | }) 194 | }) 195 | -------------------------------------------------------------------------------- /src/lang/parser.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import { AST } from './ast' 3 | import parse, * as P from './parser' 4 | 5 | describe('parser', () => { 6 | const errorRegexp = (expected, actualValue, actualType) => { 7 | return new RegExp( 8 | `Expected a token of type\\(s\\) \\[${expected.join(', ')}\\], but got token '${actualValue}' of type ${actualType}` 9 | ) 10 | } 11 | 12 | describe('query', () => { 13 | it('rejects an empty query', () => { 14 | expect(() => parse('')).to.throw( 15 | errorRegexp(['PREFIX', 'URI', 'PREFIXED_URI'], null, 'EOF') 16 | ) 17 | }) 18 | 19 | it('rejects a query with no context', () => { 20 | expect(() => parse(` 21 | @prefix foo https://example.com/foo 22 | `)).to.throw( 23 | errorRegexp(['URI', 'PREFIXED_URI'], null, 'EOF') 24 | ) 25 | expect(() => parse(` 26 | @prefix foo https://example.com/foo {} 27 | `)).to.throw( 28 | errorRegexp(['URI', 'PREFIXED_URI'], '{', 'LBRACE') 29 | ) 30 | }) 31 | 32 | it('expects EOF after the query', () => { 33 | expect(() => parse(` 34 | https://example.com/ {} 35 | blah 36 | `)).to.throw( 37 | errorRegexp(['EOF'], 'blah', 'NAME') 38 | ) 39 | }) 40 | 41 | it('parses context and context sensitive query', () => { 42 | const query = parse(` 43 | https://example.com/graph#thing {} 44 | `) 45 | expect(query).to.be.an.ast('query') 46 | expect(query.prefixList).to.be.a.listOf('prefix').with.lengthOf(0) 47 | expect(query.context).to.be.an.ast('uri') 48 | expect(query.contextSensitiveQuery).to.be.an.ast('contextSensitiveQuery') 49 | }) 50 | 51 | it('parses a prefix list, context, and context sensitive query', () => { 52 | const query = parse(` 53 | @prefix ex https://example.com/terms# 54 | 55 | https://example.com/graph#thing {} 56 | `) 57 | expect(query).to.be.an.ast('query') 58 | expect(query.prefixList).to.be.a.listOf('prefix').with.lengthOf(1) 59 | expect(query.context).to.be.an.ast('uri') 60 | expect(query.contextSensitiveQuery).to.be.an.ast('contextSensitiveQuery') 61 | }) 62 | }) 63 | 64 | describe('prefixList', () => { 65 | it('expects at least one prefix', () => { 66 | expect(() => parse('', P.prefix)).to.throw( 67 | errorRegexp(['PREFIX'], null, 'EOF') 68 | ) 69 | }) 70 | 71 | it('can parse a list of prefixes', () => { 72 | const prefixList = parse(` 73 | @prefix foo https://foo.com/ 74 | @prefix bar https://bar.com/ 75 | `, P.prefixList) 76 | expect(prefixList).to.be.a.listOf('prefix').with.lengthOf(2) 77 | expect(prefixList[0].name).to.be.an.ast('name').withValue('foo') 78 | expect(prefixList[0].uri).to.be.an.ast('uri').withValue('https://foo.com/') 79 | expect(prefixList[1].name).to.be.an.ast('name').withValue('bar') 80 | expect(prefixList[1].uri).to.be.an.ast('uri').withValue('https://bar.com/') 81 | }) 82 | }) 83 | 84 | describe('contextSensitiveQuery', () => { 85 | it('rejects an empty string', () => { 86 | expect(() => parse('', P.contextSensitiveQuery)).to.throw( 87 | errorRegexp(['LBRACE'], null, 'EOF') 88 | ) 89 | }) 90 | 91 | it('rejects a missing traversal', () => { 92 | expect(() => parse('()', P.contextSensitiveQuery)).to.throw( 93 | errorRegexp(['LBRACE'], null, 'EOF') 94 | ) 95 | }) 96 | 97 | it('parses a node specifier and a traversal', () => { 98 | const csq = parse('{}', P.contextSensitiveQuery) 99 | expect(csq).to.be.an.ast('contextSensitiveQuery') 100 | expect(csq.nodeSpecifier).to.be.an.ast('emptyNodeSpecifier') 101 | expect(csq.traversal).to.be.an.ast('traversal') 102 | }) 103 | }) 104 | 105 | describe('nodeSpecifier', () => { 106 | it('parses an empty node specifier', () => { 107 | const nodeSpecifier = parse('', P.nodeSpecifier) 108 | expect(nodeSpecifier).to.be.an.ast('emptyNodeSpecifier') 109 | }) 110 | 111 | it('parses a matching node specifier (with implicit subject)', () => { 112 | const nodeSpecifier = parse('()', P.nodeSpecifier) 113 | expect(nodeSpecifier).to.be.an.ast('matchingNodeSpecifier') 114 | expect(nodeSpecifier.contextType).to.equal('subject') 115 | expect(nodeSpecifier.matchList).to.be.an('array') 116 | }) 117 | 118 | it('parses a matching node specifier (with implicit graph)', () => { 119 | const nodeSpecifier = parse('=> ()', P.nodeSpecifier) 120 | expect(nodeSpecifier).to.be.an.ast('matchingNodeSpecifier') 121 | expect(nodeSpecifier.contextType).to.equal('graph') 122 | expect(nodeSpecifier.matchList).to.be.an('array') 123 | }) 124 | }) 125 | 126 | describe('matchList', () => { 127 | it('can parse an empty match list', () => { 128 | expect(parse('', P.matchList)).to.be.an('array').and.to.be.empty 129 | }) 130 | 131 | it('can parse a number of matches', () => { 132 | const matchList = parse('ex:prop1 "foo" ex:prop2 "bar"', P.matchList) 133 | expect(matchList).to.be.an('array').with.lengthOf(2) 134 | }) 135 | }) 136 | 137 | describe('match', () => { 138 | it('rejects an empty string', () => { 139 | expect(() => parse('', P.match)).to.throw( 140 | errorRegexp(['URI', 'PREFIXED_URI'], null, 'EOF') 141 | ) 142 | }) 143 | 144 | it('parses a leaf match node where the value is a named node', () => { 145 | let match = parse('foaf:knows https://dan-f.databox.me/profile#me', P.match) 146 | expect(match).to.be.an.ast('leafMatch') 147 | expect(match.predicate).to.be.an.ast('prefixedUri') 148 | expect(match.value).to.be.an.ast('uri') 149 | match = parse('foaf:knows dan:me', P.match) 150 | expect(match).to.be.an.ast('leafMatch') 151 | expect(match.predicate).to.be.an.ast('prefixedUri') 152 | expect(match.value).to.be.an.ast('prefixedUri') 153 | }) 154 | 155 | it('parses a leaf match node where the value is a string literal', () => { 156 | const match = parse('foaf:name "Daniel"', P.match) 157 | expect(match).to.be.an.ast('leafMatch') 158 | expect(match.predicate).to.be.an.ast('prefixedUri') 159 | expect(match.value).to.be.an.ast('stringLiteral') 160 | }) 161 | 162 | it('parses a leaf match node where the value is a node specifier', () => { 163 | const match = parse('foaf:knows ( rdf:type foaf:person )', P.match) 164 | expect(match).to.be.an.ast('intermediateMatch') 165 | expect(match.predicate).to.be.an.ast('prefixedUri') 166 | expect(match.nodeSpecifier).to.be.an.ast('matchingNodeSpecifier') 167 | }) 168 | }) 169 | 170 | describe('id', () => { 171 | it('rejects on no URI', () => { 172 | expect(() => parse('', P.id)).to.throw( 173 | errorRegexp(['URI', 'PREFIXED_URI'], null, 'EOF') 174 | ) 175 | }) 176 | 177 | it('expects a URI', () => { 178 | const id = parse('https://example.com/', P.id) 179 | expect(id).to.be.an.ast('uri').withValue('https://example.com/') 180 | }) 181 | 182 | it('expects a prefixed URI', () => { 183 | const id = parse('foo:bar', P.id) 184 | expect(id).to.be.an.ast('prefixedUri') 185 | .and.to.include({ prefix: 'foo', path: 'bar' }) 186 | }) 187 | }) 188 | 189 | describe('traversal', () => { 190 | it('rejects on an empty string', () => { 191 | expect(() => parse('', P.traversal)).to.throw( 192 | errorRegexp(['LBRACE'], null, 'EOF') 193 | ) 194 | }) 195 | 196 | it('parses a selector list', () => { 197 | const traversal = parse('{}', P.traversal) 198 | expect(traversal).to.be.an.ast('traversal') 199 | expect(traversal.selectorList).to.be.an('array').with.lengthOf(0) 200 | }) 201 | }) 202 | 203 | describe('selectorList', () => { 204 | it('can parse an empty list of selectors', () => { 205 | const selectorList = parse('', P.selectorList) 206 | expect(selectorList).to.be.an('array').with.lengthOf(0) 207 | }) 208 | 209 | it('can parse a list of some numer of selectors', () => { 210 | const selectorList = parse('foaf:name', P.selectorList) 211 | expect(selectorList).to.be.an('array').with.lengthOf(1) 212 | }) 213 | }) 214 | 215 | describe('selector', () => { 216 | it('rejects on the empty string', () => { 217 | expect(() => parse('', P.selector)).to.throw( 218 | errorRegexp(['LSQUARE', 'URI', 'PREFIXED_URI'], null, 'EOF') 219 | ) 220 | }) 221 | 222 | it('can parse a leaf selector', () => { 223 | const selector = parse('https://example.com/', P.selector) 224 | expect(selector).to.be.an.ast('leafSelector') 225 | expect(selector.edge).to.be.an.ast('singleEdge') 226 | }) 227 | 228 | it('can parse a intermediate selector node', () => { 229 | let selector = parse('https://example.com/ {}', P.selector) 230 | expect(selector).to.be.an.ast('intermediateSelector') 231 | expect(selector.edge).to.be.an.ast('singleEdge') 232 | expect(selector.contextSensitiveQuery).to.be.an.ast('contextSensitiveQuery') 233 | selector = parse('https://example.com/ () {}', P.selector) 234 | expect(selector).to.be.an.ast('intermediateSelector') 235 | expect(selector.edge).to.be.an.ast('singleEdge') 236 | expect(selector.contextSensitiveQuery).to.be.an.ast('contextSensitiveQuery') 237 | selector = parse('https://example.com/ => () {}', P.selector) 238 | expect(selector).to.be.an.ast('intermediateSelector') 239 | expect(selector.edge).to.be.an.ast('singleEdge') 240 | expect(selector.contextSensitiveQuery).to.be.an.ast('contextSensitiveQuery') 241 | }) 242 | }) 243 | 244 | describe('edge', () => { 245 | it('rejects on the empty string', () => { 246 | expect(() => parse('', P.edge)).to.throw( 247 | errorRegexp(['LSQUARE', 'URI', 'PREFIXED_URI'], null, 'EOF') 248 | ) 249 | }) 250 | 251 | it('parses a single edge node', () => { 252 | let edge = parse('https://example.com/terms#foo', P.edge) 253 | expect(edge).to.be.an.ast('singleEdge') 254 | expect(edge.predicate).to.be.an.ast('uri') 255 | edge = parse('foo:bar', P.edge) 256 | expect(edge).to.be.an.ast('singleEdge') 257 | expect(edge.predicate).to.be.an.ast('prefixedUri') 258 | }) 259 | 260 | it('parses a multi edge node', () => { 261 | const edge = parse('[ foo:bar ]', P.edge) 262 | expect(edge).to.be.an.ast('multiEdge') 263 | expect(edge.predicate).to.be.an.ast('prefixedUri') 264 | }) 265 | }) 266 | }) 267 | -------------------------------------------------------------------------------- /src/query.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | import nock from 'nock' 3 | 4 | import query from './query' 5 | import WebBackend from './backends/web-backend' 6 | 7 | import aliceTtl from '../test/alice' 8 | import bobTtl from '../test/bob' 9 | 10 | describe('query', () => { 11 | describe('query', () => { 12 | const ALICE_ORIGIN = 'https://alice.com' 13 | const BOB_ORIGIN = 'https://alice.com' 14 | 15 | let backend 16 | 17 | beforeEach(() => { 18 | backend = new WebBackend() 19 | }) 20 | 21 | afterEach(() => { 22 | nock.cleanAll() 23 | }) 24 | 25 | it('allows parse errors to bubble up to the caller', () => { 26 | return expect(query(backend, 'foo bar')) 27 | .to.eventually.be.rejectedWith(/Expected a token/) 28 | }) 29 | 30 | it('can run a query with an empty node specifier', () => { 31 | nock('https://alice.com/') 32 | .get('graph') 33 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 34 | 35 | const queryString = ` 36 | https://alice.com/graph#alice {} 37 | ` 38 | return expect(query(backend, queryString)) 39 | .to.eventually.eql({ 40 | '@id': 'https://alice.com/graph#alice' 41 | }) 42 | }) 43 | 44 | it('can run a query with a matching subject node specifier', () => { 45 | nock('https://alice.com/') 46 | .get('/graph') 47 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 48 | 49 | const queryString = ` 50 | @prefix rdf http://www.w3.org/1999/02/22-rdf-syntax-ns# 51 | @prefix foaf http://xmlns.com/foaf/0.1/ 52 | 53 | https://alice.com/graph#alice ( 54 | rdf:type foaf:Person 55 | foaf:name "Alice" 56 | ) {} 57 | ` 58 | return expect(query(backend, queryString)) 59 | .to.eventually.eql({ 60 | '@context': { 61 | rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 62 | foaf: 'http://xmlns.com/foaf/0.1/' 63 | }, 64 | '@id': 'https://alice.com/graph#alice' 65 | }) 66 | }) 67 | 68 | it('can run a query with a matching node specifier on a list of subjects', () => { 69 | nock('https://alice.com/') 70 | .get('/graph') 71 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 72 | 73 | nock('https://bob.com/') 74 | .get('/graph') 75 | .reply(200, bobTtl, { 'content-type': 'text/turtle' }) 76 | 77 | const queryString = ` 78 | @prefix rdf http://www.w3.org/1999/02/22-rdf-syntax-ns# 79 | @prefix foaf http://xmlns.com/foaf/0.1/ 80 | 81 | https://alice.com/graph#alice { 82 | [ foaf:knows ] ( rdf:type foaf:Person ) { 83 | foaf:name 84 | } 85 | } 86 | ` 87 | return expect(query(backend, queryString)) 88 | .to.eventually.eql({ 89 | '@context': { 90 | rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 91 | foaf: 'http://xmlns.com/foaf/0.1/' 92 | }, 93 | '@id': 'https://alice.com/graph#alice', 94 | 'foaf:knows': [ 95 | { 96 | '@id': 'https://bob.com/graph#bob', 97 | 'foaf:name': { '@value': 'Bob' } 98 | } 99 | ] 100 | }) 101 | }) 102 | 103 | it('can run a query with a matching graph node specifier', () => { 104 | nock('https://alice.com/') 105 | .get('/graph') 106 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 107 | 108 | const queryString = ` 109 | @prefix rdf http://www.w3.org/1999/02/22-rdf-syntax-ns# 110 | @prefix foaf http://xmlns.com/foaf/0.1/ 111 | 112 | https://alice.com/graph => ( 113 | rdf:type foaf:Person 114 | foaf:name "Alice" 115 | ) {} 116 | ` 117 | return expect(query(backend, queryString)) 118 | .to.eventually.eql({ 119 | '@context': { 120 | rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 121 | foaf: 'http://xmlns.com/foaf/0.1/' 122 | }, 123 | '@id': 'https://alice.com/graph', 124 | '@graph': [ 125 | { '@id': 'https://alice.com/graph#alice' } 126 | ] 127 | }) 128 | }) 129 | 130 | it('always returns an @id/@graph for matching graph node specifiers, even if they do not match', () => { 131 | nock('https://alice.com/') 132 | .get('/graph') 133 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 134 | 135 | const queryString = ` 136 | @prefix rdf http://www.w3.org/1999/02/22-rdf-syntax-ns# 137 | @prefix foaf http://xmlns.com/foaf/0.1/ 138 | 139 | https://alice.com/graph => ( 140 | rdf:type foaf:FooBar 141 | ) {} 142 | ` 143 | return expect(query(backend, queryString)) 144 | .to.eventually.eql({ 145 | '@context': { 146 | rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 147 | foaf: 'http://xmlns.com/foaf/0.1/' 148 | }, 149 | '@id': 'https://alice.com/graph', 150 | '@graph': [] 151 | }) 152 | }) 153 | 154 | it('can traverse a single edge', () => { 155 | nock('https://alice.com/') 156 | .get('/graph') 157 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 158 | 159 | const queryString = ` 160 | @prefix foaf http://xmlns.com/foaf/0.1/ 161 | @prefix alice https://alice.com/graph# 162 | 163 | alice:alice { 164 | foaf:name 165 | } 166 | ` 167 | return expect(query(backend, queryString)) 168 | .to.eventually.eql({ 169 | '@context': { 170 | alice: 'https://alice.com/graph#', 171 | foaf: 'http://xmlns.com/foaf/0.1/' 172 | }, 173 | '@id': 'https://alice.com/graph#alice', 174 | 'foaf:name': { "@value": "Alice" } 175 | }) 176 | }) 177 | 178 | it('can traverse a multi edge', () => { 179 | nock('https://alice.com/') 180 | .get('/graph') 181 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 182 | 183 | const queryString = ` 184 | @prefix foaf http://xmlns.com/foaf/0.1/ 185 | 186 | https://alice.com/graph#alice { 187 | [ foaf:knows ] 188 | } 189 | ` 190 | return expect(query(backend, queryString)) 191 | .to.eventually.eql({ 192 | '@context': { 193 | foaf: 'http://xmlns.com/foaf/0.1/' 194 | }, 195 | '@id': 'https://alice.com/graph#alice', 196 | 'foaf:knows': [ 197 | { '@id': 'https://bob.com/graph#bob' }, 198 | { '@id': 'https://alice.com/graph#spot' } 199 | ] 200 | }) 201 | }) 202 | 203 | it('can run a subquery', () => { 204 | nock('https://alice.com/') 205 | .get('/graph') 206 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 207 | nock('https://bob.com/') 208 | .get('/graph') 209 | .reply(200, bobTtl, { 'content-type': 'text/turtle' }) 210 | 211 | const queryString = ` 212 | @prefix foaf http://xmlns.com/foaf/0.1/ 213 | 214 | https://alice.com/graph#alice { 215 | [ foaf:knows ] { 216 | foaf:name 217 | } 218 | } 219 | ` 220 | return expect(query(backend, queryString)) 221 | .to.eventually.eql({ 222 | '@context': { 223 | foaf: 'http://xmlns.com/foaf/0.1/' 224 | }, 225 | '@id': 'https://alice.com/graph#alice', 226 | 'foaf:knows': [ 227 | { 228 | '@id': 'https://bob.com/graph#bob', 229 | 'foaf:name': { '@value': 'Bob' } 230 | }, 231 | { 232 | '@id': 'https://alice.com/graph#spot', 233 | 'foaf:name': { '@value': 'Spot' } 234 | } 235 | ] 236 | }) 237 | }) 238 | 239 | it('returns errors in the response body', () => { 240 | nock('https://alice.com/') 241 | .get('/graph') 242 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 243 | nock('https://bob.com/') 244 | .get('/graph') 245 | .reply(404) 246 | 247 | const queryString = ` 248 | @prefix foaf http://xmlns.com/foaf/0.1/ 249 | 250 | https://alice.com/graph#alice { 251 | [ foaf:knows ] { 252 | foaf:name 253 | } 254 | } 255 | ` 256 | return expect(query(backend, queryString)) 257 | .to.eventually.eql({ 258 | '@context': { 259 | foaf: 'http://xmlns.com/foaf/0.1/' 260 | }, 261 | '@id': 'https://alice.com/graph#alice', 262 | 'foaf:knows': [ 263 | { 264 | '@id': 'https://bob.com/graph#bob', 265 | '@error': { 266 | 'type': 'HttpError', 267 | 'status': 404, 268 | 'message': 'Not Found' 269 | } 270 | }, 271 | { 272 | '@id': 'https://alice.com/graph#spot', 273 | 'foaf:name': { '@value': 'Spot' } 274 | } 275 | ] 276 | }) 277 | }) 278 | 279 | it('returns errors in the response body for graph matching', () => { 280 | nock('https://alice.com/') 281 | .get('/graph') 282 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 283 | .get('/Preferences/publicTypeIndex.ttl') 284 | .reply(401) 285 | 286 | const queryString = ` 287 | @prefix rdf http://www.w3.org/1999/02/22-rdf-syntax-ns# 288 | @prefix solid http://solid.github.io/vocab/solid-terms.ttl# 289 | 290 | https://alice.com/graph#alice { 291 | solid:publicTypeIndex => ( rdf:type solid:TypeRegistration ) { 292 | solid:instance 293 | } 294 | } 295 | ` 296 | return expect(query(backend, queryString)) 297 | .to.eventually.eql({ 298 | '@context': { 299 | rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#', 300 | solid: 'http://solid.github.io/vocab/solid-terms.ttl#' 301 | }, 302 | '@id': 'https://alice.com/graph#alice', 303 | 'solid:publicTypeIndex': { 304 | '@id': 'https://alice.com/Preferences/publicTypeIndex.ttl', 305 | '@error': { 306 | type: 'HttpError', 307 | status: 401, 308 | message: 'Unauthorized' 309 | } 310 | } 311 | }) 312 | }) 313 | 314 | it(`maps a single edge to null when it doesn't exist on its subject`, () => { 315 | nock('https://alice.com/') 316 | .get('/graph') 317 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 318 | 319 | const queryString = ` 320 | @prefix foaf http://xmlns.com/foaf/0.1/ 321 | 322 | https://alice.com/graph#alice { 323 | foaf:homepage 324 | } 325 | ` 326 | return expect(query(backend, queryString)) 327 | .to.eventually.eql({ 328 | '@context': { 329 | foaf: 'http://xmlns.com/foaf/0.1/' 330 | }, 331 | '@id': 'https://alice.com/graph#alice', 332 | 'foaf:homepage': null 333 | }) 334 | }) 335 | 336 | it(`maps a multi edge to an empty array when it doesn't exist on its subject`, () => { 337 | nock('https://alice.com/') 338 | .get('/graph') 339 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 340 | 341 | const queryString = ` 342 | @prefix foaf http://xmlns.com/foaf/0.1/ 343 | 344 | https://alice.com/graph#alice { 345 | [ foaf:homepage ] 346 | } 347 | ` 348 | return expect(query(backend, queryString)) 349 | .to.eventually.eql({ 350 | '@context': { 351 | foaf: 'http://xmlns.com/foaf/0.1/' 352 | }, 353 | '@id': 'https://alice.com/graph#alice', 354 | 'foaf:homepage': [] 355 | }) 356 | }) 357 | 358 | it('formats named nodes using objects with an "@id" property', () => { 359 | nock('https://alice.com/') 360 | .get('/graph') 361 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 362 | 363 | const queryString = ` 364 | @prefix pim http://www.w3.org/ns/pim/space# 365 | 366 | https://alice.com/graph#alice { 367 | pim:storage 368 | } 369 | ` 370 | return expect(query(backend, queryString)) 371 | .to.eventually.eql({ 372 | '@context': { 373 | pim: 'http://www.w3.org/ns/pim/space#' 374 | }, 375 | '@id': 'https://alice.com/graph#alice', 376 | 'pim:storage': { '@id': 'https://alice.com/storageSpace/' } 377 | }) 378 | }) 379 | 380 | it('formats datatypes in the response', () => { 381 | nock('https://alice.com/') 382 | .get('/graph') 383 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 384 | 385 | const queryString = ` 386 | @prefix foaf http://xmlns.com/foaf/0.1/ 387 | 388 | https://alice.com/graph#alice { 389 | foaf:age 390 | } 391 | ` 392 | return expect(query(backend, queryString)) 393 | .to.eventually.eql({ 394 | '@context': { 395 | foaf: 'http://xmlns.com/foaf/0.1/' 396 | }, 397 | '@id': 'https://alice.com/graph#alice', 398 | 'foaf:age': { 399 | '@type': 'http://www.w3.org/2001/XMLSchema#integer', 400 | '@value': '24' 401 | } 402 | }) 403 | }) 404 | 405 | it('formats language in the response', () => { 406 | nock('https://alice.com/') 407 | .get('/graph') 408 | .reply(200, aliceTtl, { 'content-type': 'text/turtle' }) 409 | 410 | const queryString = ` 411 | @prefix foaf http://xmlns.com/foaf/0.1/ 412 | 413 | https://alice.com/graph#alice { 414 | foaf:based_near 415 | } 416 | ` 417 | return expect(query(backend, queryString)) 418 | .to.eventually.eql({ 419 | '@context': { 420 | foaf: 'http://xmlns.com/foaf/0.1/' 421 | }, 422 | '@id': 'https://alice.com/graph#alice', 423 | 'foaf:based_near': { 424 | '@language': 'es', 425 | '@type': 'http://www.w3.org/1999/02/22-rdf-syntax-ns#langString', 426 | '@value': 'Estados Unidos' 427 | } 428 | }) 429 | }) 430 | }) 431 | }) 432 | -------------------------------------------------------------------------------- /src/query.js: -------------------------------------------------------------------------------- 1 | import { formatErrorForResponse, isInlineError, QueryError } from './errors' 2 | import { Node, nodeSet } from './rdf/node' 3 | import parse from './lang/parser' 4 | import { iterObj } from './util' 5 | 6 | /** 7 | * The query module 8 | * @module 9 | */ 10 | 11 | const all = Promise.all.bind(Promise) 12 | 13 | /** 14 | * A query response as a JSON-LD graph. Mostly complies to the JSON-LD spec but 15 | * with a few extensions (currently just an '@error' property for nodes that 16 | * fail to be queried) 17 | * @typedef {Object} Response 18 | */ 19 | 20 | /** 21 | * Execute a query on a backend 22 | * @param {module:backends/backend~Backend} backend - the backend to query 23 | * @param {String} [q = ''] - the query 24 | * @returns {Promise} the response graph 25 | */ 26 | async function query (backend, q = '') { 27 | const parsedQuery = parse(q || '') 28 | const resp = await new QueryEngine(backend).query(parsedQuery) 29 | backend.trigger('queryDone') 30 | return resp 31 | } 32 | 33 | /** 34 | * Encapsulates a backend and a bit of state (the prefix map, for example) for 35 | * the various query functions 36 | */ 37 | class QueryEngine { 38 | /** 39 | * Creates a QueryEngine 40 | * @param {module:backends/backend~Backend} backend 41 | */ 42 | constructor (backend) { 43 | this.backend = backend 44 | } 45 | 46 | /** 47 | * Runs a query against the backend 48 | * @param {module:lang/ast.AST} ast - the AST of the query 49 | * @returns {Promise} the response graph 50 | */ 51 | async query (ast) { 52 | this.prefixMap = this.getPrefixMap(ast.prefixList) 53 | const contextBlock = Object.keys(this.prefixMap).length 54 | ? { '@context': this.prefixMap } 55 | : {} 56 | const queryResults = await this.contextSensitiveQuery(this.toNode(ast.context), ast.contextSensitiveQuery) 57 | return { 58 | ...contextBlock, 59 | ...queryResults 60 | } 61 | } 62 | 63 | /** 64 | * Constructs a map of prefix names to base URIs from a prefixList AST node 65 | * @param {module:lang/ast.AST} prefixList - the prefixList AST node 66 | * @returns {Object} A mapping of prefix names (e.g. foaf) to base URIs 67 | */ 68 | getPrefixMap (prefixList) { 69 | return prefixList 70 | .reduce((map, prefix) => ({ 71 | ...map, 72 | [prefix.name.value]: prefix.uri.value 73 | }), {}) 74 | } 75 | 76 | /** 77 | * Gets the query response for a context-sensitive query given a context node, 78 | * which are resolved at runtime. 79 | * @param {module:lang/ast.AST} contextNode - the context AST node 80 | * @param {module:lang/ast.AST} csq - the contextSensitiveQuery AST node 81 | * @returns {Promise} the response graph 82 | */ 83 | async contextSensitiveQuery (contextNode, csq) { 84 | const nodeSpec = csq.nodeSpecifier 85 | let nodes 86 | try { 87 | nodes = await this.specifiedNodes(nodeSet([contextNode]), nodeSpec) 88 | } catch (error) { 89 | return { 90 | ...formatNode(contextNode), 91 | '@error': formatErrorForResponse(error) 92 | } 93 | } 94 | const results = await Promise.all(nodes.map(node => this.traverse(node, csq.traversal))) 95 | const { type, contextType } = nodeSpec 96 | if (type === 'emptyNodeSpecifier' || (type === 'matchingNodeSpecifier' && contextType === 'subject')) { 97 | return results.length ? results[0] : {} 98 | } else if (type === 'matchingNodeSpecifier' && contextType === 'graph') { 99 | return { 100 | ...formatNode(contextNode), 101 | '@graph': results 102 | } 103 | } 104 | throw new QueryError(`Unrecognized context type for node specifier. Expected 'graph' or 'subject', but got: ${nodeSpec.contextType}`) 105 | } 106 | 107 | /** 108 | * Get the set of nodes matching a nodeSpecifier given the current context 109 | * @param {module:rdf/node~NodeSet} context - the set of context nodes 110 | * @param {module:lang/ast.AST} nodeSpec - the nodeSpecifier AST node 111 | * @returns {Promise} the set of matching nodes 112 | */ 113 | async specifiedNodes (context, nodeSpec) { 114 | switch (nodeSpec.type) { 115 | case 'uri': 116 | case 'prefixedUri': 117 | case 'stringLiteral': 118 | return nodeSet([this.toNode(nodeSpec)]) 119 | case 'emptyNodeSpecifier': 120 | return context 121 | case 'matchingNodeSpecifier': 122 | return this.matchesMatchList(context, nodeSpec.contextType, nodeSpec.matchList) 123 | default: 124 | throw new QueryError(`Invalid node specifier type: ${nodeSpec}`) 125 | } 126 | } 127 | 128 | /** 129 | * Returns the set of nodes which match a match list given the current context 130 | * @param {module:rdf/node~NodeSet} context - the set of context nodes 131 | * @param {String} contextType - the kind of context; either "graph" or 132 | * "subject" 133 | * @param {Array} matchList - the list of AST match nodes 134 | * @returns {Promise} the set of nodes matching the match list 135 | */ 136 | async matchesMatchList (context, contextType, matchList) { 137 | return (await all( 138 | matchList.map(match => this.matches(context, contextType, match)) 139 | )).reduce((allMatches, curMatches) => allMatches.intersect(curMatches)) 140 | } 141 | 142 | /** 143 | * Returns the set of nodes which match a given match given the current context 144 | * @param {module:rdf/node~NodeSet} context - the set of context nodes 145 | * @param {String} contextType - the kind of context; either "graph" or 146 | * "subject" 147 | * @param {module:lang/ast.AST} match - the match AST node 148 | * @returns {Promise} the set of nodes matching the 149 | * current match 150 | */ 151 | async matches (context, contextType, match) { 152 | switch (contextType) { 153 | case 'subject': 154 | try { 155 | const nodesMatchingContextAndPredicate = (await all( 156 | context.map(node => this.backend.getObjects(node, this.toNode(match.predicate))) 157 | )).reduce((allNodes, currentNodes) => allNodes.union(currentNodes)) 158 | const nodesMatchingSubNodeSpec = await this.specifiedNodes( 159 | nodesMatchingContextAndPredicate, 160 | match.type === 'intermediateMatch' 161 | ? match.nodeSpecifier 162 | : match.value 163 | ) 164 | const nodesMatchingMatch = (await all( 165 | nodesMatchingSubNodeSpec.map(node => this.backend.getSubjects(this.toNode(match.predicate), node)) 166 | )).reduce((allNodes, currentNodes) => allNodes.union(currentNodes)) 167 | return context.intersect(nodesMatchingMatch) 168 | } catch (error) { 169 | return nodeSet([]) 170 | } 171 | case 'graph': 172 | try { 173 | return context.map(async namedGraph => { 174 | return (await all( 175 | (await this.specifiedNodes(null, match.type === 'intermediateMatch' ? match.nodeSpecifier : match.value)) 176 | .map(node => this.backend.getSubjects(this.toNode(match.predicate), node, namedGraph)) 177 | )).reduce((allNodes, currentNodes) => allNodes.union(currentNodes)) 178 | }).reduce((allNodes, currentNodes) => allNodes.union(currentNodes)) 179 | } catch (error) { 180 | return nodeSet([]) 181 | } 182 | default: 183 | throw new QueryError(`Invalid context type: ${contextType}`) 184 | } 185 | } 186 | 187 | /** 188 | * Gets the response graph for a specific traversal (i.e. list of edges and 189 | * subqueries) for a given node 190 | * @param {module:rdf/node.Node} node - the node to traverse 191 | * @param {module:lang/ast.AST} traversal - the traversal AST node 192 | * @returns {Promise} the response graph for the current node 193 | */ 194 | async traverse (node, traversal) { 195 | let error 196 | let results = [] 197 | for (let selectorNode of traversal.selectorList) { 198 | if (error) { break } 199 | switch (selectorNode.type) { 200 | case 'leafSelector': 201 | try { 202 | results.push(await this.traverseLeafSelector(node, selectorNode)) 203 | } catch (e) { 204 | if (isInlineError(e)) { 205 | error = e 206 | } else { 207 | throw e 208 | } 209 | } 210 | break 211 | case 'intermediateSelector': 212 | results.push(await this.traverseIntermediateSelector(node, selectorNode)) 213 | break 214 | default: 215 | throw new QueryError(`Invalid selector type. Expected 'leafSelector' or 'intermediateSelector', but got: ${selectorNode.type}`) 216 | } 217 | } 218 | return error 219 | ? { 220 | '@id': node.get('value'), 221 | '@error': formatErrorForResponse(error) 222 | } 223 | : { 224 | '@id': node.get('value'), 225 | ...results.reduce((response, result) => ({ 226 | ...response, 227 | ...result 228 | }), {}) 229 | } 230 | } 231 | 232 | /** 233 | * Traverses a single edge with no subquery on a given node 234 | * @param {module:rdf/node.Node} node - the node to traverse 235 | * @param {module:lang/ast.AST} selectorNode - the selector AST node 236 | * @returns {Promise} the response for the edge traversal 237 | */ 238 | async traverseLeafSelector (node, selectorNode) { 239 | const { edge } = selectorNode 240 | const predicateValue = this.toNode(edge.predicate).get('value') 241 | let finalObjects 242 | switch (edge.type) { 243 | case 'singleEdge': 244 | finalObjects = formatNode(await this.getNextObjectForSelector(node, edge)) 245 | break 246 | case 'multiEdge': 247 | finalObjects = (await this.getNextObjectsForSelector(node, edge)) 248 | .toJS() 249 | .map(formatNode) 250 | break 251 | default: 252 | throw new QueryError(`Unrecognized edge type. Expected 'singleEdge' or 'multiEdge' but got '${edge.type}'.`) 253 | } 254 | return { [this.toPrefixed(predicateValue)]: finalObjects } 255 | } 256 | 257 | /** 258 | * Traverses a single edge with a subquery on a given node 259 | * @param {module:rdf/node.Node} node - the node to traverse 260 | * @param {module:lang/ast.AST} selectorNode - the selector AST node 261 | * @returns {Promise} the sub-response for the edge traversal 262 | */ 263 | async traverseIntermediateSelector (node, selectorNode) { 264 | const { edge, contextSensitiveQuery } = selectorNode 265 | let subQueryResults 266 | switch (edge.type) { 267 | case 'singleEdge': 268 | const nextObject = await this.getNextObjectForSelector(node, edge) 269 | subQueryResults = nextObject 270 | ? await this.contextSensitiveQuery(nextObject, contextSensitiveQuery) 271 | : null 272 | break 273 | case 'multiEdge': 274 | const isNotEmpty = obj => Object.keys(obj).length > 0 275 | const nextObjects = await this.getNextObjectsForSelector(node, edge) 276 | subQueryResults = (await all(nextObjects.map(async node => 277 | this.contextSensitiveQuery(node, contextSensitiveQuery) 278 | ))).filter(isNotEmpty) 279 | break 280 | default: 281 | throw new QueryError(`Unrecognized edge type. Expected 'singleEdge' or 'multiEdge' but got '${edge.type}'.`) 282 | } 283 | const predicateValue = this.toNode(edge.predicate).get('value') 284 | return { [this.toPrefixed(predicateValue)]: subQueryResults } 285 | } 286 | 287 | /** 288 | * Returns the set of objects to traverse given the current selector edge. 289 | * @param {module:rdf/node.Node} node - the node to traverse 290 | * @param {module:lang/ast.AST} edge - the edge AST node 291 | * @returs {Promise} the set of objects pointed to 292 | * by the current node and the edge 293 | */ 294 | async getNextObjectsForSelector (node, edge) { 295 | const { predicate } = edge 296 | switch (edge.type) { 297 | case 'multiEdge': 298 | return this.backend.getObjects(node, this.toNode(predicate)) 299 | default: 300 | throw new QueryError(`Edge type must be 'multiEdge' but got '${edge.type}'.`) 301 | } 302 | } 303 | 304 | async getNextObjectForSelector (node, edge) { 305 | const { predicate } = edge 306 | switch (edge.type) { 307 | case 'singleEdge': 308 | return (await this.backend.getObjects(node, this.toNode(predicate))).first() || null 309 | default: 310 | throw new QueryError(`Edge type must be 'singleEdge' but got '${edge.type}'.`) 311 | } 312 | } 313 | 314 | /** 315 | * Converts an AST node to a {@link module:rdf/node.Node} 316 | * @param {module:lang/ast.AST} ast - the AST node 317 | * @returns {@link module:rdf/node.Node} the converted node 318 | */ 319 | toNode (ast) { 320 | switch (ast.type) { 321 | case 'stringLiteral': 322 | return Node({ termType: 'Literal', value: ast.value }) 323 | case 'uri': 324 | return Node({ termType: 'NamedNode', value: ast.value }) 325 | case 'prefixedUri': 326 | const prefixUri = this.prefixMap[ast.prefix] 327 | if (prefixUri) { 328 | return Node({ termType: 'NamedNode', value: this.prefixMap[ast.prefix] + ast.path }) 329 | } else { 330 | throw new QueryError(`Missing prefix definition for "${ast.prefix}"`) 331 | } 332 | default: 333 | throw new QueryError(`Cannot convert from ${ast} to an RDF NamedNode. Expected type 'uri' or 'prefixedUri'`) 334 | } 335 | } 336 | 337 | /** 338 | * Converts a URI to the prefixed version for the response if the prefix map 339 | * has an entry with a base URI corresponding to the given URI 340 | * @param {String} uri - the (maybe) prefixable URI 341 | * @returns {String} the (maybe) prefixed URI 342 | */ 343 | toPrefixed (uri) { 344 | for (let [prefix, prefixedUri] of iterObj(this.prefixMap)) { 345 | if (uri.startsWith(prefixedUri)) { 346 | return `${prefix}:${uri.substr(prefixedUri.length)}` 347 | } 348 | } 349 | return uri 350 | } 351 | } 352 | 353 | /** 354 | * Converts a {@link module:rdf/node.Node} to a plain JSON-LD objectq to be included 355 | * in the response. 356 | * @param {module:rdf/node.Node} node 357 | */ 358 | function formatNode (node) { 359 | if (node == null) { 360 | return node 361 | } 362 | const { termType, datatype, language, value } = node 363 | if (termType === 'NamedNode') { 364 | return { '@id': value } 365 | } 366 | const formatted = { '@value': value } 367 | if (datatype) { 368 | formatted['@type'] = datatype 369 | } 370 | if (language) { 371 | formatted['@language'] = language 372 | } 373 | return formatted 374 | } 375 | 376 | export default query 377 | --------------------------------------------------------------------------------