Skip to main content

rete_core/sparql/
lower.rs

1//! Lowering: parse a SPARQL query with `spargebra` and translate its algebra
2//! into the engine's [`Select`]/[`Plan`]/[`FExpr`] forms (SPEC.md §8). This is
3//! the front end — it only builds plan/expression values; evaluation lives in
4//! the parent module and its `eval`/`aggregate`/`path` siblings.
5
6use super::*;
7
8use crate::bgp::{PatternTerm, TriplePattern};
9use spargebra::algebra::{
10    AggregateExpression, AggregateFunction, Expression, Function, GraphPattern, OrderExpression,
11    PropertyPathExpression, QueryDataset,
12};
13use spargebra::term::{NamedNodePattern, TermPattern, TriplePattern as SpTriplePattern};
14use spargebra::Query;
15
16/// Lower a graph pattern (with its solution modifiers) into a [`Select`].
17pub(super) fn lower_pattern(pattern: &GraphPattern) -> Result<Select, SparqlError> {
18    let mut sel = Select::default();
19    let plan = build(pattern, &mut sel, false)?;
20    sel.plan = plan;
21    Ok(sel)
22}
23
24/// Lower a SELECT's pattern + dataset clause into a [`Select`].
25pub(super) fn lower_select(
26    pattern: &GraphPattern,
27    dataset: &Option<QueryDataset>,
28) -> Result<Select, SparqlError> {
29    let mut sel = lower_pattern(pattern)?;
30    if let Some(ds) = dataset {
31        sel.from = ds.default.iter().map(|n| n.to_string()).collect();
32        sel.from_named = ds
33            .named
34            .as_ref()
35            .map(|gs| gs.iter().map(|n| n.to_string()).collect());
36    }
37    Ok(sel)
38}
39
40/// SPARQL 1.2 permits a leading `VERSION "…"` declaration; the pinned parser
41/// (SPARQL 1.1) rejects it. Accept and drop it — there is one query language, so
42/// the version is advisory. Only a declaration at the very start (after leading
43/// whitespace and `#` comments) is removed; `VERSION` anywhere else is untouched.
44/// The result is always a subslice of `query`.
45pub(super) fn strip_version(query: &str) -> &str {
46    let mut rest = query;
47    loop {
48        let t = rest.trim_start();
49        if let Some(after_hash) = t.strip_prefix('#') {
50            match after_hash.split_once('\n') {
51                Some((_, r)) => rest = r, // skip a leading comment line, look again
52                None => return query,     // comment to EOF — no VERSION
53            }
54            continue;
55        }
56        if t.len() >= 7 && t[..7].eq_ignore_ascii_case("VERSION") {
57            let after = t[7..].trim_start();
58            if let Some(q) = after.chars().next().filter(|c| *c == '"' || *c == '\'') {
59                if let Some(close) = after[q.len_utf8()..].find(q) {
60                    return &after[q.len_utf8() + close + q.len_utf8()..];
61                }
62            }
63        }
64        return query; // no leading VERSION declaration
65    }
66}
67
68/// Parse a SPARQL query — the single entry point, so every caller drops a
69/// SPARQL-1.2 `VERSION` declaration and reports a uniform parse error.
70pub(super) fn parse_query(query: &str) -> Result<Query, SparqlError> {
71    Query::parse(strip_version(query), None).map_err(|e| SparqlError::Parse(e.to_string()))
72}
73
74/// Parse a SPARQL `SELECT` query and lower it to a [`Select`].
75pub fn parse_select(query: &str) -> Result<Select, SparqlError> {
76    let parsed = parse_query(query)?;
77    match parsed {
78        Query::Select {
79            pattern, dataset, ..
80        } => lower_select(&pattern, &dataset),
81        _ => Err(SparqlError::Unsupported("only SELECT is supported")),
82    }
83}
84
85/// Collect the **concrete predicate IRIs** a query constrains on — i.e. every
86/// IRI that appears in the predicate position of a triple pattern, or as a plain
87/// predicate inside a property path. Variable predicates (`?p`) and the special
88/// `a` (`rdf:type`) keyword are normalized to their IRI tokens (`<…>`).
89///
90/// This is what `rete federate` uses to prune shards: a source whose predicate
91/// set is disjoint from this set cannot contribute a row and can be skipped.
92/// Returns an empty set when the query pins no concrete predicate (e.g. every
93/// pattern uses a variable predicate) — callers should then query every source.
94pub fn query_predicates(query: &str) -> Result<std::collections::BTreeSet<String>, SparqlError> {
95    let parsed = parse_query(query)?;
96    let mut preds = std::collections::BTreeSet::new();
97    let pattern = match &parsed {
98        Query::Select { pattern, .. } => pattern,
99        Query::Ask { pattern, .. } => pattern,
100        Query::Construct { pattern, .. } => pattern,
101        Query::Describe { pattern, .. } => pattern,
102    };
103    collect_pattern_predicates(pattern, &mut preds);
104    Ok(preds)
105}
106
107/// Walk a `GraphPattern`, adding every concrete predicate IRI to `out`.
108fn collect_pattern_predicates(p: &GraphPattern, out: &mut std::collections::BTreeSet<String>) {
109    match p {
110        GraphPattern::Bgp { patterns } => {
111            for tp in patterns {
112                if let NamedNodePattern::NamedNode(n) = &tp.predicate {
113                    out.insert(n.to_string());
114                }
115            }
116        }
117        GraphPattern::Path {
118            path: PropertyPathExpression::NamedNode(n),
119            ..
120        } => {
121            out.insert(n.to_string());
122        }
123        GraphPattern::Path { path, .. } => collect_path_predicates(path, out),
124        GraphPattern::Join { left, right }
125        | GraphPattern::Union { left, right }
126        | GraphPattern::Minus { left, right } => {
127            collect_pattern_predicates(left, out);
128            collect_pattern_predicates(right, out);
129        }
130        GraphPattern::LeftJoin { left, right, .. } => {
131            collect_pattern_predicates(left, out);
132            collect_pattern_predicates(right, out);
133        }
134        GraphPattern::Filter { inner, .. }
135        | GraphPattern::Extend { inner, .. }
136        | GraphPattern::OrderBy { inner, .. }
137        | GraphPattern::Project { inner, .. }
138        | GraphPattern::Distinct { inner }
139        | GraphPattern::Reduced { inner }
140        | GraphPattern::Slice { inner, .. }
141        | GraphPattern::Group { inner, .. }
142        | GraphPattern::Service { inner, .. }
143        | GraphPattern::Graph { inner, .. } => collect_pattern_predicates(inner, out),
144        _ => {}
145    }
146}
147
148/// Walk a (non-plain) property-path expression for its concrete predicate IRIs.
149fn collect_path_predicates(
150    path: &PropertyPathExpression,
151    out: &mut std::collections::BTreeSet<String>,
152) {
153    match path {
154        PropertyPathExpression::NamedNode(n) => {
155            out.insert(n.to_string());
156        }
157        PropertyPathExpression::Reverse(inner)
158        | PropertyPathExpression::ZeroOrMore(inner)
159        | PropertyPathExpression::OneOrMore(inner)
160        | PropertyPathExpression::ZeroOrOne(inner) => collect_path_predicates(inner, out),
161        PropertyPathExpression::Sequence(a, b) | PropertyPathExpression::Alternative(a, b) => {
162            collect_path_predicates(a, out);
163            collect_path_predicates(b, out);
164        }
165        PropertyPathExpression::NegatedPropertySet(_) => {}
166    }
167}
168
169/// Lower a left-deep chain of `Join` / `LeftJoin` **iteratively** (see the note
170/// in `build`'s Join/LeftJoin arm). Walks the left spine collecting each
171/// operator (its right side, and a LeftJoin's optional condition), lowers the
172/// base and each right, then folds the plan back up — the identical plan tree
173/// the recursive lowering would produce, but the spine costs O(1) call-stack
174/// depth instead of one (large) frame per operand.
175fn build_left_spine(p: &GraphPattern, sel: &mut Select) -> Result<Plan, SparqlError> {
176    enum SpineOp {
177        Join(Plan),
178        LeftJoin(Plan, Option<FExpr>),
179    }
180    let mut ops: Vec<SpineOp> = Vec::new();
181    let mut cur = p;
182    loop {
183        match cur {
184            GraphPattern::Join { left, right } => {
185                ops.push(SpineOp::Join(build(right, sel, true)?));
186                cur = left;
187            }
188            GraphPattern::LeftJoin {
189                left,
190                right,
191                expression,
192            } => {
193                let cond = expression.as_ref().map(convert_expr).transpose()?;
194                ops.push(SpineOp::LeftJoin(build(right, sel, true)?, cond));
195                cur = left;
196            }
197            _ => break,
198        }
199    }
200    // `cur` now points at the spine's base (the first non-Join/LeftJoin node).
201    let mut plan = build(cur, sel, true)?;
202    // `ops` is outermost-first; fold innermost-first to rebuild the same tree.
203    for op in ops.into_iter().rev() {
204        plan = match op {
205            SpineOp::Join(r) => Plan::Join(Box::new(plan), Box::new(r)),
206            SpineOp::LeftJoin(r, c) => Plan::LeftJoin(Box::new(plan), Box::new(r), c),
207        };
208    }
209    Ok(plan)
210}
211
212/// Build the evaluation [`Plan`] for a graph pattern, capturing the solution
213/// modifiers (projection/DISTINCT/slice) into `sel` as transparent wrappers.
214///
215/// `in_where` is true once we have descended into the graph-pattern body (past
216/// any pattern operator or a GROUP BY's inner). It decides where an `Extend`
217/// (`BIND`) lands: an in-pattern BIND becomes an in-tree [`Plan::Extend`] so a
218/// following FILTER/join sees it, while a top-level projection alias goes to the
219/// post-evaluation [`Select::extends`] list (applied after any aggregation).
220fn build(mut p: &GraphPattern, sel: &mut Select, mut in_where: bool) -> Result<Plan, SparqlError> {
221    // Peel the transparent single-child solution modifiers ITERATIVELY. Each one
222    // only records state into `sel` (or flips `in_where`) before descending to
223    // its inner pattern, so a stack of Slice / ORDER BY / DISTINCT / projection /
224    // GROUP BY / top-level BIND nests without one `build` stack frame per level.
225    // With `build`'s large frame, that per-level recursion overflows iOS/iPad
226    // Safari's small WASM call stack even on shallow queries — a plain
227    // `GROUP BY … ORDER BY … LIMIT` is already four wrappers deep.
228    loop {
229        match p {
230            GraphPattern::Distinct { inner } | GraphPattern::Reduced { inner } => {
231                // Below the query's own projection (or anywhere inside the pattern
232                // body) this modifier belongs to a nested SELECT, not to us — the
233                // same rule the `Project` arm below already applies.
234                if in_where || !sel.project.is_empty() {
235                    return Ok(Plan::Subquery(Box::new(lower_pattern(p)?)));
236                }
237                sel.distinct = true;
238                p = inner;
239            }
240            GraphPattern::Slice {
241                inner,
242                start,
243                length,
244            } => {
245                // A sub-SELECT's LIMIT/OFFSET must not land on the outer query.
246                // Peeling it here overwrote the outer slice *and* stole the inner
247                // one before the nested `Project` could turn it into a subquery, so
248                // `SELECT … WHERE { { SELECT … LIMIT 10 } } LIMIT 3` returned 10.
249                if in_where || !sel.project.is_empty() {
250                    return Ok(Plan::Subquery(Box::new(lower_pattern(p)?)));
251                }
252                sel.offset = *start;
253                sel.limit = *length;
254                p = inner;
255            }
256            // A *top-level* projection (a nested SELECT stays a subquery — handled
257            // in the match below, where its guard still holds).
258            GraphPattern::Project { inner, variables } if !in_where && sel.project.is_empty() => {
259                for v in variables {
260                    sel.project.push(v.as_str().to_string());
261                }
262                p = inner;
263            }
264            GraphPattern::Group {
265                inner,
266                variables,
267                aggregates,
268            } => {
269                let by = variables.iter().map(|v| v.as_str().to_string()).collect();
270                let mut aggs = Vec::with_capacity(aggregates.len());
271                let mut pre: Vec<(String, FExpr)> = Vec::new();
272                for (var, ae) in aggregates {
273                    aggs.push((var.as_str().to_string(), convert_agg(ae, &mut pre)?));
274                }
275                sel.group = Some(GroupSpec { by, aggs, pre });
276                in_where = true; // the group's inner *is* the WHERE pattern
277                p = inner;
278            }
279            GraphPattern::OrderBy { inner, expression } => {
280                for oe in expression {
281                    let (e, desc) = match oe {
282                        OrderExpression::Asc(e) => (e, false),
283                        OrderExpression::Desc(e) => (e, true),
284                    };
285                    sel.order.push((convert_expr(e)?, desc));
286                }
287                p = inner;
288            }
289            // A top-level projection alias `(expr AS ?v)`; a BIND *inside* the
290            // pattern stays in the plan tree (the match's `Extend` arm).
291            GraphPattern::Extend {
292                inner,
293                variable,
294                expression,
295            } if !in_where => {
296                sel.extends
297                    .push((variable.as_str().to_string(), convert_expr(expression)?));
298                p = inner;
299            }
300            _ => break,
301        }
302    }
303    match p {
304        GraphPattern::Bgp { patterns } => Ok(lower_bgp(patterns, &mut sel.star_counter)),
305        // Join and LeftJoin nest left-deep — `A . B OPTIONAL C OPTIONAL D` is
306        // `LeftJoin(LeftJoin(Join(A,B),C),D)`. Recursing straight down that spine
307        // costs one (large) stack frame per operand, which overflows the small
308        // WASM call stack on iOS/iPad Safari for deep queries (several OPTIONALs)
309        // — before any data is even fetched. Walk the spine ITERATIVELY instead:
310        // collect its operators, build the base + each (shallow) right, then fold
311        // the plan back up. Same plan tree, O(1) recursion depth for the spine.
312        GraphPattern::Join { .. } | GraphPattern::LeftJoin { .. } => build_left_spine(p, sel),
313        GraphPattern::Union { left, right } => Ok(Plan::Union(
314            Box::new(build(left, sel, true)?),
315            Box::new(build(right, sel, true)?),
316        )),
317        GraphPattern::Minus { left, right } => Ok(Plan::Minus(
318            Box::new(build(left, sel, true)?),
319            Box::new(build(right, sel, true)?),
320        )),
321        GraphPattern::Graph { name, inner } => {
322            let target = match name {
323                NamedNodePattern::NamedNode(n) => GraphTarget::Named(n.to_string()),
324                NamedNodePattern::Variable(v) => GraphTarget::Var(v.as_str().to_string()),
325            };
326            Ok(Plan::Graph(target, Box::new(build(inner, sel, true)?)))
327        }
328        GraphPattern::Path {
329            subject,
330            path,
331            object,
332        } => Ok(Plan::Path(
333            term_to_pattern(subject),
334            lower_path(path)?,
335            term_to_pattern(object),
336        )),
337        GraphPattern::Values {
338            variables,
339            bindings,
340        } => {
341            let vars = variables.iter().map(|v| v.as_str().to_string()).collect();
342            let rows = bindings
343                .iter()
344                .map(|row| {
345                    row.iter()
346                        .map(|g| g.as_ref().map(|t| t.to_string()))
347                        .collect()
348                })
349                .collect();
350            Ok(Plan::Values(vars, rows))
351        }
352        GraphPattern::Filter { expr, inner } => {
353            // A filter sitting *above* a GROUP BY is a HAVING: it must run after
354            // aggregation, not on the raw bindings.
355            let had_group = sel.group.is_some();
356            let inner_plan = build(inner, sel, true)?;
357            let fexpr = convert_expr(expr)?;
358            if sel.group.is_some() && !had_group {
359                sel.having.push(fexpr);
360                Ok(inner_plan)
361            } else {
362                Ok(Plan::Filter(fexpr, Box::new(inner_plan)))
363            }
364        }
365        // Transparent solution-modifier wrappers: record and descend.
366        GraphPattern::Project { inner, variables } => {
367            // A Project reached *inside* the graph pattern (or after the query's
368            // own projection is already set) is a nested SELECT: lower it into
369            // its own independent `Select` and evaluate it as a subquery whose
370            // projected solutions join with the surrounding pattern.
371            if in_where || !sel.project.is_empty() {
372                let sub = lower_pattern(p)?;
373                return Ok(Plan::Subquery(Box::new(sub)));
374            }
375            for v in variables {
376                sel.project.push(v.as_str().to_string());
377            }
378            build(inner, sel, in_where)
379        }
380        GraphPattern::Distinct { inner } | GraphPattern::Reduced { inner } => {
381            if in_where || !sel.project.is_empty() {
382                return Ok(Plan::Subquery(Box::new(lower_pattern(p)?)));
383            }
384            sel.distinct = true;
385            build(inner, sel, in_where)
386        }
387        GraphPattern::Slice {
388            inner,
389            start,
390            length,
391        } => {
392            if in_where || !sel.project.is_empty() {
393                return Ok(Plan::Subquery(Box::new(lower_pattern(p)?)));
394            }
395            sel.offset = *start;
396            sel.limit = *length;
397            build(inner, sel, in_where)
398        }
399        GraphPattern::Group {
400            inner,
401            variables,
402            aggregates,
403        } => {
404            let by = variables.iter().map(|v| v.as_str().to_string()).collect();
405            let mut aggs = Vec::with_capacity(aggregates.len());
406            let mut pre: Vec<(String, FExpr)> = Vec::new();
407            for (var, ae) in aggregates {
408                aggs.push((var.as_str().to_string(), convert_agg(ae, &mut pre)?));
409            }
410            sel.group = Some(GroupSpec { by, aggs, pre });
411            // The group's inner *is* the WHERE pattern — any BIND inside it must
412            // run per-row before aggregation, so descend as in-pattern.
413            build(inner, sel, true)
414        }
415        GraphPattern::Extend {
416            inner,
417            variable,
418            expression,
419        } => {
420            let var = variable.as_str().to_string();
421            let fexpr = convert_expr(expression)?;
422            if in_where {
423                // A BIND inside the graph pattern: keep it in the plan tree so a
424                // following FILTER or join observes the bound variable.
425                Ok(Plan::Extend(var, fexpr, Box::new(build(inner, sel, true)?)))
426            } else {
427                // A top-level projection alias `(expr AS ?v)`: applied after the
428                // pattern (and after any aggregation) at projection time.
429                sel.extends.push((var, fexpr));
430                build(inner, sel, in_where)
431            }
432        }
433        GraphPattern::OrderBy { inner, expression } => {
434            for oe in expression {
435                let (e, desc) = match oe {
436                    OrderExpression::Asc(e) => (e, false),
437                    OrderExpression::Desc(e) => (e, true),
438                };
439                sel.order.push((convert_expr(e)?, desc));
440            }
441            build(inner, sel, in_where)
442        }
443        // SPARQL 1.1 federated query: the inner pattern is not lowered — it is
444        // re-serialized to SPARQL text (spargebra round-trips, prefixes already
445        // expanded) and shipped verbatim to the endpoint at evaluation time.
446        // Only the variables it can bind are collected, so the returned
447        // solutions land in slots and join like any other operand.
448        GraphPattern::Service {
449            name,
450            inner,
451            silent,
452        } => {
453            let endpoint = match name {
454                NamedNodePattern::NamedNode(n) => n.as_str().to_string(),
455                NamedNodePattern::Variable(_) => {
456                    return Err(SparqlError::Unsupported("SERVICE with a variable endpoint"))
457                }
458            };
459            let mut vars = std::collections::BTreeSet::new();
460            collect_pattern_variables(inner, &mut vars);
461            let query = Query::Select {
462                dataset: None,
463                pattern: (**inner).clone(),
464                base_iri: None,
465            }
466            .to_string();
467            Ok(Plan::Service {
468                silent: *silent,
469                endpoint,
470                vars: vars.into_iter().collect(),
471                query,
472            })
473        }
474    }
475}
476
477/// Collect the variables a graph pattern can bind — used to give a `SERVICE`
478/// block's results their slots. Deliberately an **over-approximation** (e.g. a
479/// nested SELECT's non-projected variables are included): an extra slot just
480/// stays unbound, while a missed one would silently drop a returned binding.
481fn collect_pattern_variables(p: &GraphPattern, out: &mut std::collections::BTreeSet<String>) {
482    let term_var = |t: &TermPattern, out: &mut std::collections::BTreeSet<String>| {
483        if let TermPattern::Variable(v) = t {
484            out.insert(v.as_str().to_string());
485        }
486    };
487    match p {
488        GraphPattern::Bgp { patterns } => {
489            for tp in patterns {
490                term_var(&tp.subject, out);
491                if let NamedNodePattern::Variable(v) = &tp.predicate {
492                    out.insert(v.as_str().to_string());
493                }
494                term_var(&tp.object, out);
495            }
496        }
497        GraphPattern::Path {
498            subject, object, ..
499        } => {
500            term_var(subject, out);
501            term_var(object, out);
502        }
503        GraphPattern::Values { variables, .. } => {
504            for v in variables {
505                out.insert(v.as_str().to_string());
506            }
507        }
508        GraphPattern::Join { left, right }
509        | GraphPattern::Union { left, right }
510        | GraphPattern::Minus { left, right } => {
511            collect_pattern_variables(left, out);
512            collect_pattern_variables(right, out);
513        }
514        GraphPattern::LeftJoin { left, right, .. } => {
515            collect_pattern_variables(left, out);
516            collect_pattern_variables(right, out);
517        }
518        GraphPattern::Extend {
519            inner, variable, ..
520        } => {
521            out.insert(variable.as_str().to_string());
522            collect_pattern_variables(inner, out);
523        }
524        GraphPattern::Group {
525            inner,
526            variables,
527            aggregates,
528        } => {
529            for v in variables {
530                out.insert(v.as_str().to_string());
531            }
532            for (v, _) in aggregates {
533                out.insert(v.as_str().to_string());
534            }
535            collect_pattern_variables(inner, out);
536        }
537        GraphPattern::Graph { name, inner } => {
538            if let NamedNodePattern::Variable(v) = name {
539                out.insert(v.as_str().to_string());
540            }
541            collect_pattern_variables(inner, out);
542        }
543        GraphPattern::Project { inner, variables } => {
544            for v in variables {
545                out.insert(v.as_str().to_string());
546            }
547            collect_pattern_variables(inner, out);
548        }
549        GraphPattern::Filter { inner, .. }
550        | GraphPattern::OrderBy { inner, .. }
551        | GraphPattern::Distinct { inner }
552        | GraphPattern::Reduced { inner }
553        | GraphPattern::Slice { inner, .. }
554        | GraphPattern::Service { inner, .. } => collect_pattern_variables(inner, out),
555    }
556}
557
558fn convert_agg(
559    ae: &AggregateExpression,
560    pre: &mut Vec<(String, FExpr)>,
561) -> Result<Agg, SparqlError> {
562    match ae {
563        AggregateExpression::CountSolutions { distinct } => Ok(Agg::CountStar {
564            distinct: *distinct,
565        }),
566        AggregateExpression::FunctionCall {
567            name,
568            expr,
569            distinct,
570        } => {
571            let var = match expr {
572                Expression::Variable(v) => v.as_str().to_string(),
573                // Aggregate over an EXPRESSION (e.g. SUM(?a * 2), AVG(?x + ?y)):
574                // compute it into a synthetic per-row column before grouping, then
575                // aggregate that column — so all the aggregate machinery (and the
576                // summary-safe COUNT path) keeps treating the argument as a slot.
577                other => {
578                    let name = format!("__agg{}", pre.len());
579                    pre.push((name.clone(), convert_expr(other)?));
580                    name
581                }
582            };
583            Ok(match name {
584                AggregateFunction::Count => Agg::Count(var, *distinct),
585                AggregateFunction::Sum => Agg::Sum(var),
586                AggregateFunction::Avg => Agg::Avg(var),
587                AggregateFunction::Min => Agg::Min(var),
588                AggregateFunction::Max => Agg::Max(var),
589                AggregateFunction::Sample => Agg::Sample(var),
590                AggregateFunction::GroupConcat { separator } => Agg::GroupConcat(
591                    var,
592                    separator.clone().unwrap_or_else(|| " ".to_string()),
593                    *distinct,
594                ),
595                _ => return Err(SparqlError::Unsupported("aggregate function")),
596            })
597        }
598    }
599}
600
601/// Lower a `spargebra` property path into a [`PathAst`].
602fn lower_path(p: &PropertyPathExpression) -> Result<PathAst, SparqlError> {
603    Ok(match p {
604        PropertyPathExpression::NamedNode(n) => PathAst::Pred(n.to_string(), false),
605        PropertyPathExpression::Reverse(inner) => reverse(lower_path(inner)?),
606        PropertyPathExpression::OneOrMore(inner) => {
607            PathAst::Rep(Box::new(lower_path(inner)?), Rep::OneOrMore)
608        }
609        PropertyPathExpression::ZeroOrMore(inner) => {
610            PathAst::Rep(Box::new(lower_path(inner)?), Rep::ZeroOrMore)
611        }
612        PropertyPathExpression::ZeroOrOne(inner) => {
613            PathAst::Rep(Box::new(lower_path(inner)?), Rep::ZeroOrOne)
614        }
615        PropertyPathExpression::Sequence(a, b) => {
616            PathAst::Seq(Box::new(lower_path(a)?), Box::new(lower_path(b)?))
617        }
618        PropertyPathExpression::Alternative(a, b) => {
619            PathAst::Alt(Box::new(lower_path(a)?), Box::new(lower_path(b)?))
620        }
621        PropertyPathExpression::NegatedPropertySet(preds) => {
622            PathAst::NegatedSet(preds.iter().map(|n| n.to_string()).collect(), false)
623        }
624    })
625}
626
627/// Translate a `spargebra` expression into the supported [`FExpr`] subset.
628fn convert_expr(e: &Expression) -> Result<FExpr, SparqlError> {
629    let bin = |op, l: &Expression, r: &Expression| -> Result<FExpr, SparqlError> {
630        Ok(FExpr::Compare(
631            op,
632            Box::new(convert_expr(l)?),
633            Box::new(convert_expr(r)?),
634        ))
635    };
636    let arith = |op, l: &Expression, r: &Expression| -> Result<FExpr, SparqlError> {
637        Ok(FExpr::Arith(
638            op,
639            Box::new(convert_expr(l)?),
640            Box::new(convert_expr(r)?),
641        ))
642    };
643    Ok(match e {
644        Expression::Variable(v) => FExpr::Var(v.as_str().to_string()),
645        Expression::NamedNode(n) => FExpr::Const(n.to_string()),
646        Expression::Literal(l) => FExpr::Const(l.to_string()),
647        Expression::Equal(l, r) => bin(Op::Eq, l, r)?,
648        Expression::Greater(l, r) => bin(Op::Gt, l, r)?,
649        Expression::GreaterOrEqual(l, r) => bin(Op::Ge, l, r)?,
650        Expression::Less(l, r) => bin(Op::Lt, l, r)?,
651        Expression::LessOrEqual(l, r) => bin(Op::Le, l, r)?,
652        Expression::And(l, r) => FExpr::And(Box::new(convert_expr(l)?), Box::new(convert_expr(r)?)),
653        Expression::Or(l, r) => FExpr::Or(Box::new(convert_expr(l)?), Box::new(convert_expr(r)?)),
654        Expression::Not(inner) => FExpr::Not(Box::new(convert_expr(inner)?)),
655        Expression::Bound(v) => FExpr::Bound(v.as_str().to_string()),
656        Expression::Add(l, r) => arith(ArithOp::Add, l, r)?,
657        Expression::Subtract(l, r) => arith(ArithOp::Sub, l, r)?,
658        Expression::Multiply(l, r) => arith(ArithOp::Mul, l, r)?,
659        Expression::Divide(l, r) => arith(ArithOp::Div, l, r)?,
660        Expression::Coalesce(items) => FExpr::Coalesce(
661            items
662                .iter()
663                .map(convert_expr)
664                .collect::<Result<Vec<_>, _>>()?,
665        ),
666        Expression::UnaryPlus(e) => convert_expr(e)?,
667        Expression::UnaryMinus(e) => FExpr::Arith(
668            ArithOp::Sub,
669            Box::new(FExpr::Const("0".into())),
670            Box::new(convert_expr(e)?),
671        ),
672        Expression::If(c, t, e) => FExpr::If(
673            Box::new(convert_expr(c)?),
674            Box::new(convert_expr(t)?),
675            Box::new(convert_expr(e)?),
676        ),
677        Expression::In(e, list) => FExpr::In(
678            Box::new(convert_expr(e)?),
679            list.iter().map(convert_expr).collect::<Result<_, _>>()?,
680        ),
681        Expression::SameTerm(l, r) => {
682            FExpr::SameTerm(Box::new(convert_expr(l)?), Box::new(convert_expr(r)?))
683        }
684        Expression::Exists(pattern) => {
685            // Build the sub-plan with a throwaway Select (its modifiers don't
686            // escape the EXISTS). The whole body is a WHERE pattern, so any BIND
687            // must stay in-tree (the discarded `sub.extends` would be lost).
688            let mut sub = Select::default();
689            let plan = build(pattern, &mut sub, true)?;
690            FExpr::Exists(Box::new(plan))
691        }
692        Expression::FunctionCall(func, params) => {
693            let builtin = match func {
694                Function::Str => Builtin::Str,
695                Function::Concat => Builtin::Concat,
696                Function::SubStr => Builtin::SubStr,
697                Function::StrBefore => Builtin::StrBefore,
698                Function::StrAfter => Builtin::StrAfter,
699                Function::StrLen => Builtin::StrLen,
700                Function::UCase => Builtin::UCase,
701                Function::LCase => Builtin::LCase,
702                Function::Abs => Builtin::Abs,
703                Function::Ceil => Builtin::Ceil,
704                Function::Floor => Builtin::Floor,
705                Function::Round => Builtin::Round,
706                Function::Contains => Builtin::Contains,
707                Function::StrStarts => Builtin::StrStarts,
708                Function::StrEnds => Builtin::StrEnds,
709                Function::IsIri => Builtin::IsIri,
710                Function::IsBlank => Builtin::IsBlank,
711                Function::IsLiteral => Builtin::IsLiteral,
712                Function::IsNumeric => Builtin::IsNumeric,
713                Function::Datatype => Builtin::Datatype,
714                Function::Lang => Builtin::Lang,
715                Function::Regex => Builtin::Regex,
716                Function::LangMatches => Builtin::LangMatches,
717                Function::StrDt => Builtin::StrDt,
718                Function::StrLang => Builtin::StrLang,
719                Function::Iri => Builtin::Iri,
720                Function::EncodeForUri => Builtin::EncodeForUri,
721                Function::Replace => Builtin::Replace,
722                Function::Md5 => Builtin::Md5,
723                Function::Sha1 => Builtin::Sha1,
724                Function::Sha256 => Builtin::Sha256,
725                Function::Sha384 => Builtin::Sha384,
726                Function::Sha512 => Builtin::Sha512,
727                Function::Year => Builtin::Year,
728                Function::Month => Builtin::Month,
729                Function::Day => Builtin::Day,
730                Function::Hours => Builtin::Hours,
731                Function::Minutes => Builtin::Minutes,
732                Function::Seconds => Builtin::Seconds,
733                Function::Timezone => Builtin::Timezone,
734                Function::Tz => Builtin::Tz,
735                Function::Rand => Builtin::Rand,
736                Function::Uuid => Builtin::Uuid,
737                Function::StrUuid => Builtin::StrUuid,
738                Function::BNode => Builtin::BNode,
739                // RDF-star / SPARQL-star.
740                Function::Triple => Builtin::TripleTerm,
741                Function::IsTriple => Builtin::IsTriple,
742                Function::Subject => Builtin::Subject,
743                Function::Predicate => Builtin::Predicate,
744                Function::Object => Builtin::Object,
745                // An `xsd:<type>(expr)` constructor parses as a call to the
746                // datatype IRI — map the supported XSD casts.
747                Function::Custom(nn) => match nn.as_str() {
748                    "http://www.w3.org/2001/XMLSchema#integer" => Builtin::CastInteger,
749                    "http://www.w3.org/2001/XMLSchema#decimal" => Builtin::CastDecimal,
750                    "http://www.w3.org/2001/XMLSchema#float" => Builtin::CastFloat,
751                    "http://www.w3.org/2001/XMLSchema#double" => Builtin::CastDouble,
752                    "http://www.w3.org/2001/XMLSchema#boolean" => Builtin::CastBoolean,
753                    "http://www.w3.org/2001/XMLSchema#string" => Builtin::CastString,
754                    // GeoSPARQL geof: functions.
755                    "http://www.opengis.net/def/function/geosparql/sfContains" => {
756                        Builtin::GeoSfContains
757                    }
758                    "http://www.opengis.net/def/function/geosparql/sfWithin" => {
759                        Builtin::GeoSfWithin
760                    }
761                    "http://www.opengis.net/def/function/geosparql/sfIntersects" => {
762                        Builtin::GeoSfIntersects
763                    }
764                    "http://www.opengis.net/def/function/geosparql/sfDisjoint" => {
765                        Builtin::GeoSfDisjoint
766                    }
767                    "http://www.opengis.net/def/function/geosparql/sfEquals" => {
768                        Builtin::GeoSfEquals
769                    }
770                    "http://www.opengis.net/def/function/geosparql/distance" => {
771                        Builtin::GeoDistance
772                    }
773                    "http://www.opengis.net/def/function/geosparql/envelope" => {
774                        Builtin::GeoEnvelope
775                    }
776                    // geo3: 3D extension of GeoSPARQL (see crate::geo3).
777                    "https://w3id.org/rete/geo3/function/distance3D" => Builtin::Geo3Distance,
778                    "https://w3id.org/rete/geo3/function/contains3D" => Builtin::Geo3Contains,
779                    "https://w3id.org/rete/geo3/function/within3D" => Builtin::Geo3Within,
780                    "https://w3id.org/rete/geo3/function/adjacent3D" => Builtin::Geo3Adjacent,
781                    _ => return Err(SparqlError::Unsupported("built-in function")),
782                },
783                _ => return Err(SparqlError::Unsupported("built-in function")),
784            };
785            let args = params
786                .iter()
787                .map(convert_expr)
788                .collect::<Result<Vec<_>, _>>()?;
789            FExpr::Func(builtin, args)
790        }
791    })
792}
793
794fn convert(tp: &SpTriplePattern) -> TriplePattern {
795    TriplePattern {
796        s: term_to_pattern(&tp.subject),
797        p: named_to_pattern(&tp.predicate),
798        o: term_to_pattern(&tp.object),
799    }
800}
801
802/// Whether a term position is an RDF-star quoted triple that carries INNER
803/// VARIABLES (`<< ?s :p ?o >>`) — a fully-concrete one is a plain constant term.
804fn is_var_quoted(t: &TermPattern) -> bool {
805    matches!(t, TermPattern::Triple(inner) if ground_quoted_token(inner).is_none())
806}
807
808/// Lower a BGP, desugaring RDF-star quoted-triple patterns with inner variables
809/// (Stage 3b). A quoted position `<< ?s :p ?o >>` is replaced by a fresh variable
810/// `?__qtN`, and the plan is wrapped so the quoted triple's components are
811/// constrained/bound: `FILTER(isTRIPLE(?__qtN))`, plus per component either a
812/// `sameTerm` FILTER (a concrete inner term, or an inner variable that is also
813/// bound by a regular pattern → a join) or a BIND via `Plan::Extend` (a fresh
814/// inner variable). Nested quoting recurses. A BGP with no inner-variable quoted
815/// pattern returns the plain `Plan::Bgp` unchanged (the hot path is untouched).
816fn lower_bgp(patterns: &[SpTriplePattern], counter: &mut usize) -> Plan {
817    if !patterns
818        .iter()
819        .any(|tp| is_var_quoted(&tp.subject) || is_var_quoted(&tp.object))
820    {
821        return Plan::Bgp(patterns.iter().map(convert).collect());
822    }
823
824    // Variables appearing in a REGULAR (non-quoted) position are bound by a scan,
825    // so an inner-quoted occurrence of one is a JOIN (FILTER sameTerm), not a BIND.
826    let mut regular_vars: std::collections::BTreeSet<String> = Default::default();
827    for tp in patterns {
828        for t in [&tp.subject, &tp.object] {
829            if let TermPattern::Variable(v) = t {
830                regular_vars.insert(v.as_str().to_string());
831            }
832        }
833        if let NamedNodePattern::Variable(v) = &tp.predicate {
834            regular_vars.insert(v.as_str().to_string());
835        }
836    }
837
838    let mut st = StarRewrite {
839        counter,
840        regular_vars,
841        filters: Vec::new(),
842        binds: Vec::new(),
843        bound: Default::default(),
844        seen: Default::default(),
845    };
846    let rewritten: Vec<TriplePattern> = patterns
847        .iter()
848        .map(|tp| TriplePattern {
849            s: st.rewrite_term(&tp.subject),
850            p: named_to_pattern(&tp.predicate),
851            o: st.rewrite_term(&tp.object),
852        })
853        .collect();
854
855    // Bgp innermost; each BIND (Extend) wraps it in the order recorded (a nested
856    // `?__qtN` is bound before the component vars extracted from it); the combined
857    // FILTER sits outermost — it may reference vars bound by those Extends.
858    let mut plan = Plan::Bgp(rewritten);
859    for (var, expr) in st.binds {
860        plan = Plan::Extend(var, expr, Box::new(plan));
861    }
862    if let Some(cond) = st
863        .filters
864        .into_iter()
865        .reduce(|a, b| FExpr::And(Box::new(a), Box::new(b)))
866    {
867        plan = Plan::Filter(cond, Box::new(plan));
868    }
869    plan
870}
871
872/// Scratch state for the quoted-pattern rewrite in [`lower_bgp`].
873struct StarRewrite<'a> {
874    counter: &'a mut usize,
875    regular_vars: std::collections::BTreeSet<String>,
876    filters: Vec<FExpr>,
877    binds: Vec<(String, FExpr)>,
878    bound: std::collections::BTreeSet<String>,
879    /// Canonical quoted-triple text → the `__qtN` var already allocated for it,
880    /// so a quoted triple that appears in more than one pattern (e.g.
881    /// `<< s p o >> :a ?x ; :b ?y`, two annotations on one statement) REUSES the
882    /// same var. Without this the two patterns share no variable and the BGP is
883    /// a Cartesian product of every annotated triple against every other.
884    seen: std::collections::BTreeMap<String, String>,
885}
886
887fn star_accessor(f: Builtin, qt: &str) -> FExpr {
888    FExpr::Func(f, vec![FExpr::Var(qt.to_string())])
889}
890fn star_same(a: FExpr, b: FExpr) -> FExpr {
891    FExpr::SameTerm(Box::new(a), Box::new(b))
892}
893
894impl StarRewrite<'_> {
895    /// Rewrite a subject/object position; a quoted-with-vars becomes a fresh
896    /// variable whose decomposition constraints are recorded.
897    fn rewrite_term(&mut self, t: &TermPattern) -> PatternTerm {
898        if is_var_quoted(t) {
899            let TermPattern::Triple(inner) = t else {
900                unreachable!("is_var_quoted implies Triple");
901            };
902            // Reuse the same fresh var for a quoted triple already seen in this
903            // BGP, so repeated occurrences JOIN on it instead of forming a
904            // Cartesian product (its decomposition constraints are added once).
905            let key = format!("{inner:?}");
906            if let Some(qt) = self.seen.get(&key) {
907                return PatternTerm::Var(qt.clone());
908            }
909            *self.counter += 1;
910            let qt = format!("__qt{}", self.counter);
911            self.seen.insert(key, qt.clone());
912            self.filters
913                .push(FExpr::Func(Builtin::IsTriple, vec![FExpr::Var(qt.clone())]));
914            self.decompose(inner, &qt);
915            PatternTerm::Var(qt)
916        } else {
917            term_to_pattern(t)
918        }
919    }
920
921    /// Constrain the three components of quoted triple `inner` against the
922    /// variable `qt` that holds it.
923    fn decompose(&mut self, inner: &SpTriplePattern, qt: &str) {
924        self.constrain(&inner.subject, star_accessor(Builtin::Subject, qt));
925        match &inner.predicate {
926            NamedNodePattern::NamedNode(n) => self.filters.push(star_same(
927                star_accessor(Builtin::Predicate, qt),
928                FExpr::Const(n.to_string()),
929            )),
930            NamedNodePattern::Variable(v) => {
931                self.constrain_var(v.as_str(), star_accessor(Builtin::Predicate, qt))
932            }
933        }
934        self.constrain(&inner.object, star_accessor(Builtin::Object, qt));
935    }
936
937    fn constrain(&mut self, t: &TermPattern, acc: FExpr) {
938        match t {
939            TermPattern::NamedNode(n) => self
940                .filters
941                .push(star_same(acc, FExpr::Const(n.to_string()))),
942            TermPattern::Literal(l) => self
943                .filters
944                .push(star_same(acc, FExpr::Const(l.to_string()))),
945            TermPattern::Variable(v) => self.constrain_var(v.as_str(), acc),
946            TermPattern::BlankNode(b) => self.constrain_var(&b.to_string(), acc),
947            TermPattern::Triple(nested) => {
948                if let Some(tok) = ground_quoted_token(nested) {
949                    self.filters.push(star_same(acc, FExpr::Const(tok)));
950                } else {
951                    // Nested quoted-with-vars: bind a fresh var to this accessor,
952                    // assert it is a triple, then recurse.
953                    *self.counter += 1;
954                    let qt2 = format!("__qt{}", self.counter);
955                    self.binds.push((qt2.clone(), acc));
956                    self.filters.push(FExpr::Func(
957                        Builtin::IsTriple,
958                        vec![FExpr::Var(qt2.clone())],
959                    ));
960                    self.decompose(nested, &qt2);
961                }
962            }
963        }
964    }
965
966    /// An inner variable: JOIN (FILTER sameTerm) if it is bound elsewhere,
967    /// otherwise BIND it from the accessor.
968    fn constrain_var(&mut self, name: &str, acc: FExpr) {
969        let name = name.to_string();
970        if self.regular_vars.contains(&name) || self.bound.contains(&name) {
971            self.filters.push(star_same(acc, FExpr::Var(name)));
972        } else {
973            self.binds.push((name.clone(), acc));
974            self.bound.insert(name);
975        }
976    }
977}
978
979fn term_to_pattern(t: &TermPattern) -> PatternTerm {
980    match t {
981        TermPattern::NamedNode(n) => PatternTerm::Const(n.to_string()),
982        TermPattern::Literal(l) => PatternTerm::Const(l.to_string()),
983        // A blank node in a query pattern is a non-distinguished variable (and
984        // spargebra uses one as the join var when expanding fixed paths like
985        // `a/b`). Its label is stable across occurrences, so it joins correctly.
986        TermPattern::BlankNode(b) => PatternTerm::Var(b.to_string()),
987        TermPattern::Variable(v) => PatternTerm::Var(v.as_str().to_string()),
988        // RDF-star: a FULLY-CONCRETE quoted triple (`<< :s :p :o >>`) lowers to
989        // its canonical dictionary token — an ordinary constant, matched by the
990        // existing BGP engine (annotation lookup on a known statement). A quoted
991        // pattern with INNER VARIABLES (`<< ?s :p ?o >>`) can't be a Const/Var
992        // yet; that needs a `PatternTerm::Quoted` matcher (rdf-star Stage 3). For
993        // now it lowers to a token that cannot exist in the dictionary, so it
994        // matches nothing (empty result) rather than mis-matching.
995        TermPattern::Triple(tp) => match ground_quoted_token(tp) {
996            Some(tok) => PatternTerm::Const(tok),
997            None => PatternTerm::Const("<< rdf-star inner-var pattern >>".to_string()),
998        },
999    }
1000}
1001
1002/// The canonical `<< s p o >>` token for a quoted triple pattern, or `None` if
1003/// any inner term is a variable/blank (not yet a resolvable constant). Recurses
1004/// for nested quoting. Mirrors the ingest tokenizer + oxrdf's `Triple` Display.
1005fn ground_quoted_token(tp: &SpTriplePattern) -> Option<String> {
1006    fn ground(t: &TermPattern) -> Option<String> {
1007        match t {
1008            TermPattern::NamedNode(n) => Some(n.to_string()),
1009            TermPattern::Literal(l) => Some(l.to_string()),
1010            TermPattern::Triple(inner) => ground_quoted_token(inner),
1011            TermPattern::BlankNode(_) | TermPattern::Variable(_) => None,
1012        }
1013    }
1014    let s = ground(&tp.subject)?;
1015    let p = match &tp.predicate {
1016        NamedNodePattern::NamedNode(n) => n.to_string(),
1017        NamedNodePattern::Variable(_) => return None,
1018    };
1019    let o = ground(&tp.object)?;
1020    Some(format!("<<{s} {p} {o}>>"))
1021}
1022
1023fn named_to_pattern(n: &NamedNodePattern) -> PatternTerm {
1024    match n {
1025        NamedNodePattern::NamedNode(nn) => PatternTerm::Const(nn.to_string()),
1026        NamedNodePattern::Variable(v) => PatternTerm::Var(v.as_str().to_string()),
1027    }
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033
1034    #[test]
1035    fn version_parsing_select_only_and_uniform_errors() {
1036        let q = "  # leading\n VERSION \"1.2\" SELECT * WHERE {}";
1037        assert!(strip_version(q).trim_start().starts_with("SELECT"));
1038        assert!(parse_select(q).is_ok());
1039        assert_eq!(strip_version("# comment only"), "# comment only");
1040        assert_eq!(
1041            strip_version("VERSION invalid SELECT * WHERE {}"),
1042            "VERSION invalid SELECT * WHERE {}"
1043        );
1044        assert!(matches!(
1045            parse_select("ASK {}"),
1046            Err(SparqlError::Unsupported(_))
1047        ));
1048        assert!(matches!(
1049            parse_select("not sparql"),
1050            Err(SparqlError::Parse(_))
1051        ));
1052    }
1053
1054    #[test]
1055    fn lowers_every_graph_operator_dataset_modifier_and_subquery() {
1056        let query = r#"
1057            SELECT DISTINCT ?s ?x
1058            FROM <http://ex/default>
1059            FROM NAMED <http://ex/named>
1060            WHERE {
1061              { ?s <http://ex/p> ?o .
1062                OPTIONAL { ?s <http://ex/q> ?q FILTER(?q > 1) }
1063              }
1064              UNION { GRAPH ?g { ?s <http://ex/r> ?o } }
1065              MINUS { ?s <http://ex/bad> ?o }
1066              VALUES (?v ?u) { (1 UNDEF) (2 "two") }
1067              BIND(?v + 1 AS ?x)
1068              FILTER(BOUND(?x) && (?x IN (1, 2)))
1069            }
1070            ORDER BY ASC(?s) DESC(?x)
1071            OFFSET 1 LIMIT 2
1072        "#;
1073        let select = parse_select(query).unwrap();
1074        assert!(select.distinct);
1075        assert_eq!(select.project, ["s", "x"]);
1076        assert_eq!(select.offset, 1);
1077        assert_eq!(select.limit, Some(2));
1078        assert_eq!(select.order.len(), 2);
1079        assert_eq!(select.from, ["<http://ex/default>"]);
1080        assert_eq!(select.from_named.unwrap(), ["<http://ex/named>"]);
1081
1082        let subquery = parse_select(
1083            "SELECT ?s WHERE { ?s <http://ex/p> ?o . { SELECT REDUCED ?s WHERE { ?s <http://ex/q> ?v } LIMIT 1 } }",
1084        )
1085        .unwrap();
1086        assert!(matches!(subquery.plan, Plan::Join(..)));
1087
1088        let fixed_service = parse_select(
1089            "SELECT * WHERE { SERVICE SILENT <http://example.test/sparql> { ?s <http://ex/p> ?o OPTIONAL { ?s <http://ex/q> ?q } } }",
1090        )
1091        .unwrap();
1092        assert!(matches!(
1093            fixed_service.plan,
1094            Plan::Service { silent: true, .. }
1095        ));
1096        assert!(matches!(
1097            parse_select("SELECT * WHERE { SERVICE ?endpoint { ?s ?p ?o } }"),
1098            Err(SparqlError::Unsupported(_))
1099        ));
1100    }
1101
1102    #[test]
1103    fn lowers_aggregates_having_preexpressions_and_all_property_path_shapes() {
1104        let grouped = parse_select(
1105            r#"
1106            SELECT ?g
1107                   (COUNT(*) AS ?all)
1108                   (COUNT(DISTINCT ?v) AS ?count)
1109                   (SUM(?v * 2) AS ?sum)
1110                   (AVG(?v) AS ?avg)
1111                   (MIN(?v) AS ?min)
1112                   (MAX(?v) AS ?max)
1113                   (SAMPLE(?v) AS ?sample)
1114                   (GROUP_CONCAT(DISTINCT ?label; SEPARATOR="|") AS ?labels)
1115            WHERE { ?s <http://ex/g> ?g ; <http://ex/v> ?v ; <http://ex/label> ?label }
1116            GROUP BY ?g
1117            HAVING (SUM(?v) > 0)
1118            ORDER BY DESC(?sum)
1119            "#,
1120        )
1121        .unwrap();
1122        let group = grouped.group.unwrap();
1123        assert_eq!(group.by, ["g"]);
1124        assert_eq!(group.aggs.len(), 9);
1125        assert_eq!(group.pre.len(), 1);
1126        assert_eq!(grouped.having.len(), 1);
1127
1128        for path in [
1129            "<http://ex/p>",
1130            "^<http://ex/p>",
1131            "<http://ex/p>+",
1132            "<http://ex/p>*",
1133            "<http://ex/p>?",
1134            "<http://ex/p>/<http://ex/q>",
1135            "<http://ex/p>|<http://ex/q>",
1136            "!(<http://ex/p>|<http://ex/q>)",
1137        ] {
1138            let q = format!("SELECT * WHERE {{ ?s {path} ?o }}");
1139            assert!(parse_select(&q).is_ok(), "{path}");
1140        }
1141    }
1142
1143    #[test]
1144    fn lowers_expression_and_builtin_matrix_in_projection_aliases() {
1145        for expression in [
1146            "?v = 1",
1147            "?v > 1",
1148            "?v >= 1",
1149            "?v < 1",
1150            "?v <= 1",
1151            "(?v = 1) || (?v = 2)",
1152            "!(?v = 1)",
1153            "+?v",
1154            "-?v",
1155            "?v - 1",
1156            "?v * 2",
1157            "?v / 2",
1158            "COALESCE(?missing, ?v)",
1159            "IF(BOUND(?v), ?v, 0)",
1160            "sameTerm(?v, 1)",
1161            "EXISTS { ?s <http://ex/inside> ?v BIND(STR(?v) AS ?text) }",
1162            "STR(?v)",
1163            "CONCAT(\"a\", \"b\")",
1164            "SUBSTR(\"abc\", 2)",
1165            "STRBEFORE(\"abc\", \"b\")",
1166            "STRAFTER(\"abc\", \"b\")",
1167            "STRLEN(\"abc\")",
1168            "UCASE(\"abc\")",
1169            "LCASE(\"ABC\")",
1170            "ABS(-2)",
1171            "CEIL(1.2)",
1172            "FLOOR(1.2)",
1173            "ROUND(1.5)",
1174            "CONTAINS(\"abc\", \"b\")",
1175            "STRSTARTS(\"abc\", \"a\")",
1176            "STRENDS(\"abc\", \"c\")",
1177            "isIRI(<http://ex/a>)",
1178            "isBLANK(?v)",
1179            "isLITERAL(\"x\")",
1180            "isNUMERIC(1)",
1181            "DATATYPE(\"x\")",
1182            "LANG(\"x\"@en)",
1183            "REGEX(\"abc\", \"a\")",
1184            "LANGMATCHES(\"en-GB\", \"en\")",
1185            "STRDT(\"x\", <http://ex/type>)",
1186            "STRLANG(\"x\", \"en\")",
1187            "IRI(\"http://ex/a\")",
1188            "ENCODE_FOR_URI(\"a b\")",
1189            "REPLACE(\"abc\", \"b\", \"x\")",
1190            "MD5(\"abc\")",
1191            "SHA1(\"abc\")",
1192            "SHA256(\"abc\")",
1193            "SHA384(\"abc\")",
1194            "SHA512(\"abc\")",
1195            "YEAR(\"2024-01-01T00:00:00Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>)",
1196            "MONTH(\"2024-01-01T00:00:00Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>)",
1197            "DAY(\"2024-01-01T00:00:00Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>)",
1198            "HOURS(\"2024-01-01T00:00:00Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>)",
1199            "MINUTES(\"2024-01-01T00:00:00Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>)",
1200            "SECONDS(\"2024-01-01T00:00:00Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>)",
1201            "TIMEZONE(\"2024-01-01T00:00:00Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>)",
1202            "TZ(\"2024-01-01T00:00:00Z\"^^<http://www.w3.org/2001/XMLSchema#dateTime>)",
1203            "RAND()",
1204            "UUID()",
1205            "STRUUID()",
1206            "BNODE(\"x\")",
1207            "<http://www.w3.org/2001/XMLSchema#integer>(\"1\")",
1208            "<http://www.w3.org/2001/XMLSchema#decimal>(\"1.2\")",
1209            "<http://www.w3.org/2001/XMLSchema#float>(\"1\")",
1210            "<http://www.w3.org/2001/XMLSchema#double>(\"1\")",
1211            "<http://www.w3.org/2001/XMLSchema#boolean>(\"true\")",
1212            "<http://www.w3.org/2001/XMLSchema#string>(1)",
1213            "<http://www.opengis.net/def/function/geosparql/sfContains>(?a, ?b)",
1214            "<http://www.opengis.net/def/function/geosparql/sfWithin>(?a, ?b)",
1215            "<http://www.opengis.net/def/function/geosparql/sfIntersects>(?a, ?b)",
1216            "<http://www.opengis.net/def/function/geosparql/sfDisjoint>(?a, ?b)",
1217            "<http://www.opengis.net/def/function/geosparql/sfEquals>(?a, ?b)",
1218            "<http://www.opengis.net/def/function/geosparql/distance>(?a, ?b, <http://www.opengis.net/def/uom/OGC/1.0/metre>)",
1219            "<http://www.opengis.net/def/function/geosparql/envelope>(?a)",
1220            "<https://w3id.org/rete/geo3/function/distance3D>(?a, ?b)",
1221            "<https://w3id.org/rete/geo3/function/contains3D>(?a, ?b)",
1222            "<https://w3id.org/rete/geo3/function/within3D>(?a, ?b)",
1223            "<https://w3id.org/rete/geo3/function/adjacent3D>(?a, ?b)",
1224            "<https://w3id.org/rete/geo3/function/adjacent3D>(?a, ?b, 5)",
1225        ] {
1226            let q = format!("SELECT ({expression} AS ?result) WHERE {{ VALUES ?v {{ 1 }} }}");
1227            assert!(parse_select(&q).is_ok(), "{expression}");
1228        }
1229        assert!(matches!(
1230            parse_select("SELECT (<http://ex/unsupported>(1) AS ?x) WHERE {}"),
1231            Err(SparqlError::Unsupported(_))
1232        ));
1233    }
1234
1235    #[test]
1236    fn predicate_collection_walks_query_forms_wrappers_and_complex_paths() {
1237        let select = query_predicates(
1238            "SELECT * WHERE { { ?s (<http://ex/p>|^<http://ex/q>)/<http://ex/r>* ?o } UNION { GRAPH <http://ex/g> { ?s <http://ex/s> ?o } } OPTIONAL { ?s ?variable ?o } MINUS { ?s <http://ex/t> ?o } }",
1239        )
1240        .unwrap();
1241        for p in ["p", "q", "r", "s", "t"] {
1242            assert!(select.contains(&format!("<http://ex/{p}>")));
1243        }
1244        assert_eq!(
1245            query_predicates("ASK { ?s <http://ex/ask> ?o }")
1246                .unwrap()
1247                .len(),
1248            1
1249        );
1250        assert_eq!(
1251            query_predicates("CONSTRUCT { ?s <http://ex/out> ?o } WHERE { ?s <http://ex/in> ?o }")
1252                .unwrap(),
1253            ["<http://ex/in>".to_string()].into_iter().collect()
1254        );
1255        assert_eq!(
1256            query_predicates("DESCRIBE ?s WHERE { ?s <http://ex/describe> ?o }")
1257                .unwrap()
1258                .len(),
1259            1
1260        );
1261        assert!(
1262            query_predicates("SELECT * WHERE { ?s !(<http://ex/p>|<http://ex/q>) ?o }")
1263                .unwrap()
1264                .is_empty()
1265        );
1266    }
1267
1268    #[test]
1269    fn rdf_star_lowering_handles_ground_variables_reuse_and_nested_terms() {
1270        for query in [
1271            "SELECT * WHERE { << <http://ex/s> <http://ex/p> \"o\" >> <http://ex/a> ?v }",
1272            "SELECT * WHERE { << ?s <http://ex/p> ?o >> <http://ex/a> ?v . << ?s <http://ex/p> ?o >> <http://ex/b> ?w }",
1273            "SELECT * WHERE { ?s <http://ex/p> ?o . << ?s ?pred ?o >> <http://ex/a> ?v }",
1274            "SELECT * WHERE { << << ?s <http://ex/p> ?o >> <http://ex/q> ?inner >> <http://ex/a> ?v }",
1275        ] {
1276            let lowered = parse_select(query).unwrap();
1277            assert!(lowered.star_counter > 0 || matches!(lowered.plan, Plan::Bgp(_)), "{query}");
1278        }
1279    }
1280
1281    #[test]
1282    fn sub_select_modifiers_stay_inside_the_subquery() {
1283        // The transparent-modifier peel used to walk straight through the nested
1284        // SELECT boundary, so the inner LIMIT overwrote the outer one AND never
1285        // reached the subquery: `… WHERE { { SELECT … LIMIT 10 } } LIMIT 3`
1286        // returned 10 rows. The outer slice must survive, and the inner one must
1287        // travel with its own Select.
1288        let outer = parse_select(
1289            "SELECT ?s WHERE { { SELECT ?s WHERE { ?s <http://ex/p> ?o } LIMIT 10 } } LIMIT 3",
1290        )
1291        .unwrap();
1292        assert_eq!(outer.limit, Some(3), "outer LIMIT was overwritten");
1293        assert!(
1294            matches!(outer.plan, Plan::Subquery(_)),
1295            "inner SELECT is not a subquery"
1296        );
1297        if let Plan::Subquery(inner) = &outer.plan {
1298            assert_eq!(
1299                inner.limit,
1300                Some(10),
1301                "inner LIMIT did not travel with the subquery"
1302            );
1303        }
1304
1305        // OFFSET rides along with LIMIT.
1306        let sliced = parse_select(
1307            "SELECT ?s WHERE { { SELECT ?s WHERE { ?s <http://ex/p> ?o } LIMIT 10 } } OFFSET 5 LIMIT 2",
1308        )
1309        .unwrap();
1310        assert_eq!((sliced.offset, sliced.limit), (5, Some(2)));
1311
1312        // A nested DISTINCT must not make the outer query DISTINCT.
1313        let distinct = parse_select(
1314            "SELECT ?s WHERE { { SELECT DISTINCT ?s WHERE { ?s <http://ex/p> ?o } } } LIMIT 3",
1315        )
1316        .unwrap();
1317        assert!(
1318            !distinct.distinct,
1319            "inner DISTINCT leaked to the outer query"
1320        );
1321        assert_eq!(distinct.limit, Some(3));
1322
1323        // A plain top-level slice still peels (no subquery in sight).
1324        let plain = parse_select("SELECT ?s WHERE { ?s <http://ex/p> ?o } LIMIT 3").unwrap();
1325        assert_eq!(plain.limit, Some(3));
1326        assert!(matches!(plain.plan, Plan::Bgp(_)));
1327    }
1328}