$name }";
+ let ast = substituted(sparql, json!({})).unwrap();
+ assert_eq!(ast, parse_sparql(sparql).ast.unwrap());
+ }
+
+ #[test]
+ fn values_follow_the_json_ld_forms() {
+ let ast = substituted(
+ "SELECT * WHERE { ?s $a, $b, $c, $d, $e, $f, $g, $h }",
+ json!({
+ "a": 7,
+ "b": 1.5,
+ "c": true,
+ "d": {"@id": "http://example.org/x"},
+ "e": {"@id": "_:fdb-1"},
+ "f": {"@value": "2024-01-02", "@type": "http://www.w3.org/2001/XMLSchema#date"},
+ "g": {"@value": "chat", "@language": "fr"},
+ "h": {"@value": 3},
+ }),
+ )
+ .unwrap();
+ let GraphPattern::Bgp { patterns, .. } = &select(&ast).where_clause.pattern else {
+ panic!("expected a BGP")
+ };
+ let objects: Vec<&Term> = patterns.iter().map(|t| &t.object).collect();
+ assert!(matches!(
+ objects[0],
+ Term::Literal(Literal {
+ value: LiteralValue::Integer(7),
+ ..
+ })
+ ));
+ assert!(
+ matches!(objects[1], Term::Literal(Literal { value: LiteralValue::Double(d), .. }) if *d == 1.5)
+ );
+ assert!(matches!(
+ objects[2],
+ Term::Literal(Literal {
+ value: LiteralValue::Boolean(true),
+ ..
+ })
+ ));
+ assert!(
+ matches!(objects[3], Term::Iri(Iri { value: crate::ast::IriValue::Full(i), .. }) if i.as_ref() == "http://example.org/x")
+ );
+ assert!(
+ matches!(objects[4], Term::BlankNode(BlankNode { value: crate::ast::BlankNodeValue::Labeled(l), .. }) if l.as_ref() == "fdb-1")
+ );
+ assert!(
+ matches!(objects[5], Term::Literal(Literal { value: LiteralValue::Typed { value, .. }, .. }) if value.as_ref() == "2024-01-02")
+ );
+ assert!(
+ matches!(objects[6], Term::Literal(Literal { value: LiteralValue::LangTagged { lang, .. }, .. }) if lang.as_ref() == "fr")
+ );
+ assert!(matches!(
+ objects[7],
+ Term::Literal(Literal {
+ value: LiteralValue::Integer(3),
+ ..
+ })
+ ));
+ }
+
+ #[test]
+ fn values_that_are_not_one_term_are_rejected() {
+ for (value, reason) in [
+ (json!(null), "null"),
+ (json!([1, 2]), "list"),
+ (json!({"x": 1}), "without `@id` or `@value`"),
+ (json!({"@id": "_:"}), "blank node label"),
+ (json!({"@value": "_:x", "@type": "@id"}), "blank node label"),
+ (json!({"@value": 1, "@type": "@id"}), "must be a string"),
+ (
+ json!({"@value": "x", "@type": "@vocab"}),
+ "only `@id` or a datatype IRI",
+ ),
+ (
+ json!({"@value": [1, "a"], "@type": fluree_vocab::fluree::EMBEDDING_VECTOR}),
+ "not all numbers",
+ ),
+ (
+ json!({"@value": [1, 2], "@type": "http://example.org/t"}),
+ "embeddingVector",
+ ),
+ (json!({"@id": "x", "@type": "y"}), "unexpected key"),
+ (
+ json!({"@value": "a", "@type": "t", "@language": "en"}),
+ "both",
+ ),
+ (json!({"@value": 1, "@language": "en"}), "must be strings"),
+ ] {
+ let err =
+ substituted("SELECT ?s WHERE { ?s
$v }", json!({ "v": value })).unwrap_err();
+ assert!(err.reason.contains(reason), "{err}");
+ }
+ }
+
+ #[test]
+ fn only_a_stored_blank_node_is_a_parameter() {
+ // Any other label lowers to a variable: under DELETE WHERE it would
+ // delete every triple instead of one node's.
+ for sparql in ["DELETE WHERE { $s ?p ?o }", "SELECT ?o WHERE { $s ?p ?o }"] {
+ for value in [
+ json!({"@id": "_:x"}),
+ json!({"@value": "_:x", "@type": "@id"}),
+ ] {
+ let err = substituted(sparql, json!({ "s": value })).unwrap_err();
+ assert!(err.reason.contains("blank node label"), "{sparql}: {err}");
+ }
+ }
+ }
+
+ #[test]
+ fn an_id_typed_value_is_a_node() {
+ let ast = substituted(
+ "SELECT * WHERE { ?s
$a, $b }",
+ json!({
+ "a": {"@value": "http://example.org/x", "@type": "@id"},
+ "b": {"@value": "_:fdb-1", "@type": "@id"},
+ }),
+ )
+ .unwrap();
+ let GraphPattern::Bgp { patterns, .. } = &select(&ast).where_clause.pattern else {
+ panic!("expected a BGP")
+ };
+ assert!(
+ matches!(&patterns[0].object, Term::Iri(Iri { value: IriValue::Full(i), .. }) if i.as_ref() == "http://example.org/x")
+ );
+ assert!(
+ matches!(&patterns[1].object, Term::BlankNode(BlankNode { value: crate::ast::BlankNodeValue::Labeled(l), .. }) if l.as_ref() == "fdb-1")
+ );
+ }
+
+ #[test]
+ fn a_vector_takes_its_numbers_as_a_list() {
+ let ast = substituted(
+ "SELECT ?s WHERE { ?s
$v }",
+ json!({"v": {"@value": [0.5, 1, -2.25], "@type": fluree_vocab::fluree::EMBEDDING_VECTOR}}),
+ )
+ .unwrap();
+ let triple = first_triple(&select(&ast).where_clause.pattern);
+ assert!(matches!(
+ &triple.object,
+ Term::Literal(Literal { value: LiteralValue::Typed { value, datatype }, .. })
+ if value.as_ref() == "[0.5,1,-2.25]"
+ && matches!(&datatype.value, IriValue::Full(d) if d.as_ref() == fluree_vocab::fluree::EMBEDDING_VECTOR)
+ ));
+ }
+
+ #[test]
+ fn a_name_given_twice_is_an_error() {
+ let err = substituted(
+ "SELECT ?s WHERE { ?s $name }",
+ json!({"name": "Alice", "$name": "Bob"}),
+ )
+ .unwrap_err();
+ assert!(err.reason.contains("given twice"), "{err}");
+ }
+
+ #[test]
+ fn a_parameter_not_in_the_query_is_an_error() {
+ let err = substituted(
+ "SELECT ?s WHERE { ?s $name }",
+ json!({"nmae": "Alice"}),
+ )
+ .unwrap_err();
+ assert_eq!(
+ err.to_string(),
+ "parameter `$nmae` is not a variable in the query"
+ );
+ }
+
+ #[test]
+ fn positions_constrain_the_term() {
+ let ast = substituted(
+ "SELECT ?o WHERE { GRAPH $g { $s $p ?o } }",
+ json!({"s": {"@id": "_:fdb-b"}, "p": {"@id": "http://example.org/p"}, "g": {"@id": "http://example.org/g"}}),
+ )
+ .unwrap();
+ assert!(!mentions_var(&ast, "s") && !mentions_var(&ast, "p") && !mentions_var(&ast, "g"));
+
+ for (sparql, reason) in [
+ ("SELECT * WHERE { $v ?o }", "is a subject"),
+ ("SELECT * WHERE { ?s $v ?o }", "is a predicate"),
+ ("SELECT * WHERE { GRAPH $v { ?s ?p ?o } }", "names a graph"),
+ ("SELECT * WHERE { SERVICE $v { ?s ?p ?o } }", "SERVICE"),
+ ] {
+ let err = substituted(sparql, json!({"v": "literal"})).unwrap_err();
+ assert!(err.reason.contains(reason), "{sparql}: {err}");
+ }
+ let err = substituted(
+ "SELECT * WHERE { ?s
?o FILTER(?o = $v) }",
+ json!({"v": {"@id": "_:fdb-b"}}),
+ )
+ .unwrap_err();
+ assert!(err.reason.contains("blank node"), "{err}");
+ }
+
+ #[test]
+ fn only_a_local_service_body_takes_parameters() {
+ let ast = substituted(
+ "SELECT ?s WHERE { SERVICE { ?s $v } }",
+ json!({"v": 1}),
+ )
+ .unwrap();
+ assert!(!mentions_var(&ast, "v"));
+ for sparql in [
+ "SELECT ?s WHERE { SERVICE { ?s $v } }",
+ "SELECT ?s WHERE { SERVICE ?e { ?s
$v } }",
+ ] {
+ let err = substituted(sparql, json!({"v": 1})).unwrap_err();
+ assert!(err.reason.contains("remote SERVICE"), "{sparql}: {err}");
+ }
+ let ast = substituted(
+ "SELECT ?s WHERE { SERVICE $e { ?s
?o } }",
+ json!({"e": {"@id": "http://example.org/sparql"}}),
+ )
+ .unwrap();
+ assert!(!mentions_var(&ast, "e"));
+ }
+
+ #[test]
+ fn nested_scopes_see_the_value() {
+ let ast = substituted(
+ "SELECT ?s WHERE {
+ ?s
?o
+ OPTIONAL { ?s ?a FILTER(?a > $min) }
+ FILTER EXISTS { ?s $min }
+ { SELECT ?s WHERE { ?s $min } }
+ MINUS { ?s $min }
+ }",
+ json!({"min": 21}),
+ )
+ .unwrap();
+ assert!(!mentions_var(&ast, "min"));
+ }
+
+ #[test]
+ fn projected_parameters_stay_columns() {
+ let ast = substituted(
+ "SELECT ?name ?s WHERE { ?s ?name } ORDER BY ?name",
+ json!({"name": "Alice"}),
+ )
+ .unwrap();
+ let q = select(&ast);
+ let SelectVariables::Explicit(items) = &q.select.variables else {
+ panic!("explicit projection")
+ };
+ assert!(matches!(
+ &items[0],
+ SelectVariable::Expr { expr: Expression::Literal(_), alias, .. } if alias.name.as_ref() == "name"
+ ));
+ let order = &q.modifiers.order_by.as_ref().unwrap().conditions[0];
+ assert!(matches!(
+ order.expr,
+ OrderExpr::Expr(Expression::Literal(_))
+ ));
+ }
+
+ #[test]
+ fn a_grouped_parameter_is_projected_through_its_key() {
+ let ast = substituted(
+ "SELECT ?name (COUNT(?s) AS ?n) WHERE { ?s ?name } GROUP BY ?name",
+ json!({"name": "Alice"}),
+ )
+ .unwrap();
+ let q = select(&ast);
+ let SelectVariables::Explicit(items) = &q.select.variables else {
+ panic!("explicit projection")
+ };
+ assert!(matches!(&items[0], SelectVariable::Var(v) if v.name.as_ref() == "name"));
+ assert!(matches!(
+ &q.modifiers.group_by.as_ref().unwrap().conditions[0],
+ GroupCondition::Expr { expr: Expression::Literal(_), alias: Some(v), .. } if v.name.as_ref() == "name"
+ ));
+ }
+
+ #[test]
+ fn a_parameter_the_query_assigns_is_an_error() {
+ for sparql in [
+ "SELECT ?x WHERE { ?s ?o BIND(?o AS ?x) }",
+ "SELECT ?x WHERE { VALUES ?x { 1 2 } ?s
?x }",
+ "SELECT (?o AS ?x) WHERE { ?s
?o }",
+ "SELECT ?x WHERE { ?s
?o } GROUP BY (?o AS ?x)",
+ "SELECT ?s WHERE { ?s
?o } VALUES ?x { 1 }",
+ ] {
+ let err = substituted(sparql, json!({"x": 1})).unwrap_err();
+ assert!(err.reason.contains("assigned"), "{sparql}: {err}");
+ }
+ }
+
+ #[test]
+ fn update_templates_and_where_are_substituted() {
+ let ast = substituted(
+ "DELETE { ?s ?old } INSERT { ?s $new } WHERE { ?s $id ; ?old }",
+ json!({"id": 7, "new": "done"}),
+ )
+ .unwrap();
+ assert!(!mentions_var(&ast, "id") && !mentions_var(&ast, "new"));
+ assert!(mentions_var(&ast, "old"));
+
+ let ast = substituted(
+ "DELETE WHERE { $s ?p ?o }",
+ json!({"s": {"@id": "http://example.org/x"}}),
+ )
+ .unwrap();
+ assert!(!mentions_var(&ast, "s"));
+ }
+
+ #[test]
+ fn construct_and_describe_are_substituted() {
+ let ast = substituted(
+ "CONSTRUCT { $s true } WHERE { $s ?p ?o }",
+ json!({"s": {"@id": "http://example.org/x"}}),
+ )
+ .unwrap();
+ assert!(!mentions_var(&ast, "s"));
+ let ast =
+ substituted("DESCRIBE $s", json!({"s": {"@id": "http://example.org/x"}})).unwrap();
+ assert!(!mentions_var(&ast, "s"));
+ }
+}
diff --git a/fluree-db-sparql/src/parse/expr.rs b/fluree-db-sparql/src/parse/expr.rs
index 00fc58bbae..08c839ee4b 100644
--- a/fluree-db-sparql/src/parse/expr.rs
+++ b/fluree-db-sparql/src/parse/expr.rs
@@ -750,6 +750,9 @@ fn check_builtin_function_keyword(tokens: &TokenStream) -> Option
if tokens.check_keyword(TokenKind::KwEuclideanDistance) {
return Some(FunctionName::EuclideanDistance);
}
+ if tokens.check_keyword(TokenKind::KwFulltext) {
+ return Some(FunctionName::Fulltext);
+ }
None
}
@@ -1288,4 +1291,24 @@ mod tests {
_ => panic!("Expected function call"),
}
}
+
+ #[test]
+ fn fulltext_is_a_function_but_not_a_reserved_word() {
+ for input in ["fulltext(?text, \"rust\")", "FULL_TEXT(?text, \"rust\")"] {
+ match parse_expr_str(input).unwrap() {
+ Expression::FunctionCall { name, args, .. } => {
+ assert_eq!(name, FunctionName::Fulltext);
+ assert_eq!(args.len(), 2);
+ }
+ other => panic!("expected a function call, got {other:?}"),
+ }
+ }
+ assert!(matches!(
+ parse_expr_str("?fulltext").unwrap(),
+ Expression::Var(v) if v.name.as_ref() == "fulltext"
+ ));
+ let query = "PREFIX fulltext: \
+ SELECT ?s WHERE { ?s fulltext:title ?fulltext }";
+ assert!(!crate::parse_sparql(query).has_errors());
+ }
}
diff --git a/fluree-db-sparql/src/validate/mod.rs b/fluree-db-sparql/src/validate/mod.rs
index fd332a3652..80b6c23325 100644
--- a/fluree-db-sparql/src/validate/mod.rs
+++ b/fluree-db-sparql/src/validate/mod.rs
@@ -52,7 +52,7 @@ use crate::span::SourceSpan;
/// `fluree_db_core::ns_encoding::STABLE_BLANK_NODE_LABEL_PREFIX` (that crate
/// is a feature-gated dependency, so the value is duplicated here; a
/// lowering-feature test asserts equality).
-const STABLE_BLANK_NODE_LABEL_PREFIX: &str = "fdb-";
+pub(crate) const STABLE_BLANK_NODE_LABEL_PREFIX: &str = "fdb-";
/// Fluree capability configuration.
///
diff --git a/fluree-db-transact/src/error.rs b/fluree-db-transact/src/error.rs
index 12d32e3371..73df07dd1c 100644
--- a/fluree-db-transact/src/error.rs
+++ b/fluree-db-transact/src/error.rs
@@ -1,5 +1,6 @@
//! Transaction error types
+use serde_json::Value as JsonValue;
use thiserror::Error;
/// Transaction errors
@@ -156,7 +157,7 @@ pub enum TransactError {
/// SHACL validation violation (only available with `shacl` feature)
#[cfg(feature = "shacl")]
#[error("{0}")]
- ShaclViolation(String),
+ ShaclViolation(ShaclViolations),
/// Transaction exceeded the configured max-fuel limit
#[error("{0}")]
@@ -269,5 +270,84 @@ pub enum TransactError {
},
}
+/// One validation result with all identifiers resolved to IRIs.
+#[derive(Debug, Clone, serde::Serialize)]
+pub struct ReportResult {
+ /// The node that failed validation (`sh:focusNode`): a JSON string for
+ /// IRIs / blank-node labels, or a JSON-LD value object (or native
+ /// scalar) for literal `sh:targetNode` targets.
+ pub focus_node: JsonValue,
+ /// The property path, when it is a single predicate (`sh:resultPath`).
+ /// Complex paths are omitted rather than misrepresented.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub result_path: Option,
+ /// The node shape that produced this result.
+ pub source_shape: String,
+ /// The property shape that produced this result, when applicable.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub source_constraint: Option,
+ /// The constraint component IRI (`sh:sourceConstraintComponent`).
+ pub constraint_component: String,
+ /// Severity IRI: `sh:Violation`, `sh:Warning`, or `sh:Info`.
+ pub severity: String,
+ /// Human-readable message (`sh:resultMessage`).
+ pub message: String,
+ /// The offending value, when applicable (`sh:value`).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub value: Option,
+}
+
+/// The violations that rejected a write: the readable report the error
+/// displays, and each violation resolved to IRIs, as
+/// `fluree_db_api::validate` reports them. Reads as the report text, so it
+/// can be matched and searched as a string.
+#[derive(Debug, Clone)]
+pub struct ShaclViolations {
+ message: String,
+ results: Vec,
+}
+
+impl ShaclViolations {
+ pub fn new(message: String, results: Vec) -> Self {
+ Self { message, results }
+ }
+
+ pub fn message(&self) -> &str {
+ &self.message
+ }
+
+ /// The violations, resolved; empty where only the text was kept.
+ pub fn results(&self) -> &[ReportResult] {
+ &self.results
+ }
+}
+
+/// Violations known only by their text.
+impl From for ShaclViolations {
+ fn from(message: String) -> Self {
+ Self::new(message, Vec::new())
+ }
+}
+
+impl From<&str> for ShaclViolations {
+ fn from(message: &str) -> Self {
+ Self::from(message.to_string())
+ }
+}
+
+impl std::fmt::Display for ShaclViolations {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.message)
+ }
+}
+
+impl std::ops::Deref for ShaclViolations {
+ type Target = str;
+
+ fn deref(&self) -> &str {
+ &self.message
+ }
+}
+
/// Result type for transaction operations
pub type Result = std::result::Result;
diff --git a/fluree-db-transact/src/lib.rs b/fluree-db-transact/src/lib.rs
index 9dfc3a492e..d982673129 100644
--- a/fluree-db-transact/src/lib.rs
+++ b/fluree-db-transact/src/lib.rs
@@ -62,7 +62,7 @@ pub use commit::{
build_commit, commit, CommitOpts, CommitOptsRequest, CommitReceipt, StagedCommit,
};
pub use commit_flakes::generate_commit_flakes;
-pub use error::{Result, TransactError};
+pub use error::{ReportResult, Result, ShaclViolations, TransactError};
pub use flake_sink::FlakeSink;
pub use generate::{apply_cancellation, FlakeGenerator};
pub use ir::{