Skip to main content

qail_core/ast/cmd/
query.rs

1//! Query builder methods for Qail.
2//!
3//! Common fluent methods: columns, filter, join, order_by, limit, etc.
4
5use crate::ast::{
6    Cage, CageKind, Condition, Expr, Join, JoinKind, LogicalOp, Operator, Qail, SortOrder, Value,
7};
8
9impl Qail {
10    /// Set LIMIT.
11    pub fn limit(mut self, n: i64) -> Self {
12        self.cages.push(Cage {
13            kind: CageKind::Limit(usize::try_from(n).unwrap_or(0)),
14            conditions: vec![],
15            logical_op: LogicalOp::And,
16        });
17        self
18    }
19
20    /// SELECT * (all columns).
21    pub fn select_all(mut self) -> Self {
22        self.columns.push(Expr::Star);
23        self
24    }
25
26    /// Add columns by name.
27    pub fn columns<I, S>(mut self, cols: I) -> Self
28    where
29        I: IntoIterator<Item = S>,
30        S: AsRef<str>,
31    {
32        self.columns.extend(
33            cols.into_iter()
34                .map(|c| Expr::Named(c.as_ref().to_string())),
35        );
36        self
37    }
38
39    /// Add a single column by name.
40    pub fn column(mut self, col: impl AsRef<str>) -> Self {
41        self.columns.push(Expr::Named(col.as_ref().to_string()));
42        self
43    }
44
45    /// Add a computed expression as a SELECT column.
46    ///
47    /// Use this for subqueries, aggregates, CASE WHEN, COALESCE, etc.
48    ///
49    /// # Example
50    /// ```ignore
51    /// use qail_core::ast::builders::{subquery, coalesce, col, text};
52    /// use qail_core::ast::builders::ExprExt;
53    ///
54    /// Qail::get("orders")
55    ///     .columns(&["id", "status"])
56    ///     .select_expr(
57    ///         subquery(Qail::get("order_items")
58    ///             .column("sum(amount)")
59    ///             .eq("order_id", col("orders.id")))
60    ///         .with_alias("total_amount")
61    ///     )
62    ///     .select_expr(
63    ///         coalesce([col("nickname"), col("first_name"), text("Guest")])
64    ///             .alias("display_name")
65    ///     )
66    /// ```
67    pub fn select_expr(mut self, expr: impl Into<Expr>) -> Self {
68        self.columns.push(expr.into());
69        self
70    }
71
72    /// Add multiple computed expressions as SELECT columns.
73    ///
74    /// # Example
75    /// ```ignore
76    /// .select_exprs([
77    ///     count().alias("total"),
78    ///     sum("amount").alias("grand_total"),
79    /// ])
80    /// ```
81    pub fn select_exprs<I, E>(mut self, exprs: I) -> Self
82    where
83        I: IntoIterator<Item = E>,
84        E: Into<Expr>,
85    {
86        self.columns.extend(exprs.into_iter().map(|e| e.into()));
87        self
88    }
89
90    /// Add a WHERE filter with an operator and value.
91    pub fn filter(
92        mut self,
93        column: impl AsRef<str>,
94        op: Operator,
95        value: impl Into<Value>,
96    ) -> Self {
97        let filter_cage = self
98            .cages
99            .iter_mut()
100            .find(|c| matches!(c.kind, CageKind::Filter) && c.logical_op == LogicalOp::And);
101
102        let condition = Condition {
103            left: Expr::Named(column.as_ref().to_string()),
104            op,
105            value: value.into(),
106            is_array_unnest: false,
107        };
108
109        if let Some(cage) = filter_cage {
110            cage.conditions.push(condition);
111        } else {
112            self.cages.push(Cage {
113                kind: CageKind::Filter,
114                conditions: vec![condition],
115                logical_op: LogicalOp::And,
116            });
117        }
118        self
119    }
120
121    /// Add an OR filter condition.
122    pub fn or_filter(
123        mut self,
124        column: impl AsRef<str>,
125        op: Operator,
126        value: impl Into<Value>,
127    ) -> Self {
128        let condition = Condition {
129            left: Expr::Named(column.as_ref().to_string()),
130            op,
131            value: value.into(),
132            is_array_unnest: false,
133        };
134
135        let or_filter_cage = self
136            .cages
137            .iter_mut()
138            .find(|c| matches!(c.kind, CageKind::Filter) && c.logical_op == LogicalOp::Or);
139
140        if let Some(cage) = or_filter_cage {
141            cage.conditions.push(condition);
142        } else {
143            self.cages.push(Cage {
144                kind: CageKind::Filter,
145                conditions: vec![condition],
146                logical_op: LogicalOp::Or,
147            });
148        }
149        self
150    }
151
152    /// Filter: column = value.
153    pub fn where_eq(self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
154        self.filter(column, Operator::Eq, value)
155    }
156
157    /// Filter: column = value (alias for `where_eq`).
158    pub fn eq(self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
159        self.filter(column, Operator::Eq, value)
160    }
161
162    /// Filter: column != value.
163    pub fn ne(self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
164        self.filter(column, Operator::Ne, value)
165    }
166
167    /// Filter: column > value.
168    pub fn gt(self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
169        self.filter(column, Operator::Gt, value)
170    }
171    /// Filter: column >= value.
172    pub fn gte(self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
173        self.filter(column, Operator::Gte, value)
174    }
175
176    /// Filter: column < value
177    pub fn lt(self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
178        self.filter(column, Operator::Lt, value)
179    }
180
181    /// Filter: column <= value.
182    pub fn lte(self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
183        self.filter(column, Operator::Lte, value)
184    }
185
186    /// Filter: column IS NULL.
187    pub fn is_null(self, column: impl AsRef<str>) -> Self {
188        self.filter(column, Operator::IsNull, Value::Null)
189    }
190
191    /// Filter: column IS NOT NULL.
192    pub fn is_not_null(self, column: impl AsRef<str>) -> Self {
193        self.filter(column, Operator::IsNotNull, Value::Null)
194    }
195
196    /// Filter: column LIKE pattern.
197    pub fn like(self, column: impl AsRef<str>, pattern: impl Into<Value>) -> Self {
198        self.filter(column, Operator::Like, pattern)
199    }
200
201    /// Filter: column ILIKE pattern.
202    pub fn ilike(self, column: impl AsRef<str>, pattern: impl Into<Value>) -> Self {
203        self.filter(column, Operator::ILike, pattern)
204    }
205
206    /// Filter: does `text` contain any element from `array_column`?
207    ///
208    /// Generates an `EXISTS (SELECT 1 FROM unnest(array_column) _el WHERE ...)`
209    /// predicate with case-insensitive matching.
210    pub fn array_elem_contained_in_text(
211        self,
212        array_column: impl AsRef<str>,
213        text: impl Into<Value>,
214    ) -> Self {
215        self.filter_cond(Condition {
216            left: Expr::Named(array_column.as_ref().to_string()),
217            op: Operator::ArrayElemContainedInText,
218            value: text.into(),
219            is_array_unnest: true,
220        })
221    }
222
223    /// Filter: column IN (values).
224    ///
225    /// # Arguments
226    ///
227    /// * `column` — Column name to filter on.
228    /// * `values` — Iterable of values for the IN list.
229    pub fn in_vals<I, V>(self, column: impl AsRef<str>, values: I) -> Self
230    where
231        I: IntoIterator<Item = V>,
232        V: Into<Value>,
233    {
234        let arr: Vec<Value> = values.into_iter().map(|v| v.into()).collect();
235        self.filter(column, Operator::In, Value::Array(arr))
236    }
237
238    /// Add ORDER BY clause.
239    pub fn order_by(mut self, column: impl AsRef<str>, order: SortOrder) -> Self {
240        self.cages.push(Cage {
241            kind: CageKind::Sort(order),
242            conditions: vec![Condition {
243                left: Expr::Named(column.as_ref().to_string()),
244                op: Operator::Eq,
245                value: Value::Null,
246                is_array_unnest: false,
247            }],
248            logical_op: LogicalOp::And,
249        });
250        self
251    }
252
253    /// ORDER BY column DESC.
254    pub fn order_desc(self, column: impl AsRef<str>) -> Self {
255        self.order_by(column, SortOrder::Desc)
256    }
257
258    /// ORDER BY column ASC.
259    pub fn order_asc(self, column: impl AsRef<str>) -> Self {
260        self.order_by(column, SortOrder::Asc)
261    }
262
263    /// Set OFFSET.
264    pub fn offset(mut self, n: i64) -> Self {
265        self.cages.push(Cage {
266            kind: CageKind::Offset(usize::try_from(n).unwrap_or(0)),
267            conditions: vec![],
268            logical_op: LogicalOp::And,
269        });
270        self
271    }
272
273    /// GROUP BY columns.
274    pub fn group_by<I, S>(mut self, cols: I) -> Self
275    where
276        I: IntoIterator<Item = S>,
277        S: AsRef<str>,
278    {
279        let conditions: Vec<Condition> = cols
280            .into_iter()
281            .map(|c| Condition {
282                left: Expr::Named(c.as_ref().to_string()),
283                op: Operator::Eq,
284                value: Value::Null,
285                is_array_unnest: false,
286            })
287            .collect();
288
289        self.cages.push(Cage {
290            kind: CageKind::Partition,
291            conditions,
292            logical_op: LogicalOp::And,
293        });
294        self
295    }
296
297    /// SELECT DISTINCT (all columns).
298    pub fn distinct_on_all(mut self) -> Self {
299        self.distinct = true;
300        self
301    }
302
303    /// Add a JOIN clause.
304    pub fn join(
305        mut self,
306        kind: JoinKind,
307        table: impl AsRef<str>,
308        left_col: impl AsRef<str>,
309        right_col: impl AsRef<str>,
310    ) -> Self {
311        self.joins.push(Join {
312            kind,
313            table: table.as_ref().to_string(),
314            on: Some(vec![Condition {
315                left: Expr::Named(left_col.as_ref().to_string()),
316                op: Operator::Eq,
317                value: Value::Column(right_col.as_ref().to_string()),
318                is_array_unnest: false,
319            }]),
320            on_true: false,
321        });
322        self
323    }
324
325    /// LEFT JOIN.
326    pub fn left_join(
327        self,
328        table: impl AsRef<str>,
329        left_col: impl AsRef<str>,
330        right_col: impl AsRef<str>,
331    ) -> Self {
332        self.join(JoinKind::Left, table, left_col, right_col)
333    }
334
335    /// INNER JOIN.
336    pub fn inner_join(
337        self,
338        table: impl AsRef<str>,
339        left_col: impl AsRef<str>,
340        right_col: impl AsRef<str>,
341    ) -> Self {
342        self.join(JoinKind::Inner, table, left_col, right_col)
343    }
344
345    /// Join a related table using schema-defined foreign key relationship.
346    ///
347    /// This is the "First-Class Relations" API - it automatically infers
348    /// the join condition from the schema's `ref:` definitions.
349    ///
350    /// # Example
351    /// ```ignore
352    /// // Schema: posts.user_id UUID ref:users.id
353    ///
354    /// // Instead of:
355    /// Qail::get("users").left_join("posts", "users.id", "posts.user_id")
356    ///
357    /// // Simply:
358    /// Qail::get("users").join_on("posts")?
359    /// ```
360    ///
361    /// Returns an error when no relation is found or relation metadata is
362    /// ambiguous. Use [`Qail::join_on_optional`] for a no-op fallback.
363    pub fn join_on(self, related_table: impl AsRef<str>) -> crate::error::QailBuildResult<Self> {
364        let related = related_table.as_ref();
365
366        // Try: current table -> related (forward relation)
367        if let Some((from_col, to_col)) =
368            crate::schema::lookup_relation_state(&self.table, related)?
369        {
370            return Ok(self.left_join(related, &from_col, &to_col));
371        }
372
373        // Try: related -> current table (reverse relation)
374        if let Some((from_col, to_col)) =
375            crate::schema::lookup_relation_state(related, &self.table)?
376        {
377            return Ok(self.left_join(related, &to_col, &from_col));
378        }
379
380        Err(crate::error::QailBuildError::RelationNotFound {
381            from_table: self.table,
382            to_table: related.to_string(),
383        })
384    }
385
386    /// Join a related table if relation exists, otherwise no-op.
387    ///
388    /// This is the panic-free variant of `join_on()`.
389    /// On ambiguous relation metadata it logs and returns `self` unchanged.
390    pub fn join_on_optional(self, related_table: impl AsRef<str>) -> Self {
391        let related = related_table.as_ref();
392
393        // Try forward relation
394        match crate::schema::lookup_relation_state(&self.table, related) {
395            Ok(Some((from_col, to_col))) => return self.left_join(related, &from_col, &to_col),
396            Ok(None) => {}
397            Err(msg) => {
398                eprintln!("QAIL: join_on_optional skipped — {}", msg);
399                return self;
400            }
401        }
402
403        // Try reverse relation
404        match crate::schema::lookup_relation_state(related, &self.table) {
405            Ok(Some((from_col, to_col))) => return self.left_join(related, &to_col, &from_col),
406            Ok(None) => {}
407            Err(msg) => {
408                eprintln!("QAIL: join_on_optional skipped — {}", msg);
409                return self;
410            }
411        }
412
413        // No relation found, return self unchanged
414        self
415    }
416
417    /// Add RETURNING clause with column names.
418    pub fn returning<I, S>(mut self, cols: I) -> Self
419    where
420        I: IntoIterator<Item = S>,
421        S: AsRef<str>,
422    {
423        self.returning = Some(
424            cols.into_iter()
425                .map(|c| Expr::Named(c.as_ref().to_string()))
426                .collect(),
427        );
428        self
429    }
430
431    /// RETURNING * (all columns).
432    pub fn returning_all(mut self) -> Self {
433        self.returning = Some(vec![Expr::Star]);
434        self
435    }
436
437    /// Add payload values (INSERT positional).
438    pub fn values<I, V>(mut self, vals: I) -> Self
439    where
440        I: IntoIterator<Item = V>,
441        V: Into<Value>,
442    {
443        self.cages.push(Cage {
444            kind: CageKind::Payload,
445            conditions: vals
446                .into_iter()
447                .enumerate()
448                .map(|(i, v)| Condition {
449                    left: Expr::Named(format!("${}", i + 1)),
450                    op: Operator::Eq,
451                    value: v.into(),
452                    is_array_unnest: false,
453                })
454                .collect(),
455            logical_op: LogicalOp::And,
456        });
457        self
458    }
459
460    /// Set a column = value pair for UPDATE or INSERT.
461    pub fn set_value(mut self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
462        let payload_cage = self
463            .cages
464            .iter_mut()
465            .find(|c| matches!(c.kind, CageKind::Payload));
466
467        let condition = Condition {
468            left: Expr::Named(column.as_ref().to_string()),
469            op: Operator::Eq,
470            value: value.into(),
471            is_array_unnest: false,
472        };
473
474        if let Some(cage) = payload_cage {
475            cage.conditions.push(condition);
476        } else {
477            self.cages.push(Cage {
478                kind: CageKind::Payload,
479                conditions: vec![condition],
480                logical_op: LogicalOp::And,
481            });
482        }
483        self
484    }
485
486    /// Set value only if Some, skip entirely if None
487    /// This is ergonomic for optional fields - the column is not included in the INSERT at all if None
488    pub fn set_opt<T>(self, column: impl AsRef<str>, value: Option<T>) -> Self
489    where
490        T: Into<Value>,
491    {
492        match value {
493            Some(v) => self.set_value(column, v),
494            None => self, // Skip entirely, don't add column
495        }
496    }
497
498    /// Set column to COALESCE(new_value, existing_column) for partial updates.
499    ///
500    /// This is useful for UPDATE operations where you want to keep the existing
501    /// value if the new value is NULL.
502    ///
503    /// # Example
504    /// ```ignore
505    /// Qail::set("users")
506    ///     .set_coalesce("name", "Alice")  // name = COALESCE('Alice', name)
507    ///     .eq("id", 1)
508    /// ```
509    pub fn set_coalesce(mut self, column: impl AsRef<str>, value: impl Into<Value>) -> Self {
510        use crate::ast::builders::coalesce;
511
512        let col_name = column.as_ref().to_string();
513        let coalesce_expr =
514            coalesce([Expr::Literal(value.into()), Expr::Named(col_name.clone())]).build();
515
516        let payload_cage = self
517            .cages
518            .iter_mut()
519            .find(|c| matches!(c.kind, CageKind::Payload));
520
521        let condition = Condition {
522            left: Expr::Named(col_name),
523            op: Operator::Eq,
524            value: Value::Expr(Box::new(coalesce_expr)),
525            is_array_unnest: false,
526        };
527
528        if let Some(cage) = payload_cage {
529            cage.conditions.push(condition);
530        } else {
531            self.cages.push(Cage {
532                kind: CageKind::Payload,
533                conditions: vec![condition],
534                logical_op: LogicalOp::And,
535            });
536        }
537        self
538    }
539
540    /// Set column to COALESCE(new_value, existing_column) only if value is Some.
541    ///
542    /// Combines set_coalesce() with optional handling - if None, still adds
543    /// the COALESCE with NULL as the first argument (so existing value is kept).
544    pub fn set_coalesce_opt<T>(self, column: impl AsRef<str>, value: Option<T>) -> Self
545    where
546        T: Into<Value>,
547    {
548        match value {
549            Some(v) => self.set_coalesce(column, v),
550            None => self, // Skip - existing value will be kept
551        }
552    }
553
554    /// Add ON CONFLICT DO UPDATE clause for UPSERT operations.
555    ///
556    /// # Example
557    /// ```ignore
558    /// Qail::add("users")
559    ///     .set_value("id", 1)
560    ///     .set_value("name", "Alice")
561    ///     .on_conflict_update(&["id"], &[("name", Expr::Named("EXCLUDED.name".into()))])
562    /// ```
563    pub fn on_conflict_update<S>(mut self, conflict_cols: &[S], updates: &[(S, Expr)]) -> Self
564    where
565        S: AsRef<str>,
566    {
567        use super::{ConflictAction, OnConflict};
568
569        self.on_conflict = Some(OnConflict {
570            columns: conflict_cols
571                .iter()
572                .map(|c| c.as_ref().to_string())
573                .collect(),
574            action: ConflictAction::DoUpdate {
575                assignments: updates
576                    .iter()
577                    .map(|(col, expr)| (col.as_ref().to_string(), expr.clone()))
578                    .collect(),
579            },
580        });
581        self
582    }
583
584    /// Add ON CONFLICT DO NOTHING clause (ignore duplicates).
585    ///
586    /// # Example
587    /// ```ignore
588    /// Qail::add("users")
589    ///     .set_value("id", 1)
590    ///     .on_conflict_nothing(&["id"])
591    /// ```
592    pub fn on_conflict_nothing<S>(mut self, conflict_cols: &[S]) -> Self
593    where
594        S: AsRef<str>,
595    {
596        use super::{ConflictAction, OnConflict};
597
598        self.on_conflict = Some(OnConflict {
599            columns: conflict_cols
600                .iter()
601                .map(|c| c.as_ref().to_string())
602                .collect(),
603            action: ConflictAction::DoNothing,
604        });
605        self
606    }
607}