Skip to main content

fraiseql_core/runtime/aggregation/
partial_period_builder.rs

1//! UNION ALL SQL builder for partial-period aggregation.
2//!
3//! Generates a parameterized UNION ALL query combining up to 3 branches:
4//! - **Branch 1** (partial leading): fine-grain rows with `DATE_TRUNC` re-aggregation
5//! - **Branch 2** (complete middle): coarse-grain rows (already period-aligned)
6//! - **Branch 3** (current period): fine-grain rows with `DATE_TRUNC` re-aggregation
7//!
8//! All date range conditions are parameterized (`$N`). Extra WHERE conditions (non-date
9//! filters like tenant isolation) are passed through to every branch.
10
11use chrono::NaiveDate;
12
13use super::{AggregationSqlGenerator, Result};
14use crate::{
15    backend::where_clause::WhereClause,
16    compiler::{
17        aggregation::{AggregateExpression, AggregationPlan, GroupByExpression},
18        fact_table::{FactTableMetadata, PartialPeriodConfig, TemporalGrain},
19    },
20    runtime::partial_period::BranchPlan,
21};
22
23/// Assembled UNION ALL query with bind parameters for partial-period aggregation.
24#[derive(Debug, Clone)]
25pub struct PartialPeriodSql {
26    /// The complete UNION ALL SQL query.
27    pub sql:    String,
28    /// All bind parameters in placeholder order across all branches.
29    pub params: Vec<serde_json::Value>,
30}
31
32impl AggregationSqlGenerator {
33    /// Generates a UNION ALL query for partial-period aggregation.
34    ///
35    /// Combines fine-grain and coarse-grain branches according to the `BranchPlan`.
36    /// Each branch gets the same SELECT columns and aggregate expressions, but
37    /// fine-grain branches apply `DATE_TRUNC` to the time-grain column while coarse
38    /// branches use it directly.
39    ///
40    /// # Errors
41    ///
42    /// Returns error if SQL expression generation fails.
43    pub fn generate_partial_period(
44        &self,
45        plan: &AggregationPlan,
46        config: &PartialPeriodConfig,
47        branch_plan: &BranchPlan,
48        extra_where: Option<&WhereClause>,
49    ) -> Result<PartialPeriodSql> {
50        let mut params: Vec<serde_json::Value> = Vec::new();
51        let mut branches: Vec<String> = Vec::new();
52
53        // B1: partial leading period (fine-grain)
54        if let Some((gte, lt)) = branch_plan.partial_leading {
55            let sql = self.build_branch(
56                plan,
57                &config.fine_grain_view,
58                &config.time_grain_column,
59                Some(config.time_grain_trunc),
60                gte,
61                lt,
62                extra_where,
63                &plan.metadata,
64                &mut params,
65            )?;
66            branches.push(sql);
67        }
68
69        // B2: complete middle periods (coarse-grain, original view)
70        if let Some((gte, lt)) = branch_plan.complete_middle {
71            let sql = self.build_branch(
72                plan,
73                // SECURITY (#795): the resolved fact table, not the request's `table` key.
74                &plan.metadata.table_name,
75                &config.time_grain_column,
76                None, // no DATE_TRUNC — already period-aligned
77                gte,
78                lt,
79                extra_where,
80                &plan.metadata,
81                &mut params,
82            )?;
83            branches.push(sql);
84        }
85
86        // B3: current in-progress period (fine-grain)
87        let (gte, lt) = branch_plan.current_period;
88        let sql = self.build_branch(
89            plan,
90            &config.fine_grain_view,
91            &config.time_grain_column,
92            Some(config.time_grain_trunc),
93            gte,
94            lt,
95            extra_where,
96            &plan.metadata,
97            &mut params,
98        )?;
99        branches.push(sql);
100
101        // Assemble UNION ALL
102        let union_sql = branches.join("\nUNION ALL\n");
103
104        Ok(PartialPeriodSql {
105            sql: union_sql,
106            params,
107        })
108    }
109
110    /// Builds a single branch of the UNION ALL query.
111    ///
112    /// # Arguments
113    ///
114    /// * `plan` — the original aggregation plan (for GROUP BY / aggregate expressions)
115    /// * `view_name` — the view to query (fine-grain or coarse-grain)
116    /// * `time_col` — the time-grain column name
117    /// * `trunc_grain` — if `Some`, apply `DATE_TRUNC` to `time_col` in GROUP BY (fine-grain); if
118    ///   `None`, use `time_col` directly (coarse-grain, already period-aligned)
119    /// * `date_gte` — inclusive lower bound of the date range
120    /// * `date_lt` — exclusive upper bound of the date range
121    /// * `extra_where` — additional WHERE conditions (e.g., tenant filter)
122    /// * `metadata` — fact table metadata for WHERE clause generation
123    /// * `params` — shared parameter vec (appended to by this call)
124    #[allow(clippy::too_many_arguments)] // Reason: branch building has inherent parameter complexity
125    fn build_branch(
126        &self,
127        plan: &AggregationPlan,
128        view_name: &str,
129        time_col: &str,
130        trunc_grain: Option<TemporalGrain>,
131        date_gte: NaiveDate,
132        date_lt: NaiveDate,
133        extra_where: Option<&WhereClause>,
134        metadata: &FactTableMetadata,
135        params: &mut Vec<serde_json::Value>,
136    ) -> Result<String> {
137        // Build SELECT clause — may need to rewrite temporal bucket expressions
138        let select_sql = self.build_branch_select(
139            &plan.group_by_expressions,
140            &plan.aggregate_expressions,
141            time_col,
142            trunc_grain,
143        )?;
144
145        let from_sql = format!("FROM {view_name}");
146
147        // Build WHERE: date range + extra conditions
148        let where_sql =
149            self.build_branch_where(time_col, date_gte, date_lt, extra_where, metadata, params)?;
150
151        // Build GROUP BY — may need rewritten temporal expressions
152        let group_sql = if !plan.group_by_expressions.is_empty() {
153            self.build_branch_group_by(&plan.group_by_expressions, time_col, trunc_grain)?
154        } else {
155            String::new()
156        };
157
158        let mut parts: Vec<&str> = vec![&select_sql, &from_sql, &where_sql, &group_sql];
159        parts.retain(|s| !s.is_empty());
160
161        Ok(parts.join("\n"))
162    }
163
164    /// Builds the SELECT clause for a branch, replacing the temporal bucket expression
165    /// for the time-grain column with `DATE_TRUNC` when `trunc_grain` is `Some`.
166    fn build_branch_select(
167        &self,
168        group_by_expressions: &[GroupByExpression],
169        aggregate_expressions: &[AggregateExpression],
170        time_col: &str,
171        trunc_grain: Option<TemporalGrain>,
172    ) -> Result<String> {
173        let mut columns = Vec::new();
174
175        for expr in group_by_expressions {
176            let alias = group_by_alias(expr);
177            let sql = self.branch_group_by_expr_sql(expr, time_col, trunc_grain)?;
178            columns.push(format!("{sql} AS {alias}"));
179        }
180
181        for expr in aggregate_expressions {
182            let column = self.aggregate_expression_to_sql(expr)?;
183            let alias = aggregate_alias(expr);
184            columns.push(format!("{column} AS {alias}"));
185        }
186
187        Ok(format!("SELECT\n  {}", columns.join(",\n  ")))
188    }
189
190    /// Builds the WHERE clause for a branch: date range + extra conditions.
191    fn build_branch_where(
192        &self,
193        time_col: &str,
194        date_gte: NaiveDate,
195        date_lt: NaiveDate,
196        extra_where: Option<&WhereClause>,
197        metadata: &FactTableMetadata,
198        params: &mut Vec<serde_json::Value>,
199    ) -> Result<String> {
200        let gte_param =
201            self.emit_value_param(&serde_json::Value::String(date_gte.to_string()), params);
202        let lt_param =
203            self.emit_value_param(&serde_json::Value::String(date_lt.to_string()), params);
204
205        let quoted_col = self.quote_identifier(time_col);
206        let mut where_parts = vec![format!(
207            "{quoted_col} >= {gte_param} AND {quoted_col} < {lt_param}"
208        )];
209
210        if let Some(wc) = extra_where {
211            let extra_sql = self.where_clause_to_sql_parameterized(wc, metadata, params)?;
212            if !extra_sql.is_empty() {
213                where_parts.push(extra_sql);
214            }
215        }
216
217        Ok(format!("WHERE {}", where_parts.join(" AND ")))
218    }
219
220    /// Builds GROUP BY clause for a branch, with possible temporal rewrite.
221    fn build_branch_group_by(
222        &self,
223        group_by_expressions: &[GroupByExpression],
224        time_col: &str,
225        trunc_grain: Option<TemporalGrain>,
226    ) -> Result<String> {
227        let mut exprs = Vec::new();
228        for expr in group_by_expressions {
229            exprs.push(self.branch_group_by_expr_sql(expr, time_col, trunc_grain)?);
230        }
231        Ok(format!("GROUP BY {}", exprs.join(", ")))
232    }
233
234    /// Converts a single GROUP BY expression to SQL for a branch.
235    ///
236    /// For fine-grain branches (`trunc_grain` is `Some`), temporal bucket expressions
237    /// on the time-grain column are rewritten to use `DATE_TRUNC` at the coarse grain.
238    /// For coarse branches (`trunc_grain` is `None`), the original expression is used.
239    fn branch_group_by_expr_sql(
240        &self,
241        expr: &GroupByExpression,
242        time_col: &str,
243        trunc_grain: Option<TemporalGrain>,
244    ) -> Result<String> {
245        match (expr, trunc_grain) {
246            // Fine-grain branch: rewrite TemporalBucket on the time-grain column
247            // to use DATE_TRUNC at the coarse grain level.
248            (GroupByExpression::TemporalBucket { column, .. }, Some(grain))
249                if column == time_col =>
250            {
251                Ok(self.temporal_bucket_sql(column, grain.to_temporal_bucket()))
252            },
253
254            // Fine-grain branch: rewrite NativeColumn on the time-grain column
255            (GroupByExpression::NativeColumn { column, .. }, Some(grain)) if column == time_col => {
256                Ok(self.temporal_bucket_sql(column, grain.to_temporal_bucket()))
257            },
258
259            // Coarse branch: temporal bucket on the time-grain column uses
260            // the column directly — data is already period-aligned.
261            (
262                GroupByExpression::TemporalBucket { column, .. }
263                | GroupByExpression::NativeColumn { column, .. },
264                None,
265            ) if column == time_col => Ok(self.quote_identifier(column)),
266
267            // All other expressions: use standard conversion
268            _ => self.group_by_expression_to_sql(expr),
269        }
270    }
271}
272
273/// Extracts the alias from a `GroupByExpression`.
274fn group_by_alias(expr: &GroupByExpression) -> &str {
275    match expr {
276        GroupByExpression::JsonbPath { alias, .. }
277        | GroupByExpression::TemporalBucket { alias, .. }
278        | GroupByExpression::CalendarPath { alias, .. }
279        | GroupByExpression::TreeLevel { alias, .. }
280        | GroupByExpression::NativeColumn { alias, .. } => alias,
281    }
282}
283
284/// Extracts the alias from an `AggregateExpression`.
285fn aggregate_alias(expr: &AggregateExpression) -> &str {
286    match expr {
287        AggregateExpression::Count { alias }
288        | AggregateExpression::CountDistinct { alias, .. }
289        | AggregateExpression::MeasureAggregate { alias, .. }
290        | AggregateExpression::AdvancedAggregate { alias, .. }
291        | AggregateExpression::BoolAggregate { alias, .. } => alias,
292    }
293}