diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d81c202..18dc2f1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -7,7 +7,7 @@ jobs: name: fmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: rustup update - run: rustup component add rustfmt - run: cargo fmt -- --check @@ -16,7 +16,7 @@ jobs: name: clippy runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: rustup update - run: rustup component add clippy - run: cargo clippy --all --all-targets @@ -36,7 +36,18 @@ jobs: wasm: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: rustup update - run: cargo install wasm-pack - run: wasm-pack test --node lib + - run: wasm-pack test --node js + + js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: rustup update + - run: cargo install wasm-pack + - run: cd js + - run: npm install + - run: npm test diff --git a/.gitignore b/.gitignore index 90f36cf7..a9a6ffbf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ target/ **/*.rs.bk Cargo.lock .idea -*.iml \ No newline at end of file +*.iml +js/node_modules \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index 89367ea6..75efa828 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,8 @@ members = [ "lib", "server", - "wikibase" + "wikibase", + "js" ] [profile.release] diff --git a/README.md b/README.md index 04e60172..16c52942 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,8 @@ Its goal is to provide a compliant, safe and fast graph database based on the [R It is written in Rust. It is split into multiple parts: -* The `lib` directory contains the database written as a Rust library +* The `lib` directory contains the database written as a Rust library. +* The `js` directory contains bindings to use Oxigraph in JavaScript with the help of WebAssembly. See [its README](https://github.com/oxigraph/oxigraph/blob/master/js/README.md) for the JS bindings documentation. * The `server` directory contains a stand-alone binary of a web server implementing the [SPARQL 1.1 Protocol](https://www.w3.org/TR/sparql11-protocol/). * The `wikibase` directory contains a stand-alone binary of a web server able to synchronize with a [Wikibase instance](https://wikiba.se/). diff --git a/js/Cargo.toml b/js/Cargo.toml new file mode 100644 index 00000000..7ad750c7 --- /dev/null +++ b/js/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "oxigraph_js" +version = "0.0.1" +authors = ["Tpt "] +license = "MIT/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", "rlib"] +name = "oxigraph" + +[dependencies] +oxigraph = {path = "../lib"} +wasm-bindgen = "0.2" +js-sys = "0.3" +console_error_panic_hook = "0.1" + +[dev-dependencies] +wasm-bindgen-test = "0.2" diff --git a/js/README.md b/js/README.md new file mode 100644 index 00000000..c67f6161 --- /dev/null +++ b/js/README.md @@ -0,0 +1,142 @@ +Oxigraph for JavaScript +======================= + +[![actions status](https://github.com/oxigraph/oxigraph/workflows/build/badge.svg)](https://github.com/oxigraph/oxigraph/actions) +[![npm](https://img.shields.io/npm/v/oxigraph)](https://www.npmjs.com/package/oxigraph) + +This package provides a JavaScript API on top of Oxigraph compiled with WebAssembly. + +Oxigraph is a work in progress graph database written in Rust implementing the [SPARQL](https://www.w3.org/TR/sparql11-overview/) standard. + +It 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/) capabilities. + +It is distributed using a [a NPM package](https://www.npmjs.com/package/oxigraph) that should work with nodeJS. + +```bash +npm install oxigraph +``` + +```js +const oxigraph = require('oxigraph'); +``` + +## 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"); +} +``` + + +## 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); +} +``` + + +## 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 build the JavaScript bindings, just run `wasm-pack build`, to run the tests of the JS bindings written in JS just do a usual `npm test`. diff --git a/js/package.json b/js/package.json new file mode 100644 index 00000000..ce644e69 --- /dev/null +++ b/js/package.json @@ -0,0 +1,14 @@ +{ + "name": "oxigraph_tests", + "description": "Build a tests for Oxigraph JS", + "private": true, + "devDependencies": { + "mocha": "^5.2.0", + "@rdfjs/data-model": "^1.1.2" + }, + "scripts": { + "test": "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..43712322 --- /dev/null +++ b/js/src/model.rs @@ -0,0 +1,583 @@ +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::parse(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_from_unique_id(u128::from_str_radix(&value, 16).map_err(|_| { + format_err!("Oxigraph only supports BlankNode created with Oxigraph DataFactory") + })?) + } 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: 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: 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 { + Ok(self.from_js.to_quad(original)?.into()) + } +} + +#[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 Term { + 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 Term { + 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().clone().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(term: Term) -> Self { + match term { + Term::NamedNode(node) => node.into(), + Term::BlankNode(node) => node.into(), + Term::Literal(literal) => literal.into(), + } + } +} + +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 variable {} 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")) + } + } + } +} + +#[wasm_bindgen(js_name = Quad)] +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub struct JsQuad { + subject: JsTerm, + predicate: JsTerm, + object: JsTerm, + graph: 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.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 { + let (s, p, o, g) = quad.destruct(); + Self { + subject: s.into(), + predicate: p.into(), + object: o.into(), + graph: if let Some(g) = g { + g.into() + } else { + JsTerm::DefaultGraph(JsDefaultGraph {}) + }, + } + } +} + +impl TryFrom for Quad { + type Error = JsValue; + + fn try_from(quad: JsQuad) -> Result { + Ok(Quad::new( + NamedOrBlankNode::try_from(quad.subject)?, + NamedNode::try_from(quad.predicate)?, + Term::try_from(quad.object)?, + match quad.graph { + JsTerm::NamedNode(node) => Some(NamedOrBlankNode::from(NamedNode::from(node))), + JsTerm::BlankNode(node) => Some(NamedOrBlankNode::from(BlankNode::from(node))), + JsTerm::Literal(literal) => { + return Err(format_err!( + "The variable ?{} is not a valid graph name", + literal.inner + )) + } + JsTerm::DefaultGraph(_) => None, + }, + )) + } +} + +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::parse( + 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_from_unique_id( + u128::from_str_radix( + &Reflect::get(&value, &self.value)? + .as_string() + .ok_or_else(|| format_err!("BlankNode should have a string value"))?, + 16, + ) + .map_err(|_| { + format_err!( + "Oxigraph only supports BlankNode created with Oxigraph DataFactory" + ) + })?, + ) + .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: 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..d595e335 --- /dev/null +++ b/js/src/store.rs @@ -0,0 +1,157 @@ +use crate::format_err; +use crate::model::*; +use crate::utils::to_err; +use js_sys::{Array, Map}; +use oxigraph::sparql::{PreparedQuery, QueryOptions, QueryResult}; +use oxigraph::{Error, MemoryRepository, Repository, RepositoryConnection}; +use std::convert::TryInto; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen(js_name = MemoryStore)] +#[derive(Default)] +pub struct JsMemoryStore { + store: MemoryRepository, + from_js: FromJsConverter, +} + +#[wasm_bindgen(js_class = MemoryStore)] +impl JsMemoryStore { + #[wasm_bindgen(constructor)] + pub fn new(quads: Option>) -> Result { + console_error_panic_hook::set_once(); + + let this = Self::default(); + if let Some(quads) = quads { + for quad in quads.iter() { + this.add(quad)?; + } + } + Ok(this) + } + + #[wasm_bindgen(js_name = dataFactory, getter)] + pub fn data_factory(&self) -> JsDataFactory { + JsDataFactory::default() + } + + pub fn add(&self, quad: &JsValue) -> Result<(), JsValue> { + self.store + .connection() + .map_err(to_err)? + .insert(&self.from_js.to_quad(quad)?.try_into()?) + .map_err(to_err) + } + + pub fn delete(&self, quad: &JsValue) -> Result<(), JsValue> { + self.store + .connection() + .map_err(to_err)? + .remove(&self.from_js.to_quad(quad)?.try_into()?) + .map_err(to_err) + } + + pub fn has(&self, quad: &JsValue) -> Result { + self.store + .connection() + .map_err(to_err)? + .contains(&self.from_js.to_quad(quad)?.try_into()?) + .map_err(to_err) + } + + #[wasm_bindgen(js_name = match)] + pub fn match_quads( + &self, + subject: &JsValue, + predicate: &JsValue, + object: &JsValue, + graph: &JsValue, + ) -> Result, JsValue> { + Ok(self + .store + .connection() + .map_err(to_err)? + .quads_for_pattern( + match self.from_js.to_optional_term(subject)? { + Some(JsTerm::NamedNode(node)) => Some(node.into()), + Some(JsTerm::BlankNode(node)) => Some(node.into()), + Some(_) => { + return Err(format_err!( + "The match subject parameter should be a named or a blank node", + )) + } + None => None, + }.as_ref(), + match self.from_js.to_optional_term(predicate)? { + Some(JsTerm::NamedNode(node)) => Some(node.into()), + Some(_) => { + return Err(format_err!( + "The match predicate parameter should be a named node", + )) + } + None => None, + }.as_ref(), + match self.from_js.to_optional_term(object)? { + Some(JsTerm::NamedNode(node)) => Some(node.into()), + Some(JsTerm::BlankNode(node)) => Some(node.into()), + Some(JsTerm::Literal(literal)) => Some(literal.into()), + Some(_) => { + return Err(format_err!( + "The match object parameter should be a named or a blank node or a literal", + )) + } + None => None, + }.as_ref(), + match self.from_js.to_optional_term(graph)? { + Some(JsTerm::NamedNode(node)) => Some(Some(node.into())), + Some(JsTerm::BlankNode(node)) => Some(Some(node.into())), + Some(JsTerm::DefaultGraph(_)) => Some(None), + Some(_) => { + return Err(format_err!( + "The match subject parameter should be a named or a blank node or the default graph", + )) + } + None => None, + }.as_ref().map(|v| v.as_ref()), + ).map(|v| v.map(|v| JsQuad::from(v).into())).collect::,Error>>().map_err(to_err)?.into_boxed_slice()) + } + + pub fn query(&self, query: &str) -> Result { + let query = self + .store + .connection() + .map_err(to_err)? + .prepare_query(query, QueryOptions::default()) + .map_err(to_err)?; + let results = query.exec().map_err(to_err)?; + let output = match results { + QueryResult::Bindings(bindings) => { + let (variables, iter) = bindings.destruct(); + let variables: Vec = variables + .into_iter() + .map(|v| v.name().unwrap().into()) + .collect(); + let results = Array::new(); + for values in iter { + let values = values.map_err(to_err)?; + let result = Map::new(); + for (variable, value) in variables.iter().zip(values) { + if let Some(value) = value { + result.set(variable, &JsTerm::from(value).into()); + } + } + results.push(&result.into()); + } + results.into() + } + QueryResult::Graph(quads) => { + let results = Array::new(); + for quad in quads { + results.push(&JsQuad::from(quad.map_err(to_err)?.in_graph(None)).into()); + } + results.into() + } + QueryResult::Boolean(b) => b.into(), + }; + Ok(output) + } +} 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..eaf88fcc --- /dev/null +++ b/js/test/model.js @@ -0,0 +1,3 @@ +const { MemoryStore } = require('../pkg/oxigraph.js'); +const assert = require('assert'); +require('../node_modules/@rdfjs/data-model/test/index.js')((new MemoryStore()).dataFactory); diff --git a/js/test/store.js b/js/test/store.js new file mode 100644 index 00000000..dbe9113a --- /dev/null +++ b/js/test/store.js @@ -0,0 +1,66 @@ +const { MemoryStore } = require('../pkg/oxigraph.js'); +const assert = require('assert'); +const dataFactory = require('@rdfjs/data-model'); + +const ex = dataFactory.namedNode('http://example.com'); + +describe('MemoryStore', function() { + describe('#add()', function() { + it('an added quad should be in the store', function() { + const store = new MemoryStore(); + 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 MemoryStore([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 MemoryStore([dataFactory.triple(ex, ex, ex)]); + assert(store.has(dataFactory.triple(ex, ex, ex))); + }); + }); + + describe('#match_quads()', function() { + it('blank pattern should return all quads', function() { + const store = new MemoryStore([dataFactory.triple(ex, ex, ex)]); + const results = store.match(); + assert.equal(1, results.length); + assert(dataFactory.triple(ex, ex, ex).equals(results[0])); + }); + }); + + describe('#query()', function() { + it('ASK true', function() { + const store = new MemoryStore([dataFactory.triple(ex, ex, ex)]); + assert.equal(true, store.query("ASK { ?s ?s ?s }")); + }); + + it('ASK false', function() { + const store = new MemoryStore(); + assert.equal(false, store.query("ASK { FILTER(false)}")); + }); + + it('CONSTRUCT', function() { + const store = new MemoryStore([dataFactory.triple(ex, ex, ex)]); + const results = store.query("CONSTRUCT { ?s ?p ?o } WHERE { ?s ?p ?o }"); + assert.equal(1, results.length); + assert(dataFactory.triple(ex, ex, ex).equals(results[0])); + }); + + it('SELECT', function() { + const store = new MemoryStore([dataFactory.triple(ex, ex, ex)]); + const results = store.query("SELECT ?s WHERE { ?s ?p ?o }"); + assert.equal(1, results.length); + assert(ex.equals(results[0].get("s"))); + }); + }); +}); diff --git a/lib/Cargo.toml b/lib/Cargo.toml index ff62d487..e34183c8 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -32,6 +32,7 @@ peg = "0.6" [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3" +getrandom = {version="0.1", features=["wasm-bindgen"]} [dev-dependencies] rayon = "1"