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