diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd173db0..b58c12f1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,6 +51,17 @@ jobs: - run: wasm-pack test --node working-directory: ./lib + js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: rustup update + - run: cargo install wasm-pack + - run: npm install + working-directory: ./js + - run: npm test + working-directory: ./js + python: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fec4ff6d..fabfdb58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,6 +101,24 @@ jobs: - run: pip install 'maturin>=0.9.2,<0.10' - run: maturin publish --no-sdist -u __token__ -p ${{ secrets.PYPI_PASSWORD }} working-directory: ./python + publish_npm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v2 + with: + node-version: 14 + registry-url: https://registry.npmjs.org + - run: rustup update + - run: cargo install wasm-pack + - run: npm install + working-directory: ./js + - run: npm run build + working-directory: ./js + - run: npm run release + working-directory: ./js + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} publish_python_doc: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 7981b97f..7f29effb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,5 +3,7 @@ Cargo.lock .idea *.iml +js/node_modules +lib/tests/rockdb_bc_data lib/tests/sled_bc_data venv \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index ff352d39..398eff8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "js", "lib", "python", "server", diff --git a/README.md b/README.md index 20189dd0..42c53ca0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Oxigraph [![Latest Version](https://img.shields.io/crates/v/oxigraph.svg)](https://crates.io/crates/oxigraph) [![Released API docs](https://docs.rs/oxigraph/badge.svg)](https://docs.rs/oxigraph) [![PyPI](https://img.shields.io/pypi/v/pyoxigraph)](https://pypi.org/project/pyoxigraph/) +[![npm](https://img.shields.io/npm/v/oxigraph)](https://www.npmjs.com/package/oxigraph) [![actions status](https://github.com/oxigraph/oxigraph/workflows/build/badge.svg)](https://github.com/oxigraph/oxigraph/actions) [![Gitter](https://badges.gitter.im/oxigraph/community.svg)](https://gitter.im/oxigraph/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) @@ -20,6 +21,8 @@ It is split into multiple parts: [![Latest Version](https://img.shields.io/crates/v/oxigraph.svg)](https://crates.io/crates/oxigraph) [![Released API docs](https://docs.rs/oxigraph/badge.svg)](https://docs.rs/oxigraph) * [`pyoxigraph` that exposes Oxigraph to the Python world](https://oxigraph.org/pyoxigraph/). Its source code is in the `python` directory. [![PyPI](https://img.shields.io/pypi/v/pyoxigraph)](https://pypi.org/project/pyoxigraph/) +* [JavaScript bindings for Oxigraph](https://www.npmjs.com/package/oxigraph). WebAssembly is used to package Oxigraph into a NodeJS compatible NPM package. Its source code is in the `js` directory. +[![npm](https://img.shields.io/npm/v/oxigraph)](https://www.npmjs.com/package/oxigraph) * [Oxigraph server](https://crates.io/crates/oxigraph_server) that provides a standalone binary of a web server implementing the [SPARQL 1.1 Protocol](https://www.w3.org/TR/sparql11-protocol/) and the [SPARQL 1.1 Graph Store Protocol](https://www.w3.org/TR/sparql11-http-rdf-update/). Its source code is in the `server` directory. [![Latest Version](https://img.shields.io/crates/v/oxigraph_server.svg)](https://crates.io/crates/oxigraph_server) [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/oxigraph/oxigraph?sort=semver)](https://hub.docker.com/repository/docker/oxigraph/oxigraph) diff --git a/js/Cargo.toml b/js/Cargo.toml new file mode 100644 index 00000000..cf04a11f --- /dev/null +++ b/js/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "oxigraph_js" +version = "0.3.0-dev" +authors = ["Tpt "] +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["RDF", "N-Triples", "Turtle", "RDF/XML", "SPARQL"] +repository = "https://github.com/oxigraph/oxigraph/tree/master/js" +description = "JavaScript bindings of Oxigraph" +edition = "2018" + +[lib] +crate-type = ["cdylib"] +name = "oxigraph" + +[dependencies] +oxigraph = { version = "0.3.0-dev", path="../lib" } +wasm-bindgen = "0.2" +js-sys = "0.3" +console_error_panic_hook = "0.1" + +[dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/js/README.md b/js/README.md new file mode 100644 index 00000000..0269d8a2 --- /dev/null +++ b/js/README.md @@ -0,0 +1,211 @@ +Oxigraph for JavaScript +======================= + +[![npm](https://img.shields.io/npm/v/oxigraph)](https://www.npmjs.com/package/oxigraph) +[![actions status](https://github.com/oxigraph/oxigraph/workflows/build/badge.svg)](https://github.com/oxigraph/oxigraph/actions) +[![Gitter](https://badges.gitter.im/oxigraph/community.svg)](https://gitter.im/oxigraph/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) + +This package provides a JavaScript API on top of [Oxigraph](https://crates.io/crates/oxigraph), compiled with WebAssembly. + +Oxigraph is a graph database written in Rust implementing the [SPARQL](https://www.w3.org/TR/sparql11-overview/) standard. + +Oxigraph for JavaScript is a work in progress and currently offers a simple in-memory store with [SPARQL 1.1 Query](https://www.w3.org/TR/sparql11-query/) and [SPARQL 1.1 Update](https://www.w3.org/TR/sparql11-update/) capabilities. + +The store is also able to load RDF serialized in [Turtle](https://www.w3.org/TR/turtle/), [TriG](https://www.w3.org/TR/trig/), [N-Triples](https://www.w3.org/TR/n-triples/), [N-Quads](https://www.w3.org/TR/n-quads/) and [RDF/XML](https://www.w3.org/TR/rdf-syntax-grammar/). + + +It is distributed using a [a NPM package](https://www.npmjs.com/package/oxigraph) that should work with nodeJS 12+. + +```bash +npm install oxigraph +``` + +```js +const oxigraph = require('oxigraph'); +``` + +## Example + +Insert the triple ` "example"` and log the name of `` in SPARQL: +```js +const { MemoryStore } = require('oxigraph'); +const store = new MemoryStore(); +const dataFactory = store.dataFactory; +const ex = dataFactory.namedNode("http://example/"); +const schemaName = dataFactory.namedNode("http://schema.org/name"); +store.add(dataFactory.triple(ex, schemaName, dataFactory.literal("example"))); +for (binding of store.query("SELECT ?name WHERE { ?name }")) { + console.log(binding.get("name").value); +} +``` + +## API + +Oxigraph currently provides a simple JS API. +It is centered around the `MemoryStore` class. + +The `NamedNode`, `BlankNode`, `Literal`, `DefaultGraph`, `Quad` and `DataFactory` types +are following the [RDF/JS datamodel specification](https://rdf.js.org/data-model-spec/). + +To import `MemoryStore` using Node: +```js +const { MemoryStore } = require('oxigraph'); +``` + +### `MemoryStore` + +#### `MemoryStore(optional sequence? quads)` (constructor) +```js +const store = new MemoryStore(); +``` + +If provided, the `MemoryStore` will be initialized with a sequence of quads. + +#### `MemoryStore.dataFactory` +Returns a `DataFactory` following [RDF/JS datamodel specification](https://rdf.js.org/data-model-spec/). + +Example: +```js +const store = new MemoryStore(); +const ex = store.dataFactory.namedNode("http://example.com"); +const blank = store.dataFactory.blankNode(); +const foo = store.dataFactory.literal("foo"); +const quad = store.dataFactory.quad(blank, ex, foo); +``` + +#### `MemoryStore.prototype.add(Quad quad)` +Inserts a quad in the store. + +Example: +```js +store.add(quad); +``` + +#### `MemoryStore.prototype.delete(Quad quad)` +Removes a quad from the store. + +Example: +```js +store.delete(quad); +``` + +#### `MemoryStore.prototype.has(Quad quad)` +Returns a boolean stating if the store contains the quad. + +Example: +```js +store.has(quad); +``` + +#### `MemoryStore.prototype.match(optional Term? subject, optional Term? predicate, optional Term? object, optional Term? graph)` +Returns an array with all the quads matching a given quad pattern. + +Example to get all quads in the default graph with `ex` for subject: +```js +store.match(ex, null, null, store.dataFactory.defaultGraph()); +``` + +Example to get all quads: +```js +store.match(); +``` + +#### `MemoryStore.prototype.query(String query)` +Executes a [SPARQL 1.1 Query](https://www.w3.org/TR/sparql11-query/). +For `SELECT` queries the return type is an array of `Map` which keys are the bound variables and values are the values the result is bound to. +For `CONSTRUCT` and `ÐESCRIBE` queries the return type is an array of `Quad`. +For `ASK` queries the return type is a boolean. + +Example of SELECT query: +```js +for (binding of store.query("SELECT DISTINCT ?s WHERE { ?s ?p ?o }")) { + console.log(binding.get("s").value); +} +``` + +Example of CONSTRUCT query: +```js +const filteredStore = new MemoryStore(store.query("CONSTRUCT { ?p ?o } WHERE { ?p ?o }")); +``` + +Example of ASK query: +```js +if (store.query("ASK { ?s ?s ?s }")) { + console.log("there is a triple with same subject, predicate and object"); +} +``` + +#### `MemoryStore.prototype.update(String query)` +Executes a [SPARQL 1.1 Update](https://www.w3.org/TR/sparql11-update/). +The [`LOAD` operation](https://www.w3.org/TR/sparql11-update/#load) is not supported yet. + +Example of update: +```js +store.update("DELETE WHERE { ?p ?o }") +``` + +### `MemoryStore.prototype.load(String data, String mimeType, NamedNode|String? baseIRI, NamedNode|BlankNode|DefaultGraph? toNamedGraph)` + +Loads serialized RDF triples or quad into the store. +The method arguments are: +1. `data`: the serialized RDF triples or quads. +2. `mimeType`: the MIME type of the serialization. See below for the supported mime types. +3. `baseIRI`: the base IRI to use to resolve the relative IRIs in the serialization. +4. `toNamedGraph`: for triple serialization formats, the name of the named graph the triple should be loaded to. + +The available formats are: +* [Turtle](https://www.w3.org/TR/turtle/): `text/turtle` +* [TriG](https://www.w3.org/TR/trig/): `application/trig` +* [N-Triples](https://www.w3.org/TR/n-triples/): `application/n-triples` +* [N-Quads](https://www.w3.org/TR/n-quads/): `application/n-quads` +* [RDF/XML](https://www.w3.org/TR/rdf-syntax-grammar/): `application/rdf+xml` + +Example of loading a Turtle file into the named graph `` with the base IRI `http://example.com`: +```js +store.load(" <> .", "text/turtle", "http://example.com", store.dataFactory.namedNode("http://example.com/graph")); +``` + + +### `MemoryStore.prototype.dump(String mimeType, NamedNode|BlankNode|DefaultGraph? fromNamedGraph)` + +Returns serialized RDF triples or quad from the store. +The method arguments are: +1. `mimeType`: the MIME type of the serialization. See below for the supported mime types. +2. `fromNamedGraph`: for triple serialization formats, the name of the named graph the triple should be loaded from. + +The available formats are: +* [Turtle](https://www.w3.org/TR/turtle/): `text/turtle` +* [TriG](https://www.w3.org/TR/trig/): `application/trig` +* [N-Triples](https://www.w3.org/TR/n-triples/): `application/n-triples` +* [N-Quads](https://www.w3.org/TR/n-quads/): `application/n-quads` +* [RDF/XML](https://www.w3.org/TR/rdf-syntax-grammar/): `application/rdf+xml` + +Example of building a Turtle file from the named graph ``: +```js +store.dump("text/turtle", store.dataFactory.namedNode("http://example.com/graph")); +``` + +## How to contribute + +The Oxigraph bindings are written in Rust using [the Rust WASM toolkit](https://rustwasm.github.io/docs.html). + +The [The Rust Wasm Book](https://rustwasm.github.io/docs/book/) is a great tutorial to get started. + +To run the tests of the JS bindings written in JS run `npm test`. + + +## License + +This project is licensed under either of + +* Apache License, Version 2.0, ([LICENSE-APACHE](../LICENSE-APACHE) or + http://www.apache.org/licenses/LICENSE-2.0) +* MIT license ([LICENSE-MIT](../LICENSE-MIT) or + http://opensource.org/licenses/MIT) + +at your option. + + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in Futures by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. diff --git a/js/package.json b/js/package.json new file mode 100644 index 00000000..bf373b18 --- /dev/null +++ b/js/package.json @@ -0,0 +1,15 @@ +{ + "name": "oxigraph_tests", + "description": "Oxigraph JS build and tests", + "private": true, + "devDependencies": { + "mocha": "^8.0.1", + "@rdfjs/data-model": "1.1.2", + "standard": "^16.0.0" + }, + "scripts": { + "test": "standard test/*.js && wasm-pack build --dev --target nodejs && mocha", + "build": "wasm-pack build --release --target nodejs && sed -i 's/oxigraph_js/oxigraph/g' pkg/package.json", + "release": "wasm-pack pack && wasm-pack publish" + } +} diff --git a/js/src/lib.rs b/js/src/lib.rs new file mode 100644 index 00000000..9005e09e --- /dev/null +++ b/js/src/lib.rs @@ -0,0 +1,3 @@ +mod model; +mod store; +mod utils; diff --git a/js/src/model.rs b/js/src/model.rs new file mode 100644 index 00000000..b731f90e --- /dev/null +++ b/js/src/model.rs @@ -0,0 +1,637 @@ +use crate::format_err; +use crate::utils::to_err; +use js_sys::{Reflect, UriError}; +use oxigraph::model::*; +use std::convert::TryFrom; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name = DataFactory)] +#[derive(Default)] +pub struct JsDataFactory { + from_js: FromJsConverter, +} + +#[wasm_bindgen(js_class = DataFactory)] +impl JsDataFactory { + #[wasm_bindgen(js_name = namedNode)] + pub fn named_node(&self, value: String) -> Result { + NamedNode::new(value) + .map(|v| v.into()) + .map_err(|v| UriError::new(&v.to_string()).into()) + } + + #[wasm_bindgen(js_name = blankNode)] + pub fn blank_node(&self, value: Option) -> Result { + Ok(if let Some(value) = value { + BlankNode::new(value).map_err(to_err)? + } else { + BlankNode::default() + } + .into()) + } + + #[wasm_bindgen] + pub fn literal( + &self, + value: Option, + language_or_datatype: &JsValue, + ) -> Result { + if language_or_datatype.is_null() || language_or_datatype.is_undefined() { + Ok(Literal::new_simple_literal(value.unwrap_or_else(String::new)).into()) + } else if language_or_datatype.is_string() { + Ok(Literal::new_language_tagged_literal( + value.unwrap_or_else(String::new), + language_or_datatype.as_string().unwrap_or_else(String::new), + ) + .map_err(to_err)? + .into()) + } else if let JsTerm::NamedNode(datatype) = self.from_js.to_term(language_or_datatype)? { + Ok(Literal::new_typed_literal(value.unwrap_or_else(String::new), datatype).into()) + } else { + Err(format_err!("The literal datatype should be a NamedNode")) + } + } + + #[wasm_bindgen(js_name = defaultGraph)] + pub fn default_graph(&self) -> JsDefaultGraph { + JsDefaultGraph {} + } + + #[wasm_bindgen(js_name = triple)] + pub fn triple( + &self, + subject: &JsValue, + predicate: &JsValue, + object: &JsValue, + ) -> Result { + Ok(JsQuad { + subject: self.from_js.to_term(subject)?, + predicate: self.from_js.to_term(predicate)?, + object: self.from_js.to_term(object)?, + graph_name: JsTerm::DefaultGraph(JsDefaultGraph {}), + }) + } + + #[wasm_bindgen(js_name = quad)] + pub fn quad( + &self, + subject: &JsValue, + predicate: &JsValue, + object: &JsValue, + graph: &JsValue, + ) -> Result { + Ok(JsQuad { + subject: self.from_js.to_term(subject)?, + predicate: self.from_js.to_term(predicate)?, + object: self.from_js.to_term(object)?, + graph_name: if graph.is_undefined() || graph.is_null() { + JsTerm::DefaultGraph(JsDefaultGraph {}) + } else { + self.from_js.to_term(graph)? + }, + }) + } + + #[wasm_bindgen(js_name = fromTerm)] + pub fn convert_term(&self, original: &JsValue) -> Result { + Ok(self.from_js.to_term(original)?.into()) + } + + #[wasm_bindgen(js_name = fromQuad)] + pub fn convert_quad(&self, original: &JsValue) -> Result { + self.from_js.to_quad(original) + } +} + +#[wasm_bindgen(js_name = NamedNode)] +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub struct JsNamedNode { + inner: NamedNode, +} + +#[wasm_bindgen(js_class = NamedNode)] +impl JsNamedNode { + #[wasm_bindgen(getter = termType)] + pub fn term_type(&self) -> String { + "NamedNode".to_owned() + } + + #[wasm_bindgen(getter)] + pub fn value(&self) -> String { + self.inner.as_str().to_owned() + } + + pub fn equals(&self, other: &JsValue) -> bool { + if let Ok(Some(JsTerm::NamedNode(other))) = + FromJsConverter::default().to_optional_term(other) + { + self == &other + } else { + false + } + } +} + +impl From for JsNamedNode { + fn from(inner: NamedNode) -> Self { + Self { inner } + } +} + +impl From for NamedNode { + fn from(node: JsNamedNode) -> Self { + node.inner + } +} + +impl From for NamedOrBlankNode { + fn from(node: JsNamedNode) -> Self { + node.inner.into() + } +} + +impl From for Subject { + fn from(node: JsNamedNode) -> Self { + node.inner.into() + } +} + +impl From for Term { + fn from(node: JsNamedNode) -> Self { + node.inner.into() + } +} + +impl From for GraphName { + fn from(node: JsNamedNode) -> Self { + node.inner.into() + } +} + +#[wasm_bindgen(js_name = BlankNode)] +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub struct JsBlankNode { + inner: BlankNode, +} + +#[wasm_bindgen(js_class = BlankNode)] +impl JsBlankNode { + #[wasm_bindgen(getter = termType)] + pub fn term_type(&self) -> String { + "BlankNode".to_owned() + } + + #[wasm_bindgen(getter)] + pub fn value(&self) -> String { + self.inner.as_str().to_owned() + } + + pub fn equals(&self, other: &JsValue) -> bool { + if let Ok(Some(JsTerm::BlankNode(other))) = + FromJsConverter::default().to_optional_term(other) + { + self == &other + } else { + false + } + } +} + +impl From for JsBlankNode { + fn from(inner: BlankNode) -> Self { + Self { inner } + } +} + +impl From for BlankNode { + fn from(node: JsBlankNode) -> Self { + node.inner + } +} + +impl From for NamedOrBlankNode { + fn from(node: JsBlankNode) -> Self { + node.inner.into() + } +} + +impl From for Subject { + fn from(node: JsBlankNode) -> Self { + node.inner.into() + } +} + +impl From for Term { + fn from(node: JsBlankNode) -> Self { + node.inner.into() + } +} + +impl From for GraphName { + fn from(node: JsBlankNode) -> Self { + node.inner.into() + } +} + +#[wasm_bindgen(js_name = Literal)] +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub struct JsLiteral { + inner: Literal, +} + +#[wasm_bindgen(js_class = Literal)] +impl JsLiteral { + #[wasm_bindgen(getter = termType)] + pub fn term_type(&self) -> String { + "Literal".to_owned() + } + + #[wasm_bindgen(getter)] + pub fn value(&self) -> String { + self.inner.value().to_owned() + } + + #[wasm_bindgen(getter)] + pub fn language(&self) -> String { + self.inner.language().unwrap_or("").to_owned() + } + + #[wasm_bindgen(getter)] + pub fn datatype(&self) -> JsNamedNode { + self.inner.datatype().into_owned().into() + } + + pub fn equals(&self, other: &JsValue) -> bool { + if let Ok(Some(JsTerm::Literal(other))) = FromJsConverter::default().to_optional_term(other) + { + self == &other + } else { + false + } + } +} + +impl From for JsLiteral { + fn from(inner: Literal) -> Self { + Self { inner } + } +} + +impl From for Literal { + fn from(node: JsLiteral) -> Self { + node.inner + } +} + +impl From for Term { + fn from(node: JsLiteral) -> Self { + node.inner.into() + } +} + +#[wasm_bindgen(js_name = DefaultGraph)] +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub struct JsDefaultGraph {} + +#[wasm_bindgen(js_class = DefaultGraph)] +impl JsDefaultGraph { + #[wasm_bindgen(getter = termType)] + pub fn term_type(&self) -> String { + "DefaultGraph".to_owned() + } + + #[wasm_bindgen(getter)] + pub fn value(&self) -> String { + "".to_owned() + } + + pub fn equals(&self, other: &JsValue) -> bool { + if let Ok(Some(JsTerm::DefaultGraph(other))) = + FromJsConverter::default().to_optional_term(other) + { + self == &other + } else { + false + } + } +} + +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub enum JsTerm { + NamedNode(JsNamedNode), + BlankNode(JsBlankNode), + Literal(JsLiteral), + DefaultGraph(JsDefaultGraph), +} + +impl From for JsValue { + fn from(value: JsTerm) -> Self { + match value { + JsTerm::NamedNode(v) => v.into(), + JsTerm::BlankNode(v) => v.into(), + JsTerm::Literal(v) => v.into(), + JsTerm::DefaultGraph(v) => v.into(), + } + } +} + +impl From for JsTerm { + fn from(node: NamedNode) -> Self { + JsTerm::NamedNode(node.into()) + } +} + +impl From for JsTerm { + fn from(node: BlankNode) -> Self { + JsTerm::BlankNode(node.into()) + } +} + +impl From for JsTerm { + fn from(literal: Literal) -> Self { + JsTerm::Literal(literal.into()) + } +} + +impl From for JsTerm { + fn from(node: NamedOrBlankNode) -> Self { + match node { + NamedOrBlankNode::NamedNode(node) => node.into(), + NamedOrBlankNode::BlankNode(node) => node.into(), + } + } +} + +impl From for JsTerm { + fn from(node: Subject) -> Self { + match node { + Subject::NamedNode(node) => node.into(), + Subject::BlankNode(node) => node.into(), + Subject::Triple(_) => unimplemented!(), + } + } +} + +impl From for JsTerm { + fn from(term: Term) -> Self { + match term { + Term::NamedNode(node) => node.into(), + Term::BlankNode(node) => node.into(), + Term::Literal(literal) => literal.into(), + Term::Triple(_) => unimplemented!(), + } + } +} + +impl From for JsTerm { + fn from(name: GraphName) -> Self { + match name { + GraphName::NamedNode(node) => node.into(), + GraphName::BlankNode(node) => node.into(), + GraphName::DefaultGraph => JsTerm::DefaultGraph(JsDefaultGraph {}), + } + } +} + +impl TryFrom for NamedNode { + type Error = JsValue; + + fn try_from(value: JsTerm) -> Result { + match value { + JsTerm::NamedNode(node) => Ok(node.into()), + JsTerm::BlankNode(node) => Err(format_err!( + "The blank node {} is not a named node", + node.inner + )), + JsTerm::Literal(literal) => Err(format_err!( + "The literal {} is not a named node", + literal.inner + )), + JsTerm::DefaultGraph(_) => Err(format_err!("The default graph is not a named node")), + } + } +} + +impl TryFrom for NamedOrBlankNode { + type Error = JsValue; + + fn try_from(value: JsTerm) -> Result { + match value { + JsTerm::NamedNode(node) => Ok(node.into()), + JsTerm::BlankNode(node) => Ok(node.into()), + JsTerm::Literal(literal) => Err(format_err!( + "The literal {} is not a possible named or blank node term", + literal.inner + )), + JsTerm::DefaultGraph(_) => { + Err(format_err!("The default graph is not a possible RDF term")) + } + } + } +} + +impl TryFrom for Subject { + type Error = JsValue; + + fn try_from(value: JsTerm) -> Result { + match value { + JsTerm::NamedNode(node) => Ok(node.into()), + JsTerm::BlankNode(node) => Ok(node.into()), + JsTerm::Literal(literal) => Err(format_err!( + "The literal {} is not a possible named or blank node term", + literal.inner + )), + JsTerm::DefaultGraph(_) => { + Err(format_err!("The default graph is not a possible RDF term")) + } + } + } +} + +impl TryFrom for Term { + type Error = JsValue; + + fn try_from(value: JsTerm) -> Result { + match value { + JsTerm::NamedNode(node) => Ok(node.into()), + JsTerm::BlankNode(node) => Ok(node.into()), + JsTerm::Literal(literal) => Ok(literal.into()), + JsTerm::DefaultGraph(_) => { + Err(format_err!("The default graph is not a possible RDF term")) + } + } + } +} + +impl TryFrom for GraphName { + type Error = JsValue; + + fn try_from(value: JsTerm) -> Result { + match value { + JsTerm::NamedNode(node) => Ok(node.into()), + JsTerm::BlankNode(node) => Ok(node.into()), + JsTerm::Literal(literal) => Err(format_err!( + "The literal {} is not a possible graph name", + literal.inner + )), + JsTerm::DefaultGraph(_) => Ok(GraphName::DefaultGraph), + } + } +} + +#[wasm_bindgen(js_name = Quad)] +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub struct JsQuad { + subject: JsTerm, + predicate: JsTerm, + object: JsTerm, + graph_name: JsTerm, +} + +#[wasm_bindgen(js_class = Quad)] +impl JsQuad { + #[wasm_bindgen(getter = subject)] + pub fn subject(&self) -> JsValue { + self.subject.clone().into() + } + + #[wasm_bindgen(getter = predicate)] + pub fn predicate(&self) -> JsValue { + self.predicate.clone().into() + } + + #[wasm_bindgen(getter = object)] + pub fn object(&self) -> JsValue { + self.object.clone().into() + } + + #[wasm_bindgen(getter = graph)] + pub fn graph(&self) -> JsValue { + self.graph_name.clone().into() + } + + pub fn equals(&self, other: &JsValue) -> bool { + FromJsConverter::default() + .to_quad(other) + .map_or(false, |other| self == &other) + } +} + +impl From for JsQuad { + fn from(quad: Quad) -> Self { + Self { + subject: quad.subject.into(), + predicate: quad.predicate.into(), + object: quad.object.into(), + graph_name: quad.graph_name.into(), + } + } +} + +impl TryFrom for Quad { + type Error = JsValue; + + fn try_from(quad: JsQuad) -> Result { + Ok(Quad { + subject: Subject::try_from(quad.subject)?, + predicate: NamedNode::try_from(quad.predicate)?, + object: Term::try_from(quad.object)?, + graph_name: GraphName::try_from(quad.graph_name)?, + }) + } +} + +pub struct FromJsConverter { + term_type: JsValue, + value: JsValue, + language: JsValue, + datatype: JsValue, + subject: JsValue, + predicate: JsValue, + object: JsValue, + graph: JsValue, +} + +impl Default for FromJsConverter { + fn default() -> Self { + Self { + term_type: JsValue::from_str("termType"), + value: JsValue::from_str("value"), + language: JsValue::from_str("language"), + datatype: JsValue::from_str("datatype"), + subject: JsValue::from_str("subject"), + predicate: JsValue::from_str("predicate"), + object: JsValue::from_str("object"), + graph: JsValue::from_str("graph"), + } + } +} + +impl FromJsConverter { + pub fn to_term(&self, value: &JsValue) -> Result { + let term_type = Reflect::get(value, &self.term_type)?; + if let Some(term_type) = term_type.as_string() { + match term_type.as_str() { + "NamedNode" => Ok(NamedNode::new( + Reflect::get(value, &self.value)? + .as_string() + .ok_or_else(|| format_err!("NamedNode should have a string value"))?, + ) + .map_err(|v| UriError::new(&v.to_string()))? + .into()), + "BlankNode" => Ok(BlankNode::new( + &Reflect::get(value, &self.value)? + .as_string() + .ok_or_else(|| format_err!("BlankNode should have a string value"))?, + ) + .map_err(to_err)? + .into()), + "Literal" => { + if let JsTerm::NamedNode(datatype) = + self.to_term(&Reflect::get(value, &self.datatype)?)? + { + let datatype = NamedNode::from(datatype); + let literal_value = Reflect::get(value, &self.value)? + .as_string() + .ok_or_else(|| format_err!("Literal should have a string value"))?; + Ok(match datatype.as_str() { + "http://www.w3.org/2001/XMLSchema#string" => Literal::new_simple_literal(literal_value), + "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString" => Literal::new_language_tagged_literal(literal_value, Reflect::get(value, &self.language)?.as_string().ok_or_else( + || format_err!("Literal with rdf:langString datatype should have a language"), + )?).map_err(to_err)?, + _ => Literal::new_typed_literal(literal_value, datatype) + }.into()) + } else { + Err(format_err!( + "Literal should have a datatype that is a NamedNode" + )) + } + } + "DefaultGraph" => Ok(JsTerm::DefaultGraph(JsDefaultGraph {})), + _ => Err(format_err!( + "The termType {} is not supported by Oxigraph", + term_type + )), + } + } else { + Err(format_err!("The object termType field should be a string")) + } + } + + pub fn to_optional_term(&self, value: &JsValue) -> Result, JsValue> { + if value.is_null() || value.is_undefined() { + Ok(None) + } else { + self.to_term(value).map(Some) + } + } + + pub fn to_quad(&self, value: &JsValue) -> Result { + Ok(JsQuad { + subject: self.to_term(&Reflect::get(value, &self.subject)?)?, + predicate: self.to_term(&Reflect::get(value, &self.predicate)?)?, + object: self.to_term(&Reflect::get(value, &self.object)?)?, + graph_name: self.to_term(&Reflect::get(value, &self.graph)?)?, + }) + } +} diff --git a/js/src/store.rs b/js/src/store.rs new file mode 100644 index 00000000..3b264644 --- /dev/null +++ b/js/src/store.rs @@ -0,0 +1,230 @@ +use crate::format_err; +use crate::model::*; +use crate::utils::to_err; +use js_sys::{Array, Map}; +use oxigraph::io::{DatasetFormat, GraphFormat}; +use oxigraph::model::*; +use oxigraph::sparql::QueryResults; +use oxigraph::store::Store; +use std::convert::{TryFrom, TryInto}; +use std::io::Cursor; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name = Store)] +pub struct JsStore { + store: Store, + from_js: FromJsConverter, +} + +#[wasm_bindgen(js_class = Store)] +impl JsStore { + #[wasm_bindgen(constructor)] + pub fn new(quads: Option>) -> Result { + console_error_panic_hook::set_once(); + + let store = Self { + store: Store::new().map_err(to_err)?, + from_js: FromJsConverter::default(), + }; + if let Some(quads) = quads { + for quad in quads.iter() { + store.add(quad)?; + } + } + Ok(store) + } + + #[wasm_bindgen(js_name = dataFactory, getter)] + pub fn data_factory(&self) -> JsDataFactory { + JsDataFactory::default() + } + + pub fn add(&self, quad: &JsValue) -> Result<(), JsValue> { + self.store + .insert(&self.from_js.to_quad(quad)?.try_into()?) + .map_err(to_err)?; + Ok(()) + } + + pub fn delete(&self, quad: &JsValue) -> Result<(), JsValue> { + self.store + .remove(&self.from_js.to_quad(quad)?.try_into()?) + .map_err(to_err)?; + Ok(()) + } + + pub fn has(&self, quad: &JsValue) -> Result { + self.store + .contains(&self.from_js.to_quad(quad)?.try_into()?) + .map_err(to_err) + } + + #[wasm_bindgen(getter=size)] + pub fn size(&self) -> usize { + self.store.len() + } + + #[wasm_bindgen(js_name = match)] + pub fn match_quads( + &self, + subject: &JsValue, + predicate: &JsValue, + object: &JsValue, + graph_name: &JsValue, + ) -> Result, JsValue> { + Ok(self + .store + .quads_for_pattern( + if let Some(subject) = self.from_js.to_optional_term(subject)? { + Some(subject.try_into()?) + } else { + None + } + .as_ref() + .map(|t: &NamedOrBlankNode| t.into()), + if let Some(predicate) = self.from_js.to_optional_term(predicate)? { + Some(NamedNode::try_from(predicate)?) + } else { + None + } + .as_ref() + .map(|t: &NamedNode| t.into()), + if let Some(object) = self.from_js.to_optional_term(object)? { + Some(object.try_into()?) + } else { + None + } + .as_ref() + .map(|t: &Term| t.into()), + if let Some(graph_name) = self.from_js.to_optional_term(graph_name)? { + Some(graph_name.try_into()?) + } else { + None + } + .as_ref() + .map(|t: &GraphName| t.into()), + ) + .map(|v| v.map(|v| JsQuad::from(v).into())) + .collect::, _>>() + .map_err(to_err)? + .into_boxed_slice()) + } + + pub fn query(&self, query: &str) -> Result { + let results = self.store.query(query).map_err(to_err)?; + let output = match results { + QueryResults::Solutions(solutions) => { + let results = Array::new(); + for solution in solutions { + let solution = solution.map_err(to_err)?; + let result = Map::new(); + for (variable, value) in solution.iter() { + result.set( + &variable.as_str().into(), + &JsTerm::from(value.clone()).into(), + ); + } + results.push(&result.into()); + } + results.into() + } + QueryResults::Graph(quads) => { + let results = Array::new(); + for quad in quads { + results.push( + &JsQuad::from(quad.map_err(to_err)?.in_graph(GraphName::DefaultGraph)) + .into(), + ); + } + results.into() + } + QueryResults::Boolean(b) => b.into(), + }; + Ok(output) + } + + pub fn update(&self, update: &str) -> Result<(), JsValue> { + self.store.update(update).map_err(to_err) + } + + pub fn load( + &self, + data: &str, + mime_type: &str, + base_iri: &JsValue, + to_graph_name: &JsValue, + ) -> Result<(), JsValue> { + let base_iri = if base_iri.is_null() || base_iri.is_undefined() { + None + } else if base_iri.is_string() { + base_iri.as_string() + } else if let JsTerm::NamedNode(base_iri) = self.from_js.to_term(base_iri)? { + Some(base_iri.value()) + } else { + return Err(format_err!( + "If provided, the base IRI should be a NamedNode or a string" + )); + }; + + let to_graph_name = + if let Some(graph_name) = self.from_js.to_optional_term(to_graph_name)? { + Some(graph_name.try_into()?) + } else { + None + }; + + if let Some(graph_format) = GraphFormat::from_media_type(mime_type) { + self.store + .load_graph( + Cursor::new(data), + graph_format, + &to_graph_name.unwrap_or(GraphName::DefaultGraph), + base_iri.as_deref(), + ) + .map_err(to_err) + } else if let Some(dataset_format) = DatasetFormat::from_media_type(mime_type) { + if to_graph_name.is_some() { + return Err(format_err!( + "The target graph name parameter is not available for dataset formats" + )); + } + self.store + .load_dataset(Cursor::new(data), dataset_format, base_iri.as_deref()) + .map_err(to_err) + } else { + Err(format_err!("Not supported MIME type: {}", mime_type)) + } + } + + pub fn dump(&self, mime_type: &str, from_graph_name: &JsValue) -> Result { + let from_graph_name = + if let Some(graph_name) = self.from_js.to_optional_term(from_graph_name)? { + Some(graph_name.try_into()?) + } else { + None + }; + + let mut buffer = Vec::new(); + if let Some(graph_format) = GraphFormat::from_media_type(mime_type) { + self.store + .dump_graph( + &mut buffer, + graph_format, + &from_graph_name.unwrap_or(GraphName::DefaultGraph), + ) + .map_err(to_err)?; + } else if let Some(dataset_format) = DatasetFormat::from_media_type(mime_type) { + if from_graph_name.is_some() { + return Err(format_err!( + "The target graph name parameter is not available for dataset formats" + )); + } + self.store + .dump_dataset(&mut buffer, dataset_format) + .map_err(to_err)?; + } else { + return Err(format_err!("Not supported MIME type: {}", mime_type)); + } + String::from_utf8(buffer).map_err(to_err) + } +} diff --git a/js/src/utils.rs b/js/src/utils.rs new file mode 100644 index 00000000..a362c31d --- /dev/null +++ b/js/src/utils.rs @@ -0,0 +1,16 @@ +use js_sys::Error; +use wasm_bindgen::JsValue; + +#[macro_export] +macro_rules! format_err { + ($msg:literal $(,)?) => { + ::wasm_bindgen::JsValue::from(::js_sys::Error::new($msg)) + }; + ($fmt:literal, $($arg:tt)*) => { + ::wasm_bindgen::JsValue::from(::js_sys::Error::new(&format!($fmt, $($arg)*))) + }; +} + +pub fn to_err(e: impl ToString) -> JsValue { + JsValue::from(Error::new(&e.to_string())) +} diff --git a/js/test/model.js b/js/test/model.js new file mode 100644 index 00000000..8c5765f3 --- /dev/null +++ b/js/test/model.js @@ -0,0 +1,2 @@ +const { Store } = require('../pkg/oxigraph.js') +require('../node_modules/@rdfjs/data-model/test/index.js')((new Store()).dataFactory) diff --git a/js/test/store.js b/js/test/store.js new file mode 100644 index 00000000..243ffa00 --- /dev/null +++ b/js/test/store.js @@ -0,0 +1,156 @@ +/* global describe, it */ + +const { Store } = require('../pkg/oxigraph.js') +const assert = require('assert') +const dataFactory = require('@rdfjs/data-model') + +const ex = dataFactory.namedNode('http://example.com') + +describe('Store', function () { + describe('#add()', function () { + it('an added quad should be in the store', function () { + const store = new Store() + store.add(dataFactory.triple(ex, ex, ex)) + assert(store.has(dataFactory.triple(ex, ex, ex))) + }) + }) + + describe('#delete()', function () { + it('an removed quad should not be in the store anymore', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + assert(store.has(dataFactory.triple(ex, ex, ex))) + store.delete(dataFactory.triple(ex, ex, ex)) + assert(!store.has(dataFactory.triple(ex, ex, ex))) + }) + }) + + describe('#has()', function () { + it('an added quad should be in the store', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + assert(store.has(dataFactory.triple(ex, ex, ex))) + }) + }) + + describe('#size()', function () { + it('A store with one quad should have 1 for size', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + assert.strictEqual(1, store.size) + }) + }) + + describe('#match_quads()', function () { + it('blank pattern should return all quads', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + const results = store.match() + assert.strictEqual(1, results.length) + assert(dataFactory.triple(ex, ex, ex).equals(results[0])) + }) + }) + + describe('#query()', function () { + it('ASK true', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + assert.strictEqual(true, store.query('ASK { ?s ?s ?s }')) + }) + + it('ASK false', function () { + const store = new Store() + assert.strictEqual(false, store.query('ASK { FILTER(false)}')) + }) + + it('CONSTRUCT', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + const results = store.query('CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }') + assert.strictEqual(1, results.length) + assert(dataFactory.triple(ex, ex, ex).equals(results[0])) + }) + + it('SELECT', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + const results = store.query('SELECT ?s WHERE { ?s ?p ?o }') + assert.strictEqual(1, results.length) + assert(ex.equals(results[0].get('s'))) + }) + + it('SELECT with NOW()', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + const results = store.query('SELECT (YEAR(NOW()) AS ?y) WHERE {}') + assert.strictEqual(1, results.length) + }) + + it('SELECT with RAND()', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + const results = store.query('SELECT (RAND() AS ?y) WHERE {}') + assert.strictEqual(1, results.length) + }) + }) + + describe('#update()', function () { + it('INSERT DATA', function () { + const store = new Store() + store.update('INSERT DATA { }') + assert.strictEqual(1, store.size) + }) + + it('DELETE DATA', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + store.update('DELETE DATA { }') + assert.strictEqual(0, store.size) + }) + + it('DELETE WHERE', function () { + const store = new Store([dataFactory.triple(ex, ex, ex)]) + store.update('DELETE WHERE { ?v ?v ?v }') + assert.strictEqual(0, store.size) + }) + }) + + describe('#load()', function () { + it('load NTriples in the default graph', function () { + const store = new Store() + store.load(' .', 'application/n-triples') + assert(store.has(dataFactory.triple(ex, ex, ex))) + }) + + it('load NTriples in an other graph', function () { + const store = new Store() + store.load(' .', 'application/n-triples', null, ex) + assert(store.has(dataFactory.quad(ex, ex, ex, ex))) + }) + + it('load Turtle with a base IRI', function () { + const store = new Store() + store.load(' <> .', 'text/turtle', 'http://example.com') + assert(store.has(dataFactory.triple(ex, ex, ex))) + }) + + it('load NQuads', function () { + const store = new Store() + store.load(' .', 'application/n-quads') + assert(store.has(dataFactory.quad(ex, ex, ex, ex))) + }) + + it('load TriG with a base IRI', function () { + const store = new Store() + store.load('GRAPH <> { <> }', 'application/trig', 'http://example.com') + assert(store.has(dataFactory.quad(ex, ex, ex, ex))) + }) + }) + + describe('#dump()', function () { + it('dump dataset content', function () { + const store = new Store([dataFactory.quad(ex, ex, ex, ex)]) + assert.strictEqual(' .\n', store.dump('application/n-quads')) + }) + + it('dump named graph content', function () { + const store = new Store([dataFactory.quad(ex, ex, ex, ex)]) + assert.strictEqual(' .\n', store.dump('application/n-triples', ex)) + }) + + it('dump default graph content', function () { + const store = new Store([dataFactory.quad(ex, ex, ex, ex)]) + assert.strictEqual('', store.dump('application/n-triples')) + }) + }) +})