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}