diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 6fdb65d0..5e6b935d 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -41,7 +41,7 @@ json-event-parser = "0.1" oxrdf = { version = "0.1.5", path="oxrdf", features = ["rdf-star", "oxsdatatypes"] } oxsdatatypes = { version = "0.1.1", path="oxsdatatypes" } spargebra = { version = "0.2.8-dev", path="spargebra", features = ["rdf-star", "sep-0002", "sep-0006"] } -sparopt = { version = "0.1.0-alpha.1", path="sparopt", features = ["rdf-star", "sep-0002", "sep-0006"] } +sparopt = { version = "0.1.0-alpha.1", path="sparopt", features = ["rdf-star", "sep-0002", "sep-0006", "rules"] } sparesults = { version = "0.1.7", path="sparesults", features = ["rdf-star"] } [target.'cfg(not(target_family = "wasm"))'.dependencies] diff --git a/lib/sparopt/Cargo.toml b/lib/sparopt/Cargo.toml index ea301c75..0e1593bc 100644 --- a/lib/sparopt/Cargo.toml +++ b/lib/sparopt/Cargo.toml @@ -18,6 +18,7 @@ default = [] rdf-star = ["oxrdf/rdf-star", "spargebra/rdf-star"] sep-0002 = ["spargebra/sep-0002"] sep-0006 = ["spargebra/sep-0006"] +rules = ["spargebra/rules"] [dependencies] oxrdf = { version = "0.1.5", path="../oxrdf" } diff --git a/lib/sparopt/src/algebra.rs b/lib/sparopt/src/algebra.rs index fc2265f8..aa4b8def 100644 --- a/lib/sparopt/src/algebra.rs +++ b/lib/sparopt/src/algebra.rs @@ -7,12 +7,12 @@ use spargebra::algebra::{ GraphPattern as AlGraphPattern, OrderExpression as AlOrderExpression, }; pub use spargebra::algebra::{Function, PropertyPathExpression}; -#[cfg(feature = "rdf-star")] -use spargebra::term::GroundTriplePattern; -use spargebra::term::{BlankNode, TermPattern, TriplePattern}; +use spargebra::term::{BlankNode, GroundSubject, TermPattern, TriplePattern}; pub use spargebra::term::{ GroundTerm, GroundTermPattern, Literal, NamedNode, NamedNodePattern, Variable, }; +#[cfg(feature = "rdf-star")] +use spargebra::term::{GroundTriple, GroundTriplePattern}; use std::collections::HashMap; /// An [expression](https://www.w3.org/TR/sparql11-query/#expressions). @@ -191,6 +191,41 @@ impl From for Expression { } } +impl From for Expression { + fn from(value: GroundSubject) -> Self { + match value { + GroundSubject::NamedNode(value) => value.into(), + #[cfg(feature = "rdf-star")] + GroundSubject::Triple(value) => (*value).into(), + } + } +} + +impl From for Expression { + fn from(value: GroundTerm) -> Self { + match value { + GroundTerm::NamedNode(value) => value.into(), + GroundTerm::Literal(value) => value.into(), + #[cfg(feature = "rdf-star")] + GroundTerm::Triple(value) => (*value).into(), + } + } +} + +#[cfg(feature = "rdf-star")] +impl From for Expression { + fn from(value: GroundTriple) -> Self { + Self::FunctionCall( + Function::Triple, + vec![ + value.subject.into(), + value.predicate.into(), + value.object.into(), + ], + ) + } +} + impl From for Expression { fn from(value: Variable) -> Self { Self::Variable(value) diff --git a/lib/sparopt/src/lib.rs b/lib/sparopt/src/lib.rs index a8e222d1..95637611 100644 --- a/lib/sparopt/src/lib.rs +++ b/lib/sparopt/src/lib.rs @@ -1,4 +1,8 @@ pub use crate::optimizer::Optimizer; +#[cfg(feature = "rules")] +pub use crate::reasoning::QueryRewriter; pub mod algebra; mod optimizer; +#[cfg(feature = "rules")] +mod reasoning; diff --git a/lib/sparopt/src/reasoning.rs b/lib/sparopt/src/reasoning.rs new file mode 100644 index 00000000..ecc8085f --- /dev/null +++ b/lib/sparopt/src/reasoning.rs @@ -0,0 +1,446 @@ +//! Reasoning via query rewriting + +use crate::algebra::*; +use rand::random; +use spargebra::term::GroundTriplePattern; +use spargebra::RuleSet; +use std::collections::hash_map::Entry; +use std::collections::HashMap; + +pub struct QueryRewriter { + rules: Vec<(Vec, GraphPattern)>, +} + +impl QueryRewriter { + pub fn new(rule_set: RuleSet) -> Self { + Self { + rules: rule_set + .rules + .into_iter() + .map(|rule| (rule.head, (&rule.body).into())) + .collect(), + } + } + + pub fn rewrite_graph_pattern(&self, pattern: &GraphPattern) -> GraphPattern { + //TODO: rewrite EXISTS + match pattern { + GraphPattern::QuadPattern { + subject, + predicate, + object, + graph_name, + } => self.rewrite_quad_pattern(subject, predicate, object, graph_name.as_ref()), + GraphPattern::Path { .. } => todo!(), + GraphPattern::Join { left, right } => GraphPattern::join( + self.rewrite_graph_pattern(left), + self.rewrite_graph_pattern(right), + ), + GraphPattern::LeftJoin { + left, + right, + expression, + } => GraphPattern::left_join( + self.rewrite_graph_pattern(left), + self.rewrite_graph_pattern(right), + expression.clone(), + ), + #[cfg(feature = "sep-0006")] + GraphPattern::Lateral { left, right } => GraphPattern::lateral( + self.rewrite_graph_pattern(left), + self.rewrite_graph_pattern(right), + ), + GraphPattern::Filter { inner, expression } => { + GraphPattern::filter(self.rewrite_graph_pattern(inner), expression.clone()) + } + GraphPattern::Union { inner } => inner + .iter() + .map(|p| self.rewrite_graph_pattern(p)) + .reduce(GraphPattern::union) + .unwrap_or_else(GraphPattern::empty), + GraphPattern::Extend { + inner, + variable, + expression, + } => GraphPattern::extend( + self.rewrite_graph_pattern(inner), + variable.clone(), + expression.clone(), + ), + GraphPattern::Minus { left, right } => GraphPattern::minus( + self.rewrite_graph_pattern(left), + self.rewrite_graph_pattern(right), + ), + GraphPattern::Values { + variables, + bindings, + } => GraphPattern::values(variables.clone(), bindings.clone()), + GraphPattern::OrderBy { inner, expression } => { + GraphPattern::order_by(self.rewrite_graph_pattern(inner), expression.clone()) + } + GraphPattern::Project { inner, variables } => { + GraphPattern::project(self.rewrite_graph_pattern(inner), variables.clone()) + } + GraphPattern::Distinct { inner } => { + GraphPattern::distinct(self.rewrite_graph_pattern(inner)) + } + GraphPattern::Reduced { inner } => { + GraphPattern::reduced(self.rewrite_graph_pattern(inner)) + } + GraphPattern::Slice { + inner, + start, + length, + } => GraphPattern::slice(self.rewrite_graph_pattern(inner), *start, *length), + GraphPattern::Group { + inner, + variables, + aggregates, + } => GraphPattern::group( + self.rewrite_graph_pattern(inner), + variables.clone(), + aggregates.clone(), + ), + GraphPattern::Service { + inner, + silent, + name, + } => GraphPattern::service(self.rewrite_graph_pattern(inner), name.clone(), *silent), + } + } + + fn rewrite_quad_pattern( + &self, + subject: &GroundTermPattern, + predicate: &NamedNodePattern, + object: &GroundTermPattern, + graph_name: Option<&NamedNodePattern>, + ) -> GraphPattern { + // We rewrite based on rules + let mut graph_pattern = GraphPattern::QuadPattern { + subject: subject.clone(), + predicate: predicate.clone(), + object: object.clone(), + graph_name: graph_name.cloned(), + }; + for (rule_head, rule_body) in &self.rules { + for head_pattern in rule_head { + if let Some(nested) = self.apply_rule_for_quad_pattern( + subject, + predicate, + object, + graph_name, + head_pattern, + rule_body, + ) { + graph_pattern = GraphPattern::union(graph_pattern, nested); + } + } + } + graph_pattern + } + + /// Attempts to use a given rule to get new facts for a triple pattern + fn apply_rule_for_quad_pattern( + &self, + subject: &GroundTermPattern, + predicate: &NamedNodePattern, + object: &GroundTermPattern, + graph_name: Option<&NamedNodePattern>, + head: &GroundTriplePattern, + body: &GraphPattern, + ) -> Option { + let head_unification = Self::unify_triple_pattern( + subject.clone(), + predicate.clone(), + object.clone(), + head.clone(), + )?; + // We build a nested query + // from is the parent query and to is the nested one + let mut replacements_in_rule = HashMap::new(); + let mut final_binds = Vec::new(); + for replacement in head_unification { + match replacement { + Replacement::ConstToVar { from, to } => match replacements_in_rule.entry(to) { + Entry::Vacant(e) => { + e.insert(TermOrVariable::Term(from)); + } + Entry::Occupied(mut e) => match e.get() { + TermOrVariable::Term(c) => { + if from != *c { + return None; //Conflict + } + } + TermOrVariable::Variable(v) => { + final_binds.push((v.clone(), TermOrVariable::Term(from.clone()))); + e.insert(TermOrVariable::Term(from)); + } + }, + }, + Replacement::VarToConst { from, to } => { + final_binds.push((from, TermOrVariable::Term(to))); + } + Replacement::VarToVar { from, to } => match replacements_in_rule.entry(to) { + Entry::Vacant(e) => { + e.insert(TermOrVariable::Variable(from)); + } + Entry::Occupied(e) => final_binds.push((from, e.get().clone())), + }, + } + } + let mut plan = self.rewrite_rule_body(body, graph_name, &mut replacements_in_rule)?; + for (variable, value) in final_binds { + plan = GraphPattern::extend( + plan, + variable, + match value { + TermOrVariable::Term(v) => v.into(), + TermOrVariable::Variable(v) => v.into(), + }, + ); + } + Some(plan) + } + + fn rewrite_rule_body<'a>( + &self, + pattern: &'a GraphPattern, + parent_graph_name: Option<&'a NamedNodePattern>, + replacements_in_rule: &mut HashMap, + ) -> Option { + Some(match pattern { + GraphPattern::QuadPattern { + subject, + predicate, + object, + graph_name, + } => self.rewrite_quad_pattern( + &Self::apply_replacement_on_term_pattern(subject, replacements_in_rule)?, + &Self::apply_replacement_on_named_node_pattern(predicate, replacements_in_rule)?, + &Self::apply_replacement_on_term_pattern(object, replacements_in_rule)?, + if let Some(graph_name) = graph_name { + Some(Self::apply_replacement_on_named_node_pattern( + graph_name, + replacements_in_rule, + )?) + } else { + parent_graph_name.cloned() + } + .as_ref(), + ), + GraphPattern::Join { left, right } => GraphPattern::join( + self.rewrite_rule_body(left, parent_graph_name, replacements_in_rule)?, + self.rewrite_rule_body(right, parent_graph_name, replacements_in_rule)?, + ), + GraphPattern::Values { + variables, + bindings, + } => { + let variable_mapping = variables + .iter() + .map(|v| { + replacements_in_rule + .entry(v.clone()) + .or_insert_with(|| TermOrVariable::Variable(new_var())) + .clone() + }) + .collect::>(); + GraphPattern::Values { + variables: variable_mapping + .iter() + .filter_map(|v| match v { + TermOrVariable::Term(_) => None, + TermOrVariable::Variable(v) => Some(v.clone()), + }) + .collect(), + bindings: bindings + .iter() + .filter_map(|binding| { + let mut new_binding = Vec::with_capacity(binding.len()); + for (variable, value) in variable_mapping.iter().zip(binding) { + match variable { + TermOrVariable::Variable(_) => new_binding.push(value.clone()), + TermOrVariable::Term(cst) => { + let compatible = if let Some(value) = value { + cst == value + } else { + true + }; + if !compatible { + return None; + } + } + } + } + Some(new_binding) + }) + .collect(), + } + } + _ => unreachable!("Not allowed by the parser yet: {pattern:?}"), + }) + } + + fn apply_replacement_on_named_node_pattern( + pattern: &NamedNodePattern, + replacements: &mut HashMap, + ) -> Option { + Some(match pattern { + NamedNodePattern::NamedNode(node) => NamedNodePattern::NamedNode(node.clone()), + NamedNodePattern::Variable(variable) => { + match replacements + .entry(variable.clone()) + .or_insert_with(|| TermOrVariable::Variable(new_var())) + { + TermOrVariable::Term(c) => { + if let GroundTerm::NamedNode(node) = c { + NamedNodePattern::NamedNode(node.clone()) + } else { + return None; + } + } + TermOrVariable::Variable(v) => NamedNodePattern::Variable(v.clone()), + } + } + }) + } + + fn apply_replacement_on_term_pattern( + pattern: &GroundTermPattern, + replacements: &mut HashMap, + ) -> Option { + Some(match pattern { + GroundTermPattern::NamedNode(node) => node.clone().into(), + GroundTermPattern::Literal(literal) => literal.clone().into(), + GroundTermPattern::Triple(triple) => GroundTriplePattern { + subject: Self::apply_replacement_on_term_pattern(&triple.subject, replacements)?, + predicate: Self::apply_replacement_on_named_node_pattern( + &triple.predicate, + replacements, + )?, + object: Self::apply_replacement_on_term_pattern(&triple.subject, replacements)?, + } + .into(), + GroundTermPattern::Variable(variable) => { + match replacements + .entry(variable.clone()) + .or_insert_with(|| TermOrVariable::Variable(new_var())) + { + TermOrVariable::Term(c) => c.clone().into(), + TermOrVariable::Variable(v) => v.clone().into(), + } + } + }) + } + + fn unify_triple_pattern( + from_subject: GroundTermPattern, + from_predicate: NamedNodePattern, + from_object: GroundTermPattern, + to: GroundTriplePattern, + ) -> Option> { + let mut mapping = Self::unify_ground_term_pattern(from_subject, to.subject)?; + mapping.extend(Self::unify_named_node_pattern( + from_predicate, + to.predicate, + )?); + mapping.extend(Self::unify_ground_term_pattern(from_object, to.object)?); + Some(mapping) + } + + fn unify_named_node_pattern( + from: NamedNodePattern, + to: NamedNodePattern, + ) -> Option> { + match from { + NamedNodePattern::NamedNode(from) => match to { + NamedNodePattern::NamedNode(to) => { + if from == to { + Some(Vec::new()) + } else { + None + } + } + NamedNodePattern::Variable(to) => Some(vec![Replacement::ConstToVar { + from: from.into(), + to, + }]), + }, + NamedNodePattern::Variable(from) => match to { + NamedNodePattern::NamedNode(to) => Some(vec![Replacement::VarToConst { + from, + to: to.into(), + }]), + NamedNodePattern::Variable(to) => Some(vec![Replacement::VarToVar { from, to }]), + }, + } + } + + fn unify_ground_term_pattern( + from: GroundTermPattern, + to: GroundTermPattern, + ) -> Option> { + match from { + GroundTermPattern::NamedNode(from) => match to { + GroundTermPattern::NamedNode(to) => { + if from == to { + Some(Vec::new()) + } else { + None + } + } + GroundTermPattern::Literal(_) | GroundTermPattern::Triple(_) => None, + GroundTermPattern::Variable(to) => Some(vec![Replacement::ConstToVar { + from: from.into(), + to, + }]), + }, + GroundTermPattern::Literal(from) => match to { + GroundTermPattern::NamedNode(_) => None, + GroundTermPattern::Literal(to) => { + if from == to { + Some(Vec::new()) + } else { + None + } + } + GroundTermPattern::Triple(_) => None, + GroundTermPattern::Variable(to) => Some(vec![Replacement::ConstToVar { + from: from.into(), + to, + }]), + }, + GroundTermPattern::Triple(_) => unimplemented!(), + GroundTermPattern::Variable(from) => match to { + GroundTermPattern::NamedNode(to) => Some(vec![Replacement::VarToConst { + from, + to: to.into(), + }]), + GroundTermPattern::Literal(to) => Some(vec![Replacement::VarToConst { + from, + to: to.into(), + }]), + GroundTermPattern::Triple(_) => unimplemented!(), + GroundTermPattern::Variable(to) => Some(vec![Replacement::VarToVar { from, to }]), + }, + } + } +} + +#[derive(Clone)] +enum Replacement { + VarToConst { from: Variable, to: GroundTerm }, + ConstToVar { from: GroundTerm, to: Variable }, + VarToVar { from: Variable, to: Variable }, +} + +#[derive(Clone)] +enum TermOrVariable { + Term(GroundTerm), + Variable(Variable), +} + +fn new_var() -> Variable { + Variable::new_unchecked(format!("{:x}", random::())) +} diff --git a/lib/src/sparql/algebra.rs b/lib/src/sparql/algebra.rs index 41fad114..8d03baae 100644 --- a/lib/src/sparql/algebra.rs +++ b/lib/src/sparql/algebra.rs @@ -181,6 +181,56 @@ impl<'a> TryFrom<&'a String> for Update { } } +/// A parsed rule set. +#[derive(Eq, PartialEq, Debug, Clone, Hash)] +pub struct RuleSet { + pub(super) inner: spargebra::RuleSet, +} + +impl RuleSet { + /// Parses a rule set with an optional base IRI to resolve relative IRIs in the rule set. + pub fn parse(rule_set: &str, base_iri: Option<&str>) -> Result { + let rule_set = spargebra::RuleSet::parse(rule_set, base_iri)?; + Ok(rule_set.into()) + } +} + +impl fmt::Display for RuleSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.inner.fmt(f) //TODO: override + } +} + +impl FromStr for RuleSet { + type Err = spargebra::ParseError; + + fn from_str(rule_set: &str) -> Result { + Self::parse(rule_set, None) + } +} + +impl<'a> TryFrom<&'a str> for RuleSet { + type Error = spargebra::ParseError; + + fn try_from(rule_set: &str) -> Result { + Self::from_str(rule_set) + } +} + +impl<'a> TryFrom<&'a String> for RuleSet { + type Error = spargebra::ParseError; + + fn try_from(rule_set: &String) -> Result { + Self::from_str(rule_set) + } +} + +impl From for RuleSet { + fn from(rule_set: spargebra::RuleSet) -> Self { + Self { inner: rule_set } + } +} + /// A SPARQL query [dataset specification](https://www.w3.org/TR/sparql11-query/#specifyingDataset) #[derive(Eq, PartialEq, Debug, Clone, Hash)] pub struct QueryDataset { diff --git a/lib/src/sparql/mod.rs b/lib/src/sparql/mod.rs index 93fda294..7ad8eb6f 100644 --- a/lib/src/sparql/mod.rs +++ b/lib/src/sparql/mod.rs @@ -14,7 +14,7 @@ mod service; mod update; use crate::model::{NamedNode, Term}; -pub use crate::sparql::algebra::{Query, QueryDataset, Update}; +pub use crate::sparql::algebra::{Query, QueryDataset, RuleSet, Update}; use crate::sparql::dataset::DatasetView; pub use crate::sparql::error::{EvaluationError, QueryError}; use crate::sparql::eval::{SimpleEvaluator, Timer}; @@ -29,6 +29,7 @@ use json_event_parser::{JsonEvent, JsonWriter}; pub use oxrdf::{Variable, VariableNameParseError}; pub use sparesults::QueryResultsFormat; pub use spargebra::ParseError; +use sparopt::QueryRewriter; use std::collections::HashMap; use std::rc::Rc; use std::time::Duration; @@ -53,6 +54,7 @@ pub(crate) fn evaluate_query( &pattern, true, &options.custom_functions, + options.query_rewriter.as_ref(), options.without_optimizations, )?; let planning_duration = start_planning.elapsed(); @@ -74,6 +76,7 @@ pub(crate) fn evaluate_query( &pattern, false, &options.custom_functions, + options.query_rewriter.as_ref(), options.without_optimizations, )?; let planning_duration = start_planning.elapsed(); @@ -98,6 +101,7 @@ pub(crate) fn evaluate_query( &pattern, false, &options.custom_functions, + options.query_rewriter.as_ref(), options.without_optimizations, )?; let construct = PlanBuilder::build_graph_template( @@ -126,6 +130,7 @@ pub(crate) fn evaluate_query( &pattern, false, &options.custom_functions, + options.query_rewriter.as_ref(), options.without_optimizations, )?; let planning_duration = start_planning.elapsed(); @@ -174,6 +179,7 @@ pub struct QueryOptions { http_timeout: Option, http_redirection_limit: usize, without_optimizations: bool, + query_rewriter: Option>, } impl QueryOptions { @@ -267,6 +273,13 @@ impl QueryOptions { self.without_optimizations = true; self } + + #[inline] + #[must_use] + pub fn with_inference_rules(mut self, rules: RuleSet) -> Self { + self.query_rewriter = Some(Rc::new(QueryRewriter::new(rules.inner))); + self + } } /// Options for SPARQL update evaluation. diff --git a/lib/src/sparql/plan_builder.rs b/lib/src/sparql/plan_builder.rs index 5d5f6f62..78795c7a 100644 --- a/lib/src/sparql/plan_builder.rs +++ b/lib/src/sparql/plan_builder.rs @@ -9,7 +9,7 @@ use oxrdf::{BlankNode, Term, TermRef, Triple}; use regex::Regex; use spargebra::term::{GroundSubject, GroundTriple, TermPattern, TriplePattern}; use sparopt::algebra::*; -use sparopt::Optimizer; +use sparopt::{Optimizer, QueryRewriter}; use std::collections::{BTreeSet, HashMap}; use std::mem::swap; use std::rc::Rc; @@ -26,9 +26,13 @@ impl<'a> PlanBuilder<'a> { pattern: &spargebra::algebra::GraphPattern, is_cardinality_meaningful: bool, custom_functions: &'a HashMap Option>>, + query_rewriter: Option<&Rc>, without_optimizations: bool, ) -> Result<(PlanNode, Vec), EvaluationError> { let mut pattern = GraphPattern::from(pattern); + if let Some(query_rewriter) = query_rewriter { + pattern = query_rewriter.rewrite_graph_pattern(&pattern); + } if !without_optimizations { pattern = Optimizer::default().optimize(pattern); } diff --git a/lib/src/sparql/update.rs b/lib/src/sparql/update.rs index dff7d02c..92abd4e6 100644 --- a/lib/src/sparql/update.rs +++ b/lib/src/sparql/update.rs @@ -123,6 +123,7 @@ impl<'a, 'b: 'a> SimpleUpdateEvaluator<'a, 'b> { algebra, false, &self.options.query_options.custom_functions, + None, !self.options.query_options.without_optimizations, )?; let evaluator = SimpleEvaluator::new( diff --git a/testsuite/oxigraph-tests/sparql-reasoning/manifest.ttl b/testsuite/oxigraph-tests/sparql-reasoning/manifest.ttl new file mode 100644 index 00000000..d10eb8a3 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/manifest.ttl @@ -0,0 +1,39 @@ +@prefix rdf: . +@prefix : . +@prefix rdfs: . +@prefix mf: . +@prefix qt: . +@prefix ox: . + +<> rdf:type mf:Manifest ; + rdfs:label "Oxigraph SPARQL reasoning tests" ; + mf:entries + ( + :simple_type_inheritance + :simple_fact + :with_graph_name + ) . + +:simple_type_inheritance rdf:type ox:SparqlRuleEvaluationTest ; + mf:name "Simple type inheritance" ; + mf:action + [ qt:query ; + qt:data ] ; + ox:rulesData ; + mf:result . + +:simple_fact rdf:type ox:SparqlRuleEvaluationTest ; + mf:name "Simple fact" ; + mf:action + [ qt:query ; + qt:data ] ; + ox:rulesData ; + mf:result . + +:with_graph_name rdf:type ox:SparqlRuleEvaluationTest ; + mf:name "Simple type inheritance" ; + mf:action + [ qt:query ; + qt:graphData ] ; + ox:rulesData ; + mf:result . diff --git a/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.rq b/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.rq new file mode 100644 index 00000000..172437a4 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.rq @@ -0,0 +1,3 @@ +SELECT * WHERE { + ?s ?p ?o +} diff --git a/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.rr b/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.rr new file mode 100644 index 00000000..8addd295 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.rr @@ -0,0 +1,3 @@ +PREFIX ex: + +IF {} THEN { ex:s ex:p ex:o } diff --git a/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.srj b/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.srj new file mode 100644 index 00000000..bde25870 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.srj @@ -0,0 +1,23 @@ +{ + "head": { + "vars": ["s", "p", "o"] + }, + "results": { + "bindings": [ + { + "s": { + "type": "uri", + "value": "http://example.org/s" + }, + "p": { + "type": "uri", + "value": "http://example.org/p" + }, + "o": { + "type": "uri", + "value": "http://example.org/o" + } + } + ] + } +} diff --git a/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.ttl b/testsuite/oxigraph-tests/sparql-reasoning/simple_fact.ttl new file mode 100644 index 00000000..e69de29b diff --git a/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.rq b/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.rq new file mode 100644 index 00000000..0879c24a --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.rq @@ -0,0 +1,5 @@ +PREFIX ex: + +SELECT * WHERE { + ?s a ex:Bar +} diff --git a/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.rr b/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.rr new file mode 100644 index 00000000..7a12053f --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.rr @@ -0,0 +1,3 @@ +PREFIX ex: + +IF { ?s a ex:Foo } THEN { ?s a ex:Bar } diff --git a/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.srj b/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.srj new file mode 100644 index 00000000..a533f08d --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.srj @@ -0,0 +1,15 @@ +{ + "head": { + "vars": ["s"] + }, + "results": { + "bindings": [ + { + "s": { + "type": "uri", + "value": "http://example.org/s1" + } + } + ] + } +} diff --git a/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.ttl b/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.ttl new file mode 100644 index 00000000..f0d631f2 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/simple_type_inheritance.ttl @@ -0,0 +1,2 @@ +PREFIX ex: +ex:s1 a ex:Foo . diff --git a/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.rq b/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.rq new file mode 100644 index 00000000..cd1132a1 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.rq @@ -0,0 +1,5 @@ +PREFIX ex: + +SELECT * WHERE { + GRAPH ?g { ?s a ex:Bar } +} diff --git a/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.rr b/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.rr new file mode 100644 index 00000000..bee05578 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.rr @@ -0,0 +1,3 @@ +PREFIX ex: + +IF { ?g a ex:Foo } THEN { ?g a ex:Bar } diff --git a/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.srj b/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.srj new file mode 100644 index 00000000..3b4ad7b3 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.srj @@ -0,0 +1,19 @@ +{ + "head": { + "vars": ["s", "g"] + }, + "results": { + "bindings": [ + { + "s": { + "type": "uri", + "value": "http://example.org/s1" + }, + "g": { + "type": "uri", + "value": "https://github.com/oxigraph/oxigraph/tests/sparql-reasoning/with_graph_name.ttl" + } + } + ] + } +} diff --git a/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.ttl b/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.ttl new file mode 100644 index 00000000..f0d631f2 --- /dev/null +++ b/testsuite/oxigraph-tests/sparql-reasoning/with_graph_name.ttl @@ -0,0 +1,2 @@ +PREFIX ex: +ex:s1 a ex:Foo . diff --git a/testsuite/src/manifest.rs b/testsuite/src/manifest.rs index fcf79b36..ee0457a8 100644 --- a/testsuite/src/manifest.rs +++ b/testsuite/src/manifest.rs @@ -19,6 +19,7 @@ pub struct Test { pub service_data: Vec<(String, String)>, pub result: Option, pub result_graph_data: Vec<(NamedNode, String)>, + pub rules_data: Option, } impl fmt::Display for Test { @@ -255,6 +256,14 @@ impl TestManifest { Some(_) => bail!("invalid result"), None => (None, Vec::new()), }; + let rules_data = if let Some(TermRef::NamedNode(n)) = self + .graph + .object_for_subject_predicate(&test_node, ox::RULES_DATA) + { + Some(n.as_str().to_owned()) + } else { + None + }; return Ok(Some(Test { id: test_node, kind, @@ -268,6 +277,7 @@ impl TestManifest { service_data, result, result_graph_data, + rules_data, })); } } diff --git a/testsuite/src/sparql_evaluator.rs b/testsuite/src/sparql_evaluator.rs index 44153c68..5dbfd62c 100644 --- a/testsuite/src/sparql_evaluator.rs +++ b/testsuite/src/sparql_evaluator.rs @@ -67,6 +67,10 @@ pub fn register_sparql_tests(evaluator: &mut TestEvaluator) { "https://github.com/oxigraph/oxigraph/tests#NegativeTsvResultsSyntaxTest", evaluate_negative_tsv_result_syntax_test, ); + evaluator.register( + "https://github.com/oxigraph/oxigraph/tests#SparqlRuleEvaluationTest", + evaluate_rule_evaluation_test, + ); } fn evaluate_positive_syntax_test(test: &Test) -> Result<()> { @@ -286,6 +290,58 @@ fn evaluate_update_evaluation_test(test: &Test) -> Result<()> { } } +fn evaluate_rule_evaluation_test(test: &Test) -> Result<()> { + let store = Store::new()?; + if let Some(data) = &test.data { + load_dataset_to_store(data, &store)?; + } + for (name, value) in &test.graph_data { + load_graph_to_store(value, &store, name)?; + } + let query_file = test + .query + .as_deref() + .ok_or_else(|| anyhow!("No action found for test {test}"))?; + let query = Query::parse(&read_file_to_string(query_file)?, Some(query_file)) + .map_err(|e| anyhow!("Failure to parse query of {test} with error: {e}"))?; + let rule_file = test + .rules_data + .as_deref() + .ok_or_else(|| anyhow!("No rules data found for test {test}"))?; + let options = QueryOptions::default().with_inference_rules( + RuleSet::parse(&read_file_to_string(rule_file)?, Some(rule_file)) + .map_err(|e| anyhow!("Failure to parse rule set of {test} with error: {e}"))?, + ); + + let expected_results = load_sparql_query_result(test.result.as_ref().unwrap()) + .map_err(|e| anyhow!("Error constructing expected graph for {test}: {e}"))?; + let with_order = if let StaticQueryResults::Solutions { ordered, .. } = &expected_results { + *ordered + } else { + false + }; + + for with_query_optimizer in [true, false] { + let mut options = options.clone(); + if !with_query_optimizer { + options = options.without_optimizations(); + } + let actual_results = store + .query_opt(query.clone(), options) + .map_err(|e| anyhow!("Failure to execute query of {test} with error: {e}"))?; + let actual_results = StaticQueryResults::from_query_results(actual_results, with_order)?; + + if !are_query_results_isomorphic(&expected_results, &actual_results) { + bail!( + "Failure on {test}.\n{}\nParsed query:\n{}\nData:\n{store}\n", + results_diff(expected_results, actual_results), + Query::parse(&read_file_to_string(query_file)?, Some(query_file)).unwrap() + ); + } + } + Ok(()) +} + fn load_sparql_query_result(url: &str) -> Result { if url.ends_with(".srx") { StaticQueryResults::from_query_results( diff --git a/testsuite/src/vocab.rs b/testsuite/src/vocab.rs index 64327755..3562a7f1 100644 --- a/testsuite/src/vocab.rs +++ b/testsuite/src/vocab.rs @@ -77,6 +77,7 @@ pub mod qt { pub mod ut { use oxigraph::model::NamedNodeRef; + pub const DATA: NamedNodeRef<'_> = NamedNodeRef::new_unchecked("http://www.w3.org/2009/sparql/tests/test-update#data"); pub const GRAPH_DATA: NamedNodeRef<'_> = @@ -86,3 +87,10 @@ pub mod ut { pub const REQUEST: NamedNodeRef<'_> = NamedNodeRef::new_unchecked("http://www.w3.org/2009/sparql/tests/test-update#request"); } + +pub mod ox { + use oxigraph::model::NamedNodeRef; + + pub const RULES_DATA: NamedNodeRef<'_> = + NamedNodeRef::new_unchecked("https://github.com/oxigraph/oxigraph/tests#rulesData"); +} diff --git a/testsuite/tests/oxigraph.rs b/testsuite/tests/oxigraph.rs index 04360a9e..a129b1da 100644 --- a/testsuite/tests/oxigraph.rs +++ b/testsuite/tests/oxigraph.rs @@ -38,3 +38,10 @@ fn oxigraph_sparql_results_testsuite() -> Result<()> { "https://github.com/oxigraph/oxigraph/tests/sparql-results/manifest.ttl", ]) } + +#[test] +fn oxigraph_sparql_reasoning_testsuite() -> Result<()> { + run_testsuite(vec![ + "https://github.com/oxigraph/oxigraph/tests/sparql-reasoning/manifest.ttl", + ]) +}