Skip to main content

fraiseql_core/runtime/aggregation/
where_clause.rs

1//! Parameterized WHERE and HAVING clause SQL generation.
2
3use super::{
4    AggregationSqlGenerator, DatabaseType, FactTableMetadata, FraiseQLError, Result,
5    ValidatedHavingCondition, WhereClause, WhereOperator, to_snake_case,
6};
7
8impl AggregationSqlGenerator {
9    /// Convert a [`WhereClause`] AST to parameterized SQL, appending bind values to `params`.
10    pub(super) fn where_clause_to_sql_parameterized(
11        &self,
12        clause: &WhereClause,
13        metadata: &FactTableMetadata,
14        params: &mut Vec<serde_json::Value>,
15    ) -> Result<String> {
16        match clause {
17            WhereClause::Field {
18                path,
19                operator,
20                value,
21            } => {
22                let field_name = &path[0];
23                let is_denormalized =
24                    metadata.denormalized_filters.iter().any(|f| f.name == *field_name);
25                if is_denormalized {
26                    self.generate_direct_column_where_parameterized(
27                        field_name, operator, value, params,
28                    )
29                } else {
30                    let jsonb_column = &metadata.dimensions.name;
31                    self.generate_jsonb_where_parameterized(
32                        jsonb_column,
33                        path,
34                        operator,
35                        value,
36                        params,
37                    )
38                }
39            },
40            WhereClause::And(clauses) => {
41                let conditions: Vec<String> = clauses
42                    .iter()
43                    .map(|c| self.where_clause_to_sql_parameterized(c, metadata, params))
44                    .collect::<Result<Vec<_>>>()?;
45                Ok(format!("({})", conditions.join(" AND ")))
46            },
47            WhereClause::Or(clauses) => {
48                let conditions: Vec<String> = clauses
49                    .iter()
50                    .map(|c| self.where_clause_to_sql_parameterized(c, metadata, params))
51                    .collect::<Result<Vec<_>>>()?;
52                Ok(format!("({})", conditions.join(" OR ")))
53            },
54            WhereClause::Not(inner) => {
55                let s = self.where_clause_to_sql_parameterized(inner, metadata, params)?;
56                Ok(format!("NOT ({s})"))
57            },
58            WhereClause::NativeField {
59                column,
60                pg_cast,
61                operator,
62                value,
63            } => {
64                // Direct column reference (no JSONB extraction) for native SQL columns.
65                // Use quote_identifier for dialect-correct quoting (MySQL backticks, SQL
66                // Server brackets, PG/SQLite double-quotes).
67                let col_ref = self.quote_identifier(column);
68                let pre_len = params.len();
69                let sql = self.generate_direct_column_where_parameterized(
70                    &col_ref, operator, value, params,
71                )?;
72
73                // For PostgreSQL with a non-empty type cast, append ::type to the single
74                // scalar placeholder that was just added.  IN / NOT IN push multiple params;
75                // IsNull pushes none — neither needs a cast suffix here.
76                if self.database_type == DatabaseType::PostgreSQL
77                    && !pg_cast.is_empty()
78                    && params.len() == pre_len + 1
79                {
80                    let ph = self.placeholder(pre_len);
81                    Ok(sql.replace(&ph, &format!("{ph}::{pg_cast}")))
82                } else {
83                    Ok(sql)
84                }
85            },
86            // Reason: non_exhaustive requires catch-all for cross-crate matches
87            _ => Err(crate::FraiseQLError::Validation {
88                message: "Unknown WhereClause variant".to_string(),
89                path:    None,
90            }),
91        }
92    }
93
94    /// Parameterized WHERE for a denormalized (direct column) filter.
95    pub(super) fn generate_direct_column_where_parameterized(
96        &self,
97        field: &str,
98        operator: &WhereOperator,
99        value: &serde_json::Value,
100        params: &mut Vec<serde_json::Value>,
101    ) -> Result<String> {
102        if matches!(operator, WhereOperator::IsNull) {
103            return Ok(format!("{field} IS NULL"));
104        }
105
106        let op_sql = self.operator_to_sql(operator);
107
108        if matches!(operator, WhereOperator::In | WhereOperator::Nin) {
109            let arr = value.as_array().ok_or_else(|| {
110                FraiseQLError::validation("IN/NOT IN operators require array values")
111            })?;
112            let phs: Vec<String> = arr.iter().map(|v| self.emit_value_param(v, params)).collect();
113            return Ok(format!("{field} {op_sql} ({})", phs.join(", ")));
114        }
115
116        if matches!(
117            operator,
118            WhereOperator::Contains
119                | WhereOperator::Startswith
120                | WhereOperator::Endswith
121                | WhereOperator::Like
122        ) {
123            let s = value
124                .as_str()
125                .ok_or_else(|| FraiseQLError::validation("LIKE operators require string values"))?;
126            let (ph, needs_escape) = self.emit_like_pattern_param(operator, s, params);
127            return if needs_escape {
128                Ok(format!("{field} {op_sql} {ph} ESCAPE '!'"))
129            } else {
130                Ok(format!("{field} {op_sql} {ph}"))
131            };
132        }
133
134        if operator.is_case_insensitive() {
135            let s = value.as_str().ok_or_else(|| {
136                FraiseQLError::validation("Case-insensitive operators require string values")
137            })?;
138            return self.generate_case_insensitive_where_parameterized(field, operator, s, params);
139        }
140
141        let ph = self.emit_value_param(value, params);
142        Ok(format!("{field} {op_sql} {ph}"))
143    }
144
145    /// Parameterized WHERE for a JSONB dimension field.
146    pub(super) fn generate_jsonb_where_parameterized(
147        &self,
148        jsonb_column: &str,
149        path: &[String],
150        operator: &WhereOperator,
151        value: &serde_json::Value,
152        params: &mut Vec<serde_json::Value>,
153    ) -> Result<String> {
154        let field_path = &path[0];
155        let db_field_path = to_snake_case(field_path);
156        let jsonb_extract = self.jsonb_extract_sql(jsonb_column, &db_field_path);
157        let op_sql = self.operator_to_sql(operator);
158
159        if matches!(operator, WhereOperator::IsNull) {
160            return Ok(format!("{jsonb_extract} IS NULL"));
161        }
162
163        if operator.is_case_insensitive() {
164            let s = value.as_str().ok_or_else(|| {
165                FraiseQLError::validation("Case-insensitive operators require string values")
166            })?;
167            return self.generate_case_insensitive_where_parameterized(
168                &jsonb_extract,
169                operator,
170                s,
171                params,
172            );
173        }
174
175        if matches!(operator, WhereOperator::In | WhereOperator::Nin) {
176            let arr = value.as_array().ok_or_else(|| {
177                FraiseQLError::validation("IN/NOT IN operators require array values")
178            })?;
179            let phs: Vec<String> = arr.iter().map(|v| self.emit_value_param(v, params)).collect();
180            return Ok(format!("{jsonb_extract} {op_sql} ({})", phs.join(", ")));
181        }
182
183        if matches!(
184            operator,
185            WhereOperator::Contains | WhereOperator::Startswith | WhereOperator::Endswith
186        ) {
187            let s = value
188                .as_str()
189                .ok_or_else(|| FraiseQLError::validation("LIKE operators require string values"))?;
190            // needs_escape is always true for semantic LIKE operators (Contains etc.)
191            let (ph, _) = self.emit_like_pattern_param(operator, s, params);
192            return Ok(format!("{jsonb_extract} {op_sql} {ph} ESCAPE '!'"));
193        }
194
195        let ph = self.emit_value_param(value, params);
196        Ok(format!("{jsonb_extract} {op_sql} {ph}"))
197    }
198
199    /// Parameterized case-insensitive WHERE (ILIKE for PostgreSQL, `UPPER()` for others).
200    pub(super) fn generate_case_insensitive_where_parameterized(
201        &self,
202        column: &str,
203        operator: &WhereOperator,
204        value_str: &str,
205        params: &mut Vec<serde_json::Value>,
206    ) -> Result<String> {
207        let op = self.operator_to_sql(operator);
208        if self.database_type == DatabaseType::PostgreSQL {
209            let (ph, needs_escape) = self.emit_like_pattern_param(operator, value_str, params);
210            Ok(if needs_escape {
211                format!("{column} {op} {ph} ESCAPE '!'")
212            } else {
213                format!("{column} {op} {ph}")
214            })
215        } else {
216            let upper = value_str.to_uppercase();
217            let (ph, needs_escape) = self.emit_like_pattern_param(operator, &upper, params);
218            Ok(if needs_escape {
219                format!("UPPER({column}) LIKE {ph} ESCAPE '!'")
220            } else {
221                format!("UPPER({column}) LIKE {ph}")
222            })
223        }
224    }
225
226    /// Build a parameterized `WHERE …` clause, or an empty string if the clause is empty.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if WHERE clause generation fails.
231    pub fn build_where_clause_parameterized(
232        &self,
233        where_clause: &WhereClause,
234        metadata: &FactTableMetadata,
235        params: &mut Vec<serde_json::Value>,
236    ) -> Result<String> {
237        if where_clause.is_empty() {
238            return Ok(String::new());
239        }
240        let cond = self.where_clause_to_sql_parameterized(where_clause, metadata, params)?;
241        Ok(format!("WHERE {cond}"))
242    }
243
244    /// Build a parameterized `HAVING …` clause.
245    ///
246    /// # Errors
247    ///
248    /// Returns an error if HAVING clause generation fails.
249    pub(super) fn build_having_clause_parameterized(
250        &self,
251        having_conditions: &[ValidatedHavingCondition],
252        params: &mut Vec<serde_json::Value>,
253    ) -> Result<String> {
254        if having_conditions.is_empty() {
255            return Ok(String::new());
256        }
257        let mut conditions = Vec::new();
258        for condition in having_conditions {
259            let aggregate_sql = self.aggregate_expression_to_sql(&condition.aggregate)?;
260            let operator_sql = condition.operator.sql_operator();
261            let value_sql = self.emit_value_param(&condition.value, params);
262            conditions.push(format!("{aggregate_sql} {operator_sql} {value_sql}"));
263        }
264        Ok(format!("HAVING {}", conditions.join(" AND ")))
265    }
266}