Skip to main content

server/command/report/
activity.rs

1use chrono::{DateTime, Utc};
2use sqlx::types::Uuid;
3use supp_macro::command;
4
5use crate::{config::ConfigError, user::User};
6
7use super::super::{
8    ActivityData, ActivityGroup, ActivityGroupResult, ActivityPeriod, CmdError, CmdResult,
9    FilterEntity, PeriodGrouping, ReportFilter, ReportMeta,
10};
11use super::fetch::{
12    fetch_accounts, fetch_date_range_splits_filtered_no_conversion,
13    fetch_date_range_splits_filtered_with_conversion, fetch_target_symbol,
14};
15use super::period::boundaries;
16
17/// Queries a single activity report may issue.
18///
19/// The report runs one query per period per group; 4096 is already far past a
20/// readable report and keeps one request off a cluster shared with other
21/// tenants.
22pub(super) const MAX_QUERIES: usize = 4096;
23use super::tree::build_tree;
24
25// TODO(script-classifier): swap this helper for one that evaluates a
26// user-supplied nomiscript expression returning a Vec<ActivityGroup>. See
27// doc/reporting.org "Future work" for the intent.
28fn default_activity_groups() -> Vec<ActivityGroup> {
29    vec![
30        ActivityGroup {
31            label: "Income".to_owned(),
32            filter: ReportFilter::Tag {
33                entity: FilterEntity::Account,
34                name: "type".to_owned(),
35                value: "income".to_owned(),
36            },
37            flip_sign: true,
38        },
39        ActivityGroup {
40            label: "Expense".to_owned(),
41            filter: ReportFilter::Tag {
42                entity: FilterEntity::Account,
43                name: "type".to_owned(),
44                value: "expense".to_owned(),
45            },
46            flip_sign: false,
47        },
48    ]
49}
50
51/// Leaves in the largest query this report will run.
52///
53/// The MAX, not the sum: each query is `And(group.filter, report_filter)` for
54/// ONE group (see [`compose_group_filter`]), so summing across groups measures
55/// a cost no query ever incurs. How MANY groups there are is bounded separately,
56/// by the query budget.
57pub(super) fn widest_query_leaves(
58    report_filter: Option<&ReportFilter>,
59    groups: &[ActivityGroup],
60) -> usize {
61    report_filter.map_or(0, ReportFilter::leaves)
62        + groups.iter().map(|g| g.filter.leaves()).max().unwrap_or(0)
63}
64
65fn compose_group_filter(group: &ActivityGroup, user_filter: Option<&ReportFilter>) -> ReportFilter {
66    match user_filter {
67        Some(f) => ReportFilter::And(vec![group.filter.clone(), f.clone()]),
68        None => group.filter.clone(),
69    }
70}
71
72async fn fetch_range(
73    conn: &mut sqlx::PgConnection,
74    target_commodity_id: Option<Uuid>,
75    target_symbol: Option<&str>,
76    from: DateTime<Utc>,
77    to: DateTime<Utc>,
78    filter: &ReportFilter,
79) -> Result<super::tree::AccountAmounts, CmdError> {
80    match (target_commodity_id, target_symbol) {
81        (Some(tid), Some(sym)) => {
82            fetch_date_range_splits_filtered_with_conversion(conn, tid, sym, from, to, filter).await
83        }
84        _ => fetch_date_range_splits_filtered_no_conversion(conn, from, to, filter).await,
85    }
86}
87
88struct RunCtx<'a> {
89    accounts: &'a [super::tree::AccountRow],
90    user_filter: Option<&'a ReportFilter>,
91    target_commodity_id: Option<Uuid>,
92    target_symbol: Option<&'a str>,
93}
94
95async fn run_group_for_period(
96    conn: &mut sqlx::PgConnection,
97    ctx: &RunCtx<'_>,
98    group: &ActivityGroup,
99    from: DateTime<Utc>,
100    to: DateTime<Utc>,
101) -> Result<ActivityGroupResult, CmdError> {
102    let filter = compose_group_filter(group, ctx.user_filter);
103    let amounts = fetch_range(
104        conn,
105        ctx.target_commodity_id,
106        ctx.target_symbol,
107        from,
108        to,
109        &filter,
110    )
111    .await?;
112    let roots = build_tree(ctx.accounts, &amounts);
113    Ok(ActivityGroupResult {
114        label: group.label.clone(),
115        flip_sign: group.flip_sign,
116        roots,
117    })
118}
119
120command! {
121    ActivityReport {
122        #[required]
123        user_id: Uuid,
124        #[required]
125        date_from: DateTime<Utc>,
126        #[required]
127        date_to: DateTime<Utc>,
128        #[optional]
129        target_commodity_id: Uuid,
130        #[optional]
131        period_grouping: PeriodGrouping,
132        #[optional]
133        groups: Vec<ActivityGroup>,
134        #[optional]
135        report_filter: ReportFilter,
136    } => {
137        let user = User { id: user_id };
138        let mut conn = user.get_connection().await.map_err(|err| {
139            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
140            ConfigError::DB
141        })?;
142
143        let accounts = fetch_accounts(&mut conn).await?;
144        let groups = groups.unwrap_or_else(default_activity_groups);
145
146        let target_symbol = match target_commodity_id {
147            Some(tid) => Some(fetch_target_symbol(&mut conn, tid).await?),
148            None => None,
149        };
150
151        super::period::check_range(date_from, date_to)?;
152        let period_boundaries = period_grouping
153            .map(|g| boundaries(g, date_from, date_to))
154            .transpose()?
155            .unwrap_or_default();
156
157        // The loop below runs one query per period PER GROUP, and both factors
158        // come from the caller: `groups` is JSON on the query string with no
159        // length of its own. Bounding only the periods leaves the product
160        // unbounded, which is the whole defect over again.
161        super::period::query_budget(period_boundaries.len(), groups.len(), MAX_QUERIES)?;
162
163        super::filter::check_filter_size(widest_query_leaves(report_filter.as_ref(), &groups))?;
164
165        let ctx = RunCtx {
166            accounts: &accounts,
167            user_filter: report_filter.as_ref(),
168            target_commodity_id,
169            target_symbol: target_symbol.as_deref(),
170        };
171
172        let periods = if period_boundaries.is_empty() {
173            let mut group_results = Vec::with_capacity(groups.len());
174            for group in &groups {
175                group_results.push(
176                    run_group_for_period(&mut conn, &ctx, group, date_from, date_to).await?,
177                );
178            }
179            vec![ActivityPeriod {
180                label: None,
181                groups: group_results,
182            }]
183        } else {
184            let mut out = Vec::with_capacity(period_boundaries.len());
185            for (label, pfrom, pto) in &period_boundaries {
186                let mut group_results = Vec::with_capacity(groups.len());
187                for group in &groups {
188                    group_results
189                        .push(run_group_for_period(&mut conn, &ctx, group, *pfrom, *pto).await?);
190                }
191                out.push(ActivityPeriod {
192                    label: Some(label.clone()),
193                    groups: group_results,
194                });
195            }
196            out
197        };
198
199        Ok(Some(CmdResult::Activity(ActivityData {
200            meta: ReportMeta {
201                date_from: Some(date_from),
202                date_to: Some(date_to),
203                target_commodity_id,
204            },
205            periods,
206        })))
207    }
208}