Skip to main content

clicktype_query/
builder.rs

1//! Query builder implementation
2
3use std::marker::PhantomData;
4use clicktype_core::traits::{ClickTable, TypedColumn};
5use crate::join::{JoinSpec, JoinType};
6
7/// Supported SQL operators for filtering
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum Operator {
10    /// Equals `=`
11    Eq,
12    /// Not equals `!=`
13    Ne,
14    /// Greater than `>`
15    Gt,
16    /// Less than `<`
17    Lt,
18    /// Greater than or equals `>=`
19    Gte,
20    /// Less than or equals `<=`
21    Lte,
22    /// LIKE matching
23    Like,
24    /// ILIKE (Case insensitive)
25    ILike,
26}
27
28impl Operator {
29    fn as_str(&self) -> &'static str {
30        match self {
31            Operator::Eq => "=",
32            Operator::Ne => "!=",
33            Operator::Gt => ">",
34            Operator::Lt => "<",
35            Operator::Gte => ">=",
36            Operator::Lte => "<=",
37            Operator::Like => "LIKE",
38            Operator::ILike => "ILIKE",
39        }
40    }
41}
42
43/// Trait for safely serializing values to SQL.
44pub trait SqlValue {
45    fn to_sql_value(&self) -> String;
46}
47
48impl SqlValue for String {
49    fn to_sql_value(&self) -> String {
50        format!("'{}'", self.replace('`', "`"))
51    }
52}
53
54impl SqlValue for &str {
55    fn to_sql_value(&self) -> String {
56        format!("'{}'", self.replace('`', "`"))
57    }
58}
59
60macro_rules! impl_sql_value_for_numeric {
61    ($($t:ty),*) => {
62        $( 
63            impl SqlValue for $t {
64                fn to_sql_value(&self) -> String {
65                    self.to_string()
66                }
67            }
68        )*
69    };
70}
71
72impl_sql_value_for_numeric!(i8, i16, i32, i64, u8, u16, u32, u64, f32, f64, bool);
73
74/// Simplified query builder for ClickHouse.
75///
76/// Provides a fluent API to construct SQL queries safely, ensuring column names
77/// belong to the specified table.
78///
79/// # Example
80///
81/// ```rust,ignore
82/// let sql = QueryBuilder::<User>::new()
83///     .select(User::NAME)
84///     .filter(User::AGE, Operator::Gt, 18)
85///     .to_sql();
86/// ```
87#[derive(Debug, Clone)]
88pub struct QueryBuilder<T> {
89    table_name: &'static str,
90    select_exprs: Vec<String>,
91    joins: Vec<String>,
92    where_exprs: Vec<String>,
93    group_by_exprs: Vec<String>,
94    having_exprs: Vec<String>,
95    order_by_exprs: Vec<String>,
96    limit_val: Option<u64>,
97    offset_val: Option<u64>,
98    _phantom: PhantomData<T>,
99}
100
101impl<T: ClickTable> QueryBuilder<T> {
102    /// Create a new query builder for the given table.
103    pub fn new() -> Self {
104        Self {
105            table_name: T::table_name(),
106            select_exprs: Vec::new(),
107            joins: Vec::new(),
108            where_exprs: Vec::new(),
109            group_by_exprs: Vec::new(),
110            having_exprs: Vec::new(),
111            order_by_exprs: Vec::new(),
112            limit_val: None,
113            offset_val: None,
114            _phantom: PhantomData,
115        }
116    }
117
118    /// Add a typed column to the SELECT clause.
119    pub fn select<C: TypedColumn<Table = T>>(mut self, _col: C) -> Self {
120        self.select_exprs.push(C::name().to_string());
121        self
122    }
123
124    /// Add a raw expression string to the SELECT clause.
125    /// Useful for aggregate functions like `count()`, `sum(field)`, etc.
126    pub fn select_raw(mut self, expr: impl Into<String>) -> Self {
127        self.select_exprs.push(expr.into());
128        self
129    }
130
131    /// Add a JOIN clause using a JoinSpec.
132    ///
133    /// # Example
134    /// ```rust,ignore
135    /// use clicktype::query::{QueryBuilder, inner_join};
136    ///
137    /// let query = QueryBuilder::<User>::new()
138    ///     .select(User::NAME)
139    ///     .join(inner_join::<User, Order>().on(User::ID, Order::USER_ID))
140    ///     .to_sql();
141    /// ```
142    pub fn join<T2: ClickTable, J: JoinType>(mut self, join_spec: JoinSpec<T, T2, J>) -> Self {
143        self.joins.push(join_spec.to_sql());
144        self
145    }
146
147    /// Add a raw JOIN string.
148    pub fn join_raw(mut self, join_clause: impl Into<String>) -> Self {
149        self.joins.push(join_clause.into());
150        self
151    }
152
153    /// Add a WHERE condition with type-safe column and operator.
154    ///
155    /// The `value` is automatically converted to a safe SQL string representation.
156    ///
157    /// # Example
158    /// ```rust,ignore
159    /// .filter(User::ID, Operator::Eq, 42)
160    /// .filter(User::NAME, Operator::Eq, "Alice") // Automatically quoted
161    /// ```
162    pub fn filter<C: TypedColumn<Table = T>, V: SqlValue>(
163        mut self, 
164        _col: C, 
165        op: Operator, 
166        value: V
167    ) -> Self {
168        self.where_exprs.push(format!("{} {} {}", C::name(), op.as_str(), value.to_sql_value()));
169        self
170    }
171
172    /// Add a raw WHERE condition string.
173    pub fn filter_raw(mut self, condition: impl Into<String>) -> Self {
174        self.where_exprs.push(condition.into());
175        self
176    }
177
178    /// Add an ORDER BY clause for a specific column.
179    pub fn order_by<C: TypedColumn<Table = T>>(mut self, _col: C, desc: bool) -> Self {
180        let order = if desc {
181            format!("{} DESC", C::name())
182        } else {
183            C::name().to_string()
184        };
185        self.order_by_exprs.push(order);
186        self
187    }
188
189    /// Add a raw string to the ORDER BY clause.
190    pub fn order_by_raw(mut self, expr: impl Into<String>) -> Self {
191        self.order_by_exprs.push(expr.into());
192        self
193    }
194
195    /// Add a LIMIT clause.
196    pub fn limit(mut self, limit: u64) -> Self {
197        self.limit_val = Some(limit);
198        self
199    }
200
201    /// Add an OFFSET clause.
202    pub fn offset(mut self, offset: u64) -> Self {
203        self.offset_val = Some(offset);
204        self
205    }
206
207    /// Add a GROUP BY clause for a typed column.
208    pub fn group_by<C: TypedColumn<Table = T>>(mut self, _col: C) -> Self {
209        self.group_by_exprs.push(C::name().to_string());
210        self
211    }
212
213    /// Add a raw string to the GROUP BY clause.
214    pub fn group_by_raw(mut self, expr: impl Into<String>) -> Self {
215        self.group_by_exprs.push(expr.into());
216        self
217    }
218
219    /// Add a raw HAVING clause.
220    pub fn having(mut self, expr: impl Into<String>) -> Self {
221        self.having_exprs.push(expr.into());
222        self
223    }
224
225    /// Render the final SQL query string.
226    pub fn to_sql(&self) -> String {
227        let mut sql = String::from("SELECT ");
228
229        if self.select_exprs.is_empty() {
230            sql.push('*');
231        } else {
232            sql.push_str(&self.select_exprs.join(", "));
233        }
234
235        sql.push_str(" FROM ");
236        sql.push_str(self.table_name);
237
238        if !self.joins.is_empty() {
239            sql.push(' ');
240            sql.push_str(&self.joins.join(" "));
241        }
242
243        if !self.where_exprs.is_empty() {
244            sql.push_str(" WHERE ");
245            sql.push_str(&self.where_exprs.join(" AND "));
246        }
247
248        if !self.group_by_exprs.is_empty() {
249            sql.push_str(" GROUP BY ");
250            sql.push_str(&self.group_by_exprs.join(", "));
251        }
252
253        if !self.having_exprs.is_empty() {
254            sql.push_str(" HAVING ");
255            sql.push_str(&self.having_exprs.join(" AND "));
256        }
257
258        if !self.order_by_exprs.is_empty() {
259            sql.push_str(" ORDER BY ");
260            sql.push_str(&self.order_by_exprs.join(", "));
261        }
262
263        if let Some(limit) = self.limit_val {
264            sql.push_str(" LIMIT ");
265            sql.push_str(&limit.to_string());
266        }
267
268        if let Some(offset) = self.offset_val {
269            sql.push_str(" OFFSET ");
270            sql.push_str(&offset.to_string());
271        }
272
273        sql
274    }
275}