From ef2172ce5982a6706856e04201ff62c6425fcaf8 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 13:50:01 -0400 Subject: [PATCH 01/17] feat(shacl): record sh:sourceConstraintComponent IRI on every validation result Adds the W3C constraint-component IRI constants to fluree-vocab, a Constraint::component() mapping, and a constraint_component field on ValidationResult threaded through all emit sites. Qualified-count results distinguish the min/max bound that actually failed; paths that fail to compile report a Fluree-specific component IRI since W3C SHACL has no component for that condition. Groundwork for the W3C-shaped validation report (fluree validate). --- fluree-db-shacl/src/constraints/mod.rs | 85 ++++++++++++++++++ fluree-db-shacl/src/validate.rs | 55 ++++++++++-- fluree-vocab/src/lib.rs | 117 +++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 8 deletions(-) diff --git a/fluree-db-shacl/src/constraints/mod.rs b/fluree-db-shacl/src/constraints/mod.rs index 564ba635c..36ba7cd26 100644 --- a/fluree-db-shacl/src/constraints/mod.rs +++ b/fluree-db-shacl/src/constraints/mod.rs @@ -169,6 +169,45 @@ impl Constraint { } } } + + /// The W3C constraint-component IRI reported as + /// `sh:sourceConstraintComponent` for violations of this constraint. + /// + /// `QualifiedValueShape` maps to the min-count component when + /// `sh:qualifiedMinCount` is declared (max-count otherwise); emit sites + /// that know which bound actually failed pick the component directly. + pub fn component(&self) -> &'static str { + use fluree_vocab::shacl as sh; + match self { + Constraint::MinCount(_) => sh::MIN_COUNT_CONSTRAINT_COMPONENT, + Constraint::MaxCount(_) => sh::MAX_COUNT_CONSTRAINT_COMPONENT, + Constraint::Datatype(_) => sh::DATATYPE_CONSTRAINT_COMPONENT, + Constraint::NodeKind(_) => sh::NODE_KIND_CONSTRAINT_COMPONENT, + Constraint::Class(_) => sh::CLASS_CONSTRAINT_COMPONENT, + Constraint::MinInclusive(_) => sh::MIN_INCLUSIVE_CONSTRAINT_COMPONENT, + Constraint::MaxInclusive(_) => sh::MAX_INCLUSIVE_CONSTRAINT_COMPONENT, + Constraint::MinExclusive(_) => sh::MIN_EXCLUSIVE_CONSTRAINT_COMPONENT, + Constraint::MaxExclusive(_) => sh::MAX_EXCLUSIVE_CONSTRAINT_COMPONENT, + Constraint::Pattern(..) => sh::PATTERN_CONSTRAINT_COMPONENT, + Constraint::MinLength(_) => sh::MIN_LENGTH_CONSTRAINT_COMPONENT, + Constraint::MaxLength(_) => sh::MAX_LENGTH_CONSTRAINT_COMPONENT, + Constraint::HasValue(_) => sh::HAS_VALUE_CONSTRAINT_COMPONENT, + Constraint::In(_) => sh::IN_CONSTRAINT_COMPONENT, + Constraint::Equals(_) => sh::EQUALS_CONSTRAINT_COMPONENT, + Constraint::Disjoint(_) => sh::DISJOINT_CONSTRAINT_COMPONENT, + Constraint::LessThan(_) => sh::LESS_THAN_CONSTRAINT_COMPONENT, + Constraint::LessThanOrEquals(_) => sh::LESS_THAN_OR_EQUALS_CONSTRAINT_COMPONENT, + Constraint::UniqueLang(_) => sh::UNIQUE_LANG_CONSTRAINT_COMPONENT, + Constraint::LanguageIn(_) => sh::LANGUAGE_IN_CONSTRAINT_COMPONENT, + Constraint::QualifiedValueShape { min_count, .. } => { + if min_count.is_some() { + sh::QUALIFIED_MIN_COUNT_CONSTRAINT_COMPONENT + } else { + sh::QUALIFIED_MAX_COUNT_CONSTRAINT_COMPONENT + } + } + } + } } impl NodeConstraint { @@ -204,3 +243,49 @@ pub struct ConstraintViolation { /// Human-readable message about the violation pub message: String, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn component_iris_match_w3c_names() { + assert_eq!( + Constraint::MinCount(1).component(), + "http://www.w3.org/ns/shacl#MinCountConstraintComponent" + ); + assert_eq!( + Constraint::Pattern("a".into(), None).component(), + "http://www.w3.org/ns/shacl#PatternConstraintComponent" + ); + assert_eq!( + Constraint::LessThanOrEquals(Sid::new(0, "p")).component(), + "http://www.w3.org/ns/shacl#LessThanOrEqualsConstraintComponent" + ); + } + + #[test] + fn qualified_component_tracks_declared_bound() { + let qualified = |min_count, max_count| Constraint::QualifiedValueShape { + shape: Arc::new(NestedShape { + id: Sid::new(0, "q"), + property_constraints: Vec::new(), + node_constraints: Vec::new(), + value_constraints: Vec::new(), + message: None, + }), + min_count, + max_count, + disjoint: false, + sibling_shapes: Vec::new(), + }; + assert_eq!( + qualified(Some(1), None).component(), + "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent" + ); + assert_eq!( + qualified(None, Some(2)).component(), + "http://www.w3.org/ns/shacl#QualifiedMaxCountConstraintComponent" + ); + } +} diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index c2b10e3e7..becff58ec 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -21,6 +21,7 @@ use fluree_db_core::{ }; use fluree_vocab::namespaces::{BLANK_NODE, RDF}; use fluree_vocab::rdf_names; +use fluree_vocab::shacl as sh_vocab; use parking_lot::Mutex; use std::collections::{HashMap, HashSet}; @@ -676,6 +677,7 @@ async fn validate_node_value_constraints<'a>( result_path: None, source_shape: shape.id.clone(), source_constraint: None, + constraint_component: violation.constraint.component(), severity: shape.severity, message: shape.message.clone().unwrap_or(violation.message), value: violation.value, @@ -806,6 +808,7 @@ fn validate_structural_constraint<'a>( result_path: Some(prop.clone()), source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::CLOSED_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!("Property {} not allowed by closed shape", prop.name) @@ -839,6 +842,7 @@ fn validate_structural_constraint<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::NODE_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!( @@ -876,6 +880,7 @@ fn validate_structural_constraint<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::NOT_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!( @@ -910,6 +915,7 @@ fn validate_structural_constraint<'a>( result_path: r.result_path, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::AND_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!("sh:and constraint - {}", r.message) @@ -959,6 +965,7 @@ fn validate_structural_constraint<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::OR_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!( @@ -1003,6 +1010,7 @@ fn validate_structural_constraint<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::XONE_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { "Node does not conform to any shape in sh:xone".to_string() @@ -1016,6 +1024,7 @@ fn validate_structural_constraint<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, + constraint_component: sh_vocab::XONE_CONSTRAINT_COMPONENT, severity: parent_shape.severity, message: parent_shape.message.clone().unwrap_or_else(|| { format!( @@ -1067,6 +1076,7 @@ fn validate_nested_shape<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: sh_vocab::NODE_CONSTRAINT_COMPONENT, severity: Severity::Violation, message: format!("Referenced shape {} could not be resolved", nested.id.name), value: None, @@ -1090,6 +1100,7 @@ fn validate_nested_shape<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: violation.constraint.component(), severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, @@ -1107,6 +1118,8 @@ fn validate_nested_shape<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: + fluree_vocab::fluree::UNRESOLVABLE_PATH_CONSTRAINT_COMPONENT, severity: Severity::Violation, message: format!("Unsupported sh:path expression: {reason}"), value: None, @@ -1172,6 +1185,7 @@ fn validate_nested_shape<'a>( result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: sh_vocab::EQUALS_CONSTRAINT_COMPONENT, severity: Severity::Violation, message: nested.message.clone().unwrap_or_else(|| { format!( @@ -1194,6 +1208,7 @@ fn validate_nested_shape<'a>( result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: sh_vocab::CLASS_CONSTRAINT_COMPONENT, severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, @@ -1254,6 +1269,7 @@ fn validate_nested_shape<'a>( result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: if below { sh_vocab::QUALIFIED_MIN_COUNT_CONSTRAINT_COMPONENT } else { sh_vocab::QUALIFIED_MAX_COUNT_CONSTRAINT_COMPONENT }, severity: Severity::Violation, message: nested.message.clone().unwrap_or_else(|| { format!( @@ -1283,6 +1299,7 @@ fn validate_nested_shape<'a>( result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: violation.constraint.component(), severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, @@ -1299,6 +1316,7 @@ fn validate_nested_shape<'a>( result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), + constraint_component: violation.constraint.component(), severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, @@ -1350,6 +1368,7 @@ async fn validate_property_shape<'a>( result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: fluree_vocab::fluree::UNRESOLVABLE_PATH_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: format!("Unsupported sh:path expression: {reason}"), value: None, @@ -1416,6 +1435,7 @@ async fn validate_property_shape<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: constraint.component(), severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, @@ -1432,6 +1452,7 @@ async fn validate_property_shape<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::CLASS_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, @@ -1487,29 +1508,36 @@ async fn validate_property_shape<'a>( } } - let mut qualified_messages: Vec = Vec::new(); + let mut qualified_messages: Vec<(String, &'static str)> = Vec::new(); if let Some(min) = min_count { if conforming < *min { - qualified_messages.push(format!( - "Expected at least {} value(s) conforming to shape {} but found {}", - min, shape.id.name, conforming + qualified_messages.push(( + format!( + "Expected at least {} value(s) conforming to shape {} but found {}", + min, shape.id.name, conforming + ), + sh_vocab::QUALIFIED_MIN_COUNT_CONSTRAINT_COMPONENT, )); } } if let Some(max) = max_count { if conforming > *max { - qualified_messages.push(format!( - "Expected at most {} value(s) conforming to shape {} but found {}", - max, shape.id.name, conforming + qualified_messages.push(( + format!( + "Expected at most {} value(s) conforming to shape {} but found {}", + max, shape.id.name, conforming + ), + sh_vocab::QUALIFIED_MAX_COUNT_CONSTRAINT_COMPONENT, )); } } - for message in qualified_messages { + for (message, component) in qualified_messages { results.push(ValidationResult { focus_node: focus_node.clone(), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: component, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(message), value: None, @@ -1529,6 +1557,7 @@ async fn validate_property_shape<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: violation.constraint.component(), severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, @@ -1546,6 +1575,7 @@ async fn validate_property_shape<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: violation.constraint.component(), severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, @@ -1634,6 +1664,7 @@ async fn validate_property_value_structural_constraint<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::OR_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -1672,6 +1703,7 @@ async fn validate_property_value_structural_constraint<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::AND_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -1717,6 +1749,7 @@ async fn validate_property_value_structural_constraint<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::XONE_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!("Value {value:?} does not conform to any shape in sh:xone") @@ -1730,6 +1763,7 @@ async fn validate_property_value_structural_constraint<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::XONE_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -1765,6 +1799,7 @@ async fn validate_property_value_structural_constraint<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::NODE_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -1801,6 +1836,7 @@ async fn validate_property_value_structural_constraint<'a>( result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::NOT_CONSTRAINT_COMPONENT, severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or_else(|| { format!( @@ -2495,6 +2531,9 @@ pub struct ValidationResult { pub source_shape: Sid, /// The constraint component that produced this result pub source_constraint: Option, + /// IRI of the SHACL constraint component that produced this result + /// (`sh:sourceConstraintComponent` in W3C validation reports) + pub constraint_component: &'static str, /// Severity level pub severity: Severity, /// Human-readable message diff --git a/fluree-vocab/src/lib.rs b/fluree-vocab/src/lib.rs index c355f6faf..d6bc3a80c 100644 --- a/fluree-vocab/src/lib.rs +++ b/fluree-vocab/src/lib.rs @@ -1117,6 +1117,118 @@ pub mod shacl { /// sh:resultMessage IRI pub const RESULT_MESSAGE: &str = "http://www.w3.org/ns/shacl#resultMessage"; + + // ======================================================================== + // Constraint Component IRIs (sh:sourceConstraintComponent values) + // ======================================================================== + + /// sh:MinCountConstraintComponent IRI + pub const MIN_COUNT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MinCountConstraintComponent"; + + /// sh:MaxCountConstraintComponent IRI + pub const MAX_COUNT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MaxCountConstraintComponent"; + + /// sh:DatatypeConstraintComponent IRI + pub const DATATYPE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#DatatypeConstraintComponent"; + + /// sh:NodeKindConstraintComponent IRI + pub const NODE_KIND_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#NodeKindConstraintComponent"; + + /// sh:ClassConstraintComponent IRI + pub const CLASS_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#ClassConstraintComponent"; + + /// sh:MinInclusiveConstraintComponent IRI + pub const MIN_INCLUSIVE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MinInclusiveConstraintComponent"; + + /// sh:MaxInclusiveConstraintComponent IRI + pub const MAX_INCLUSIVE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MaxInclusiveConstraintComponent"; + + /// sh:MinExclusiveConstraintComponent IRI + pub const MIN_EXCLUSIVE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MinExclusiveConstraintComponent"; + + /// sh:MaxExclusiveConstraintComponent IRI + pub const MAX_EXCLUSIVE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MaxExclusiveConstraintComponent"; + + /// sh:PatternConstraintComponent IRI + pub const PATTERN_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#PatternConstraintComponent"; + + /// sh:MinLengthConstraintComponent IRI + pub const MIN_LENGTH_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MinLengthConstraintComponent"; + + /// sh:MaxLengthConstraintComponent IRI + pub const MAX_LENGTH_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#MaxLengthConstraintComponent"; + + /// sh:HasValueConstraintComponent IRI + pub const HAS_VALUE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#HasValueConstraintComponent"; + + /// sh:InConstraintComponent IRI + pub const IN_CONSTRAINT_COMPONENT: &str = "http://www.w3.org/ns/shacl#InConstraintComponent"; + + /// sh:EqualsConstraintComponent IRI + pub const EQUALS_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#EqualsConstraintComponent"; + + /// sh:DisjointConstraintComponent IRI + pub const DISJOINT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#DisjointConstraintComponent"; + + /// sh:LessThanConstraintComponent IRI + pub const LESS_THAN_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#LessThanConstraintComponent"; + + /// sh:LessThanOrEqualsConstraintComponent IRI + pub const LESS_THAN_OR_EQUALS_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#LessThanOrEqualsConstraintComponent"; + + /// sh:UniqueLangConstraintComponent IRI + pub const UNIQUE_LANG_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#UniqueLangConstraintComponent"; + + /// sh:LanguageInConstraintComponent IRI + pub const LANGUAGE_IN_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#LanguageInConstraintComponent"; + + /// sh:QualifiedMinCountConstraintComponent IRI + pub const QUALIFIED_MIN_COUNT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#QualifiedMinCountConstraintComponent"; + + /// sh:QualifiedMaxCountConstraintComponent IRI + pub const QUALIFIED_MAX_COUNT_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#QualifiedMaxCountConstraintComponent"; + + /// sh:NodeConstraintComponent IRI + pub const NODE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#NodeConstraintComponent"; + + /// sh:NotConstraintComponent IRI + pub const NOT_CONSTRAINT_COMPONENT: &str = "http://www.w3.org/ns/shacl#NotConstraintComponent"; + + /// sh:AndConstraintComponent IRI + pub const AND_CONSTRAINT_COMPONENT: &str = "http://www.w3.org/ns/shacl#AndConstraintComponent"; + + /// sh:OrConstraintComponent IRI + pub const OR_CONSTRAINT_COMPONENT: &str = "http://www.w3.org/ns/shacl#OrConstraintComponent"; + + /// sh:XoneConstraintComponent IRI + pub const XONE_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#XoneConstraintComponent"; + + /// sh:ClosedConstraintComponent IRI + pub const CLOSED_CONSTRAINT_COMPONENT: &str = + "http://www.w3.org/ns/shacl#ClosedConstraintComponent"; } /// SHACL vocabulary local names (for SID construction) @@ -1420,6 +1532,11 @@ pub mod fluree { /// Fluree DB system namespace IRI (canonical base for all Fluree system vocabulary) pub const DB: &str = "https://ns.flur.ee/db#"; + /// Constraint component reported when a `sh:path` could not be compiled + /// (Fluree extension; W3C SHACL has no component for this condition) + pub const UNRESOLVABLE_PATH_CONSTRAINT_COMPONENT: &str = + "https://ns.flur.ee/db#UnresolvablePathConstraintComponent"; + /// Fluree URN prefix for ledger-scoped identifiers. /// /// Used as a namespace prefix so `encode_iri` can decompose ledger-scoped IRIs. From ded7af6d04d2b5c42b47bee542f5b44d931fa5a3 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 14:11:56 -0400 Subject: [PATCH 02/17] fix(json-ld): emit triples of embedded nodes that carry @id The graph-events adapter treated any nested object with @id as a pure reference, silently dropping its other properties. An embedded node object carrying more than a bare @id now recurses so its own triples are asserted (JSON-LD embedded-node semantics). Affected JSON-LD bulk import and inline shapes/ontology documents. --- fluree-graph-json-ld/src/adapter.rs | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/fluree-graph-json-ld/src/adapter.rs b/fluree-graph-json-ld/src/adapter.rs index 6670c418e..f6919b058 100644 --- a/fluree-graph-json-ld/src/adapter.rs +++ b/fluree-graph-json-ld/src/adapter.rs @@ -209,6 +209,14 @@ fn process_value(value: &Value, sink: &mut S) -> Result { // Check for @id (reference to another node) if let Some(id_val) = obj.get("@id") { + // An embedded node object carrying more than a bare `@id` + // asserts its own triples (JSON-LD embedded-node semantics); + // recurse so they aren't silently dropped. A `{"@id": ...}` + // singleton is a pure reference. + if obj.len() > 1 { + let subject_id = process_node(value, sink, None)?; + return Ok(ProcessedValue::Single(subject_id)); + } let id_str = id_val.as_str().ok_or_else(|| { AdapterError::InvalidStructure("@id must be a string".to_string()) })?; @@ -1122,4 +1130,33 @@ mod tests { } } } + + #[test] + fn embedded_node_with_id_asserts_its_own_triples() { + // {"@id": ..., } nested as a value is an embedded node, not a + // bare reference — its own triples must be emitted, not dropped. + let expanded = json!([{ + "@id": "http://example.org/shape", + "http://example.org/ns#property": [{ + "@id": "http://example.org/ps1", + "http://example.org/ns#minCount": [{"@value": 1}] + }] + }]); + + let mut sink = GraphCollectorSink::new(); + to_graph_events(&expanded, &mut sink).unwrap(); + + let graph = sink.graph(); + // shape →property→ ps1, plus ps1 →minCount→ 1 + assert_eq!(graph.len(), 2); + + let mut sink = GraphCollectorSink::new(); + // A bare {"@id": ...} singleton stays a pure reference. + let expanded = json!([{ + "@id": "http://example.org/shape", + "http://example.org/ns#property": [{"@id": "http://example.org/ps1"}] + }]); + to_graph_events(&expanded, &mut sink).unwrap(); + assert_eq!(sink.graph().len(), 1); + } } From b03f5c8c914f6e30740fbd4c34a73e95c5119b1a Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 14:12:54 -0400 Subject: [PATCH 03/17] =?UTF-8?q?feat(api):=20SHACL=20validation-report=20?= =?UTF-8?q?core=20(fluree=20validate=20fa=C3=A7ade)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds fluree_db_api::validate — the shared core behind the upcoming 'fluree validate' CLI command and HTTP endpoint: - ValidateOptions { graph, shapes, include_attached } with ShapesSource = Attached | Graph(iri) | InlineJsonLd | InlineTurtle. Ad-hoc shapes REPLACE attached shapes by default (include_attached unions them) and ride the non-persisting inline-shapes bundle; replace-mode compiles the bundle against an empty genesis snapshot so the ledger's own graph-0 shapes never leak into the scan. - Validation runs over the query-visible view (snapshot + novelty); graph IRIs resolve through snapshot.graph_registry. - ValidateReport with IRI-resolved results (component IRIs, severity, path when a single predicate) and W3C sh:ValidationReport JSON-LD serialization; deterministic result ordering. - Turtle counterpart for the inline-shapes bundle parser. - Five integration tests: attached-shapes report over a non-conforming ledger, conforming state, inline-Turtle replace vs include_attached, inline JSON-LD, unknown-graph NotFound. --- fluree-db-api/src/inline_shapes.rs | 22 ++ fluree-db-api/src/lib.rs | 2 + fluree-db-api/src/shacl_tests.rs | 284 ++++++++++++++++++++ fluree-db-api/src/tx.rs | 2 +- fluree-db-api/src/validate.rs | 411 +++++++++++++++++++++++++++++ 5 files changed, 720 insertions(+), 1 deletion(-) create mode 100644 fluree-db-api/src/validate.rs diff --git a/fluree-db-api/src/inline_shapes.rs b/fluree-db-api/src/inline_shapes.rs index fc1e7bc61..b5fa0036e 100644 --- a/fluree-db-api/src/inline_shapes.rs +++ b/fluree-db-api/src/inline_shapes.rs @@ -57,6 +57,28 @@ pub(crate) fn parse_inline_shapes_to_bundle( TransactError::Parse(format!("inline shapes JSON-LD event conversion error: {e}")) })?; + bundle_from_sink(sink) +} + +/// Parse a Turtle inline shapes document into a `SchemaBundleFlakes`. +/// +/// Turtle counterpart of [`parse_inline_shapes_to_bundle`]; `@prefix` +/// declarations register through the sink's `on_prefix` events, so no +/// separate prefix pre-registration pass is needed. +pub(crate) fn parse_inline_shapes_turtle_to_bundle( + turtle: &str, + staged_ns: &mut NamespaceRegistry, + t: i64, + ledger_id: &str, +) -> Result>, TransactError> { + let txn_id = format!("inline-shapes-{ledger_id}-{t}"); + let mut sink = FlakeSink::new(staged_ns, t, txn_id); + fluree_graph_turtle::parse(turtle, &mut sink) + .map_err(|e| TransactError::Parse(format!("inline shapes Turtle parse error: {e}")))?; + bundle_from_sink(sink) +} + +fn bundle_from_sink(sink: FlakeSink<'_>) -> Result>, TransactError> { let flakes: Vec = sink.finish()?; if flakes.is_empty() { return Ok(None); diff --git a/fluree-db-api/src/lib.rs b/fluree-db-api/src/lib.rs index 9cd1cb274..c192832e7 100644 --- a/fluree-db-api/src/lib.rs +++ b/fluree-db-api/src/lib.rs @@ -83,6 +83,8 @@ pub mod server_defaults; mod time_resolve; pub mod tx; pub mod tx_builder; +#[cfg(feature = "shacl")] +pub mod validate; #[cfg(feature = "vector")] pub mod vector_worker; pub mod view; diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index e2c3d0720..d641a9101 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -3849,3 +3849,287 @@ async fn shacl_inverse_of_sequence_path() { .unwrap_err(); assert_shacl_violation(err, "at least 1"); } + +// ============================================================ +// Validation reports (fluree validate core — crate::validate) +// ============================================================ + +#[tokio::test] +async fn validate_report_attached_shapes() { + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-report:main") + .await + .unwrap(); + // Data first (no shapes yet, so staging enforcement doesn't run), then + // the shape — leaving the ledger in a non-conforming state that only a + // full-state validation pass can surface. + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:nameless", + "@type": "ex:User", + "schema:email": "nameless@example.org" + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:UserShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:vr-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + + let view = crate::ledger_view::LedgerView::from_state(&ledger); + let options = crate::validate::ValidateOptions::default(); + let report = crate::validate::validate_view(&view, "shacl/validate-report:main", &options) + .await + .unwrap(); + + assert!(!report.conforms); + assert_eq!(report.violation_count(), 1); + assert!(report.shape_count >= 1); + let result = &report.results[0]; + assert_eq!(result.focus_node, "http://example.org/ns/nameless"); + assert_eq!( + result.result_path.as_deref(), + Some("http://schema.org/name") + ); + assert_eq!( + result.constraint_component, + "http://www.w3.org/ns/shacl#MinCountConstraintComponent" + ); + assert_eq!(result.severity, "http://www.w3.org/ns/shacl#Violation"); + + let doc = report.to_jsonld(); + assert_eq!(doc["sh:conforms"], json!(false)); + let results = doc["sh:result"].as_array().unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + results[0]["sh:sourceConstraintComponent"]["@id"], + json!("sh:MinCountConstraintComponent") + ); + assert_eq!( + results[0]["sh:focusNode"]["@id"], + json!("http://example.org/ns/nameless") + ); +} + +#[tokio::test] +async fn validate_report_conforming_state() { + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-conforms:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:UserShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:vc-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:alice", + "@type": "ex:User", + "schema:name": "Alice" + }), + ) + .await + .unwrap() + .ledger; + + let view = crate::ledger_view::LedgerView::from_state(&ledger); + let options = crate::validate::ValidateOptions::default(); + let report = crate::validate::validate_view(&view, "shacl/validate-conforms:main", &options) + .await + .unwrap(); + assert!(report.conforms); + assert!(report.results.is_empty()); + assert!(report.shape_count >= 1); +} + +#[tokio::test] +async fn validate_report_inline_turtle_replaces_attached() { + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-inline:main") + .await + .unwrap(); + // Non-conforming against the ATTACHED shape (missing schema:name), + // conforming against the inline shape (has schema:email). + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:bob", + "@type": "ex:User", + "schema:email": "bob@example.org" + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:NameShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:vi-name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await + .unwrap() + .ledger; + let view = crate::ledger_view::LedgerView::from_state(&ledger); + + let email_shapes_turtle = r#" + @prefix sh: . + @prefix schema: . + @prefix ex: . + ex:EmailShape a sh:NodeShape ; + sh:targetClass ex:User ; + sh:property [ sh:path schema:email ; sh:minCount 1 ] . + "#; + + // Replace (default): only the inline shape runs — bob conforms. + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineTurtle(email_shapes_turtle.to_string()), + ..Default::default() + }; + let report = crate::validate::validate_view(&view, "shacl/validate-inline:main", &options) + .await + .unwrap(); + assert!( + report.conforms, + "inline shapes must REPLACE attached shapes by default: {:?}", + report.results + ); + + // include_attached: union — the attached name shape now fires. + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineTurtle(email_shapes_turtle.to_string()), + include_attached: true, + ..Default::default() + }; + let report = crate::validate::validate_view(&view, "shacl/validate-inline:main", &options) + .await + .unwrap(); + assert!(!report.conforms); + assert_eq!(report.violation_count(), 1); + assert_eq!( + report.results[0].result_path.as_deref(), + Some("http://schema.org/name") + ); +} + +#[tokio::test] +async fn validate_report_inline_jsonld_shapes() { + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-inline-jsonld:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:carol", + "@type": "ex:User", + "schema:name": "Carol" + }), + ) + .await + .unwrap() + .ledger; + let view = crate::ledger_view::LedgerView::from_state(&ledger); + + let shapes_doc = json!({ + "@context": context.clone(), + "@id": "ex:EmailShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:vj-email-ps", + "sh:path": {"@id": "schema:email"}, + "sh:minCount": 1 + }] + }); + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineJsonLd(shapes_doc), + ..Default::default() + }; + let report = + crate::validate::validate_view(&view, "shacl/validate-inline-jsonld:main", &options) + .await + .unwrap(); + assert!(!report.conforms); + assert_eq!(report.violation_count(), 1); + assert_eq!(report.results[0].focus_node, "http://example.org/ns/carol"); + assert_eq!( + report.results[0].constraint_component, + "http://www.w3.org/ns/shacl#MinCountConstraintComponent" + ); +} + +#[tokio::test] +async fn validate_report_unknown_graph_is_not_found() { + let fluree = FlureeBuilder::memory().build_memory(); + let ledger = fluree + .create_ledger("shacl/validate-nograph:main") + .await + .unwrap(); + let view = crate::ledger_view::LedgerView::from_state(&ledger); + let options = crate::validate::ValidateOptions { + graph: Some("http://example.org/graphs/missing".to_string()), + ..Default::default() + }; + let err = crate::validate::validate_view(&view, "shacl/validate-nograph:main", &options) + .await + .unwrap_err(); + assert!(matches!(err, ApiError::NotFound(_)), "got {err:?}"); +} diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index 14afb6f1e..ae7dd4e16 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -366,7 +366,7 @@ async fn resolve_cross_ledger_shapes_for_tx( /// same mechanism — schema, policy, and SHACL shapes can live in any graph /// the ledger knows about, including the config graph itself. #[cfg(feature = "shacl")] -fn resolve_shapes_source_g_ids( +pub(crate) fn resolve_shapes_source_g_ids( config: Option<&LedgerConfig>, snapshot: &fluree_db_core::LedgerSnapshot, ) -> std::result::Result, fluree_db_transact::TransactError> { diff --git a/fluree-db-api/src/validate.rs b/fluree-db-api/src/validate.rs new file mode 100644 index 000000000..618bcc961 --- /dev/null +++ b/fluree-db-api/src/validate.rs @@ -0,0 +1,411 @@ +//! SHACL validation reports over a ledger view. +//! +//! The shared core behind `fluree validate` (CLI) and the HTTP validate +//! endpoint: validate the *current state* of a ledger (or one of its named +//! graphs) against SHACL shapes and produce a W3C-shaped validation report, +//! instead of rejecting a transaction the way staging-time enforcement does. +//! +//! Shapes can come from four places (see [`ShapesSource`]): the ledger's own +//! attached shapes (honoring `f:shapesSource`), a same-ledger named graph, or +//! an ad-hoc JSON-LD / Turtle document. Ad-hoc documents ride the same +//! non-persisting inline-shapes overlay used by per-transaction `opts.shapes` +//! — nothing is written to the ledger. By default an ad-hoc source *replaces* +//! the attached shapes ("does this data conform to THESE rules?"); set +//! [`ValidateOptions::include_attached`] to union them instead. +//! +//! Validation always runs over the query-visible view — indexed snapshot plus +//! novelty overlay — so recently committed data is never silently skipped. + +use crate::error::{ApiError, Result}; +use crate::ledger_view::LedgerView; +use fluree_db_core::{FlakeValue, GraphDbRef, GraphId, LedgerSnapshot, NoOverlay, Sid}; +use fluree_db_shacl::compile::ShapeCompiler; +use fluree_db_shacl::{Severity, ShaclCache, ShaclCacheKey, ShaclEngine}; +use fluree_db_transact::namespace::NamespaceRegistry; +use fluree_db_transact::TransactError; +use fluree_vocab::config_iris; +use fluree_vocab::shacl as sh_vocab; +use serde_json::{json, Value as JsonValue}; + +/// Where the shapes used for validation come from. +#[derive(Debug, Clone)] +pub enum ShapesSource { + /// The ledger's own attached shapes: the default graph, or the graph(s) + /// configured via `f:shapesSource`. Mirrors what transactions enforce. + Attached, + /// A named graph of the ledger being validated, addressed by IRI. + Graph(String), + /// An ad-hoc JSON-LD shapes document (non-persisting overlay). + InlineJsonLd(JsonValue), + /// An ad-hoc Turtle shapes document (non-persisting overlay). + InlineTurtle(String), +} + +/// Options for [`validate_view`] / [`crate::Fluree::validate_ledger`]. +#[derive(Debug, Clone)] +pub struct ValidateOptions { + /// IRI of the data graph to validate. `None` = the default graph. + pub graph: Option, + /// Where the shapes come from. + pub shapes: ShapesSource, + /// When the shapes source is not [`ShapesSource::Attached`], also union + /// in the ledger's attached shapes instead of replacing them. + pub include_attached: bool, +} + +impl Default for ValidateOptions { + fn default() -> Self { + Self { + graph: None, + shapes: ShapesSource::Attached, + include_attached: false, + } + } +} + +/// One validation result with all identifiers resolved to IRIs. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ReportResult { + /// The node that failed validation (`sh:focusNode`). + pub focus_node: String, + /// 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, +} + +/// A resolved validation report, ready for serialization. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ValidateReport { + /// `true` when no result carries `sh:Violation` severity. + pub conforms: bool, + /// Individual results, sorted by (focus node, component, message). + pub results: Vec, + /// Number of compiled shapes that were checked. `0` means the shapes + /// source produced no shapes — the report is vacuously conforming. + pub shape_count: usize, +} + +impl ValidateReport { + /// Count results with `sh:Violation` severity. + pub fn violation_count(&self) -> usize { + self.count_severity(sh_vocab::VIOLATION) + } + + /// Count results with `sh:Warning` severity. + pub fn warning_count(&self) -> usize { + self.count_severity(sh_vocab::WARNING) + } + + /// Count results with `sh:Info` severity. + pub fn info_count(&self) -> usize { + self.count_severity(sh_vocab::INFO) + } + + fn count_severity(&self, severity: &str) -> usize { + self.results + .iter() + .filter(|r| r.severity == severity) + .count() + } + + /// Serialize as a W3C `sh:ValidationReport` JSON-LD document. + pub fn to_jsonld(&self) -> JsonValue { + let results: Vec = self + .results + .iter() + .map(|r| { + let mut obj = serde_json::Map::new(); + obj.insert("@type".into(), json!("sh:ValidationResult")); + obj.insert("sh:focusNode".into(), json!({"@id": r.focus_node})); + if let Some(path) = &r.result_path { + obj.insert("sh:resultPath".into(), json!({"@id": path})); + } + obj.insert( + "sh:resultSeverity".into(), + json!({"@id": compact_sh(&r.severity)}), + ); + // W3C sh:sourceShape is the shape that declares the failed + // constraint — the property shape when there is one. + let source_shape = r.source_constraint.as_ref().unwrap_or(&r.source_shape); + obj.insert("sh:sourceShape".into(), json!({"@id": source_shape})); + obj.insert( + "sh:sourceConstraintComponent".into(), + json!({"@id": compact_sh(&r.constraint_component)}), + ); + obj.insert("sh:resultMessage".into(), json!(r.message)); + if let Some(value) = &r.value { + obj.insert("sh:value".into(), value.clone()); + } + JsonValue::Object(obj) + }) + .collect(); + + json!({ + "@context": {"sh": "http://www.w3.org/ns/shacl#"}, + "@type": "sh:ValidationReport", + "sh:conforms": self.conforms, + "sh:result": results, + }) + } +} + +/// Compact an IRI in the SHACL namespace to its `sh:` form for readability. +fn compact_sh(iri: &str) -> String { + match iri.strip_prefix("http://www.w3.org/ns/shacl#") { + Some(local) => format!("sh:{local}"), + None => iri.to_string(), + } +} + +impl crate::Fluree { + /// Validate the current state of a ledger against SHACL shapes and + /// return a resolved validation report. See [`ValidateOptions`]. + pub async fn validate_ledger( + &self, + ledger_id: &str, + options: &ValidateOptions, + ) -> Result { + let handle = self.ledger_cached(ledger_id).await?; + let view = handle.snapshot().await; + validate_view(&view, ledger_id, options).await + } +} + +/// Validate a ledger view against SHACL shapes. +/// +/// Runs over the query-visible composition (snapshot + novelty overlay) of +/// `view`, scoped to the data graph selected by `options.graph`. +pub async fn validate_view( + view: &LedgerView, + ledger_id: &str, + options: &ValidateOptions, +) -> Result { + let snapshot = view.snapshot.as_ref(); + let novelty = view.novelty.as_ref(); + let to_t = view.t; + + let data_g_id = match options.graph.as_deref() { + None => 0u16, + Some(iri) => resolve_graph_iri(snapshot, iri)?, + }; + + // Holders declared before `shape_dbs` so the borrows they hand out + // outlive the GraphDbRefs (drop order: shape_dbs first). + let mut inline_registry: Option = None; + #[allow(unused_assignments)] + let mut inline_snapshot: Option = None; + #[allow(unused_assignments)] + let mut inline_overlay = None; + + let mut shape_dbs: Vec> = Vec::new(); + let mut membership: Vec = Vec::new(); + + let use_attached = matches!(options.shapes, ShapesSource::Attached) || options.include_attached; + if use_attached { + // Mirror the transaction path: a broken config graph read degrades to + // defaults (default-graph shapes) rather than failing validation. + let config = match crate::config_resolver::resolve_ledger_config(snapshot, novelty, to_t) + .await + { + Ok(config) => config, + Err(e) => { + tracing::debug!(error = %e, "Config graph read failed during validate — using defaults"); + None + } + }; + let shapes_g_ids = crate::tx::resolve_shapes_source_g_ids(config.as_ref(), snapshot)?; + for g_id in &shapes_g_ids { + shape_dbs.push(GraphDbRef::new(snapshot, *g_id, novelty, to_t)); + } + membership.extend(shapes_g_ids); + } + + match &options.shapes { + ShapesSource::Attached => {} + ShapesSource::Graph(iri) => { + let g_id = resolve_graph_iri(snapshot, iri)?; + if !membership.contains(&g_id) { + shape_dbs.push(GraphDbRef::new(snapshot, g_id, novelty, to_t)); + membership.push(g_id); + } + } + ShapesSource::InlineJsonLd(_) | ShapesSource::InlineTurtle(_) => { + let mut registry = NamespaceRegistry::from_db(snapshot); + let bundle = match &options.shapes { + ShapesSource::InlineJsonLd(doc) => { + crate::inline_shapes::parse_inline_shapes_to_bundle( + doc, + &mut registry, + to_t, + ledger_id, + )? + } + ShapesSource::InlineTurtle(turtle) => { + crate::inline_shapes::parse_inline_shapes_turtle_to_bundle( + turtle, + &mut registry, + to_t, + ledger_id, + )? + } + _ => unreachable!("outer match arm covers only inline sources"), + } + .ok_or_else(empty_shapes_doc)?; + inline_registry = Some(registry); + // Compile from the bundle alone: an empty genesis snapshot with a + // no-op base overlay keeps the ledger's own graph-0 shapes + // (indexed or in novelty) out of the compile scan. When + // `include_attached` is set, the attached shape dbs pushed above + // contribute the union — never a double scan of graph 0. + static NO_OVERLAY: NoOverlay = NoOverlay; + inline_snapshot = Some(LedgerSnapshot::genesis(ledger_id)); + inline_overlay = Some(fluree_db_query::schema_bundle::SchemaBundleOverlay::new( + &NO_OVERLAY, + bundle, + )); + shape_dbs.push(GraphDbRef::new( + inline_snapshot.as_ref().expect("just set above"), + 0u16, + inline_overlay.as_ref().expect("just set above"), + to_t, + )); + } + } + + let shapes = ShapeCompiler::compile_from_dbs(&shape_dbs) + .await + .map_err(TransactError::from)?; + // Hierarchy comes from the real snapshot even when shapes compile from a + // detached inline bundle — subclass expansion must reflect the data. + let hierarchy = snapshot.schema_hierarchy(); + let cache = ShaclCache::new( + ShaclCacheKey::new(ledger_id, to_t as u64), + shapes, + hierarchy.as_ref(), + ); + let engine = match hierarchy { + Some(h) => ShaclEngine::new_with_hierarchy(cache, h), + None => ShaclEngine::new(cache), + } + .with_membership_graphs(membership); + + let shape_count = engine.shape_count(); + if engine.is_empty() { + return Ok(ValidateReport { + conforms: true, + results: Vec::new(), + shape_count, + }); + } + + let data_db = GraphDbRef::new(snapshot, data_g_id, novelty, to_t); + let raw = engine + .validate_all(data_db) + .await + .map_err(TransactError::from)?; + + let resolve = |sid: &Sid| resolve_sid(snapshot, inline_registry.as_ref(), sid); + let mut results: Vec = raw + .results + .iter() + .map(|r| ReportResult { + focus_node: resolve(&r.focus_node), + result_path: r.result_path.as_ref().map(&resolve), + source_shape: resolve(&r.source_shape), + source_constraint: r.source_constraint.as_ref().map(&resolve), + constraint_component: r.constraint_component.to_string(), + severity: severity_iri(r.severity).to_string(), + message: r.message.clone(), + value: r.value.as_ref().map(|v| value_json(v, &resolve)), + }) + .collect(); + results.sort_by(|a, b| { + (&a.focus_node, &a.constraint_component, &a.message).cmp(&( + &b.focus_node, + &b.constraint_component, + &b.message, + )) + }); + + Ok(ValidateReport { + conforms: raw.conforms, + results, + shape_count, + }) +} + +fn empty_shapes_doc() -> ApiError { + ApiError::Transact(TransactError::Parse( + "shapes document contains no triples".into(), + )) +} + +fn resolve_graph_iri(snapshot: &LedgerSnapshot, iri: &str) -> Result { + if iri == config_iris::DEFAULT_GRAPH { + return Ok(0); + } + snapshot + .graph_registry + .graph_id_for_iri(iri) + .ok_or_else(|| { + ApiError::NotFound(format!( + "graph '{iri}' not found in this ledger's graph registry" + )) + }) +} + +/// Resolve a SID to an IRI: the snapshot's namespace table first, then the +/// inline-shapes registry (ad-hoc shape vocabulary gets ephemeral codes the +/// snapshot has never seen), then the raw name as a last resort. +fn resolve_sid( + snapshot: &LedgerSnapshot, + inline_registry: Option<&NamespaceRegistry>, + sid: &Sid, +) -> String { + snapshot + .decode_sid(sid) + .or_else(|| { + inline_registry.and_then(|registry| { + registry + .get_prefix(sid.namespace_code) + .map(|prefix| format!("{prefix}{}", sid.name)) + }) + }) + .unwrap_or_else(|| sid.name.to_string()) +} + +fn severity_iri(severity: Severity) -> &'static str { + match severity { + Severity::Violation => sh_vocab::VIOLATION, + Severity::Warning => sh_vocab::WARNING, + Severity::Info => sh_vocab::INFO, + } +} + +fn value_json(value: &FlakeValue, resolve: &impl Fn(&Sid) -> String) -> JsonValue { + match value { + FlakeValue::Ref(sid) => json!({"@id": resolve(sid)}), + FlakeValue::Boolean(b) => json!(b), + FlakeValue::Long(n) => json!(n), + FlakeValue::Double(d) => json!(d), + FlakeValue::String(s) => json!(s), + FlakeValue::Json(s) => serde_json::from_str(s).unwrap_or_else(|_| json!(s)), + FlakeValue::Null => JsonValue::Null, + other => json!(other.to_string()), + } +} From 65669f3f400494ad8924296e660f0da0431c2959 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 14:51:16 -0400 Subject: [PATCH 04/17] =?UTF-8?q?feat(cli):=20fluree=20validate=20?= =?UTF-8?q?=E2=80=94=20SHACL=20validation=20reports=20for=20ledgers=20and?= =?UTF-8?q?=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ledger mode validates a local ledger's current state (attached shapes, --shacl ad-hoc shapes with replace-by-default semantics and --include-attached union, or --shacl-graph ); file mode loads an RDF file into an ephemeral in-memory ledger with staging-time SHACL disabled via the config graph, so files that embed their own shapes report violations instead of failing to load. Output formats: table (default), jsonld, turtle; exit codes 0 = conforms, 1 = findings at or above --fail-on (violation|warning|info), 2 = usage error. Adds ValidateReport::to_turtle() (W3C report as Turtle, with blank-node label sanitization for skolemized ids), CLI integration tests, docs/cli/validate.md, and cookbook cross-references. --- docs/SUMMARY.md | 1 + docs/cli/README.md | 1 + docs/cli/validate.md | 106 ++++++++++ docs/guides/cookbook-shacl.md | 33 +++- fluree-db-api/src/validate.rs | 155 +++++++++++++++ fluree-db-cli/src/cli.rs | 46 +++++ fluree-db-cli/src/commands/mod.rs | 2 + fluree-db-cli/src/commands/validate.rs | 256 +++++++++++++++++++++++++ fluree-db-cli/src/lib.rs | 23 +++ fluree-db-cli/tests/integration.rs | 86 +++++++++ 10 files changed, 706 insertions(+), 3 deletions(-) create mode 100644 docs/cli/validate.md create mode 100644 fluree-db-cli/src/commands/validate.rs diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 32fef87ca..508e419a6 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -16,6 +16,7 @@ - [upsert](cli/upsert.md) - [update](cli/update.md) - [query](cli/query.md) + - [validate](cli/validate.md) - [multi-query](cli/multi-query.md) - [history](cli/history.md) - [export](cli/export.md) diff --git a/docs/cli/README.md b/docs/cli/README.md index 4f6b7adb3..8e9e8ed3c 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -59,6 +59,7 @@ fluree query 'SELECT ?name WHERE { ?s ?name }' | [`upsert`](upsert.md) | Upsert data (insert or update existing) | | [`update`](update.md) | Update with WHERE/DELETE/INSERT patterns | | [`query`](query.md) | Query a ledger | +| [`validate`](validate.md) | Validate data against SHACL shapes (report) | | [`history`](history.md) | Show change history for an entity | | [`export`](export.md) | Export ledger data | | [`log`](log.md) | Show commit log | diff --git a/docs/cli/validate.md b/docs/cli/validate.md new file mode 100644 index 000000000..090a17a87 --- /dev/null +++ b/docs/cli/validate.md @@ -0,0 +1,106 @@ +# fluree validate + +Validate data against SHACL shapes and print a **validation report** — +instead of rejecting a transaction the way staging-time enforcement does, +`validate` inspects existing state (or a standalone file) and reports every +result it finds. + +```bash +fluree validate [ | ] [options] +``` + +Requires the `shacl` build feature (enabled by default). + +## Two modes + +**Ledger mode** validates the current state of a local ledger — indexed data +plus any commits not yet indexed — against its attached shapes (or ad-hoc +shapes you supply): + +```bash +fluree validate mydb # attached shapes +fluree validate mydb --shacl proposed.ttl # trial new shapes (replaces attached) +fluree validate mydb --shacl-graph http://example.org/graphs/shapes +``` + +**File mode** validates an RDF file with no ledger at all: the data loads +into an ephemeral in-memory ledger, the report prints, and nothing persists. +This is the recommended pre-flight for [bulk import](import.md), which +deliberately never runs SHACL: + +```bash +fluree validate data.ttl --shacl shapes.ttl +fluree validate dataset.jsonld --shacl shapes.jsonld --format jsonld +fluree validate data-with-embedded-shapes.ttl +``` + +A file that embeds its own shapes validates against them (staging-time +enforcement is disabled during the ephemeral load, so violating data can't +be rejected before the report is produced). + +## Options + +| Option | Description | +|--------|-------------| +| `--graph ` | Validate a named data graph instead of the default graph | +| `--shacl ` | Shapes file (Turtle or JSON-LD). **Replaces** the ledger's attached shapes by default | +| `--shacl-graph ` | Named graph in the target ledger holding the shapes (conflicts with `--shacl`) | +| `--include-attached` | Union ad-hoc shapes with the attached shapes instead of replacing them | +| `--format ` | `table` (default, human), `jsonld`, or `turtle` (W3C `sh:ValidationReport`) | +| `--fail-on ` | Exit non-zero when results at or above this severity exist: `violation` (default), `warning`, or `info` | + +## Replace vs union semantics + +`--shacl` answers "does this data conform to *these* rules?" — the ledger's +attached shapes are not evaluated. This makes trialing a stricter shape +predictable before you transact it. Pass `--include-attached` to evaluate +both sets together. + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Report conforms under the `--fail-on` threshold | +| 1 | Validation results at or above the threshold exist (or an operational error) | +| 2 | Usage error (bad flags/arguments) | + +CI example: + +```bash +fluree validate export.ttl --shacl contracts.ttl --fail-on warning \ + || { echo "source data failed SHACL pre-flight"; exit 1; } +``` + +## Output + +`table` prints one block per result plus a summary line: + +``` +Violation: http://example.org/ns/bob + path: http://schema.org/name + component: MinCountConstraintComponent + message: Expected at least 1 value(s) but found 0 + +Conforms: false — 1 violation(s), 0 warning(s), 0 info (1 shape(s) checked) +``` + +`jsonld` and `turtle` emit a W3C-shaped `sh:ValidationReport` with +`sh:focusNode`, `sh:resultPath` (single-predicate paths only — complex paths +are omitted rather than misrepresented), `sh:resultSeverity`, +`sh:sourceShape`, `sh:sourceConstraintComponent`, `sh:resultMessage`, and +`sh:value`. + +If the shapes source produces no shapes, the report is vacuously conforming +and a warning is printed to stderr. + +## Current limitations + +- Local ledgers only; validating a remote (tracked) ledger over HTTP is not + yet supported. +- Shapes from a *different* ledger must be exported to a file first. + +## Related + +- [Cookbook: SHACL validation](../guides/cookbook-shacl.md) — shape authoring + and transaction-time enforcement +- [import](import.md) — bulk import (SHACL-exempt by design; validate first) diff --git a/docs/guides/cookbook-shacl.md b/docs/guides/cookbook-shacl.md index 1bb94d0f8..fa297490e 100644 --- a/docs/guides/cookbook-shacl.md +++ b/docs/guides/cookbook-shacl.md @@ -12,6 +12,7 @@ This guide covers: - [Predicate-target shapes](#predicate-target-shapes) — `sh:targetSubjectsOf` / `sh:targetObjectsOf` - [Per-graph enable/disable and warn vs reject](#per-graph-configuration) modes - [Storing shapes in a named graph](#storing-shapes-in-a-named-graph) with `f:shapesSource` +- [Validation reports](#validation-reports-fluree-validate) — `fluree validate` over a ledger or file - [What isn't enforced yet](#not-yet-supported) ## When SHACL runs @@ -25,9 +26,10 @@ This means you can start using SHACL **without writing any config** — just tra **Bulk import is deliberately exempt.** The bulk-import pipeline never runs SHACL — it is a trusted, high-throughput load path. If your source data must -conform, validate it *before* importing (e.g. run a SHACL report over the -source with your shapes) so the ledger starts clean; transaction-time -validation keeps it clean from there. +conform, validate it *before* importing — `fluree validate source.ttl +--shacl shapes.ttl` produces a full report (see +[Validation reports](#validation-reports-fluree-validate)) — so the ledger +starts clean; transaction-time validation keeps it clean from there. The `shacl` feature must be enabled at build time (it's on by default for the server and CLI binaries). See [Standards and feature flags](../reference/compatibility.md). @@ -593,6 +595,31 @@ SHACL validation runs consistently on every write surface: All three routes go through the same post-stage helper, so the ledger's configured SHACL posture (enable/disable, mode, per-graph, shapes source) applies uniformly. +## Validation reports (`fluree validate`) + +Transaction-time enforcement rejects (or warns about) *new* writes. +`fluree validate` answers the complementary questions: *is my existing data +clean?* and *is this source file clean before I import it?* It produces a +W3C-shaped `sh:ValidationReport` instead of an error: + +```bash +fluree validate mydb # ledger vs its attached shapes +fluree validate mydb --shacl proposed.ttl # trial stricter shapes (replaces attached) +fluree validate data.ttl --shacl shapes.ttl # standalone file, ephemeral in-memory ledger +fluree validate data.ttl --format turtle # W3C report as Turtle (also: jsonld) +``` + +Ad-hoc shapes (`--shacl`) **replace** the attached shapes by default so +"does this data conform to these rules?" is answered exactly; +`--include-attached` unions both sets. Exit codes make it CI-friendly: +0 = conforms, 1 = findings at/above `--fail-on` (default `violation`). +See the [`fluree validate` reference](../cli/validate.md). + +The same core is exposed in Rust as `fluree_db_api::validate` — +`Fluree::validate_ledger(alias, &ValidateOptions)` returns a `ValidateReport` +with per-result constraint-component IRIs, severities, and messages, plus +`to_jsonld()` / `to_turtle()` serializers. + ## Not yet supported - `sh:targetNode` with a literal value — only IRI/blank-node targets are compiled. diff --git a/fluree-db-api/src/validate.rs b/fluree-db-api/src/validate.rs index 618bcc961..5519cd4df 100644 --- a/fluree-db-api/src/validate.rs +++ b/fluree-db-api/src/validate.rs @@ -162,6 +162,112 @@ impl ValidateReport { "sh:result": results, }) } + + /// Serialize as a W3C `sh:ValidationReport` Turtle document. + pub fn to_turtle(&self) -> String { + let mut out = String::from("@prefix sh: .\n\n"); + out.push_str("[] a sh:ValidationReport ;\n"); + out.push_str(&format!(" sh:conforms {}", self.conforms)); + for r in &self.results { + out.push_str(" ;\n sh:result [\n a sh:ValidationResult ;\n"); + out.push_str(&format!( + " sh:focusNode {} ;\n", + turtle_term(&r.focus_node) + )); + if let Some(path) = &r.result_path { + out.push_str(&format!(" sh:resultPath {} ;\n", turtle_term(path))); + } + out.push_str(&format!( + " sh:resultSeverity {} ;\n", + turtle_sh_term(&r.severity) + )); + let source_shape = r.source_constraint.as_ref().unwrap_or(&r.source_shape); + out.push_str(&format!( + " sh:sourceShape {} ;\n", + turtle_term(source_shape) + )); + out.push_str(&format!( + " sh:sourceConstraintComponent {} ;\n", + turtle_sh_term(&r.constraint_component) + )); + if let Some(value) = &r.value { + out.push_str(&format!( + " sh:value {} ;\n", + turtle_value_term(value) + )); + } + out.push_str(&format!( + " sh:resultMessage {}\n ]", + turtle_string(&r.message) + )); + } + out.push_str(" .\n"); + out + } +} + +/// Render an IRI or blank-node label as a Turtle term. +/// +/// Skolemized blank-node labels may carry characters that are invalid in a +/// Turtle BLANK_NODE_LABEL (e.g. `/` and `:` from embedded ledger ids) — +/// sanitize them so the emitted document always parses. +fn turtle_term(iri_or_bnode: &str) -> String { + match iri_or_bnode.strip_prefix("_:") { + Some(label) => { + let clean: String = label + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + format!("_:{clean}") + } + None => format!("<{iri_or_bnode}>"), + } +} + +/// Render an IRI as `sh:Name` when it lives in the SHACL namespace. +fn turtle_sh_term(iri: &str) -> String { + match iri.strip_prefix("http://www.w3.org/ns/shacl#") { + Some(local) => format!("sh:{local}"), + None => turtle_term(iri), + } +} + +/// Render a report value (as produced by `value_json`) as a Turtle term. +fn turtle_value_term(value: &JsonValue) -> String { + match value { + JsonValue::Object(obj) => match obj.get("@id").and_then(|v| v.as_str()) { + Some(iri) => turtle_term(iri), + None => turtle_string(&value.to_string()), + }, + JsonValue::Bool(b) => b.to_string(), + JsonValue::Number(n) => n.to_string(), + JsonValue::String(s) => turtle_string(s), + other => turtle_string(&other.to_string()), + } +} + +/// Quote and escape a Turtle string literal. +fn turtle_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + out } /// Compact an IRI in the SHACL namespace to its `sh:` form for readability. @@ -409,3 +515,52 @@ fn value_json(value: &FlakeValue, resolve: &impl Fn(&Sid) -> String) -> JsonValu other => json!(other.to_string()), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_report() -> ValidateReport { + ValidateReport { + conforms: false, + results: vec![ReportResult { + focus_node: "http://example.org/ns/nameless".into(), + result_path: Some("http://schema.org/name".into()), + source_shape: "http://example.org/ns/UserShape".into(), + source_constraint: Some("http://example.org/ns/name-ps".into()), + constraint_component: "http://www.w3.org/ns/shacl#MinCountConstraintComponent" + .into(), + severity: "http://www.w3.org/ns/shacl#Violation".into(), + message: "Expected at least 1 value(s) but found 0".into(), + value: None, + }], + shape_count: 1, + } + } + + #[test] + fn turtle_report_round_trips_through_parser() { + let turtle = sample_report().to_turtle(); + let mut sink = fluree_graph_ir::GraphCollectorSink::new(); + fluree_graph_turtle::parse(&turtle, &mut sink).expect("report Turtle must parse"); + let graph = sink.finish(); + // report node (type + conforms + result) and the result node's fields + assert!(graph.len() >= 8, "expected full report triples: {turtle}"); + assert!(turtle.contains("sh:MinCountConstraintComponent")); + assert!(turtle.contains("")); + } + + #[test] + fn turtle_term_sanitizes_blank_node_labels() { + assert_eq!( + turtle_term("_:fdb-inline-shapes-validate/scratch:main-2-b1"), + "_:fdb-inline-shapes-validate-scratch-main-2-b1" + ); + assert_eq!(turtle_term("http://ex.org/a"), ""); + } + + #[test] + fn turtle_string_escapes_specials() { + assert_eq!(turtle_string("a\"b\\c\nd"), "\"a\\\"b\\\\c\\nd\""); + } +} diff --git a/fluree-db-cli/src/cli.rs b/fluree-db-cli/src/cli.rs index baa29fd9a..ed0971640 100644 --- a/fluree-db-cli/src/cli.rs +++ b/fluree-db-cli/src/cli.rs @@ -743,6 +743,52 @@ pub enum Commands { #[command(subcommand)] action: ContextAction, }, + /// Validate data against SHACL shapes and print a validation report + /// + /// Ledger mode validates the current state of a local ledger against its + /// attached shapes (or ad-hoc shapes via --shacl / --shacl-graph). + /// File mode validates an RDF file in an ephemeral in-memory ledger — + /// nothing persists. Exits 1 when results at or above --fail-on exist. + /// + /// Examples: + /// fluree validate mydb + /// fluree validate mydb --shacl proposed-shapes.ttl + /// fluree validate data.ttl --shacl shapes.ttl + /// fluree validate data.jsonld --format jsonld + #[cfg(feature = "shacl")] + Validate { + /// Ledger name (with optional :branch) or an RDF data file + /// (.ttl / .jsonld / .json). Defaults to the active ledger. + #[arg(num_args = 0..=1)] + target: Option, + + /// IRI of a named data graph to validate (defaults to the default graph) + #[arg(long)] + graph: Option, + + /// SHACL shapes file (Turtle or JSON-LD). Replaces the ledger's + /// attached shapes unless --include-attached is also set. + #[arg(long, value_name = "FILE")] + shacl: Option, + + /// IRI of a named graph in the target ledger holding the shapes + #[arg(long, value_name = "IRI", conflicts_with = "shacl")] + shacl_graph: Option, + + /// Union ad-hoc shapes with the ledger's attached shapes instead of + /// replacing them + #[arg(long)] + include_attached: bool, + + /// Output format: table (human), jsonld, or turtle (W3C report) + #[arg(long, default_value = "table")] + format: String, + + /// Exit non-zero when results at or above this severity exist: + /// violation, warning, or info + #[arg(long, default_value = "violation", value_name = "SEVERITY")] + fail_on: String, + }, /// Export ledger data as RDF (Turtle, N-Triples, N-Quads, TriG, JSON-LD) or as a `.flpack` archive Export { diff --git a/fluree-db-cli/src/commands/mod.rs b/fluree-db-cli/src/commands/mod.rs index 64b6a94ca..b88c36bcb 100644 --- a/fluree-db-cli/src/commands/mod.rs +++ b/fluree-db-cli/src/commands/mod.rs @@ -35,3 +35,5 @@ pub mod update; pub mod upsert; pub mod upstream; pub mod use_cmd; +#[cfg(feature = "shacl")] +pub mod validate; diff --git a/fluree-db-cli/src/commands/validate.rs b/fluree-db-cli/src/commands/validate.rs new file mode 100644 index 000000000..9b1194d33 --- /dev/null +++ b/fluree-db-cli/src/commands/validate.rs @@ -0,0 +1,256 @@ +//! `fluree validate` — SHACL validation reports. +//! +//! Ledger mode validates the current state of a local ledger; file mode +//! loads an RDF file into an ephemeral in-memory ledger (staging-time SHACL +//! enforcement disabled so embedded shapes can't reject the load) and runs +//! the same validation core. Both call `fluree_db_api::validate`. + +use crate::context::{self, LedgerMode}; +use crate::detect; +use crate::error::{CliError, CliResult, EXIT_ERROR}; +use fluree_db_api::validate::{ShapesSource, ValidateOptions, ValidateReport}; +use fluree_db_api::Fluree; +use std::io::Write; +use std::path::Path; + +/// Severity threshold for the process exit code. +#[derive(Clone, Copy, PartialEq)] +enum FailOn { + Violation, + Warning, + Info, +} + +enum ReportFormat { + Table, + JsonLd, + Turtle, +} + +#[allow(clippy::too_many_arguments)] +pub async fn run( + target: Option<&str>, + graph: Option<&str>, + shacl: Option<&Path>, + shacl_graph: Option<&str>, + include_attached: bool, + format: &str, + fail_on: &str, + config_path: Option<&Path>, +) -> CliResult<()> { + let format = parse_format(format)?; + let fail_on = parse_fail_on(fail_on)?; + let shapes = resolve_shapes_source(shacl, shacl_graph)?; + + // File mode: an existing RDF file validates in an ephemeral memory ledger. + if let Some(t) = target { + let path = Path::new(t); + if path.is_file() { + let report = validate_file(path, graph, shapes, include_attached).await?; + return finish(&report, format, fail_on); + } + if looks_like_data_file(t) { + return Err(CliError::Usage(format!( + "no such file: '{t}' — pass an existing RDF file or a ledger name" + ))); + } + } + + // Ledger mode: local ledgers only (the HTTP validate endpoint comes later). + let dirs = crate::config::require_fluree_dir(config_path)?; + let mode = context::resolve_ledger_mode(target, &dirs).await?; + match mode { + LedgerMode::Local { fluree, alias } => { + let options = ValidateOptions { + graph: graph.map(String::from), + shapes, + include_attached, + }; + let report = fluree.validate_ledger(&alias, &options).await?; + finish(&report, format, fail_on) + } + LedgerMode::Tracked { .. } => Err(CliError::Usage( + "fluree validate runs against local ledgers; validating a remote \ + ledger over HTTP is not yet supported" + .into(), + )), + } +} + +/// Load an RDF file into an ephemeral in-memory ledger and validate it. +async fn validate_file( + path: &Path, + graph: Option<&str>, + shapes: ShapesSource, + include_attached: bool, +) -> CliResult { + let content = std::fs::read_to_string(path) + .map_err(|e| CliError::Input(format!("cannot read data file '{}': {e}", path.display())))?; + let data_format = detect::detect_data_format(Some(path), &content, None)?; + + let fluree = context::build_memory_fluree(); + let alias = "validate/scratch:main"; + fluree.create_ledger(alias).await?; + + // Disable staging-time SHACL so a file that embeds its own shapes loads + // even when the data violates them — surfacing violations is this + // command's job, not the loader's. + disable_staging_shacl(&fluree, alias).await?; + + let ledger_graph = fluree.graph(alias); + match data_format { + detect::DataFormat::Turtle => { + ledger_graph + .transact() + .insert_turtle(&content) + .commit() + .await?; + } + detect::DataFormat::JsonLd => { + let json: serde_json::Value = serde_json::from_str(&content)?; + ledger_graph.transact().insert(&json).commit().await?; + } + } + + let options = ValidateOptions { + graph: graph.map(String::from), + shapes, + include_attached, + }; + Ok(fluree.validate_ledger(alias, &options).await?) +} + +async fn disable_staging_shacl(fluree: &Fluree, alias: &str) -> CliResult<()> { + let config_iri = fluree_db_core::graph_registry::config_graph_iri(alias); + let trig = format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:shaclDefaults . + f:shaclEnabled false . + }} + " + ); + fluree + .graph(alias) + .transact() + .upsert_turtle(&trig) + .commit() + .await?; + Ok(()) +} + +fn resolve_shapes_source( + shacl: Option<&Path>, + shacl_graph: Option<&str>, +) -> CliResult { + if let Some(path) = shacl { + let content = std::fs::read_to_string(path).map_err(|e| { + CliError::Input(format!("cannot read shapes file '{}': {e}", path.display())) + })?; + return Ok( + match detect::detect_data_format(Some(path), &content, None)? { + detect::DataFormat::Turtle => ShapesSource::InlineTurtle(content), + detect::DataFormat::JsonLd => { + ShapesSource::InlineJsonLd(serde_json::from_str(&content)?) + } + }, + ); + } + if let Some(iri) = shacl_graph { + return Ok(ShapesSource::Graph(iri.to_string())); + } + Ok(ShapesSource::Attached) +} + +fn parse_format(format: &str) -> CliResult { + match format { + "table" => Ok(ReportFormat::Table), + "jsonld" | "json-ld" | "json" => Ok(ReportFormat::JsonLd), + "turtle" | "ttl" => Ok(ReportFormat::Turtle), + other => Err(CliError::Usage(format!( + "unknown --format '{other}' (expected table, jsonld, or turtle)" + ))), + } +} + +fn parse_fail_on(fail_on: &str) -> CliResult { + match fail_on { + "violation" => Ok(FailOn::Violation), + "warning" => Ok(FailOn::Warning), + "info" => Ok(FailOn::Info), + other => Err(CliError::Usage(format!( + "unknown --fail-on '{other}' (expected violation, warning, or info)" + ))), + } +} + +/// Print the report and exit non-zero when the fail-on threshold is met. +fn finish(report: &ValidateReport, format: ReportFormat, fail_on: FailOn) -> CliResult<()> { + match format { + ReportFormat::Table => print_table(report), + ReportFormat::JsonLd => println!("{}", serde_json::to_string_pretty(&report.to_jsonld())?), + ReportFormat::Turtle => print!("{}", report.to_turtle()), + } + + if report.shape_count == 0 { + eprintln!("warning: no SHACL shapes found — nothing was validated"); + } + + let failing = match fail_on { + FailOn::Violation => report.violation_count(), + FailOn::Warning => report.violation_count() + report.warning_count(), + FailOn::Info => report.results.len(), + }; + if failing > 0 { + std::io::stdout().flush().ok(); + std::process::exit(EXIT_ERROR); + } + Ok(()) +} + +fn print_table(report: &ValidateReport) { + for result in &report.results { + let severity = short_iri(&result.severity); + let component = short_iri(&result.constraint_component); + println!("{severity}: {}", result.focus_node); + if let Some(path) = &result.result_path { + println!(" path: {path}"); + } + println!(" component: {component}"); + println!(" message: {}", result.message); + if let Some(value) = &result.value { + println!(" value: {value}"); + } + } + if !report.results.is_empty() { + println!(); + } + println!( + "Conforms: {} — {} violation(s), {} warning(s), {} info ({} shape(s) checked)", + report.conforms, + report.violation_count(), + report.warning_count(), + report.info_count(), + report.shape_count + ); +} + +/// Shorten a SHACL-namespace IRI to its local name for table display. +fn short_iri(iri: &str) -> &str { + iri.rsplit_once('#').map_or(iri, |(_, local)| local) +} + +/// A path-shaped or extension-bearing argument that names no existing file is +/// almost always a typo'd file path — reject clearly instead of resolving it +/// as a ledger name. +fn looks_like_data_file(s: &str) -> bool { + // Ledger aliases may contain '/', so key off data-file extensions only. + let lower = s.to_ascii_lowercase(); + [".json", ".jsonld", ".ttl", ".nt", ".nq", ".trig"] + .iter() + .any(|ext| lower.ends_with(ext)) +} diff --git a/fluree-db-cli/src/lib.rs b/fluree-db-cli/src/lib.rs index d4e8f1716..81569b03a 100644 --- a/fluree-db-cli/src/lib.rs +++ b/fluree-db-cli/src/lib.rs @@ -398,6 +398,29 @@ pub async fn run(cli: Cli) -> error::CliResult<()> { } } + #[cfg(feature = "shacl")] + Commands::Validate { + target, + graph, + shacl, + shacl_graph, + include_attached, + format, + fail_on, + } => { + commands::validate::run( + target.as_deref(), + graph.as_deref(), + shacl.as_deref(), + shacl_graph.as_deref(), + include_attached, + &format, + &fail_on, + config_path, + ) + .await + } + Commands::Export { ledger, format, diff --git a/fluree-db-cli/tests/integration.rs b/fluree-db-cli/tests/integration.rs index 57ab9ca8b..4f38ca5d9 100644 --- a/fluree-db-cli/tests/integration.rs +++ b/fluree-db-cli/tests/integration.rs @@ -2633,3 +2633,89 @@ fn update_via_stdin() { .success() .stdout(predicate::str::contains("Committed t=1")); } + +// ============================================================================ +// Validate command (file mode — no .fluree required) +// ============================================================================ + +const VALIDATE_SHAPES_TTL: &str = r#" +@prefix sh: . +@prefix ex: . +@prefix schema: . +ex:UserShape a sh:NodeShape ; + sh:targetClass ex:User ; + sh:property [ sh:path schema:name ; sh:minCount 1 ] . +"#; + +#[test] +fn validate_file_with_shapes_reports_violation() { + let work = TempDir::new().unwrap(); + std::fs::write( + work.path().join("data.ttl"), + r#" +@prefix ex: . +@prefix schema: . +ex:bob a ex:User ; schema:email "bob@example.org" . +"#, + ) + .unwrap(); + std::fs::write(work.path().join("shapes.ttl"), VALIDATE_SHAPES_TTL).unwrap(); + + fluree_cmd(&work) + .args(["validate", "data.ttl", "--shacl", "shapes.ttl"]) + .assert() + .code(1) + .stdout(predicate::str::contains("MinCountConstraintComponent")) + .stdout(predicate::str::contains("Conforms: false")); +} + +#[test] +fn validate_conforming_file_exits_zero() { + let work = TempDir::new().unwrap(); + std::fs::write( + work.path().join("data.ttl"), + r#" +@prefix ex: . +@prefix schema: . +ex:alice a ex:User ; schema:name "Alice" . +"#, + ) + .unwrap(); + std::fs::write(work.path().join("shapes.ttl"), VALIDATE_SHAPES_TTL).unwrap(); + + fluree_cmd(&work) + .args(["validate", "data.ttl", "--shacl", "shapes.ttl"]) + .assert() + .success() + .stdout(predicate::str::contains("Conforms: true")); +} + +#[test] +fn validate_file_with_embedded_shapes() { + // Shapes and violating data in one file: the loader must not reject the + // load (staging-time SHACL is disabled in the ephemeral ledger) — the + // violation surfaces in the report instead. + let work = TempDir::new().unwrap(); + let mut doc = String::from(VALIDATE_SHAPES_TTL); + doc.push_str("\nex:carol a ex:User ; schema:email \"carol@example.org\" .\n"); + std::fs::write(work.path().join("embedded.ttl"), doc).unwrap(); + + fluree_cmd(&work) + .args(["validate", "embedded.ttl", "--format", "turtle"]) + .assert() + .code(1) + .stdout(predicate::str::contains("sh:ValidationReport")) + .stdout(predicate::str::contains("sh:conforms false")) + .stdout(predicate::str::contains("carol")); +} + +#[test] +fn validate_rejects_unknown_fail_on() { + let work = TempDir::new().unwrap(); + std::fs::write(work.path().join("data.ttl"), "@prefix ex: .").unwrap(); + fluree_cmd(&work) + .args(["validate", "data.ttl", "--fail-on", "nope"]) + .assert() + .code(2) + .stderr(predicate::str::contains("unknown --fail-on")); +} From dec99dc0339c86eec6d2b4efd78bafe5ed0d535d Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 15:10:30 -0400 Subject: [PATCH 05/17] =?UTF-8?q?feat(server):=20GET|POST=20/validate/*led?= =?UTF-8?q?ger=20=E2=80=94=20SHACL=20validation=20report=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP surface of fluree_db_api::validate, following the /show read- endpoint conventions (peer-mode forwarding, data-auth can_read gate, proxy-storage NotImplemented guard, request span). POST body selects the data graph and shapes source: inline shapes as a JSON-LD object or a Turtle string (replace-by-default, includeAttached unions), or a same-ledger shapesGraph IRI; GET validates attached shapes with defaults. Accept negotiation: JSON summary envelope (default), application/ld+json and text/turtle for the W3C sh:ValidationReport. Non-conformance is a 200, never an error; unknown ledger/graph map to 404 via ApiError::is_not_found. Feature-gated behind shacl (default on). Eight HTTP integration tests and endpoint documentation with CLI/cookbook cross-links. --- docs/api/endpoints.md | 77 +++++ docs/cli/validate.md | 5 +- docs/guides/cookbook-shacl.md | 5 +- fluree-db-server/src/routes/mod.rs | 9 + fluree-db-server/src/routes/validate.rs | 228 ++++++++++++++ .../tests/validate_http_integration.rs | 293 ++++++++++++++++++ 6 files changed, 614 insertions(+), 3 deletions(-) create mode 100644 fluree-db-server/src/routes/validate.rs create mode 100644 fluree-db-server/tests/validate_http_integration.rs diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 4c65608ba..b2bf29f73 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -1406,6 +1406,83 @@ curl -X POST http://localhost:8090/v1/fluree/explain/mydb \ -d '{"select":["?s"],"where":{"@id":"?s"}}' ``` +### GET/POST /validate/{ledger...} + +Validate the current state of a ledger (or one of its named graphs) against SHACL shapes and return a **validation report** — the HTTP surface of `fluree validate`. Unlike transaction-time enforcement, this never rejects anything; it reports every result it finds. Requires the `shacl` build feature (on by default). + +**URL:** +``` +GET /validate/{ledger...} +POST /validate/{ledger...} +``` + +**Request body** (POST; all fields optional — an empty body validates the default graph against the attached shapes): + +```json +{ + "graph": "http://example.org/graphs/data", + "shapes": { "...JSON-LD shapes document..." }, + "shapesGraph": "http://example.org/graphs/shapes", + "includeAttached": false +} +``` + +- `graph` — IRI of the named data graph to validate (default: the default graph). +- `shapes` — ad-hoc shapes: a JSON-LD object/array, or a **string containing Turtle**. Ad-hoc shapes **replace** the ledger's attached shapes ("does this data conform to *these* rules?"). +- `shapesGraph` — IRI of a named graph in this ledger holding the shapes (mutually exclusive with `shapes`). +- `includeAttached` — union ad-hoc shapes with the attached shapes instead of replacing them. + +**Response** (negotiated via `Accept`): + +- Default / `application/json` — summary envelope: + +```json +{ + "conforms": false, + "violations": 1, + "warnings": 0, + "infos": 0, + "shapesChecked": 1, + "results": [{ + "focus_node": "http://example.org/ns/bob", + "result_path": "http://schema.org/name", + "source_shape": "http://example.org/ns/UserShape", + "source_constraint": "http://example.org/ns/name-ps", + "constraint_component": "http://www.w3.org/ns/shacl#MinCountConstraintComponent", + "severity": "http://www.w3.org/ns/shacl#Violation", + "message": "Expected at least 1 value(s) but found 0" + }] +} +``` + +- `application/ld+json` — a W3C `sh:ValidationReport` JSON-LD document. +- `text/turtle` — the same report as Turtle. + +`result_path` is present only when the path is a single predicate; complex paths are omitted rather than misrepresented. + +**Auth:** same read gate as `/query` (Bearer `can_read` on the ledger). Note that validation reads the full graph — results are **not** policy-filtered, so access to this endpoint implies read access to the data it reports on. + +**Status Codes:** +- `200 OK` — report returned (conforming or not; non-conformance is not an error) +- `400 Bad Request` — malformed body, conflicting `shapes`/`shapesGraph`, unparseable shapes document +- `401 Unauthorized` — authentication required and missing +- `404 Not Found` — ledger or named graph not found (or not authorized) + +**Examples:** + +```bash +# Validate against the ledger's attached shapes +curl http://localhost:8090/v1/fluree/validate/mydb + +# Trial ad-hoc Turtle shapes (replaces attached shapes) +curl -X POST http://localhost:8090/v1/fluree/validate/mydb \ + -H "Content-Type: application/json" \ + -d '{"shapes": "@prefix sh: . ..."}' + +# W3C report as Turtle +curl http://localhost:8090/v1/fluree/validate/mydb -H "Accept: text/turtle" +``` + ## Nameservice Metadata The standalone server does not expose a general-purpose `POST /nameservice/query` diff --git a/docs/cli/validate.md b/docs/cli/validate.md index 090a17a87..068f85945 100644 --- a/docs/cli/validate.md +++ b/docs/cli/validate.md @@ -95,8 +95,9 @@ and a warning is printed to stderr. ## Current limitations -- Local ledgers only; validating a remote (tracked) ledger over HTTP is not - yet supported. +- The CLI validates local ledgers; for a remote server, call the + [`/validate` HTTP endpoint](../api/endpoints.md#getpost-validateledger) + directly (CLI `--remote` wiring is not yet implemented). - Shapes from a *different* ledger must be exported to a file first. ## Related diff --git a/docs/guides/cookbook-shacl.md b/docs/guides/cookbook-shacl.md index fa297490e..1d3af0172 100644 --- a/docs/guides/cookbook-shacl.md +++ b/docs/guides/cookbook-shacl.md @@ -615,7 +615,10 @@ Ad-hoc shapes (`--shacl`) **replace** the attached shapes by default so 0 = conforms, 1 = findings at/above `--fail-on` (default `violation`). See the [`fluree validate` reference](../cli/validate.md). -The same core is exposed in Rust as `fluree_db_api::validate` — +The same core is served over HTTP as +[`GET|POST /validate/{ledger}`](../api/endpoints.md#getpost-validateledger) +(JSON summary by default; `Accept: application/ld+json` or `text/turtle` +for the W3C report), and exposed in Rust as `fluree_db_api::validate` — `Fluree::validate_ledger(alias, &ValidateOptions)` returns a `ValidateReport` with per-result constraint-component IRIs, severities, and messages, plus `to_jsonld()` / `to_turtle()` serializers. diff --git a/fluree-db-server/src/routes/mod.rs b/fluree-db-server/src/routes/mod.rs index ca929f296..621f0121f 100644 --- a/fluree-db-server/src/routes/mod.rs +++ b/fluree-db-server/src/routes/mod.rs @@ -22,6 +22,8 @@ mod stream_query; mod stubs; mod submissions; mod transact; +#[cfg(feature = "shacl")] +mod validate; use crate::state::AppState; use axum::{ @@ -261,6 +263,13 @@ pub fn build_router(state: Arc) -> Router { .route("/subscribe", get(stubs::subscribe)) .route("/remote/:path", get(stubs::remote).post(stubs::remote)); + // SHACL validation report (read endpoint; see routes/validate.rs) + #[cfg(feature = "shacl")] + let v1 = v1.route( + "/validate/*ledger", + get(validate::validate_ledger_tail).post(validate::validate_ledger_tail), + ); + let mut router = Router::new() // Health check .route("/health", get(admin::health)) diff --git a/fluree-db-server/src/routes/validate.rs b/fluree-db-server/src/routes/validate.rs new file mode 100644 index 000000000..ed76ca7eb --- /dev/null +++ b/fluree-db-server/src/routes/validate.rs @@ -0,0 +1,228 @@ +//! SHACL validation report endpoint: `GET|POST /v1/fluree/validate/*ledger`. +//! +//! The HTTP surface of `fluree_db_api::validate` — validates the current +//! state of a ledger (or one of its named graphs) against SHACL shapes and +//! returns a validation report, instead of rejecting a transaction the way +//! staging-time enforcement does. +//! +//! `GET` validates against the ledger's attached shapes with default +//! options. `POST` accepts a JSON body selecting the data graph and the +//! shapes source (see [`ValidateBody`]). Ad-hoc shapes REPLACE the attached +//! shapes unless `includeAttached` is set, matching the CLI semantics. +//! +//! Response negotiation via `Accept`: +//! - `text/turtle` → W3C `sh:ValidationReport` as Turtle +//! - `application/ld+json` → W3C `sh:ValidationReport` as JSON-LD +//! - anything else (default) → JSON summary envelope with per-result fields +//! +//! Auth matches the read endpoints (bearer `can_read` on the ledger). Note +//! that validation reads the full graph — results are not policy-filtered, +//! so access to this endpoint implies read access to the data it reports on. + +use crate::config::ServerRole; +use crate::error::{Result, ServerError}; +use crate::extract::{FlureeHeaders, MaybeDataBearer}; +use crate::state::AppState; +use crate::telemetry::{ + create_request_span, extract_request_id, extract_trace_id, set_span_error_code, +}; +use axum::extract::{Path, Request, State}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use fluree_db_api::validate::{ShapesSource, ValidateOptions, ValidateReport}; +use serde::Deserialize; +use serde_json::{json, Value as JsonValue}; +use std::sync::Arc; +use tracing::Instrument; + +/// Maximum request-body size (inline shapes documents). +const MAX_BODY_BYTES: usize = 16 * 1024 * 1024; + +/// POST body for the validate endpoint. All fields optional; an empty or +/// absent body validates the default graph against the attached shapes. +#[derive(Deserialize, Default)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ValidateBody { + /// IRI of the named data graph to validate (default: the default graph). + pub graph: Option, + /// Ad-hoc shapes: a JSON-LD object/array, or a string containing Turtle. + pub shapes: Option, + /// IRI of a named graph in this ledger holding the shapes. + pub shapes_graph: Option, + /// Union ad-hoc shapes with the attached shapes instead of replacing. + #[serde(default)] + pub include_attached: bool, +} + +/// Validate a ledger's current state (ledger in path tail). +/// +/// `GET|POST /v1/fluree/validate/` +pub async fn validate_ledger_tail( + State(state): State>, + Path(ledger): Path, + headers: FlureeHeaders, + bearer: MaybeDataBearer, + request: Request, +) -> Response { + // In peer mode, forward to transactor (body still intact). + if state.config.server_role == ServerRole::Peer { + let client = match state.forwarding_client.as_ref() { + Some(c) => c, + None => { + return ServerError::internal("Forwarding client not configured").into_response() + } + }; + return match client.forward(request).await { + Ok(resp) => resp, + Err(e) => e.into_response(), + }; + } + + let body = match read_body(request).await { + Ok(body) => body, + Err(e) => return e.into_response(), + }; + validate_local(state, ledger, headers, bearer, body) + .await + .into_response() +} + +async fn read_body(request: Request) -> Result { + let bytes = axum::body::to_bytes(request.into_body(), MAX_BODY_BYTES) + .await + .map_err(|e| ServerError::bad_request(format!("failed to read request body: {e}")))?; + if bytes.is_empty() { + return Ok(ValidateBody::default()); + } + serde_json::from_slice(&bytes) + .map_err(|e| ServerError::bad_request(format!("invalid validate body: {e}"))) +} + +async fn validate_local( + state: Arc, + alias: String, + headers: FlureeHeaders, + bearer: MaybeDataBearer, + body: ValidateBody, +) -> Result { + let request_id = extract_request_id(&headers.raw, &state.telemetry_config); + let trace_id = extract_trace_id(&headers.raw); + + let span = create_request_span( + "validate", + request_id.as_deref(), + trace_id.as_deref(), + Some(&alias), + None, + None, + ); + async move { + let span = tracing::Span::current(); + tracing::info!(status = "start", "shacl validate requested"); + + // Enforce data auth (same pattern as the query/show endpoints). + let data_auth = state.config.data_auth(); + if data_auth.mode == crate::config::DataAuthMode::Required && bearer.0.is_none() { + set_span_error_code(&span, "error:Unauthorized"); + return Err(ServerError::unauthorized("Bearer token required")); + } + if let Some(p) = bearer.0.as_ref() { + if !p.can_read(&alias) { + set_span_error_code(&span, "error:Forbidden"); + return Err(ServerError::not_found("Ledger not found")); + } + } + + // Validation reads the local index; proxy storage mode has none. + if state.config.is_proxy_storage_mode() { + set_span_error_code(&span, "error:NotImplemented"); + return Err(ServerError::NotImplemented( + "SHACL validation is not available in proxy storage mode".to_string(), + )); + } + + let options = ValidateOptions { + graph: body.graph.clone(), + shapes: shapes_source(&body)?, + include_attached: body.include_attached, + }; + + let report = state + .fluree + .validate_ledger(&alias, &options) + .await + .map_err(|e| { + if e.is_not_found() { + ServerError::not_found(format!("Ledger not found: {alias}")) + } else { + ServerError::Api(e) + } + })?; + + tracing::info!( + status = "success", + conforms = report.conforms, + results = report.results.len(), + shapes = report.shape_count, + "shacl validate complete" + ); + Ok(negotiate_response(&report, &headers)) + } + .instrument(span) + .await +} + +fn shapes_source(body: &ValidateBody) -> Result { + if body.shapes.is_some() && body.shapes_graph.is_some() { + return Err(ServerError::bad_request( + "'shapes' and 'shapesGraph' are mutually exclusive", + )); + } + match &body.shapes { + Some(JsonValue::String(turtle)) => Ok(ShapesSource::InlineTurtle(turtle.clone())), + Some(doc @ (JsonValue::Object(_) | JsonValue::Array(_))) => { + Ok(ShapesSource::InlineJsonLd(doc.clone())) + } + Some(_) => Err(ServerError::bad_request( + "'shapes' must be a JSON-LD object/array or a string of Turtle", + )), + None => Ok(match &body.shapes_graph { + Some(iri) => ShapesSource::Graph(iri.clone()), + None => ShapesSource::Attached, + }), + } +} + +fn negotiate_response(report: &ValidateReport, headers: &FlureeHeaders) -> Response { + let accept = headers + .raw + .get(http::header::ACCEPT) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + if accept.contains("text/turtle") { + return ( + [(http::header::CONTENT_TYPE, "text/turtle")], + report.to_turtle(), + ) + .into_response(); + } + if accept.contains("application/ld+json") { + return ( + [(http::header::CONTENT_TYPE, "application/ld+json")], + Json(report.to_jsonld()).into_response().into_body(), + ) + .into_response(); + } + + // Default: JSON summary envelope with IRI-resolved per-result fields. + Json(json!({ + "conforms": report.conforms, + "violations": report.violation_count(), + "warnings": report.warning_count(), + "infos": report.info_count(), + "shapesChecked": report.shape_count, + "results": report.results, + })) + .into_response() +} diff --git a/fluree-db-server/tests/validate_http_integration.rs b/fluree-db-server/tests/validate_http_integration.rs new file mode 100644 index 000000000..2ece9f1a5 --- /dev/null +++ b/fluree-db-server/tests/validate_http_integration.rs @@ -0,0 +1,293 @@ +//! HTTP-layer coverage for `GET|POST /v1/fluree/validate/*ledger` — the +//! SHACL validation-report endpoint. Exercises the shapes-source selection +//! (attached / inline Turtle / inline JSON-LD / replace-vs-union), Accept +//! negotiation (JSON envelope, JSON-LD report, Turtle report), and error +//! paths (conflicting sources, unknown graph, unknown ledger). + +#![cfg(feature = "shacl")] + +use axum::body::Body; +use fluree_db_server::routes::build_router; +use fluree_db_server::{AppState, ServerConfig, TelemetryConfig}; +use http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{json, Value as JsonValue}; +use std::sync::Arc; +use tempfile::TempDir; +use tower::ServiceExt; + +async fn server_state() -> (TempDir, Arc) { + let tmp = tempfile::tempdir().expect("tempdir"); + let cfg = ServerConfig { + cors_enabled: false, + indexing_enabled: false, + storage_path: Some(tmp.path().to_path_buf()), + ..Default::default() + }; + let telemetry = TelemetryConfig::with_server_config(&cfg); + let state = Arc::new(AppState::new(cfg, telemetry).await.expect("AppState")); + (tmp, state) +} + +async fn post_json(state: &Arc, uri: &str, body: JsonValue) -> (StatusCode, JsonValue) { + let resp = build_router(Arc::clone(state)) + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(JsonValue::Null); + (status, json) +} + +/// Seed a ledger whose committed state violates its attached shape: the +/// data (a User without schema:name) commits before the shape exists, so +/// staging-time enforcement never fires. +async fn seed_nonconforming(state: &Arc, ledger: &str) { + let (status, _) = post_json(state, "/v1/fluree/create", json!({ "ledger": ledger })).await; + assert_eq!(status, StatusCode::CREATED, "create {ledger}"); + + let (status, _) = post_json( + state, + &format!("/v1/fluree/insert/{ledger}"), + json!({ + "@context": {"ex": "http://example.org/ns/", "schema": "http://schema.org/"}, + "@id": "ex:bob", + "@type": "ex:User", + "schema:email": "bob@example.org" + }), + ) + .await; + assert!(status.is_success(), "insert data into {ledger}: {status}"); + + let (status, _) = post_json( + state, + &format!("/v1/fluree/insert/{ledger}"), + json!({ + "@context": { + "ex": "http://example.org/ns/", + "sh": "http://www.w3.org/ns/shacl#", + "schema": "http://schema.org/" + }, + "@id": "ex:UserShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:name-ps", + "sh:path": {"@id": "schema:name"}, + "sh:minCount": 1 + }] + }), + ) + .await; + assert!(status.is_success(), "insert shape into {ledger}: {status}"); +} + +const EMAIL_SHAPES_TTL: &str = r#" +@prefix sh: . +@prefix ex: . +@prefix schema: . +ex:EmailShape a sh:NodeShape ; + sh:targetClass ex:User ; + sh:property [ sh:path schema:email ; sh:minCount 1 ] . +"#; + +#[tokio::test] +async fn validate_attached_shapes_json_envelope() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-attached").await; + + let (status, body) = post_json( + &state, + "/v1/fluree/validate/it/validate-attached", + json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["conforms"], json!(false)); + assert_eq!(body["violations"], json!(1)); + assert_eq!(body["shapesChecked"], json!(1)); + let result = &body["results"][0]; + assert_eq!(result["focus_node"], json!("http://example.org/ns/bob")); + assert_eq!( + result["constraint_component"], + json!("http://www.w3.org/ns/shacl#MinCountConstraintComponent") + ); +} + +#[tokio::test] +async fn validate_get_without_body() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-get").await; + + let resp = build_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .method("GET") + .uri("/v1/fluree/validate/it/validate-get") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let body: JsonValue = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["conforms"], json!(false)); +} + +#[tokio::test] +async fn validate_inline_turtle_replaces_attached() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-inline").await; + + // Replace (default): bob conforms to the email-only inline shapes. + let (status, body) = post_json( + &state, + "/v1/fluree/validate/it/validate-inline", + json!({ "shapes": EMAIL_SHAPES_TTL }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["conforms"], json!(true), "{body}"); + + // Union: the attached name shape fires again. + let (status, body) = post_json( + &state, + "/v1/fluree/validate/it/validate-inline", + json!({ "shapes": EMAIL_SHAPES_TTL, "includeAttached": true }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["conforms"], json!(false), "{body}"); + assert_eq!(body["violations"], json!(1)); +} + +#[tokio::test] +async fn validate_inline_jsonld_shapes() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-jsonld-shapes").await; + + let shapes = json!({ + "@context": { + "ex": "http://example.org/ns/", + "sh": "http://www.w3.org/ns/shacl#", + "schema": "http://schema.org/" + }, + "@id": "ex:PhoneShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:User"}, + "sh:property": [{ + "@id": "ex:phone-ps", + "sh:path": {"@id": "schema:telephone"}, + "sh:minCount": 1 + }] + }); + let (status, body) = post_json( + &state, + "/v1/fluree/validate/it/validate-jsonld-shapes", + json!({ "shapes": shapes }), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["conforms"], json!(false)); + assert_eq!( + body["results"][0]["result_path"], + json!("http://schema.org/telephone") + ); +} + +#[tokio::test] +async fn validate_accept_negotiation() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-accept").await; + + for (accept, content_type, needle) in [ + ( + "text/turtle", + "text/turtle", + "a sh:ValidationReport".to_string(), + ), + ( + "application/ld+json", + "application/ld+json", + "\"sh:conforms\":false".to_string(), + ), + ] { + let resp = build_router(Arc::clone(&state)) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/fluree/validate/it/validate-accept") + .header("content-type", "application/json") + .header("accept", accept) + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let got_ct = resp + .headers() + .get("content-type") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + assert!( + got_ct.starts_with(content_type), + "Accept {accept}: content-type {got_ct}" + ); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let text = String::from_utf8_lossy(&bytes).replace(char::is_whitespace, ""); + let needle = needle.replace(char::is_whitespace, ""); + assert!( + text.contains(&needle), + "Accept {accept}: body missing {needle}: {text}" + ); + } +} + +#[tokio::test] +async fn validate_conflicting_shape_sources_is_bad_request() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-conflict").await; + + let (status, _) = post_json( + &state, + "/v1/fluree/validate/it/validate-conflict", + json!({ + "shapes": EMAIL_SHAPES_TTL, + "shapesGraph": "http://example.org/graphs/shapes" + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn validate_unknown_graph_is_not_found() { + let (_tmp, state) = server_state().await; + seed_nonconforming(&state, "it/validate-nograph").await; + + let (status, _) = post_json( + &state, + "/v1/fluree/validate/it/validate-nograph", + json!({ "graph": "http://example.org/graphs/missing" }), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn validate_unknown_ledger_is_not_found() { + let (_tmp, state) = server_state().await; + let (status, _) = post_json(&state, "/v1/fluree/validate/it/no-such-ledger", json!({})).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} From d8bba2a930ea96384958fc4cefa6c2e5bbb583b8 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 16:40:25 -0400 Subject: [PATCH 06/17] chore: drop needless raw-string hashes in validate test fixtures --- fluree-db-api/src/shacl_tests.rs | 4 ++-- fluree-db-cli/tests/integration.rs | 4 ++-- fluree-db-server/tests/validate_http_integration.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index d641a9101..f1df81fb6 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -4025,14 +4025,14 @@ async fn validate_report_inline_turtle_replaces_attached() { .ledger; let view = crate::ledger_view::LedgerView::from_state(&ledger); - let email_shapes_turtle = r#" + let email_shapes_turtle = r" @prefix sh: . @prefix schema: . @prefix ex: . ex:EmailShape a sh:NodeShape ; sh:targetClass ex:User ; sh:property [ sh:path schema:email ; sh:minCount 1 ] . - "#; + "; // Replace (default): only the inline shape runs — bob conforms. let options = crate::validate::ValidateOptions { diff --git a/fluree-db-cli/tests/integration.rs b/fluree-db-cli/tests/integration.rs index 4f38ca5d9..5301c6976 100644 --- a/fluree-db-cli/tests/integration.rs +++ b/fluree-db-cli/tests/integration.rs @@ -2638,14 +2638,14 @@ fn update_via_stdin() { // Validate command (file mode — no .fluree required) // ============================================================================ -const VALIDATE_SHAPES_TTL: &str = r#" +const VALIDATE_SHAPES_TTL: &str = r" @prefix sh: . @prefix ex: . @prefix schema: . ex:UserShape a sh:NodeShape ; sh:targetClass ex:User ; sh:property [ sh:path schema:name ; sh:minCount 1 ] . -"#; +"; #[test] fn validate_file_with_shapes_reports_violation() { diff --git a/fluree-db-server/tests/validate_http_integration.rs b/fluree-db-server/tests/validate_http_integration.rs index 2ece9f1a5..23eb21c75 100644 --- a/fluree-db-server/tests/validate_http_integration.rs +++ b/fluree-db-server/tests/validate_http_integration.rs @@ -90,14 +90,14 @@ async fn seed_nonconforming(state: &Arc, ledger: &str) { assert!(status.is_success(), "insert shape into {ledger}: {status}"); } -const EMAIL_SHAPES_TTL: &str = r#" +const EMAIL_SHAPES_TTL: &str = r" @prefix sh: . @prefix ex: . @prefix schema: . ex:EmailShape a sh:NodeShape ; sh:targetClass ex:User ; sh:property [ sh:path schema:email ; sh:minCount 1 ] . -"#; +"; #[tokio::test] async fn validate_attached_shapes_json_envelope() { From 279309ca9dc31ec9874c6d0a1fe426990b1055f8 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 16:50:58 -0400 Subject: [PATCH 07/17] fix(shacl): inline shapes' value-set facts now visible to sh:class membership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ad-hoc shapes doc can ship the controlled vocabulary its sh:class constraints refer to (ex:CA rdf:type ex:State), matching f:shapesSource semantics where value-sets live with the shapes — but the detached inline bundle was invisible to membership lookups, so every such value reported as not-an-instance. CrossLedgerMembership gains a same_term_space mode: the bundle is encoded against the data ledger's namespace registry, so membership probes use the data-side Sids directly instead of the decode/re-encode translation (which always misses against the bundle's empty genesis snapshot). validate_view threads the bundle db in via the new validate_all_with_membership; the existing cross-ledger path is unchanged (same_term_space: false). --- fluree-db-api/src/shacl_tests.rs | 55 ++++++++++++++++++++++++++++++++ fluree-db-api/src/tx.rs | 1 + fluree-db-api/src/validate.rs | 19 +++++++++-- fluree-db-shacl/src/validate.rs | 55 ++++++++++++++++++++++---------- 4 files changed, 111 insertions(+), 19 deletions(-) diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index f1df81fb6..cdb543f13 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -4133,3 +4133,58 @@ async fn validate_report_unknown_graph_is_not_found() { .unwrap_err(); assert!(matches!(err, ApiError::NotFound(_)), "got {err:?}"); } + +#[tokio::test] +async fn validate_report_inline_shapes_carry_class_value_set() { + // An ad-hoc shapes doc may ship both a sh:class constraint AND the + // controlled vocabulary it refers to (`ex:CA rdf:type ex:State`) — + // matching f:shapesSource semantics where value-sets live with the + // shapes. Membership checks must see those bundle facts even though + // the bundle never touches the ledger. + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-valueset:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@graph": [ + {"@id": "ex:addr1", "@type": "ex:Address", "ex:state": {"@id": "ex:CA"}}, + {"@id": "ex:addr2", "@type": "ex:Address", "ex:state": {"@id": "ex:XX"}} + ] + }), + ) + .await + .unwrap() + .ledger; + let view = crate::ledger_view::LedgerView::from_state(&ledger); + + let shapes_turtle = r" + @prefix sh: . + @prefix ex: . + ex:AddressShape a sh:NodeShape ; + sh:targetClass ex:Address ; + sh:property [ sh:path ex:state ; sh:class ex:State ] . + ex:CA a ex:State . + "; + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineTurtle(shapes_turtle.to_string()), + ..Default::default() + }; + let report = crate::validate::validate_view(&view, "shacl/validate-valueset:main", &options) + .await + .unwrap(); + + // ex:CA is typed in the bundle -> conforms; ex:XX is typed nowhere -> violation. + assert_eq!(report.violation_count(), 1, "{:?}", report.results); + let result = &report.results[0]; + assert_eq!(result.focus_node, "http://example.org/ns/addr2"); + assert_eq!( + result.constraint_component, + "http://www.w3.org/ns/shacl#ClassConstraintComponent" + ); +} diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index ae7dd4e16..d10a24694 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -765,6 +765,7 @@ async fn stage_with_config_shacl( m_db.t, ), data_ns_map: ns_map, + same_term_space: false, }) } _ => None, diff --git a/fluree-db-api/src/validate.rs b/fluree-db-api/src/validate.rs index 5519cd4df..1033fd3d1 100644 --- a/fluree-db-api/src/validate.rs +++ b/fluree-db-api/src/validate.rs @@ -317,6 +317,7 @@ pub async fn validate_view( let mut inline_snapshot: Option = None; #[allow(unused_assignments)] let mut inline_overlay = None; + let mut inline_membership: Option> = None; let mut shape_dbs: Vec> = Vec::new(); let mut membership: Vec = Vec::new(); @@ -384,12 +385,24 @@ pub async fn validate_view( &NO_OVERLAY, bundle, )); - shape_dbs.push(GraphDbRef::new( + let bundle_db = GraphDbRef::new( inline_snapshot.as_ref().expect("just set above"), 0u16, inline_overlay.as_ref().expect("just set above"), to_t, - )); + ); + shape_dbs.push(bundle_db); + // The bundle may carry value-set facts alongside the shapes + // (e.g. `ex:CA rdf:type ex:State` for a `sh:class ex:State` + // constraint) — matching the documented f:shapesSource + // semantics where vocabulary lives with the shapes. It shares + // the data ledger's term space, so membership probes use the + // data-side Sids directly. + inline_membership = Some(fluree_db_shacl::CrossLedgerMembership { + model_db: bundle_db, + data_ns_map: snapshot.namespaces(), + same_term_space: true, + }); } } @@ -421,7 +434,7 @@ pub async fn validate_view( let data_db = GraphDbRef::new(snapshot, data_g_id, novelty, to_t); let raw = engine - .validate_all(data_db) + .validate_all_with_membership(data_db, inline_membership) .await .map_err(TransactError::from)?; diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index becff58ec..82c183b78 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -60,6 +60,12 @@ type ActiveShapeChecks = Mutex>; pub struct CrossLedgerMembership<'a> { /// `GraphDbRef` into M's value-set graph at the resolved `t`. pub model_db: GraphDbRef<'a>, + /// When true, `model_db` shares the data ledger's term space (e.g. an + /// inline-shapes bundle encoded against the data ledger's namespace + /// registry): membership probes use the data-side Sids directly instead + /// of the decode-to-IRI / re-encode-against-M translation — which would + /// always miss against a bundle backed by an empty genesis snapshot. + pub same_term_space: bool, /// D's namespace codes → IRI prefixes (base + this transaction's staged /// allocations). Used to decode a D-term Sid to its full IRI before /// re-encoding it against M (whose split mode may differ), because the @@ -320,6 +326,17 @@ impl ShaclEngine { /// Validate all focus nodes targeted by shapes pub async fn validate_all(&self, db: GraphDbRef<'_>) -> Result { + self.validate_all_with_membership(db, None).await + } + + /// [`Self::validate_all`] with an optional external `sh:class` + /// value-membership source (a model ledger or a same-term-space + /// inline-shapes bundle), consulted when the local lookup misses. + pub async fn validate_all_with_membership( + &self, + db: GraphDbRef<'_>, + cross_ledger: Option>, + ) -> Result { let mut all_results = Vec::new(); // Collect all shapes for logical constraint resolution @@ -328,9 +345,7 @@ impl ShaclEngine { let class_ctx = ClassMembershipCtx { membership_g_ids: &self.membership_g_ids, cache: &self.class_cache, - // Full-db validation (`validate_all`) has no cross-ledger model - // context; `sh:class` uses the local lookup only. - cross_ledger: None, + cross_ledger, }; for shape in self.cache.all_shapes() { if shape.deactivated { @@ -2356,19 +2371,27 @@ async fn value_conforms_cross_ledger( expected_class: &Sid, ) -> Result { let m_db = cl.model_db; - // D term -> IRI (via D's staged ns map) -> M term. A missing decode/encode - // means the value/class is simply not known to M -> not a member there. - let (Some(value_iri), Some(class_iri)) = ( - decode_sid_with_ns_map(cl.data_ns_map, value_ref), - decode_sid_with_ns_map(cl.data_ns_map, expected_class), - ) else { - return Ok(false); - }; - let (Some(m_value), Some(m_class)) = ( - m_db.snapshot.encode_iri_strict(&value_iri), - m_db.snapshot.encode_iri_strict(&class_iri), - ) else { - return Ok(false); + let (m_value, m_class) = if cl.same_term_space { + // The membership source shares the data ledger's term space (inline + // shapes bundle) — probe with the data-side Sids directly. + (value_ref.clone(), expected_class.clone()) + } else { + // D term -> IRI (via D's staged ns map) -> M term. A missing + // decode/encode means the value/class is simply not known to M -> + // not a member there. + let (Some(value_iri), Some(class_iri)) = ( + decode_sid_with_ns_map(cl.data_ns_map, value_ref), + decode_sid_with_ns_map(cl.data_ns_map, expected_class), + ) else { + return Ok(false); + }; + let (Some(m_value), Some(m_class)) = ( + m_db.snapshot.encode_iri_strict(&value_iri), + m_db.snapshot.encode_iri_strict(&class_iri), + ) else { + return Ok(false); + }; + (m_value, m_class) }; let rdf_type = Sid::new(RDF, rdf_names::TYPE); From 723e76f6f169744be3c0c647a43dbfafee74ce15 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 16:53:07 -0400 Subject: [PATCH 08/17] fix(cli): validate returns typed CliError::ExitCode instead of process::exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run(cli) is a library entry point; terminating the host process from inside command dispatch breaks embedding. Non-conforming validation now returns CliError::ExitCode(1), which exit_with_error maps to a silent exit in the binary — observable behavior unchanged (integration tests still assert exit codes 0/1/2). --- fluree-db-cli/src/commands/validate.rs | 2 +- fluree-db-cli/src/error.rs | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/fluree-db-cli/src/commands/validate.rs b/fluree-db-cli/src/commands/validate.rs index 9b1194d33..b00cb832d 100644 --- a/fluree-db-cli/src/commands/validate.rs +++ b/fluree-db-cli/src/commands/validate.rs @@ -207,7 +207,7 @@ fn finish(report: &ValidateReport, format: ReportFormat, fail_on: FailOn) -> Cli }; if failing > 0 { std::io::stdout().flush().ok(); - std::process::exit(EXIT_ERROR); + return Err(CliError::ExitCode(EXIT_ERROR)); } Ok(()) } diff --git a/fluree-db-cli/src/error.rs b/fluree-db-cli/src/error.rs index 19e1d0568..8e19c744e 100644 --- a/fluree-db-cli/src/error.rs +++ b/fluree-db-cli/src/error.rs @@ -31,6 +31,11 @@ pub enum CliError { Remote(String), /// Server lifecycle error (start/stop/status). Server(String), + /// Terminate with a specific exit code and no error message — the + /// command already wrote its output (e.g. `validate` printed a + /// non-conforming report). Library consumers of `run()` receive this + /// as a typed outcome instead of a `process::exit`. + ExitCode(i32), } impl fmt::Display for CliError { @@ -57,6 +62,7 @@ impl fmt::Display for CliError { CliError::Credential(e) => write!(f, "{} {e}", "error:".red().bold()), CliError::Remote(msg) => write!(f, "{} {msg}", "error:".red().bold()), CliError::Server(msg) => write!(f, "{} {msg}", "error:".red().bold()), + CliError::ExitCode(code) => write!(f, "exit code {code}"), } } } @@ -135,6 +141,10 @@ impl From for CliError { /// Print error and exit with the appropriate code. pub fn exit_with_error(err: CliError) -> ! { + // A typed exit carries no message — the command already wrote its output. + if let CliError::ExitCode(code) = &err { + process::exit(*code); + } eprintln!("{err}"); let code = match &err { CliError::Usage(_) => EXIT_USAGE, From a8eaed0d6d9e1a86058cd1128d6886ca4e7b0d47 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 17:02:40 -0400 Subject: [PATCH 09/17] feat(shacl): preserve RDF term fidelity for sh:value in validation reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ValidationResult carried only a FlakeValue, so reports could not represent language tags or non-native datatypes. ConstraintViolation gains value_index (stamped by the per-value dispatcher, unique-lang's internal position, the pair-constraint wrapper, and the class loop — validator signatures unchanged); result construction recovers the value's datatype and language from the parallel arrays into new ValidationResult::{value_datatype, value_lang} fields. Report emission renders sh:value as a JSON-LD value object: language- tagged literals as {"@value", "@language"}, non-native datatypes as {"@value", "@type"} with the lexical form, stringified-IRI facet values as {"@id"}, and self-describing temporal/numeric variants with inferred XSD types; only JSON-native XSD types stay bare scalars. The Turtle report renders the same terms as "lex"@lang / "lex"^^
. --- fluree-db-api/src/shacl_tests.rs | 89 +++++++++ fluree-db-api/src/validate.rs | 105 ++++++++++- .../src/constraints/cardinality.rs | 2 + fluree-db-shacl/src/constraints/datatype.rs | 2 + fluree-db-shacl/src/constraints/lang.rs | 10 +- fluree-db-shacl/src/constraints/mod.rs | 5 + fluree-db-shacl/src/constraints/pair.rs | 6 + fluree-db-shacl/src/constraints/pattern.rs | 6 + fluree-db-shacl/src/constraints/value.rs | 6 + fluree-db-shacl/src/validate.rs | 170 +++++++++++++++--- 10 files changed, 367 insertions(+), 34 deletions(-) diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index cdb543f13..e00ca9593 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -4188,3 +4188,92 @@ async fn validate_report_inline_shapes_carry_class_value_set() { "http://www.w3.org/ns/shacl#ClassConstraintComponent" ); } + +#[tokio::test] +async fn validate_report_value_term_fidelity() { + // sh:value must keep RDF term fidelity: language-tagged literals carry + // @language, non-native datatypes carry @type with the lexical form. + let fluree = FlureeBuilder::memory().build_memory(); + let context = shacl_context(); + let ledger = fluree + .create_ledger("shacl/validate-fidelity:main") + .await + .unwrap(); + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:thing", + "@type": "ex:Thing", + "ex:label": {"@value": "trop long", "@language": "fr"}, + "ex:score": {"@value": "3.14", "@type": "xsd:decimal"} + }), + ) + .await + .unwrap() + .ledger; + let ledger = fluree + .upsert( + ledger, + &json!({ + "@context": context.clone(), + "@id": "ex:ThingShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Thing"}, + "sh:property": [ + { + "@id": "ex:vf-label-ps", + "sh:path": {"@id": "ex:label"}, + "sh:maxLength": 4 + }, + { + "@id": "ex:vf-score-ps", + "sh:path": {"@id": "ex:score"}, + "sh:maxInclusive": 2 + } + ] + }), + ) + .await + .unwrap() + .ledger; + + let view = crate::ledger_view::LedgerView::from_state(&ledger); + let options = crate::validate::ValidateOptions::default(); + let report = crate::validate::validate_view(&view, "shacl/validate-fidelity:main", &options) + .await + .unwrap(); + assert_eq!(report.violation_count(), 2, "{:?}", report.results); + + let label = report + .results + .iter() + .find(|r| r.result_path.as_deref() == Some("http://example.org/ns/label")) + .expect("label violation"); + assert_eq!( + label.value, + Some(json!({"@value": "trop long", "@language": "fr"})), + "language tag must survive into sh:value" + ); + + let score = report + .results + .iter() + .find(|r| r.result_path.as_deref() == Some("http://example.org/ns/score")) + .expect("score violation"); + let value = score.value.as_ref().expect("sh:value present"); + assert_eq!(value["@value"], json!("3.14")); + assert_eq!( + value["@type"], + json!("http://www.w3.org/2001/XMLSchema#decimal") + ); + + // Turtle report renders the same terms as typed / language literals. + let turtle = report.to_turtle(); + assert!(turtle.contains("\"trop long\"@fr"), "{turtle}"); + assert!( + turtle.contains("\"3.14\"^^"), + "{turtle}" + ); +} diff --git a/fluree-db-api/src/validate.rs b/fluree-db-api/src/validate.rs index 1033fd3d1..a1f221e62 100644 --- a/fluree-db-api/src/validate.rs +++ b/fluree-db-api/src/validate.rs @@ -241,10 +241,21 @@ fn turtle_sh_term(iri: &str) -> String { /// Render a report value (as produced by `value_json`) as a Turtle term. fn turtle_value_term(value: &JsonValue) -> String { match value { - JsonValue::Object(obj) => match obj.get("@id").and_then(|v| v.as_str()) { - Some(iri) => turtle_term(iri), - None => turtle_string(&value.to_string()), - }, + JsonValue::Object(obj) => { + if let Some(iri) = obj.get("@id").and_then(|v| v.as_str()) { + return turtle_term(iri); + } + if let Some(lex) = obj.get("@value").and_then(|v| v.as_str()) { + if let Some(lang) = obj.get("@language").and_then(|v| v.as_str()) { + return format!("{}@{lang}", turtle_string(lex)); + } + if let Some(dt) = obj.get("@type").and_then(|v| v.as_str()) { + return format!("{}^^<{dt}>", turtle_string(lex)); + } + return turtle_string(lex); + } + turtle_string(&value.to_string()) + } JsonValue::Bool(b) => b.to_string(), JsonValue::Number(n) => n.to_string(), JsonValue::String(s) => turtle_string(s), @@ -450,7 +461,14 @@ pub async fn validate_view( constraint_component: r.constraint_component.to_string(), severity: severity_iri(r.severity).to_string(), message: r.message.clone(), - value: r.value.as_ref().map(|v| value_json(v, &resolve)), + value: r.value.as_ref().map(|v| { + value_json( + v, + r.value_datatype.as_ref(), + r.value_lang.as_deref(), + &resolve, + ) + }), }) .collect(); results.sort_by(|a, b| { @@ -516,19 +534,90 @@ fn severity_iri(severity: Severity) -> &'static str { } } -fn value_json(value: &FlakeValue, resolve: &impl Fn(&Sid) -> String) -> JsonValue { +const XSD: &str = "http://www.w3.org/2001/XMLSchema#"; + +/// Render an `sh:value` term as JSON-LD, preserving RDF term fidelity: +/// language-tagged literals become `{"@value", "@language"}`, non-native +/// datatypes become `{"@value", "@type"}` with the lexical form, and only +/// the JSON-native XSD types (string / boolean / integer / double) render +/// as bare JSON scalars. +fn value_json( + value: &FlakeValue, + datatype: Option<&Sid>, + lang: Option<&str>, + resolve: &impl Fn(&Sid) -> String, +) -> JsonValue { + if let FlakeValue::Ref(sid) = value { + return json!({"@id": resolve(sid)}); + } + if let Some(lang) = lang { + return json!({"@value": lexical_form(value), "@language": lang}); + } + if let Some(dt) = datatype { + let dt_iri = resolve(dt); + // A String carrying the `@id` datatype is a stringified IRI + // (STR() semantics in the string facets) — report the IRI node. + if dt_iri == "@id" || &*dt.name == "id" { + if let FlakeValue::String(s) = value { + return json!({"@id": s}); + } + } + return match (value, dt_iri.strip_prefix(XSD)) { + (FlakeValue::String(s), Some("string")) => json!(s), + (FlakeValue::Boolean(b), Some("boolean")) => json!(b), + (FlakeValue::Long(n), Some("integer")) => json!(n), + (FlakeValue::Double(d), Some("double")) => json!(d), + _ => json!({"@value": lexical_form(value), "@type": dt_iri}), + }; + } + // No datatype threaded: JSON natives stay native; self-describing + // temporal / numeric variants carry their XSD type. match value { - FlakeValue::Ref(sid) => json!({"@id": resolve(sid)}), FlakeValue::Boolean(b) => json!(b), FlakeValue::Long(n) => json!(n), FlakeValue::Double(d) => json!(d), FlakeValue::String(s) => json!(s), FlakeValue::Json(s) => serde_json::from_str(s).unwrap_or_else(|_| json!(s)), FlakeValue::Null => JsonValue::Null, - other => json!(other.to_string()), + other => match inferred_xsd_type(other) { + Some(local) => json!({ + "@value": lexical_form(other), + "@type": format!("{XSD}{local}"), + }), + None => json!(other.to_string()), + }, + } +} + +/// Lexical form of a literal for `@value`. `Display` is the canonical form +/// for the temporal / numeric variants, but wraps strings in quotes. +fn lexical_form(value: &FlakeValue) -> String { + match value { + FlakeValue::String(s) | FlakeValue::Json(s) => s.clone(), + other => other.to_string(), } } +/// XSD local name for FlakeValue variants that self-describe their datatype. +fn inferred_xsd_type(value: &FlakeValue) -> Option<&'static str> { + Some(match value { + FlakeValue::Decimal(_) => "decimal", + FlakeValue::BigInt(_) => "integer", + FlakeValue::DateTime(_) => "dateTime", + FlakeValue::Date(_) => "date", + FlakeValue::Time(_) => "time", + FlakeValue::GYear(_) => "gYear", + FlakeValue::GYearMonth(_) => "gYearMonth", + FlakeValue::GMonth(_) => "gMonth", + FlakeValue::GDay(_) => "gDay", + FlakeValue::GMonthDay(_) => "gMonthDay", + FlakeValue::YearMonthDuration(_) => "yearMonthDuration", + FlakeValue::DayTimeDuration(_) => "dayTimeDuration", + FlakeValue::Duration(_) => "duration", + _ => return None, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/fluree-db-shacl/src/constraints/cardinality.rs b/fluree-db-shacl/src/constraints/cardinality.rs index 23822fb5f..7b4d7afb8 100644 --- a/fluree-db-shacl/src/constraints/cardinality.rs +++ b/fluree-db-shacl/src/constraints/cardinality.rs @@ -9,6 +9,7 @@ pub fn validate_min_count(values: &[FlakeValue], min: usize) -> Option Option, + /// Index of `value` within the value set being checked, when the + /// violation concerns exactly one value. Lets result construction + /// recover the value's datatype / language tag from the parallel + /// `datatypes` / `langs` arrays (term fidelity for `sh:value`). + pub value_index: Option, /// Human-readable message about the violation pub message: String, } diff --git a/fluree-db-shacl/src/constraints/pair.rs b/fluree-db-shacl/src/constraints/pair.rs index 3ed2f8a3f..316d969a0 100644 --- a/fluree-db-shacl/src/constraints/pair.rs +++ b/fluree-db-shacl/src/constraints/pair.rs @@ -42,6 +42,7 @@ pub fn validate_equals( name: other_path.into(), }), value: None, + value_index: None, message: format!( "Value set does not equal value set for {}: {}", other_path, @@ -74,6 +75,7 @@ pub fn validate_disjoint( name: other_path.into(), }), value: intersection.first().map(|v| (**v).clone()), + value_index: None, message: format!( "Values must be disjoint from {other_path}, but found common values: {intersection:?}" ), @@ -101,6 +103,7 @@ pub fn validate_less_than( name: other_path.into(), }), value: Some(value.clone()), + value_index: None, message: format!( "Value {value:?} is not less than {other:?} from {other_path}" ), @@ -114,6 +117,7 @@ pub fn validate_less_than( name: other_path.into(), }), value: Some(value.clone()), + value_index: None, message: format!( "Cannot compare value {value:?} with {other:?} from {other_path} (incompatible types)" ), @@ -142,6 +146,7 @@ pub fn validate_less_than_or_equals( name: other_path.into(), }), value: Some(value.clone()), + value_index: None, message: format!( "Value {value:?} is not less than or equal to {other:?} from {other_path}" ), @@ -155,6 +160,7 @@ pub fn validate_less_than_or_equals( name: other_path.into(), }), value: Some(value.clone()), + value_index: None, message: format!( "Cannot compare value {value:?} with {other:?} from {other_path} (incompatible types)" ), diff --git a/fluree-db-shacl/src/constraints/pattern.rs b/fluree-db-shacl/src/constraints/pattern.rs index a8da14dd1..ec0a7844a 100644 --- a/fluree-db-shacl/src/constraints/pattern.rs +++ b/fluree-db-shacl/src/constraints/pattern.rs @@ -49,6 +49,7 @@ pub fn validate_pattern( return Ok(Some(ConstraintViolation { constraint: Constraint::Pattern(pattern.to_string(), flags.map(String::from)), value: Some(value.clone()), + value_index: None, message: "Pattern constraint cannot be applied to a non-literal value".to_string(), })); }; @@ -83,6 +84,7 @@ pub fn validate_pattern( Ok(Some(ConstraintViolation { constraint: Constraint::Pattern(pattern.to_string(), flags.map(String::from)), value: Some(value.clone()), + value_index: None, message: format!("Value '{string_value}' does not match pattern '{pattern}'"), })) } @@ -97,6 +99,7 @@ pub fn validate_min_length(value: &FlakeValue, min: usize) -> Option Option Option Option Option Option Some(ConstraintViolation { constraint: Constraint::MinInclusive(min.clone()), value: Some(value.clone()), + value_index: None, message: format!("Value {value:?} is less than minimum {min:?}"), }), } @@ -68,6 +71,7 @@ pub fn validate_max_inclusive(value: &FlakeValue, max: &FlakeValue) -> Option Some(ConstraintViolation { constraint: Constraint::MaxInclusive(max.clone()), value: Some(value.clone()), + value_index: None, message: format!("Value {value:?} exceeds maximum {max:?}"), }), } @@ -80,6 +84,7 @@ pub fn validate_min_exclusive(value: &FlakeValue, min: &FlakeValue) -> Option Some(ConstraintViolation { constraint: Constraint::MinExclusive(min.clone()), value: Some(value.clone()), + value_index: None, message: format!("Value {value:?} must be greater than {min:?}"), }), } @@ -92,6 +97,7 @@ pub fn validate_max_exclusive(value: &FlakeValue, max: &FlakeValue) -> Option Some(ConstraintViolation { constraint: Constraint::MaxExclusive(max.clone()), value: Some(value.clone()), + value_index: None, message: format!("Value {value:?} must be less than {max:?}"), }), } diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index 82c183b78..d37918f55 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -696,6 +696,8 @@ async fn validate_node_value_constraints<'a>( severity: shape.severity, message: shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: None, + value_lang: None, graph_id: None, }) .collect()) @@ -829,6 +831,8 @@ fn validate_structural_constraint<'a>( format!("Property {} not allowed by closed shape", prop.name) }), value: Some(flake.o.clone()), + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -866,6 +870,8 @@ fn validate_structural_constraint<'a>( ) }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -904,6 +910,8 @@ fn validate_structural_constraint<'a>( ) }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -936,6 +944,8 @@ fn validate_structural_constraint<'a>( format!("sh:and constraint - {}", r.message) }), value: r.value, + value_datatype: r.value_datatype, + value_lang: r.value_lang, graph_id: None, }); } @@ -989,6 +999,8 @@ fn validate_structural_constraint<'a>( ) }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1031,6 +1043,8 @@ fn validate_structural_constraint<'a>( "Node does not conform to any shape in sh:xone".to_string() }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } else if conforming_count > 1 { @@ -1049,6 +1063,8 @@ fn validate_structural_constraint<'a>( ) }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1095,6 +1111,8 @@ fn validate_nested_shape<'a>( severity: Severity::Violation, message: format!("Referenced shape {} could not be resolved", nested.id.name), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }]); } @@ -1119,6 +1137,8 @@ fn validate_nested_shape<'a>( severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1138,6 +1158,8 @@ fn validate_nested_shape<'a>( severity: Severity::Violation, message: format!("Unsupported sh:path expression: {reason}"), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); continue; @@ -1209,6 +1231,8 @@ fn validate_nested_shape<'a>( ) }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1227,6 +1251,14 @@ fn validate_nested_shape<'a>( severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1296,6 +1328,8 @@ fn validate_nested_shape<'a>( ) }), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1318,6 +1352,14 @@ fn validate_nested_shape<'a>( severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1335,6 +1377,14 @@ fn validate_nested_shape<'a>( severity: Severity::Violation, message: nested.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1387,6 +1437,8 @@ async fn validate_property_shape<'a>( severity: prop_shape.severity, message: format!("Unsupported sh:path expression: {reason}"), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); return Ok(results); @@ -1454,6 +1506,14 @@ async fn validate_property_shape<'a>( severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1471,6 +1531,14 @@ async fn validate_property_shape<'a>( severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1556,6 +1624,8 @@ async fn validate_property_shape<'a>( severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(message), value: None, + value_datatype: None, + value_lang: None, graph_id: None, }); } @@ -1576,6 +1646,14 @@ async fn validate_property_shape<'a>( severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1594,6 +1672,14 @@ async fn validate_property_shape<'a>( severity: prop_shape.severity, message: prop_shape.message.clone().unwrap_or(violation.message), value: violation.value, + value_datatype: violation + .value_index + .and_then(|i| datatypes.get(i)) + .cloned(), + value_lang: violation + .value_index + .and_then(|i| langs.get(i)) + .and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1689,6 +1775,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1727,6 +1815,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1770,6 +1860,8 @@ async fn validate_property_value_structural_constraint<'a>( format!("Value {value:?} does not conform to any shape in sh:xone") }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } else if conforming_count > 1 { @@ -1786,6 +1878,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1823,6 +1917,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -1860,6 +1956,8 @@ async fn validate_property_value_structural_constraint<'a>( ) }), value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), graph_id: None, }); } @@ -2012,15 +2110,17 @@ fn validate_constraint( Constraint::Datatype(expected_dt) => { for (i, value) in values.iter().enumerate() { if let Some(actual_dt) = datatypes.get(i) { - if let Some(v) = validate_datatype(value, actual_dt, expected_dt) { + if let Some(mut v) = validate_datatype(value, actual_dt, expected_dt) { + v.value_index = Some(i); violations.push(v); } } } } Constraint::NodeKind(kind) => { - for value in values { - if let Some(v) = validate_node_kind(value, *kind) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_node_kind(value, *kind) { + v.value_index = Some(i); violations.push(v); } } @@ -2031,57 +2131,65 @@ fn validate_constraint( // pure-values path without a snapshot). } Constraint::MinInclusive(min) => { - for value in values { - if let Some(v) = validate_min_inclusive(value, min) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_min_inclusive(value, min) { + v.value_index = Some(i); violations.push(v); } } } Constraint::MaxInclusive(max) => { - for value in values { - if let Some(v) = validate_max_inclusive(value, max) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_max_inclusive(value, max) { + v.value_index = Some(i); violations.push(v); } } } Constraint::MinExclusive(min) => { - for value in values { - if let Some(v) = validate_min_exclusive(value, min) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_min_exclusive(value, min) { + v.value_index = Some(i); violations.push(v); } } } Constraint::MaxExclusive(max) => { - for value in values { - if let Some(v) = validate_max_exclusive(value, max) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_max_exclusive(value, max) { + v.value_index = Some(i); violations.push(v); } } } Constraint::Pattern(pattern, flags) => { - for value in values { - if let Some(v) = validate_pattern(value, pattern, flags.as_deref())? { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_pattern(value, pattern, flags.as_deref())? { + v.value_index = Some(i); violations.push(v); } } } Constraint::MinLength(min) => { - for value in values { - if let Some(v) = validate_min_length(value, *min) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_min_length(value, *min) { + v.value_index = Some(i); violations.push(v); } } } Constraint::MaxLength(max) => { - for value in values { - if let Some(v) = validate_max_length(value, *max) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_max_length(value, *max) { + v.value_index = Some(i); violations.push(v); } } } Constraint::In(allowed) => { - for value in values { - if let Some(v) = validate_in(value, allowed) { + for (i, value) in values.iter().enumerate() { + if let Some(mut v) = validate_in(value, allowed) { + v.value_index = Some(i); violations.push(v); } } @@ -2104,7 +2212,8 @@ fn validate_constraint( Constraint::LanguageIn(allowed) => { for (i, value) in values.iter().enumerate() { let lang = langs.get(i).and_then(|l| l.as_deref()); - if let Some(v) = validate_language_in(value, lang, allowed) { + if let Some(mut v) = validate_language_in(value, lang, allowed) { + v.value_index = Some(i); violations.push(v); } } @@ -2166,6 +2275,18 @@ fn validate_pair_constraint( // Caller is responsible for only passing pair-constraint variants. _ => {} } + // Backfill value indices by position so result construction can recover + // the datatype / language of the offending source value. Equality lookup + // is exact for the per-value helpers (the violation value IS values[i]). + for v in &mut out { + if v.value_index.is_none() { + v.value_index = v + .value + .as_ref() + .and_then(|val| values.iter().position(|x| x == val)); + } + } + out } @@ -2215,13 +2336,14 @@ async fn validate_class_constraint( } } - for value in values { + for (value_index, value) in values.iter().enumerate() { let value_ref = match value { FlakeValue::Ref(r) => r, other => { out.push(ConstraintViolation { constraint: Constraint::Class(expected_class.clone()), value: Some(other.clone()), + value_index: Some(value_index), message: format!( "Value {:?} is a literal and cannot be an instance of class {}", other, expected_class.name @@ -2264,6 +2386,7 @@ async fn validate_class_constraint( out.push(ConstraintViolation { constraint: Constraint::Class(expected_class.clone()), value: Some(value.clone()), + value_index: Some(value_index), message: format!( "Value {} is not an instance of class {}", value_ref.name, expected_class.name @@ -2563,6 +2686,11 @@ pub struct ValidationResult { pub message: String, /// The value that caused the violation (if applicable) pub value: Option, + /// Datatype of `value`, when the violation concerns a single value whose + /// datatype is known (resolves to the `sh:value` literal's `@type`) + pub value_datatype: Option, + /// Language tag of `value`, when it is a language-tagged literal + pub value_lang: Option, /// The graph where the focus node was being validated. Populated by the /// staged-validation path (`validate_staged_nodes`) so that callers can /// apply per-graph SHACL policy (e.g. warn vs reject, enable/disable). From d17c583a8b78fc11d997040fd7a7547416241307 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 17:26:17 -0400 Subject: [PATCH 10/17] fix(turtle): accept bare blank-node-property-list statements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Turtle grammar allows `[ ... ] .` as a whole statement — the predicate-object list after a blankNodePropertyList subject is optional. The parser required it, rejecting valid documents (found via the W3C SHACL test suite, e.g. core/node/class-002.ttl). --- fluree-graph-turtle/src/parser.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/fluree-graph-turtle/src/parser.rs b/fluree-graph-turtle/src/parser.rs index ea4e38a46..7664f87f8 100644 --- a/fluree-graph-turtle/src/parser.rs +++ b/fluree-graph-turtle/src/parser.rs @@ -457,8 +457,14 @@ impl<'a, 'input, S: GraphSink> Parser<'a, 'input, S> { /// Parse a triple statement. fn parse_triples(&mut self) -> Result<()> { + let bnode_list_subject = matches!(self.current().kind, TokenKind::LBracket); let subject = self.parse_subject()?; - self.parse_predicate_object_list(subject)?; + // Turtle grammar: `blankNodePropertyList predicateObjectList? '.'` — + // the predicate-object list is optional when the subject is a + // `[...]` property list (its triples were emitted inside the list). + if !(bnode_list_subject && matches!(self.current().kind, TokenKind::Dot)) { + self.parse_predicate_object_list(subject)?; + } self.expect(&TokenKind::Dot)?; Ok(()) } @@ -1533,4 +1539,19 @@ mod tests { ); } } + + #[test] + fn bare_blank_node_property_list_statement() { + // Turtle grammar allows `[ ... ] .` with no outer predicate list. + let turtle = r" + @prefix ex: . + [ + ex:p ex:A ; + ex:p ex:B ; + ]. + "; + let mut sink = GraphCollectorSink::new(); + parse(turtle, &mut sink).expect("bare [ ... ] . statement must parse"); + assert_eq!(sink.finish().len(), 2); + } } From fbe9c66f19904a3c2618698fa2d38cd0c8749f61 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 17:27:21 -0400 Subject: [PATCH 11/17] test(shacl): W3C SHACL core compliance harness (testsuite-shacl) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors testsuite-sparql: workspace-excluded crate with the W3C data-shapes repo as a git submodule. Walks the core manifest tree, runs each sht:Validate case through fluree_db_api::validate (data loaded into an ephemeral memory ledger with staging-time SHACL disabled; shapes via ShapesSource::InlineTurtle), and compares the produced report against the expected sh:ValidationReport as a result multiset on focusNode / resultPath / severity / component / value, with documented leniency for blank nodes and absent fields (sourceShape and resultMessage are not compared). Make targets: count, summary, test-cat CAT=, test-one TEST=, failures, report-json, show. Report-only by default (SHACL_STRICT=1 to fail on any miss). First run: 46/98 (46.9%) — property 31/38, targets 5/7, node 5/32. The node category is dominated by two known engine gaps the suite now surfaces precisely: sh:targetNode with literal targets, and node-shape constraint results missing sh:value = focus node. See docs/contributing/shacl-compliance.md. --- .gitmodules | 3 + Cargo.toml | 2 +- docs/SUMMARY.md | 1 + docs/contributing/shacl-compliance.md | 72 + testsuite-shacl/Cargo.lock | 3025 +++++++++++++++++++++++++ testsuite-shacl/Cargo.toml | 21 + testsuite-shacl/Makefile | 67 + testsuite-shacl/data-shapes | 1 + testsuite-shacl/src/compare.rs | 134 ++ testsuite-shacl/src/lib.rs | 48 + testsuite-shacl/src/manifest.rs | 279 +++ testsuite-shacl/src/runner.rs | 114 + testsuite-shacl/tests/w3c_shacl.rs | 111 + 13 files changed, 3877 insertions(+), 1 deletion(-) create mode 100644 docs/contributing/shacl-compliance.md create mode 100644 testsuite-shacl/Cargo.lock create mode 100644 testsuite-shacl/Cargo.toml create mode 100644 testsuite-shacl/Makefile create mode 160000 testsuite-shacl/data-shapes create mode 100644 testsuite-shacl/src/compare.rs create mode 100644 testsuite-shacl/src/lib.rs create mode 100644 testsuite-shacl/src/manifest.rs create mode 100644 testsuite-shacl/src/runner.rs create mode 100644 testsuite-shacl/tests/w3c_shacl.rs diff --git a/.gitmodules b/.gitmodules index a6dbd58b5..898f9c029 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "testsuite-sparql/rdf-tests"] path = testsuite-sparql/rdf-tests url = https://github.com/w3c/rdf-tests.git +[submodule "testsuite-shacl/data-shapes"] + path = testsuite-shacl/data-shapes + url = https://github.com/w3c/data-shapes diff --git a/Cargo.toml b/Cargo.toml index 13e0b8e1f..bdbd4cc1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ members = [ "fluree-bench-support", "fluree-db-consensus", ] -exclude = ["testsuite-sparql"] +exclude = ["testsuite-sparql", "testsuite-shacl"] [workspace.package] version = "4.1.1" diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 508e419a6..de4327766 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -227,5 +227,6 @@ - [Dev setup](contributing/dev-setup.md) - [Tests](contributing/tests.md) - [W3C SPARQL compliance suite](contributing/sparql-compliance.md) + - [W3C SHACL compliance suite](contributing/shacl-compliance.md) - [Adding tracing spans](contributing/tracing-guide.md) - [Releasing](contributing/releasing.md) diff --git a/docs/contributing/shacl-compliance.md b/docs/contributing/shacl-compliance.md new file mode 100644 index 000000000..e9ab1c22e --- /dev/null +++ b/docs/contributing/shacl-compliance.md @@ -0,0 +1,72 @@ +# W3C SHACL Compliance Testing + +The `testsuite-shacl/` crate runs the official [W3C data-shapes test +suite](https://github.com/w3c/data-shapes) (core section) against the SHACL +engine, through the same `fluree_db_api::validate` core that powers +`fluree validate` and the `/validate` HTTP endpoint. + +The crate is **excluded from the workspace** — `cd testsuite-shacl/` before +running any cargo or make commands. The W3C tests are vendored as a git +submodule; on a fresh clone run: + +```bash +git submodule update --init testsuite-shacl/data-shapes +``` + +## Quick commands (from testsuite-shacl/) + +| Command | Purpose | +|---------|---------| +| `make count` | Overall pass/fail numbers | +| `make summary` | Per-category breakdown table | +| `make test-cat CAT=node` | Run one category | +| `make test-one TEST=minLength-001` | Run matching tests with full output | +| `make failures` | List failing tests with mismatch reasons | +| `make report-json` | Machine-readable report → `report.json` | +| `make show TEST=node/minLength-001` | Print a W3C test file | + +To debug a single case interactively, the CLI runs the same pipeline: + +```bash +fluree validate .ttl --format turtle # most tests embed data+shapes +``` + +## How a test runs + +Each `sht:Validate` entry names a data graph and a shapes graph (usually the +test file itself). The runner loads the data into an ephemeral in-memory +ledger (staging-time SHACL disabled via the config graph, so violating data +can load), validates with `ShapesSource::InlineTurtle`, and compares the +produced report against the `sh:ValidationReport` embedded in the manifest. + +## Comparison semantics and deliberate leniency + +Results are compared as a multiset on `sh:focusNode`, `sh:resultPath`, +`sh:resultSeverity`, `sh:sourceConstraintComponent`, and `sh:value` +(`sh:resultMessage` is never compared, per the suite's own rules). Where +exact comparison needs machinery we don't have yet, the harness is lenient: + +- **Blank nodes** (focus nodes, values, complex `sh:resultPath` structures) + match as wildcards instead of by graph isomorphism, and `sh:sourceShape` + is not compared at all. +- **Fields absent from an expected result** accept any actual value. + +Tightening these is future work; a pass under leniency is still meaningful +because conformance flag, result count, component IRIs, and severities must +match exactly. + +## Known engine gaps the suite surfaces + +Failures are expected in these areas (honest gaps, not harness bugs): + +- `sh:targetNode` with **literal** targets — unsupported; dominates the + `node` category, whose tests target literals directly. +- Node-shape constraint results don't set `sh:value` to the focus node + (the spec requires `sh:value` = focus for node-shape constraints). +- Complex `sh:resultPath` serialization (sequence/inverse path structures) + is omitted from reports. +- `sh:sparql` (the whole `sparql/` section of the suite is not wired in). + +Track the current pass rate with `make count` before and after engine +changes; regressions in previously-passing categories are the signal to +watch. diff --git a/testsuite-shacl/Cargo.lock b/testsuite-shacl/Cargo.lock new file mode 100644 index 000000000..3ce140f1d --- /dev/null +++ b/testsuite-shacl/Cargo.lock @@ -0,0 +1,3025 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "approx" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" +dependencies = [ + "num-traits", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6773ddc0eafc0e509fb60e48dff7f450f8e674a0686ae8605e8d9901bd5eefa" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitmaps" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cgmath" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" +dependencies = [ + "approx 0.4.0", + "num-traits", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "unsigned-varint", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-encoding-macro" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090" +dependencies = [ + "data-encoding", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "earcutr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79127ed59a85d7687c409e9978547cffb7dc79675355ed22da6b66fd5f6ead01" +dependencies = [ + "itertools", + "num-traits", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float_extras" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b22b70f8649ea2315955f1a36d964b0e4da482dfaa5f0d04df0d1fb7c338ab7a" +dependencies = [ + "libc", +] + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluree-db-api" +version = "4.1.1" +dependencies = [ + "async-trait", + "base64", + "bytes", + "chrono", + "csv", + "dirs", + "flate2", + "fluree-db-binary-index", + "fluree-db-connection", + "fluree-db-core", + "fluree-db-crypto", + "fluree-db-cypher", + "fluree-db-indexer", + "fluree-db-ledger", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-db-policy", + "fluree-db-query", + "fluree-db-r2rml", + "fluree-db-shacl", + "fluree-db-sparql", + "fluree-db-spatial", + "fluree-db-transact", + "fluree-graph-format", + "fluree-graph-ir", + "fluree-graph-json-ld", + "fluree-graph-turtle", + "fluree-search-protocol", + "fluree-vocab", + "futures", + "hex", + "indexmap", + "itoa", + "moka", + "percent-encoding", + "rustc-hash", + "ryu", + "serde", + "serde_json", + "sysinfo", + "thiserror 1.0.69", + "tokio", + "tracing", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-binary-index" +version = "4.1.1" +dependencies = [ + "bigdecimal 0.4.10", + "chrono", + "ciborium", + "fluree-db-core", + "fluree-db-spatial", + "fluree-vocab", + "fs2", + "futures", + "hex", + "memmap2", + "moka", + "num-bigint", + "num-traits", + "once_cell", + "parking_lot", + "regex", + "rust-stemmers", + "rustc-hash", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-connection" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-core", + "fluree-graph-json-ld", + "serde", + "serde_json", + "sysinfo", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-db-core" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "chrono", + "ciborium", + "cid", + "dashmap", + "fluree-db-credential", + "fluree-vocab", + "fs2", + "futures", + "hashbrown 0.16.1", + "hex", + "moka", + "multihash", + "num-bigint", + "num-traits", + "once_cell", + "parking_lot", + "rustc-hash", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tracing", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-credential" +version = "4.1.1" +dependencies = [ + "base64", + "bs58", + "ed25519-dalek", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-db-crypto" +version = "4.1.1" +dependencies = [ + "aes-gcm", + "async-trait", + "base64", + "fluree-db-core", + "fluree-db-nameservice", + "hex", + "rand_core 0.6.4", + "sha2", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "fluree-db-cypher" +version = "4.1.1" +dependencies = [ + "fluree-db-core", + "fluree-db-query", + "fluree-graph-json-ld", + "fluree-vocab", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "winnow", +] + +[[package]] +name = "fluree-db-indexer" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "chrono", + "fluree-db-binary-index", + "fluree-db-core", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-db-spatial", + "fluree-vocab", + "futures", + "hex", + "libc", + "memmap2", + "moka", + "num-bigint", + "num-traits", + "parking_lot", + "rayon", + "rustc-hash", + "serde", + "serde_json", + "sha2", + "sysinfo", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-ledger" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-core", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-vocab", + "futures", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-db-nameservice" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-core", + "fluree-vocab", + "parking_lot", + "rand 0.8.6", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "fluree-db-novelty" +version = "4.1.1" +dependencies = [ + "async-trait", + "chrono", + "fluree-db-core", + "fluree-db-credential", + "fluree-vocab", + "futures", + "imbl", + "rayon", + "rustc-hash", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-db-policy" +version = "4.1.1" +dependencies = [ + "fluree-db-core", + "fluree-vocab", + "futures", + "thiserror 1.0.69", + "tracing", + "uuid", +] + +[[package]] +name = "fluree-db-query" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "chrono", + "fluree-db-binary-index", + "fluree-db-core", + "fluree-db-novelty", + "fluree-db-policy", + "fluree-db-r2rml", + "fluree-db-reasoner", + "fluree-db-spatial", + "fluree-db-tabular", + "fluree-graph-json-ld", + "fluree-search-protocol", + "fluree-vocab", + "futures", + "geo", + "geo-types", + "hashbrown 0.16.1", + "lru", + "md-5", + "memmap2", + "moka", + "num-bigint", + "num-traits", + "once_cell", + "percent-encoding", + "postcard", + "rand 0.8.6", + "rayon", + "regex", + "rustc-hash", + "serde", + "serde_json", + "sha1", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "uuid", + "wkt", + "xxhash-rust", + "zstd", +] + +[[package]] +name = "fluree-db-r2rml" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-tabular", + "fluree-graph-ir", + "fluree-graph-turtle", + "fluree-vocab", + "once_cell", + "regex", + "serde", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-db-reasoner" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "fluree-db-core", + "fluree-vocab", + "hashbrown 0.16.1", + "lru", + "num-bigint", + "parking_lot", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-db-shacl" +version = "4.1.1" +dependencies = [ + "async-trait", + "fluree-db-core", + "fluree-db-query", + "fluree-vocab", + "parking_lot", + "regex", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-db-sparql" +version = "4.1.1" +dependencies = [ + "bigdecimal 0.4.10", + "fluree-db-core", + "fluree-db-query", + "fluree-graph-json-ld", + "fluree-vocab", + "num-bigint", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", + "winnow", +] + +[[package]] +name = "fluree-db-spatial" +version = "4.1.1" +dependencies = [ + "async-trait", + "crc32fast", + "fluree-db-core", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-vocab", + "futures", + "geo", + "geo-types", + "hex", + "memmap2", + "moka", + "rustc-hash", + "s2", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tracing", + "wkt", + "zstd", +] + +[[package]] +name = "fluree-db-tabular" +version = "4.1.1" +dependencies = [ + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-db-transact" +version = "4.1.1" +dependencies = [ + "async-trait", + "bigdecimal 0.4.10", + "chrono", + "fluree-db-binary-index", + "fluree-db-core", + "fluree-db-cypher", + "fluree-db-indexer", + "fluree-db-ledger", + "fluree-db-nameservice", + "fluree-db-novelty", + "fluree-db-policy", + "fluree-db-query", + "fluree-db-shacl", + "fluree-db-sparql", + "fluree-graph-ir", + "fluree-graph-json-ld", + "fluree-graph-turtle", + "fluree-vocab", + "futures", + "hex", + "num-bigint", + "once_cell", + "parking_lot", + "postcard", + "rustc-hash", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "ulid", + "zstd", +] + +[[package]] +name = "fluree-graph-format" +version = "4.1.1" +dependencies = [ + "fluree-graph-ir", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-graph-ir" +version = "4.1.1" +dependencies = [ + "fluree-vocab", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-graph-json-ld" +version = "4.1.1" +dependencies = [ + "fluree-graph-ir", + "fluree-vocab", + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + +[[package]] +name = "fluree-graph-turtle" +version = "4.1.1" +dependencies = [ + "fluree-graph-ir", + "fluree-vocab", + "rustc-hash", + "serde_json", + "thiserror 1.0.69", + "tracing", + "winnow", +] + +[[package]] +name = "fluree-search-protocol" +version = "4.1.1" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "fluree-vocab" +version = "4.1.1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "geo" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f0e6e028c581e82e6822a68869514e94c25e7f8ea669a2d8595bdf7461ccc5" +dependencies = [ + "earcutr", + "float_next_after", + "geo-types", + "geographiclib-rs", + "i_overlay", + "log", + "num-traits", + "robust", + "rstar", + "spade", +] + +[[package]] +name = "geo-types" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +dependencies = [ + "approx 0.5.1", + "num-traits", + "rayon", + "rstar", + "serde", +] + +[[package]] +name = "geographiclib-rs" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" +dependencies = [ + "libm", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32 0.2.1", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32 0.3.1", + "stable_deref_trait", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "i_float" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775f9961a8d2f879725da8aff789bb20a3ddf297473e0c90af75e69313919490" +dependencies = [ + "serde", +] + +[[package]] +name = "i_key_sort" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "347c253b4748a1a28baf94c9ce133b6b166f08573157e05afe718812bc599fcd" + +[[package]] +name = "i_overlay" +version = "1.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01882ce5ed786bf6e8f5167f171a4026cd129ce17d9ff5cbf1e6749b98628ece" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", + "rayon", +] + +[[package]] +name = "i_shape" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27dbe9e5238d6b9c694c08415bf00fb370b089949bd818ab01f41f8927b8774c" +dependencies = [ + "i_float", + "serde", +] + +[[package]] +name = "i_tree" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "155181bc97d770181cf9477da51218a19ee92a8e5be642e796661aee2b601139" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "imbl" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc3be8d8cd36f33a46b1849f31f837c44d9fa87223baee3b4bd96b8f11df81eb" +dependencies = [ + "bitmaps", + "imbl-sized-chunks", + "rand_core 0.6.4", + "rand_xoshiro", + "version_check", +] + +[[package]] +name = "imbl-sized-chunks" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f4241005618a62f8d57b2febd02510fb96e0137304728543dfc5fd6f052c22d" +dependencies = [ + "bitmaps", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "match-lookup" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multibase" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" +dependencies = [ + "base-x", + "base256emoji", + "base45", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "unsigned-varint", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num-bigint" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c863e9ab5e7bf9c99ba75e1050f1e4d624ae87ed3532d6238ffbdc7b585dbbe6" +dependencies = [ + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless 0.7.17", + "serde", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "robust" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" + +[[package]] +name = "rstar" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "421400d13ccfd26dfa5858199c30a5d76f9c54e0dba7575273025b43c5175dbb" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "smallvec", +] + +[[package]] +name = "rust-stemmers" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" +dependencies = [ + "serde", + "serde_derive", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "s2" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7fbc04bb52c40b5f48c9bb2d2961375301916e0c25d9f373750654d588cd5c" +dependencies = [ + "bigdecimal 0.3.1", + "cgmath", + "float_extras", + "lazy_static", + "libm", + "serde", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "testsuite-shacl" +version = "4.1.1" +dependencies = [ + "anyhow", + "fluree-db-api", + "fluree-db-core", + "fluree-graph-ir", + "fluree-graph-turtle", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.4", + "web-time", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wkt" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f7f1ff4ea4c18936d6cd26a6fd24f0003af37e951a8e0e8b9e9a2d0bd0a46d" +dependencies = [ + "geo-types", + "log", + "num-traits", + "thiserror 1.0.69", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d93c89cdc2d3a63c3ec48ffe926931bdc069eafa8e4402fe6d8f790c9d1e576" + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/testsuite-shacl/Cargo.toml b/testsuite-shacl/Cargo.toml new file mode 100644 index 000000000..41952efcf --- /dev/null +++ b/testsuite-shacl/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "testsuite-shacl" +version = "4.1.1" +edition = "2021" +publish = false + +[dependencies] +# Fluree crates (for testing) +fluree-db-api = { path = "../fluree-db-api", features = ["native", "shacl"] } +fluree-db-core = { path = "../fluree-db-core" } +fluree-graph-turtle = { path = "../fluree-graph-turtle" } +fluree-graph-ir = { path = "../fluree-graph-ir" } + +# Test infrastructure +anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread"] } + +[profile.dev] +opt-level = 1 diff --git a/testsuite-shacl/Makefile b/testsuite-shacl/Makefile new file mode 100644 index 000000000..57bfda18f --- /dev/null +++ b/testsuite-shacl/Makefile @@ -0,0 +1,67 @@ +# testsuite-shacl/Makefile +# +# Developer targets for the W3C SHACL core compliance test suite. +# Run `make help` to see available targets. +# +# NOTE: this crate is excluded from the workspace — run make (or cargo) +# from inside testsuite-shacl/. + +CARGO := cargo +REPORT_JSON := report.json + +.PHONY: help test count summary test-cat test-one report-json failures show clean update-submodule + +help: ## Show this help + @echo "W3C SHACL Core Compliance Test Suite" + @echo "" + @echo "Usage: make " + @echo "" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}' + @echo "" + @echo "Examples:" + @echo " make count # overall pass/fail numbers" + @echo " make summary # per-category table" + @echo " make test-cat CAT=node # run one category" + @echo " make test-one TEST=minLength-001 # run matching tests, full output" + @echo " make failures # list failing tests with reasons" + @echo " make show TEST=node/minLength-001 # print a test file" + +test: ## Run the full core suite (report-only; never fails the build) + @$(CARGO) test --test w3c_shacl -- --nocapture + +count: ## Overall pass/fail numbers + @$(CARGO) test --test w3c_shacl -- --nocapture 2>&1 | grep -E "=== Test Summary" || true + +summary: ## Per-category pass/fail table + @$(CARGO) test --test w3c_shacl -- --nocapture 2>&1 | \ + sed -n '/=== W3C SHACL Core/,/=== Test Summary/p' + +test-cat: ## Run one category: make test-cat CAT=node + @test -n "$(CAT)" || (echo "usage: make test-cat CAT=node" && exit 2) + @SHACL_CATEGORY=$(CAT) $(CARGO) test --test w3c_shacl -- --nocapture + +test-one: ## Run tests whose name matches: make test-one TEST=minLength-001 + @test -n "$(TEST)" || (echo "usage: make test-one TEST=minLength-001" && exit 2) + @SHACL_TEST=$(TEST) $(CARGO) test --test w3c_shacl -- --nocapture + +report-json: ## Write machine-readable report to report.json + @W3C_REPORT_JSON=$(REPORT_JSON) $(CARGO) test --test w3c_shacl -- --nocapture > /dev/null 2>&1; true + @echo "JSON report written to $(REPORT_JSON)" + @python3 -c "import json; r=json.load(open('$(REPORT_JSON)')); print(f' Total: {r[\"total\"]} Passed: {r[\"passed\"]} Failed: {r[\"failed\"]} Rate: {r[\"pass_rate\"]}')" 2>/dev/null || true + +failures: ## List failing tests with reasons (from a fresh run) + @$(CARGO) test --test w3c_shacl -- --nocapture 2>&1 | grep -A1 -E "^(FAIL|ERROR)" || echo "no failures" + +show: ## Print a W3C test file: make show TEST=node/minLength-001 + @test -n "$(TEST)" || (echo "usage: make show TEST=node/minLength-001" && exit 2) + @cat data-shapes/data-shapes-test-suite/tests/core/$(TEST).ttl + +clean: ## Remove build artifacts and reports + @$(CARGO) clean + @rm -f $(REPORT_JSON) + +update-submodule: ## Update the W3C data-shapes submodule to latest + cd data-shapes && git pull origin gh-pages + @echo "" + @echo "Submodule updated. Run 'make count' to check for changes." diff --git a/testsuite-shacl/data-shapes b/testsuite-shacl/data-shapes new file mode 160000 index 000000000..08adb3776 --- /dev/null +++ b/testsuite-shacl/data-shapes @@ -0,0 +1 @@ +Subproject commit 08adb3776709a014bc3062ede793c36275b22446 diff --git a/testsuite-shacl/src/compare.rs b/testsuite-shacl/src/compare.rs new file mode 100644 index 000000000..3d0ed2473 --- /dev/null +++ b/testsuite-shacl/src/compare.rs @@ -0,0 +1,134 @@ +//! Compare an actual [`ValidateReport`] against the expected results. + +use std::fmt; + +use fluree_db_api::validate::{ReportResult, ValidateReport}; +use serde_json::json; + +use crate::manifest::{ExpectedResult, TermPat}; + +/// Why a report did not match. +pub struct Mismatch(String); + +impl fmt::Display for Mismatch { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// Compare the actual report to the expected conformance + result multiset. +/// Returns `None` on a match. +pub fn compare_report( + expected_conforms: bool, + expected: &[ExpectedResult], + actual: &ValidateReport, +) -> Option { + if actual.conforms != expected_conforms { + return Some(Mismatch(format!( + "conforms: expected {expected_conforms}, got {} ({} results: {})", + actual.conforms, + actual.results.len(), + summarize(&actual.results), + ))); + } + if actual.results.len() != expected.len() { + return Some(Mismatch(format!( + "result count: expected {}, got {} ({})", + expected.len(), + actual.results.len(), + summarize(&actual.results), + ))); + } + + // Multiset match with backtracking (result sets are small). + let mut used = vec![false; actual.results.len()]; + if match_all(expected, &actual.results, &mut used) { + None + } else { + Some(Mismatch(format!( + "results do not match expected set (actual: {})", + summarize(&actual.results), + ))) + } +} + +fn match_all(expected: &[ExpectedResult], actual: &[ReportResult], used: &mut [bool]) -> bool { + let Some(exp) = expected.first() else { + return true; + }; + for (i, act) in actual.iter().enumerate() { + if !used[i] && matches(exp, act) { + used[i] = true; + if match_all(&expected[1..], actual, used) { + return true; + } + used[i] = false; + } + } + false +} + +fn matches(exp: &ExpectedResult, act: &ReportResult) -> bool { + // Focus node: actual is an IRI string, or a skolemized blank-node label. + let focus_ok = match &exp.focus { + TermPat::Absent | TermPat::Any => true, + TermPat::Json(j) => { + !act.focus_node.starts_with("_:") && *j == json!({"@id": act.focus_node}) + } + }; + if !focus_ok { + return false; + } + + // Result path: compared only when the expected path is a plain IRI; + // blank-node (complex) paths match leniently, absent accepts anything. + let path_ok = match &exp.path { + TermPat::Absent | TermPat::Any => true, + TermPat::Json(j) => match &act.result_path { + Some(p) => *j == json!({"@id": p}), + None => false, + }, + }; + if !path_ok { + return false; + } + + if let Some(sev) = &exp.severity { + if *sev != act.severity { + return false; + } + } + if let Some(component) = &exp.component { + if *component != act.constraint_component { + return false; + } + } + + match &exp.value { + TermPat::Absent | TermPat::Any => true, + TermPat::Json(j) => act.value.as_ref() == Some(j), + } +} + +fn summarize(results: &[ReportResult]) -> String { + results + .iter() + .map(|r| { + format!( + "[{} {} @{}{}]", + short(&r.constraint_component), + r.focus_node, + r.result_path.as_deref().unwrap_or("-"), + r.value + .as_ref() + .map(|v| format!(" = {v}")) + .unwrap_or_default(), + ) + }) + .collect::>() + .join(" ") +} + +fn short(iri: &str) -> &str { + iri.rsplit_once('#').map_or(iri, |(_, l)| l) +} diff --git a/testsuite-shacl/src/lib.rs b/testsuite-shacl/src/lib.rs new file mode 100644 index 000000000..a1ec1640f --- /dev/null +++ b/testsuite-shacl/src/lib.rs @@ -0,0 +1,48 @@ +//! W3C SHACL test-suite harness. +//! +//! Walks the official `data-shapes` test manifests (vendored as a git +//! submodule under `data-shapes/`), runs each `sht:Validate` case through +//! the `fluree_db_api::validate` core (the same code path behind +//! `fluree validate`), and compares the produced report against the +//! expected `sh:ValidationReport` embedded in the manifest. +//! +//! ## Comparison semantics +//! +//! The W3C suite compares validation reports as RDF graphs. This harness +//! compares the *result multiset* on the fields implementations are judged +//! on — `sh:focusNode`, `sh:resultPath`, `sh:resultSeverity`, +//! `sh:sourceConstraintComponent`, `sh:value` — with deliberate leniency +//! where exact comparison needs machinery we don't have yet: +//! +//! - **Blank nodes** (focus nodes, values, complex `sh:resultPath` +//! structures, `sh:sourceShape`): matched as wildcards rather than by +//! graph isomorphism. `sh:sourceShape` is not compared at all. +//! - **Missing expected fields**: not compared (an expected result without +//! `sh:value` accepts any actual value). +//! +//! `sh:resultMessage` is never compared (per the suite's own rules). + +pub mod compare; +pub mod manifest; +pub mod runner; + +pub use compare::{compare_report, Mismatch}; +pub use manifest::{collect_tests, Expectation, ExpectedResult, TermPat, TestCase}; +pub use runner::{run_case, Outcome}; + +/// Namespaces used by the manifests. +pub mod ns { + pub const MF: &str = "http://www.w3.org/2001/sw/DataAccess/tests/test-manifest#"; + pub const SHT: &str = "http://www.w3.org/ns/shacl-test#"; + pub const SH: &str = "http://www.w3.org/ns/shacl#"; + pub const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; + pub const XSD: &str = "http://www.w3.org/2001/XMLSchema#"; +} + +/// Turn a filesystem path into the `file://` IRI used as the Turtle +/// `@base`, so relative IRIs (including `<>` self-references) resolve +/// identically in the manifest graph, the loaded data, and the report. +pub fn file_iri(path: &std::path::Path) -> String { + let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + format!("file://{}", abs.display()) +} diff --git a/testsuite-shacl/src/manifest.rs b/testsuite-shacl/src/manifest.rs new file mode 100644 index 000000000..4ddf3f289 --- /dev/null +++ b/testsuite-shacl/src/manifest.rs @@ -0,0 +1,279 @@ +//! Manifest walking: turn the W3C `data-shapes` manifest tree into test cases. + +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; +use fluree_graph_ir::{Graph, GraphCollectorSink, Term}; +use fluree_graph_turtle::parse; +use serde_json::{json, Value as JsonValue}; + +use crate::{file_iri, ns}; + +/// One `sht:Validate` test case. +#[derive(Debug)] +pub struct TestCase { + /// Entry IRI (resolved against the manifest file). + pub id: String, + /// Short display name, e.g. `node/minLength-001`. + pub name: String, + /// Category directory, e.g. `node`. + pub category: String, + /// `rdfs:label` when present. + pub label: Option, + /// `mf:status` is `sht:approved`. + pub approved: bool, + /// Data graph file. + pub data_path: PathBuf, + /// Shapes graph file. + pub shapes_path: PathBuf, + /// Expected outcome. + pub expect: Expectation, +} + +/// Expected outcome of a test case. +#[derive(Debug)] +pub enum Expectation { + /// A validation report with the given conformance and results. + Report { + conforms: bool, + results: Vec, + }, + /// Validation itself is expected to fail (`mf:result sht:Failure`). + Failure, +} + +/// One expected `sh:ValidationResult`, reduced to comparable patterns. +#[derive(Debug)] +pub struct ExpectedResult { + pub focus: TermPat, + pub path: TermPat, + pub severity: Option, + pub component: Option, + pub value: TermPat, +} + +/// A comparable pattern for one expected term. +#[derive(Debug, Clone, PartialEq)] +pub enum TermPat { + /// The field is absent in the expected result — accept anything. + Absent, + /// A blank node (or other structure we match leniently) — accept anything. + Any, + /// Concrete term in the same JSON form the report core emits. + Json(JsonValue), +} + +/// Recursively collect all `sht:Validate` cases reachable from a manifest. +pub fn collect_tests(manifest_path: &Path) -> Result> { + let mut out = Vec::new(); + walk(manifest_path, &mut out)?; + Ok(out) +} + +fn walk(manifest_path: &Path, out: &mut Vec) -> Result<()> { + let base = file_iri(manifest_path); + let raw = std::fs::read_to_string(manifest_path) + .with_context(|| format!("reading manifest {}", manifest_path.display()))?; + let content = format!("@base <{base}> .\n{raw}"); + + let mut sink = GraphCollectorSink::new(); + parse(&content, &mut sink) + .with_context(|| format!("parsing manifest {}", manifest_path.display()))?; + let graph = sink.finish(); + + let manifest_subject = find_manifest_subject(&graph, &base) + .ok_or_else(|| anyhow!("no mf:Manifest subject in {}", manifest_path.display()))?; + + // Sub-manifests + let mf_include = format!("{}include", ns::MF); + for include in list_items(&graph, &manifest_subject, &mf_include) { + let path = iri_to_path(&include) + .ok_or_else(|| anyhow!("mf:include is not a file IRI: {include}"))?; + walk(&path, out)?; + } + + // Entries in this manifest + let mf_entries = format!("{}entries", ns::MF); + for entry in list_items(&graph, &manifest_subject, &mf_entries) { + if let Some(case) = parse_entry(&graph, &entry, manifest_path) + .with_context(|| format!("entry {entry} in {}", manifest_path.display()))? + { + out.push(case); + } + } + Ok(()) +} + +fn parse_entry(graph: &Graph, entry_iri: &str, manifest_path: &Path) -> Result> { + let entry = Term::iri(entry_iri); + + // Only sht:Validate entries are runnable validation tests. + let sht_validate = format!("{}Validate", ns::SHT); + let is_validate = objects_for(graph, &entry, ns::RDF_TYPE) + .iter() + .any(|t| t.as_iri() == Some(sht_validate.as_str())); + if !is_validate { + return Ok(None); + } + + let label = object_for(graph, &entry, "http://www.w3.org/2000/01/rdf-schema#label") + .and_then(|t| t.as_literal().map(|(v, _, _)| v.lexical())); + + let approved = object_for(graph, &entry, &format!("{}status", ns::MF)) + .and_then(|t| t.as_iri().map(String::from)) + .is_some_and(|iri| iri == format!("{}approved", ns::SHT)); + + // mf:action → sht:dataGraph / sht:shapesGraph + let action = object_for(graph, &entry, &format!("{}action", ns::MF)) + .ok_or_else(|| anyhow!("entry has no mf:action"))? + .clone(); + let data_iri = object_for(graph, &action, &format!("{}dataGraph", ns::SHT)) + .and_then(|t| t.as_iri().map(String::from)) + .ok_or_else(|| anyhow!("mf:action has no sht:dataGraph"))?; + let shapes_iri = object_for(graph, &action, &format!("{}shapesGraph", ns::SHT)) + .and_then(|t| t.as_iri().map(String::from)) + .ok_or_else(|| anyhow!("mf:action has no sht:shapesGraph"))?; + let data_path = iri_to_path(&data_iri).ok_or_else(|| anyhow!("dataGraph is not a file IRI"))?; + let shapes_path = + iri_to_path(&shapes_iri).ok_or_else(|| anyhow!("shapesGraph is not a file IRI"))?; + + // mf:result → sht:Failure or an inline sh:ValidationReport + let result = object_for(graph, &entry, &format!("{}result", ns::MF)) + .ok_or_else(|| anyhow!("entry has no mf:result"))? + .clone(); + let expect = if result.as_iri() == Some(format!("{}Failure", ns::SHT).as_str()) { + Expectation::Failure + } else { + let conforms = object_for(graph, &result, &format!("{}conforms", ns::SH)) + .and_then(|t| t.as_literal().map(|(v, _, _)| v.lexical() == "true")) + .ok_or_else(|| anyhow!("expected report has no sh:conforms"))?; + let results = objects_for(graph, &result, &format!("{}result", ns::SH)) + .into_iter() + .map(|r| parse_expected_result(graph, r)) + .collect(); + Expectation::Report { conforms, results } + }; + + // node/minLength-001.ttl → category "node", name "node/minLength-001" + let category = manifest_path + .parent() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let stem = manifest_path + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + + Ok(Some(TestCase { + id: entry_iri.to_string(), + name: format!("{category}/{stem}"), + category, + label, + approved, + data_path, + shapes_path, + expect, + })) +} + +fn parse_expected_result(graph: &Graph, result: &Term) -> ExpectedResult { + let get = |local: &str| object_for(graph, result, &format!("{}{local}", ns::SH)); + ExpectedResult { + focus: term_pat(get("focusNode")), + path: term_pat(get("resultPath")), + severity: get("resultSeverity").and_then(|t| t.as_iri().map(String::from)), + component: get("sourceConstraintComponent").and_then(|t| t.as_iri().map(String::from)), + value: term_pat(get("value")), + } +} + +/// Reduce an expected term to a comparable pattern in the same JSON form +/// the report core emits for `sh:value` / focus nodes. +fn term_pat(term: Option<&Term>) -> TermPat { + match term { + None => TermPat::Absent, + Some(Term::BlankNode(_)) => TermPat::Any, + Some(Term::Iri(iri)) => TermPat::Json(json!({"@id": iri.to_string()})), + Some(Term::Literal { + value, + datatype, + language, + }) => TermPat::Json(literal_json(value, datatype.as_iri(), language.as_deref())), + } +} + +/// Mirror of the report core's `value_json` emission rules: JSON-native XSD +/// types render as bare scalars, language-tagged literals as +/// `{"@value", "@language"}`, everything else as `{"@value", "@type"}`. +fn literal_json( + value: &fluree_graph_ir::LiteralValue, + dt_iri: &str, + lang: Option<&str>, +) -> JsonValue { + use fluree_graph_ir::LiteralValue as LV; + if let Some(lang) = lang { + return json!({"@value": value.lexical(), "@language": lang}); + } + match (value, dt_iri.strip_prefix(ns::XSD)) { + (LV::String(s), Some("string")) => json!(s.to_string()), + (LV::Boolean(b), Some("boolean")) => json!(b), + (LV::Integer(i), Some("integer")) => json!(i), + (LV::Double(d), Some("double")) => json!(d), + _ => json!({"@value": value.lexical(), "@type": dt_iri}), + } +} + +// --------------------------------------------------------------------------- +// Graph helpers +// --------------------------------------------------------------------------- + +fn find_manifest_subject(graph: &Graph, base: &str) -> Option { + let mf_manifest = format!("{}Manifest", ns::MF); + for t in graph.iter() { + if t.p.as_iri() == Some(ns::RDF_TYPE) && t.o.as_iri() == Some(mf_manifest.as_str()) { + return Some(t.s.clone()); + } + } + let mf_entries = format!("{}entries", ns::MF); + for t in graph.iter() { + if t.p.as_iri() == Some(mf_entries.as_str()) { + return Some(t.s.clone()); + } + } + Some(Term::iri(base)) +} + +fn object_for<'g>(graph: &'g Graph, subject: &Term, predicate: &str) -> Option<&'g Term> { + graph + .iter() + .find(|t| t.s == *subject && t.p.as_iri() == Some(predicate)) + .map(|t| &t.o) +} + +fn objects_for<'g>(graph: &'g Graph, subject: &Term, predicate: &str) -> Vec<&'g Term> { + graph + .iter() + .filter(|t| t.s == *subject && t.p.as_iri() == Some(predicate)) + .map(|t| &t.o) + .collect() +} + +/// Ordered items of an object-position collection. Fluree's Turtle parser +/// emits list members as triples carrying `list_index` (not rdf:first/rest). +fn list_items(graph: &Graph, subject: &Term, predicate: &str) -> Vec { + let mut items: Vec<(i32, String)> = graph + .iter() + .filter(|t| t.s == *subject && t.p.as_iri() == Some(predicate)) + .filter_map(|t| { + let iri = t.o.as_iri()?; + Some((t.list_index.unwrap_or(0), iri.to_string())) + }) + .collect(); + items.sort_by_key(|(i, _)| *i); + items.into_iter().map(|(_, v)| v).collect() +} + +fn iri_to_path(iri: &str) -> Option { + iri.strip_prefix("file://").map(PathBuf::from) +} diff --git a/testsuite-shacl/src/runner.rs b/testsuite-shacl/src/runner.rs new file mode 100644 index 000000000..016203cb6 --- /dev/null +++ b/testsuite-shacl/src/runner.rs @@ -0,0 +1,114 @@ +//! Run one test case through the `fluree_db_api::validate` core. + +use anyhow::{Context, Result}; +use fluree_db_api::validate::{ShapesSource, ValidateOptions, ValidateReport}; +use fluree_db_api::{Fluree, FlureeBuilder}; + +use crate::compare::compare_report; +use crate::file_iri; +use crate::manifest::{Expectation, TestCase}; + +/// Outcome of running one case. +#[derive(Debug)] +pub enum Outcome { + Pass, + /// The report was produced but did not match the expected one. + Fail(String), + /// The pipeline errored where a report was expected (load failure, + /// unsupported construct, validation error). + Error(String), +} + +impl Outcome { + pub fn passed(&self) -> bool { + matches!(self, Outcome::Pass) + } +} + +/// Load the data graph into an ephemeral memory ledger, validate against the +/// shapes graph (inline Turtle), compare against the expectation. +pub async fn run_case(case: &TestCase) -> Outcome { + match execute(case).await { + Ok(outcome) => outcome, + Err(e) => match case.expect { + // A processing failure is the expected outcome of sht:Failure tests. + Expectation::Failure => Outcome::Pass, + _ => Outcome::Error(format!("{e:#}")), + }, + } +} + +async fn execute(case: &TestCase) -> Result { + let data = read_with_base(&case.data_path)?; + let shapes = read_with_base(&case.shapes_path)?; + + let fluree = FlureeBuilder::memory().build_memory(); + let alias = "w3c/shacl:main"; + fluree.create_ledger(alias).await?; + disable_staging_shacl(&fluree, alias).await?; + + fluree + .graph(alias) + .transact() + .insert_turtle(&data) + .commit() + .await + .context("loading data graph")?; + + let options = ValidateOptions { + graph: None, + shapes: ShapesSource::InlineTurtle(shapes), + include_attached: false, + }; + let report: ValidateReport = fluree + .validate_ledger(alias, &options) + .await + .context("validating")?; + + match &case.expect { + Expectation::Failure => Ok(Outcome::Fail( + "expected a validation failure, got a report".to_string(), + )), + Expectation::Report { conforms, results } => { + match compare_report(*conforms, results, &report) { + None => Ok(Outcome::Pass), + Some(mismatch) => Ok(Outcome::Fail(mismatch.to_string())), + } + } + } +} + +/// Read a Turtle file, prepending `@base ` so relative IRIs +/// (including `<>` self-references) resolve the same way they do in the +/// manifest graph the expected report was parsed from. +fn read_with_base(path: &std::path::Path) -> Result { + let raw = + std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?; + Ok(format!("@base <{}> .\n{raw}", file_iri(path))) +} + +/// Test files embed shapes and (often deliberately violating) data in one +/// document — staging-time SHACL enforcement must not reject the load. +/// Same mechanism as `fluree validate`'s file mode. +async fn disable_staging_shacl(fluree: &Fluree, alias: &str) -> Result<()> { + let config_iri = fluree_db_core::graph_registry::config_graph_iri(alias); + let trig = format!( + r" + @prefix f: . + @prefix rdf: . + + GRAPH <{config_iri}> {{ + rdf:type f:LedgerConfig . + f:shaclDefaults . + f:shaclEnabled false . + }} + " + ); + fluree + .graph(alias) + .transact() + .upsert_turtle(&trig) + .commit() + .await?; + Ok(()) +} diff --git a/testsuite-shacl/tests/w3c_shacl.rs b/testsuite-shacl/tests/w3c_shacl.rs new file mode 100644 index 000000000..d9f3360f5 --- /dev/null +++ b/testsuite-shacl/tests/w3c_shacl.rs @@ -0,0 +1,111 @@ +//! W3C SHACL core test-suite runner. +//! +//! Env vars: +//! - `SHACL_CATEGORY=node` — run one category only +//! - `SHACL_TEST=minLength-001` — run tests whose name contains the string +//! - `W3C_REPORT_JSON=report.json` — write a machine-readable report +//! - `SHACL_STRICT=1` — fail the test on any non-pass (default: report only) + +use std::collections::BTreeMap; +use std::path::Path; + +use testsuite_shacl::{collect_tests, run_case, Outcome}; + +#[test] +fn shacl_core_w3c_testsuite() { + let manifest = Path::new("data-shapes/data-shapes-test-suite/tests/core/manifest.ttl"); + assert!( + manifest.exists(), + "data-shapes submodule missing — run: git submodule update --init testsuite-shacl/data-shapes" + ); + + let category_filter = std::env::var("SHACL_CATEGORY").ok(); + let test_filter = std::env::var("SHACL_TEST").ok(); + + let cases = collect_tests(manifest).expect("manifest walk"); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + + let mut per_category: BTreeMap = BTreeMap::new(); // (passed, total) + let mut failures: Vec<(String, String)> = Vec::new(); + let mut json_tests = Vec::new(); + let mut skipped = 0usize; + + for case in &cases { + if let Some(cat) = &category_filter { + if case.category != *cat { + skipped += 1; + continue; + } + } + if let Some(pat) = &test_filter { + if !case.name.contains(pat.as_str()) { + skipped += 1; + continue; + } + } + + let outcome = runtime.block_on(run_case(case)); + let entry = per_category.entry(case.category.clone()).or_default(); + entry.1 += 1; + + let (status, detail) = match &outcome { + Outcome::Pass => { + entry.0 += 1; + ("PASS", String::new()) + } + Outcome::Fail(reason) => ("FAIL", reason.clone()), + Outcome::Error(reason) => ("ERROR", reason.clone()), + }; + println!("{status:5} {}", case.name); + if !outcome.passed() { + println!(" {detail}"); + failures.push((case.name.clone(), detail.clone())); + } + json_tests.push(serde_json::json!({ + "name": case.name, + "category": case.category, + "approved": case.approved, + "status": status, + "detail": detail, + })); + } + + let total: usize = per_category.values().map(|(_, t)| t).sum(); + let passed: usize = per_category.values().map(|(p, _)| p).sum(); + + println!(); + println!("=== W3C SHACL Core — Per-Category Breakdown ==="); + for (cat, (p, t)) in &per_category { + println!(" {cat:20} {p:3}/{t:<3}"); + } + println!(); + println!( + "=== Test Summary === Total: {total} Passed: {passed} Failed: {} Skipped: {skipped} Rate: {:.1}%", + total - passed, + if total > 0 { + passed as f64 * 100.0 / total as f64 + } else { + 0.0 + } + ); + + if let Ok(path) = std::env::var("W3C_REPORT_JSON") { + let report = serde_json::json!({ + "total": total, + "passed": passed, + "failed": total - passed, + "pass_rate": format!("{:.1}%", if total > 0 { passed as f64 * 100.0 / total as f64 } else { 0.0 }), + "tests": json_tests, + }); + std::fs::write(&path, serde_json::to_string_pretty(&report).unwrap()) + .expect("write report"); + println!("JSON report written to {path}"); + } + + if std::env::var("SHACL_STRICT").is_ok() && !failures.is_empty() { + panic!("{} W3C SHACL test(s) failed", failures.len()); + } +} From 650f31de11db7a5441285c9f92eb17ac8c5fd30c Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 17:45:19 -0400 Subject: [PATCH 12/17] fix(shacl): spec-shape results for node-shape constraints; strict sh:closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per spec, a node shape's value node is the focus node itself — its constraint results now carry sh:value = focus: structural constraints (sh:node/not/or/xone; sh:and collapses to one result per focus with aggregated messages instead of propagating nested results), direct value constraints (defaulting when the validator produced no value, and reporting the focus node rather than its stringified IRI for the string facets), and sh:closed results carry the offending flake's datatype/language. sh:closed no longer implicitly ignores rdf:type — the spec never grants that (W3C core/node/closed-001 pins it); shapes targeting typed instances must declare sh:ignoredProperties (rdf:type), as the spec's own examples do. Tests and cookbook updated accordingly. W3C SHACL core: 46.9% -> 55.1% (node 5/32 -> 13/32); no category regressed. Remaining node failures are the literal sh:targetNode gap. --- docs/guides/cookbook-shacl.md | 2 +- fluree-db-api/src/shacl_tests.rs | 4 +- fluree-db-shacl/src/validate.rs | 84 ++++++++++++++++++-------------- 3 files changed, 51 insertions(+), 39 deletions(-) diff --git a/docs/guides/cookbook-shacl.md b/docs/guides/cookbook-shacl.md index 1d3af0172..f8d0cb3d9 100644 --- a/docs/guides/cookbook-shacl.md +++ b/docs/guides/cookbook-shacl.md @@ -373,7 +373,7 @@ ex:StrictPersonShape a sh:NodeShape ; sh:property [ sh:path schema:name ; sh:minCount 1 ] . ``` -A closed shape forbids any property not explicitly declared (or listed in `sh:ignoredProperties`). `rdf:type` is implicitly ignored per the SHACL spec. +A closed shape forbids any property not explicitly declared (or listed in `sh:ignoredProperties`). Per the SHACL spec, `rdf:type` is **not** implicitly ignored — a closed shape with `sh:targetClass` (whose instances necessarily carry `rdf:type`) must list it in `sh:ignoredProperties`, as above. ## RDFS subclass reasoning for `sh:class` diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index e00ca9593..038026e3f 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -484,6 +484,7 @@ async fn shacl_closed_constraint() { "@type": "sh:NodeShape", "sh:targetClass": {"@id": "ex:Person"}, "sh:closed": true, + "sh:ignoredProperties": { "@list": [{"@id": "rdf:type"}] }, "sh:property": [ { "@id": "ex:pshape1", @@ -2921,12 +2922,13 @@ async fn shacl_ignored_properties_turtle_list() { let context = shacl_context(); let shapes_ttl = r" @prefix sh: . + @prefix rdf: . @prefix ex: . ex:AuditedShape a sh:NodeShape ; sh:targetClass ex:Audited ; sh:closed true ; - sh:ignoredProperties ( ex:internal ex:auditLog ) ; + sh:ignoredProperties ( rdf:type ex:internal ex:auditLog ) ; sh:property [ sh:path ex:label ] . "; diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index d37918f55..a9264db22 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -695,7 +695,9 @@ async fn validate_node_value_constraints<'a>( constraint_component: violation.constraint.component(), severity: shape.severity, message: shape.message.clone().unwrap_or(violation.message), - value: violation.value, + value: violation + .value + .or_else(|| Some(FlakeValue::Ref(focus_node.clone()))), value_datatype: None, value_lang: None, graph_id: None, @@ -745,13 +747,17 @@ async fn focus_value_violations<'a>( ); } Constraint::Pattern(..) | Constraint::MinLength(_) | Constraint::MaxLength(_) => { + // String facets evaluate STR(focus), but the reported value + // node is the focus itself — not its stringified IRI. let effective = stringify_iri_values(db, &values); - violations.extend(validate_constraint( - constraint, - &effective, - &datatypes, - &[None], - )?); + violations.extend( + validate_constraint(constraint, &effective, &datatypes, &[None])? + .into_iter() + .map(|mut v| { + v.value = Some(FlakeValue::Ref(focus_node.clone())); + v + }), + ); } _ => { violations.extend(validate_constraint( @@ -810,15 +816,13 @@ fn validate_structural_constraint<'a>( .filter_map(|ps| ps.path.as_predicate()) .collect(); - // Per SHACL spec section 4.8.1, rdf:type is implicitly ignored - let rdf_type_sid = Sid::new(RDF, rdf_names::TYPE); - let mut effective_ignored = ignored_properties.clone(); - effective_ignored.insert(rdf_type_sid); - + // Per spec, rdf:type is NOT implicitly ignored — shapes + // must declare `sh:ignoredProperties (rdf:type)` (W3C + // core/node/closed-001 pins this). // Check each property on the node for flake in node_flakes { let prop = &flake.p; - if !declared_properties.contains(prop) && !effective_ignored.contains(prop) + if !declared_properties.contains(prop) && !ignored_properties.contains(prop) { results.push(ValidationResult { focus_node: focus_node.clone(), @@ -831,8 +835,8 @@ fn validate_structural_constraint<'a>( format!("Property {} not allowed by closed shape", prop.name) }), value: Some(flake.o.clone()), - value_datatype: None, - value_lang: None, + value_datatype: Some(flake.dt.clone()), + value_lang: flake.m.as_ref().and_then(|m| m.lang.clone()), graph_id: None, }); } @@ -869,7 +873,7 @@ fn validate_structural_constraint<'a>( nested_shape.id.name ) }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), value_datatype: None, value_lang: None, graph_id: None, @@ -909,7 +913,7 @@ fn validate_structural_constraint<'a>( nested_shape.id.name ) }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), value_datatype: None, value_lang: None, graph_id: None, @@ -918,7 +922,11 @@ fn validate_structural_constraint<'a>( } NodeConstraint::And(nested_shapes) => { - // sh:and - ALL nested shapes must match (no violations) + // sh:and - ALL nested shapes must match (no violations). + // Per spec, a failed conjunction produces ONE result per value + // node (= the focus node) with sh:value = focus; the nested + // violations' messages are aggregated for diagnostics. + let mut failure_messages = Vec::new(); for nested in nested_shapes { let nested_results = validate_nested_shape( db, @@ -930,27 +938,29 @@ fn validate_structural_constraint<'a>( active, ) .await?; - // Include violations from the nested shape for r in nested_results { if r.severity == Severity::Violation { - results.push(ValidationResult { - focus_node: focus_node.clone(), - result_path: r.result_path, - source_shape: parent_shape.id.clone(), - source_constraint: None, - constraint_component: sh_vocab::AND_CONSTRAINT_COMPONENT, - severity: parent_shape.severity, - message: parent_shape.message.clone().unwrap_or_else(|| { - format!("sh:and constraint - {}", r.message) - }), - value: r.value, - value_datatype: r.value_datatype, - value_lang: r.value_lang, - graph_id: None, - }); + failure_messages.push(r.message); } } } + if !failure_messages.is_empty() { + results.push(ValidationResult { + focus_node: focus_node.clone(), + result_path: None, + source_shape: parent_shape.id.clone(), + source_constraint: None, + constraint_component: sh_vocab::AND_CONSTRAINT_COMPONENT, + severity: parent_shape.severity, + message: parent_shape.message.clone().unwrap_or_else(|| { + format!("sh:and constraint - {}", failure_messages.join("; ")) + }), + value: Some(FlakeValue::Ref(focus_node.clone())), + value_datatype: None, + value_lang: None, + graph_id: None, + }); + } } NodeConstraint::Or(nested_shapes) => { @@ -998,7 +1008,7 @@ fn validate_structural_constraint<'a>( all_messages.join("; ") ) }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), value_datatype: None, value_lang: None, graph_id: None, @@ -1042,7 +1052,7 @@ fn validate_structural_constraint<'a>( message: parent_shape.message.clone().unwrap_or_else(|| { "Node does not conform to any shape in sh:xone".to_string() }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), value_datatype: None, value_lang: None, graph_id: None, @@ -1062,7 +1072,7 @@ fn validate_structural_constraint<'a>( conforming_shapes.join(", ") ) }), - value: None, + value: Some(FlakeValue::Ref(focus_node.clone())), value_datatype: None, value_lang: None, graph_id: None, From ca07049d0e1926c050d390fcb65babd2775fb399 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 18:15:52 -0400 Subject: [PATCH 13/17] feat(shacl): literal sh:targetNode support (focus nodes can be literals) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Literal targets compile into TargetType::LiteralNode (value + datatype + language from the flake) and validate through a dedicated path: value constraints evaluate against the literal directly (the value node IS the focus), structural constraints test the literal's conformance to the nested shapes, and property shapes see an empty value set (only the minimum-count constraints can fire). ValidationResult.focus_node becomes a FocusNode enum (Node(Sid) | Literal). The report layer emits literal focus nodes as JSON-LD value forms — plain strings wrap as {"@value": ...} so they stay distinct from IRI strings — and ReportResult.focus_node is now a JsonValue (string for IRIs/bnodes; wire format unchanged for those). Also registers subjects explicitly typed sh:NodeShape during compile: a shape whose only markers are the type + value constraints (e.g. an implicit-class-target shape carrying just sh:in) was previously never created at all. W3C SHACL core: 55.1% -> 69.4% (node 13/32 -> 25/32, property 32/38). Remaining node failures are documented model differences (numeric value identity, ill-formed literal ingest, list dedup) or future work (sh:equals per-value reporting, xsd:dateTime timezone partial order) — see docs/contributing/shacl-compliance.md. --- docs/contributing/shacl-compliance.md | 17 +- docs/guides/cookbook-shacl.md | 1 - fluree-db-api/src/shacl_tests.rs | 58 +++++ fluree-db-api/src/tx.rs | 6 +- fluree-db-api/src/validate.rs | 53 +++-- fluree-db-cli/src/commands/validate.rs | 5 +- fluree-db-shacl/src/cache.rs | 3 + fluree-db-shacl/src/compile.rs | 51 +++++ fluree-db-shacl/src/lib.rs | 8 +- fluree-db-shacl/src/validate.rs | 288 ++++++++++++++++++++++--- fluree-db-transact/src/stage.rs | 7 +- testsuite-shacl/src/compare.rs | 24 ++- testsuite-shacl/src/manifest.rs | 12 ++ 13 files changed, 463 insertions(+), 70 deletions(-) diff --git a/docs/contributing/shacl-compliance.md b/docs/contributing/shacl-compliance.md index e9ab1c22e..05cc03d22 100644 --- a/docs/contributing/shacl-compliance.md +++ b/docs/contributing/shacl-compliance.md @@ -59,14 +59,23 @@ match exactly. Failures are expected in these areas (honest gaps, not harness bugs): -- `sh:targetNode` with **literal** targets — unsupported; dominates the - `node` category, whose tests target literals directly. -- Node-shape constraint results don't set `sh:value` to the focus node - (the spec requires `sh:value` = focus for node-shape constraints). - Complex `sh:resultPath` serialization (sequence/inverse path structures) is omitted from reports. +- `sh:equals` reports one aggregate violation instead of one per + missing/extra value. +- `xsd:dateTime` range comparison does not implement the spec's + timezone partial order (`minInclusive-002/003`). - `sh:sparql` (the whole `sparql/` section of the suite is not wired in). +A few expectations are unachievable by design — Fluree's value-centric +store differs from RDF term identity: + +- `4` and `4.0` are the same value in flake space, so they collapse into + one `sh:targetNode` target (`minExclusive-001` / `maxExclusive-001`). +- Ill-formed typed literals (`"aldi"^^xsd:integer`) are rejected at ingest + and can never be present to validate (`datatype-001`). +- Duplicate values in an RDF list collapse (`xone-duplicate`). + Track the current pass rate with `make count` before and after engine changes; regressions in previously-passing categories are the signal to watch. diff --git a/docs/guides/cookbook-shacl.md b/docs/guides/cookbook-shacl.md index f8d0cb3d9..eb2cd839a 100644 --- a/docs/guides/cookbook-shacl.md +++ b/docs/guides/cookbook-shacl.md @@ -625,7 +625,6 @@ with per-result constraint-component IRIs, severities, and messages, plus ## Not yet supported -- `sh:targetNode` with a literal value — only IRI/blank-node targets are compiled. - `sh:sparql` (SPARQL-based constraints). These are tracked in the SHACL compliance effort. diff --git a/fluree-db-api/src/shacl_tests.rs b/fluree-db-api/src/shacl_tests.rs index 038026e3f..9f7a67607 100644 --- a/fluree-db-api/src/shacl_tests.rs +++ b/fluree-db-api/src/shacl_tests.rs @@ -4279,3 +4279,61 @@ async fn validate_report_value_term_fidelity() { "{turtle}" ); } + +#[tokio::test] +async fn validate_report_literal_target_nodes() { + // sh:targetNode with literal targets: the focus is the literal itself, + // validated directly against the shape's value constraints and reported + // with a value-object focus in the report. + let fluree = FlureeBuilder::memory().build_memory(); + let ledger = fluree + .create_ledger("shacl/validate-literal-target:main") + .await + .unwrap(); + let view = crate::ledger_view::LedgerView::from_state(&ledger); + + let shapes_turtle = r#" + @prefix sh: . + @prefix ex: . + ex:MinLengthShape a sh:NodeShape ; + sh:minLength 4 ; + sh:targetNode "Hel" ; + sh:targetNode "Hello" ; + sh:targetNode "Hell"@en ; + sh:targetNode 123 . + "#; + let options = crate::validate::ValidateOptions { + shapes: crate::validate::ShapesSource::InlineTurtle(shapes_turtle.to_string()), + ..Default::default() + }; + let report = + crate::validate::validate_view(&view, "shacl/validate-literal-target:main", &options) + .await + .unwrap(); + + // "Hel" (3) and 123 (3) violate; "Hello", "Hell"@en conform. + assert_eq!(report.violation_count(), 2, "{:?}", report.results); + let focuses: Vec<&JsonValue> = report.results.iter().map(|r| &r.focus_node).collect(); + assert!( + focuses.contains(&&json!({"@value": "Hel"})), + "string literal focus reported as value object: {focuses:?}" + ); + assert!(focuses.contains(&&json!(123)), "{focuses:?}"); + // sh:value = the focus literal. Unlike the focus field (where a bare + // string would be ambiguous with an IRI), value strings stay native — + // IRIs are always {"@id"} objects there. + let values: Vec<&JsonValue> = report.results.iter().flat_map(|r| &r.value).collect(); + assert!(values.contains(&&json!("Hel")), "{values:?}"); + assert!(values.contains(&&json!(123)), "{values:?}"); + for result in &report.results { + assert_eq!( + result.constraint_component, + "http://www.w3.org/ns/shacl#MinLengthConstraintComponent" + ); + } + + // Turtle report renders literal focus nodes as literals. + let turtle = report.to_turtle(); + assert!(turtle.contains("sh:focusNode \"Hel\""), "{turtle}"); + assert!(turtle.contains("sh:focusNode 123"), "{turtle}"); +} diff --git a/fluree-db-api/src/tx.rs b/fluree-db-api/src/tx.rs index d10a24694..93947d3e1 100644 --- a/fluree-db-api/src/tx.rs +++ b/fluree-db-api/src/tx.rs @@ -630,11 +630,7 @@ fn format_violations(violations: &[fluree_db_shacl::ValidationResult]) -> String ); for (i, v) in violations.iter().enumerate() { let _ = writeln!(out, " {}. {}", i + 1, v.message); - let _ = writeln!( - out, - " Focus node: {}{}", - v.focus_node.namespace_code, v.focus_node.name - ); + let _ = writeln!(out, " Focus node: {}", v.focus_node); if let Some(path) = &v.result_path { let _ = writeln!(out, " Path: {}{}", path.namespace_code, path.name); } diff --git a/fluree-db-api/src/validate.rs b/fluree-db-api/src/validate.rs index a1f221e62..3a9a5df76 100644 --- a/fluree-db-api/src/validate.rs +++ b/fluree-db-api/src/validate.rs @@ -66,8 +66,10 @@ impl Default for ValidateOptions { /// One validation result with all identifiers resolved to IRIs. #[derive(Debug, Clone, serde::Serialize)] pub struct ReportResult { - /// The node that failed validation (`sh:focusNode`). - pub focus_node: String, + /// 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")] @@ -131,7 +133,11 @@ impl ValidateReport { .map(|r| { let mut obj = serde_json::Map::new(); obj.insert("@type".into(), json!("sh:ValidationResult")); - obj.insert("sh:focusNode".into(), json!({"@id": r.focus_node})); + let focus = match r.focus_node.as_str() { + Some(iri) => json!({"@id": iri}), + None => r.focus_node.clone(), + }; + obj.insert("sh:focusNode".into(), focus); if let Some(path) = &r.result_path { obj.insert("sh:resultPath".into(), json!({"@id": path})); } @@ -170,10 +176,11 @@ impl ValidateReport { out.push_str(&format!(" sh:conforms {}", self.conforms)); for r in &self.results { out.push_str(" ;\n sh:result [\n a sh:ValidationResult ;\n"); - out.push_str(&format!( - " sh:focusNode {} ;\n", - turtle_term(&r.focus_node) - )); + let focus_term = match r.focus_node.as_str() { + Some(iri) => turtle_term(iri), + None => turtle_value_term(&r.focus_node), + }; + out.push_str(&format!(" sh:focusNode {focus_term} ;\n")); if let Some(path) = &r.result_path { out.push_str(&format!(" sh:resultPath {} ;\n", turtle_term(path))); } @@ -454,7 +461,22 @@ pub async fn validate_view( .results .iter() .map(|r| ReportResult { - focus_node: resolve(&r.focus_node), + focus_node: match &r.focus_node { + fluree_db_shacl::FocusNode::Node(sid) => JsonValue::String(resolve(sid)), + fluree_db_shacl::FocusNode::Literal(lit) => { + match value_json( + &lit.value, + Some(&lit.datatype), + lit.lang.as_deref(), + &resolve, + ) { + // A bare string would be ambiguous with an IRI focus — + // wrap plain string literals as a value object. + JsonValue::String(s) => json!({"@value": s}), + other => other, + } + } + }, result_path: r.result_path.as_ref().map(&resolve), source_shape: resolve(&r.source_shape), source_constraint: r.source_constraint.as_ref().map(&resolve), @@ -472,11 +494,16 @@ pub async fn validate_view( }) .collect(); results.sort_by(|a, b| { - (&a.focus_node, &a.constraint_component, &a.message).cmp(&( - &b.focus_node, - &b.constraint_component, - &b.message, - )) + ( + a.focus_node.to_string(), + &a.constraint_component, + &a.message, + ) + .cmp(&( + b.focus_node.to_string(), + &b.constraint_component, + &b.message, + )) }); Ok(ValidateReport { diff --git a/fluree-db-cli/src/commands/validate.rs b/fluree-db-cli/src/commands/validate.rs index b00cb832d..bf11c3ffc 100644 --- a/fluree-db-cli/src/commands/validate.rs +++ b/fluree-db-cli/src/commands/validate.rs @@ -216,7 +216,10 @@ fn print_table(report: &ValidateReport) { for result in &report.results { let severity = short_iri(&result.severity); let component = short_iri(&result.constraint_component); - println!("{severity}: {}", result.focus_node); + match result.focus_node.as_str() { + Some(iri) => println!("{severity}: {iri}"), + None => println!("{severity}: {}", result.focus_node), + } if let Some(path) = &result.result_path { println!(" path: {path}"); } diff --git a/fluree-db-shacl/src/cache.rs b/fluree-db-shacl/src/cache.rs index 0adbcde36..b6f4f7f71 100644 --- a/fluree-db-shacl/src/cache.rs +++ b/fluree-db-shacl/src/cache.rs @@ -95,6 +95,9 @@ impl ShaclCache { by_target_node.entry(node.clone()).or_default().push(idx); } } + // Literal targets have no Sid to index; validate_all + // walks them straight off the shape's target list. + crate::compile::TargetType::LiteralNode(_) => {} crate::compile::TargetType::SubjectsOf(pred) => { by_target_subjects_of .entry(pred.clone()) diff --git a/fluree-db-shacl/src/compile.rs b/fluree-db-shacl/src/compile.rs index 35f916494..cb7ea31ba 100644 --- a/fluree-db-shacl/src/compile.rs +++ b/fluree-db-shacl/src/compile.rs @@ -23,6 +23,9 @@ pub enum TargetType { Class(Sid), /// sh:targetNode - specific node(s) Node(Vec), + /// sh:targetNode with literal targets — the focus "node" is a literal + /// value, validated directly against the shape's value constraints. + LiteralNode(Vec), /// sh:targetSubjectsOf - subjects of triples with this predicate SubjectsOf(Sid), /// sh:targetObjectsOf - objects of triples with this predicate @@ -31,6 +34,15 @@ pub enum TargetType { ImplicitClass(Sid), } +/// A literal `sh:targetNode` target: the value plus the datatype / language +/// needed to validate and report it faithfully. +#[derive(Debug, Clone, PartialEq)] +pub struct LiteralTarget { + pub value: FlakeValue, + pub datatype: Sid, + pub lang: Option, +} + /// Severity level for constraint violations #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Severity { @@ -303,6 +315,26 @@ impl ShapeCompiler { class_typed.extend(flakes.iter().map(|f| f.s.clone())); } + // Register subjects explicitly typed sh:NodeShape. A shape whose + // only markers are `rdf:type sh:NodeShape` plus value constraints + // (e.g. an implicit-class-target shape carrying just sh:in) would + // otherwise never be created — no target/property predicate ever + // calls get_or_create_shape for it. + let node_shape_type = Sid::new(SHACL, "NodeShape"); + let flakes = db + .range( + IndexType::Opst, + RangeTest::Eq, + RangeMatch::predicate_object( + rdf_type.clone(), + FlakeValue::Ref(node_shape_type), + ), + ) + .await?; + for flake in &flakes { + compiler.get_or_create_shape(&flake.s); + } + // Expand rdf:first/rdf:rest lists referenced by sh:in / sh:and / // sh:or / sh:xone / sh:ignoredProperties. Run after each graph so // that lists whose head lives in this graph can resolve — a list @@ -557,6 +589,25 @@ impl ShapeCompiler { if !found { shape.targets.push(TargetType::Node(vec![node.clone()])); } + } else { + // Literal target node: the focus is the literal itself. + let lit = LiteralTarget { + value: flake.o.clone(), + datatype: flake.dt.clone(), + lang: flake.m.as_ref().and_then(|m| m.lang.clone()), + }; + let shape = self.get_or_create_shape(&flake.s); + let mut found = false; + for target in &mut shape.targets { + if let TargetType::LiteralNode(lits) = target { + lits.push(lit.clone()); + found = true; + break; + } + } + if !found { + shape.targets.push(TargetType::LiteralNode(vec![lit])); + } } } name if name == predicates::TARGET_SUBJECTS_OF => { diff --git a/fluree-db-shacl/src/lib.rs b/fluree-db-shacl/src/lib.rs index 09489b096..f0519bcfb 100644 --- a/fluree-db-shacl/src/lib.rs +++ b/fluree-db-shacl/src/lib.rs @@ -77,8 +77,6 @@ //! //! # Not Yet Supported //! -//! - `sh:targetNode` with a literal value — only IRI/blank-node targets are -//! compiled (focus nodes are subject ids throughout the engine). //! - `sh:sparql` (SPARQL-based constraints). //! //! # Example @@ -107,11 +105,13 @@ pub mod path; pub mod validate; pub use cache::{ShaclCache, ShaclCacheKey}; -pub use compile::{CompiledShape, PropertyShape, Severity, ShapeId, TargetType}; +pub use compile::{CompiledShape, LiteralTarget, PropertyShape, Severity, ShapeId, TargetType}; pub use constraints::Constraint; pub use error::{Result, ShaclError}; pub use path::PropertyPath; -pub use validate::{CrossLedgerMembership, ShaclEngine, ValidationReport, ValidationResult}; +pub use validate::{ + CrossLedgerMembership, FocusNode, ShaclEngine, ValidationReport, ValidationResult, +}; /// SHACL namespace code (re-exported from fluree-vocab) pub use fluree_vocab::namespaces::SHACL; diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index a9264db22..cbc1eb554 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -368,6 +368,25 @@ impl ShaclEngine { .await?; all_results.extend(results); } + + // Literal sh:targetNode targets: the focus is the literal itself. + for target in &shape.targets { + if let crate::compile::TargetType::LiteralNode(lits) = target { + for lit in lits { + let active = ActiveShapeChecks::default(); + let results = validate_literal_focus( + db, + lit, + shape, + &all_shapes, + Some(class_ctx), + &active, + ) + .await?; + all_results.extend(results); + } + } + } } let conforms = all_results @@ -516,10 +535,10 @@ impl ShaclEngine { if let Some(ref path) = r.result_path { format!( "Node {}: property {}: {}", - r.focus_node.name, path.name, r.message + r.focus_node, path.name, r.message ) } else { - format!("Node {}: {}", r.focus_node.name, r.message) + format!("Node {}: {}", r.focus_node, r.message) } }) .collect(); @@ -573,6 +592,9 @@ async fn get_focus_nodes( TargetType::Node(nodes) => { focus_nodes.extend(nodes.iter().cloned()); } + // Literal targets are not graph nodes — validated directly via + // `validate_literal_focus` in `validate_all_with_membership`. + TargetType::LiteralNode(_) => {} TargetType::SubjectsOf(predicate) => { // Find all subjects that have this predicate let flakes = db @@ -673,6 +695,180 @@ fn validate_shape<'a>( }) } +/// Validate a literal `sh:targetNode` target against a shape. +/// +/// A literal has no graph presence: value constraints evaluate against the +/// literal directly (the value node IS the focus), structural constraints +/// test the literal's conformance to the nested shapes, and property shapes +/// see an empty value set (so only `sh:minCount` / `sh:qualifiedMinCount` +/// can fire). +async fn validate_literal_focus<'a>( + db: GraphDbRef<'a>, + lit: &crate::compile::LiteralTarget, + shape: &'a CompiledShape, + all_shapes: &'a [&'a CompiledShape], + class_ctx: Option>, + active: &'a ActiveShapeChecks, +) -> Result> { + let mut results = Vec::new(); + let focus = FocusNode::Literal(lit.clone()); + let values = [lit.value.clone()]; + let datatypes = [lit.datatype.clone()]; + let langs = [lit.lang.clone()]; + + let mut push = |component: &'static str, message: String| { + results.push(ValidationResult { + focus_node: focus.clone(), + result_path: None, + source_shape: shape.id.clone(), + source_constraint: None, + constraint_component: component, + severity: shape.severity, + message: shape.message.clone().unwrap_or(message), + value: Some(lit.value.clone()), + value_datatype: Some(lit.datatype.clone()), + value_lang: lit.lang.clone(), + graph_id: None, + }); + }; + + // Direct value constraints: the value node is the literal itself. + for constraint in &shape.node_constraints { + let violations = match constraint { + Constraint::Class(expected_class) => { + validate_class_constraint(db, &values, expected_class, class_ctx).await? + } + _ => validate_constraint(constraint, &values, &datatypes, &langs)?, + }; + for violation in violations { + push(violation.constraint.component(), violation.message); + } + } + + // Structural constraints: test the literal against the nested shapes. + for constraint in &shape.structural_constraints { + let conforms_to = |nested: &'a std::sync::Arc| { + check_value_against_nested_shape( + db, + &lit.value, + Some(&lit.datatype), + lit.lang.as_deref(), + nested, + shape, + all_shapes, + class_ctx, + active, + ) + }; + match constraint { + // A literal has no properties to close over. + NodeConstraint::Closed { .. } => {} + NodeConstraint::Node(nested) => { + if !conforms_to(nested).await? { + push( + sh_vocab::NODE_CONSTRAINT_COMPONENT, + format!( + "Node does not conform to shape {} (sh:node)", + nested.id.name + ), + ); + } + } + NodeConstraint::Not(nested) => { + if conforms_to(nested).await? { + push( + sh_vocab::NOT_CONSTRAINT_COMPONENT, + format!( + "Node conforms to shape {} which is not allowed (sh:not)", + nested.id.name + ), + ); + } + } + NodeConstraint::And(nested_shapes) => { + let mut all_conform = true; + for nested in nested_shapes { + if !conforms_to(nested).await? { + all_conform = false; + } + } + if !all_conform { + push( + sh_vocab::AND_CONSTRAINT_COMPONENT, + "Node does not conform to all shapes in sh:and".to_string(), + ); + } + } + NodeConstraint::Or(nested_shapes) => { + let mut any_conforms = nested_shapes.is_empty(); + for nested in nested_shapes { + if conforms_to(nested).await? { + any_conforms = true; + break; + } + } + if !any_conforms { + push( + sh_vocab::OR_CONSTRAINT_COMPONENT, + "Node does not conform to any shape in sh:or".to_string(), + ); + } + } + NodeConstraint::Xone(nested_shapes) => { + let mut conforming = 0usize; + for nested in nested_shapes { + if conforms_to(nested).await? { + conforming += 1; + } + } + if conforming != 1 { + push( + sh_vocab::XONE_CONSTRAINT_COMPONENT, + format!( + "Node conforms to {conforming} shapes in sh:xone (must be exactly 1)" + ), + ); + } + } + } + } + + // Property shapes: any path over a literal yields no values, so only the + // minimum-count constraints can fire. + for prop_shape in &shape.property_shapes { + for constraint in &prop_shape.constraints { + let fired = match constraint { + Constraint::MinCount(min) => *min > 0, + Constraint::QualifiedValueShape { + min_count: Some(min), + .. + } => *min > 0, + _ => continue, + }; + if fired { + results.push(ValidationResult { + focus_node: focus.clone(), + result_path: prop_shape.path.as_predicate().cloned(), + source_shape: shape.id.clone(), + source_constraint: Some(prop_shape.id.clone()), + constraint_component: constraint.component(), + severity: prop_shape.severity, + message: prop_shape + .message + .clone() + .unwrap_or_else(|| "Expected at least 1 value(s) but found 0".to_string()), + value: None, + value_datatype: None, + value_lang: None, + graph_id: None, + }); + } + } + } + + Ok(results) +} + /// Validate value constraints declared directly on a node shape (no `sh:path`) /// against the focus node itself. Per spec, a node shape's value-node set is /// exactly the focus node, so per-value constraints (`sh:in`, `sh:hasValue`, @@ -688,7 +884,7 @@ async fn validate_node_value_constraints<'a>( Ok(violations .into_iter() .map(|violation| ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: shape.id.clone(), source_constraint: None, @@ -825,7 +1021,7 @@ fn validate_structural_constraint<'a>( if !declared_properties.contains(prop) && !ignored_properties.contains(prop) { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: Some(prop.clone()), source_shape: parent_shape.id.clone(), source_constraint: None, @@ -861,7 +1057,7 @@ fn validate_structural_constraint<'a>( .any(|r| r.severity == Severity::Violation); if has_violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, @@ -901,7 +1097,7 @@ fn validate_structural_constraint<'a>( .any(|r| r.severity == Severity::Violation); if !has_violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, @@ -946,7 +1142,7 @@ fn validate_structural_constraint<'a>( } if !failure_messages.is_empty() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, @@ -996,7 +1192,7 @@ fn validate_structural_constraint<'a>( if !any_conforms && !nested_shapes.is_empty() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, @@ -1043,7 +1239,7 @@ fn validate_structural_constraint<'a>( if conforming_count == 0 { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, @@ -1059,7 +1255,7 @@ fn validate_structural_constraint<'a>( }); } else if conforming_count > 1 { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: None, @@ -1113,7 +1309,7 @@ fn validate_nested_shape<'a>( // Shape not found and no inline constraints — treat as unresolved. // Return a violation to prevent sh:or from being trivially true. return Ok(vec![ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), @@ -1139,7 +1335,7 @@ fn validate_nested_shape<'a>( .await?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), @@ -1159,7 +1355,7 @@ fn validate_nested_shape<'a>( // A path that never compiled surfaces as a violation on this member. if let Some(reason) = path.unresolvable_reason() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), @@ -1228,7 +1424,7 @@ fn validate_nested_shape<'a>( if source_values != target_values { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), @@ -1253,7 +1449,7 @@ fn validate_nested_shape<'a>( .await?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), @@ -1322,7 +1518,7 @@ fn validate_nested_shape<'a>( let above = max_count.map(|max| conforming > max).unwrap_or(false); if below || above { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), @@ -1354,7 +1550,7 @@ fn validate_nested_shape<'a>( validate_constraint(constraint, &effective, &datatypes, &langs)?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), @@ -1379,7 +1575,7 @@ fn validate_nested_shape<'a>( validate_constraint(constraint, &values, &datatypes, &langs)?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: result_path.clone(), source_shape: parent_shape.id.clone(), source_constraint: Some(nested.id.clone()), @@ -1439,7 +1635,7 @@ async fn validate_property_shape<'a>( // actually targets) rather than as a ledger-wide compile failure. if let Some(reason) = prop_shape.path.unresolvable_reason() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: None, source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1508,7 +1704,7 @@ async fn validate_property_shape<'a>( ); for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1533,7 +1729,7 @@ async fn validate_property_shape<'a>( validate_class_constraint(db, &values, expected_class, class_ctx).await?; for violation in class_violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1626,7 +1822,7 @@ async fn validate_property_shape<'a>( } for (message, component) in qualified_messages { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1648,7 +1844,7 @@ async fn validate_property_shape<'a>( let violations = validate_constraint(constraint, &effective, &datatypes, &langs)?; for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1674,7 +1870,7 @@ async fn validate_property_shape<'a>( for violation in violations { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1771,7 +1967,7 @@ async fn validate_property_value_structural_constraint<'a>( if !any_conforms && !nested_shapes.is_empty() { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1812,7 +2008,7 @@ async fn validate_property_value_structural_constraint<'a>( .await?; if !conforms { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1860,7 +2056,7 @@ async fn validate_property_value_structural_constraint<'a>( if conforming_count == 0 { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1876,7 +2072,7 @@ async fn validate_property_value_structural_constraint<'a>( }); } else if conforming_count > 1 { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1914,7 +2110,7 @@ async fn validate_property_value_structural_constraint<'a>( .await?; if !conforms { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -1953,7 +2149,7 @@ async fn validate_property_value_structural_constraint<'a>( .await?; if conforms { results.push(ValidationResult { - focus_node: focus_node.clone(), + focus_node: FocusNode::Node(focus_node.clone()), result_path: prop_shape.path.as_predicate().cloned(), source_shape: parent_shape.id.clone(), source_constraint: Some(prop_shape.id.clone()), @@ -2676,11 +2872,41 @@ impl ValidationReport { } } +/// The node (or literal, for literal `sh:targetNode` targets) a validation +/// result is about. +#[derive(Debug, Clone, PartialEq)] +pub enum FocusNode { + /// An IRI or blank node in the graph. + Node(Sid), + /// A literal target from `sh:targetNode` — validated directly, since a + /// literal has no graph presence to probe. + Literal(crate::compile::LiteralTarget), +} + +impl FocusNode { + /// The Sid when the focus is a graph node. + pub fn as_sid(&self) -> Option<&Sid> { + match self { + FocusNode::Node(sid) => Some(sid), + FocusNode::Literal(_) => None, + } + } +} + +impl std::fmt::Display for FocusNode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FocusNode::Node(sid) => write!(f, "{}{}", sid.namespace_code, sid.name), + FocusNode::Literal(lit) => write!(f, "{}", lit.value), + } + } +} + /// Individual validation result #[derive(Debug, Clone)] pub struct ValidationResult { /// The focus node that was validated - pub focus_node: Sid, + pub focus_node: FocusNode, /// The property path (if property constraint) pub result_path: Option, /// The shape that produced this result diff --git a/fluree-db-transact/src/stage.rs b/fluree-db-transact/src/stage.rs index dd57d8340..87a573e91 100644 --- a/fluree-db-transact/src/stage.rs +++ b/fluree-db-transact/src/stage.rs @@ -2454,12 +2454,7 @@ fn format_shacl_report(report: &ValidationReport) -> String { .enumerate() { writeln!(&mut output, " {}. {}", i + 1, result.message).ok(); - writeln!( - &mut output, - " Focus node: {}{}", - result.focus_node.namespace_code, result.focus_node.name - ) - .ok(); + writeln!(&mut output, " Focus node: {}", result.focus_node).ok(); if let Some(path) = &result.result_path { writeln!( &mut output, diff --git a/testsuite-shacl/src/compare.rs b/testsuite-shacl/src/compare.rs index 3d0ed2473..df2307f23 100644 --- a/testsuite-shacl/src/compare.rs +++ b/testsuite-shacl/src/compare.rs @@ -46,7 +46,8 @@ pub fn compare_report( None } else { Some(Mismatch(format!( - "results do not match expected set (actual: {})", + "results do not match expected set (expected: {:?} | actual: {})", + expected, summarize(&actual.results), ))) } @@ -69,12 +70,25 @@ fn match_all(expected: &[ExpectedResult], actual: &[ReportResult], used: &mut [b } fn matches(exp: &ExpectedResult, act: &ReportResult) -> bool { - // Focus node: actual is an IRI string, or a skolemized blank-node label. + // Focus node: a JSON string for IRIs / skolemized blank-node labels, + // or a JSON-LD value form for literal sh:targetNode targets. let focus_ok = match &exp.focus { TermPat::Absent | TermPat::Any => true, - TermPat::Json(j) => { - !act.focus_node.starts_with("_:") && *j == json!({"@id": act.focus_node}) - } + TermPat::Json(j) => match act.focus_node.as_str() { + Some(s) if s.starts_with("_:") => false, + Some(s) => *j == json!({"@id": s}), + // Literal focus: a bare {"@value": s} wraps a plain string to + // keep it distinct from an IRI — unwrap it for comparison. + None => { + let unwrapped = act + .focus_node + .as_object() + .filter(|o| o.len() == 1) + .and_then(|o| o.get("@value")) + .unwrap_or(&act.focus_node); + *j == *unwrapped + } + }, }; if !focus_ok { return false; diff --git a/testsuite-shacl/src/manifest.rs b/testsuite-shacl/src/manifest.rs index 4ddf3f289..c751f1212 100644 --- a/testsuite-shacl/src/manifest.rs +++ b/testsuite-shacl/src/manifest.rs @@ -215,11 +215,23 @@ fn literal_json( if let Some(lang) = lang { return json!({"@value": value.lexical(), "@language": lang}); } + // The Turtle parser may keep explicitly-typed literals as lexical + // strings (`"true"^^xsd:boolean` → String("true")) — normalize the + // JSON-native XSD types the same way the report core does. match (value, dt_iri.strip_prefix(ns::XSD)) { (LV::String(s), Some("string")) => json!(s.to_string()), (LV::Boolean(b), Some("boolean")) => json!(b), (LV::Integer(i), Some("integer")) => json!(i), (LV::Double(d), Some("double")) => json!(d), + (LV::String(s), Some("boolean")) if s.parse::().is_ok() => { + json!(s.parse::().unwrap()) + } + (LV::String(s), Some("integer")) if s.parse::().is_ok() => { + json!(s.parse::().unwrap()) + } + (LV::String(s), Some("double")) if s.parse::().is_ok() => { + json!(s.parse::().unwrap()) + } _ => json!({"@value": value.lexical(), "@type": dt_iri}), } } From 1a518748a69d029c5eb2559e1766dce2266e5ab0 Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 19:15:39 -0400 Subject: [PATCH 14/17] fix(shacl): standalone targeted property shapes; sequence-first path reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A property shape carrying its own targets (ex:S a sh:PropertyShape ; sh:path ...; sh:targetNode ...) with no wrapping node shape never compiled — finalize only merged a shape's own entry when it was path-less. The shape now attaches its own path-bearing entry and validates its focus nodes directly; this is the shape every W3C path test uses. Path resolution reads a bare rdf:first list before the operator keys, so an ill-formed node carrying both a sequence structure and sh:inversePath resolves as the sequence (path-strange-001/002). Harness: a list-valued sh:resultPath in an expected report is a complex path structure — matched leniently like blank-node paths instead of being misread as its first element. W3C SHACL core: 69.4% -> 78.6% (path 2/13 -> 12/13). uniqueLang-002 flips pass->fail honestly: it passed only because the shape never compiled; sh:uniqueLang "1"^^xsd:boolean must be ignored per spec but canonicalizes to true in Fluree's value space — documented as a model difference alongside path-complex-002's duplicate-list-entry collapse. --- docs/contributing/shacl-compliance.md | 9 +++++++-- fluree-db-shacl/src/compile.rs | 13 ++++++++++++- fluree-db-shacl/src/path.rs | 25 ++++++++++++++----------- testsuite-shacl/src/manifest.rs | 12 +++++++++++- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs/contributing/shacl-compliance.md b/docs/contributing/shacl-compliance.md index 05cc03d22..7322c991e 100644 --- a/docs/contributing/shacl-compliance.md +++ b/docs/contributing/shacl-compliance.md @@ -71,10 +71,15 @@ A few expectations are unachievable by design — Fluree's value-centric store differs from RDF term identity: - `4` and `4.0` are the same value in flake space, so they collapse into - one `sh:targetNode` target (`minExclusive-001` / `maxExclusive-001`). + one `sh:targetNode` target (`minExclusive-001` / `maxExclusive-001`); + likewise `"1"^^xsd:boolean` canonicalizes to `true`, so a spec-pedantic + `sh:uniqueLang "1"^^xsd:boolean` (which must be ignored — only the term + `true` activates the component) is indistinguishable from `true` and + fires (`uniqueLang-002`). - Ill-formed typed literals (`"aldi"^^xsd:integer`) are rejected at ingest and can never be present to validate (`datatype-001`). -- Duplicate values in an RDF list collapse (`xone-duplicate`). +- Duplicate values in an RDF list collapse (`xone-duplicate`, + `path-complex-002`'s `( _:pinv _:pinv )` sequence). Track the current pass rate with `make count` before and after engine changes; regressions in previously-passing categories are the signal to diff --git a/fluree-db-shacl/src/compile.rs b/fluree-db-shacl/src/compile.rs index cb7ea31ba..59411f627 100644 --- a/fluree-db-shacl/src/compile.rs +++ b/fluree-db-shacl/src/compile.rs @@ -913,7 +913,18 @@ impl ShapeCompiler { for (id, data) in &shapes { // Resolve property shapes let mut prop_shapes = Vec::new(); - for ps_id in &data.property_shape_ids { + // A property shape can carry its own targets (`ex:S a + // sh:PropertyShape ; sh:path ... ; sh:targetNode ...`) with no + // wrapping node shape — the shape then validates its own focus + // nodes: attach its own path-bearing entry alongside any + // sh:property references. + let mut ps_ids: Vec<&Sid> = data.property_shape_ids.iter().collect(); + if ps_map.get(id).is_some_and(|own| own.path.is_some()) + && !data.property_shape_ids.contains(id) + { + ps_ids.push(id); + } + for ps_id in ps_ids { if let Some(ps_data) = ps_map.get(ps_id) { if ps_data.deactivated { continue; diff --git a/fluree-db-shacl/src/path.rs b/fluree-db-shacl/src/path.rs index 6b1aa3460..7e0858d40 100644 --- a/fluree-db-shacl/src/path.rs +++ b/fluree-db-shacl/src/path.rs @@ -195,6 +195,20 @@ pub fn resolve_sh_path<'a>( /// blank node) into a [`PropertyPath`]. fn resolve_path_node<'a>(db: GraphDbRef<'a>, node: &'a Sid) -> PathFuture<'a, PropertyPath> { Box::pin(async move { + // Bare RDF list → sequence path. Checked before the operator keys: + // an (ill-formed) node carrying both a list structure and an + // operator reads as the sequence, matching the W3C suite's + // path-strange-001/002 expectations. + let rdf_first = Sid::new(RDF, rdf_names::FIRST); + if has_object(db, node, &rdf_first).await? { + let members = resolve_rdf_list(db, node).await?; + match members.len() { + 0 => return Err(unsupported(node, "sh:path sequence list is empty")), + 1 => return Ok(members.into_iter().next().unwrap()), + _ => return Ok(PropertyPath::Sequence(members)), + } + } + // sh:inversePath — inverse of any path, rewritten into the AST // (inverse of a sequence = reversed sequence of inverses, etc.). if let Some(inner) = operand_path(db, node, &shacl(predicates::INVERSE_PATH)).await? { @@ -224,17 +238,6 @@ fn resolve_path_node<'a>(db: GraphDbRef<'a>, node: &'a Sid) -> PathFuture<'a, Pr } } - // Bare RDF list → sequence path. - let rdf_first = Sid::new(RDF, rdf_names::FIRST); - if has_object(db, node, &rdf_first).await? { - let members = resolve_rdf_list(db, node).await?; - match members.len() { - 0 => return Err(unsupported(node, "sh:path sequence list is empty")), - 1 => return Ok(members.into_iter().next().unwrap()), - _ => return Ok(PropertyPath::Sequence(members)), - } - } - // No path-expression structure → a plain predicate IRI. Ok(PropertyPath::Predicate(node.clone())) }) diff --git a/testsuite-shacl/src/manifest.rs b/testsuite-shacl/src/manifest.rs index c751f1212..1d770a2cb 100644 --- a/testsuite-shacl/src/manifest.rs +++ b/testsuite-shacl/src/manifest.rs @@ -179,9 +179,19 @@ fn parse_entry(graph: &Graph, entry_iri: &str, manifest_path: &Path) -> Result ExpectedResult { let get = |local: &str| object_for(graph, result, &format!("{}{local}", ns::SH)); + // A list-valued sh:resultPath (sequence/alternative path structure) is a + // complex path — matched leniently, like blank-node path structures. + let path_pred = format!("{}resultPath", ns::SH); + let path_is_list = graph.iter().any(|t| { + t.s == *result && t.p.as_iri() == Some(path_pred.as_str()) && t.list_index.is_some() + }); ExpectedResult { focus: term_pat(get("focusNode")), - path: term_pat(get("resultPath")), + path: if path_is_list { + TermPat::Any + } else { + term_pat(get("resultPath")) + }, severity: get("resultSeverity").and_then(|t| t.as_iri().map(String::from)), component: get("sourceConstraintComponent").and_then(|t| t.as_iri().map(String::from)), value: term_pat(get("value")), From 19c1a110219c5d4d9b01b512ec58edcabcd8cd9a Mon Sep 17 00:00:00 2001 From: bplatz Date: Thu, 2 Jul 2026 19:48:29 -0400 Subject: [PATCH 15/17] fix(shacl): spec conforms semantics; and-collapse; literal/subclass targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four spec-conformance fixes surfaced by the W3C suite: - sh:conforms is true iff the report has NO results (warnings and infos included), per spec §3.4.2 — previously only violations counted. The two transaction-enforcement gates now check violation_count() explicitly, so warn/info results still never block a commit. - Per-value sh:and on property shapes produces ONE result per value node, not one per failing conjunct (matches the node-shape collapse). - sh:targetObjectsOf includes literal objects as focus nodes, routed through the literal-focus path (deduplicated by value). - Class-target focus discovery unions a live rdfs:subClassOf descendant walk with the indexed hierarchy, so novelty-only subclass relations reach sh:targetClass / implicit class targets (mirrors the live walk sh:class membership already had). W3C SHACL core: 78.6% -> 82.7% (targets 7/7, misc 4/5, property 32/38). The 17 remaining failures are all documented: value-model differences (ill-formed literals, numeric term identity, list dedup) or deferred features (per-value pair reporting, dateTime timezone partial order, nested sh:property on property shapes, custom severity IRIs, sh:sparql) — see docs/contributing/shacl-compliance.md. --- docs/contributing/shacl-compliance.md | 16 ++- fluree-db-shacl/src/validate.rs | 144 ++++++++++++++++++-------- fluree-db-transact/src/stage.rs | 4 +- 3 files changed, 117 insertions(+), 47 deletions(-) diff --git a/docs/contributing/shacl-compliance.md b/docs/contributing/shacl-compliance.md index 7322c991e..f4610c46f 100644 --- a/docs/contributing/shacl-compliance.md +++ b/docs/contributing/shacl-compliance.md @@ -61,11 +61,18 @@ Failures are expected in these areas (honest gaps, not harness bugs): - Complex `sh:resultPath` serialization (sequence/inverse path structures) is omitted from reports. -- `sh:equals` reports one aggregate violation instead of one per - missing/extra value. +- `sh:equals` / `sh:lessThan` report one aggregate violation instead of one + per missing/extra value (`equals-001`, `lessThan-002`). - `xsd:dateTime` range comparison does not implement the spec's timezone partial order (`minInclusive-002/003`). +- `sh:property` nested on a *property* shape (validating each value against + child property shapes) is not implemented (`property-001`, + `validation-reports/shared`). +- Custom severity IRIs (`sh:severity ex:MySeverity`) collapse to + `sh:Violation` (`severity-002`). - `sh:sparql` (the whole `sparql/` section of the suite is not wired in). +- `complex/shacl-shacl` (validating shapes against the SHACL-SHACL meta + shapes) depends on several of the above. A few expectations are unachievable by design — Fluree's value-centric store differs from RDF term identity: @@ -76,8 +83,9 @@ store differs from RDF term identity: `sh:uniqueLang "1"^^xsd:boolean` (which must be ignored — only the term `true` activates the component) is indistinguishable from `true` and fires (`uniqueLang-002`). -- Ill-formed typed literals (`"aldi"^^xsd:integer`) are rejected at ingest - and can never be present to validate (`datatype-001`). +- Ill-formed typed literals (`"aldi"^^xsd:integer`, `"none"^^xsd:boolean`) + are rejected at ingest and can never be present to validate + (`datatype-001`, `datatype-ill-formed`, `or-datatypes-001`). - Duplicate values in an RDF list collapse (`xone-duplicate`, `path-complex-002`'s `( _:pinv _:pinv )` sequence). diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index cbc1eb554..859510e9f 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -307,7 +307,9 @@ impl ShaclEngine { results.extend(shape_results); } - let conforms = results.iter().all(|r| r.severity != Severity::Violation); + // Spec: sh:conforms is true iff there are NO validation results + // (warnings and infos included), not merely no violations. + let conforms = results.is_empty(); Ok(ValidationReport { conforms, results }) } @@ -369,29 +371,55 @@ impl ShaclEngine { all_results.extend(results); } - // Literal sh:targetNode targets: the focus is the literal itself. + // Literal focus nodes: explicit literal sh:targetNode targets, plus + // literal objects reached via sh:targetObjectsOf (a target + // predicate's objects may be literals). De-duplicated by value — + // the focus set is a set of nodes. + let mut literal_targets: Vec = Vec::new(); for target in &shape.targets { - if let crate::compile::TargetType::LiteralNode(lits) = target { - for lit in lits { - let active = ActiveShapeChecks::default(); - let results = validate_literal_focus( - db, - lit, - shape, - &all_shapes, - Some(class_ctx), - &active, - ) - .await?; - all_results.extend(results); + match target { + crate::compile::TargetType::LiteralNode(lits) => { + for lit in lits { + if !literal_targets.contains(lit) { + literal_targets.push(lit.clone()); + } + } + } + crate::compile::TargetType::ObjectsOf(predicate) => { + let flakes = db + .range( + IndexType::Psot, + RangeTest::Eq, + RangeMatch::predicate(predicate.clone()), + ) + .await?; + for flake in &flakes { + if matches!(flake.o, FlakeValue::Ref(_)) { + continue; + } + let lit = crate::compile::LiteralTarget { + value: flake.o.clone(), + datatype: flake.dt.clone(), + lang: flake.m.as_ref().and_then(|m| m.lang.clone()), + }; + if !literal_targets.contains(&lit) { + literal_targets.push(lit); + } + } } + _ => {} } } + for lit in &literal_targets { + let active = ActiveShapeChecks::default(); + let results = + validate_literal_focus(db, lit, shape, &all_shapes, Some(class_ctx), &active) + .await?; + all_results.extend(results); + } } - let conforms = all_results - .iter() - .all(|r| r.severity != Severity::Violation); + let conforms = all_results.is_empty(); Ok(ValidationReport { conforms, @@ -501,9 +529,7 @@ impl ShaclEngine { all_results.extend(report.results); } - let conforms = all_results - .iter() - .all(|r| r.severity != Severity::Violation); + let conforms = all_results.is_empty(); Ok(ValidationReport { conforms, @@ -522,7 +548,9 @@ impl ShaclEngine { ) -> Result<()> { let report = self.validate_staged(db, modified_subjects).await?; - if report.conforms { + // Enforcement rejects on violations only — spec-level `conforms` + // is also false for warnings/infos, which must not block a commit. + if report.violation_count() == 0 { Ok(()) } else { // Build detailed error messages (limit to first 10 to avoid huge errors) @@ -567,11 +595,36 @@ async fn get_focus_nodes( for target in &shape.targets { match target { TargetType::Class(class) | TargetType::ImplicitClass(class) => { - // Build list of classes to query: target class + all subclasses + // Build list of classes to query: target class + all subclasses. + // The indexed hierarchy misses novelty-added subclass relations, + // so a live rdfs:subClassOf descendant walk unions them in + // (mirrors the live walk sh:class membership already does). let mut classes_to_query = vec![class.clone()]; if let Some(h) = hierarchy { classes_to_query.extend(h.subclasses_of(class).iter().cloned()); } + let sub_class_of = Sid::new(fluree_vocab::namespaces::RDFS, "subClassOf"); + let mut queue: std::collections::VecDeque = + classes_to_query.iter().cloned().collect(); + let mut visited: HashSet = classes_to_query.iter().cloned().collect(); + while let Some(cls) = queue.pop_front() { + let sub_flakes = db + .range( + IndexType::Opst, + RangeTest::Eq, + RangeMatch::predicate_object( + sub_class_of.clone(), + FlakeValue::Ref(cls), + ), + ) + .await?; + for flake in &sub_flakes { + if visited.insert(flake.s.clone()) { + classes_to_query.push(flake.s.clone()); + queue.push_back(flake.s.clone()); + } + } + } // Find all instances of each class let rdf_type = Sid::new(RDF, rdf_names::TYPE); @@ -1990,9 +2043,12 @@ async fn validate_property_value_structural_constraint<'a>( } NodeConstraint::And(nested_shapes) => { - // For each value, ALL nested shapes must accept it + // For each value, ALL nested shapes must accept it. Per spec a + // failed conjunction produces ONE result per value node, however + // many members rejected it. for (i, value) in values.iter().enumerate() { let dt = datatypes.get(i); + let mut failed_members = Vec::new(); for nested in nested_shapes { let conforms = check_value_against_nested_shape( db, @@ -2007,26 +2063,30 @@ async fn validate_property_value_structural_constraint<'a>( ) .await?; if !conforms { - results.push(ValidationResult { - focus_node: FocusNode::Node(focus_node.clone()), - result_path: prop_shape.path.as_predicate().cloned(), - source_shape: parent_shape.id.clone(), - source_constraint: Some(prop_shape.id.clone()), - constraint_component: sh_vocab::AND_CONSTRAINT_COMPONENT, - severity: prop_shape.severity, - message: prop_shape.message.clone().unwrap_or_else(|| { - format!( - "Value {:?} does not conform to shape {} (sh:and)", - value, nested.id.name - ) - }), - value: Some(value.clone()), - value_datatype: datatypes.get(i).cloned(), - value_lang: langs.get(i).and_then(std::clone::Clone::clone), - graph_id: None, - }); + failed_members.push(nested.id.name.to_string()); } } + if !failed_members.is_empty() { + results.push(ValidationResult { + focus_node: FocusNode::Node(focus_node.clone()), + result_path: prop_shape.path.as_predicate().cloned(), + source_shape: parent_shape.id.clone(), + source_constraint: Some(prop_shape.id.clone()), + constraint_component: sh_vocab::AND_CONSTRAINT_COMPONENT, + severity: prop_shape.severity, + message: prop_shape.message.clone().unwrap_or_else(|| { + format!( + "Value {:?} does not conform to shape(s) {} (sh:and)", + value, + failed_members.join(", ") + ) + }), + value: Some(value.clone()), + value_datatype: datatypes.get(i).cloned(), + value_lang: langs.get(i).and_then(std::clone::Clone::clone), + graph_id: None, + }); + } } } diff --git a/fluree-db-transact/src/stage.rs b/fluree-db-transact/src/stage.rs index 87a573e91..75b503111 100644 --- a/fluree-db-transact/src/stage.rs +++ b/fluree-db-transact/src/stage.rs @@ -2167,7 +2167,9 @@ pub async fn stage_with_shacl( let report = validate_staged_nodes(&view, &engine, Some(&graph_sids), tracker, None, None).await?; - if !report.conforms { + // Reject on violations only — spec-level `conforms` is also false for + // warnings/infos, which must not block a commit. + if report.violation_count() > 0 { return Err(TransactError::ShaclViolation(format_shacl_report(&report))); } From b7e0b296c62a491fde13700b87d784a3bb30d66e Mon Sep 17 00:00:00 2001 From: bplatz Date: Sat, 4 Jul 2026 21:42:41 -0400 Subject: [PATCH 16/17] fix(shacl): report-serialization fidelity and cross-crate conforms semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review-driven fixes to the validation report path: - value_json: match the JSON-LD @id datatype by namespace (JSON_LD + local "id"), not the bare local name, so a real datatype whose local name is "id" (e.g. ) is no longer misreported as an IRI node in sh:value. - Turtle IRIREF serialization: \uXXXX-escape the characters the Turtle grammar forbids inside <…> (controls, space, <>"{}|^`\) for both subject/ object IRIs and datatype IRIs. UCHAR escapes round-trip to the original IRI, unlike percent-encoding. Adds a parser round-trip test. - validate_staged_nodes: compute `conforms` as `all_results.is_empty()` (spec semantics) to match ShaclEngine::validate_staged, so the field means the same thing across crates. Enforcement gates already key off violation_count(), so behavior is unchanged. --- fluree-db-api/src/validate.rs | 49 +++++++++++++++++++++++++++++++-- fluree-db-transact/src/stage.rs | 9 +++--- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/fluree-db-api/src/validate.rs b/fluree-db-api/src/validate.rs index 3a9a5df76..f65eb919f 100644 --- a/fluree-db-api/src/validate.rs +++ b/fluree-db-api/src/validate.rs @@ -233,10 +233,30 @@ fn turtle_term(iri_or_bnode: &str) -> String { .collect(); format!("_:{clean}") } - None => format!("<{iri_or_bnode}>"), + None => turtle_iri(iri_or_bnode), } } +/// Render an IRI as a Turtle IRIREF, `\uXXXX`-escaping the characters the Turtle +/// grammar forbids inside `<…>` (controls, space, and ``<>"{}|^`\``). The parser +/// unescapes these back to the original codepoint, so the IRI round-trips — +/// unlike percent-encoding, which would change the IRI's identity. +fn turtle_iri(iri: &str) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(iri.len() + 2); + out.push('<'); + for c in iri.chars() { + match c { + '\u{00}'..='\u{20}' | '<' | '>' | '"' | '{' | '}' | '|' | '^' | '`' | '\\' => { + let _ = write!(out, "\\u{:04X}", c as u32); + } + _ => out.push(c), + } + } + out.push('>'); + out +} + /// Render an IRI as `sh:Name` when it lives in the SHACL namespace. fn turtle_sh_term(iri: &str) -> String { match iri.strip_prefix("http://www.w3.org/ns/shacl#") { @@ -257,7 +277,7 @@ fn turtle_value_term(value: &JsonValue) -> String { return format!("{}@{lang}", turtle_string(lex)); } if let Some(dt) = obj.get("@type").and_then(|v| v.as_str()) { - return format!("{}^^<{dt}>", turtle_string(lex)); + return format!("{}^^{}", turtle_string(lex), turtle_iri(dt)); } return turtle_string(lex); } @@ -584,7 +604,13 @@ fn value_json( let dt_iri = resolve(dt); // A String carrying the `@id` datatype is a stringified IRI // (STR() semantics in the string facets) — report the IRI node. - if dt_iri == "@id" || &*dt.name == "id" { + // Match the JSON-LD `@id` datatype by namespace, not the bare local + // name, so a real datatype whose local name is `id` (e.g. + // ``) is not misreported as an IRI node. + let is_id_datatype = dt_iri == "@id" + || (dt.namespace_code == fluree_vocab::namespaces::JSON_LD + && &*dt.name == fluree_vocab::jsonld_names::ID); + if is_id_datatype { if let FlakeValue::String(s) = value { return json!({"@id": s}); } @@ -692,4 +718,21 @@ mod tests { fn turtle_string_escapes_specials() { assert_eq!(turtle_string("a\"b\\c\nd"), "\"a\\\"b\\\\c\\nd\""); } + + #[test] + fn turtle_iri_escapes_illegal_chars() { + // Well-formed IRIs pass through unchanged. + assert_eq!(turtle_iri("http://ex.org/a"), ""); + // Grammar-illegal chars become \uXXXX and the result still parses. + assert_eq!( + turtle_iri("http://ex.org/a b"), + "" + ); + let turtle = format!( + " {} .\n", + turtle_iri("http://ex.org/a b|c") + ); + let mut sink = fluree_graph_ir::GraphCollectorSink::new(); + fluree_graph_turtle::parse(&turtle, &mut sink).expect("escaped IRI must parse"); + } } diff --git a/fluree-db-transact/src/stage.rs b/fluree-db-transact/src/stage.rs index 75b503111..f7be8639b 100644 --- a/fluree-db-transact/src/stage.rs +++ b/fluree-db-transact/src/stage.rs @@ -2425,10 +2425,11 @@ async fn validate_staged_nodes( } } - // Check conformance - let conforms = all_results - .iter() - .all(|r| r.severity != fluree_db_shacl::Severity::Violation); + // Spec semantics: `sh:conforms` is true iff there are NO results, matching + // `ShaclEngine::validate_staged` so `ValidationReport.conforms` means the + // same thing across crates. Enforcement gates here key off + // `violation_count()` (warnings/info don't reject), not `conforms`. + let conforms = all_results.is_empty(); Ok(ValidationReport { conforms, From 40e7e6fa1981937e4bd5ad3786f8fb977565a786 Mon Sep 17 00:00:00 2001 From: bplatz Date: Sun, 5 Jul 2026 06:42:54 -0400 Subject: [PATCH 17/17] perf(shacl): memoize class-target focus nodes across shapes in validate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_focus_nodes ran the rdfs:subClassOf descendant BFS and the rdf:type instance scans once per shape, so N shapes targeting the same class paid that I/O N times. Memoize class -> focus-node list per validate call (db and hierarchy are constant across the shape loop), keyed by the target class Sid, so shapes sharing a class compute it once. Cold path only — the explicit validate endpoint/CLI, not transaction commit. Adds it_validate_report.rs, the first integration coverage for the validate endpoint: two shapes targeting the same class over a subclass hierarchy, asserting subclass expansion (a subclass instance is a focus node) and that both shapes fire on the shared, memoized focus set. --- fluree-db-api/tests/grp_graphsource.rs | 2 + fluree-db-api/tests/it_validate_report.rs | 124 ++++++++++++++++++++++ fluree-db-shacl/src/validate.rs | 19 +++- 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 fluree-db-api/tests/it_validate_report.rs diff --git a/fluree-db-api/tests/grp_graphsource.rs b/fluree-db-api/tests/grp_graphsource.rs index 1eb44db7f..d2807e385 100644 --- a/fluree-db-api/tests/grp_graphsource.rs +++ b/fluree-db-api/tests/grp_graphsource.rs @@ -29,3 +29,5 @@ mod it_ontology_inline; mod it_shapes_cross_ledger; #[path = "it_shapes_inline.rs"] mod it_shapes_inline; +#[path = "it_validate_report.rs"] +mod it_validate_report; diff --git a/fluree-db-api/tests/it_validate_report.rs b/fluree-db-api/tests/it_validate_report.rs new file mode 100644 index 000000000..106b33781 --- /dev/null +++ b/fluree-db-api/tests/it_validate_report.rs @@ -0,0 +1,124 @@ +//! Explicit `validate` endpoint: focus-node computation for class targets. +//! +//! Covers subclass expansion (a `sh:targetClass` shape must also select +//! instances of subclasses) and the per-class focus-node memo shared by +//! multiple shapes targeting the same class — the path in +//! `ShaclEngine::validate_all_with_membership` / `get_focus_nodes`. + +#![cfg(all(feature = "native", feature = "shacl"))] + +use fluree_db_api::validate::ValidateOptions; +use fluree_db_api::FlureeBuilder; +use serde_json::json; + +use crate::support::genesis_ledger; + +#[tokio::test] +async fn validate_report_shares_focus_nodes_across_shapes_targeting_same_class() { + let fluree = FlureeBuilder::memory().build_memory(); + let ledger_id = "test/validate-report/memo:main"; + let ledger = genesis_ledger(&fluree, ledger_id); + + // Step 1: data only — a subclass hierarchy plus two instances, one of the + // subclass. No shapes exist yet, so no transaction-time enforcement fires. + let r1 = fluree + .insert( + ledger, + &json!({ + "@context": { + "ex": "http://example.org/ns/", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#" + }, + "@graph": [ + { "@id": "ex:Dog", "rdfs:subClassOf": {"@id": "ex:Animal"} }, + { "@id": "ex:rex", "@type": "ex:Dog" }, + { "@id": "ex:generic", "@type": "ex:Animal" } + ] + }), + ) + .await + .expect("seed data (no shapes) must succeed"); + let ledger = r1.ledger; + + // Step 2: two node shapes, BOTH targeting ex:Animal — this is what + // exercises the per-class focus-node memo. Staging shape definitions only + // validates the shape subjects (not the pre-existing instances), so it + // commits cleanly. + let r2 = fluree + .insert( + ledger, + &json!({ + "@context": { + "ex": "http://example.org/ns/", + "sh": "http://www.w3.org/ns/shacl#" + }, + "@graph": [ + { + "@id": "ex:AnimalNameShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Animal"}, + "sh:property": { "sh:path": {"@id": "ex:name"}, "sh:minCount": 1 } + }, + { + "@id": "ex:AnimalSpeciesShape", + "@type": "sh:NodeShape", + "sh:targetClass": {"@id": "ex:Animal"}, + "sh:property": { "sh:path": {"@id": "ex:species"}, "sh:minCount": 1 } + } + ] + }), + ) + .await + .expect("seed shapes must succeed (shape subjects are not Animal instances)"); + let _ledger = r2.ledger; + + // Explicit validation over the default graph against the ledger's own + // (attached) shapes. + let report = fluree + .validate_ledger(ledger_id, &ValidateOptions::default()) + .await + .expect("validate must succeed"); + + assert!( + !report.conforms, + "instances violate both shapes: {report:?}" + ); + assert_eq!(report.shape_count, 2, "both shapes compiled"); + + // Subclass expansion: ex:rex (an ex:Dog) must appear as a focus node even + // though the shapes target ex:Animal. ex:generic is a direct instance. + let focus: std::collections::HashSet = report + .results + .iter() + .filter_map(|r| r.focus_node.as_str().map(String::from)) + .collect(); + assert!( + focus.contains("http://example.org/ns/rex"), + "subclass instance ex:rex must be a focus node: {focus:?}" + ); + assert!( + focus.contains("http://example.org/ns/generic"), + "direct instance ex:generic must be a focus node: {focus:?}" + ); + + // Both shapes fired on the shared class — the memo returned the same focus + // set to each. 2 instances × 2 shapes = 4 minCount violations. + let paths: std::collections::HashSet = report + .results + .iter() + .filter_map(|r| r.result_path.clone()) + .collect(); + assert!( + paths.contains("http://example.org/ns/name"), + "name shape must fire: {paths:?}" + ); + assert!( + paths.contains("http://example.org/ns/species"), + "species shape must fire: {paths:?}" + ); + assert_eq!( + report.violation_count(), + 4, + "each of 2 instances violates each of 2 shapes: {report:?}" + ); +} diff --git a/fluree-db-shacl/src/validate.rs b/fluree-db-shacl/src/validate.rs index 859510e9f..e6b27d5f0 100644 --- a/fluree-db-shacl/src/validate.rs +++ b/fluree-db-shacl/src/validate.rs @@ -349,13 +349,19 @@ impl ShaclEngine { cache: &self.class_cache, cross_ledger, }; + // Class-target focus nodes are constant across the shape loop (same + // `db`, same hierarchy), so memoize them per class: several shapes + // sharing a target class then pay the subclass BFS + instance scans + // once instead of once each. + let mut class_focus_memo: HashMap> = HashMap::new(); for shape in self.cache.all_shapes() { if shape.deactivated { continue; } // Get focus nodes for this shape (with hierarchy expansion) - let focus_nodes = get_focus_nodes(db, shape, self.hierarchy.as_ref()).await?; + let focus_nodes = + get_focus_nodes(db, shape, self.hierarchy.as_ref(), &mut class_focus_memo).await?; for focus_node in focus_nodes { let active = ActiveShapeChecks::default(); @@ -589,12 +595,18 @@ async fn get_focus_nodes( db: GraphDbRef<'_>, shape: &CompiledShape, hierarchy: Option<&SchemaHierarchy>, + class_focus_memo: &mut HashMap>, ) -> Result> { let mut focus_nodes = Vec::new(); for target in &shape.targets { match target { TargetType::Class(class) | TargetType::ImplicitClass(class) => { + if let Some(cached) = class_focus_memo.get(class) { + focus_nodes.extend(cached.iter().cloned()); + continue; + } + // Build list of classes to query: target class + all subclasses. // The indexed hierarchy misses novelty-added subclass relations, // so a live rdfs:subClassOf descendant walk unions them in @@ -628,6 +640,7 @@ async fn get_focus_nodes( // Find all instances of each class let rdf_type = Sid::new(RDF, rdf_names::TYPE); + let mut class_focus = Vec::new(); for cls in classes_to_query { let flakes = db .range( @@ -638,9 +651,11 @@ async fn get_focus_nodes( .await?; for flake in flakes { - focus_nodes.push(flake.s.clone()); + class_focus.push(flake.s.clone()); } } + focus_nodes.extend(class_focus.iter().cloned()); + class_focus_memo.insert(class.clone(), class_focus); } TargetType::Node(nodes) => { focus_nodes.extend(nodes.iter().cloned());