From 79ef859fbec752eaac25b0c1ebe4d52be33fa11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Manuel=20Forc=C3=A9n=20Mu=C3=B1oz?= Date: Thu, 21 Mar 2024 23:34:43 +0100 Subject: [PATCH] Moved accumulation calculations into DB triggers --- migrations/20231110161954_base.sql | 50 ++++- src/models.rs | 5 +- src/models/account.rs | 244 +---------------------- src/models/transaction.rs | 1 + webserver/src/main.rs | 4 - webserver/src/routes/api/accounts.rs | 18 +- webserver/src/routes/api/transactions.rs | 2 +- webserver/src/routes/ui.rs | 2 +- webserver/src/routes/ui/account.rs | 5 +- 9 files changed, 50 insertions(+), 281 deletions(-) diff --git a/migrations/20231110161954_base.sql b/migrations/20231110161954_base.sql index d3c55d3..bf51ced 100644 --- a/migrations/20231110161954_base.sql +++ b/migrations/20231110161954_base.sql @@ -14,14 +14,6 @@ CREATE TABLE IF NOT EXISTS accounts( FOREIGN KEY (user) REFERENCES users(user_id) ); -CREATE TABLE IF NOT EXISTS account_snapshot( - account INTEGER, - datestamp DATE, - amount INT, - FOREIGN KEY (account) REFERENCES accounts(account_id), - PRIMARY KEY (account, datestamp) -); - CREATE TABLE IF NOT EXISTS categories ( category_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, @@ -41,13 +33,49 @@ CREATE TABLE IF NOT EXISTS transactions ( transaction_id INTEGER PRIMARY KEY AUTOINCREMENT, account INTEGER, description TEXT, - transaction_timestamp DATETIME, + tx_date DATETIME, category INTEGER, amount INTEGER, + accumulated INTEGER DEFAULT 0, + tx_order INTEGER DEFAULT 0, hash TEXT, FOREIGN KEY (account) REFERENCES accounts(account_id), FOREIGN KEY (category) REFERENCES categories(category_id) ); -CREATE INDEX idx_transactions_ts ON transactions(account, transaction_timestamp); -CREATE INDEX idx_transactions_hash ON transactions(hash); +CREATE TRIGGER tx_insert AFTER INSERT ON transactions +BEGIN + UPDATE transactions + SET accumulated=old.acc+NEW.amount + FROM ( + SELECT COALESCE(max(accumulated), 0) AS acc + FROM transactions + WHERE date <= NEW.date + ORDER BY tx_order DESC + LIMIT 1 + ) AS old + WHERE id=NEW.id; + + UPDATE transactions + SET tx_order=old.tx_order+1 FROM ( + SELECT COALESCE(max(tx_order), 0) as tx_order + FROM tx WHERE date=NEW.date + ) AS old + WHERE id=NEW.id; + + UPDATE transactions SET accumulated=calc.acc+NEW.accumulated FROM ( + SELECT tx.id, ( + SUM(amount) OVER ( + ORDER BY date, tx_order + ROWS BETWEEN + UNBOUNDED PRECEDING + AND CURRENT ROW + ) + ) acc + FROM transactions tx + WHERE date > NEW.date OR id=NEW.id; + ) + WHERE transactions.id=calc.id; +END; +CREATE INDEX idx_transactions_ts ON transactions(account, tx_date); +CREATE INDEX idx_transactions_hash ON transactions(hash); diff --git a/src/models.rs b/src/models.rs index cc1ef55..bff0b58 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,8 +1,5 @@ -mod account; +pub mod account; pub mod categories; pub mod rules; pub mod transaction; pub mod users; - -pub use account::{Account, AccountSnapshot}; -pub use transaction::Transaction; diff --git a/src/models/account.rs b/src/models/account.rs index b90bceb..f364348 100644 --- a/src/models/account.rs +++ b/src/models/account.rs @@ -1,199 +1,8 @@ -use chrono::{prelude::*, Duration, DurationRound}; +use chrono::prelude::*; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Result, SqlitePool}; -use super::{rules::Rule, Transaction}; - -#[derive(FromRow, Serialize, Deserialize, Clone, Debug)] -pub struct AccountSnapshot { - account: i32, - datestamp: DateTime, - amount: i32, -} - -impl AccountSnapshot { - pub async fn get( - pool: &SqlitePool, - account: i32, - date: DateTime, - ) -> Result { - sqlx::query("SELECT * FROM account_snapshot WHERE account=? AND datestamp=?") - .bind(account) - .bind(date) - .fetch_one(pool) - .await - .and_then(|r| AccountSnapshot::from_row(&r)) - } - - pub async fn get_last( - pool: &SqlitePool, - account: i32, - date: DateTime, - ) -> Result { - sqlx::query("SELECT * FROM account_snapshot WHERE account=? AND datestamp<=? LIMIT 1") - .bind(account) - .bind(date) - .fetch_one(pool) - .await - .and_then(|r| AccountSnapshot::from_row(&r)) - } - - pub async fn list( - pool: &SqlitePool, - account: i32, - limit: Option, - offset: Option, - asc: bool, - ) -> sqlx::Result> { - let mut query = sqlx::QueryBuilder::new("SELECT * FROM account_snapshot WHERE account="); - query.push_bind(account); - - if let Some(limit) = limit { - query.push(" LIMIT "); - query.push_bind(limit); - } - - if let Some(offset) = offset { - query.push(" OFFSET "); - query.push_bind(offset); - } - - if asc { - query.push(" ORDER BY datestamp ASC"); - } else { - query.push(" ORDER BY datestamp DESC"); - } - - let rows = query.build().fetch_all(pool).await?; - - let mut res = Vec::new(); - for r in rows.iter() { - res.push(AccountSnapshot::from_row(r)?); - } - Ok(res) - } - - pub async fn list_by_date( - pool: &SqlitePool, - account: i32, - after: Option>, - before: Option>, - limit: Option, - asc: bool, - ) -> sqlx::Result> { - let mut query = sqlx::QueryBuilder::new("SELECT * FROM account_snapshot WHERE account="); - query.push_bind(account); - - if let Some(after) = after { - query.push(" AND datestamp >= "); - query.push_bind(after); - } - - if let Some(before) = before { - query.push(" AND datestamp < "); - query.push_bind(before); - } - - if let Some(limit) = limit { - query.push(" LIMIT "); - query.push_bind(limit); - } - - if asc { - query.push(" ORDER BY datestamp ASC"); - } else { - query.push(" ORDER BY datestamp DESC"); - } - - let rows = query.build().fetch_all(pool).await?; - - let mut res = Vec::new(); - for r in rows.iter() { - res.push(AccountSnapshot::from_row(r)?); - } - Ok(res) - } - - pub async fn delete_by_dates( - pool: &SqlitePool, - account: i32, - after: Option>, - before: Option>, - ) -> sqlx::Result<()> { - if after.is_none() && before.is_none() { - return Err(sqlx::Error::RowNotFound); - } - - let mut query = sqlx::QueryBuilder::new("DELETE FROM account_snapshot WHERE account="); - query.push_bind(account); - - if let Some(after) = after { - query.push(" AND datestamp >= "); - query.push_bind(after); - } - - if let Some(before) = before { - query.push(" AND datestamp < "); - query.push_bind(before); - } - - query.build().execute(pool).await?; - - Ok(()) - } - - pub async fn insert(&self, pool: &SqlitePool) -> sqlx::Result<()> { - sqlx::query("INSERT INTO account_snapshot(account, datestamp, amount) VALUES(?,?,?)") - .bind(self.account) - .bind(self.datestamp) - .bind(self.amount) - .execute(pool) - .await - .map(|_| ()) - } - - pub async fn get_next(&self, pool: &SqlitePool) -> sqlx::Result> { - let date_next = match Transaction::list_by_date( - pool, - self.account, - Some(self.datestamp + Duration::days(1)), - None, - Some(1), - true, - ) - .await? - .first() - { - Some(tx) => tx.get_timestamp(), - None => { - return Ok(None); - } - } - .duration_trunc(chrono::Duration::days(1)) - .unwrap(); - - println!( - "Starting date: {:?}, ending date: {:?}", - self.datestamp, date_next - ); - - let tx_list = Transaction::list_by_date( - pool, - self.account, - Some(self.datestamp), - Some(date_next), - None, - true, - ) - .await?; - - Ok(Some(AccountSnapshot { - datestamp: date_next, - account: self.account, - amount: self.amount + tx_list.iter().fold(0, |acc, tx| acc + tx.get_amount()), - })) - } -} +use super::{rules::Rule, transaction::Transaction}; #[derive(FromRow, Serialize, Deserialize, Debug)] pub struct Account { @@ -254,55 +63,6 @@ impl Account { Ok(res) } - pub async fn recalculate_snapshots( - &self, - pool: &SqlitePool, - from: Option>, - ) -> Result<()> { - let mut snap = match from { - Some(f) => { - let snapshot = AccountSnapshot::list_by_date( - pool, - self.get_id(), - None, - Some(f), - Some(1), - true, - ) - .await?; - - if snapshot.is_empty() { - AccountSnapshot { - account: self.account_id, - datestamp: Utc.timestamp_opt(0, 0).unwrap(), - amount: 0, - } - } else { - snapshot.first().unwrap().clone() - } - } - None => AccountSnapshot { - account: self.account_id, - datestamp: Utc.timestamp_opt(0, 0).unwrap(), - amount: 0, - }, - }; - - AccountSnapshot::delete_by_dates( - pool, - self.get_id(), - Some(snap.datestamp + Duration::hours(12)), - None, - ) - .await?; - - while let Some(next) = snap.get_next(pool).await? { - next.insert(pool).await?; - snap = next; - } - Ok(()) - } - pub async fn recategorize_transactions( &self, pool: &SqlitePool, diff --git a/src/models/transaction.rs b/src/models/transaction.rs index 13d4c1a..29c877e 100644 --- a/src/models/transaction.rs +++ b/src/models/transaction.rs @@ -20,6 +20,7 @@ pub struct Transaction { transaction_timestamp: DateTime, category: Option, amount: i32, + accumulated: i32, #[serde(default, skip_serializing)] hash: Option, } diff --git a/webserver/src/main.rs b/webserver/src/main.rs index 04aa77f..fceb693 100644 --- a/webserver/src/main.rs +++ b/webserver/src/main.rs @@ -85,10 +85,6 @@ async fn main() { "/accounts/id/:id/transaction", get(routes::api::transactions::list), ) - .route( - "/accounts/id/:id/update", - post(routes::api::accounts::snapshot_update), - ) .route( "/accounts/id/:id/recategorize", post(routes::api::accounts::recategorize), diff --git a/webserver/src/routes/api/accounts.rs b/webserver/src/routes/api/accounts.rs index 4fc115c..79a6a93 100644 --- a/webserver/src/routes/api/accounts.rs +++ b/webserver/src/routes/api/accounts.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use sqlx::SqlitePool; use crate::users::UserToken; -use accounters::models::Account; +use accounters::models::account::Account; pub async fn account_get( State(db): State>, @@ -51,22 +51,6 @@ pub async fn account_list( } } -pub async fn snapshot_update( - State(db): State>, - uid: UserToken, - Path(account): Path, -) -> (StatusCode, String) { - let account = Account::get_by_id(db.as_ref(), account).await.unwrap(); - if account.get_user() != uid.user_id { - return (StatusCode::UNAUTHORIZED, String::new()); - } - - match account.recalculate_snapshots(db.as_ref(), None).await { - Ok(_) => (StatusCode::OK, String::new()), - Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")), - } -} - pub async fn recategorize( State(db): State>, uid: UserToken, diff --git a/webserver/src/routes/api/transactions.rs b/webserver/src/routes/api/transactions.rs index fb60857..1d434f7 100644 --- a/webserver/src/routes/api/transactions.rs +++ b/webserver/src/routes/api/transactions.rs @@ -6,7 +6,7 @@ use hyper::StatusCode; use serde::Deserialize; use sqlx::SqlitePool; -use accounters::models::{transaction::TxConflictResolutionMode, Transaction}; +use accounters::models::transaction::{Transaction, TxConflictResolutionMode}; #[derive(Deserialize)] pub struct TransactionContent { diff --git a/webserver/src/routes/ui.rs b/webserver/src/routes/ui.rs index 3e8a59a..ec45f47 100644 --- a/webserver/src/routes/ui.rs +++ b/webserver/src/routes/ui.rs @@ -6,7 +6,7 @@ use sqlx::SqlitePool; use tera::{Context, Tera}; use crate::users::UserToken; -use accounters::models::{Account, Transaction}; +use accounters::models::{account::Account, transaction::Transaction}; pub mod account; pub mod categories; diff --git a/webserver/src/routes/ui/account.rs b/webserver/src/routes/ui/account.rs index 225431c..bf50c20 100644 --- a/webserver/src/routes/ui/account.rs +++ b/webserver/src/routes/ui/account.rs @@ -12,7 +12,10 @@ use sqlx::SqlitePool; use tera::{Context, Tera}; use crate::users::UserToken; -use accounters::models::{transaction::TxConflictResolutionMode, Account, Transaction}; +use accounters::models::{ + account::Account, + transaction::{Transaction, TxConflictResolutionMode}, +}; #[derive(Deserialize)] pub struct AccountViewParams {