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