Skip to main content

qualia_core_db/modalities/logic/shacl/
text_input.rs

1//! Text-driven SHACL validation — the entry point the docs playground / any
2//! "paste shapes + data, get a report" caller uses.
3//!
4//! * **Data** is N3/N-Triples text (parsed by the engine's own [`N3Parser`]).
5//!   Numeric object literals are inline-encoded (so range constraints work);
6//!   IRIs/strings are `q_hash`ed and retained in a resolver (so string
7//!   constraints — pattern/length/language — work).
8//! * **Shapes** are a compact JSON list ([`ShapeSpec`]) mapping 1:1 onto the
9//!   [`ShaclConstraint`] vocabulary.
10//!
11//! [`validate_json`] runs the comprehensive [`ShaclEngine`] and returns the
12//! [`ValidationReport`] as JSON.
13
14use std::collections::HashMap;
15
16use super::shacl_types::{CompiledShape, ShaclConstraint, ShaclSeverity, ValidationReport};
17use super::validate::ShaclEngine;
18use crate::frame_layout::{pack_float_object, INLINE_TAG_INTEGER, INLINE_VALUE_MASK};
19use crate::modalities::logic::n3_parser::{N3Event, N3Parser, Term};
20use crate::{q_hash, NQuin};
21
22/// One shape in the playground/JSON input.
23#[derive(Debug, Clone, serde::Deserialize)]
24#[serde(rename_all = "camelCase")]
25pub struct ShapeSpec {
26    /// `sh:targetClass` IRI (instances of this class are the focus nodes).
27    pub target_class: String,
28    /// Property path the value constraints apply to (`""` = a node shape on the
29    /// focus node itself, for `class`/`nodeKind`/`closed`/logical constraints).
30    #[serde(default)]
31    pub path: String,
32    /// `Violation` (default) | `Warning` | `Info`.
33    #[serde(default)]
34    pub severity: Option<String>,
35    pub constraints: Vec<ConstraintSpec>,
36}
37
38/// One constraint in a [`ShapeSpec`]. Exactly the field for the `kind` is read.
39#[derive(Debug, Clone, serde::Deserialize)]
40pub struct ConstraintSpec {
41    /// e.g. `minInclusive`, `minCount`, `class`, `datatype`, `nodeKind`,
42    /// `pattern`, `in`, `hasValue`, `equals`, `lessThan`, `node`, `not`, `and`,
43    /// `or`, `xone`, `languageIn`, `uniqueLang`, `closed`, `minLength`…
44    pub kind: String,
45    #[serde(default)]
46    pub num: Option<f64>,
47    #[serde(default)]
48    pub text: Option<String>,
49    #[serde(default)]
50    pub list: Option<Vec<String>>,
51}
52
53fn term_str<'a>(t: &Term<'a>) -> &'a str {
54    match t {
55        Term::Uri(s) | Term::Variable(s) | Term::Literal(s) | Term::Formula(s) => s,
56    }
57}
58
59fn intern(s: &str, r: &mut HashMap<u64, String>) -> u64 {
60    let h = q_hash(s);
61    r.entry(h).or_insert_with(|| s.to_string());
62    h
63}
64
65/// Encode a triple object: numeric literals → inline-typed value; everything
66/// else → `q_hash` (retained in the resolver for string constraints).
67fn encode_object(t: &Term, r: &mut HashMap<u64, String>) -> u64 {
68    let s = term_str(t);
69    if let Term::Literal(_) = t {
70        if let Ok(i) = s.parse::<i64>() {
71            return INLINE_TAG_INTEGER | ((i as u64) & INLINE_VALUE_MASK);
72        }
73        if let Ok(f) = s.parse::<f64>() {
74            return pack_float_object(f as f32);
75        }
76    }
77    intern(s, r)
78}
79
80/// Parse N3/N-Triples text into a quin graph plus a hash→lexical resolver.
81pub fn build_graph(data: &str) -> (Vec<NQuin>, HashMap<u64, String>) {
82    let mut quins = Vec::new();
83    let mut resolver = HashMap::new();
84    let mut parser = N3Parser::new(data);
85    let _ = parser.parse_all(|ev| {
86        if let N3Event::StaticTriple(t) = ev {
87            let subject = intern(term_str(&t.subject), &mut resolver);
88            let predicate = intern(term_str(&t.predicate), &mut resolver);
89            let object = encode_object(&t.object, &mut resolver);
90            quins.push(NQuin {
91                subject,
92                predicate,
93                object,
94                context: 0,
95                metadata: 0,
96                parity: 0,
97            });
98        }
99        Ok(())
100    });
101    (quins, resolver)
102}
103
104fn severity_of(s: &Option<String>) -> ShaclSeverity {
105    match s.as_deref() {
106        Some("Warning") | Some("warning") => ShaclSeverity::Warning,
107        Some("Info") | Some("info") => ShaclSeverity::Info,
108        _ => ShaclSeverity::Violation,
109    }
110}
111
112fn constraint_of(c: &ConstraintSpec) -> Option<ShaclConstraint> {
113    let num = c.num.unwrap_or(0.0);
114    let u = num as u32;
115    let text = || c.text.clone().unwrap_or_default();
116    let list = || c.list.clone().unwrap_or_default();
117    Some(match c.kind.as_str() {
118        "minInclusive" => ShaclConstraint::MinInclusive(num),
119        "maxInclusive" => ShaclConstraint::MaxInclusive(num),
120        "minExclusive" => ShaclConstraint::MinExclusive(num),
121        "maxExclusive" => ShaclConstraint::MaxExclusive(num),
122        "minCount" => ShaclConstraint::MinCount(u),
123        "maxCount" => ShaclConstraint::MaxCount(u),
124        "minLength" => ShaclConstraint::MinLength(u),
125        "maxLength" => ShaclConstraint::MaxLength(u),
126        "pattern" => ShaclConstraint::Pattern(text()),
127        "class" => ShaclConstraint::Class(text()),
128        "datatype" => ShaclConstraint::DataType(text()),
129        "nodeKind" => ShaclConstraint::NodeKind(text()),
130        "hasValue" => ShaclConstraint::HasValue(text()),
131        "in" => ShaclConstraint::In(list()),
132        "equals" => ShaclConstraint::Equals(text()),
133        "lessThan" => ShaclConstraint::LessThan(text()),
134        "lessThanOrEquals" => ShaclConstraint::LessThanOrEquals(text()),
135        "greaterThan" => ShaclConstraint::GreaterThan(text()),
136        "greaterThanOrEquals" => ShaclConstraint::GreaterThanOrEquals(text()),
137        "node" => ShaclConstraint::Node(text()),
138        "not" => ShaclConstraint::Not(text()),
139        "and" => ShaclConstraint::And(list()),
140        "or" => ShaclConstraint::Or(list()),
141        "xone" => ShaclConstraint::Xone(list()),
142        "languageIn" => ShaclConstraint::LanguageIn(list()),
143        "uniqueLang" => ShaclConstraint::UniqueLang,
144        "closed" => ShaclConstraint::Closed {
145            ignored_properties: list(),
146        },
147        _ => return None,
148    })
149}
150
151/// Build a [`CompiledShape`] from a [`ShapeSpec`].
152pub fn shape_from_spec(spec: &ShapeSpec) -> CompiledShape {
153    let constraints: Vec<ShaclConstraint> =
154        spec.constraints.iter().filter_map(constraint_of).collect();
155    let mut shape = CompiledShape::new(
156        spec.target_class.clone(),
157        constraints,
158        severity_of(&spec.severity),
159    );
160    shape.property_path = spec.path.clone();
161    shape
162}
163
164/// Validate N3/N-Triples `data` against `specs` and return the report.
165pub fn validate_text(data: &str, specs: &[ShapeSpec]) -> ValidationReport {
166    let (quins, resolver) = build_graph(data);
167    let shapes: Vec<CompiledShape> = specs.iter().map(shape_from_spec).collect();
168    let engine = ShaclEngine::new(&quins, &shapes);
169    engine.validate(&|h| resolver.get(&h).cloned())
170}
171
172/// JSON-in / JSON-out convenience for the playground & WASM boundary.
173pub fn validate_json(data: &str, shapes_json: &str) -> Result<String, String> {
174    let specs: Vec<ShapeSpec> =
175        serde_json::from_str(shapes_json).map_err(|e| format!("invalid shapes JSON: {e}"))?;
176    let report = validate_text(data, &specs);
177    serde_json::to_string(&report).map_err(|e| format!("serialize report: {e}"))
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn end_to_end_age_mininclusive() {
186        let data = ":alice a :Person .\n:alice :age 15 .\n:bob a :Person .\n:bob :age 40 .";
187        let shapes = r#"[
188            {"targetClass":":Person","path":":age","severity":"Violation",
189             "constraints":[{"kind":"minInclusive","num":18}]}
190        ]"#;
191        let out = validate_json(data, shapes).unwrap();
192        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
193        assert_eq!(
194            v["conforms"], false,
195            "alice (15) must violate minInclusive 18"
196        );
197        let results = v["results"].as_array().unwrap();
198        assert_eq!(results.len(), 1);
199        assert_eq!(
200            results[0]["source_constraint_component"],
201            "sh:MinInclusiveConstraintComponent"
202        );
203    }
204
205    #[test]
206    fn end_to_end_pattern_with_resolver() {
207        let data = r#":u a :User . :u :email "alice@example.org" ."#;
208        let shapes = r#"[
209            {"targetClass":":User","path":":email",
210             "constraints":[{"kind":"pattern","text":"^[^@]+@[^@]+\\.[a-z]+$"}]}
211        ]"#;
212        let out = validate_json(data, shapes).unwrap();
213        let v: serde_json::Value = serde_json::from_str(&out).unwrap();
214        assert_eq!(
215            v["conforms"], true,
216            "a well-formed email matches the pattern"
217        );
218    }
219
220    #[test]
221    fn end_to_end_class_and_node_kind() {
222        // pet must be an instance of :Dog; here it's a :Cat → violation.
223        let data = ":o a :Owner . :o :pet :rex . :rex a :Cat .";
224        let shapes = r#"[
225            {"targetClass":":Owner","path":":pet",
226             "constraints":[{"kind":"class","text":":Dog"}]}
227        ]"#;
228        let v: serde_json::Value =
229            serde_json::from_str(&validate_json(data, shapes).unwrap()).unwrap();
230        assert_eq!(v["conforms"], false);
231    }
232
233    #[test]
234    fn invalid_json_is_reported() {
235        assert!(validate_json(":a :b :c .", "{not json").is_err());
236    }
237}