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