Skip to main content

fraiseql_core/runtime/aggregation/
mod.rs

1//! Runtime Aggregation SQL Generation Module
2//!
3//! This module generates database-specific SQL from aggregation execution plans.
4//!
5//! # Database-Specific SQL
6//!
7//! ## PostgreSQL
8//! ```sql
9//! SELECT
10//!   data->>'category' AS category,
11//!   DATE_TRUNC('day', occurred_at) AS occurred_at_day,
12//!   COUNT(*) AS count,
13//!   SUM(revenue) AS revenue_sum
14//! FROM tf_sales
15//! WHERE customer_id = $1
16//! GROUP BY data->>'category', DATE_TRUNC('day', occurred_at)
17//! HAVING SUM(revenue) > $2
18//! ORDER BY revenue_sum DESC
19//! LIMIT 10
20//! ```
21//!
22//! ## MySQL
23//! ```sql
24//! SELECT
25//!   JSON_UNQUOTE(JSON_EXTRACT(data, '$.category')) AS category,
26//!   DATE_FORMAT(occurred_at, '%Y-%m-%d') AS occurred_at_day,
27//!   COUNT(*) AS count,
28//!   SUM(revenue) AS revenue_sum
29//! FROM tf_sales
30//! WHERE customer_id = ?
31//! GROUP BY JSON_UNQUOTE(JSON_EXTRACT(data, '$.category')), DATE_FORMAT(occurred_at, '%Y-%m-%d')
32//! HAVING SUM(revenue) > ?
33//! ORDER BY revenue_sum DESC
34//! LIMIT 10
35//! ```
36//!
37//! ## SQLite
38//! ```sql
39//! SELECT
40//!   json_extract(data, '$.category') AS category,
41//!   strftime('%Y-%m-%d', occurred_at) AS occurred_at_day,
42//!   COUNT(*) AS count,
43//!   SUM(revenue) AS revenue_sum
44//! FROM tf_sales
45//! WHERE customer_id = ?
46//! GROUP BY json_extract(data, '$.category'), strftime('%Y-%m-%d', occurred_at)
47//! HAVING SUM(revenue) > ?
48//! ORDER BY revenue_sum DESC
49//! LIMIT 10
50//! ```
51
52use std::fmt::Write as _;
53
54use crate::{
55    compiler::{
56        aggregate_types::{AggregateFunction, TemporalBucket},
57        aggregation::{
58            AggregateExpression, AggregationPlan, GroupByExpression, OrderByClause, OrderDirection,
59            ValidatedHavingCondition,
60        },
61        fact_table::FactTableMetadata,
62    },
63    db::{
64        identifier::{
65            quote_mysql_identifier, quote_postgres_identifier, quote_sqlserver_identifier,
66        },
67        path_escape::{
68            escape_mysql_json_path, escape_postgres_jsonb_segment, escape_sqlite_json_path,
69            escape_sqlserver_json_path,
70        },
71        types::DatabaseType,
72        where_clause::{WhereClause, WhereOperator},
73    },
74    error::{FraiseQLError, Result},
75    utils::casing::to_snake_case,
76};
77
78mod expressions;
79pub(crate) mod partial_period_builder;
80mod where_clause;
81
82#[cfg(test)]
83mod tests;
84
85/// Aggregate query with bind parameters instead of escaped string literals.
86///
87/// Produced by [`AggregationSqlGenerator::generate_parameterized`].  Pass `sql`
88/// and `params` directly to [`crate::db::DatabaseAdapter::execute_parameterized_aggregate`].
89#[derive(Debug, Clone)]
90pub struct ParameterizedAggregationSql {
91    /// SQL with `$N` (PostgreSQL), `?` (MySQL / SQLite), or `@P1` (SQL Server) placeholders.
92    pub sql:    String,
93    /// Bind parameters in placeholder order.
94    pub params: Vec<serde_json::Value>,
95}
96
97/// Aggregation SQL generator
98pub struct AggregationSqlGenerator {
99    database_type: DatabaseType,
100}
101
102impl AggregationSqlGenerator {
103    /// Create new SQL generator for specific database
104    #[must_use]
105    pub const fn new(database_type: DatabaseType) -> Self {
106        Self { database_type }
107    }
108
109    /// Generate JSONB extraction SQL with per-database path escaping.
110    ///
111    /// Each database uses a different string literal syntax for JSON paths.
112    /// Single quotes or other metacharacters in `path` could otherwise break
113    /// out of the string literal and inject arbitrary SQL. The per-database
114    /// escape functions from `fraiseql_db::path_escape` are applied here as
115    /// a second line of defence after schema allowlist validation in the planner.
116    pub(super) fn jsonb_extract_sql(&self, jsonb_column: &str, path: &str) -> String {
117        match self.database_type {
118            DatabaseType::PostgreSQL => {
119                let escaped = escape_postgres_jsonb_segment(path);
120                format!("{}->>'{}' ", jsonb_column, escaped)
121            },
122            DatabaseType::MySQL => {
123                // escape_mysql_json_path returns "$.escaped_segment"
124                let escaped = escape_mysql_json_path(&[path.to_owned()]);
125                format!("JSON_UNQUOTE(JSON_EXTRACT({}, '{}'))", jsonb_column, escaped)
126            },
127            DatabaseType::SQLite => {
128                // escape_sqlite_json_path returns "$.escaped_segment"
129                let escaped = escape_sqlite_json_path(&[path.to_owned()]);
130                format!("json_extract({}, '{}')", jsonb_column, escaped)
131            },
132            DatabaseType::SQLServer => {
133                // escape_sqlserver_json_path returns "$.escaped_segment"
134                let escaped = escape_sqlserver_json_path(&[path.to_owned()]);
135                format!("JSON_VALUE({}, '{}')", jsonb_column, escaped)
136            },
137        }
138    }
139
140    /// Convert `WhereOperator` to SQL operator
141    pub(super) const fn operator_to_sql(&self, operator: &WhereOperator) -> &'static str {
142        match operator {
143            WhereOperator::Neq => "!=",
144            WhereOperator::Gt => ">",
145            WhereOperator::Gte => ">=",
146            WhereOperator::Lt => "<",
147            WhereOperator::Lte => "<=",
148            WhereOperator::In => "IN",
149            WhereOperator::Nin => "NOT IN",
150            WhereOperator::Like
151            | WhereOperator::Contains
152            | WhereOperator::Startswith
153            | WhereOperator::Endswith => "LIKE",
154            WhereOperator::Ilike
155            | WhereOperator::Icontains
156            | WhereOperator::Istartswith
157            | WhereOperator::Iendswith => match self.database_type {
158                DatabaseType::PostgreSQL => "ILIKE",
159                _ => "LIKE", // Other databases use LIKE with UPPER/LOWER
160            },
161            // Eq and any future operators default to equality
162            _ => "=",
163        }
164    }
165
166    /// Quote a validated field alias/column name using the database-appropriate identifier syntax.
167    ///
168    /// Field names arrive here after `OrderByClause::validate_field_name` has verified they
169    /// match `[_A-Za-z][_0-9A-Za-z]*`, so no delimiter-escaping is required — but quoting
170    /// still protects against SQL reserved words (`order`, `count`, `group`, `select`, …)
171    /// that would break unquoted ORDER BY clauses.
172    pub(super) fn quote_identifier(&self, name: &str) -> String {
173        match self.database_type {
174            DatabaseType::MySQL => quote_mysql_identifier(name),
175            DatabaseType::SQLServer => quote_sqlserver_identifier(name),
176            // PostgreSQL and SQLite both use double-quote syntax.
177            DatabaseType::PostgreSQL | DatabaseType::SQLite => quote_postgres_identifier(name),
178        }
179    }
180
181    /// Escape a string value for embedding inside a SQL string literal.
182    ///
183    /// MySQL treats backslash as an escape character in string literals by default
184    /// (unless `NO_BACKSLASH_ESCAPES` `sql_mode` is set). Backslashes must be doubled
185    /// before single-quote escaping to prevent injection via sequences like `\';`.
186    ///
187    /// Null bytes (`\x00`) are stripped before escaping. PostgreSQL rejects null
188    /// bytes in string literals with "invalid byte sequence for encoding", which
189    /// would surface as a confusing database error. Stripping them produces
190    /// deterministic SQL regardless of the database's null-byte handling.
191    pub(super) fn escape_sql_string(&self, s: &str) -> String {
192        // Strip null bytes — never valid in SQL string literals.
193        let without_nulls: std::borrow::Cow<str> = if s.contains('\0') {
194            s.replace('\0', "").into()
195        } else {
196            s.into()
197        };
198        if matches!(self.database_type, DatabaseType::MySQL) {
199            // Escape backslashes first, then single quotes.
200            without_nulls.replace('\\', "\\\\").replace('\'', "''")
201        } else {
202            // Standard SQL: only double single quotes.
203            without_nulls.replace('\'', "''")
204        }
205    }
206
207    /// Returns the bind-parameter placeholder for position `index` (0-based).
208    pub(super) fn placeholder(&self, index: usize) -> String {
209        match self.database_type {
210            DatabaseType::PostgreSQL => format!("${}", index + 1),
211            DatabaseType::SQLServer => format!("@P{}", index + 1),
212            _ => "?".to_string(),
213        }
214    }
215
216    /// If `value` is non-NULL, appends it to `params` and returns the placeholder.
217    ///
218    /// `NULL` is emitted inline as the literal `NULL`; it cannot be reliably
219    /// parameterized across all four database drivers in the same way.
220    pub(super) fn emit_value_param(
221        &self,
222        value: &serde_json::Value,
223        params: &mut Vec<serde_json::Value>,
224    ) -> String {
225        if matches!(value, serde_json::Value::Null) {
226            return "NULL".to_string();
227        }
228        let idx = params.len();
229        params.push(value.clone());
230        self.placeholder(idx)
231    }
232
233    /// Build a LIKE pattern string, escape LIKE metacharacters with `!`, bind it as a
234    /// parameter, and return the placeholder.  Returns `(placeholder, needs_escape_clause)`
235    /// where `needs_escape_clause` indicates whether `ESCAPE '!'` should be appended to
236    /// the SQL fragment.
237    pub(super) fn emit_like_pattern_param(
238        &self,
239        operator: &WhereOperator,
240        value: &str,
241        params: &mut Vec<serde_json::Value>,
242    ) -> (String, bool) {
243        // Strip null bytes before binding (same invariant as escape_sql_string).
244        let clean: String = if value.contains('\0') {
245            value.replace('\0', "")
246        } else {
247            value.to_string()
248        };
249
250        let (pattern, needs_escape) = match operator {
251            WhereOperator::Contains | WhereOperator::Icontains => {
252                let esc = clean.replace('!', "!!").replace('%', "!%").replace('_', "!_");
253                (format!("%{esc}%"), true)
254            },
255            WhereOperator::Startswith | WhereOperator::Istartswith => {
256                let esc = clean.replace('!', "!!").replace('%', "!%").replace('_', "!_");
257                (format!("{esc}%"), true)
258            },
259            WhereOperator::Endswith | WhereOperator::Iendswith => {
260                let esc = clean.replace('!', "!!").replace('%', "!%").replace('_', "!_");
261                (format!("%{esc}"), true)
262            },
263            // Like / Ilike: caller controls wildcards — bind as-is.
264            _ => (clean, false),
265        };
266
267        let ph = self.emit_value_param(&serde_json::Value::String(pattern), params);
268        (ph, needs_escape)
269    }
270
271    /// Generate a parameterized aggregate SQL query.
272    ///
273    /// All user-supplied string values in `WHERE` and `HAVING` clauses are emitted as
274    /// bind placeholders (`$N` / `?` / `@P1` depending on the database dialect) rather
275    /// than being embedded as escaped string literals.  Numeric, boolean, and `NULL`
276    /// values are still inlined since they carry no injection risk.
277    ///
278    /// # Errors
279    ///
280    /// Returns error if SQL generation fails (unknown aggregate function, etc.).
281    pub fn generate_parameterized(
282        &self,
283        plan: &AggregationPlan,
284    ) -> Result<ParameterizedAggregationSql> {
285        let mut params: Vec<serde_json::Value> = Vec::new();
286
287        let select_sql =
288            self.build_select_clause(&plan.group_by_expressions, &plan.aggregate_expressions)?;
289        let from_sql = format!("FROM {}", plan.request.table_name);
290
291        let where_sql = if let Some(ref wc) = plan.request.where_clause {
292            self.build_where_clause_parameterized(wc, &plan.metadata, &mut params)?
293        } else {
294            String::new()
295        };
296
297        let group_sql = if !plan.group_by_expressions.is_empty() {
298            self.build_group_by_clause(&plan.group_by_expressions)?
299        } else {
300            String::new()
301        };
302
303        let having_sql =
304            self.build_having_clause_parameterized(&plan.having_conditions, &mut params)?;
305
306        let native_aliases = plan.native_aliases();
307        let order_sql = if !plan.request.order_by.is_empty() {
308            self.build_order_by_clause(&plan.request.order_by, &native_aliases)?
309        } else {
310            String::new()
311        };
312
313        let mut parts: Vec<&str> = vec![
314            &select_sql,
315            &from_sql,
316            &where_sql,
317            &group_sql,
318            &having_sql,
319            &order_sql,
320        ];
321        parts.retain(|s| !s.is_empty());
322
323        let mut sql = parts.join("\n");
324
325        if let Some(limit) = plan.request.limit {
326            let _ = write!(sql, "\nLIMIT {limit}");
327        }
328        if let Some(offset) = plan.request.offset {
329            let _ = write!(sql, "\nOFFSET {offset}");
330        }
331
332        Ok(ParameterizedAggregationSql { sql, params })
333    }
334}