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}