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    backend::{
56        identifier::quote_postgres_identifier,
57        path_escape::escape_postgres_jsonb_segment,
58        types::DatabaseType,
59        where_clause::{WhereClause, WhereOperator},
60    },
61    compiler::{
62        aggregate_types::{AggregateFunction, TemporalBucket},
63        aggregation::{
64            AggregateExpression, AggregationPlan, GroupByExpression, OrderByClause, OrderDirection,
65            ValidatedHavingCondition,
66        },
67        fact_table::FactTableMetadata,
68    },
69    error::{FraiseQLError, Result},
70    utils::casing::to_snake_case,
71};
72
73mod expressions;
74pub(crate) mod partial_period_builder;
75mod where_clause;
76
77#[cfg(test)]
78mod tests;
79
80/// Aggregate query with bind parameters instead of escaped string literals.
81///
82/// Produced by [`AggregationSqlGenerator::generate_parameterized`].  Pass `sql`
83/// and `params` directly to [`crate::backend::DatabaseAdapter::execute_parameterized_aggregate`].
84#[derive(Debug, Clone)]
85pub struct ParameterizedAggregationSql {
86    /// SQL with `$N` (PostgreSQL), `?` (MySQL / SQLite), or `@P1` (SQL Server) placeholders.
87    pub sql:    String,
88    /// Bind parameters in placeholder order.
89    pub params: Vec<serde_json::Value>,
90}
91
92/// Aggregation SQL generator
93pub struct AggregationSqlGenerator {
94    database_type: DatabaseType,
95}
96
97impl AggregationSqlGenerator {
98    /// Create new SQL generator for specific database
99    #[must_use]
100    pub const fn new(database_type: DatabaseType) -> Self {
101        Self { database_type }
102    }
103
104    /// Generate JSONB extraction SQL with per-database path escaping.
105    ///
106    /// Each database uses a different string literal syntax for JSON paths.
107    /// Single quotes or other metacharacters in `path` could otherwise break
108    /// out of the string literal and inject arbitrary SQL. The per-database
109    /// escape functions from `fraiseql_db::path_escape` are applied here as
110    /// a second line of defence after schema allowlist validation in the planner.
111    pub(super) fn jsonb_extract_sql(&self, jsonb_column: &str, path: &str) -> String {
112        match self.database_type {
113            DatabaseType::PostgreSQL => {
114                let escaped = escape_postgres_jsonb_segment(path);
115                format!("{}->>'{}' ", jsonb_column, escaped)
116            },
117        }
118    }
119
120    /// Convert `WhereOperator` to SQL operator.
121    ///
122    /// # Errors
123    ///
124    /// `FraiseQLError::Validation` for an operator this generator does not implement. It used
125    /// to fall through to `=`, so `descendantOf "a.b"` matched only the row whose path *is*
126    /// `a.b` and the aggregate was computed over the wrong rows with no error (#1460).
127    pub(super) fn operator_to_sql(&self, operator: &WhereOperator) -> Result<&'static str> {
128        Ok(match operator {
129            WhereOperator::Eq => "=",
130            WhereOperator::Neq => "!=",
131            WhereOperator::Gt => ">",
132            WhereOperator::Gte => ">=",
133            WhereOperator::Lt => "<",
134            WhereOperator::Lte => "<=",
135            WhereOperator::In => "IN",
136            WhereOperator::Nin => "NOT IN",
137            WhereOperator::Like
138            | WhereOperator::Contains
139            | WhereOperator::Startswith
140            | WhereOperator::Endswith => "LIKE",
141            WhereOperator::Ilike
142            | WhereOperator::Icontains
143            | WhereOperator::Istartswith
144            | WhereOperator::Iendswith => match self.database_type {
145                DatabaseType::PostgreSQL => "ILIKE",
146            },
147            // Reason: `WhereOperator` is non_exhaustive across crates. Refusing is the safe
148            // default for an operator added later, too.
149            other => {
150                return Err(FraiseQLError::validation(format!(
151                    "operator {other:?} is not supported in a fact-table aggregate filter"
152                )));
153            },
154        })
155    }
156
157    /// Quote a validated field alias/column name using the database-appropriate identifier syntax.
158    ///
159    /// Field names arrive here after `OrderByClause::validate_field_name` has verified they
160    /// match `[_A-Za-z][_0-9A-Za-z]*`, so no delimiter-escaping is required — but quoting
161    /// still protects against SQL reserved words (`order`, `count`, `group`, `select`, …)
162    /// that would break unquoted ORDER BY clauses.
163    pub(super) fn quote_identifier(&self, name: &str) -> String {
164        match self.database_type {
165            // PostgreSQL and SQLite both use double-quote syntax.
166            DatabaseType::PostgreSQL => quote_postgres_identifier(name),
167        }
168    }
169
170    /// Escape a string value for embedding inside a SQL string literal.
171    ///
172    /// MySQL treats backslash as an escape character in string literals by default
173    /// (unless `NO_BACKSLASH_ESCAPES` `sql_mode` is set). Backslashes must be doubled
174    /// before single-quote escaping to prevent injection via sequences like `\';`.
175    ///
176    /// Null bytes (`\x00`) are stripped before escaping. PostgreSQL rejects null
177    /// bytes in string literals with "invalid byte sequence for encoding", which
178    /// would surface as a confusing database error. Stripping them produces
179    /// deterministic SQL regardless of the database's null-byte handling.
180    pub(super) fn escape_sql_string(&self, s: &str) -> String {
181        // Strip null bytes — never valid in SQL string literals.
182        let without_nulls: std::borrow::Cow<str> = if s.contains('\0') {
183            s.replace('\0', "").into()
184        } else {
185            s.into()
186        };
187        // Standard SQL: only double single quotes.
188        without_nulls.replace('\'', "''")
189    }
190
191    /// Returns the bind-parameter placeholder for position `index` (0-based).
192    pub(super) fn placeholder(&self, index: usize) -> String {
193        match self.database_type {
194            DatabaseType::PostgreSQL => format!("${}", index + 1),
195        }
196    }
197
198    /// If `value` is non-NULL, appends it to `params` and returns the placeholder.
199    ///
200    /// `NULL` is emitted inline as the literal `NULL`; it cannot be reliably
201    /// parameterized across all four database drivers in the same way.
202    pub(super) fn emit_value_param(
203        &self,
204        value: &serde_json::Value,
205        params: &mut Vec<serde_json::Value>,
206    ) -> String {
207        if matches!(value, serde_json::Value::Null) {
208            return "NULL".to_string();
209        }
210        let idx = params.len();
211        params.push(value.clone());
212        self.placeholder(idx)
213    }
214
215    /// Build a LIKE pattern string, escape LIKE metacharacters with `!`, bind it as a
216    /// parameter, and return the placeholder.  Returns `(placeholder, needs_escape_clause)`
217    /// where `needs_escape_clause` indicates whether `ESCAPE '!'` should be appended to
218    /// the SQL fragment.
219    pub(super) fn emit_like_pattern_param(
220        &self,
221        operator: &WhereOperator,
222        value: &str,
223        params: &mut Vec<serde_json::Value>,
224    ) -> (String, bool) {
225        // Strip null bytes before binding (same invariant as escape_sql_string).
226        let clean: String = if value.contains('\0') {
227            value.replace('\0', "")
228        } else {
229            value.to_string()
230        };
231
232        let (pattern, needs_escape) = match operator {
233            WhereOperator::Contains | WhereOperator::Icontains => {
234                let esc = clean.replace('!', "!!").replace('%', "!%").replace('_', "!_");
235                (format!("%{esc}%"), true)
236            },
237            WhereOperator::Startswith | WhereOperator::Istartswith => {
238                let esc = clean.replace('!', "!!").replace('%', "!%").replace('_', "!_");
239                (format!("{esc}%"), true)
240            },
241            WhereOperator::Endswith | WhereOperator::Iendswith => {
242                let esc = clean.replace('!', "!!").replace('%', "!%").replace('_', "!_");
243                (format!("%{esc}"), true)
244            },
245            // Like / Ilike: caller controls wildcards — bind as-is.
246            _ => (clean, false),
247        };
248
249        let ph = self.emit_value_param(&serde_json::Value::String(pattern), params);
250        (ph, needs_escape)
251    }
252
253    /// Generate a parameterized aggregate SQL query.
254    ///
255    /// All user-supplied string values in `WHERE` and `HAVING` clauses are emitted as
256    /// bind placeholders (`$N` / `?` / `@P1` depending on the database dialect) rather
257    /// than being embedded as escaped string literals.  Numeric, boolean, and `NULL`
258    /// values are still inlined since they carry no injection risk.
259    ///
260    /// # Errors
261    ///
262    /// Returns error if SQL generation fails (unknown aggregate function, etc.).
263    pub fn generate_parameterized(
264        &self,
265        plan: &AggregationPlan,
266    ) -> Result<ParameterizedAggregationSql> {
267        let mut params: Vec<serde_json::Value> = Vec::new();
268
269        let select_sql =
270            self.build_select_clause(&plan.group_by_expressions, &plan.aggregate_expressions)?;
271        // SECURITY (#795): emit the *resolved* fact table, never the request's `table` key.
272        // `AggregationPlanner::plan` rejects a mismatch; sourcing the name from metadata
273        // here means a future planner change cannot silently re-open the sink.
274        let from_sql = format!("FROM {}", plan.metadata.table_name);
275
276        let where_sql = if let Some(ref wc) = plan.request.where_clause {
277            self.build_where_clause_parameterized(wc, &plan.metadata, &mut params)?
278        } else {
279            String::new()
280        };
281
282        let group_sql = if !plan.group_by_expressions.is_empty() {
283            self.build_group_by_clause(&plan.group_by_expressions)?
284        } else {
285            String::new()
286        };
287
288        let having_sql =
289            self.build_having_clause_parameterized(&plan.having_conditions, &mut params)?;
290
291        let native_aliases = plan.native_aliases();
292        let order_sql = if !plan.request.order_by.is_empty() {
293            self.build_order_by_clause(&plan.request.order_by, &native_aliases)?
294        } else {
295            String::new()
296        };
297
298        let mut parts: Vec<&str> = vec![
299            &select_sql,
300            &from_sql,
301            &where_sql,
302            &group_sql,
303            &having_sql,
304            &order_sql,
305        ];
306        parts.retain(|s| !s.is_empty());
307
308        let mut sql = parts.join("\n");
309
310        if let Some(limit) = plan.request.limit {
311            let _ = write!(sql, "\nLIMIT {limit}");
312        }
313        if let Some(offset) = plan.request.offset {
314            let _ = write!(sql, "\nOFFSET {offset}");
315        }
316
317        Ok(ParameterizedAggregationSql { sql, params })
318    }
319}