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/// A GROUP BY specification.
354#[derive(Debug, Clone)]
355pub struct GroupBy {
356    pub columns: Vec<String>,
357}
358
359impl GroupBy {
360    pub fn to_sql(&self) -> String {
361        if self.columns.is_empty() {
362            String::new()
363        } else {
364            format!("GROUP BY {}", self.columns.join(", "))
365        }
366    }
367}
368
369/// A HAVING condition.
370#[derive(Debug, Clone)]
371pub struct HavingCondition {
372    pub expression: String,
373}
374
375impl HavingCondition {
376    pub fn to_sql(&self) -> String {
377        format!("HAVING {}", self.expression)
378    }
379}
380
381/// Aggregate function kind for `HavingExpr`.
382///
383/// Used by `linq!(having ...)` (Form B) when the having clause contains
384/// nested boolean expressions (`AND`/`OR`/`NOT`) or aggregate-versus-aggregate
385/// comparisons (`COUNT(b.id) > SUM(b.views)`).
386#[derive(Debug, Clone, Copy, PartialEq, Eq)]
387pub enum AggKind {
388    Count,
389    Sum,
390    Avg,
391    Min,
392    Max,
393}
394
395impl AggKind {
396    /// Returns the SQL keyword for this aggregate.
397    pub fn sql_name(&self) -> &'static str {
398        match self {
399            AggKind::Count => "COUNT",
400            AggKind::Sum => "SUM",
401            AggKind::Avg => "AVG",
402            AggKind::Min => "MIN",
403            AggKind::Max => "MAX",
404        }
405    }
406
407    /// Parses an aggregate name (case-insensitive).
408    pub fn from_name(name: &str) -> Option<Self> {
409        match name.to_uppercase().as_str() {
410            "COUNT" => Some(AggKind::Count),
411            "SUM" => Some(AggKind::Sum),
412            "AVG" => Some(AggKind::Avg),
413            "MIN" => Some(AggKind::Min),
414            "MAX" => Some(AggKind::Max),
415            _ => None,
416        }
417    }
418}
419
420/// Comparison operator for `HavingExpr`.
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum CompareOp {
423    Eq,
424    Ne,
425    Gt,
426    Ge,
427    Lt,
428    Le,
429}
430
431impl CompareOp {
432    /// Returns the SQL operator string.
433    pub fn sql_name(&self) -> &'static str {
434        match self {
435            CompareOp::Eq => "=",
436            CompareOp::Ne => "!=",
437            CompareOp::Gt => ">",
438            CompareOp::Ge => ">=",
439            CompareOp::Lt => "<",
440            CompareOp::Le => "<=",
441        }
442    }
443
444    /// Parses a comparison operator from its SQL symbol.
445    pub fn from_symbol(symbol: &str) -> Option<Self> {
446        match symbol {
447            "=" => Some(CompareOp::Eq),
448            "!=" => Some(CompareOp::Ne),
449            ">" => Some(CompareOp::Gt),
450            ">=" => Some(CompareOp::Ge),
451            "<" => Some(CompareOp::Lt),
452            "<=" => Some(CompareOp::Le),
453            _ => None,
454        }
455    }
456}
457
458/// AST node for `HAVING` expressions.
459///
460/// Supports boolean combinations (`AND`/`OR`/`NOT`) and aggregate-versus-aggregate
461/// comparisons in addition to the basic `agg(col) op value` form. Generated by
462/// `linq!(having ...)` (Form B) expansion and compiled to SQL by `to_sql`.
463#[derive(Debug, Clone)]
464pub enum HavingExpr {
465    /// `agg(col) op value` — basic comparison against a literal.
466    Compare {
467        agg: AggKind,
468        col: String,
469        op: CompareOp,
470        value: DbValue,
471    },
472    /// `expr AND expr`.
473    And(Box<HavingExpr>, Box<HavingExpr>),
474    /// `expr OR expr`.
475    Or(Box<HavingExpr>, Box<HavingExpr>),
476    /// `NOT expr`.
477    Not(Box<HavingExpr>),
478    /// `agg(col1) op agg(col2)` — aggregate-vs-aggregate comparison (no bound parameter).
479    CompareAgg {
480        left_agg: AggKind,
481        left_col: String,
482        op: CompareOp,
483        right_agg: AggKind,
484        right_col: String,
485    },
486}
487
488impl HavingExpr {
489    /// Recursively compiles the expression into a SQL fragment using the
490    /// provider-specific placeholder syntax (`?` for SQLite/MySQL, `$N` for
491    /// PostgreSQL).
492    ///
493    /// `param_idx` is advanced past each bound parameter in left-to-right
494    /// order, matching the order produced by [`HavingExpr::collect_params`].
495    /// This ensures PostgreSQL's 1-indexed `$N` placeholders stay contiguous
496    /// with the WHERE clause's placeholders.
497    pub fn to_sql(
498        &self,
499        gen: &dyn crate::provider::ISqlGenerator,
500        param_idx: &mut usize,
501    ) -> String {
502        match self {
503            Self::Compare {
504                agg,
505                col,
506                op,
507                value: _,
508            } => {
509                let placeholder = gen.parameter_placeholder(*param_idx);
510                *param_idx += 1;
511                format!(
512                    "{}({}) {} {}",
513                    agg.sql_name(),
514                    col,
515                    op.sql_name(),
516                    placeholder
517                )
518            }
519            Self::And(left, right) => format!(
520                "({} AND {})",
521                left.to_sql(gen, param_idx),
522                right.to_sql(gen, param_idx)
523            ),
524            Self::Or(left, right) => format!(
525                "({} OR {})",
526                left.to_sql(gen, param_idx),
527                right.to_sql(gen, param_idx)
528            ),
529            Self::Not(inner) => format!("NOT ({})", inner.to_sql(gen, param_idx)),
530            Self::CompareAgg {
531                left_agg,
532                left_col,
533                op,
534                right_agg,
535                right_col,
536            } => format!(
537                "{}({}) {} {}({})",
538                left_agg.sql_name(),
539                left_col,
540                op.sql_name(),
541                right_agg.sql_name(),
542                right_col
543            ),
544        }
545    }
546
547    /// Collects bound parameter values in the same left-to-right order that
548    /// [`HavingExpr::to_sql`] emits placeholders. Used to populate the query
549    /// parameter vector at registration time so that `compile_sql` returns
550    /// params matching the placeholder order.
551    pub fn collect_params(&self) -> Vec<DbValue> {
552        match self {
553            Self::Compare { value, .. } => vec![value.clone()],
554            Self::And(left, right) | Self::Or(left, right) => {
555                let mut v = left.collect_params();
556                v.extend(right.collect_params());
557                v
558            }
559            Self::Not(inner) => inner.collect_params(),
560            Self::CompareAgg { .. } => Vec::new(),
561        }
562    }
563}