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}