qualia_cli/ingest/
mapper.rs1use 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
27pub 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 let prefixes = parse_prefixes(&content);
40
41 let base_class_hash = extract_iri_value(&content, "sh:targetClass", &prefixes)
43 .map(|iri| hash_token(&iri))
44 .unwrap_or(0);
45
46 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 let open = match search.find('[') {
55 Some(i) => i,
56 None => break,
57 };
58 search = &search[open + 1..];
59
60 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
98fn 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 if let Some(colon) = rest.find(':') {
115 let pfx = &rest[..colon + 1]; 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
127fn 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 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 Some(token.to_string());
154 }
155
156 None
157}
158
159fn 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
184fn 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
220fn 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}