├── 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 |
16 |
24 |
25 |
26 |
Query
27 |
28 |
29 |
30 |
31 |
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 | [](https://travis-ci.org/dan-f/twinql)
4 | [](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