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).