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    compiler::{
16        aggregation::{AggregateExpression, AggregationPlan, GroupByExpression},
17        fact_table::{FactTableMetadata, PartialPeriodConfig, TemporalGrain},
18    },
19    db::where_clause::WhereClause,
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                &plan.request.table_name,
74                &config.time_grain_column,
75                None, // no DATE_TRUNC — already period-aligned
76                gte,
77                lt,
78                extra_where,
79                &plan.metadata,
80                &mut params,
81            )?;
82            branches.push(sql);
83        }
84
85        // B3: current in-progress period (fine-grain)
86        let (gte, lt) = branch_plan.current_period;
87        let sql = self.build_branch(
88            plan,
89            &config.fine_grain_view,
90            &config.time_grain_column,
91            Some(config.time_grain_trunc),
92            gte,
93            lt,
94            extra_where,
95            &plan.metadata,
96            &mut params,
97        )?;
98        branches.push(sql);
99
100        // Assemble UNION ALL
101        let union_sql = branches.join("\nUNION ALL\n");
102
103        Ok(PartialPeriodSql {
104            sql: union_sql,
105            params,
106        })
107    }
108
109    /// Builds a single branch of the UNION ALL query.
110    ///
111    /// # Arguments
112    ///
113    /// * `plan` — the original aggregation plan (for GROUP BY / aggregate expressions)
114    /// * `view_name` — the view to query (fine-grain or coarse-grain)
115    /// * `time_col` — the time-grain column name
116    /// * `trunc_grain` — if `Some`, apply `DATE_TRUNC` to `time_col` in GROUP BY (fine-grain); if
117    ///   `None`, use `time_col` directly (coarse-grain, already period-aligned)
118    /// * `date_gte` — inclusive lower bound of the date range
119    /// * `date_lt` — exclusive upper bound of the date range
120    /// * `extra_where` — additional WHERE conditions (e.g., tenant filter)
121    /// * `metadata` — fact table metadata for WHERE clause generation
122    /// * `params` — shared parameter vec (appended to by this call)
123    #[allow(clippy::too_many_arguments)] // Reason: branch building has inherent parameter complexity
124    fn build_branch(
125        &self,
126        plan: &AggregationPlan,
127        view_name: &str,
128        time_col: &str,
129        trunc_grain: Option<TemporalGrain>,
130        date_gte: NaiveDate,
131        date_lt: NaiveDate,
132        extra_where: Option<&WhereClause>,
133        metadata: &FactTableMetadata,
134        params: &mut Vec<serde_json::Value>,
135    ) -> Result<String> {
136        // Build SELECT clause — may need to rewrite temporal bucket expressions
137        let select_sql = self.build_branch_select(
138            &plan.group_by_expressions,
139            &plan.aggregate_expressions,
140            time_col,
141            trunc_grain,
142        )?;
143
144        let from_sql = format!("FROM {view_name}");
145
146        // Build WHERE: date range + extra conditions
147        let where_sql =
148            self.build_branch_where(time_col, date_gte, date_lt, extra_where, metadata, params)?;
149
150        // Build GROUP BY — may need rewritten temporal expressions
151        let group_sql = if !plan.group_by_expressions.is_empty() {
152            self.build_branch_group_by(&plan.group_by_expressions, time_col, trunc_grain)?
153        } else {
154            String::new()
155        };
156
157        let mut parts: Vec<&str> = vec![&select_sql, &from_sql, &where_sql, &group_sql];
158        parts.retain(|s| !s.is_empty());
159
160        Ok(parts.join("\n"))
161    }
162
163    /// Builds the SELECT clause for a branch, replacing the temporal bucket expression
164    /// for the time-grain column with `DATE_TRUNC` when `trunc_grain` is `Some`.
165    fn build_branch_select(
166        &self,
167        group_by_expressions: &[GroupByExpression],
168        aggregate_expressions: &[AggregateExpression],
169        time_col: &str,
170        trunc_grain: Option<TemporalGrain>,
171    ) -> Result<String> {
172        let mut columns = Vec::new();
173
174        for expr in group_by_expressions {
175            let alias = group_by_alias(expr);
176            let sql = self.branch_group_by_expr_sql(expr, time_col, trunc_grain)?;
177            columns.push(format!("{sql} AS {alias}"));
178        }
179
180        for expr in aggregate_expressions {
181            let column = self.aggregate_expression_to_sql(expr)?;
182            let alias = aggregate_alias(expr);
183            columns.push(format!("{column} AS {alias}"));
184        }
185
186        Ok(format!("SELECT\n  {}", columns.join(",\n  ")))
187    }
188
189    /// Builds the WHERE clause for a branch: date range + extra conditions.
190    fn build_branch_where(
191        &self,
192        time_col: &str,
193        date_gte: NaiveDate,
194        date_lt: NaiveDate,
195        extra_where: Option<&WhereClause>,
196        metadata: &FactTableMetadata,
197        params: &mut Vec<serde_json::Value>,
198    ) -> Result<String> {
199        let gte_param =
200            self.emit_value_param(&serde_json::Value::String(date_gte.to_string()), params);
201        let lt_param =
202            self.emit_value_param(&serde_json::Value::String(date_lt.to_string()), params);
203
204        let quoted_col = self.quote_identifier(time_col);
205        let mut where_parts = vec![format!(
206            "{quoted_col} >= {gte_param} AND {quoted_col} < {lt_param}"
207        )];
208
209        if let Some(wc) = extra_where {
210            let extra_sql = self.where_clause_to_sql_parameterized(wc, metadata, params)?;
211            if !extra_sql.is_empty() {
212                where_parts.push(extra_sql);
213            }
214        }
215
216        Ok(format!("WHERE {}", where_parts.join(" AND ")))
217    }
218
219    /// Builds GROUP BY clause for a branch, with possible temporal rewrite.
220    fn build_branch_group_by(
221        &self,
222        group_by_expressions: &[GroupByExpression],
223        time_col: &str,
224        trunc_grain: Option<TemporalGrain>,
225    ) -> Result<String> {
226        let mut exprs = Vec::new();
227        for expr in group_by_expressions {
228            exprs.push(self.branch_group_by_expr_sql(expr, time_col, trunc_grain)?);
229        }
230        Ok(format!("GROUP BY {}", exprs.join(", ")))
231    }
232
233    /// Converts a single GROUP BY expression to SQL for a branch.
234    ///
235    /// For fine-grain branches (`trunc_grain` is `Some`), temporal bucket expressions
236    /// on the time-grain column are rewritten to use `DATE_TRUNC` at the coarse grain.
237    /// For coarse branches (`trunc_grain` is `None`), the original expression is used.
238    fn branch_group_by_expr_sql(
239        &self,
240        expr: &GroupByExpression,
241        time_col: &str,
242        trunc_grain: Option<TemporalGrain>,
243    ) -> Result<String> {
244        match (expr, trunc_grain) {
245            // Fine-grain branch: rewrite TemporalBucket on the time-grain column
246            // to use DATE_TRUNC at the coarse grain level.
247            (GroupByExpression::TemporalBucket { column, .. }, Some(grain))
248                if column == time_col =>
249            {
250                Ok(self.temporal_bucket_sql(column, grain.to_temporal_bucket()))
251            },
252
253            // Fine-grain branch: rewrite NativeColumn on the time-grain column
254            (GroupByExpression::NativeColumn { column, .. }, Some(grain)) if column == time_col => {
255                Ok(self.temporal_bucket_sql(column, grain.to_temporal_bucket()))
256            },
257
258            // Coarse branch: temporal bucket on the time-grain column uses
259            // the column directly — data is already period-aligned.
260            (
261                GroupByExpression::TemporalBucket { column, .. }
262                | GroupByExpression::NativeColumn { column, .. },
263                None,
264            ) if column == time_col => Ok(self.quote_identifier(column)),
265
266            // All other expressions: use standard conversion
267            _ => self.group_by_expression_to_sql(expr),
268        }
269    }
270}
271
272/// Extracts the alias from a `GroupByExpression`.
273fn group_by_alias(expr: &GroupByExpression) -> &str {
274    match expr {
275        GroupByExpression::JsonbPath { alias, .. }
276        | GroupByExpression::TemporalBucket { alias, .. }
277        | GroupByExpression::CalendarPath { alias, .. }
278        | GroupByExpression::NativeColumn { alias, .. } => alias,
279    }
280}
281
282/// Extracts the alias from an `AggregateExpression`.
283fn aggregate_alias(expr: &AggregateExpression) -> &str {
284    match expr {
285        AggregateExpression::Count { alias }
286        | AggregateExpression::CountDistinct { alias, .. }
287        | AggregateExpression::MeasureAggregate { alias, .. }
288        | AggregateExpression::AdvancedAggregate { alias, .. }
289        | AggregateExpression::BoolAggregate { alias, .. } => alias,
290    }
291}