Skip to main content

qualia_core_db/sparql_library/sparql_grammar/
pattern.rs

1//! SPARQL group-graph-pattern parser — the WHERE-clause grammar.
2//!
3//! Parses `{ … }` groups into the `Pattern` arena the planner/executor already
4//! consume: basic triple patterns, `OPTIONAL { … }`, `{ … } UNION { … }`,
5//! `MINUS { … }`, nested groups, `<< s p o >>` quoted triples, and `FILTER(…)`
6//! (whose expression is parsed by `super::expr`).
7//!
8//! ## The arena-contiguity constraint
9//! `Pattern::Group { start_idx, len }` is planned by joining the *contiguous*
10//! range `[start_idx, start_idx+len)` of the pattern arena. So a group's direct
11//! children must be allocated as one uninterrupted batch. We therefore parse a
12//! group into a list of `ChildSpec`s — building any inner sub-patterns
13//! (OPTIONAL/UNION/MINUS inners) eagerly and referencing them by id — and only
14//! then allocate the direct-child nodes contiguously. Plain nested groups are
15//! flattened into the parent (group nesting is join-associative), which also
16//! sidesteps the contiguity problem for them.
17//!
18//! ## Engine semantics
19//! An `OPTIONAL` group child lowers to a real left-join and a `MINUS` child to a
20//! real anti-join (SPARQL 1.1: a left solution survives MINUS unless a right
21//! solution is compatible with it *and* shares a bound variable). Filter/BIND
22//! scoping over a group is simplified (both apply over the whole group's join
23//! result — see below), which is the remaining deliberate simplification here.
24
25use std::collections::HashMap;
26
27use crate::sparql_ast::{
28    Expression, ExpressionId, Pattern, PatternId, SparqlQuery, SparqlQueryContext, VariableId,
29};
30
31/// Render a token slice back to a SPARQL fragment. Used to hand a sub-`SELECT`'s
32/// tokens to the (string-based) SELECT parser. Whitespace-joined, which is safe
33/// for SPARQL. Datatype IRIs are re-bracketed; a prefixed datatype is left as
34/// `prefix:local` for the parser to expand against the same prefix map.
35fn render_tokens(tokens: &[Token]) -> String {
36    let mut parts: Vec<String> = Vec::with_capacity(tokens.len());
37    for t in tokens {
38        parts.push(match t {
39            Token::Var(v) => v.clone(),
40            Token::Iri(i) => format!("<{i}>"),
41            Token::Prefixed(p, l) => format!("{p}:{l}"),
42            Token::Str {
43                value,
44                lang,
45                datatype,
46            } => {
47                let mut s = format!("\"{value}\"");
48                if let Some(l) = lang {
49                    s.push('@');
50                    s.push_str(l);
51                } else if let Some(d) = datatype {
52                    if d.contains("://") {
53                        s.push_str(&format!("^^<{d}>"));
54                    } else {
55                        s.push_str(&format!("^^{d}"));
56                    }
57                }
58                s
59            }
60            Token::Num(n) => n.clone(),
61            Token::Bool(b) => (if *b { "true" } else { "false" }).to_string(),
62            Token::Word(w) => w.clone(),
63            Token::Punct(c) => c.to_string(),
64            Token::Op(o) => (*o).to_string(),
65            Token::StarOpen => "<<".to_string(),
66            Token::StarClose => ">>".to_string(),
67        });
68    }
69    parts.join(" ")
70}
71use crate::sparql_library::sparql_grammar::expr::parse_expression;
72use crate::sparql_library::sparql_grammar::tokenizer::{tokenize, Token};
73
74/// Parse a WHERE group graph pattern from a fragment beginning at (or before)
75/// the opening `{`. Returns the root `PatternId`.
76pub fn parse_where_group(
77    input: &str,
78    ctx: &mut SparqlQueryContext,
79    prefixes: &HashMap<String, String>,
80) -> Result<PatternId, String> {
81    let tokens = tokenize(input)?;
82    let mut p = PatternParser {
83        tokens: &tokens,
84        pos: 0,
85        ctx,
86        prefixes,
87    };
88    // Skip a leading WHERE keyword if the caller included it.
89    if let Some(Token::Word(w)) = p.peek() {
90        if w.eq_ignore_ascii_case("WHERE") {
91            p.pos += 1;
92        }
93    }
94    p.parse_group()
95}
96
97/// Parse a `{ … }` group graph pattern directly from a token slice starting at
98/// `pos` (which must index the opening `{`). Returns the root `PatternId` and
99/// the position just past the closing `}`. Used by the FILTER-expression parser
100/// to parse an `EXISTS { … }` group embedded in a bracketed expression.
101pub(crate) fn parse_group_tokens(
102    tokens: &[Token],
103    pos: usize,
104    ctx: &mut SparqlQueryContext,
105    prefixes: &HashMap<String, String>,
106) -> Result<(PatternId, usize), String> {
107    let mut p = PatternParser {
108        tokens,
109        pos,
110        ctx,
111        prefixes,
112    };
113    let id = p.parse_group()?;
114    Ok((id, p.pos))
115}
116
117/// One direct child of a group, allocated as exactly one arena node in the
118/// contiguous batch at group close (inner sub-patterns already allocated).
119enum ChildSpec {
120    Triple {
121        s: u64,
122        p: u64,
123        o: u64,
124    },
125    StarTriple {
126        is: u64,
127        ip: u64,
128        io: u64,
129        op: u64,
130        oo: u64,
131    },
132    Optional(PatternId),
133    Union(PatternId, PatternId),
134    Minus(PatternId),
135    Service {
136        endpoint: u64,
137        inner: PatternId,
138    },
139    Graph {
140        graph_var_or_id: u64,
141        inner: PatternId,
142    },
143    SubSelect {
144        query_id: u16,
145    },
146}
147
148struct PatternParser<'a> {
149    tokens: &'a [Token],
150    pos: usize,
151    ctx: &'a mut SparqlQueryContext,
152    prefixes: &'a HashMap<String, String>,
153}
154
155impl<'a> PatternParser<'a> {
156    fn peek(&self) -> Option<&Token> {
157        self.tokens.get(self.pos)
158    }
159
160    fn eat_punct(&mut self, c: char) -> bool {
161        if matches!(self.peek(), Some(Token::Punct(p)) if *p == c) {
162            self.pos += 1;
163            true
164        } else {
165            false
166        }
167    }
168
169    fn expect_punct(&mut self, c: char) -> Result<(), String> {
170        if self.eat_punct(c) {
171            Ok(())
172        } else {
173            Err(format!("expected '{c}' at token {}", self.pos))
174        }
175    }
176
177    fn peek_word_ci(&self, kw: &str) -> bool {
178        matches!(self.peek(), Some(Token::Word(w)) if w.eq_ignore_ascii_case(kw))
179    }
180
181    /// Parse `{ … }` and return a single `PatternId`.
182    fn parse_group(&mut self) -> Result<PatternId, String> {
183        self.expect_punct('{')?;
184        let (specs, filters, binds) = self.parse_group_body()?;
185        let mut root = self.materialize(specs)?;
186        // BINDs wrap the group first (in parse order — innermost = first), so a
187        // later BIND and any FILTER can see an earlier BIND's variable. Scoping
188        // is simplified the same way FILTER is (see module doc): both apply over
189        // the whole group's join result.
190        for (expr_id, var) in binds {
191            root = self.ctx.alloc_pattern(Pattern::Bind {
192                pattern: root,
193                var,
194                expression: expr_id,
195            })?;
196        }
197        for expr_id in filters {
198            root = self.ctx.alloc_pattern(Pattern::Filter {
199                pattern: root,
200                expression: expr_id,
201            })?;
202        }
203        Ok(root)
204    }
205
206    /// Parse the body of a group up to (and consuming) the closing `}`.
207    /// Returns the direct-child specs and any FILTER expression ids (hoisted to
208    /// the group). Plain nested groups are flattened in.
209    #[allow(clippy::type_complexity)]
210    fn parse_group_body(
211        &mut self,
212    ) -> Result<
213        (
214            Vec<ChildSpec>,
215            Vec<ExpressionId>,
216            Vec<(ExpressionId, VariableId)>,
217        ),
218        String,
219    > {
220        let mut specs: Vec<ChildSpec> = Vec::new();
221        let mut filters: Vec<ExpressionId> = Vec::new();
222        let mut binds: Vec<(ExpressionId, VariableId)> = Vec::new();
223
224        loop {
225            match self.peek() {
226                None => return Err("unterminated group (missing '}')".to_string()),
227                Some(Token::Punct('}')) => {
228                    self.pos += 1;
229                    break;
230                }
231                Some(Token::Punct('.')) => {
232                    self.pos += 1; // triple separator between items
233                }
234                Some(Token::Word(w)) if w.eq_ignore_ascii_case("OPTIONAL") => {
235                    self.pos += 1;
236                    let inner = self.parse_group()?;
237                    specs.push(ChildSpec::Optional(inner));
238                }
239                Some(Token::Word(w)) if w.eq_ignore_ascii_case("MINUS") => {
240                    self.pos += 1;
241                    let inner = self.parse_group()?;
242                    specs.push(ChildSpec::Minus(inner));
243                }
244                Some(Token::Word(w)) if w.eq_ignore_ascii_case("SERVICE") => {
245                    self.pos += 1;
246                    // Optional SILENT keyword.
247                    if self.peek_word_ci("SILENT") {
248                        self.pos += 1;
249                    }
250                    let endpoint = self.service_endpoint()?;
251                    let inner = self.parse_group()?;
252                    specs.push(ChildSpec::Service { endpoint, inner });
253                }
254                Some(Token::Word(w)) if w.eq_ignore_ascii_case("GRAPH") => {
255                    self.pos += 1;
256                    // `GRAPH <iri> { … }` (a named graph) or `GRAPH ?g { … }`
257                    // (bind ?g to each matching named graph). `term()` reads the
258                    // graph term the same way as any triple term — a variable is
259                    // registered and returned as its id, an IRI as its hash.
260                    let graph_var_or_id = self.term()?;
261                    let inner = self.parse_group()?;
262                    specs.push(ChildSpec::Graph {
263                        graph_var_or_id,
264                        inner,
265                    });
266                }
267                Some(Token::Word(w)) if w.eq_ignore_ascii_case("FILTER") => {
268                    self.pos += 1;
269                    let expr_id = self.parse_filter_expr()?;
270                    filters.push(expr_id);
271                }
272                Some(Token::Word(w)) if w.eq_ignore_ascii_case("BIND") => {
273                    self.pos += 1;
274                    let (expr_id, var_id) = self.parse_bind()?;
275                    binds.push((expr_id, var_id));
276                }
277                Some(Token::Punct('{'))
278                    if matches!(self.tokens.get(self.pos + 1),
279                        Some(Token::Word(w)) if w.eq_ignore_ascii_case("SELECT")) =>
280                {
281                    // `{ SELECT … }` sub-select.
282                    let query_id = self.parse_sub_select()?;
283                    specs.push(ChildSpec::SubSelect { query_id });
284                }
285                Some(Token::Punct('{')) => {
286                    // A nested group: either the left side of `{ } UNION { }`, or
287                    // a plain nested group (flattened into this one).
288                    let left = self.parse_group()?;
289                    if self.peek_word_ci("UNION") {
290                        self.pos += 1;
291                        let right = self.parse_group()?;
292                        specs.push(ChildSpec::Union(left, right));
293                    } else {
294                        // Plain nested group → flatten its children into this
295                        // group (group nesting is join-associative). Any inner
296                        // FILTER is hoisted to this group's filter list — filter
297                        // scoping is thereby simplified (see module doc).
298                        self.flatten_group_into(left, &mut specs, &mut filters, &mut binds);
299                    }
300                }
301                Some(Token::StarOpen) => {
302                    let spec = self.parse_star_triple()?;
303                    specs.push(spec);
304                }
305                _ => {
306                    let spec = self.parse_triple()?;
307                    specs.push(spec);
308                }
309            }
310        }
311        Ok((specs, filters, binds))
312    }
313
314    /// Allocate the direct-child specs as a contiguous arena batch and return a
315    /// single root: the child itself if there is one, else a `Group`.
316    fn materialize(&mut self, specs: Vec<ChildSpec>) -> Result<PatternId, String> {
317        if specs.is_empty() {
318            return Err("empty group graph pattern".to_string());
319        }
320        let start = self.ctx.pattern_count as u16;
321        let len = specs.len() as u16;
322        for spec in specs {
323            match spec {
324                ChildSpec::Triple { s, p, o } => {
325                    self.ctx.alloc_pattern(Pattern::Triple {
326                        subject: s,
327                        predicate: p,
328                        object: o,
329                    })?;
330                }
331                ChildSpec::StarTriple { is, ip, io, op, oo } => {
332                    self.ctx.alloc_pattern(Pattern::StarTriple {
333                        inner_subject: is,
334                        inner_predicate: ip,
335                        inner_object: io,
336                        outer_predicate: op,
337                        outer_object: oo,
338                    })?;
339                }
340                ChildSpec::Optional(inner) => {
341                    self.ctx.alloc_pattern(Pattern::Optional { inner })?;
342                }
343                ChildSpec::Union(left, right) => {
344                    self.ctx.alloc_pattern(Pattern::Union { left, right })?;
345                }
346                ChildSpec::Minus(inner) => {
347                    self.ctx.alloc_pattern(Pattern::Minus { inner })?;
348                }
349                ChildSpec::Service { endpoint, inner } => {
350                    self.ctx.alloc_pattern(Pattern::Service {
351                        endpoint_did_id: endpoint,
352                        inner_pattern: inner,
353                    })?;
354                }
355                ChildSpec::Graph {
356                    graph_var_or_id,
357                    inner,
358                } => {
359                    self.ctx.alloc_pattern(Pattern::Graph {
360                        graph_var_or_id,
361                        inner,
362                    })?;
363                }
364                ChildSpec::SubSelect { query_id } => {
365                    self.ctx.alloc_pattern(Pattern::SubSelect { query_id })?;
366                }
367            }
368        }
369        if len == 1 {
370            Ok(start)
371        } else {
372            self.ctx.alloc_pattern(Pattern::Group {
373                start_idx: start,
374                len,
375            })
376        }
377    }
378
379    /// Parse a `FILTER` expression: either `( expr )` or a bare `WORD(args)`
380    /// builtin. Collects the balanced-paren token span and parses it.
381    fn parse_filter_expr(&mut self) -> Result<ExpressionId, String> {
382        // `FILTER EXISTS { … }` / `FILTER NOT EXISTS { … }` — an unparenthesised
383        // built-in constraint (SPARQL 1.1 [69] Constraint → BuiltInCall).
384        let not_exists = self.peek_word_ci("NOT")
385            && matches!(self.tokens.get(self.pos + 1),
386                Some(Token::Word(w)) if w.eq_ignore_ascii_case("EXISTS"));
387        if self.peek_word_ci("EXISTS") || not_exists {
388            return self.parse_exists_constraint();
389        }
390
391        // Collect the token span of a parenthesised expression.
392        if !matches!(self.peek(), Some(Token::Punct('('))) {
393            return Err("expected '(' after FILTER".to_string());
394        }
395        let start = self.pos + 1;
396        let mut depth = 0i32;
397        let mut i = self.pos;
398        while i < self.tokens.len() {
399            match &self.tokens[i] {
400                Token::Punct('(') => depth += 1,
401                Token::Punct(')') => {
402                    depth -= 1;
403                    if depth == 0 {
404                        break;
405                    }
406                }
407                _ => {}
408            }
409            i += 1;
410        }
411        if depth != 0 {
412            return Err("unbalanced parentheses in FILTER".to_string());
413        }
414        let expr_tokens = &self.tokens[start..i];
415        self.pos = i + 1; // past ')'
416        parse_expression(expr_tokens, self.ctx, self.prefixes)
417    }
418
419    /// Parse `EXISTS { … }` or `NOT EXISTS { … }` (positioned at `NOT`/`EXISTS`)
420    /// into an `Expression::Exists` over the inner group graph pattern.
421    fn parse_exists_constraint(&mut self) -> Result<ExpressionId, String> {
422        let mut negated = false;
423        if self.peek_word_ci("NOT") {
424            self.pos += 1;
425            negated = true;
426        }
427        if !self.peek_word_ci("EXISTS") {
428            return Err("expected EXISTS".to_string());
429        }
430        self.pos += 1;
431        let pattern = self.parse_group()?;
432        self.ctx
433            .alloc_expression(Expression::Exists { pattern, negated })
434    }
435
436    /// Index of the `}` token that closes the `{` at `open` (balanced).
437    fn matching_brace(&self, open: usize) -> Result<usize, String> {
438        let mut depth = 0i32;
439        for (i, t) in self.tokens.iter().enumerate().skip(open) {
440            match t {
441                Token::Punct('{') => depth += 1,
442                Token::Punct('}') => {
443                    depth -= 1;
444                    if depth == 0 {
445                        return Ok(i);
446                    }
447                }
448                _ => {}
449            }
450        }
451        Err("unbalanced braces in sub-SELECT".to_string())
452    }
453
454    /// Parse `{ SELECT … }` (positioned at the opening `{`) into a stored
455    /// subquery, returning its id. The inner tokens are rendered back to a
456    /// SPARQL string and handed to the full SELECT parser (which shares this
457    /// `ctx`, so the sub-select's variables interned by name line up with the
458    /// enclosing scope), giving sub-selects the same feature set as a top-level
459    /// query (projection, DISTINCT, GROUP BY, ORDER BY, LIMIT).
460    fn parse_sub_select(&mut self) -> Result<u16, String> {
461        let open = self.pos;
462        let close = self.matching_brace(open)?;
463        let query_str = render_tokens(&self.tokens[open + 1..close]);
464        self.pos = close + 1; // consume through the closing '}'
465        let select = crate::sparql_library::sparql_parser::parse_select_query(
466            &query_str,
467            self.ctx,
468            self.prefixes,
469        )?;
470        self.ctx.alloc_subquery(SparqlQuery::Select(select))
471    }
472
473    /// Parse `BIND ( expr AS ?var )` → (expression id, target variable id).
474    ///
475    /// The value-producing case — numeric / boolean / term / already-interned
476    /// string results (`?a + ?b`, `STRLEN(?x)`, `IF(...)`, `?x`) — binds a real
477    /// `u64`. A string-*producing* expression (`CONCAT`/`SUBSTR`/…) evaluates to
478    /// an error at runtime because the zero-heap arena has no channel to intern
479    /// a new string; per SPARQL, that error leaves the variable unbound rather
480    /// than failing the query.
481    fn parse_bind(&mut self) -> Result<(ExpressionId, VariableId), String> {
482        if !matches!(self.peek(), Some(Token::Punct('('))) {
483            return Err("expected '(' after BIND".to_string());
484        }
485        // Collect the balanced-paren span (same approach as parse_filter_expr).
486        let start = self.pos + 1;
487        let mut depth = 0i32;
488        let mut i = self.pos;
489        while i < self.tokens.len() {
490            match &self.tokens[i] {
491                Token::Punct('(') => depth += 1,
492                Token::Punct(')') => {
493                    depth -= 1;
494                    if depth == 0 {
495                        break;
496                    }
497                }
498                _ => {}
499            }
500            i += 1;
501        }
502        if depth != 0 {
503            return Err("unbalanced parentheses in BIND".to_string());
504        }
505        let span = &self.tokens[start..i];
506        self.pos = i + 1; // past ')'
507
508        // Split on the top-level `AS` keyword: `expr AS ?var`.
509        let as_pos = span
510            .iter()
511            .position(|t| matches!(t, Token::Word(w) if w.eq_ignore_ascii_case("AS")))
512            .ok_or("BIND requires the form `BIND(expr AS ?var)`")?;
513        let expr_tokens = &span[..as_pos];
514        let var_tokens = &span[as_pos + 1..];
515        if expr_tokens.is_empty() {
516            return Err("BIND has no expression before AS".to_string());
517        }
518        let var_name = match var_tokens {
519            [Token::Var(name)] => name.clone(),
520            _ => return Err("BIND target must be a single ?variable".to_string()),
521        };
522        let expr_id = parse_expression(expr_tokens, self.ctx, self.prefixes)?;
523        let var_id = self.ctx.register_variable(&var_name)?;
524        Ok((expr_id, var_id))
525    }
526
527    fn parse_triple(&mut self) -> Result<ChildSpec, String> {
528        let s = self.term()?;
529        let p = self.term()?;
530        // Consume a trailing property-path quantifier (`+` / `*`) on the
531        // predicate. Full property-path semantics (Pattern::PropertyPath) are a
532        // later slice; for now the base predicate is used, matching the legacy
533        // parser's behaviour (which hashed the whole token including the `+`).
534        while matches!(self.peek(), Some(Token::Op(o)) if *o == "+" || *o == "*") {
535            self.pos += 1;
536        }
537        let o = self.term()?;
538        Ok(ChildSpec::Triple { s, p, o })
539    }
540
541    /// Resolve a SERVICE endpoint. Local endpoints (`local:` / `qualia:` / a
542    /// `did:` IRI) get the executor's `0x8` DID prefix so its `Service` operator
543    /// runs the inner pattern against the local graph. A remote `http(s)`
544    /// endpoint is rejected with an honest error — real network egress is a
545    /// governance decision and is intentionally not wired (see the plan). A
546    /// variable endpoint (dynamic SERVICE) is likewise deferred.
547    fn service_endpoint(&mut self) -> Result<u64, String> {
548        const DID_PREFIX: u64 = 0x8000_0000_0000_0000;
549        let tok = self
550            .tokens
551            .get(self.pos)
552            .ok_or_else(|| "expected SERVICE endpoint".to_string())?
553            .clone();
554        self.pos += 1;
555        let iri = match tok {
556            Token::Iri(iri) => iri,
557            Token::Prefixed(prefix, local) => match self.prefixes.get(&prefix) {
558                Some(base) => format!("{base}{local}"),
559                None => format!("{prefix}:{local}"),
560            },
561            Token::Var(_) => {
562                return Err("dynamic SERVICE endpoint (a variable) is not supported yet".to_string())
563            }
564            other => return Err(format!("invalid SERVICE endpoint token: {other:?}")),
565        };
566        let lower = iri.to_ascii_lowercase();
567        if lower.starts_with("local:") || lower.starts_with("qualia:") || lower.starts_with("did:")
568        {
569            Ok(crate::lexicon::generate_60bit_token(iri.as_bytes()) | DID_PREFIX)
570        } else if lower.starts_with("http://") || lower.starts_with("https://") {
571            Err(format!(
572                "remote SERVICE endpoint <{iri}> is not supported — network egress is \
573                 governance-gated; use a local: or qualia: endpoint"
574            ))
575        } else {
576            // Unknown scheme: treat as a local/opaque endpoint (local execution).
577            Ok(crate::lexicon::generate_60bit_token(iri.as_bytes()) | DID_PREFIX)
578        }
579    }
580
581    fn parse_star_triple(&mut self) -> Result<ChildSpec, String> {
582        // consume `<<`
583        self.pos += 1;
584        let is = self.term()?;
585        let ip = self.term()?;
586        let io = self.term()?;
587        if !matches!(self.peek(), Some(Token::StarClose)) {
588            return Err("expected '>>' in quoted triple pattern".to_string());
589        }
590        self.pos += 1;
591        let op = self.term()?;
592        let oo = self.term()?;
593        Ok(ChildSpec::StarTriple { is, ip, io, op, oo })
594    }
595
596    /// Flatten an already-built plain nested group `id` into the parent's spec
597    /// list. A multi-child `Group` contributes each of its children; a `Filter`
598    /// hoists its expression and recurses into its inner pattern; any other
599    /// single node contributes one spec.
600    fn flatten_group_into(
601        &mut self,
602        id: PatternId,
603        specs: &mut Vec<ChildSpec>,
604        filters: &mut Vec<ExpressionId>,
605        binds: &mut Vec<(ExpressionId, VariableId)>,
606    ) {
607        let pat = self.ctx.patterns[id as usize];
608        match pat {
609            Pattern::Group { start_idx, len } => {
610                for i in start_idx..(start_idx + len) {
611                    self.flatten_group_into(i, specs, filters, binds);
612                }
613            }
614            Pattern::Filter {
615                pattern,
616                expression,
617            } => {
618                filters.push(expression);
619                self.flatten_group_into(pattern, specs, filters, binds);
620            }
621            Pattern::Bind {
622                pattern,
623                var,
624                expression,
625            } => {
626                binds.push((expression, var));
627                self.flatten_group_into(pattern, specs, filters, binds);
628            }
629            Pattern::Triple {
630                subject,
631                predicate,
632                object,
633            } => specs.push(ChildSpec::Triple {
634                s: subject,
635                p: predicate,
636                o: object,
637            }),
638            Pattern::StarTriple {
639                inner_subject,
640                inner_predicate,
641                inner_object,
642                outer_predicate,
643                outer_object,
644            } => specs.push(ChildSpec::StarTriple {
645                is: inner_subject,
646                ip: inner_predicate,
647                io: inner_object,
648                op: outer_predicate,
649                oo: outer_object,
650            }),
651            Pattern::Union { left, right } => specs.push(ChildSpec::Union(left, right)),
652            Pattern::Optional { inner } => specs.push(ChildSpec::Optional(inner)),
653            Pattern::Minus { inner } => specs.push(ChildSpec::Minus(inner)),
654            Pattern::Graph {
655                graph_var_or_id,
656                inner,
657            } => specs.push(ChildSpec::Graph {
658                graph_var_or_id,
659                inner,
660            }),
661            Pattern::SubSelect { query_id } => specs.push(ChildSpec::SubSelect { query_id }),
662            // Anything else (Service/PropertyPath/AsOf) is already a single
663            // built node; carry it as an Optional inner, which the current
664            // join-only planner treats as a plain join (see module doc).
665            _ => specs.push(ChildSpec::Optional(id)),
666        }
667    }
668
669    /// Resolve one term token to a `u64`, matching `parse_term`'s convention
670    /// (variables → their id, IRIs/literals → 60-bit token, `a` → rdf:type).
671    fn term(&mut self) -> Result<u64, String> {
672        let tok = self
673            .tokens
674            .get(self.pos)
675            .ok_or_else(|| "unexpected end of pattern (expected a term)".to_string())?
676            .clone();
677        self.pos += 1;
678        match tok {
679            Token::Var(name) => Ok(self.ctx.register_variable(&name)? as u64),
680            Token::Iri(iri) => Ok(crate::lexicon::generate_60bit_token(iri.as_bytes())),
681            Token::Prefixed(prefix, local) => {
682                let expanded = match self.prefixes.get(&prefix) {
683                    Some(base) => format!("{base}{local}"),
684                    None => format!("{prefix}:{local}"),
685                };
686                Ok(crate::lexicon::generate_60bit_token(expanded.as_bytes()))
687            }
688            Token::Str { value, .. } => Ok(crate::lexicon::generate_60bit_token(value.as_bytes())),
689            Token::Num(text) => Ok(text
690                .parse::<u64>()
691                .unwrap_or_else(|_| crate::lexicon::generate_60bit_token(text.as_bytes()))),
692            Token::Bool(b) => Ok(crate::lexicon::generate_60bit_token(if b {
693                b"true"
694            } else {
695                b"false"
696            })),
697            Token::Word(w) if w == "a" => Ok(crate::lexicon::generate_60bit_token(
698                b"http://www.w3.org/1999/02/22-rdf-syntax-ns#type",
699            )),
700            // Any other bareword is treated as an IRI token, matching the legacy
701            // `parse_term`'s permissive fallthrough (e.g. `?s knows ?o`).
702            Token::Word(w) => Ok(crate::lexicon::generate_60bit_token(w.as_bytes())),
703            other => Err(format!("invalid term token: {other:?}")),
704        }
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use crate::sparql_ast::SparqlQueryContext;
712
713    fn root_pattern(input: &str) -> (SparqlQueryContext, Pattern) {
714        let mut ctx = SparqlQueryContext::new();
715        let id = parse_where_group(input, &mut ctx, &HashMap::new()).unwrap();
716        let pat = ctx.patterns[id as usize];
717        (ctx, pat)
718    }
719
720    #[test]
721    fn parses_optional() {
722        let (_ctx, pat) = root_pattern("{ ?s ?p ?o . OPTIONAL { ?s ?x ?y } }");
723        // Root is a Group of [Triple, Optional].
724        assert!(matches!(pat, Pattern::Group { len: 2, .. }));
725    }
726
727    #[test]
728    fn parses_union() {
729        let (ctx, pat) = root_pattern("{ { ?s ?p ?o } UNION { ?a ?b ?c } }");
730        // A single UNION child → the group collapses to the Union node.
731        assert!(matches!(pat, Pattern::Union { .. }), "got {pat:?}");
732        let _ = ctx;
733    }
734
735    #[test]
736    fn parses_minus() {
737        let (_ctx, pat) = root_pattern("{ ?s ?p ?o . MINUS { ?s ?x ?y } }");
738        assert!(matches!(pat, Pattern::Group { len: 2, .. }));
739    }
740
741    #[test]
742    fn parses_filter_in_group() {
743        let (_ctx, pat) = root_pattern("{ ?s ?p ?o . FILTER(?o >= 18) }");
744        assert!(matches!(pat, Pattern::Filter { .. }));
745    }
746
747    #[test]
748    fn parses_plain_bgp() {
749        let (_ctx, pat) = root_pattern("{ ?s ?p ?o . ?a ?b ?c }");
750        assert!(matches!(pat, Pattern::Group { len: 2, .. }));
751    }
752
753    #[test]
754    fn parses_local_service() {
755        let (_ctx, pat) = root_pattern("{ SERVICE <local:health> { ?s ?p ?o } }");
756        assert!(matches!(pat, Pattern::Service { .. }), "got {pat:?}");
757    }
758
759    #[test]
760    fn remote_service_is_rejected() {
761        let mut ctx = SparqlQueryContext::new();
762        let err = parse_where_group(
763            "{ SERVICE <https://dbpedia.org/sparql> { ?s ?p ?o } }",
764            &mut ctx,
765            &HashMap::new(),
766        )
767        .unwrap_err();
768        assert!(err.contains("egress"), "got {err}");
769    }
770
771    #[test]
772    fn parses_bind_into_bind_node() {
773        let (ctx, pat) = root_pattern("{ ?s ?p ?o . BIND(?o AS ?x) }");
774        // BIND wraps the group in a Pattern::Bind whose target var is registered.
775        match pat {
776            Pattern::Bind { var, .. } => {
777                // ?x is the last variable registered (?s ?p ?o then ?x).
778                let x = ctx
779                    .variable_hashes
780                    .iter()
781                    .position(|h| *h == crate::lexicon::generate_60bit_token(b"?x"))
782                    .unwrap();
783                assert_eq!(var as usize, x);
784            }
785            other => panic!("expected Pattern::Bind, got {other:?}"),
786        }
787    }
788
789    #[test]
790    fn bind_requires_as_var_form() {
791        let mut ctx = SparqlQueryContext::new();
792        let err =
793            parse_where_group("{ ?s ?p ?o . BIND(?o) }", &mut ctx, &HashMap::new()).unwrap_err();
794        assert!(err.contains("AS"), "got {err}");
795    }
796}