Skip to main content

rust_ef/query/
ast.rs

1//! Query expression AST types.
2//!
3//! Boolean expression tree (`BoolExpr`), filter conditions, ordering,
4//! grouping, having, includes, joins, and supporting types used across
5//! the query builder and SQL compiler.
6
7use crate::provider::DbValue;
8
9// Use the compile-module helper for inline value collection. Declared
10// here (not `super::compile::collect_bool_expr_values`) so `CompiledFilter::new`
11// can pre-collect values at construction time.
12use super::compile::collect_bool_expr_values;
13
14// ---------------------------------------------------------------------------
15// Query operators
16// ---------------------------------------------------------------------------
17
18/// A filter condition built from property accessors.
19#[derive(Debug, Clone)]
20pub struct FilterCondition {
21    /// The column name this condition applies to.
22    column: String,
23    /// SQL operator (e.g., "=", ">", "LIKE", "IN", "BETWEEN", "IS NULL").
24    operator: String,
25    /// Number of bound parameters consumed by this condition.
26    param_count: usize,
27    /// Inline parameter values (for self-contained `BoolExpr` used outside
28    /// `QueryBuilder` state, e.g. global query filters produced by
29    /// `linq!(filter |b: T| ...)`). Empty for in-builder conditions where
30    /// values are tracked in `QueryState::parameters`.
31    pub(crate) values: Vec<DbValue>,
32}
33
34impl FilterCondition {
35    pub fn new(column: impl Into<String>, operator: impl Into<String>, param_count: usize) -> Self {
36        Self {
37            column: column.into(),
38            operator: operator.into(),
39            param_count,
40            values: Vec::new(),
41        }
42    }
43
44    /// Creates a condition carrying its own parameter values. Used by
45    /// `linq!(filter |b: T| ...)` (Form C) to produce self-contained
46    /// `BoolExpr` values for global query filters.
47    pub fn with_values(
48        column: impl Into<String>,
49        operator: impl Into<String>,
50        values: Vec<DbValue>,
51    ) -> Self {
52        let count = values.len();
53        Self {
54            column: column.into(),
55            operator: operator.into(),
56            param_count: count,
57            values,
58        }
59    }
60
61    /// Returns the inline values carried by this condition (empty for
62    /// in-builder conditions).
63    pub fn values(&self) -> &[DbValue] {
64        &self.values
65    }
66
67    /// Convert to a SQL WHERE fragment using dialect-specific placeholders.
68    pub fn to_sql(&self, placeholders: &[String]) -> String {
69        match self.operator.as_str() {
70            "IS NULL" => format!("{} IS NULL", self.column),
71            "IS NOT NULL" => format!("{} IS NOT NULL", self.column),
72            "IN" => format!("{} IN ({})", self.column, placeholders.join(", ")),
73            "BETWEEN" if placeholders.len() >= 2 => format!(
74                "{} BETWEEN {} AND {}",
75                self.column, placeholders[0], placeholders[1]
76            ),
77            op if self.param_count == 0 => format!("{} {}", self.column, op),
78            op => format!("{} {} {}", self.column, op, placeholders[0]),
79        }
80    }
81
82    pub fn param_count(&self) -> usize {
83        self.param_count
84    }
85
86    pub fn column(&self) -> &str {
87        &self.column
88    }
89
90    pub fn operator(&self) -> &str {
91        &self.operator
92    }
93}
94
95/// Wrapper for raw SQL fragments inside `BoolExpr::Raw`.
96///
97/// The type is `pub` (so the `BoolExpr::Raw` variant field doesn't trigger
98/// `private_interfaces`), but the inner `String` field is `pub(crate)`.
99/// External code can name `RawSql` but cannot construct it (field
100/// inaccessible) and cannot read the SQL string — closing the raw SQL
101/// injection hatch at the type level. Internal callers use
102/// `BoolExpr::raw()` (`pub(crate)`).
103#[derive(Debug, Clone)]
104pub struct RawSql(pub(crate) String);
105
106/// Boolean expression AST for WHERE clauses.
107#[derive(Debug, Clone)]
108pub enum BoolExpr {
109    /// A single parameterized filter condition.
110    Filter(FilterCondition),
111    /// Raw SQL fragment (no parameters), e.g. global query filters.
112    /// Payload is `RawSql` (`pub(crate)`) so the variant cannot be
113    /// constructed by external code.
114    Raw(RawSql),
115    /// AND combination.
116    And(Box<BoolExpr>, Box<BoolExpr>),
117    /// OR combination.
118    Or(Box<BoolExpr>, Box<BoolExpr>),
119    /// NOT negation.
120    Not(Box<BoolExpr>),
121    /// EXISTS (SELECT 1 FROM related_table WHERE related.fk = outer.pk AND <predicate>)
122    Exists(Box<SubquerySpec>),
123    /// NOT EXISTS (...)
124    NotExists(Box<SubquerySpec>),
125    /// `column IN (SELECT projection FROM source_table [WHERE predicate])`
126    InSubquery(Box<InSubquerySpec>),
127    /// `column NOT IN (SELECT projection FROM source_table [WHERE predicate])`
128    NotInSubquery(Box<InSubquerySpec>),
129}
130
131/// G5: Specification for a correlated subquery (`EXISTS` / `NOT EXISTS`).
132///
133/// Created by the `linq!` macro when parsing `b.posts.any(|p| p.published)`.
134/// The `navigation_field` and `related_type_name` are set at macro expansion
135/// time; the table/column fields are resolved at SQL generation time from
136/// `EntityTypeMeta` navigation metadata.
137#[derive(Debug, Clone)]
138pub struct SubquerySpec {
139    /// Navigation field name on the outer entity (e.g. "posts").
140    pub navigation_field: String,
141    /// Related entity type name (e.g. "Post").
142    pub related_type_name: String,
143    /// Additional predicate from the closure body (e.g. `p.published`).
144    pub predicate: Option<Box<BoolExpr>>,
145    /// Resolved: outer table name (e.g. "blogs").
146    pub outer_table: String,
147    /// Resolved: related table name (e.g. "posts").
148    pub related_table: String,
149    /// Resolved: FK column on the related table (e.g. "blog_id").
150    pub fk_column: String,
151    /// Resolved: outer entity's PK column (e.g. "id").
152    pub outer_pk_column: String,
153}
154
155impl SubquerySpec {
156    /// Creates an unresolved spec (table/column fields filled at SQL gen time).
157    pub fn new(navigation_field: impl Into<String>, related_type_name: impl Into<String>) -> Self {
158        Self {
159            navigation_field: navigation_field.into(),
160            related_type_name: related_type_name.into(),
161            predicate: None,
162            outer_table: String::new(),
163            related_table: String::new(),
164            fk_column: String::new(),
165            outer_pk_column: String::new(),
166        }
167    }
168}
169
170/// v1.1: Specification for a scalar `IN (SELECT ...)` / `NOT IN (SELECT ...)`
171/// subquery.
172///
173/// Created by the `linq!` macro when parsing
174/// `b.field.in_subquery(|p: Post| p.blog_id)`. Unlike [`SubquerySpec`], this
175/// variant is **not** navigation-driven — the subquery projects a single column
176/// from an arbitrary table, and the outer column is compared against the
177/// projected values via the `IN` operator.
178#[derive(Debug, Clone)]
179pub struct InSubquerySpec {
180    /// The outer column being tested (e.g. `"id"` on the parent table).
181    pub outer_column: String,
182    /// The source table name for the inner SELECT (e.g. `"posts"`).
183    pub source_table: String,
184    /// The projection column selected from the inner table
185    /// (e.g. `"blog_id"`).
186    pub projection_column: String,
187    /// Optional predicate applied inside the subquery
188    /// (e.g. `WHERE published = ?`).
189    pub predicate: Option<Box<BoolExpr>>,
190}
191
192impl InSubquerySpec {
193    /// Creates a new IN-subquery specification.
194    pub fn new(
195        outer_column: impl Into<String>,
196        source_table: impl Into<String>,
197        projection_column: impl Into<String>,
198    ) -> Self {
199        Self {
200            outer_column: outer_column.into(),
201            source_table: source_table.into(),
202            projection_column: projection_column.into(),
203            predicate: None,
204        }
205    }
206}
207
208impl BoolExpr {
209    pub fn filter(
210        column: impl Into<String>,
211        operator: impl Into<String>,
212        param_count: usize,
213    ) -> Self {
214        BoolExpr::Filter(FilterCondition::new(column, operator, param_count))
215    }
216
217    pub(crate) fn raw(sql: impl Into<String>) -> Self {
218        BoolExpr::Raw(RawSql(sql.into()))
219    }
220
221    pub fn and(self, other: BoolExpr) -> Self {
222        BoolExpr::And(Box::new(self), Box::new(other))
223    }
224
225    pub fn or(self, other: BoolExpr) -> Self {
226        BoolExpr::Or(Box::new(self), Box::new(other))
227    }
228
229    #[allow(clippy::should_implement_trait)]
230    pub fn not(self) -> Self {
231        BoolExpr::Not(Box::new(self))
232    }
233
234    pub fn total_param_count(&self) -> usize {
235        match self {
236            BoolExpr::Filter(f) => f.param_count(),
237            BoolExpr::Raw(_) => 0,
238            BoolExpr::And(a, b) | BoolExpr::Or(a, b) => {
239                a.total_param_count() + b.total_param_count()
240            }
241            BoolExpr::Not(inner) => inner.total_param_count(),
242            BoolExpr::Exists(spec) | BoolExpr::NotExists(spec) => spec
243                .predicate
244                .as_ref()
245                .map(|p| p.total_param_count())
246                .unwrap_or(0),
247            BoolExpr::InSubquery(spec) | BoolExpr::NotInSubquery(spec) => spec
248                .predicate
249                .as_ref()
250                .map(|p| p.total_param_count())
251                .unwrap_or(0),
252        }
253    }
254}
255
256/// An ordering specification.
257#[derive(Debug, Clone)]
258pub struct OrderBy {
259    column: String,
260    pub(crate) direction: OrderDirection,
261}
262
263#[derive(Debug, Clone, Copy)]
264pub enum OrderDirection {
265    Ascending,
266    Descending,
267}
268
269impl OrderBy {
270    pub fn new(column: impl Into<String>, direction: OrderDirection) -> Self {
271        Self {
272            column: column.into(),
273            direction,
274        }
275    }
276
277    pub fn to_sql(&self) -> String {
278        let dir = match self.direction {
279            OrderDirection::Ascending => "ASC",
280            OrderDirection::Descending => "DESC",
281        };
282        format!("{} {}", self.column, dir)
283    }
284}
285
286/// A query filter with pre-collected parameter values.
287///
288/// Produced by `ModelBuilder::filters_by_table()`. The `expr` is retained for
289/// per-query SQL compilation (which depends on the provider's dialect and the
290/// current placeholder index), while `params` are collected once at
291/// registration time to avoid redundant `collect_bool_expr_values` traversals
292/// on every navigation/primary query.
293///
294/// For simple tenant filters (`tenant_id = ?`) the per-query SQL compilation
295/// is a single `to_sql` call — cheap and correct for all dialects.
296#[derive(Debug, Clone)]
297pub struct CompiledFilter {
298    /// The filter expression tree. Compiled to SQL per query using the
299    /// provider's `ISqlGenerator` (placeholder syntax is dialect-specific).
300    pub expr: BoolExpr,
301    /// Parameter values extracted from the expression tree at registration
302    /// time. Appended to the query's parameter list at apply time.
303    pub params: Vec<DbValue>,
304}
305
306impl CompiledFilter {
307    /// Builds a `CompiledFilter` from a `BoolExpr`, pre-collecting its
308    /// inline parameter values.
309    pub fn new(expr: BoolExpr) -> Self {
310        let params = collect_bool_expr_values(&expr);
311        Self { expr, params }
312    }
313}
314
315/// An eager-load include specification.
316#[derive(Debug, Clone)]
317pub struct IncludePath {
318    pub navigation: String,
319    /// Nested ThenInclude paths (tree).
320    pub nested: Vec<IncludePath>,
321    /// The related table name for JOIN generation.
322    pub related_table: Option<String>,
323    /// The foreign key column for the JOIN condition.
324    pub foreign_key_column: Option<String>,
325    /// The referenced key column (typically primary key of the related table).
326    pub referenced_key_column: Option<String>,
327}
328
329/// A JOIN specification for SQL generation.
330#[derive(Debug, Clone)]
331pub struct JoinSpec {
332    /// JOIN type: "INNER", "LEFT", "RIGHT", "FULL", "CROSS"
333    pub join_type: String,
334    /// The table to join.
335    pub table: String,
336    /// The ON condition. Empty for CROSS JOIN.
337    pub on_clause: String,
338}
339
340impl JoinSpec {
341    pub fn to_sql(&self) -> String {
342        if self.join_type == "CROSS" {
343            format!("CROSS JOIN {}", self.table)
344        } else {
345            format!(
346                "{} JOIN {} ON {}",
347                self.join_type, self.table, self.on_clause
348            )
349        }
350    }
351}
352
353// Note: `HavingExpr`, `AggKind`, and `CompareOp` live in `having_expr.rs`.
354// `GroupBy` and `HavingCondition` were removed as dead code (superseded by
355// `Vec<String>` and `HavingExpr` respectively).