Skip to main content

shifty_engine/
sparql.rs

1//! Shared SPARQL execution over an Oxigraph default graph.
2//!
3//! Queries are parsed and canonicalized by `shifty-parse`. This layer caches
4//! Oxigraph's prepared form, applies SHACL prebindings, and executes against a
5//! store that is kept in sync with rule inference.
6//!
7//! During inference, the executor owns a mutable Oxigraph `Store`: rules add
8//! triples between firings, so the store is the authoritative query dataset.
9//! During validation, it instead owns a [`FrozenIndexedDataset`], allowing the
10//! supported relational subset to run over Shifty's compact indexes without
11//! allocating a second mutable store. The public methods hide that split; their
12//! result semantics are the same in either mode.
13//!
14//! A constraint query first receives its static SHACL bindings (`$PATH`, shape,
15//! component parameters, and `$shapesGraph`) and is cached under that rewritten
16//! form. `$this` stays dynamic so one compiled query can serve a focus batch.
17//! The cached query either lowers to `NativeQueryPlan` or falls back to Spareval;
18//! debug builds compare the native result with the fallback oracle. This keeps
19//! the fast path a replaceable implementation detail rather than a separate
20//! dialect of SPARQL.
21
22use crate::frozen::{FrozenIndexedDataset, TermId};
23use crate::native_exec;
24use crate::profile;
25use crate::validate::UnsupportedPolicy;
26use oxigraph::sparql::{PreparedSparqlQuery, QueryResults, SparqlEvaluator};
27use oxigraph::store::Store;
28use oxrdf::{
29    Graph, GraphName, GraphNameRef, Literal, NamedNode, NamedOrBlankNode, Quad, QuadRef, Term,
30    Triple, Variable,
31};
32use serde::{Deserialize, Serialize};
33use shifty_algebra::{Path, SparqlConstraint};
34use shifty_opt::{NativeQueryPlan, QueryForm, lower_query_with_stats};
35use spargebra::algebra::{
36    AggregateExpression, Expression, GraphPattern, OrderExpression, PropertyPathExpression,
37};
38use spargebra::term::{NamedNodePattern, TermPattern, TriplePattern};
39use spargebra::{Query, SparqlParser};
40use std::cell::RefCell;
41use std::collections::{HashMap, HashSet};
42use std::rc::Rc;
43
44/// The named graph the shapes graph is loaded under so `GRAPH $shapesGraph {…}`
45/// can be evaluated; `$shapesGraph` is pre-bound to this IRI.
46pub(crate) use crate::frozen::SHAPES_GRAPH_IRI;
47
48pub(crate) struct SparqlExecutor {
49    /// Mutable store used by inference. Validation evaluates directly over
50    /// `frozen`, so it does not allocate this second indexed representation.
51    store: Option<Store>,
52    /// Frozen dataset used for constraint/target SPARQL during validation. `None`
53    /// during inference (where the store mutates between rule firings).
54    frozen: Option<FrozenIndexedDataset>,
55    prepared: RefCell<HashMap<String, PreparedSparqlQuery>>,
56    parsed: RefCell<HashMap<String, Query>>,
57    /// Per-constraint compilation cache (doc §72): static SHACL substitutions
58    /// applied once, then either a native plan or a fallback query. Keyed by the
59    /// constraint's query plus its static bindings (path / shape).
60    compiled: RefCell<HashMap<CompileKey, Rc<Compiled>>>,
61    /// Native plans for SPARQL rule WHERE clauses, keyed by canonical query.
62    constructs: RefCell<HashMap<String, Rc<CompiledConstruct>>>,
63    /// CONSTRUCT-rule queries whose per-focus probe blew its time budget once
64    /// (doc §189): subsequent firings within this inference run skip the probe and
65    /// go straight to the batched free-`?this` run. Memoizing the decision avoids
66    /// re-paying the probe budget on every fixpoint pass.
67    batch_construct: RefCell<HashSet<String>>,
68    /// IRI of the loaded shapes graph, pre-bound to `$shapesGraph`. `None` when
69    /// no shapes graph was loaded (so `$shapesGraph` is left unsupported).
70    shapes_graph: Option<NamedNode>,
71    /// `sh:SPARQLFunction`s registered as custom SPARQL functions, so calls to
72    /// them inside constraint / CONSTRUCT / expression queries resolve. Empty
73    /// unless [`set_functions`](Self::set_functions) was called.
74    functions: Vec<FunctionDef>,
75    /// How partially supported features are handled (see [`UnsupportedPolicy`]).
76    unsupported: UnsupportedPolicy,
77}
78
79/// A `sh:SPARQLFunction` (SHACL-AF §5) registered as a custom SPARQL function.
80///
81/// Evaluated as a **pure** function of its arguments: the body runs over an
82/// empty dataset, so computational functions (math, string, URI building) work,
83/// but a body that reads the data graph is not supported in SPARQL contexts —
84/// the frozen dataset is `Rc`-based and cannot cross the `Send + Sync` boundary
85/// the custom-function closure requires. (Node-expression function calls keep
86/// full graph access via [`SparqlExecutor::call_sparql_function`].)
87#[derive(Clone)]
88pub(crate) struct FunctionDef {
89    pub iri: NamedNode,
90    /// Parameter variable names, in positional call order.
91    pub params: Vec<String>,
92    /// The function body (`sh:select` or `sh:ask`), prefix-expanded.
93    pub query: String,
94    /// Whether the body reads the data graph (has a non-trivial WHERE pattern).
95    /// Such functions only return correct results in node expressions, where the
96    /// real dataset is available; from a SPARQL context they run over an empty
97    /// dataset, so `UnsupportedPolicy::Error` declines to register them.
98    pub reads_graph: bool,
99}
100
101/// Cache key for a compiled constraint: the canonical query plus the static
102/// bindings that change its plan. `$this` is bound per focus and so is *not*
103/// part of the key (one plan serves every focus node).
104#[derive(PartialEq, Eq, Hash)]
105struct CompileKey {
106    query: String,
107    path: Option<String>,
108    shape: Option<String>,
109}
110
111/// A compiled constraint. `query` is the statically substituted Spargebra query
112/// with `$this` left free — used for fallback execution and, in debug builds, as
113/// the differential oracle. `plan` is present when the query lowered to the
114/// native subset.
115struct Compiled {
116    plan: Option<NativeQueryPlan>,
117    query: Query,
118    /// The first unsupported construct `lower_query` hit, when the query did
119    /// not lower to a native plan and a frozen dataset was available to lower
120    /// against (`None` when native lowering was never attempted, e.g. during
121    /// inference). Discarded by every call site until [`SparqlDiagnostic`]
122    /// needed it (doc §143: "recording the first unsupported construct as the
123    /// fallback reason for inspection and telemetry").
124    fallback_reason: Option<String>,
125    /// Per-focus violations precomputed in one batched fallback run (doc §189):
126    /// the SELECT analog of the global CONSTRUCT batching. Populated by
127    /// [`SparqlExecutor::prefetch_constraint`]; `None` until then. Only the
128    /// per-focus Spareval fallback is batched here — native plans already
129    /// evaluate a focus batch efficiently and keep the debug differential gate.
130    batched: RefCell<Option<BatchedResults>>,
131}
132
133/// Debugging detail for one SPARQL-backed constraint evaluation (`sh:sparql`
134/// or a custom SPARQL-based constraint component), meant to be attached to a
135/// violating report result so a failure is never a dead end: it shows exactly
136/// what query ran, what it was bound to, and what rows it actually returned.
137#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
138pub struct SparqlDiagnostic {
139    /// The query actually executed, after every static SHACL substitution
140    /// (`$PATH`, `$currentShape`, `$shapesGraph`, custom-component parameters).
141    /// `$this` is left as a free variable in this text; its per-focus binding
142    /// is the first entry of `bindings`.
143    pub query: String,
144    /// Every SHACL prebinding applied before execution, in application order:
145    /// `$this`, then `$currentShape`/`$shapesGraph` when present, then any
146    /// custom-component parameters (SHACL §6.2.3).
147    pub bindings: Vec<(String, Term)>,
148    /// The solution rows the query actually produced (one entry per row, each
149    /// a variable-name-to-term list in projection order). For `ASK` queries
150    /// this is always empty — the answer is captured by whether any
151    /// [`ValidationResult`](crate::report::ValidationResult)/[`Reason`](crate::validate::Reason)
152    /// exists at all, not by a bound row.
153    pub results: Vec<Vec<(String, Term)>>,
154    /// Why native lowering did not apply, when known: the first unsupported
155    /// construct `lower_query` hit. `None` when the query ran natively, or
156    /// when it's a custom constraint component (always opaque today — see
157    /// `SparqlExecutor::eval_ask` / `SparqlExecutor::eval_select`).
158    pub fallback_reason: Option<String>,
159}
160
161/// Results of a batched fallback run. `covered` is the focus set the single
162/// query actually ranged over (named-node foci only — blank-node foci cannot be
163/// matched back from relabeled query results); a focus outside it falls through
164/// to per-focus execution.
165struct BatchedResults {
166    covered: HashSet<Term>,
167    map: HashMap<Term, Vec<SparqlViolation>>,
168}
169
170struct CompiledConstruct {
171    plan: Option<NativeQueryPlan>,
172    template: Vec<TriplePattern>,
173}
174
175#[derive(Debug, Clone)]
176pub(crate) struct SparqlViolation {
177    pub value: Option<Term>,
178    pub path: Option<Term>,
179    /// The `?message` solution binding (SHACL §5.2.1) — a per-result, human
180    /// authored explanation. `None` for `ASK` constraints and `SELECT` queries
181    /// that do not project `?message`.
182    pub message: Option<Term>,
183    /// All projected solution bindings, keyed by variable name (without `?`).
184    /// Used to substitute `{?varName}` placeholders in `sh:message` templates.
185    pub bindings: HashMap<String, Term>,
186}
187
188impl SparqlExecutor {
189    pub fn new(graph: &Graph) -> Result<Self, String> {
190        Self::build(graph, None)
191    }
192
193    fn build(context: &Graph, shapes: Option<&Graph>) -> Result<Self, String> {
194        let store = Store::new().map_err(|e| e.to_string())?;
195        store
196            .extend(context.iter().map(|triple| {
197                Quad::new(
198                    triple.subject.into_owned(),
199                    triple.predicate.into_owned(),
200                    triple.object.into_owned(),
201                    GraphName::DefaultGraph,
202                )
203            }))
204            .map_err(|e| e.to_string())?;
205        let shapes_graph = match shapes {
206            Some(shapes) => {
207                let iri = NamedNode::new(SHAPES_GRAPH_IRI).expect("static IRI is valid");
208                store
209                    .extend(shapes.iter().map(|triple| {
210                        Quad::new(
211                            triple.subject.into_owned(),
212                            triple.predicate.into_owned(),
213                            triple.object.into_owned(),
214                            GraphName::NamedNode(iri.clone()),
215                        )
216                    }))
217                    .map_err(|e| e.to_string())?;
218                Some(iri)
219            }
220            None => None,
221        };
222        Ok(Self {
223            store: Some(store),
224            frozen: None,
225            prepared: RefCell::new(HashMap::new()),
226            parsed: RefCell::new(HashMap::new()),
227            compiled: RefCell::new(HashMap::new()),
228            constructs: RefCell::new(HashMap::new()),
229            batch_construct: RefCell::new(HashSet::new()),
230            shapes_graph,
231            functions: Vec::new(),
232            unsupported: UnsupportedPolicy::default(),
233        })
234    }
235
236    pub fn from_frozen(frozen: FrozenIndexedDataset, has_shapes_graph: bool) -> Self {
237        Self {
238            store: None,
239            frozen: Some(frozen),
240            prepared: RefCell::new(HashMap::new()),
241            parsed: RefCell::new(HashMap::new()),
242            compiled: RefCell::new(HashMap::new()),
243            constructs: RefCell::new(HashMap::new()),
244            batch_construct: RefCell::new(HashSet::new()),
245            shapes_graph: has_shapes_graph
246                .then(|| NamedNode::new(SHAPES_GRAPH_IRI).expect("static IRI is valid")),
247            functions: Vec::new(),
248            unsupported: UnsupportedPolicy::default(),
249        }
250    }
251
252    /// Register `sh:SPARQLFunction`s so that calls to them inside constraint,
253    /// CONSTRUCT, target, and expression queries resolve. `policy` decides what
254    /// happens to graph-reading function bodies (which this layer can only run
255    /// as pure functions over an empty dataset).
256    pub(crate) fn set_functions(&mut self, functions: Vec<FunctionDef>, policy: UnsupportedPolicy) {
257        self.functions = functions;
258        self.unsupported = policy;
259    }
260
261    /// A fresh evaluator with every registered SHACL function available as a
262    /// custom SPARQL function. Use in place of `SparqlEvaluator::new()` for any
263    /// Spareval execution so function calls in the query resolve. When no
264    /// functions are registered this is exactly `SparqlEvaluator::new()`.
265    ///
266    /// Under [`UnsupportedPolicy::Error`], a graph-reading function is *not*
267    /// registered: a SPARQL call to it then raises an unknown-function error
268    /// (surfaced as a constraint failure) rather than silently evaluating its
269    /// body over an empty dataset.
270    fn evaluator(&self) -> SparqlEvaluator {
271        let mut evaluator = SparqlEvaluator::new();
272        for f in &self.functions {
273            if f.reads_graph && self.unsupported == UnsupportedPolicy::Error {
274                continue;
275            }
276            let query = f.query.clone();
277            let params = f.params.clone();
278            evaluator = evaluator.with_custom_function(f.iri.clone(), move |args| {
279                eval_sparql_function(&query, &params, args)
280            });
281        }
282        evaluator
283    }
284
285    /// Evaluate a SPARQL expression (e.g. a `dash:expression` function call) to
286    /// a single term, with registered SHACL functions available. The expression
287    /// is wrapped as `SELECT ((expr) AS ?result) WHERE {}`.
288    pub(crate) fn evaluate_expression(
289        &self,
290        prologue: &str,
291        expr: &str,
292    ) -> Result<Option<Term>, String> {
293        let wrapped = format!("{prologue}\nSELECT (({expr}) AS ?result) WHERE {{}}");
294        let query = SparqlParser::new().parse_query(&wrapped).map_err(err)?;
295        let results = if let Some(frozen) = &self.frozen {
296            self.evaluator()
297                .for_query(query)
298                .on_queryable_dataset(frozen)
299                .execute()
300                .map_err(err)?
301        } else {
302            self.evaluator()
303                .for_query(query)
304                .on_store(self.store()?)
305                .execute()
306                .map_err(err)?
307        };
308        match results {
309            QueryResults::Solutions(mut solutions) => Ok(solutions
310                .next()
311                .transpose()
312                .map_err(err)?
313                .and_then(|s| s.get("result").cloned())),
314            _ => Err("expression did not produce solutions".to_string()),
315        }
316    }
317
318    /// The attached frozen snapshot, if any. Validation uses it as the indexed
319    /// `PathBackend` for `sh:path` traversal; inference leaves it `None` and
320    /// falls back to its mutable graph.
321    pub(crate) fn frozen(&self) -> Option<&FrozenIndexedDataset> {
322        self.frozen.as_ref()
323    }
324
325    pub fn insert(&self, triple: &Triple) -> Result<(), String> {
326        self.store()?
327            .insert(QuadRef::new(
328                triple.subject.as_ref(),
329                triple.predicate.as_ref(),
330                triple.object.as_ref(),
331                GraphNameRef::DefaultGraph,
332            ))
333            .map_err(|e| e.to_string())
334    }
335
336    pub fn target_nodes(&self, query: &str) -> Result<Vec<Term>, String> {
337        let fp = profile::fingerprint(query);
338        let start = web_time::Instant::now();
339        let results = if let Some(frozen) = &self.frozen {
340            self.prepared(query)?
341                .on_queryable_dataset(frozen)
342                .execute()
343                .map_err(err)?
344        } else {
345            self.prepared(query)?
346                .on_store(self.store()?)
347                .execute()
348                .map_err(err)?
349        };
350        let QueryResults::Solutions(solutions) = results else {
351            return Err("SPARQL target did not produce SELECT solutions".to_string());
352        };
353        let mut nodes = Vec::new();
354        for solution in solutions {
355            if let Some(node) = solution.map_err(err)?.get("this") {
356                nodes.push(node.clone());
357            }
358        }
359        profile::record(
360            &fp,
361            start.elapsed().as_micros() as u64,
362            profile::ExecutorKind::Fallback { reason: None },
363        );
364        Ok(nodes)
365    }
366
367    pub fn constraint_violations(
368        &self,
369        constraint: &SparqlConstraint,
370        focus: &Term,
371    ) -> Result<Vec<SparqlViolation>, String> {
372        let compiled = self.compile_constraint(constraint)?;
373        let fp = profile::fingerprint(&constraint.query);
374        let start = web_time::Instant::now();
375
376        // A prior `prefetch_constraint` may have evaluated this focus in one
377        // batched fallback run (doc §189). Serve from that map when the focus is
378        // covered; uncovered foci (e.g. blank nodes) fall through to live exec.
379        if let Some(batched) = compiled.batched.borrow().as_ref()
380            && batched.covered.contains(focus)
381        {
382            let violations = batched.map.get(focus).cloned().unwrap_or_default();
383            profile::record(
384                &fp,
385                start.elapsed().as_micros() as u64,
386                profile::ExecutorKind::Fallback { reason: None },
387            );
388            return Ok(violations);
389        }
390
391        if let Some(plan) = &compiled.plan {
392            let violations = self.run_native(plan, focus);
393            // Differential gate (doc §254): in debug builds, every natively
394            // executed query is re-run through Spareval over the same frozen
395            // dataset and the two violation multisets must match exactly.
396            #[cfg(debug_assertions)]
397            {
398                let reference = self.run_fallback(&compiled.query, focus)?;
399                assert_violations_match(&constraint.query, focus, &violations, &reference);
400            }
401            profile::record(
402                &fp,
403                start.elapsed().as_micros() as u64,
404                profile::ExecutorKind::Native,
405            );
406            Ok(violations)
407        } else {
408            let result = self.run_fallback(&compiled.query, focus)?;
409            profile::record(
410                &fp,
411                start.elapsed().as_micros() as u64,
412                profile::ExecutorKind::Fallback { reason: None },
413            );
414            Ok(result)
415        }
416    }
417
418    /// Apply the static SHACL substitutions to a constraint's query and, when a
419    /// frozen dataset is available, try to lower it to the native subset. The
420    /// result is cached: `$this` is bound per focus, so one compiled entry serves
421    /// every focus node (doc §72).
422    fn compile_constraint(&self, constraint: &SparqlConstraint) -> Result<Rc<Compiled>, String> {
423        let key = CompileKey {
424            query: constraint.query.clone(),
425            path: constraint.path.as_ref().map(path_key),
426            shape: constraint.shape.as_ref().map(|s| s.to_string()),
427        };
428        if let Some(compiled) = self.compiled.borrow().get(&key) {
429            return Ok(compiled.clone());
430        }
431
432        // SHACL pre-binding is *substitution* throughout the query (SHACL
433        // §5.2.1), not an initial binding. `$this` is left free here — the native
434        // executor binds it per focus, and the fallback substitutes it per call.
435        let mut query = self.parse(&constraint.query)?;
436        if let Some(path) = &constraint.path {
437            query = apply_path_prebinding(query, path)?;
438        }
439        if let Some(shape) = &constraint.shape {
440            substitute_query(&mut query, &variable("currentShape"), shape);
441        }
442        if let Some(graph) = &self.shapes_graph {
443            substitute_query(
444                &mut query,
445                &variable("shapesGraph"),
446                &Term::NamedNode(graph.clone()),
447            );
448        }
449        // Custom constraint component parameters (§6.2.3): substitute static
450        // per-activation bindings before `$this` is bound per focus.
451        for (name, value) in &constraint.extra_bindings {
452            substitute_query(&mut query, &variable(name), value);
453        }
454        // For ASK validators on node shapes, $value equals the focus node.
455        // Rename ?value → ?this so the per-focus $this binding covers it.
456        if constraint.bind_value_to_this {
457            rename_variable_in_query(&mut query, &variable("value"), &variable("this"));
458        }
459
460        // Native execution requires the frozen dataset as its storage backend.
461        let (plan, fallback_reason) = match &self.frozen {
462            Some(frozen) => match lower_query_with_stats(&query, Some(&frozen.plan_stats())) {
463                Ok(plan) => (Some(plan), None),
464                Err(reason) => (None, Some(reason)),
465            },
466            None => (None, None),
467        };
468        let compiled = Rc::new(Compiled {
469            plan,
470            query,
471            fallback_reason,
472            batched: RefCell::new(None),
473        });
474        self.compiled.borrow_mut().insert(key, compiled.clone());
475        Ok(compiled)
476    }
477
478    /// Debugging detail for `constraint` at `focus`: the executed query text,
479    /// the applied SHACL bindings, and the solution rows `violations` actually
480    /// carried back (the same violations [`Self::constraint_violations`] just
481    /// returned — passed in rather than recomputed, since a fallback query has
482    /// no other side-channel for its result rows). Reuses the same compilation
483    /// cache as `constraint_violations`, so this is cheap to call again once a
484    /// violation is already known — it is meant to be called only then, not on
485    /// the hot per-focus evaluation path.
486    pub fn constraint_diagnostic(
487        &self,
488        constraint: &SparqlConstraint,
489        focus: &Term,
490        violations: &[SparqlViolation],
491    ) -> Result<SparqlDiagnostic, String> {
492        let compiled = self.compile_constraint(constraint)?;
493        let mut bindings = vec![("this".to_string(), focus.clone())];
494        if let Some(shape) = &constraint.shape {
495            bindings.push(("currentShape".to_string(), shape.clone()));
496        }
497        if let Some(graph) = &self.shapes_graph {
498            bindings.push(("shapesGraph".to_string(), Term::NamedNode(graph.clone())));
499        }
500        bindings.extend(constraint.extra_bindings.iter().cloned());
501        let results = violations
502            .iter()
503            .map(|v| {
504                let mut row: Vec<(String, Term)> = v
505                    .bindings
506                    .iter()
507                    .map(|(k, t)| (k.clone(), t.clone()))
508                    .collect();
509                row.sort_by(|a, b| a.0.cmp(&b.0));
510                row
511            })
512            .collect();
513        Ok(SparqlDiagnostic {
514            query: compiled.query.to_string(),
515            bindings,
516            results,
517            fallback_reason: compiled.fallback_reason.clone(),
518        })
519    }
520
521    /// Execute a native plan for one focus node, mapping its solutions to SHACL
522    /// violations.
523    fn run_native(&self, plan: &NativeQueryPlan, focus: &Term) -> Vec<SparqlViolation> {
524        let frozen = self
525            .frozen
526            .as_ref()
527            .expect("native plan implies a frozen dataset (compile_constraint guards this)");
528        let foci = [focus.clone()];
529        let result = native_exec::execute(plan, frozen, &foci);
530        let solutions = &result.solutions[0];
531        match result.form {
532            // ASK: any solution is a violation, matching the fallback's Boolean.
533            QueryForm::Ask => {
534                if solutions.is_empty() {
535                    Vec::new()
536                } else {
537                    vec![SparqlViolation {
538                        value: None,
539                        path: None,
540                        message: None,
541                        bindings: HashMap::new(),
542                    }]
543                }
544            }
545            QueryForm::Select => solutions
546                .iter()
547                .map(|b| SparqlViolation {
548                    value: b.get("value").cloned(),
549                    path: b.get("path").cloned(),
550                    message: b.get("message").cloned(),
551                    bindings: b.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
552                })
553                .collect(),
554        }
555    }
556
557    /// Execute a statically substituted query through Spareval (over the frozen
558    /// dataset when present, else the Store), substituting `$this` for this call.
559    fn run_fallback(&self, query: &Query, focus: &Term) -> Result<Vec<SparqlViolation>, String> {
560        let mut query = query.clone();
561        substitute_query(&mut query, &variable("this"), focus);
562        let prepared = self.evaluator().for_query(query);
563        let query_result = if let Some(frozen) = &self.frozen {
564            prepared
565                .on_queryable_dataset(frozen)
566                .execute()
567                .map_err(err)?
568        } else {
569            prepared.on_store(self.store()?).execute().map_err(err)?
570        };
571        match query_result {
572            QueryResults::Solutions(solutions) => {
573                let vars: Vec<String> = solutions
574                    .variables()
575                    .iter()
576                    .map(|v| v.as_str().to_string())
577                    .collect();
578                solutions
579                    .map(|solution| {
580                        let solution = solution.map_err(err)?;
581                        let bindings = vars
582                            .iter()
583                            .filter_map(|name| {
584                                solution
585                                    .get(name.as_str())
586                                    .map(|t| (name.clone(), t.clone()))
587                            })
588                            .collect();
589                        Ok(SparqlViolation {
590                            value: solution.get("value").cloned(),
591                            path: solution.get("path").cloned(),
592                            message: solution.get("message").cloned(),
593                            bindings,
594                        })
595                    })
596                    .collect()
597            }
598            QueryResults::Boolean(violates) => Ok(if violates {
599                vec![SparqlViolation {
600                    value: None,
601                    path: None,
602                    message: None,
603                    bindings: HashMap::new(),
604                }]
605            } else {
606                Vec::new()
607            }),
608            QueryResults::Graph(_) => {
609                Err("SPARQL constraint unexpectedly produced graph results".to_string())
610            }
611        }
612    }
613
614    /// Evaluate a fallback (non-native) constraint over a whole focus set in one
615    /// `VALUES`-injected query, caching the per-focus violations on the compiled
616    /// entry (doc §189). Subsequent [`Self::constraint_violations`] calls for any
617    /// covered focus are served from the cache instead of re-running Spareval
618    /// once per node. A no-op when the constraint lowered to a native plan
619    /// (already batched), when fewer than two named-node foci are supplied, or
620    /// when the query is outside the safe-to-batch subset.
621    pub fn prefetch_constraint(
622        &self,
623        constraint: &SparqlConstraint,
624        foci: &[Term],
625    ) -> Result<(), String> {
626        let compiled = self.compile_constraint(constraint)?;
627        if compiled.plan.is_some() || compiled.batched.borrow().is_some() {
628            return Ok(());
629        }
630        if foci.len() < 2 {
631            return Ok(());
632        }
633        let fp = profile::fingerprint(&constraint.query);
634        let start = web_time::Instant::now();
635        if let Some(batched) = self.run_fallback_batch(&compiled.query, foci)? {
636            *compiled.batched.borrow_mut() = Some(batched);
637            profile::record(
638                &fp,
639                start.elapsed().as_micros() as u64,
640                profile::ExecutorKind::Fallback { reason: None },
641            );
642        }
643        Ok(())
644    }
645
646    /// Evaluate a constraint over a whole focus set in one free-`?this` run and
647    /// bucket the solutions by `?this` (see [`plan_batch_query`]). Returns the
648    /// covered named foci alongside their violations, or `None` when the query is
649    /// outside the safe-batch subset.
650    fn run_fallback_batch(
651        &self,
652        query: &Query,
653        foci: &[Term],
654    ) -> Result<Option<BatchedResults>, String> {
655        let Some((batched, covered)) = plan_batch_query(query, foci) else {
656            return Ok(None);
657        };
658        let covered: HashSet<Term> = covered.into_iter().collect();
659        let prepared = self.evaluator().for_query(batched);
660        let query_result = if let Some(frozen) = &self.frozen {
661            prepared
662                .on_queryable_dataset(frozen)
663                .execute()
664                .map_err(err)?
665        } else {
666            prepared.on_store(self.store()?).execute().map_err(err)?
667        };
668        let QueryResults::Solutions(solutions) = query_result else {
669            return Ok(None);
670        };
671        let vars: Vec<String> = solutions
672            .variables()
673            .iter()
674            .map(|v| v.as_str().to_string())
675            .collect();
676        let mut map: HashMap<Term, Vec<SparqlViolation>> = HashMap::new();
677        for solution in solutions {
678            let solution = solution.map_err(err)?;
679            let Some(this) = solution.get("this") else {
680                continue;
681            };
682            // The free-`?this` strategy enumerates every matching `?this`, not
683            // just our foci; keep only solutions for foci we were asked about.
684            if !covered.contains(this) {
685                continue;
686            }
687            // Mirror the per-focus binding set: `$this` is substituted away on the
688            // per-focus path, so it must not appear as a violation binding here
689            // (it would otherwise leak into `{?this}` message templates).
690            let bindings = vars
691                .iter()
692                .filter(|name| name.as_str() != "this")
693                .filter_map(|name| {
694                    solution
695                        .get(name.as_str())
696                        .map(|t| (name.clone(), t.clone()))
697                })
698                .collect();
699            map.entry(this.clone()).or_default().push(SparqlViolation {
700                value: solution.get("value").cloned(),
701                path: solution.get("path").cloned(),
702                message: solution.get("message").cloned(),
703                bindings,
704            });
705        }
706        Ok(Some(BatchedResults { covered, map }))
707    }
708
709    /// Execute a SPARQL CONSTRUCT rule for multiple focus nodes.
710    pub fn construct_many(
711        &self,
712        query: &str,
713        foci: &[Term],
714        frozen: Option<&FrozenIndexedDataset>,
715    ) -> Result<Vec<Triple>, String> {
716        const BATCH_SIZE: usize = 2048;
717
718        if foci.is_empty() {
719            return Ok(Vec::new());
720        }
721
722        let fp = profile::fingerprint(query);
723        let start = web_time::Instant::now();
724        let mut triples = Vec::new();
725        let compiled = self.compile_construct(query)?;
726        let executor = if let (Some(plan), Some(frozen)) = (&compiled.plan, frozen) {
727            for chunk in foci.chunks(BATCH_SIZE) {
728                let result = native_exec::execute_ids(plan, frozen, chunk);
729                for (focus_index, solutions) in result.solutions.into_iter().enumerate() {
730                    for bindings in solutions {
731                        instantiate_template(
732                            &compiled.template,
733                            plan,
734                            frozen,
735                            &chunk[focus_index],
736                            &bindings,
737                            &mut triples,
738                        );
739                    }
740                }
741            }
742            profile::ExecutorKind::Native
743        } else {
744            // A single free-`?this` run binds `?this` to every node matching the
745            // WHERE clause and emits the union of what the per-focus runs would,
746            // so filtering the output by subject identity recovers the per-focus
747            // results exactly. This is the CONSTRUCT analog of the batched-fallback
748            // SELECT path (doc §189): for aggregate / sub-SELECT bodies it computes
749            // the `GROUP BY ?this` once over the whole graph instead of re-running
750            // it per focus.
751            //
752            // Soundness preconditions: every template triple's subject is `?this`
753            // (so output subjects identify their focus), and `?this` is positively
754            // bound by the required part of the WHERE clause (so the free run
755            // actually constrains it). When `?this` is unbound by the body — e.g.
756            // `CONSTRUCT { ?this ex:p [] } WHERE {}` — the free run produces nothing
757            // while per-focus produces a triple per focus.
758            //
759            // Cost gate: the free run scans the whole graph once regardless of how
760            // many foci there are, while per-focus cost grows with the focus set.
761            // Rather than guess a focus-count threshold, we *time-box* the per-focus
762            // probe: run foci one at a time and, if the loop spends more than
763            // `PROBE_BUDGET` with foci still pending, abandon the partial work and
764            // do one free run. Budget ≈ the whole-graph free-run cost, so we bail
765            // exactly when per-focus has already cost as much as a full free run and
766            // is not done — the point past which batching is the cheaper path. Small
767            // / medium focus sets finish under budget and stay on the fast per-focus
768            // path; large sets (where per-focus also starts to blow up) bail to the
769            // batched run. The decision is memoized per query so later fixpoint
770            // passes skip the probe instead of re-paying the budget each time.
771            const PROBE_BUDGET: std::time::Duration = std::time::Duration::from_secs(2);
772            let this_var = variable("this");
773            let parsed = self.parse(query)?;
774            let body_binds_this = matches!(
775                &parsed,
776                Query::Construct { pattern, .. } if this_required(pattern, &this_var)
777            );
778            let subject_is_focus = !compiled.template.is_empty()
779                && compiled
780                    .template
781                    .iter()
782                    .all(|t| term_pattern_is(&t.subject, &this_var))
783                && body_binds_this;
784            drop(parsed);
785
786            let blank_foci = || foci.iter().filter(|f| matches!(f, Term::BlankNode(_)));
787
788            if subject_is_focus && self.batch_construct.borrow().contains(query) {
789                // A prior firing of this query already bailed; batch directly over
790                // all foci (named and blank), skipping the per-focus probe.
791                let foci_set: HashSet<Term> = foci.iter().cloned().collect();
792                self.construct_free_filtered(query, &foci_set, &mut triples)?;
793            } else if subject_is_focus {
794                // Probe per-focus with a wall-clock budget; bail to one free run if
795                // it runs long with foci still pending.
796                let named: Vec<&Term> = foci
797                    .iter()
798                    .filter(|f| !matches!(f, Term::BlankNode(_)))
799                    .collect();
800                let deadline = web_time::Instant::now() + PROBE_BUDGET;
801                let mut probed = Vec::new();
802                let mut bailed = false;
803                for (i, focus) in named.iter().enumerate() {
804                    probed.extend(self.construct_one(query, focus)?);
805                    if i + 1 < named.len() && web_time::Instant::now() >= deadline {
806                        bailed = true;
807                        break;
808                    }
809                }
810                if bailed {
811                    // Discard the partial per-focus work and cover every focus
812                    // (named + blank) with one free run. Remember the decision.
813                    self.batch_construct.borrow_mut().insert(query.to_string());
814                    let foci_set: HashSet<Term> = foci.iter().cloned().collect();
815                    self.construct_free_filtered(query, &foci_set, &mut triples)?;
816                } else {
817                    triples.append(&mut probed);
818                    // Blank-node foci can't be matched by per-focus substitution
819                    // (sparopt rewrites `TermPattern::BlankNode` to a fresh
820                    // variable), so they always take the free run, filtered by
821                    // blank-node subject identity.
822                    if blank_foci().next().is_some() {
823                        let blank_set: HashSet<Term> = blank_foci().cloned().collect();
824                        self.construct_free_filtered(query, &blank_set, &mut triples)?;
825                    }
826                }
827            } else {
828                // Not batchable (template subject is not `?this`, or `?this` is
829                // unbound by the body). Named-node foci: pre-bind ?this via algebra
830                // substitution so Oxigraph can use subject–predicate index lookups.
831                for focus in foci.iter().filter(|f| !matches!(f, Term::BlankNode(_))) {
832                    triples.extend(self.construct_one(query, focus)?);
833                }
834                if blank_foci().next().is_some() {
835                    let blank_set: HashSet<Term> = blank_foci().cloned().collect();
836                    self.construct_free_filtered(query, &blank_set, &mut triples)?;
837                }
838            }
839
840            let store = self.store()?;
841            triples.retain(|triple| {
842                !store
843                    .contains(QuadRef::new(
844                        triple.subject.as_ref(),
845                        triple.predicate.as_ref(),
846                        triple.object.as_ref(),
847                        GraphNameRef::DefaultGraph,
848                    ))
849                    .unwrap_or(false)
850            });
851            profile::ExecutorKind::Fallback { reason: None }
852        };
853
854        profile::record(&fp, start.elapsed().as_micros() as u64, executor);
855        Ok(triples)
856    }
857
858    /// Return focus nodes whose native CONSTRUCT WHERE solutions involve the
859    /// supplied default-graph delta. `None` requests full-focus evaluation
860    /// because this query is not in the supported differential subset.
861    pub fn construct_delta_foci(
862        &self,
863        query: &str,
864        delta: &[Triple],
865        frozen: Option<&FrozenIndexedDataset>,
866    ) -> Result<Option<HashSet<Term>>, String> {
867        let compiled = self.compile_construct(query)?;
868        let (Some(plan), Some(frozen)) = (&compiled.plan, frozen) else {
869            return Ok(None);
870        };
871        Ok(
872            native_exec::delta_focus_ids(plan, frozen, delta).map(|ids| {
873                ids.into_iter()
874                    .filter_map(|id| frozen.externalize(id))
875                    .collect()
876            }),
877        )
878    }
879
880    fn compile_construct(&self, query: &str) -> Result<Rc<CompiledConstruct>, String> {
881        if let Some(compiled) = self.constructs.borrow().get(query) {
882            return Ok(compiled.clone());
883        }
884        let parsed = self.parse(query)?;
885        let Query::Construct {
886            template,
887            dataset,
888            pattern,
889            base_iri,
890        } = parsed
891        else {
892            return Err("SPARQL rule did not contain a CONSTRUCT query".to_string());
893        };
894
895        let plan = if dataset.is_none() && !template.iter().any(triple_has_blank_node) {
896            let stats = self.frozen.as_ref().map(|f| f.plan_stats());
897            lower_query_with_stats(
898                &Query::Select {
899                    dataset: None,
900                    pattern,
901                    base_iri,
902                },
903                stats.as_ref(),
904            )
905            .ok()
906        } else {
907            None
908        };
909        let compiled = Rc::new(CompiledConstruct { plan, template });
910        self.constructs
911            .borrow_mut()
912            .insert(query.to_string(), compiled.clone());
913        Ok(compiled)
914    }
915
916    fn construct_one(&self, query: &str, focus: &Term) -> Result<Vec<Triple>, String> {
917        let mut query = self.parse(query)?;
918        substitute_query(&mut query, &variable("this"), focus);
919        let prepared = self.evaluator().for_query(query);
920        match prepared.on_store(self.store()?).execute().map_err(err)? {
921            QueryResults::Graph(triples) => triples.map(|triple| triple.map_err(err)).collect(),
922            _ => Err("SPARQL rule did not produce CONSTRUCT graph results".to_string()),
923        }
924    }
925
926    /// Run a CONSTRUCT rule once with `?this` free and append the output triples
927    /// whose subject is in `foci_set`. This is the batched analog of
928    /// [`construct_one`]: one whole-graph evaluation covers every focus, and a
929    /// `subject == ?this` template makes the output subject identify its focus.
930    fn construct_free_filtered(
931        &self,
932        query: &str,
933        foci_set: &HashSet<Term>,
934        out: &mut Vec<Triple>,
935    ) -> Result<(), String> {
936        match self
937            .evaluator()
938            .for_query(self.parse(query)?)
939            .on_store(self.store()?)
940            .execute()
941            .map_err(err)?
942        {
943            QueryResults::Graph(iter) => {
944                for triple in iter {
945                    let triple = triple.map_err(err)?;
946                    if foci_set.contains(&Term::from(triple.subject.clone())) {
947                        out.push(triple);
948                    }
949                }
950                Ok(())
951            }
952            _ => Err("SPARQL rule did not produce CONSTRUCT graph results".to_string()),
953        }
954    }
955
956    fn prepared(&self, query: &str) -> Result<PreparedSparqlQuery, String> {
957        if let Some(prepared) = self.prepared.borrow().get(query) {
958            return Ok(prepared.clone());
959        }
960        let prepared = self.evaluator().parse_query(query).map_err(err)?;
961        self.prepared
962            .borrow_mut()
963            .insert(query.to_string(), prepared.clone());
964        Ok(prepared)
965    }
966
967    /// Parse (and cache) a canonical query string into its algebra form. The
968    /// cached `Query` is cloned per call so callers can substitute pre-bound
969    /// variables into it without affecting the cache.
970    fn parse(&self, query: &str) -> Result<Query, String> {
971        if let Some(parsed) = self.parsed.borrow().get(query) {
972            return Ok(parsed.clone());
973        }
974        let parsed = SparqlParser::new().parse_query(query).map_err(err)?;
975        self.parsed
976            .borrow_mut()
977            .insert(query.to_string(), parsed.clone());
978        Ok(parsed)
979    }
980
981    /// Invoke a `sh:SPARQLFunction` by substituting positional parameter
982    /// bindings into the query and extracting `?result` terms.
983    pub fn call_sparql_function(
984        &self,
985        raw_query: &str,
986        params: &[(String, Term)],
987    ) -> Result<Vec<Term>, String> {
988        let mut query = self.parse(raw_query)?;
989        for (name, value) in params {
990            let var = Variable::new(name).map_err(err)?;
991            substitute_query(&mut query, &var, value);
992        }
993        let prepared = SparqlEvaluator::new().for_query(query);
994        let result = if let Some(frozen) = &self.frozen {
995            prepared
996                .on_queryable_dataset(frozen)
997                .execute()
998                .map_err(err)?
999        } else {
1000            prepared.on_store(self.store()?).execute().map_err(err)?
1001        };
1002        match result {
1003            QueryResults::Solutions(solutions) => {
1004                let mut out = Vec::new();
1005                for sol in solutions {
1006                    if let Some(t) = sol.map_err(err)?.get("result") {
1007                        out.push(t.clone());
1008                    }
1009                }
1010                Ok(out)
1011            }
1012            QueryResults::Boolean(b) => Ok(if b {
1013                vec![Term::Literal(Literal::from(true))]
1014            } else {
1015                vec![]
1016            }),
1017            QueryResults::Graph(_) => Err("sh:SPARQLFunction produced graph results".to_string()),
1018        }
1019    }
1020
1021    /// Run a custom constraint-component **ASK** validator (SHACL §6.3.2) with
1022    /// the given variable substitutions (`$value`, `$this`, parameter vars, …)
1023    /// and, for property shapes, `$PATH` pre-bound to `path`. Returns the ASK's
1024    /// boolean. The value node conforms iff this is `true`.
1025    pub(crate) fn eval_ask(
1026        &self,
1027        raw_query: &str,
1028        path: Option<&Path>,
1029        bindings: &[(String, Term)],
1030    ) -> Result<bool, String> {
1031        match self.run_substituted(raw_query, path, bindings)? {
1032            QueryResults::Boolean(b) => Ok(b),
1033            _ => Err("sh:SPARQLAskValidator query is not an ASK".to_string()),
1034        }
1035    }
1036
1037    /// Run a custom constraint-component **SELECT** validator (SHACL §6.3.3) with
1038    /// the given variable substitutions and, for property shapes, `$PATH`
1039    /// pre-bound to `path`. Each solution row (its variable → term map) is one
1040    /// violation.
1041    pub(crate) fn eval_select(
1042        &self,
1043        raw_query: &str,
1044        path: Option<&Path>,
1045        bindings: &[(String, Term)],
1046    ) -> Result<Vec<HashMap<String, Term>>, String> {
1047        match self.run_substituted(raw_query, path, bindings)? {
1048            QueryResults::Solutions(solutions) => {
1049                let vars: Vec<String> = solutions
1050                    .variables()
1051                    .iter()
1052                    .map(|v| v.as_str().to_string())
1053                    .collect();
1054                solutions
1055                    .map(|solution| {
1056                        let solution = solution.map_err(err)?;
1057                        Ok(vars
1058                            .iter()
1059                            .filter_map(|name| {
1060                                solution
1061                                    .get(name.as_str())
1062                                    .map(|t| (name.clone(), t.clone()))
1063                            })
1064                            .collect())
1065                    })
1066                    .collect()
1067            }
1068            _ => Err("sh:SPARQLSelectValidator query is not a SELECT".to_string()),
1069        }
1070    }
1071
1072    /// Parse `raw_query`, substitute each `(var, value)` binding throughout
1073    /// (SHACL pre-binding), and execute over the frozen dataset (or store).
1074    fn run_substituted(
1075        &self,
1076        raw_query: &str,
1077        path: Option<&Path>,
1078        bindings: &[(String, Term)],
1079    ) -> Result<QueryResults<'_>, String> {
1080        let mut query = self.parse(raw_query)?;
1081        if let Some(path) = path {
1082            query = apply_path_prebinding(query, path)?;
1083        }
1084        for (name, value) in bindings {
1085            let var = Variable::new(name).map_err(err)?;
1086            substitute_query(&mut query, &var, value);
1087        }
1088        let prepared = self.evaluator().for_query(query);
1089        if let Some(frozen) = &self.frozen {
1090            prepared.on_queryable_dataset(frozen).execute().map_err(err)
1091        } else {
1092            prepared.on_store(self.store()?).execute().map_err(err)
1093        }
1094    }
1095
1096    fn store(&self) -> Result<&Store, String> {
1097        self.store
1098            .as_ref()
1099            .ok_or_else(|| "mutable SPARQL store is unavailable during validation".to_string())
1100    }
1101
1102    #[cfg(test)]
1103    fn has_store(&self) -> bool {
1104        self.store.is_some()
1105    }
1106}
1107
1108fn triple_has_blank_node(triple: &TriplePattern) -> bool {
1109    matches!(triple.subject, TermPattern::BlankNode(_))
1110        || matches!(triple.object, TermPattern::BlankNode(_))
1111}
1112
1113fn instantiate_template(
1114    template: &[TriplePattern],
1115    plan: &NativeQueryPlan,
1116    frozen: &FrozenIndexedDataset,
1117    focus: &Term,
1118    bindings: &native_exec::NativeIdBindings,
1119    out: &mut Vec<Triple>,
1120) {
1121    for triple in template {
1122        let Some(subject_id) = resolve_template_id(&triple.subject, plan, frozen, focus, bindings)
1123        else {
1124            continue;
1125        };
1126        let Some(predicate_id) =
1127            resolve_template_predicate_id(&triple.predicate, plan, frozen, focus, bindings)
1128        else {
1129            continue;
1130        };
1131        let Some(object_id) = resolve_template_id(&triple.object, plan, frozen, focus, bindings)
1132        else {
1133            continue;
1134        };
1135        if frozen.contains_ids(subject_id, predicate_id, object_id) {
1136            continue;
1137        }
1138
1139        let Some(subject) = frozen.externalize(subject_id).and_then(|term| match term {
1140            Term::NamedNode(node) => Some(NamedOrBlankNode::NamedNode(node)),
1141            Term::BlankNode(node) => Some(NamedOrBlankNode::BlankNode(node)),
1142            Term::Literal(_) => None,
1143        }) else {
1144            continue;
1145        };
1146        let Some(Term::NamedNode(predicate)) = frozen.externalize(predicate_id) else {
1147            continue;
1148        };
1149        let Some(object) = frozen.externalize(object_id) else {
1150            continue;
1151        };
1152        out.push(Triple::new(subject, predicate, object));
1153    }
1154}
1155
1156fn resolve_template_id(
1157    pattern: &TermPattern,
1158    plan: &NativeQueryPlan,
1159    frozen: &FrozenIndexedDataset,
1160    focus: &Term,
1161    bindings: &native_exec::NativeIdBindings,
1162) -> Option<TermId> {
1163    match pattern {
1164        TermPattern::NamedNode(node) => Some(frozen.intern(&Term::NamedNode(node.clone()))),
1165        TermPattern::BlankNode(node) => Some(frozen.intern(&Term::BlankNode(node.clone()))),
1166        TermPattern::Literal(literal) => Some(frozen.intern(&Term::Literal(literal.clone()))),
1167        TermPattern::Variable(variable) if variable.as_str() == "this" => {
1168            Some(frozen.intern(focus))
1169        }
1170        TermPattern::Variable(variable) => {
1171            let id = plan.var_id(variable.as_str())?;
1172            bindings.get(&id).copied()
1173        }
1174        #[allow(unreachable_patterns)]
1175        _ => None,
1176    }
1177}
1178
1179fn resolve_template_predicate_id(
1180    pattern: &NamedNodePattern,
1181    plan: &NativeQueryPlan,
1182    frozen: &FrozenIndexedDataset,
1183    focus: &Term,
1184    bindings: &native_exec::NativeIdBindings,
1185) -> Option<TermId> {
1186    match pattern {
1187        NamedNodePattern::NamedNode(node) => Some(frozen.intern(&Term::NamedNode(node.clone()))),
1188        NamedNodePattern::Variable(variable) => {
1189            if variable.as_str() == "this" {
1190                Some(frozen.intern(focus))
1191            } else {
1192                let id = plan.var_id(variable.as_str())?;
1193                bindings.get(&id).copied()
1194            }
1195        }
1196    }
1197}
1198
1199/// Convert a SHACL `Path` to a Spargebra `PropertyPathExpression` for use in
1200/// SPARQL property-path patterns. Returns `None` for paths that have no SPARQL
1201/// equivalent (`Path::Id`, and empty `Alt`/`Seq`).
1202fn path_to_property_path(path: &Path) -> Option<PropertyPathExpression> {
1203    match path {
1204        Path::Id => None,
1205        Path::Pred(n) => Some(PropertyPathExpression::NamedNode(n.clone())),
1206        Path::Inverse(inner) => {
1207            path_to_property_path(inner).map(|p| PropertyPathExpression::Reverse(Box::new(p)))
1208        }
1209        Path::Seq(parts) => {
1210            let sparql: Vec<_> = parts
1211                .iter()
1212                .map(path_to_property_path)
1213                .collect::<Option<Vec<_>>>()?;
1214            sparql
1215                .into_iter()
1216                .reduce(|a, b| PropertyPathExpression::Sequence(Box::new(a), Box::new(b)))
1217        }
1218        Path::Alt(parts) => {
1219            // `zero_or_one(p)` expands to `Alt([p, Id])`. Strip `Id` elements and,
1220            // if any were present, wrap the remainder in `ZeroOrOne`.
1221            let has_id = parts.iter().any(|p| matches!(p, Path::Id));
1222            let sparql: Vec<_> = parts
1223                .iter()
1224                .filter(|p| !matches!(p, Path::Id))
1225                .map(path_to_property_path)
1226                .collect::<Option<Vec<_>>>()?;
1227            if sparql.is_empty() {
1228                return None;
1229            }
1230            let base = sparql
1231                .into_iter()
1232                .reduce(|a, b| PropertyPathExpression::Alternative(Box::new(a), Box::new(b)))?;
1233            if has_id {
1234                Some(PropertyPathExpression::ZeroOrOne(Box::new(base)))
1235            } else {
1236                Some(base)
1237            }
1238        }
1239        Path::Star(inner) => {
1240            path_to_property_path(inner).map(|p| PropertyPathExpression::ZeroOrMore(Box::new(p)))
1241        }
1242    }
1243}
1244
1245/// Apply SHACL `$PATH` pre-binding (SHACL §5.2.1) to `query` for a property
1246/// shape's `path`: a simple predicate is substituted as a `$PATH` term; a
1247/// complex path is rewritten to a SPARQL property path. Shared by `sh:sparql`
1248/// constraints ([`SparqlExecutor::compile_constraint`]) and custom-component
1249/// validators ([`SparqlExecutor::run_substituted`]). Errors (fail-closed) for
1250/// paths with no SPARQL property-path form (e.g. `Path::Id`).
1251fn apply_path_prebinding(mut query: Query, path: &Path) -> Result<Query, String> {
1252    match path {
1253        Path::Pred(predicate) => {
1254            substitute_query(
1255                &mut query,
1256                &variable("PATH"),
1257                &Term::NamedNode(predicate.clone()),
1258            );
1259            Ok(query)
1260        }
1261        complex => {
1262            let sparql_path = path_to_property_path(complex).ok_or_else(|| {
1263                format!("SHACL path cannot be expressed as a SPARQL property path: {complex:?}")
1264            })?;
1265            Ok(rewrite_path_query(query, &sparql_path))
1266        }
1267    }
1268}
1269
1270/// Rewrite a query by replacing every BGP triple whose predicate is `?PATH`
1271/// with a `GraphPattern::Path` using `sparql_path`. This is the correct
1272/// treatment for SHACL `$PATH` pre-binding when the path is complex (non-pred).
1273fn rewrite_path_query(query: Query, path: &PropertyPathExpression) -> Query {
1274    match query {
1275        Query::Select {
1276            dataset,
1277            pattern,
1278            base_iri,
1279        } => Query::Select {
1280            dataset,
1281            pattern: rewrite_path_pattern(pattern, path),
1282            base_iri,
1283        },
1284        Query::Ask {
1285            dataset,
1286            pattern,
1287            base_iri,
1288        } => Query::Ask {
1289            dataset,
1290            pattern: rewrite_path_pattern(pattern, path),
1291            base_iri,
1292        },
1293        other => other,
1294    }
1295}
1296
1297fn rewrite_path_pattern(pattern: GraphPattern, path: &PropertyPathExpression) -> GraphPattern {
1298    match pattern {
1299        GraphPattern::Bgp { patterns } => {
1300            let mut result = GraphPattern::Bgp { patterns: vec![] };
1301            let mut remaining = Vec::new();
1302            for triple in patterns {
1303                if matches!(&triple.predicate, NamedNodePattern::Variable(v) if v.as_str() == "PATH")
1304                {
1305                    let path_gp = GraphPattern::Path {
1306                        subject: triple.subject,
1307                        path: path.clone(),
1308                        object: triple.object,
1309                    };
1310                    result = GraphPattern::Join {
1311                        left: Box::new(result),
1312                        right: Box::new(path_gp),
1313                    };
1314                } else {
1315                    remaining.push(triple);
1316                }
1317            }
1318            if remaining.is_empty() {
1319                result
1320            } else {
1321                let bgp = GraphPattern::Bgp {
1322                    patterns: remaining,
1323                };
1324                GraphPattern::Join {
1325                    left: Box::new(bgp),
1326                    right: Box::new(result),
1327                }
1328            }
1329        }
1330        // Path patterns don't contain $PATH in predicate position.
1331        GraphPattern::Path { .. } => pattern,
1332        GraphPattern::Join { left, right } => GraphPattern::Join {
1333            left: Box::new(rewrite_path_pattern(*left, path)),
1334            right: Box::new(rewrite_path_pattern(*right, path)),
1335        },
1336        GraphPattern::Union { left, right } => GraphPattern::Union {
1337            left: Box::new(rewrite_path_pattern(*left, path)),
1338            right: Box::new(rewrite_path_pattern(*right, path)),
1339        },
1340        GraphPattern::Minus { left, right } => GraphPattern::Minus {
1341            left: Box::new(rewrite_path_pattern(*left, path)),
1342            right: Box::new(rewrite_path_pattern(*right, path)),
1343        },
1344        GraphPattern::Lateral { left, right } => GraphPattern::Lateral {
1345            left: Box::new(rewrite_path_pattern(*left, path)),
1346            right: Box::new(rewrite_path_pattern(*right, path)),
1347        },
1348        GraphPattern::LeftJoin {
1349            left,
1350            right,
1351            expression,
1352        } => GraphPattern::LeftJoin {
1353            left: Box::new(rewrite_path_pattern(*left, path)),
1354            right: Box::new(rewrite_path_pattern(*right, path)),
1355            expression,
1356        },
1357        GraphPattern::Filter { expr, inner } => GraphPattern::Filter {
1358            expr,
1359            inner: Box::new(rewrite_path_pattern(*inner, path)),
1360        },
1361        GraphPattern::Graph { name, inner } => GraphPattern::Graph {
1362            name,
1363            inner: Box::new(rewrite_path_pattern(*inner, path)),
1364        },
1365        GraphPattern::Extend {
1366            inner,
1367            variable,
1368            expression,
1369        } => GraphPattern::Extend {
1370            inner: Box::new(rewrite_path_pattern(*inner, path)),
1371            variable,
1372            expression,
1373        },
1374        GraphPattern::OrderBy { inner, expression } => GraphPattern::OrderBy {
1375            inner: Box::new(rewrite_path_pattern(*inner, path)),
1376            expression,
1377        },
1378        GraphPattern::Project { inner, variables } => GraphPattern::Project {
1379            inner: Box::new(rewrite_path_pattern(*inner, path)),
1380            variables,
1381        },
1382        GraphPattern::Distinct { inner } => GraphPattern::Distinct {
1383            inner: Box::new(rewrite_path_pattern(*inner, path)),
1384        },
1385        GraphPattern::Reduced { inner } => GraphPattern::Reduced {
1386            inner: Box::new(rewrite_path_pattern(*inner, path)),
1387        },
1388        GraphPattern::Slice {
1389            inner,
1390            start,
1391            length,
1392        } => GraphPattern::Slice {
1393            inner: Box::new(rewrite_path_pattern(*inner, path)),
1394            start,
1395            length,
1396        },
1397        GraphPattern::Group {
1398            inner,
1399            variables,
1400            aggregates,
1401        } => GraphPattern::Group {
1402            inner: Box::new(rewrite_path_pattern(*inner, path)),
1403            variables,
1404            aggregates,
1405        },
1406        GraphPattern::Service {
1407            name,
1408            inner,
1409            silent,
1410        } => GraphPattern::Service {
1411            name,
1412            inner: Box::new(rewrite_path_pattern(*inner, path)),
1413            silent,
1414        },
1415        GraphPattern::Values { .. } => pattern,
1416    }
1417}
1418
1419/// Rewrite a constraint query so one run covers a whole focus set, returning the
1420/// query and the named foci it covers.
1421///
1422/// The strategy is **free `?this`**: it applies only when `?this` is positively
1423/// bound by the *required* part of the WHERE clause (a non-optional,
1424/// non-negated triple or path). The natural join then already constrains
1425/// `?this`, so the query runs unmodified (just ensuring `?this` is projected)
1426/// and the foci are recovered from the solutions. This replaces N per-focus
1427/// Spareval runs with a single scan that a selective binding pattern drives.
1428///
1429/// Coverage is restricted to **named-node** foci. A blank node cannot be matched
1430/// back from query results — SPARQL relabels result blank nodes, so a solution's
1431/// `?this` never equals the focus blank node. Blank-node foci stay on the
1432/// per-focus path, where `$this` is substituted as a constant and identity is
1433/// preserved.
1434///
1435/// `None` (keep the per-focus path) when batching would be unsound or unhelpful:
1436/// `?this` is not positively bound (a pure `NOT EXISTS`/`FILTER` body, where a
1437/// `VALUES` table would just re-do the per-focus work), aggregation (`GROUP BY`),
1438/// result slicing (`LIMIT`/`OFFSET`), or fewer than two named foci. `ASK` becomes
1439/// `SELECT DISTINCT ?this`.
1440fn plan_batch_query(query: &Query, foci: &[Term]) -> Option<(Query, Vec<Term>)> {
1441    let this = Variable::new("this").ok()?;
1442    let (pattern, is_ask) = match query {
1443        Query::Select { pattern, .. } => (pattern, false),
1444        Query::Ask { pattern, .. } => (pattern, true),
1445        _ => return None,
1446    };
1447    if pattern_has_group_or_slice(pattern) || !this_required(pattern, &this) {
1448        return None;
1449    }
1450
1451    let covered: Vec<Term> = foci
1452        .iter()
1453        .filter(|f| matches!(f, Term::NamedNode(_)))
1454        .cloned()
1455        .collect();
1456    if covered.len() < 2 {
1457        return None;
1458    }
1459
1460    let (dataset, base_iri) = match query {
1461        Query::Select {
1462            dataset, base_iri, ..
1463        }
1464        | Query::Ask {
1465            dataset, base_iri, ..
1466        } => (dataset.clone(), base_iri.clone()),
1467        _ => unreachable!(),
1468    };
1469
1470    let rewritten = if is_ask {
1471        // ASK conformance is existential: DISTINCT collapses each focus to at
1472        // most one row, matching the single Boolean violation the per-focus path
1473        // emits for a true ASK.
1474        Query::Select {
1475            dataset,
1476            pattern: GraphPattern::Project {
1477                inner: Box::new(GraphPattern::Distinct {
1478                    inner: Box::new(pattern.clone()),
1479                }),
1480                variables: vec![this],
1481            },
1482            base_iri,
1483        }
1484    } else {
1485        Query::Select {
1486            dataset,
1487            pattern: ensure_projected(pattern.clone(), &this)?,
1488            base_iri,
1489        }
1490    };
1491    Some((rewritten, covered))
1492}
1493
1494/// Whether `var` is positively bound by the *required* part of the pattern —
1495/// the subject or object of a triple or path that is not under `OPTIONAL`
1496/// (`LeftJoin` right arm), `MINUS`, `FILTER`, or `EXISTS`. If so, running the
1497/// query with `var` free still constrains it to those bindings, so the focus
1498/// domain need not be supplied via `VALUES`. A `UNION` binds it only if *both*
1499/// arms do.
1500fn this_required(p: &GraphPattern, var: &Variable) -> bool {
1501    match p {
1502        GraphPattern::Bgp { patterns } => patterns
1503            .iter()
1504            .any(|t| term_pattern_is(&t.subject, var) || term_pattern_is(&t.object, var)),
1505        GraphPattern::Path {
1506            subject, object, ..
1507        } => term_pattern_is(subject, var) || term_pattern_is(object, var),
1508        GraphPattern::Join { left, right } | GraphPattern::Lateral { left, right } => {
1509            this_required(left, var) || this_required(right, var)
1510        }
1511        GraphPattern::Union { left, right } => {
1512            this_required(left, var) && this_required(right, var)
1513        }
1514        // Only the mandatory (left) side of OPTIONAL / MINUS binds `var`.
1515        GraphPattern::LeftJoin { left, .. } | GraphPattern::Minus { left, .. } => {
1516            this_required(left, var)
1517        }
1518        GraphPattern::Filter { inner, .. }
1519        | GraphPattern::Graph { inner, .. }
1520        | GraphPattern::Extend { inner, .. }
1521        | GraphPattern::OrderBy { inner, .. }
1522        | GraphPattern::Project { inner, .. }
1523        | GraphPattern::Distinct { inner }
1524        | GraphPattern::Reduced { inner }
1525        | GraphPattern::Slice { inner, .. }
1526        | GraphPattern::Group { inner, .. }
1527        | GraphPattern::Service { inner, .. } => this_required(inner, var),
1528        GraphPattern::Values {
1529            variables,
1530            bindings,
1531        } => {
1532            // A VALUES table binds `var` only if every row supplies it.
1533            variables.iter().position(|v| v == var).is_some_and(|col| {
1534                bindings
1535                    .iter()
1536                    .all(|row| row.get(col).is_some_and(Option::is_some))
1537            })
1538        }
1539    }
1540}
1541
1542/// Descend through the solution modifiers (`DISTINCT`/`REDUCED`/`ORDER BY`) to
1543/// the projection and add `?this` to its variables if absent, so the free run's
1544/// solutions expose `?this` for demultiplexing. `None` if no projection is found
1545/// (e.g. a bare `SELECT *` without a `Project` node, which should not occur for
1546/// parsed constraints).
1547fn ensure_projected(pattern: GraphPattern, this: &Variable) -> Option<GraphPattern> {
1548    match pattern {
1549        GraphPattern::Project {
1550            inner,
1551            mut variables,
1552        } => {
1553            if !variables.iter().any(|v| v == this) {
1554                variables.push(this.clone());
1555            }
1556            Some(GraphPattern::Project { inner, variables })
1557        }
1558        GraphPattern::Distinct { inner } => Some(GraphPattern::Distinct {
1559            inner: Box::new(ensure_projected(*inner, this)?),
1560        }),
1561        GraphPattern::Reduced { inner } => Some(GraphPattern::Reduced {
1562            inner: Box::new(ensure_projected(*inner, this)?),
1563        }),
1564        GraphPattern::OrderBy { inner, expression } => Some(GraphPattern::OrderBy {
1565            inner: Box::new(ensure_projected(*inner, this)?),
1566            expression,
1567        }),
1568        _ => None,
1569    }
1570}
1571
1572/// Whether the pattern (anywhere, including correlated sub-queries) contains an
1573/// aggregation or a slice, either of which makes per-focus batching unsound.
1574fn pattern_has_group_or_slice(p: &GraphPattern) -> bool {
1575    match p {
1576        GraphPattern::Group { .. } | GraphPattern::Slice { .. } => true,
1577        GraphPattern::Bgp { .. } | GraphPattern::Path { .. } | GraphPattern::Values { .. } => false,
1578        GraphPattern::Join { left, right }
1579        | GraphPattern::Union { left, right }
1580        | GraphPattern::Minus { left, right }
1581        | GraphPattern::Lateral { left, right }
1582        | GraphPattern::LeftJoin { left, right, .. } => {
1583            pattern_has_group_or_slice(left) || pattern_has_group_or_slice(right)
1584        }
1585        GraphPattern::Filter { inner, .. }
1586        | GraphPattern::Graph { inner, .. }
1587        | GraphPattern::Extend { inner, .. }
1588        | GraphPattern::OrderBy { inner, .. }
1589        | GraphPattern::Project { inner, .. }
1590        | GraphPattern::Distinct { inner }
1591        | GraphPattern::Reduced { inner }
1592        | GraphPattern::Service { inner, .. } => pattern_has_group_or_slice(inner),
1593    }
1594}
1595
1596fn term_pattern_is(t: &TermPattern, var: &Variable) -> bool {
1597    matches!(t, TermPattern::Variable(v) if v == var)
1598}
1599
1600/// Substitute every occurrence of `var` with the pre-bound `value` throughout a
1601/// query, implementing SHACL-SPARQL pre-binding by algebra substitution.
1602/// Whether a function body reads the data graph: it contains a non-empty BGP, a
1603/// property path, a named `GRAPH`, or a `SERVICE`. A pure function (only
1604/// `BIND`/`FILTER`/`VALUES` over no triples) returns `false` and is safe to run
1605/// over an empty dataset. Unparseable queries are treated as graph-reading
1606/// (conservative). Used to decide registration under `UnsupportedPolicy`.
1607pub(crate) fn query_reads_graph(query: &str) -> bool {
1608    let Ok(parsed) = SparqlParser::new().parse_query(query) else {
1609        return true;
1610    };
1611    fn walk(pattern: &GraphPattern) -> bool {
1612        use GraphPattern::*;
1613        match pattern {
1614            Bgp { patterns } => !patterns.is_empty(),
1615            Path { .. } | Graph { .. } | Service { .. } => true,
1616            Join { left, right }
1617            | Union { left, right }
1618            | Minus { left, right }
1619            | Lateral { left, right } => walk(left) || walk(right),
1620            LeftJoin { left, right, .. } => walk(left) || walk(right),
1621            Filter { inner, .. }
1622            | Extend { inner, .. }
1623            | Project { inner, .. }
1624            | Distinct { inner }
1625            | Reduced { inner }
1626            | Slice { inner, .. }
1627            | OrderBy { inner, .. }
1628            | Group { inner, .. } => walk(inner),
1629            _ => false,
1630        }
1631    }
1632    match &parsed {
1633        Query::Select { pattern, .. } | Query::Ask { pattern, .. } => walk(pattern),
1634        _ => true,
1635    }
1636}
1637
1638/// Evaluate a SHACL function body as a pure function of `args`: substitute the
1639/// call arguments positionally for the parameter variables and run the body
1640/// over an empty store. Returns the `?result` binding (SELECT) or the boolean
1641/// (ASK). `None` on any parse/eval failure — the calling query then sees an
1642/// unbound result, matching SPARQL's treatment of a failed function call.
1643fn eval_sparql_function(query: &str, params: &[String], args: &[Term]) -> Option<Term> {
1644    let mut q = SparqlParser::new().parse_query(query).ok()?;
1645    for (name, value) in params.iter().zip(args) {
1646        if let Ok(var) = Variable::new(name) {
1647            substitute_query(&mut q, &var, value);
1648        }
1649    }
1650    let store = Store::new().ok()?;
1651    match SparqlEvaluator::new()
1652        .for_query(q)
1653        .on_store(&store)
1654        .execute()
1655        .ok()?
1656    {
1657        QueryResults::Solutions(mut solutions) => solutions.next()?.ok()?.get("result").cloned(),
1658        QueryResults::Boolean(b) => Some(Term::Literal(Literal::from(b))),
1659        QueryResults::Graph(_) => None,
1660    }
1661}
1662
1663/// Rename every occurrence of `from` to `to` throughout a query AST. Used to
1664/// implement the `bind_value_to_this` flag: for node-shape activations of ASK
1665/// validators, `?value` is the focus node (`?this`), so we rename before the
1666/// per-focus `$this` binding is applied.
1667fn rename_variable_in_query(query: &mut Query, from: &Variable, to: &Variable) {
1668    match query {
1669        Query::Select { pattern, .. }
1670        | Query::Describe { pattern, .. }
1671        | Query::Ask { pattern, .. } => rename_var_pattern(pattern, from, to),
1672        Query::Construct {
1673            template, pattern, ..
1674        } => {
1675            for triple in template {
1676                rename_var_triple(triple, from, to);
1677            }
1678            rename_var_pattern(pattern, from, to);
1679        }
1680    }
1681}
1682
1683fn rename_var_pattern(pattern: &mut GraphPattern, from: &Variable, to: &Variable) {
1684    match pattern {
1685        GraphPattern::Bgp { patterns } => {
1686            for triple in patterns {
1687                rename_var_triple(triple, from, to);
1688            }
1689        }
1690        GraphPattern::Path {
1691            subject, object, ..
1692        } => {
1693            rename_var_term_pattern(subject, from, to);
1694            rename_var_term_pattern(object, from, to);
1695        }
1696        GraphPattern::Join { left, right }
1697        | GraphPattern::Union { left, right }
1698        | GraphPattern::Minus { left, right }
1699        | GraphPattern::Lateral { left, right } => {
1700            rename_var_pattern(left, from, to);
1701            rename_var_pattern(right, from, to);
1702        }
1703        GraphPattern::LeftJoin {
1704            left,
1705            right,
1706            expression,
1707        } => {
1708            rename_var_pattern(left, from, to);
1709            rename_var_pattern(right, from, to);
1710            if let Some(expr) = expression {
1711                rename_var_expr(expr, from, to);
1712            }
1713        }
1714        GraphPattern::Filter { expr, inner } => {
1715            rename_var_expr(expr, from, to);
1716            rename_var_pattern(inner, from, to);
1717        }
1718        GraphPattern::Graph { name, inner } => {
1719            if let NamedNodePattern::Variable(v) = name
1720                && v == from
1721            {
1722                *name = NamedNodePattern::Variable(to.clone());
1723            }
1724            rename_var_pattern(inner, from, to);
1725        }
1726        GraphPattern::Extend {
1727            inner,
1728            expression,
1729            variable,
1730        } => {
1731            rename_var_pattern(inner, from, to);
1732            rename_var_expr(expression, from, to);
1733            if variable == from {
1734                *variable = to.clone();
1735            }
1736        }
1737        GraphPattern::OrderBy { inner, expression } => {
1738            rename_var_pattern(inner, from, to);
1739            for order in expression {
1740                match order {
1741                    OrderExpression::Asc(e) | OrderExpression::Desc(e) => {
1742                        rename_var_expr(e, from, to);
1743                    }
1744                }
1745            }
1746        }
1747        GraphPattern::Project { inner, variables } => {
1748            rename_var_pattern(inner, from, to);
1749            for v in variables {
1750                if v == from {
1751                    *v = to.clone();
1752                }
1753            }
1754        }
1755        GraphPattern::Distinct { inner }
1756        | GraphPattern::Reduced { inner }
1757        | GraphPattern::Slice { inner, .. } => rename_var_pattern(inner, from, to),
1758        GraphPattern::Group {
1759            inner,
1760            variables,
1761            aggregates,
1762        } => {
1763            rename_var_pattern(inner, from, to);
1764            for v in variables {
1765                if v == from {
1766                    *v = to.clone();
1767                }
1768            }
1769            for (v, agg) in aggregates {
1770                if v == from {
1771                    *v = to.clone();
1772                }
1773                if let AggregateExpression::FunctionCall { expr, .. } = agg {
1774                    rename_var_expr(expr, from, to);
1775                }
1776            }
1777        }
1778        GraphPattern::Service { name, inner, .. } => {
1779            if let NamedNodePattern::Variable(v) = name
1780                && v == from
1781            {
1782                *name = NamedNodePattern::Variable(to.clone());
1783            }
1784            rename_var_pattern(inner, from, to);
1785        }
1786        GraphPattern::Values {
1787            variables,
1788            bindings,
1789        } => {
1790            for v in variables {
1791                if v == from {
1792                    *v = to.clone();
1793                }
1794            }
1795            let _ = bindings; // values are ground terms, no variables
1796        }
1797    }
1798}
1799
1800fn rename_var_triple(triple: &mut TriplePattern, from: &Variable, to: &Variable) {
1801    rename_var_term_pattern(&mut triple.subject, from, to);
1802    if let NamedNodePattern::Variable(v) = &mut triple.predicate
1803        && v == from
1804    {
1805        triple.predicate = NamedNodePattern::Variable(to.clone());
1806    }
1807    rename_var_term_pattern(&mut triple.object, from, to);
1808}
1809
1810fn rename_var_term_pattern(pattern: &mut TermPattern, from: &Variable, to: &Variable) {
1811    if let TermPattern::Variable(v) = pattern
1812        && v == from
1813    {
1814        *pattern = TermPattern::Variable(to.clone());
1815    }
1816}
1817
1818fn rename_var_expr(expr: &mut Expression, from: &Variable, to: &Variable) {
1819    match expr {
1820        Expression::Variable(v) if v == from => *v = to.clone(),
1821        Expression::Bound(v) if v == from => *v = to.clone(),
1822        Expression::Variable(_)
1823        | Expression::Bound(_)
1824        | Expression::NamedNode(_)
1825        | Expression::Literal(_) => {}
1826        Expression::Or(a, b)
1827        | Expression::And(a, b)
1828        | Expression::Equal(a, b)
1829        | Expression::SameTerm(a, b)
1830        | Expression::Greater(a, b)
1831        | Expression::GreaterOrEqual(a, b)
1832        | Expression::Less(a, b)
1833        | Expression::LessOrEqual(a, b)
1834        | Expression::Add(a, b)
1835        | Expression::Subtract(a, b)
1836        | Expression::Multiply(a, b)
1837        | Expression::Divide(a, b) => {
1838            rename_var_expr(a, from, to);
1839            rename_var_expr(b, from, to);
1840        }
1841        Expression::In(a, list) => {
1842            rename_var_expr(a, from, to);
1843            for e in list {
1844                rename_var_expr(e, from, to);
1845            }
1846        }
1847        Expression::UnaryPlus(a) | Expression::UnaryMinus(a) | Expression::Not(a) => {
1848            rename_var_expr(a, from, to);
1849        }
1850        Expression::Exists(pattern) => rename_var_pattern(pattern, from, to),
1851        Expression::If(a, b, c) => {
1852            rename_var_expr(a, from, to);
1853            rename_var_expr(b, from, to);
1854            rename_var_expr(c, from, to);
1855        }
1856        Expression::Coalesce(list) | Expression::FunctionCall(_, list) => {
1857            for e in list {
1858                rename_var_expr(e, from, to);
1859            }
1860        }
1861    }
1862}
1863
1864fn substitute_query(query: &mut Query, var: &Variable, value: &Term) {
1865    match query {
1866        Query::Select { pattern, .. }
1867        | Query::Describe { pattern, .. }
1868        | Query::Ask { pattern, .. } => substitute_pattern(pattern, var, value),
1869        Query::Construct {
1870            template, pattern, ..
1871        } => {
1872            for triple in template {
1873                substitute_triple(triple, var, value);
1874            }
1875            substitute_pattern(pattern, var, value);
1876        }
1877    }
1878}
1879
1880fn substitute_pattern(pattern: &mut GraphPattern, var: &Variable, value: &Term) {
1881    match pattern {
1882        GraphPattern::Bgp { patterns } => {
1883            for triple in patterns {
1884                substitute_triple(triple, var, value);
1885            }
1886        }
1887        GraphPattern::Path {
1888            subject, object, ..
1889        } => {
1890            substitute_term_pattern(subject, var, value);
1891            substitute_term_pattern(object, var, value);
1892        }
1893        GraphPattern::Join { left, right }
1894        | GraphPattern::Union { left, right }
1895        | GraphPattern::Minus { left, right }
1896        | GraphPattern::Lateral { left, right } => {
1897            substitute_pattern(left, var, value);
1898            substitute_pattern(right, var, value);
1899        }
1900        GraphPattern::LeftJoin {
1901            left,
1902            right,
1903            expression,
1904        } => {
1905            substitute_pattern(left, var, value);
1906            substitute_pattern(right, var, value);
1907            if let Some(expression) = expression {
1908                substitute_expr(expression, var, value);
1909            }
1910        }
1911        GraphPattern::Filter { expr, inner } => {
1912            substitute_expr(expr, var, value);
1913            substitute_pattern(inner, var, value);
1914        }
1915        GraphPattern::Graph { name, inner } => {
1916            substitute_named_node_pattern(name, var, value);
1917            substitute_pattern(inner, var, value);
1918        }
1919        GraphPattern::Extend {
1920            inner, expression, ..
1921        } => {
1922            // `variable` is the BIND target; pre-binding it is disallowed, so
1923            // only its inner pattern and defining expression are substituted.
1924            substitute_pattern(inner, var, value);
1925            substitute_expr(expression, var, value);
1926        }
1927        GraphPattern::OrderBy { inner, expression } => {
1928            substitute_pattern(inner, var, value);
1929            for order in expression {
1930                match order {
1931                    OrderExpression::Asc(e) | OrderExpression::Desc(e) => {
1932                        substitute_expr(e, var, value)
1933                    }
1934                }
1935            }
1936        }
1937        GraphPattern::Project { inner, .. }
1938        | GraphPattern::Distinct { inner }
1939        | GraphPattern::Reduced { inner }
1940        | GraphPattern::Slice { inner, .. } => substitute_pattern(inner, var, value),
1941        GraphPattern::Group {
1942            inner, aggregates, ..
1943        } => {
1944            substitute_pattern(inner, var, value);
1945            for (_, aggregate) in aggregates {
1946                if let AggregateExpression::FunctionCall { expr, .. } = aggregate {
1947                    substitute_expr(expr, var, value);
1948                }
1949            }
1950        }
1951        GraphPattern::Service { name, inner, .. } => {
1952            substitute_named_node_pattern(name, var, value);
1953            substitute_pattern(inner, var, value);
1954        }
1955        GraphPattern::Values { .. } => {}
1956    }
1957}
1958
1959fn substitute_triple(triple: &mut TriplePattern, var: &Variable, value: &Term) {
1960    substitute_term_pattern(&mut triple.subject, var, value);
1961    substitute_named_node_pattern(&mut triple.predicate, var, value);
1962    substitute_term_pattern(&mut triple.object, var, value);
1963}
1964
1965fn substitute_term_pattern(pattern: &mut TermPattern, var: &Variable, value: &Term) {
1966    if matches!(pattern, TermPattern::Variable(v) if v == var)
1967        && let Some(replacement) = term_to_term_pattern(value)
1968    {
1969        *pattern = replacement;
1970    }
1971}
1972
1973fn substitute_named_node_pattern(pattern: &mut NamedNodePattern, var: &Variable, value: &Term) {
1974    if matches!(pattern, NamedNodePattern::Variable(v) if v == var)
1975        && let Term::NamedNode(node) = value
1976    {
1977        *pattern = NamedNodePattern::NamedNode(node.clone());
1978    }
1979}
1980
1981fn substitute_expr(expr: &mut Expression, var: &Variable, value: &Term) {
1982    match expr {
1983        Expression::Variable(v) if v == var => {
1984            if let Some(replacement) = term_to_expr(value) {
1985                *expr = replacement;
1986            }
1987        }
1988        // A pre-bound variable is always bound, so `bound(var)` is constant-true.
1989        Expression::Bound(v) if v == var => *expr = Expression::Literal(Literal::from(true)),
1990        Expression::Variable(_)
1991        | Expression::Bound(_)
1992        | Expression::NamedNode(_)
1993        | Expression::Literal(_) => {}
1994        Expression::Or(a, b)
1995        | Expression::And(a, b)
1996        | Expression::Equal(a, b)
1997        | Expression::SameTerm(a, b)
1998        | Expression::Greater(a, b)
1999        | Expression::GreaterOrEqual(a, b)
2000        | Expression::Less(a, b)
2001        | Expression::LessOrEqual(a, b)
2002        | Expression::Add(a, b)
2003        | Expression::Subtract(a, b)
2004        | Expression::Multiply(a, b)
2005        | Expression::Divide(a, b) => {
2006            substitute_expr(a, var, value);
2007            substitute_expr(b, var, value);
2008        }
2009        Expression::In(a, list) => {
2010            substitute_expr(a, var, value);
2011            for e in list {
2012                substitute_expr(e, var, value);
2013            }
2014        }
2015        Expression::UnaryPlus(a) | Expression::UnaryMinus(a) | Expression::Not(a) => {
2016            substitute_expr(a, var, value)
2017        }
2018        Expression::Exists(pattern) => substitute_pattern(pattern, var, value),
2019        Expression::If(a, b, c) => {
2020            substitute_expr(a, var, value);
2021            substitute_expr(b, var, value);
2022            substitute_expr(c, var, value);
2023        }
2024        Expression::Coalesce(list) | Expression::FunctionCall(_, list) => {
2025            for e in list {
2026                substitute_expr(e, var, value);
2027            }
2028        }
2029    }
2030}
2031
2032fn term_to_term_pattern(value: &Term) -> Option<TermPattern> {
2033    match value {
2034        Term::NamedNode(n) => Some(TermPattern::NamedNode(n.clone())),
2035        Term::BlankNode(b) => Some(TermPattern::BlankNode(b.clone())),
2036        Term::Literal(l) => Some(TermPattern::Literal(l.clone())),
2037        // rdf-star triple terms are not pre-bound by SHACL.
2038        #[allow(unreachable_patterns)]
2039        _ => None,
2040    }
2041}
2042
2043fn term_to_expr(value: &Term) -> Option<Expression> {
2044    match value {
2045        Term::NamedNode(n) => Some(Expression::NamedNode(n.clone())),
2046        Term::Literal(l) => Some(Expression::Literal(l.clone())),
2047        // Blank-node / triple terms cannot appear as SPARQL expression constants.
2048        _ => None,
2049    }
2050}
2051
2052fn variable(name: &str) -> Variable {
2053    Variable::new(name).expect("static SPARQL variable name")
2054}
2055
2056fn err(error: impl std::fmt::Display) -> String {
2057    error.to_string()
2058}
2059
2060/// Canonical cache-key fragment for a constraint's `$PATH` binding.
2061fn path_key(path: &Path) -> String {
2062    shifty_algebra::render::path_to_string(path)
2063}
2064
2065/// Assert the native executor and the Spareval fallback produce the same
2066/// violation multiset for a focus node. Debug-only differential gate.
2067#[cfg(debug_assertions)]
2068fn assert_violations_match(
2069    query: &str,
2070    focus: &Term,
2071    native: &[SparqlViolation],
2072    reference: &[SparqlViolation],
2073) {
2074    fn canonical(violations: &[SparqlViolation]) -> Vec<(String, String)> {
2075        let mut keyed: Vec<(String, String)> = violations
2076            .iter()
2077            .map(|v| {
2078                (
2079                    v.value.as_ref().map(Term::to_string).unwrap_or_default(),
2080                    v.path.as_ref().map(Term::to_string).unwrap_or_default(),
2081                )
2082            })
2083            .collect();
2084        keyed.sort();
2085        keyed
2086    }
2087    let native = canonical(native);
2088    let reference = canonical(reference);
2089    assert_eq!(
2090        native, reference,
2091        "native vs Spareval disagreement for focus {focus}\n  query: {query}\n  native:   {native:?}\n  spareval: {reference:?}",
2092    );
2093}
2094
2095#[cfg(test)]
2096mod storage_tests {
2097    use super::*;
2098
2099    #[test]
2100    fn validation_executor_does_not_allocate_mutable_store() {
2101        let executor =
2102            SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&Graph::new()), false);
2103        assert!(!executor.has_store());
2104    }
2105}
2106
2107#[cfg(test)]
2108mod batch_tests {
2109    use super::*;
2110    use shifty_algebra::SparqlQueryKind;
2111
2112    fn nn(s: &str) -> NamedNode {
2113        NamedNode::new(s).unwrap()
2114    }
2115
2116    fn subject(s: &str) -> Term {
2117        Term::NamedNode(nn(&format!("http://ex/{s}")))
2118    }
2119
2120    /// Three subjects with `ex:value` 5/15/25 and no `ex:other`.
2121    fn sample_graph() -> Graph {
2122        let mut g = Graph::new();
2123        for (s, v) in [("a", 5i64), ("b", 15), ("c", 25)] {
2124            g.insert(&Triple::new(
2125                nn(&format!("http://ex/{s}")),
2126                nn("http://ex/value"),
2127                Literal::from(v),
2128            ));
2129        }
2130        g
2131    }
2132
2133    fn foci() -> Vec<Term> {
2134        vec![subject("a"), subject("b"), subject("c")]
2135    }
2136
2137    /// Per-focus violation keys `(focus, value)` over the whole focus set.
2138    fn per_focus(exec: &SparqlExecutor, c: &SparqlConstraint, foci: &[Term]) -> Vec<String> {
2139        let mut out = Vec::new();
2140        for f in foci {
2141            for v in exec.constraint_violations(c, f).unwrap() {
2142                out.push(format!("{f:?}|{:?}", v.value));
2143            }
2144        }
2145        out.sort();
2146        out
2147    }
2148
2149    #[test]
2150    fn batched_select_matches_per_focus() {
2151        // OPTIONAL forces the non-native fallback path, where VALUES batching
2152        // applies. Every subject violates (no ex:other), projecting its value.
2153        let constraint = SparqlConstraint {
2154            kind: SparqlQueryKind::Select,
2155            query: "SELECT $this ?value WHERE { \
2156                $this <http://ex/value> ?value . \
2157                OPTIONAL { $this <http://ex/other> ?o } \
2158                FILTER(!bound(?o)) }"
2159                .to_string(),
2160            path: None,
2161            shape: None,
2162            messages: Vec::new(),
2163            extra_bindings: Vec::new(),
2164            bind_value_to_this: false,
2165        };
2166        let g = sample_graph();
2167        let foci = foci();
2168
2169        let baseline = SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&g), false);
2170        let want = per_focus(&baseline, &constraint, &foci);
2171
2172        let batched = SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&g), false);
2173        batched.prefetch_constraint(&constraint, &foci).unwrap();
2174        let got = per_focus(&batched, &constraint, &foci);
2175
2176        assert_eq!(want.len(), 3, "every subject should violate");
2177        assert_eq!(want, got);
2178    }
2179
2180    #[test]
2181    fn batched_ask_matches_per_focus() {
2182        // ASK is true iff value > 10: only b and c violate.
2183        let constraint = SparqlConstraint {
2184            kind: SparqlQueryKind::Ask,
2185            query: "ASK { $this <http://ex/value> ?value . \
2186                OPTIONAL { $this <http://ex/other> ?o } \
2187                FILTER(?value > 10) }"
2188                .to_string(),
2189            path: None,
2190            shape: None,
2191            messages: Vec::new(),
2192            extra_bindings: Vec::new(),
2193            bind_value_to_this: false,
2194        };
2195        let g = sample_graph();
2196        let foci = foci();
2197
2198        let baseline = SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&g), false);
2199        let want: Vec<usize> = foci
2200            .iter()
2201            .map(|f| {
2202                baseline
2203                    .constraint_violations(&constraint, f)
2204                    .unwrap()
2205                    .len()
2206            })
2207            .collect();
2208
2209        let batched = SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&g), false);
2210        batched.prefetch_constraint(&constraint, &foci).unwrap();
2211        let got: Vec<usize> = foci
2212            .iter()
2213            .map(|f| batched.constraint_violations(&constraint, f).unwrap().len())
2214            .collect();
2215
2216        assert_eq!(want, vec![0, 1, 1]);
2217        assert_eq!(want, got);
2218    }
2219
2220    #[test]
2221    fn blank_node_foci_fall_through_to_per_focus() {
2222        // Regression: the free-`?this` run cannot match blank-node foci back from
2223        // its (relabeled) solutions, so blank foci must stay on the per-focus
2224        // path. Mixed named/blank focus set; result must match per-focus exactly.
2225        use oxrdf::BlankNode;
2226        let mut g = Graph::new();
2227        let blank = BlankNode::default();
2228        // ex:a and the blank node are both "flagged"; ex:c is not.
2229        g.insert(&Triple::new(
2230            nn("http://ex/a"),
2231            nn("http://ex/flag"),
2232            Literal::from(true),
2233        ));
2234        g.insert(&Triple::new(
2235            blank.clone(),
2236            nn("http://ex/flag"),
2237            Literal::from(true),
2238        ));
2239        // referrers: ex:x -> ex:a, ex:y -> blank
2240        g.insert(&Triple::new(
2241            nn("http://ex/x"),
2242            nn("http://ex/ref"),
2243            nn("http://ex/a"),
2244        ));
2245        g.insert(&Triple::new(
2246            nn("http://ex/y"),
2247            nn("http://ex/ref"),
2248            blank.clone(),
2249        ));
2250
2251        // `?this` is positively bound (subject of `?this ex:flag true`), so the
2252        // free strategy applies; OPTIONAL forces the non-native fallback.
2253        let constraint = SparqlConstraint {
2254            kind: SparqlQueryKind::Select,
2255            query: "SELECT ?s $this WHERE { \
2256                $this <http://ex/flag> true . \
2257                ?s <http://ex/ref> $this . \
2258                OPTIONAL { $this <http://ex/other> ?o } }"
2259                .to_string(),
2260            path: None,
2261            shape: None,
2262            messages: Vec::new(),
2263            extra_bindings: Vec::new(),
2264            bind_value_to_this: false,
2265        };
2266        let foci = vec![
2267            Term::NamedNode(nn("http://ex/a")),
2268            Term::BlankNode(blank),
2269            subject("c"),
2270        ];
2271
2272        let baseline = SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&g), false);
2273        let want = per_focus(&baseline, &constraint, &foci);
2274
2275        let batched = SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&g), false);
2276        batched.prefetch_constraint(&constraint, &foci).unwrap();
2277        let got = per_focus(&batched, &constraint, &foci);
2278
2279        // The named focus is served from the batch; the blank focus falls
2280        // through to the per-focus path. Either way the batched result must
2281        // equal the per-focus oracle exactly.
2282        assert!(!want.is_empty());
2283        assert_eq!(want, got);
2284    }
2285
2286    #[test]
2287    fn aggregating_query_is_not_batched_but_still_correct() {
2288        // GROUP BY / COUNT is outside the safe-batch subset: build_batch_query
2289        // returns None, so prefetch is a no-op and per-focus results are used.
2290        let constraint = SparqlConstraint {
2291            kind: SparqlQueryKind::Select,
2292            query: "SELECT $this (COUNT(?value) AS ?n) WHERE { \
2293                $this <http://ex/value> ?value } \
2294                GROUP BY $this HAVING (COUNT(?value) > 5)"
2295                .to_string(),
2296            path: None,
2297            shape: None,
2298            messages: Vec::new(),
2299            extra_bindings: Vec::new(),
2300            bind_value_to_this: false,
2301        };
2302        let g = sample_graph();
2303        let foci = foci();
2304
2305        let baseline = SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&g), false);
2306        let want = per_focus(&baseline, &constraint, &foci);
2307
2308        let batched = SparqlExecutor::from_frozen(FrozenIndexedDataset::from_graph(&g), false);
2309        batched.prefetch_constraint(&constraint, &foci).unwrap();
2310        let got = per_focus(&batched, &constraint, &foci);
2311
2312        assert_eq!(want, got); // none violate; importantly, no panic / divergence
2313    }
2314
2315    #[test]
2316    fn batched_construct_matches_per_focus() {
2317        // An aggregate (GROUP BY / COUNT DISTINCT) CONSTRUCT body cannot lower to
2318        // the native executor, so it runs on the Spareval fallback. Seeding the
2319        // batch memo forces the free-`?this` path; its output must equal the
2320        // per-focus path. ex:a reads two properties of one kind (distinct count 1,
2321        // eligible); ex:b reads two kinds (count 2, not eligible).
2322        let query = "CONSTRUCT { $this <http://ex/uniqueKind> ?k } WHERE { \
2323            { SELECT $this (COUNT(DISTINCT ?k0) AS ?c) \
2324              WHERE { $this <http://ex/reads>/<http://ex/kind> ?k0 } GROUP BY $this } \
2325            FILTER(?c = 1) \
2326            $this <http://ex/reads>/<http://ex/kind> ?k }";
2327
2328        let mut g = Graph::new();
2329        let mut edge = |s: &str, p: &str, o: &str| {
2330            g.insert(&Triple::new(
2331                nn(&format!("http://ex/{s}")),
2332                nn(&format!("http://ex/{p}")),
2333                nn(&format!("http://ex/{o}")),
2334            ));
2335        };
2336        edge("a", "reads", "pa1");
2337        edge("pa1", "kind", "Temp");
2338        edge("a", "reads", "pa2");
2339        edge("pa2", "kind", "Temp");
2340        edge("b", "reads", "qb1");
2341        edge("qb1", "kind", "Temp");
2342        edge("b", "reads", "qb2");
2343        edge("qb2", "kind", "Flow");
2344
2345        let foci = vec![subject("a"), subject("b")];
2346        let sorted = |mut v: Vec<Triple>| -> Vec<Triple> {
2347            v.sort_by_key(|t| t.to_string());
2348            v
2349        };
2350
2351        // Per-focus baseline: the small focus set finishes within the probe budget,
2352        // so construct_many stays on the per-focus path.
2353        let baseline = SparqlExecutor::new(&g).unwrap();
2354        let want = sorted(baseline.construct_many(query, &foci, None).unwrap());
2355
2356        // Memo-forced batched path: one free-`?this` run, filtered by subject.
2357        let batched = SparqlExecutor::new(&g).unwrap();
2358        batched
2359            .batch_construct
2360            .borrow_mut()
2361            .insert(query.to_string());
2362        let got = sorted(batched.construct_many(query, &foci, None).unwrap());
2363
2364        let expected = Triple::new(
2365            nn("http://ex/a"),
2366            nn("http://ex/uniqueKind"),
2367            nn("http://ex/Temp"),
2368        );
2369        assert_eq!(
2370            want,
2371            vec![expected],
2372            "per-focus oracle: only ex:a is inferred"
2373        );
2374        assert_eq!(got, want, "batched output must match per-focus exactly");
2375    }
2376}