Skip to main content

molframe_query/execution/
plan.rs

1//! Storage-independent logical plans and structure-bound physical plans.
2
3use crate::ast::{Column, Expr};
4use crate::glob::Glob;
5use molframe_core::contract::AnalysisPolicy;
6use molframe_core::diagnostic::{Code, Diagnostic};
7use molframe_core::structure::Structure;
8use molframe_core::symbol::SymbolId;
9use std::collections::BTreeSet;
10
11/// An immutable storage-independent selection plan.
12#[derive(Clone, Debug, PartialEq)]
13pub struct LogicalPlan(pub(crate) Expr);
14
15/// A selection plan bound to one structure dictionary and policy.
16///
17/// Glob patterns have become stable symbol sets and constant branches have
18/// been removed. Reuse this value for repeated frames sharing the topology.
19#[derive(Clone, Debug)]
20pub struct PhysicalQuery {
21    pub(crate) expr: PhysicalExpr,
22    pub(crate) warnings: Vec<Diagnostic>,
23}
24
25#[derive(Clone, Debug)]
26pub(crate) enum PhysicalExpr {
27    All,
28    None,
29    And(Box<Self>, Box<Self>),
30    Or(Box<Self>, Box<Self>),
31    Not(Box<Self>),
32    ResolvedMembership {
33        column: Column,
34        symbols: BTreeSet<SymbolId>,
35    },
36    Logical(Expr),
37}
38
39/// Binds an expression directly without first cloning it into `LogicalPlan`.
40///
41/// This path allows `Query::plan` to eliminate one complete AST clone while
42/// retaining exactly the same physical-plan representation.
43pub(crate) fn bind_expr(
44    expr: &Expr,
45    structure: &Structure,
46    policy: &AnalysisPolicy,
47    mut warnings: Vec<Diagnostic>,
48) -> PhysicalQuery {
49    let expr = lower(expr, structure, policy, &mut warnings);
50
51    warnings.sort_by_key(Diagnostic::code);
52
53    PhysicalQuery { expr, warnings }
54}
55
56/// Lowers one logical expression into a structure-bound physical expression.
57///
58/// Constant branches are folded and supported symbol memberships are resolved
59/// against the structure dictionary exactly once.
60fn lower(
61    expr: &Expr,
62    structure: &Structure,
63    policy: &AnalysisPolicy,
64    warnings: &mut Vec<Diagnostic>,
65) -> PhysicalExpr {
66    match expr {
67        Expr::All => PhysicalExpr::All,
68        Expr::None => PhysicalExpr::None,
69        Expr::And(left, right) => conjunction(
70            lower(left, structure, policy, warnings),
71            lower(right, structure, policy, warnings),
72        ),
73        Expr::Or(left, right) => disjunction(
74            lower(left, structure, policy, warnings),
75            lower(right, structure, policy, warnings),
76        ),
77        Expr::Not(target) => match lower(target, structure, policy, warnings) {
78            PhysicalExpr::All => PhysicalExpr::None,
79            PhysicalExpr::None => PhysicalExpr::All,
80            target => PhysicalExpr::Not(Box::new(target)),
81        },
82        Expr::Membership { column, values }
83            if symbol_column(*column, policy)
84                && !(matches!(column, Column::AlternateLocation)
85                    && values
86                        .iter()
87                        .any(|value| value.eq_ignore_ascii_case("none"))) =>
88        {
89            let column = crate::predicate::resolved_column(*column, policy);
90            let symbols = resolve_symbols(structure, values);
91
92            if symbols.is_empty() {
93                warnings.push(Diagnostic::new(Code::W4003));
94                PhysicalExpr::None
95            } else {
96                PhysicalExpr::ResolvedMembership { column, symbols }
97            }
98        }
99        other => PhysicalExpr::Logical(other.clone()),
100    }
101}
102
103/// Folds and cost-orders one binary conjunction.
104///
105/// Existing ordering semantics are retained: operands are exchanged only when
106/// the right-hand side has strictly lower estimated cost.
107fn conjunction(left: PhysicalExpr, right: PhysicalExpr) -> PhysicalExpr {
108    match (left, right) {
109        (PhysicalExpr::None, _) | (_, PhysicalExpr::None) => PhysicalExpr::None,
110        (PhysicalExpr::All, right) => right,
111        (left, PhysicalExpr::All) => left,
112        (left, right) if cost(&right) < cost(&left) => {
113            PhysicalExpr::And(Box::new(right), Box::new(left))
114        }
115        (left, right) => PhysicalExpr::And(Box::new(left), Box::new(right)),
116    }
117}
118
119/// Constant-folds one binary disjunction.
120fn disjunction(left: PhysicalExpr, right: PhysicalExpr) -> PhysicalExpr {
121    match (left, right) {
122        (PhysicalExpr::All, _) | (_, PhysicalExpr::All) => PhysicalExpr::All,
123        (PhysicalExpr::None, right) => right,
124        (left, PhysicalExpr::None) => left,
125        (left, right) => PhysicalExpr::Or(Box::new(left), Box::new(right)),
126    }
127}
128
129/// Returns a small relative execution-cost class for plan ordering.
130///
131/// Runtime and space are `O(1)`.
132#[inline]
133fn cost(expr: &PhysicalExpr) -> u8 {
134    match expr {
135        PhysicalExpr::None | PhysicalExpr::All => 0,
136        PhysicalExpr::ResolvedMembership { .. } => 1,
137        PhysicalExpr::And(_, _) | PhysicalExpr::Or(_, _) | PhysicalExpr::Not(_) => 2,
138        PhysicalExpr::Logical(Expr::Geometric(_)) => 5,
139        PhysicalExpr::Logical(_) => 3,
140    }
141}
142
143/// Returns whether `column` can be lowered to dictionary symbol membership.
144///
145/// Explicit identifier-policy restrictions are preserved.
146fn symbol_column(column: Column, policy: &AnalysisPolicy) -> bool {
147    if policy.identifiers == molframe_core::contract::Namespace::Explicit
148        && matches!(
149            column,
150            Column::Chain | Column::ResidueName | Column::AtomName
151        )
152    {
153        return false;
154    }
155
156    matches!(
157        crate::predicate::resolved_column(column, policy),
158        Column::LabelChain
159            | Column::AuthChain
160            | Column::LabelResidueName
161            | Column::AuthResidueName
162            | Column::LabelAtomName
163            | Column::AuthAtomName
164            | Column::AlternateLocation
165            | Column::Entity
166            | Column::Element
167            | Column::SegmentId
168            | Column::InsertionCode
169    )
170}
171
172/// Resolves membership patterns against the structure symbol dictionary.
173///
174/// Plain literal patterns use direct dictionary lookup and therefore avoid a
175/// complete dictionary scan. Only true glob/escape patterns are compiled and
176/// scanned across dictionary entries.
177fn resolve_symbols(structure: &Structure, values: &[Box<str>]) -> BTreeSet<SymbolId> {
178    let dictionary = &structure.data().dictionary;
179    let mut symbols = BTreeSet::new();
180    let mut patterns = Vec::new();
181
182    for value in values {
183        if is_plain_literal(value) {
184            if let Some(symbol) = dictionary.get(value) {
185                symbols.insert(symbol);
186            }
187        } else {
188            patterns.push(Glob::new(value));
189        }
190    }
191
192    if patterns.is_empty() {
193        return symbols;
194    }
195
196    for (symbol, text) in dictionary.iter() {
197        if patterns.iter().any(|pattern| pattern.matches(text)) {
198            symbols.insert(symbol);
199        }
200    }
201
202    symbols
203}
204
205/// Returns whether `pattern` has no glob or escape metacharacters.
206///
207/// Such patterns are semantically exact literals and can be resolved through a
208/// direct dictionary lookup.
209#[inline]
210fn is_plain_literal(pattern: &str) -> bool {
211    !pattern
212        .bytes()
213        .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'\\'))
214}
215
216#[cfg(test)]
217#[path = "plan_tests.rs"]
218mod tests;