Skip to main content

qualia_core_db/domains/geospatial/adapters/
sparql_adapter.rs

1use crate::domains::geospatial::adapters::{AdapterHttpRequest, DataAdapter};
2use crate::net::disclosure::NetworkDisclosureRegistry;
3
4/// Adapter for Semantic Web RDF / SPARQL endpoints (e.g. Wikidata).
5pub struct SparqlAdapter {
6    pub id: &'static str,
7    pub endpoint: String,
8}
9
10/// Build a provenance quin with the crate's canonical parity checksum.
11fn quin(s: u64, p: u64, o: u64) -> crate::NQuin {
12    crate::NQuin {
13        subject: s,
14        predicate: p,
15        object: o,
16        context: 0,
17        metadata: 0,
18        parity: s ^ p ^ o,
19    }
20}
21
22impl SparqlAdapter {
23    pub fn new(id: &'static str, endpoint: &str) -> Self {
24        Self {
25            id,
26            endpoint: endpoint.to_string(),
27        }
28    }
29
30    /// Parse a standard SPARQL 1.1 Query Results JSON response (e.g. from Wikidata
31    /// or any generic SPARQL endpoint) into provenance `NQuin`s.
32    ///
33    /// The mapping is generic and deterministic so the resulting quins are queryable
34    /// by the SPARQL layer, which hashes IRIs with the same `generate_60bit_token`:
35    ///
36    /// For each `results.bindings` row `i` (iterating `head.vars` in declared order):
37    /// * subject = the value of the FIRST variable whose binding is `type == "uri"`;
38    ///   if the row binds no URI, subject = `generate_60bit_token("urn:sparql:row:{i}")`.
39    /// * for every OTHER bound variable `v` in the row: predicate =
40    ///   `generate_60bit_token("urn:sparql:var:{v}")`, object =
41    ///   `generate_60bit_token(binding.value)`, emitting `quin(subject, predicate, object)`.
42    ///
43    /// If `results` or `results.bindings` are absent, an empty `Vec` is returned
44    /// (not an error). `Err` is only returned when `body` is not valid JSON.
45    pub fn parse_features(&self, body: &str) -> Result<Vec<crate::NQuin>, String> {
46        use crate::query::lexicon::generate_60bit_token;
47
48        let json: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?;
49
50        // Ordered list of variable names from head.vars (deterministic subject selection).
51        let vars: Vec<String> = json
52            .get("head")
53            .and_then(|h| h.get("vars"))
54            .and_then(|v| v.as_array())
55            .map(|arr| {
56                arr.iter()
57                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
58                    .collect()
59            })
60            .unwrap_or_default();
61
62        // Absent results / bindings => empty Vec, not an error.
63        let bindings = match json
64            .get("results")
65            .and_then(|r| r.get("bindings"))
66            .and_then(|b| b.as_array())
67        {
68            Some(b) => b,
69            None => return Ok(Vec::new()),
70        };
71
72        let mut quins = Vec::new();
73
74        for (i, row) in bindings.iter().enumerate() {
75            // Pick subject: first head var bound in this row with type == "uri".
76            let mut subject_var: Option<&str> = None;
77            for v in &vars {
78                if let Some(binding) = row.get(v.as_str()) {
79                    if binding.get("type").and_then(|t| t.as_str()) == Some("uri") {
80                        subject_var = Some(v.as_str());
81                        break;
82                    }
83                }
84            }
85
86            let subject = match subject_var {
87                Some(v) => {
88                    let val = row
89                        .get(v)
90                        .and_then(|b| b.get("value"))
91                        .and_then(|v| v.as_str())
92                        .unwrap_or("");
93                    generate_60bit_token(val.as_bytes())
94                }
95                None => generate_60bit_token(format!("urn:sparql:row:{}", i).as_bytes()),
96            };
97
98            // Emit a quin for every OTHER bound variable in this row.
99            for v in &vars {
100                if Some(v.as_str()) == subject_var {
101                    continue;
102                }
103                let value = match row
104                    .get(v.as_str())
105                    .and_then(|b| b.get("value"))
106                    .and_then(|v| v.as_str())
107                {
108                    Some(val) => val,
109                    None => continue, // variable not bound in this row
110                };
111                let predicate = generate_60bit_token(format!("urn:sparql:var:{}", v).as_bytes());
112                let object = generate_60bit_token(value.as_bytes());
113                quins.push(quin(subject, predicate, object));
114            }
115        }
116
117        Ok(quins)
118    }
119}
120
121impl DataAdapter for SparqlAdapter {
122    fn adapter_id(&self) -> &'static str {
123        self.id
124    }
125
126    fn build_fetch_request(
127        &self,
128        bbox: (f64, f64, f64, f64),
129        _time_range: (u64, u64),
130        registry: &NetworkDisclosureRegistry,
131    ) -> Result<AdapterHttpRequest, String> {
132        if !registry.check_egress_consent(self.adapter_id(), &self.endpoint) {
133            return Err(format!(
134                "Consent denied or unregistered for SPARQL endpoint {}",
135                self.endpoint
136            ));
137        }
138
139        // Construct geospatial SPARQL query. Using wdt:P625 for Earth-based coordinates.
140        // Bounding box: west, south, east, north -> bbox.0, bbox.1, bbox.2, bbox.3
141        let sparql_query = format!(
142            "SELECT ?item ?itemLabel ?location WHERE {{
143  ?item wdt:P625 ?location.
144  SERVICE wikibase:box {{
145    ?item wdt:P625 ?location.
146    bd:serviceParam wikibase:cornerSouthWest \"Point({} {})\"^^geo:wktLiteral.
147    bd:serviceParam wikibase:cornerNorthEast \"Point({} {})\"^^geo:wktLiteral.
148  }}
149  SERVICE wikibase:label {{ bd:serviceParam wikibase:language \"[AUTO_LANGUAGE],en\". }}
150}}",
151            bbox.0, bbox.1, bbox.2, bbox.3
152        );
153
154        let url = format!(
155            "{}?query={}&format=json",
156            self.endpoint,
157            urlencoding::encode(&sparql_query)
158        );
159
160        Ok(AdapterHttpRequest::get(url, "SPARQL"))
161    }
162
163    fn parse_response(&self, body: &str) -> Result<Vec<crate::NQuin>, String> {
164        self.parse_features(body)
165    }
166
167    fn primary_endpoint(&self) -> &str {
168        &self.endpoint
169    }
170
171    fn estimate_tile_count(&self, _bbox: (f64, f64, f64, f64)) -> u32 {
172        1 // A single complex SPARQL query.
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn test_sparql_adapter_consent_denied() {
182        let registry = NetworkDisclosureRegistry::new();
183        let adapter = SparqlAdapter::new("sparql_adapter", "https://query.wikidata.org/sparql");
184
185        let res = adapter.fetch_region((0.0, 0.0, 1.0, 1.0), (0, 0), &registry);
186        assert!(res.is_err());
187    }
188
189    #[test]
190    fn test_sparql_results_parse_features() {
191        use crate::query::lexicon::generate_60bit_token;
192
193        let body = r#"{"head":{"vars":["item","itemLabel","coord"]},
194 "results":{"bindings":[
195   {"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q42"},
196    "itemLabel":{"type":"literal","value":"Douglas Adams"},
197    "coord":{"type":"literal","value":"Point(-0.13 51.5)"}},
198   {"item":{"type":"uri","value":"http://www.wikidata.org/entity/Q64"},
199    "itemLabel":{"type":"literal","value":"Berlin"}}
200 ]}}"#;
201
202        let adapter = SparqlAdapter::new("sparql_adapter", "https://query.wikidata.org/sparql");
203        let quins = adapter.parse_features(body).expect("parse should succeed");
204
205        // Row1 (Q42): itemLabel + coord => 2 quins. Row2 (Q64): itemLabel => 1 quin. Total 3.
206        assert_eq!(quins.len(), 3, "expected 3 quins, got {}", quins.len());
207
208        // The Q42 subject must have an `urn:sparql:var:itemLabel` quin with the Douglas Adams value.
209        let q42_subject = generate_60bit_token(b"http://www.wikidata.org/entity/Q42");
210        let label_pred = generate_60bit_token(b"urn:sparql:var:itemLabel");
211        let douglas_obj = generate_60bit_token(b"Douglas Adams");
212
213        let found = quins.iter().any(|q| {
214            q.subject == q42_subject
215                && q.predicate == label_pred
216                && q.object == douglas_obj
217                && q.parity == (q.subject ^ q.predicate ^ q.object)
218        });
219        assert!(found, "expected Q42 itemLabel=Douglas Adams quin not found");
220    }
221}