Skip to main content

lora_analyzer/analyzer/
state.rs

1use crate::{errors::*, resolved::*, scope::*, symbols::*};
2use lora_ast::{
3    Document, Expr, Query, QueryPart, ReadingClause, SinglePartQuery, SingleQuery, Statement,
4    UpdatingClause,
5};
6use lora_store::GraphCatalog;
7use std::collections::{BTreeMap, BTreeSet};
8
9pub struct Analyzer<'a, S: GraphCatalog + ?Sized> {
10    /// Analysis deliberately does not read the stored data: whether a
11    /// query is valid must not depend on which labels, types or keys
12    /// happen to exist right now. The catalog type stays in the signature
13    /// so callers keep constructing the analyzer against their store.
14    pub(super) _catalog: std::marker::PhantomData<&'a S>,
15    pub(super) scopes: ScopeStack,
16    pub(super) symbols: SymbolTable,
17    /// Variables whose runtime value shape isn't tracked by the analyzer
18    /// (UNWIND-bound elements, anything that may legitimately hold a map
19    /// or list of maps). Property access on these vars must skip the
20    /// graph-catalog check — the caller can't know which keys the data
21    /// carries until rows are bound at execution time.
22    pub(super) dynamic_property_vars: BTreeSet<VarId>,
23    /// Every `$name` resolved so far (see [`ResolvedQuery::parameters`]).
24    pub(super) parameters: BTreeSet<String>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub(super) enum PatternContext {
29    Read,
30    /// OPTIONAL MATCH — tolerate unknown labels/types (they just won't match).
31    OptionalRead,
32    Write,
33}
34
35impl<'a, S: GraphCatalog + ?Sized> Analyzer<'a, S> {
36    pub fn new(_storage: &'a S) -> Self {
37        Self {
38            _catalog: std::marker::PhantomData,
39            scopes: ScopeStack::new(),
40            symbols: SymbolTable::default(),
41            dynamic_property_vars: BTreeSet::new(),
42            parameters: BTreeSet::new(),
43        }
44    }
45
46    pub fn analyze(&mut self, doc: &Document) -> Result<ResolvedQuery, SemanticError> {
47        match &doc.statement {
48            Statement::Query(q) => self.analyze_query(q),
49            Statement::Schema(_) => Err(SemanticError::UnsupportedFeature(
50                "schema commands are dispatched outside the analyzer".to_string(),
51            )),
52        }
53    }
54
55    fn analyze_query(&mut self, query: &Query) -> Result<ResolvedQuery, SemanticError> {
56        let mut clauses = Vec::new();
57        let mut unions = Vec::new();
58
59        match query {
60            Query::Regular(r) => {
61                clauses.extend(self.analyze_single_query(&r.head)?);
62
63                for union_part in &r.unions {
64                    // Each UNION branch gets a fresh scope — variables from one
65                    // branch must not leak into another.
66                    self.scopes.clear();
67
68                    let branch_clauses = self.analyze_single_query(&union_part.query)?;
69                    unions.push(ResolvedUnionPart {
70                        all: union_part.all,
71                        clauses: branch_clauses,
72                    });
73                }
74
75                // Validate UNION column compatibility: all branches must
76                // have the same number of columns. Column names are taken
77                // from the first branch (standard Lora semantics).
78                if !unions.is_empty() {
79                    let head_cols = return_column_info(&clauses);
80                    for branch in &unions {
81                        let branch_cols = return_column_info(&branch.clauses);
82                        if let (Some(hc), Some(bc)) = (&head_cols, &branch_cols) {
83                            if hc.len() != bc.len() {
84                                return Err(SemanticError::UnionColumnCountMismatch(
85                                    hc.len(),
86                                    bc.len(),
87                                ));
88                            }
89                            // Validate column names when at least one side
90                            // uses an explicit AS alias.
91                            for ((h_name, h_explicit), (b_name, b_explicit)) in
92                                hc.iter().zip(bc.iter())
93                            {
94                                if (*h_explicit || *b_explicit) && h_name != b_name {
95                                    return Err(SemanticError::UnionColumnNameMismatch(
96                                        h_name.clone(),
97                                        b_name.clone(),
98                                    ));
99                                }
100                            }
101                        }
102                    }
103                }
104            }
105            Query::StandaloneCall(_) => {
106                return Err(SemanticError::UnsupportedFeature(
107                    "Standalone CALL is not yet supported by the analyzer".into(),
108                ));
109            }
110        }
111
112        Ok(ResolvedQuery {
113            clauses,
114            unions,
115            parameters: std::mem::take(&mut self.parameters),
116        })
117    }
118
119    fn analyze_single_query(
120        &mut self,
121        q: &SingleQuery,
122    ) -> Result<Vec<ResolvedClause>, SemanticError> {
123        match q {
124            SingleQuery::SinglePart(sp) => self.analyze_single_part(sp),
125            SingleQuery::MultiPart(mp) => {
126                let mut clauses = Vec::new();
127
128                for part in &mp.parts {
129                    clauses.extend(self.analyze_query_part(part)?);
130                }
131
132                clauses.extend(self.analyze_single_part(&mp.tail)?);
133                Ok(clauses)
134            }
135        }
136    }
137
138    fn analyze_query_part(
139        &mut self,
140        part: &QueryPart,
141    ) -> Result<Vec<ResolvedClause>, SemanticError> {
142        let mut clauses = Vec::new();
143
144        for rc in &part.reading_clauses {
145            clauses.extend(self.analyze_reading_clause(rc)?);
146        }
147
148        for uc in &part.updating_clauses {
149            clauses.push(self.analyze_updating_clause(uc)?);
150        }
151
152        clauses.push(ResolvedClause::With(self.analyze_with(&part.with_clause)?));
153        Ok(clauses)
154    }
155
156    fn analyze_single_part(
157        &mut self,
158        q: &SinglePartQuery,
159    ) -> Result<Vec<ResolvedClause>, SemanticError> {
160        let mut clauses = Vec::new();
161
162        for rc in &q.reading_clauses {
163            clauses.extend(self.analyze_reading_clause(rc)?);
164        }
165
166        for uc in &q.updating_clauses {
167            clauses.push(self.analyze_updating_clause(uc)?);
168        }
169
170        if let Some(ret) = &q.return_clause {
171            clauses.push(ResolvedClause::Return(self.analyze_return(ret)?));
172        } else if q.updating_clauses.is_empty() {
173            // The parser only lets a query end in a reading clause when it
174            // is a `CALL { ... }`; it must be a unit subquery.
175            match clauses.last() {
176                Some(ResolvedClause::CallSubquery(call)) if call.return_vars.is_empty() => {}
177                _ => {
178                    return Err(SemanticError::UnsupportedFeature(
179                        "a query can only end in CALL { ... } when the subquery ends in an \
180                         updating clause; add a RETURN"
181                            .into(),
182                    ))
183                }
184            }
185        }
186
187        Ok(clauses)
188    }
189
190    fn analyze_reading_clause(
191        &mut self,
192        rc: &ReadingClause,
193    ) -> Result<Vec<ResolvedClause>, SemanticError> {
194        Ok(match rc {
195            ReadingClause::Match(m) => vec![ResolvedClause::Match(self.analyze_match(m)?)],
196            ReadingClause::Unwind(u) => vec![ResolvedClause::Unwind(self.analyze_unwind(u)?)],
197            ReadingClause::InQueryCall(c) => self.analyze_in_query_call(c)?,
198            ReadingClause::CallSubquery(c) => {
199                vec![ResolvedClause::CallSubquery(self.analyze_call_subquery(c)?)]
200            }
201        })
202    }
203
204    /// Analyze a CALL { ... } subquery. The inner body is analyzed
205    /// with the outer scope visible (so MATCH inside the CALL can
206    /// reference outer-bound variables). After analysis the outer
207    /// scope is restored and the inner final RETURN's projection
208    /// aliases are injected as new bindings.
209    fn analyze_call_subquery(
210        &mut self,
211        call: &lora_ast::CallSubquery,
212    ) -> Result<ResolvedCallSubquery, SemanticError> {
213        let outer = self.visible_bindings();
214
215        if !call.body.unions.is_empty() {
216            return Err(SemanticError::UnsupportedFeature(
217                "UNION inside CALL { ... } is not yet supported".into(),
218            ));
219        }
220
221        let inner_clauses = self.analyze_single_query(&call.body.head)?;
222
223        let return_items: Vec<(String, VarId)> = match inner_clauses.last() {
224            Some(ResolvedClause::Return(ret)) => ret
225                .items
226                .iter()
227                .map(|p| (p.name.to_string(), p.output))
228                .collect(),
229            // A unit subquery: its body ends in an update and returns
230            // nothing. It runs once per outer row for its side effects and
231            // leaves the outer rows (and scope) unchanged.
232            Some(
233                ResolvedClause::Create(_)
234                | ResolvedClause::Merge(_)
235                | ResolvedClause::Delete(_)
236                | ResolvedClause::Set(_)
237                | ResolvedClause::Remove(_)
238                | ResolvedClause::Foreach(_),
239            ) => Vec::new(),
240            Some(ResolvedClause::CallSubquery(inner)) if inner.return_vars.is_empty() => Vec::new(),
241            _ => {
242                return Err(SemanticError::UnsupportedFeature(
243                    "CALL { ... } subquery must end with RETURN or an updating clause".into(),
244                ));
245            }
246        };
247
248        let mut new_scope = outer;
249        for (name, id) in &return_items {
250            new_scope.insert(name.clone(), *id);
251        }
252        self.replace_scope(new_scope);
253
254        Ok(ResolvedCallSubquery {
255            clauses: inner_clauses,
256            return_vars: return_items.into_iter().map(|(_, id)| id).collect(),
257        })
258    }
259
260    fn analyze_updating_clause(
261        &mut self,
262        uc: &UpdatingClause,
263    ) -> Result<ResolvedClause, SemanticError> {
264        match uc {
265            UpdatingClause::Create(c) => Ok(ResolvedClause::Create(self.analyze_create(c)?)),
266            UpdatingClause::Merge(m) => Ok(ResolvedClause::Merge(self.analyze_merge(m)?)),
267            UpdatingClause::Delete(d) => Ok(ResolvedClause::Delete(self.analyze_delete(d)?)),
268            UpdatingClause::Set(s) => Ok(ResolvedClause::Set(self.analyze_set(s)?)),
269            UpdatingClause::Remove(r) => Ok(ResolvedClause::Remove(self.analyze_remove(r)?)),
270            UpdatingClause::Foreach(f) => Ok(ResolvedClause::Foreach(self.analyze_foreach(f)?)),
271        }
272    }
273
274    pub(super) fn analyze_property_map_expr(
275        &mut self,
276        expr: &Expr,
277    ) -> Result<ResolvedExpr, SemanticError> {
278        match expr {
279            Expr::Map(_, _) | Expr::Parameter(_, _) => self.analyze_expr(expr),
280            _ => Err(SemanticError::ExpectedPropertyMap(
281                expr.span().start,
282                expr.span().end,
283            )),
284        }
285    }
286
287    pub(super) fn resolve_required_variable(&self, name: &str) -> Result<VarId, SemanticError> {
288        self.scopes
289            .resolve(name)
290            .ok_or_else(|| SemanticError::UnknownVariable(name.to_string()))
291    }
292
293    pub(super) fn declare_fresh_variable(&mut self, name: &str) -> Result<VarId, SemanticError> {
294        if self.scopes.resolve(name).is_some() {
295            return Err(SemanticError::DuplicateVariable(name.to_string()));
296        }
297
298        let id = self.symbols.new_var();
299        self.scopes.declare(name.to_string(), id);
300        Ok(id)
301    }
302
303    pub(super) fn declare_or_reuse_variable(&mut self, name: &str) -> Result<VarId, SemanticError> {
304        if let Some(id) = self.scopes.resolve(name) {
305            Ok(id)
306        } else {
307            let id = self.symbols.new_var();
308            self.scopes.declare(name.to_string(), id);
309            Ok(id)
310        }
311    }
312
313    /// Label names are never rejected. Standard Cypher treats a label no
314    /// node carries as an empty match, not an error, and checking against
315    /// the stored data made the *kind* of answer depend on what happens to
316    /// exist right now: the same query errored after the last `:Comment`
317    /// was deleted, but not on an empty database. A pattern with an unknown
318    /// label simply matches nothing.
319    pub(super) fn validate_label_name(
320        &self,
321        _label: &str,
322        _context: PatternContext,
323    ) -> Result<(), SemanticError> {
324        Ok(())
325    }
326
327    /// Relationship types follow the same rule as labels: an unknown type
328    /// matches nothing.
329    pub(super) fn validate_relationship_type_name(
330        &self,
331        _rel_type: &str,
332        _context: PatternContext,
333    ) -> Result<(), SemanticError> {
334        Ok(())
335    }
336
337    /// Analyze an expression that is the target of a SET operation.
338    /// Property names on the left side of SET are always allowed (new property creation).
339    pub(super) fn analyze_expr_write_property(
340        &mut self,
341        expr: &Expr,
342    ) -> Result<ResolvedExpr, SemanticError> {
343        match expr {
344            Expr::Property {
345                expr: inner, key, ..
346            } => {
347                let inner_resolved = self.analyze_expr(inner)?;
348                Ok(ResolvedExpr::Property {
349                    expr: Box::new(inner_resolved),
350                    property: key.clone(),
351                })
352            }
353            // Fallback to normal analysis for non-property expressions
354            other => self.analyze_expr(other),
355        }
356    }
357
358    /// Property access is always allowed: reading a key no entity carries
359    /// yields `null` (standard Cypher), including a key written earlier in
360    /// the same statement. Checking the stored catalog made the query's
361    /// validity depend on the data.
362    pub(super) fn property_access_allowed(&self, _base: &ResolvedExpr, _key: &str) -> bool {
363        true
364    }
365
366    pub(super) fn visible_bindings(&self) -> BTreeMap<String, VarId> {
367        self.scopes.visible_bindings()
368    }
369
370    pub(super) fn replace_scope(&mut self, bindings: BTreeMap<String, VarId>) {
371        self.scopes.clear();
372        for (name, id) in bindings {
373            self.scopes.declare(name, id);
374        }
375    }
376}
377
378/// Extract column names and explicit-alias flags from the RETURN clause.
379fn return_column_info(clauses: &[ResolvedClause]) -> Option<Vec<(String, bool)>> {
380    for clause in clauses.iter().rev() {
381        if let ResolvedClause::Return(ret) = clause {
382            return Some(
383                ret.items
384                    .iter()
385                    .map(|p| (p.name.to_string(), p.explicit_alias))
386                    .collect(),
387            );
388        }
389    }
390    None
391}