Skip to main content

qualia_cli/ingest/
mapper.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use qualia_core_db::mini_parser::hash_token;
5
6#[derive(Debug, Clone)]
7pub enum TargetDatatype {
8    Integer,
9    Float,
10    DateTime,
11    StringRef,
12}
13
14#[derive(Debug, Clone)]
15pub struct ColumnMapping {
16    pub source_key: String,
17    pub column_index: Option<usize>,
18    pub predicate_hash: u64,
19    pub datatype: TargetDatatype,
20}
21
22pub struct MappingProfile {
23    pub base_class_hash: u64,
24    pub fields: Vec<ColumnMapping>,
25}
26
27/// Boot Phase: parse a `.shacl.ttl` mapping file and compile it into a
28/// [`MappingProfile`] for use in the zero-allocation stream phase.
29///
30/// Expected predicates in the Turtle file:
31/// - `sh:targetClass`  — the RDF class of each row (used as subject class hint)
32/// - `sh:property [ sh:path <URI> ; sh:datatype xsd:T ; qext:sourceColumn "hdr" ]`
33/// - `qext:sourceJsonKey "key"` may substitute `qext:sourceColumn` for JSON inputs
34pub fn compile_shacl_mapping(path: &Path) -> Result<MappingProfile, String> {
35    let content = std::fs::read_to_string(path)
36        .map_err(|e| format!("Cannot read SHACL mapping file '{}': {e}", path.display()))?;
37
38    // ── 1. Build prefix map ───────────────────────────────────────────────
39    let prefixes = parse_prefixes(&content);
40
41    // ── 2. Extract base class ─────────────────────────────────────────────
42    let base_class_hash = extract_iri_value(&content, "sh:targetClass", &prefixes)
43        .map(|iri| hash_token(&iri))
44        .unwrap_or(0);
45
46    // ── 3. Parse sh:property blocks ───────────────────────────────────────
47    let mut fields: Vec<ColumnMapping> = Vec::new();
48    let mut search = content.as_str();
49
50    while let Some(pos) = search.find("sh:property") {
51        search = &search[pos + "sh:property".len()..];
52
53        // Find the anonymous blank node block [ ... ]
54        let open = match search.find('[') {
55            Some(i) => i,
56            None => break,
57        };
58        search = &search[open + 1..];
59
60        // Allow nested brackets (e.g., sh:or lists)
61        let close = match find_matching_bracket(search) {
62            Some(i) => i,
63            None => break,
64        };
65        let block = &search[..close];
66        search = &search[close + 1..];
67
68        let pred_iri = extract_iri_value(block, "sh:path", &prefixes);
69        let dtype_str = extract_iri_value(block, "sh:datatype", &prefixes);
70        let source_key = extract_string_literal(block, "qext:sourceColumn")
71            .or_else(|| extract_string_literal(block, "qext:sourceJsonKey"));
72
73        if let (Some(pred_iri), Some(key)) = (pred_iri, source_key) {
74            let predicate_hash = hash_token(&pred_iri);
75            let datatype = map_datatype(dtype_str.as_deref().unwrap_or(""));
76            fields.push(ColumnMapping {
77                source_key: key,
78                column_index: None,
79                predicate_hash,
80                datatype,
81            });
82        }
83    }
84
85    if fields.is_empty() {
86        return Err(format!(
87            "No sh:property mappings with qext:sourceColumn/qext:sourceJsonKey found in '{}'.",
88            path.display()
89        ));
90    }
91
92    Ok(MappingProfile {
93        base_class_hash,
94        fields,
95    })
96}
97
98// ── Helpers ───────────────────────────────────────────────────────────────────
99
100/// Build `prefix → IRI` map from `@prefix` / `PREFIX` declarations.
101fn parse_prefixes(content: &str) -> HashMap<String, String> {
102    let mut map = HashMap::new();
103    for line in content.lines() {
104        let line = line.trim();
105        let rest = if line.starts_with("@prefix") {
106            line.strip_prefix("@prefix").unwrap_or("").trim()
107        } else if line.to_ascii_uppercase().starts_with("PREFIX") {
108            line[6..].trim()
109        } else {
110            continue;
111        };
112
113        // Expect:  prefix: <IRI>
114        if let Some(colon) = rest.find(':') {
115            let pfx = &rest[..colon + 1]; // includes the trailing colon
116            let after = rest[colon + 1..].trim();
117            if after.starts_with('<') {
118                if let Some(end) = after.find('>') {
119                    map.insert(pfx.to_string(), after[1..end].to_string());
120                }
121            }
122        }
123    }
124    map
125}
126
127/// Resolve a prefixed name or bracketed IRI from the content following `predicate`.
128fn extract_iri_value(
129    block: &str,
130    predicate: &str,
131    prefixes: &HashMap<String, String>,
132) -> Option<String> {
133    let pos = block.find(predicate)?;
134    let after = block[pos + predicate.len()..].trim_start();
135
136    if after.starts_with('<') {
137        let end = after.find('>')?;
138        return Some(after[1..end].to_string());
139    }
140
141    // Prefixed name: prefix:local
142    let end = after
143        .find(|c: char| c.is_whitespace() || c == ';' || c == ',' || c == ']')
144        .unwrap_or(after.len());
145    let token = &after[..end];
146    if let Some(colon) = token.find(':') {
147        let pfx = &token[..colon + 1];
148        let local = &token[colon + 1..];
149        if let Some(base) = prefixes.get(pfx) {
150            return Some(format!("{}{}", base, local));
151        }
152        // Return as-is when prefix not found — still useful for hashing
153        return Some(token.to_string());
154    }
155
156    None
157}
158
159/// Extract the contents of a double-quoted string literal following `predicate`.
160fn extract_string_literal(block: &str, predicate: &str) -> Option<String> {
161    let pos = block.find(predicate)?;
162    let after = block[pos + predicate.len()..].trim_start();
163    if !after.starts_with('"') {
164        return None;
165    }
166    let inner = &after[1..];
167    let mut result = String::new();
168    let mut escaped = false;
169    for ch in inner.chars() {
170        if escaped {
171            result.push(ch);
172            escaped = false;
173        } else if ch == '\\' {
174            escaped = true;
175        } else if ch == '"' {
176            break;
177        } else {
178            result.push(ch);
179        }
180    }
181    Some(result)
182}
183
184/// Find the index of the `]` that closes the opening `[` already consumed.
185/// Handles one level of nesting for inner blank nodes.
186fn find_matching_bracket(s: &str) -> Option<usize> {
187    let mut depth = 1usize;
188    let mut in_string = false;
189    let mut escaped = false;
190    for (i, ch) in s.char_indices() {
191        if escaped {
192            escaped = false;
193            continue;
194        }
195        if ch == '\\' && in_string {
196            escaped = true;
197            continue;
198        }
199        if ch == '"' {
200            in_string = !in_string;
201            continue;
202        }
203        if in_string {
204            continue;
205        }
206        match ch {
207            '[' => depth += 1,
208            ']' => {
209                depth -= 1;
210                if depth == 0 {
211                    return Some(i);
212                }
213            }
214            _ => {}
215        }
216    }
217    None
218}
219
220/// Map an XSD datatype IRI to [`TargetDatatype`].
221fn map_datatype(datatype_iri: &str) -> TargetDatatype {
222    let lower = datatype_iri.to_ascii_lowercase();
223    if lower.contains("integer") || lower.ends_with("#int") || lower.ends_with("#long") {
224        TargetDatatype::Integer
225    } else if lower.contains("double") || lower.contains("float") || lower.contains("decimal") {
226        TargetDatatype::Float
227    } else if lower.contains("datetime") || lower.contains("date") {
228        TargetDatatype::DateTime
229    } else {
230        TargetDatatype::StringRef
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use std::io::Write;
238
239    const SAMPLE_SHACL: &str = r#"
240@prefix sh: <http://www.w3.org/ns/shacl#> .
241@prefix qext: <http://webizen.org/ext#> .
242@prefix ex: <http://example.org/> .
243@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
244
245ex:HealthShape a sh:NodeShape ;
246    sh:targetClass ex:HealthRecord ;
247    sh:property [
248        sh:path ex:stepCount ;
249        sh:datatype xsd:integer ;
250        qext:sourceColumn "Step count"
251    ] ;
252    sh:property [
253        sh:path ex:heartRate ;
254        sh:datatype xsd:decimal ;
255        qext:sourceColumn "Heart rate (bpm)"
256    ] ;
257    sh:property [
258        sh:path ex:recordedAt ;
259        sh:datatype xsd:dateTime ;
260        qext:sourceColumn "Date"
261    ] ;
262    sh:property [
263        sh:path ex:deviceName ;
264        sh:datatype xsd:string ;
265        qext:sourceJsonKey "device"
266    ] .
267"#;
268
269    fn write_shacl(name: &str, content: &str) -> std::path::PathBuf {
270        let path = std::env::temp_dir().join(name);
271        let mut f = std::fs::File::create(&path).expect("create shacl file");
272        f.write_all(content.as_bytes()).expect("write shacl file");
273        path
274    }
275
276    #[test]
277    fn compile_basic_shacl() {
278        let path = write_shacl("test_shacl_basic.ttl", SAMPLE_SHACL);
279        let profile = compile_shacl_mapping(&path).expect("compile_shacl_mapping failed");
280        assert_eq!(profile.fields.len(), 4, "should find 4 property mappings");
281    }
282
283    #[test]
284    fn shacl_field_source_keys() {
285        let path = write_shacl("test_shacl_keys.ttl", SAMPLE_SHACL);
286        let profile = compile_shacl_mapping(&path).expect("compile");
287        let keys: Vec<&str> = profile
288            .fields
289            .iter()
290            .map(|f| f.source_key.as_str())
291            .collect();
292        assert!(keys.contains(&"Step count"));
293        assert!(keys.contains(&"Heart rate (bpm)"));
294        assert!(keys.contains(&"Date"));
295        assert!(keys.contains(&"device"));
296    }
297
298    #[test]
299    fn shacl_datatypes_parsed_correctly() {
300        let path = write_shacl("test_shacl_dtypes.ttl", SAMPLE_SHACL);
301        let profile = compile_shacl_mapping(&path).expect("compile");
302        for field in &profile.fields {
303            match field.source_key.as_str() {
304                "Step count" => assert!(matches!(field.datatype, TargetDatatype::Integer)),
305                "Heart rate (bpm)" => assert!(matches!(field.datatype, TargetDatatype::Float)),
306                "Date" => assert!(matches!(field.datatype, TargetDatatype::DateTime)),
307                "device" => assert!(matches!(field.datatype, TargetDatatype::StringRef)),
308                _ => {}
309            }
310        }
311    }
312
313    #[test]
314    fn shacl_predicate_hashes_are_nonzero() {
315        let path = write_shacl("test_shacl_hashes.ttl", SAMPLE_SHACL);
316        let profile = compile_shacl_mapping(&path).expect("compile");
317        for field in &profile.fields {
318            assert_ne!(
319                field.predicate_hash, 0,
320                "predicate hash should be nonzero for '{}'",
321                field.source_key
322            );
323        }
324    }
325
326    #[test]
327    fn shacl_error_on_missing_file() {
328        let result = compile_shacl_mapping(Path::new("/nonexistent/path.shacl.ttl"));
329        assert!(result.is_err());
330    }
331
332    #[test]
333    fn shacl_error_on_empty_mapping() {
334        let path = write_shacl(
335            "test_shacl_empty.ttl",
336            "@prefix sh: <http://www.w3.org/ns/shacl#> .",
337        );
338        let result = compile_shacl_mapping(&path);
339        assert!(result.is_err(), "should error when no mappings found");
340    }
341}