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