diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc28598..a962a5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,12 +34,15 @@ jobs: - name: Rust Cache dependencies uses: Swatinem/rust-cache@v2 - - name: Clippy --features sqlite,postgresql,mysql,xml - run: cargo clippy --features sqlite,postgresql,mysql,xml --all-targets -- -D warnings + - name: Clippy --features sqlite,postgresql,mysql,xml,sqlitefaster + run: cargo clippy --features sqlite,postgresql,mysql,xml,sqlitefaster --all-targets -- -D warnings - name: Clippy --features sqlite run: cargo clippy --features sqlite --all-targets -- -D warnings + - name: Clippy --features sqlitefaster + run: cargo clippy --features sqlitefaster --all-targets -- -D warnings + - name: Clippy --features postgresql run: cargo clippy --features postgresql --all-targets -- -D warnings @@ -49,12 +52,15 @@ jobs: - name: Clippy --features xml run: cargo clippy --features xml --all-targets -- -D warnings - - name: Clippy --features sqlite,postgresql,mysql,xml,decimal - run: cargo clippy --features sqlite,postgresql,mysql,xml,decimal --all-targets -- -D warnings + - name: Clippy --features sqlite,postgresql,mysql,xml,sqlitefaster,decimal + run: cargo clippy --features sqlite,postgresql,mysql,xml,sqlitefaster,decimal --all-targets -- -D warnings - name: Clippy --features sqlite,decimal run: cargo clippy --features sqlite,decimal --all-targets -- -D warnings + - name: Clippy --features sqlitefaster,decimal + run: cargo clippy --features sqlitefaster,decimal --all-targets -- -D warnings + - name: Clippy --features postgresql,decimal run: cargo clippy --features postgresql,decimal --all-targets -- -D warnings @@ -134,6 +140,43 @@ jobs: cargo test --doc --features sqlite,decimal + test_sqlitefaster: + name: Test SQLiteFaster + needs: [lint] + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: + - ubuntu-latest + - macos-latest + - windows-latest + + steps: + - uses: actions/checkout@v4 + + - name: Rust Cache dependencies + uses: Swatinem/rust-cache@v2 + + - name: Install latest nextest release + uses: taiki-e/install-action@nextest + + - name: Check Schema + run: cargo check --features sqlitefaster --all-targets + env: + DATABASE_URL: "file:/tests/db/sqlite/complex_sample.gnucash" + + - name: Run tests --features sqlitefaster + run: cargo nextest run --config-file ${{ github.workspace }}/.github/nextest.toml --profile ci --features sqlitefaster + + - name: Run tests --features sqlitefaster,decimal + run: cargo nextest run --config-file ${{ github.workspace }}/.github/nextest.toml --profile ci --features sqlitefaster,decimal + + - name: Run doc tests + run: | + cargo test --doc --features sqlitefaster + cargo test --doc --features sqlitefaster,decimal + + test_mysql: name: Test MySQL needs: [lint] @@ -238,4 +281,4 @@ jobs: - name: Run doc tests run: | cargo test --doc --features postgresql - cargo test --doc --features postgresql,decimal \ No newline at end of file + cargo test --doc --features postgresql,decimal diff --git a/Cargo.toml b/Cargo.toml index bfb6965..91e3060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ flate2 = "1.0" tokio = { version = "1.36", features = ["sync"] } num-traits = "0.2" thiserror = "1.0" +rusqlite = { version = "*", features = ["bundled", "chrono"], optional = true } [lib] name = "rucash" @@ -48,6 +49,7 @@ sqlx_common = [ "sqlx/chrono", ] sqlite = ["sqlx_common", "sqlx/sqlite"] +sqlitefaster = ["rusqlite"] postgresql = ["sqlx_common", "sqlx/postgres"] mysql = ["sqlx_common", "sqlx/mysql"] xml = ["xmltree"] @@ -56,13 +58,18 @@ decimal = ["rust_decimal"] [[bench]] name = "benchmark" harness = false -required-features = ["sqlite", "xml"] +required-features = ["sqlite", "xml", "sqlitefaster"] [[test]] name = "sqlite" path = "tests/sqlite.rs" required-features = ["sqlite"] +[[test]] +name = "sqlitefaster" +path = "tests/sqlitefaster.rs" +required-features = ["sqlitefaster"] + [[test]] name = "mysql" path = "tests/mysql.rs" @@ -81,4 +88,4 @@ required-features = ["postgresql"] [[test]] name = "all" path = "tests/all.rs" -required-features = ["sqlite", "postgresql", "mysql", "xml"] +required-features = ["sqlite", "postgresql", "mysql", "xml", "sqlitefaster"] diff --git a/benches/benchmark.rs b/benches/benchmark.rs index 87fd5e4..16f7f6f 100644 --- a/benches/benchmark.rs +++ b/benches/benchmark.rs @@ -1,81 +1,117 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; - -fn uri_sqlite() -> String { - format!( - "sqlite://{}/tests/db/sqlite/complex_sample.gnucash?mode=ro", - env!("CARGO_MANIFEST_DIR") - ) -} - -fn uri_xml() -> String { - format!( - "{}/tests/db/xml/complex_sample.gnucash", - env!("CARGO_MANIFEST_DIR") - ) -} - -fn benchmark_sql_query(c: &mut Criterion) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let book = rt.block_on(async { - let query = rucash::SQLiteQuery::new(&uri_sqlite()).await.unwrap(); - rucash::Book::new(query).await.unwrap() - }); - - c.bench_function("sql query", |b| { - b.to_async(&rt).iter(|| async { - book.accounts_contains_name_ignore_case(black_box("aS")) - .await - }); - }); -} - -fn benchmark_vec_filter(c: &mut Criterion) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let book = rt.block_on(async { - let query = rucash::SQLiteQuery::new(&uri_sqlite()).await.unwrap(); - rucash::Book::new(query).await.unwrap() - }); - - c.bench_function("vec filter", |b| { - b.to_async(&rt).iter(|| async { - let vec = book.accounts().await.unwrap(); - let _: Vec<_> = vec - .into_iter() - .filter(|x| x.name.to_lowercase().contains(black_box("aS"))) - .collect(); - }) - }); -} - -fn benchmark_xml_book(c: &mut Criterion) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let book = rt.block_on(async { - let query = rucash::XMLQuery::new(&uri_xml()).unwrap(); - rucash::Book::new(query).await.unwrap() - }); - - c.bench_function("XMLBook", |b| { - b.to_async(&rt).iter(|| async { book.accounts().await }) - }); -} - -fn benchmark_sqlite_book(c: &mut Criterion) { - let rt = tokio::runtime::Runtime::new().unwrap(); - let book = rt.block_on(async { - let query = rucash::SQLiteQuery::new(&uri_sqlite()).await.unwrap(); - rucash::Book::new(query).await.unwrap() - }); - - c.bench_function("SqliteBook", |b| { - b.to_async(&rt).iter(|| async { book.accounts().await }) - }); -} - -criterion_group!( - benches, - benchmark_sql_query, - benchmark_vec_filter, - benchmark_xml_book, - benchmark_sqlite_book, -); -criterion_main!(benches); +use criterion::{black_box, criterion_group, criterion_main, Criterion}; + +fn uri_sqlite() -> String { + format!( + "sqlite://{}/tests/db/sqlite/complex_sample.gnucash?mode=ro", + env!("CARGO_MANIFEST_DIR") + ) +} + +fn uri_xml() -> String { + format!( + "{}/tests/db/xml/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ) +} + +fn uri_sqlitefaster() -> String { + format!( + "file:/{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ) +} + +fn benchmark_sql_query(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let book = rt.block_on(async { + let query = rucash::SQLiteQuery::new(&uri_sqlite()).await.unwrap(); + rucash::Book::new(query).await.unwrap() + }); + + c.bench_function("sql query", |b| { + b.to_async(&rt).iter(|| async { + book.accounts_contains_name_ignore_case(black_box("aS")) + .await + }); + }); +} + +fn benchmark_sql_faster_query(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let book = rt.block_on(async { + let query = rucash::SQLiteQueryFaster::new(&uri_sqlitefaster()).unwrap(); + rucash::Book::new(query).await.unwrap() + }); + + c.bench_function("sql faster query", |b| { + b.to_async(&rt).iter(|| async { + book.accounts_contains_name_ignore_case(black_box("aS")) + .await + }); + }); +} + +fn benchmark_vec_filter(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let book = rt.block_on(async { + let query = rucash::SQLiteQuery::new(&uri_sqlite()).await.unwrap(); + rucash::Book::new(query).await.unwrap() + }); + + c.bench_function("vec filter", |b| { + b.to_async(&rt).iter(|| async { + let vec = book.accounts().await.unwrap(); + let _: Vec<_> = vec + .into_iter() + .filter(|x| x.name.to_lowercase().contains(black_box("aS"))) + .collect(); + }) + }); +} + +fn benchmark_xml_book(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let book = rt.block_on(async { + let query = rucash::XMLQuery::new(&uri_xml()).unwrap(); + rucash::Book::new(query).await.unwrap() + }); + + c.bench_function("XMLBook", |b| { + b.to_async(&rt).iter(|| async { book.accounts().await }) + }); +} + +fn benchmark_sqlite_book(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let book = rt.block_on(async { + let query = rucash::SQLiteQuery::new(&uri_sqlite()).await.unwrap(); + rucash::Book::new(query).await.unwrap() + }); + + c.bench_function("SqliteBook", |b| { + b.to_async(&rt).iter(|| async { book.accounts().await }) + }); +} + +fn benchmark_sqlitefaster_book(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let book = rt.block_on(async { + let query = rucash::SQLiteQueryFaster::new(&uri_sqlitefaster()).unwrap(); + rucash::Book::new(query).await.unwrap() + }); + + c.bench_function("SqliteFasterBook", |b| { + b.to_async(&rt).iter(|| async { book.accounts().await }) + }); +} + +criterion_group!( + benches, + benchmark_sql_query, + benchmark_sql_faster_query, + benchmark_vec_filter, + benchmark_xml_book, + benchmark_sqlite_book, + benchmark_sqlitefaster_book, +); +criterion_main!(benches); diff --git a/makefile b/makefile index e1ae38b..47af30f 100755 --- a/makefile +++ b/makefile @@ -2,25 +2,27 @@ all: test build build: cargo build test: - cargo test --features sqlite,postgresql,mysql,xml + cargo test --features sqlite,postgresql,mysql,xml,sqlitefaster cargo test --features sqlite + cargo test --features sqlitefaster cargo test --features postgresql cargo test --features mysql cargo test --features xml - cargo test --features sqlite,postgresql,mysql,xml,decimal + cargo test --features sqlite,postgresql,mysql,xml,sqlitefaster,decimal cargo test --features sqlite,decimal + cargo test --features sqlitefaster,decimal cargo test --features postgresql,decimal cargo test --features mysql,decimal cargo test --features xml,decimal clean: cargo clean bench: - cargo bench --features sqlite,xml + cargo bench --features sqlite,xml,sqlitefaster check: - cargo check --features sqlite,postgresql,mysql,xml --all-targets - cargo clippy --features sqlite,postgresql,mysql,xml --all-targets - cargo check --features sqlite,postgresql,mysql,xml,decimal --all-targets - cargo clippy --features sqlite,postgresql,mysql,xml,decimal --all-targets + cargo check --features sqlite,postgresql,mysql,xml,sqlitefaster --all-targets + cargo clippy --features sqlite,postgresql,mysql,xml,sqlitefaster --all-targets + cargo check --features sqlite,postgresql,mysql,xml,sqlitefaster,decimal --all-targets + cargo clippy --features sqlite,postgresql,mysql,xml,sqlitefaster,decimal --all-targets checkschema: export DATABASE_URL=sqlite://tests/db/sqlite/complex_sample.gnucash?mode=ro cargo check --features sqlite,schema --all-targets diff --git a/src/book.rs b/src/book.rs index 20ed03a..ad38526 100644 --- a/src/book.rs +++ b/src/book.rs @@ -265,6 +265,133 @@ mod tests { } } + #[cfg(feature = "sqlitefaster")] + mod sqlitefaster { + use super::*; + + use pretty_assertions::assert_eq; + + use crate::SQLiteQueryFaster; + + static Q: OnceCell> = OnceCell::const_new(); + async fn setup() -> &'static Book { + Q.get_or_init(|| async { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + let query = SQLiteQueryFaster::new(uri).unwrap(); + Book::new(query).await.unwrap() + }) + .await + } + + #[tokio::test] + async fn test_new() { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + let query = SQLiteQueryFaster::new(uri).unwrap(); + Book::new(query).await.unwrap(); + } + + #[tokio::test] + async fn test_new_fail() { + assert!(matches!( + SQLiteQueryFaster::new("sqlite://tests/sample/no.gnucash"), + Err(crate::Error::Rusqlite(_)) + )); + } + + #[tokio::test] + async fn test_accounts() { + let book = setup().await; + let accounts = book.accounts().await.unwrap(); + assert_eq!(accounts.len(), 21); + } + + #[tokio::test] + async fn test_accounts_contains_name() { + let book = setup().await; + let accounts = book.accounts_contains_name_ignore_case("aS").await.unwrap(); + assert_eq!(accounts.len(), 3); + } + + #[tokio::test] + async fn test_account_contains_name() { + let book = setup().await; + let account = book + .account_contains_name_ignore_case("NAS") + .await + .unwrap() + .unwrap(); + assert_eq!(account.name, "NASDAQ"); + } + + #[tokio::test] + async fn test_splits() { + let book = setup().await; + let splits = book.splits().await.unwrap(); + assert_eq!(splits.len(), 25); + } + + #[tokio::test] + async fn test_transactions() { + let book = setup().await; + let transactions = book.transactions().await.unwrap(); + assert_eq!(transactions.len(), 11); + } + + #[tokio::test] + async fn test_prices() { + let book = setup().await; + let prices = book.prices().await.unwrap(); + assert_eq!(prices.len(), 5); + } + + #[tokio::test] + async fn test_commodities() { + let book = setup().await; + let commodities = book.commodities().await.unwrap(); + assert_eq!(commodities.len(), 5); + } + + #[tokio::test] + async fn test_currencies() { + let book = setup().await; + let currencies = book.currencies().await.unwrap(); + assert_eq!(currencies.len(), 4); + } + + #[tokio::test] + async fn test_exchange() { + let book = setup().await; + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "d821d6776fde9f7c2d01b67876406fd3") + .unwrap(); + let currency = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "5f586908098232e67edb1371408bfaa8") + .unwrap(); + + let rate = book.exchange(&commodity, ¤cy).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 1.5); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(15, 1)); + } + } + #[cfg(feature = "mysql")] mod mysql { use super::*; diff --git a/src/error.rs b/src/error.rs index b856c1e..566a1ec 100644 --- a/src/error.rs +++ b/src/error.rs @@ -18,6 +18,10 @@ pub enum Error { #[error("SQLx error: {0}")] Sql(#[from] sqlx::Error), + #[cfg(feature = "sqlitefaster")] + #[error("rusqlite error: {0}")] + Rusqlite(#[from] rusqlite::Error), + #[cfg(feature = "xml")] #[error("XML error: {0}")] XML(#[from] xmltree::Error), diff --git a/src/exchange.rs b/src/exchange.rs index 2d0e700..f5b1158 100644 --- a/src/exchange.rs +++ b/src/exchange.rs @@ -342,6 +342,204 @@ mod tests { } } + #[cfg(feature = "sqlitefaster")] + mod sqlitefaster { + use super::*; + + use crate::SQLiteQueryFaster; + + use pretty_assertions::assert_eq; + + #[allow(clippy::unused_async)] + async fn setup() -> SQLiteQueryFaster { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQueryFaster::new(uri).unwrap() + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn test_exchange() { + let query = setup().await; + let book = Book::new(query.clone()).await.unwrap(); + let query = Arc::new(query); + let mut exchange = Exchange::new(query.clone()).await.unwrap(); + exchange.update(query).await.expect("ok"); + + let from = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "ADF") + .unwrap(); + let to = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "AED") + .unwrap(); + assert_eq!(from.mnemonic, "ADF"); + assert_eq!(to.mnemonic, "AED"); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, 1.5, exchange.cal(&from, &to).unwrap()); + #[cfg(feature = "decimal")] + assert_eq!(Decimal::new(15, 1), exchange.cal(&from, &to).unwrap()); + + let from = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "FOO") + .unwrap(); + let to = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "FOO") + .unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, 1.0, exchange.cal(&from, &to).unwrap()); + #[cfg(feature = "decimal")] + assert_eq!(Decimal::new(10, 1), exchange.cal(&from, &to).unwrap()); + + let from = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "FOO") + .unwrap(); + let to = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "AED") + .unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, 0.9, exchange.cal(&from, &to).unwrap()); + #[cfg(feature = "decimal")] + assert_eq!(Decimal::new(9, 1), exchange.cal(&from, &to).unwrap()); + + let from = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "EUR") + .unwrap(); + let to = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "USD") + .unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, 1.0 / 1.4, exchange.cal(&from, &to).unwrap()); + #[cfg(feature = "decimal")] + assert_eq!( + Decimal::new(10, 1) / Decimal::new(14, 1), + exchange.cal(&from, &to).unwrap() + ); + + let from = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "AED") + .unwrap(); + let to = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "EUR") + .unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, 0.9, exchange.cal(&from, &to).unwrap()); + #[cfg(feature = "decimal")] + assert_eq!(Decimal::new(9, 1), exchange.cal(&from, &to).unwrap()); + + let from = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "USD") + .unwrap(); + let to = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "AED") + .unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!( + f64, + 7.0 / 5.0 * 10.0 / 9.0, + exchange.cal(&from, &to).unwrap() + ); + #[cfg(feature = "decimal")] + assert_eq!( + (Decimal::new(7, 0) / Decimal::new(5, 0)) + * (Decimal::new(10, 0) / Decimal::new(9, 0)), + exchange.cal(&from, &to).unwrap() + ); + + let from = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "FOO") + .unwrap(); + let to = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "EUR") + .unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, 0.81, exchange.cal(&from, &to).unwrap()); + #[cfg(feature = "decimal")] + assert_eq!(Decimal::new(81, 2), exchange.cal(&from, &to).unwrap()); + + let from = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "EUR") + .unwrap(); + let to = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|c| c.mnemonic == "FOO") + .unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, 1.0 / 0.81, exchange.cal(&from, &to).unwrap()); + #[cfg(feature = "decimal")] + assert_eq!( + Decimal::new(10, 1) / Decimal::new(81, 2), + exchange.cal(&from, &to).unwrap() + ); + } + } + #[cfg(feature = "mysql")] mod mysql { use super::*; diff --git a/src/lib.rs b/src/lib.rs index 5e4a1ec..0b2f569 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,8 @@ pub use query::mysql::MySQLQuery; pub use query::postgresql::PostgreSQLQuery; #[cfg(feature = "sqlite")] pub use query::sqlite::SQLiteQuery; +#[cfg(feature = "sqlitefaster")] +pub use query::sqlitefaster::SQLiteQuery as SQLiteQueryFaster; #[cfg(feature = "xml")] pub use query::xml::XMLQuery; pub use query::Query; diff --git a/src/model/account.rs b/src/model/account.rs index fbe28e0..7fe01ee 100644 --- a/src/model/account.rs +++ b/src/model/account.rs @@ -356,6 +356,203 @@ mod tests { } } + #[cfg(feature = "sqlitefaster")] + mod sqlitefaster { + use super::*; + + use pretty_assertions::assert_eq; + + use crate::query::sqlitefaster::account::Account as AccountBase; + use crate::SQLiteQueryFaster; + + #[allow(clippy::unused_async)] + async fn setup() -> SQLiteQueryFaster { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQueryFaster::new(uri).unwrap() + } + + #[tokio::test] + async fn test_from_with_query() { + let query = Arc::new(setup().await); + let item = AccountBase { + guid: "guid".to_string(), + name: "name".to_string(), + account_type: "account_type".to_string(), + commodity_guid: Some("commodity_guid".to_string()), + commodity_scu: 100, + non_std_scu: 0, + parent_guid: Some("parent_guid".to_string()), + code: Some("code".to_string()), + description: Some("description".to_string()), + hidden: Some(0), + placeholder: Some(1), + }; + + let result = Account::from_with_query(&item, query); + + assert_eq!(result.guid, "guid"); + assert_eq!(result.name, "name"); + assert_eq!(result.r#type, "account_type"); + assert_eq!(result.commodity_guid, "commodity_guid"); + assert_eq!(result.commodity_scu, 100); + assert_eq!(result.non_std_scu, false); + assert_eq!(result.parent_guid, "parent_guid"); + assert_eq!(result.code, "code"); + assert_eq!(result.description, "description"); + assert_eq!(result.hidden, false); + assert_eq!(result.placeholder, true); + } + + #[tokio::test] + async fn test_splits() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Cash") + .await + .unwrap() + .unwrap(); + let splits = account.splits().await.unwrap(); + assert_eq!(splits.len(), 3); + } + + #[tokio::test] + async fn test_parent() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Cash") + .await + .unwrap() + .unwrap(); + let parent = account.parent().await.unwrap().unwrap(); + assert_eq!(parent.name, "Current"); + } + + #[tokio::test] + async fn test_no_parent() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Root Account") + .await + .unwrap() + .unwrap(); + let parent = account.parent().await.unwrap(); + dbg!(&parent); + assert!(parent.is_none()); + } + + #[tokio::test] + async fn test_children() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Current") + .await + .unwrap() + .unwrap(); + let children = account.children().await.unwrap(); + assert_eq!(children.len(), 3); + } + + #[tokio::test] + async fn test_children2() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Asset") + .await + .unwrap() + .unwrap(); + let children = account.children().await.unwrap(); + + assert_eq!(children.len(), 3); + assert!( + children + .iter() + .map(|x| &x.name) + .any(|name| name == "Current"), + "children does not contains Current" + ); + assert!( + children.iter().map(|x| &x.name).any(|name| name == "Fixed"), + "children does not contains Fixed" + ); + assert!( + children + .iter() + .map(|x| &x.name) + .any(|name| name == "Broker"), + "children does not contains Broker" + ); + } + + #[tokio::test] + async fn test_commodity() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Cash") + .await + .unwrap() + .unwrap(); + let commodity = account.commodity().await.unwrap(); + assert_eq!(commodity.mnemonic, "EUR"); + } + + #[tokio::test] + async fn test_balance_into_currency() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Asset") + .await + .unwrap() + .unwrap(); + let commodity = account.commodity().await.unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!( + f64, + account + .balance_into_currency(&commodity, &book) + .await + .unwrap(), + 24695.3 + ); + #[cfg(feature = "decimal")] + assert_eq!( + account + .balance_into_currency(&commodity, &book) + .await + .unwrap(), + Decimal::new(246_953, 1) + ); + } + + #[tokio::test] + async fn test_balance() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Current") + .await + .unwrap() + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, account.balance(&book).await.unwrap(), 4590.0); + #[cfg(feature = "decimal")] + assert_eq!(account.balance(&book).await.unwrap(), Decimal::new(4590, 0)); + } + } + #[cfg(feature = "mysql")] mod mysql { use super::*; diff --git a/src/model/commodity.rs b/src/model/commodity.rs index 34b97be..fc5cc82 100644 --- a/src/model/commodity.rs +++ b/src/model/commodity.rs @@ -319,6 +319,224 @@ mod tests { } } + #[cfg(feature = "sqlitefaster")] + mod sqlitefaster { + use super::*; + + use pretty_assertions::assert_eq; + + use crate::query::sqlitefaster::commodity::Commodity as CommodityBase; + use crate::SQLiteQueryFaster; + + #[allow(clippy::unused_async)] + async fn setup() -> SQLiteQueryFaster { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQueryFaster::new(uri).unwrap() + } + + #[tokio::test] + async fn test_from_with_query() { + let query = Arc::new(setup().await); + let item = CommodityBase { + guid: "guid".to_string(), + namespace: "namespace".to_string(), + mnemonic: "mnemonic".to_string(), + fullname: Some("fullname".to_string()), + cusip: Some("cusip".to_string()), + fraction: 100, + quote_flag: 1, + quote_source: Some("quote_source".to_string()), + quote_tz: Some("quote_tz".to_string()), + }; + + let result = Commodity::from_with_query(&item, query); + + assert_eq!(result.guid, "guid"); + assert_eq!(result.namespace, "namespace"); + assert_eq!(result.mnemonic, "mnemonic"); + assert_eq!(result.fullname, "fullname"); + assert_eq!(result.cusip, "cusip"); + assert_eq!(result.fraction, 100); + assert_eq!(result.quote_flag, true); + assert_eq!(result.quote_source, "quote_source"); + assert_eq!(result.quote_tz, "quote_tz"); + } + + #[tokio::test] + async fn test_accounts() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let accounts = commodity.accounts().await.unwrap(); + assert_eq!(accounts.len(), 14); + } + + #[tokio::test] + async fn test_transactions() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let transactions = commodity.transactions().await.unwrap(); + assert_eq!(transactions.len(), 11); + } + + #[tokio::test] + async fn test_as_commodity_prices() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let prices = commodity.as_commodity_prices().await.unwrap(); + assert_eq!(prices.len(), 1); + } + + #[tokio::test] + async fn test_as_currency_prices() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let prices = commodity.as_currency_prices().await.unwrap(); + assert_eq!(prices.len(), 2); + } + + #[tokio::test] + async fn test_as_commodity_or_currency_prices() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let prices = commodity.as_commodity_or_currency_prices().await.unwrap(); + assert_eq!(prices.len(), 3); + } + + #[tokio::test] + async fn test_rate_direct() { + // ADF => AED + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "d821d6776fde9f7c2d01b67876406fd3") + .unwrap(); + let currency = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "5f586908098232e67edb1371408bfaa8") + .unwrap(); + + let rate = commodity.sell(¤cy, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 1.5); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(15, 1)); + + let rate = currency.buy(&commodity, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 1.5); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(15, 1)); + + // AED => EUR + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "5f586908098232e67edb1371408bfaa8") + .unwrap(); + let currency = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + + let rate = commodity.sell(¤cy, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 9.0 / 10.0); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(9, 0) / Decimal::new(10, 0)); + + let rate = currency.buy(&commodity, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 9.0 / 10.0); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(9, 0) / Decimal::new(10, 0)); + } + + #[tokio::test] + async fn test_rate_indirect() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + // USD => AED + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "1e5d65e2726a5d4595741cb204992991") + .unwrap(); + let currency = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "5f586908098232e67edb1371408bfaa8") + .unwrap(); + + let rate = commodity.sell(¤cy, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 7.0 / 5.0 * 10.0 / 9.0); + #[cfg(feature = "decimal")] + assert_eq!( + rate, + (Decimal::new(7, 0) / Decimal::new(5, 0)) + * (Decimal::new(10, 0) / Decimal::new(9, 0)), + ); + } + } + #[cfg(feature = "mysql")] mod mysql { use super::*; diff --git a/src/model/price.rs b/src/model/price.rs index 1e75da3..35537b1 100644 --- a/src/model/price.rs +++ b/src/model/price.rs @@ -183,6 +183,89 @@ mod tests { } } + #[cfg(feature = "sqlitefaster")] + mod sqlitefaster { + use super::*; + + use pretty_assertions::assert_eq; + + use crate::query::sqlitefaster::price::Price as PriceBase; + use crate::SQLiteQueryFaster; + + #[allow(clippy::unused_async)] + async fn setup() -> SQLiteQueryFaster { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQueryFaster::new(uri).unwrap() + } + + #[tokio::test] + async fn test_from_with_query() { + let query = Arc::new(setup().await); + let item = PriceBase { + guid: "guid".to_string(), + commodity_guid: "commodity_guid".to_string(), + currency_guid: "currency_guid".to_string(), + date: NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S") + .unwrap(), + source: Some("source".to_string()), + r#type: Some("type".to_string()), + value_num: 1000, + value_denom: 10, + }; + + let result = Price::from_with_query(&item, query); + + assert_eq!(result.guid, "guid"); + assert_eq!(result.commodity_guid, "commodity_guid"); + assert_eq!(result.currency_guid, "currency_guid"); + assert_eq!( + result.datetime, + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!(result.source, "source"); + assert_eq!(result.r#type, "type"); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.value, 100.0); + #[cfg(feature = "decimal")] + assert_eq!(result.value, Decimal::new(100, 0)); + } + + #[tokio::test] + async fn commodity() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let price = book + .prices() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "0d6684f44fb018e882de76094ed9c433") + .unwrap(); + let commodity = price.commodity().await.unwrap(); + assert_eq!(commodity.fullname, "Andorran Franc"); + } + + #[tokio::test] + async fn currency() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let price = book + .prices() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "0d6684f44fb018e882de76094ed9c433") + .unwrap(); + let currency = price.currency().await.unwrap(); + assert_eq!(currency.fullname, "UAE Dirham"); + } + } + #[cfg(feature = "mysql")] mod mysql { use super::*; diff --git a/src/model/split.rs b/src/model/split.rs index 29d4cdc..9aac73a 100644 --- a/src/model/split.rs +++ b/src/model/split.rs @@ -201,6 +201,103 @@ mod tests { } } + #[cfg(feature = "sqlitefaster")] + mod sqlitefaster { + use super::*; + + use pretty_assertions::assert_eq; + + use crate::query::sqlitefaster::split::Split as SplitBase; + use crate::SQLiteQueryFaster; + + #[allow(clippy::unused_async)] + async fn setup() -> SQLiteQueryFaster { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQueryFaster::new(uri).unwrap() + } + + #[tokio::test] + async fn test_from_with_query() { + let query = Arc::new(setup().await); + let item = SplitBase { + guid: "guid".to_string(), + tx_guid: "tx_guid".to_string(), + account_guid: "account_guid".to_string(), + memo: "memo".to_string(), + action: "action".to_string(), + reconcile_state: "n".to_string(), + reconcile_date: NaiveDateTime::parse_from_str( + "2014-12-24 10:59:00", + "%Y-%m-%d %H:%M:%S", + ) + .ok(), + lot_guid: Some("lot_guid".to_string()), + + value_num: 1000, + value_denom: 10, + quantity_num: 1100, + quantity_denom: 10, + }; + + let result = Split::from_with_query(&item, query); + + assert_eq!(result.guid, "guid"); + assert_eq!(result.tx_guid, "tx_guid"); + assert_eq!(result.account_guid, "account_guid"); + assert_eq!(result.memo, "memo"); + assert_eq!(result.action, "action"); + assert_eq!(result.reconcile_state, false); + assert_eq!( + result.reconcile_datetime, + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S").ok() + ); + assert_eq!(result.lot_guid, "lot_guid"); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.value, 100.0); + #[cfg(feature = "decimal")] + assert_eq!(result.value, Decimal::new(100, 0)); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.quantity, 110.0); + #[cfg(feature = "decimal")] + assert_eq!(result.quantity, Decimal::new(110, 0)); + } + + #[tokio::test] + async fn transaction() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let split = book + .splits() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "de832fe97e37811a7fff7e28b3a43425") + .unwrap(); + let transaction = split.transaction().await.unwrap(); + assert_eq!(transaction.description, "income 1"); + } + + #[tokio::test] + async fn account() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let split = book + .splits() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "de832fe97e37811a7fff7e28b3a43425") + .unwrap(); + let account = split.account().await.unwrap(); + assert_eq!(account.name, "Cash"); + } + } + #[cfg(feature = "mysql")] mod mysql { use super::*; diff --git a/src/model/transaction.rs b/src/model/transaction.rs index d0c22d6..58406e1 100644 --- a/src/model/transaction.rs +++ b/src/model/transaction.rs @@ -163,6 +163,93 @@ mod tests { } } + #[cfg(feature = "sqlitefaster")] + mod sqlitefaster { + use super::*; + + use pretty_assertions::assert_eq; + + use crate::query::sqlitefaster::transaction::Transaction as TransactionBase; + use crate::SQLiteQueryFaster; + + #[allow(clippy::unused_async)] + async fn setup() -> SQLiteQueryFaster { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQueryFaster::new(uri).unwrap() + } + + #[tokio::test] + async fn test_from_with_query() { + let query = Arc::new(setup().await); + let item = TransactionBase { + guid: "guid".to_string(), + currency_guid: "currency_guid".to_string(), + num: "currency_guid".to_string(), + post_date: NaiveDateTime::parse_from_str( + "2014-12-24 10:59:00", + "%Y-%m-%d %H:%M:%S", + ) + .ok(), + enter_date: NaiveDateTime::parse_from_str( + "2014-12-24 10:59:00", + "%Y-%m-%d %H:%M:%S", + ) + .ok(), + description: Some("source".to_string()), + }; + + let result = Transaction::from_with_query(&item, query); + + assert_eq!(result.guid, "guid"); + assert_eq!(result.currency_guid, "currency_guid"); + assert_eq!(result.num, "currency_guid"); + assert_eq!( + result.post_datetime, + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!( + result.enter_datetime, + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!(result.description, "source"); + } + + #[tokio::test] + async fn currency() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let transaction = book + .transactions() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "6c8876003c4a6026e38e3afb67d6f2b1") + .unwrap(); + let currency = transaction.currency().await.unwrap(); + assert_eq!(currency.fullname, "Euro"); + } + + #[tokio::test] + async fn splits() { + let query = setup().await; + let book = Book::new(query).await.unwrap(); + let transaction = book + .transactions() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "6c8876003c4a6026e38e3afb67d6f2b1") + .unwrap(); + let splits = transaction.splits().await.unwrap(); + assert_eq!(splits.len(), 2); + } + } + #[cfg(feature = "mysql")] mod mysql { use super::*; diff --git a/src/query.rs b/src/query.rs index 1890c6e..1e18922 100644 --- a/src/query.rs +++ b/src/query.rs @@ -4,6 +4,8 @@ pub(crate) mod mysql; pub(crate) mod postgresql; #[cfg(feature = "sqlite")] pub(crate) mod sqlite; +#[cfg(feature = "sqlitefaster")] +pub(crate) mod sqlitefaster; #[cfg(feature = "xml")] pub(crate) mod xml; @@ -633,6 +635,439 @@ mod tests { } } + #[cfg(feature = "sqlitefaster")] + mod sqlitefaster { + use super::*; + + use crate::SQLiteQueryFaster; + + static Q: OnceCell = OnceCell::const_new(); + async fn setup() -> &'static SQLiteQueryFaster { + Q.get_or_init(|| async { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQueryFaster::new(uri).unwrap() + }) + .await + } + + mod query { + use super::*; + + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn test_accounts() { + let query = setup().await; + let result = query.accounts().await.unwrap(); + assert_eq!(result.len(), 21); + } + + #[tokio::test] + async fn test_accounts_contains_name() { + let query = setup().await; + let result = query + .accounts_contains_name_ignore_case("aS") + .await + .unwrap(); + assert_eq!(result.len(), 3); + } + + #[tokio::test] + async fn test_splits() { + let query = setup().await; + let result = query.splits().await.unwrap(); + assert_eq!(result.len(), 25); + } + + #[tokio::test] + async fn test_transactions() { + let query = setup().await; + let result = query.transactions().await.unwrap(); + assert_eq!(result.len(), 11); + } + + #[tokio::test] + async fn test_prices() { + let query = setup().await; + let result = query.prices().await.unwrap(); + assert_eq!(result.len(), 5); + } + + #[tokio::test] + async fn test_commodities() { + let query = setup().await; + let result = query.commodities().await.unwrap(); + assert_eq!(result.len(), 5); + } + + #[tokio::test] + async fn test_currencies() { + let query = setup().await; + let result = query.currencies().await.unwrap(); + assert_eq!(result.len(), 4); + } + } + mod account_q { + use super::*; + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = AccountQ::all(query).await.unwrap(); + assert_eq!(result.len(), 21); + } + + #[tokio::test] + async fn test_guid() { + let query = setup().await; + let result = AccountQ::guid(query, "fcd795021c976ba75621ec39e75f6214") + .await + .unwrap(); + assert_eq!(result[0].guid, "fcd795021c976ba75621ec39e75f6214"); + } + + #[tokio::test] + async fn test_commodity_guid() { + let query = setup().await; + let result = AccountQ::commodity_guid(query, "346629655191dcf59a7e2c2a85b70f69") + .await + .unwrap(); + assert!( + result + .iter() + .map(|x| &x.guid) + .any(|guid| guid == "fcd795021c976ba75621ec39e75f6214"), + "result does not contains fcd795021c976ba75621ec39e75f6214" + ); + } + + #[tokio::test] + async fn test_parent_guid() { + let query = setup().await; + let result = AccountQ::parent_guid(query, "fcd795021c976ba75621ec39e75f6214") + .await + .unwrap(); + assert_eq!(result[0].guid, "3bc319753945b6dba3e1928abed49e35"); + } + + #[tokio::test] + async fn test_name() { + let query = setup().await; + let result = AccountQ::name(query, "Asset").await.unwrap(); + assert_eq!(result[0].guid, "fcd795021c976ba75621ec39e75f6214"); + } + + #[tokio::test] + async fn test_contains_name_ignore_case() { + let query = setup().await; + let result = AccountQ::contains_name_ignore_case(query, "aS") + .await + .unwrap(); + assert_eq!(result.len(), 3); + } + } + mod commodity_q { + use super::*; + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = CommodityQ::all(query).await.unwrap(); + assert_eq!(result.len(), 5); + } + + #[tokio::test] + async fn test_guid() { + let query = setup().await; + let result = CommodityQ::guid(query, "346629655191dcf59a7e2c2a85b70f69") + .await + .unwrap(); + assert_eq!(result[0].fullname.as_ref().unwrap(), "Euro"); + } + + #[tokio::test] + async fn test_namespace() { + let query = setup().await; + let result = CommodityQ::namespace(query, "CURRENCY").await.unwrap(); + assert_eq!(result.len(), 4); + } + } + mod price_q { + use super::*; + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = PriceQ::all(query).await.unwrap(); + assert_eq!(result.len(), 5); + } + + #[tokio::test] + async fn test_guid() { + let query = setup().await; + let result = PriceQ::guid(query, "0d6684f44fb018e882de76094ed9c433") + .await + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result[0].value(), 1.5); + #[cfg(feature = "decimal")] + assert_eq!(result[0].value(), Decimal::new(15, 1)); + } + + #[tokio::test] + async fn commodity_guid() { + let query = setup().await; + let result = PriceQ::commodity_guid(query, "d821d6776fde9f7c2d01b67876406fd3") + .await + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result[0].value(), 1.5); + #[cfg(feature = "decimal")] + assert_eq!(result[0].value(), Decimal::new(15, 1)); + } + + #[tokio::test] + async fn currency_guid() { + let query = setup().await; + let result = PriceQ::currency_guid(query, "5f586908098232e67edb1371408bfaa8") + .await + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result[0].value(), 1.5); + #[cfg(feature = "decimal")] + assert_eq!(result[0].value(), Decimal::new(15, 1)); + } + + #[tokio::test] + async fn commodity_or_currency_guid() { + let query = setup().await; + let result = + PriceQ::commodity_or_currency_guid(query, "5f586908098232e67edb1371408bfaa8") + .await + .unwrap(); + assert_eq!(result.len(), 4); + } + } + mod split_q { + use super::*; + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = SplitQ::all(query).await.unwrap(); + assert_eq!(result.len(), 25); + } + + #[tokio::test] + async fn test_guid() { + let query = setup().await; + let result = SplitQ::guid(query, "de832fe97e37811a7fff7e28b3a43425") + .await + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result[0].value(), 150.0); + #[cfg(feature = "decimal")] + assert_eq!(result[0].value(), Decimal::new(150, 0)); + } + + #[tokio::test] + async fn test_account_guid() { + let query = setup().await; + let result = SplitQ::account_guid(query, "93fc043c3062aaa1297b30e543d2cd0d") + .await + .unwrap(); + assert_eq!(result.len(), 3); + } + + #[tokio::test] + async fn test_tx_guid() { + let query = setup().await; + let result = SplitQ::tx_guid(query, "6c8876003c4a6026e38e3afb67d6f2b1") + .await + .unwrap(); + assert_eq!(result.len(), 2); + } + } + mod transaction_q { + use super::*; + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = TransactionQ::all(query).await.unwrap(); + assert_eq!(result.len(), 11); + } + + #[tokio::test] + async fn test_by_guid() { + let query = setup().await; + let result = TransactionQ::guid(query, "6c8876003c4a6026e38e3afb67d6f2b1") + .await + .unwrap(); + + assert_eq!( + result[0].post_date.unwrap(), + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + ); + + assert_eq!( + result[0].enter_date.unwrap(), + NaiveDateTime::parse_from_str("2014-12-25 10:08:15", "%Y-%m-%d %H:%M:%S") + .unwrap() + ); + } + + #[tokio::test] + async fn test_currency_guid() { + let query = setup().await; + let result = TransactionQ::currency_guid(query, "346629655191dcf59a7e2c2a85b70f69") + .await + .unwrap(); + + assert_eq!(result.len(), 11); + } + } + mod account_t { + use super::*; + + #[tokio::test] + async fn test_trait_fn() { + let query = setup().await; + let result = AccountQ::guid(query, "fcd795021c976ba75621ec39e75f6214") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "fcd795021c976ba75621ec39e75f6214"); + assert_eq!(result.name(), "Asset"); + assert_eq!(result.account_type(), "ASSET"); + assert_eq!(result.commodity_guid(), "346629655191dcf59a7e2c2a85b70f69"); + assert_eq!(result.commodity_scu(), 100); + assert!(!result.non_std_scu()); + assert_eq!(result.parent_guid(), "00622dda21937b29e494179de5013f82"); + assert_eq!(result.code(), ""); + assert_eq!(result.description(), ""); + assert!(!result.hidden()); + assert!(result.placeholder()); + } + } + mod commodity_t { + use super::*; + + #[tokio::test] + async fn test_trait_fn() { + let query = setup().await; + let result = CommodityQ::guid(query, "346629655191dcf59a7e2c2a85b70f69") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "346629655191dcf59a7e2c2a85b70f69"); + assert_eq!(result.namespace(), "CURRENCY"); + assert_eq!(result.mnemonic(), "EUR"); + assert_eq!(result.fullname(), "Euro"); + assert_eq!(result.cusip(), "978"); + assert_eq!(result.fraction(), 100); + assert!(result.quote_flag()); + assert_eq!(result.quote_source(), "currency"); + assert_eq!(result.quote_tz(), ""); + } + } + mod price_t { + use super::*; + + #[tokio::test] + async fn test_trait_fn() { + let query = setup().await; + let result = PriceQ::guid(query, "0d6684f44fb018e882de76094ed9c433") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "0d6684f44fb018e882de76094ed9c433"); + assert_eq!(result.commodity_guid(), "d821d6776fde9f7c2d01b67876406fd3"); + assert_eq!(result.currency_guid(), "5f586908098232e67edb1371408bfaa8"); + assert_eq!( + result.datetime(), + NaiveDateTime::parse_from_str("2018-02-20 23:00:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + ); + assert_eq!(result.source(), "user:price-editor"); + assert_eq!(result.r#type(), "unknown"); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.value(), 1.5); + #[cfg(feature = "decimal")] + assert_eq!(result.value(), Decimal::new(15, 1)); + } + } + mod split_t { + use super::*; + + #[tokio::test] + async fn test_trait_fn() { + let query = setup().await; + let result = SplitQ::guid(query, "de832fe97e37811a7fff7e28b3a43425") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "de832fe97e37811a7fff7e28b3a43425"); + assert_eq!(result.tx_guid(), "6c8876003c4a6026e38e3afb67d6f2b1"); + assert_eq!(result.account_guid(), "93fc043c3062aaa1297b30e543d2cd0d"); + assert_eq!(result.memo(), ""); + assert_eq!(result.action(), ""); + assert!(!result.reconcile_state()); + assert_eq!(result.reconcile_datetime(), None); + assert_eq!(result.lot_guid(), ""); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.value(), 150.0); + #[cfg(feature = "decimal")] + assert_eq!(result.value(), Decimal::new(150, 0)); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.quantity(), 150.0); + #[cfg(feature = "decimal")] + assert_eq!(result.quantity(), Decimal::new(150, 0)); + } + } + mod transaction_t { + use super::*; + + #[tokio::test] + async fn test_trait_fn() { + let query = setup().await; + let result = TransactionQ::guid(query, "6c8876003c4a6026e38e3afb67d6f2b1") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "6c8876003c4a6026e38e3afb67d6f2b1"); + assert_eq!(result.currency_guid(), "346629655191dcf59a7e2c2a85b70f69"); + assert_eq!(result.num(), ""); + assert_eq!( + result.post_datetime(), + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S") + .unwrap() + ); + assert_eq!( + result.enter_datetime(), + NaiveDateTime::parse_from_str("2014-12-25 10:08:15", "%Y-%m-%d %H:%M:%S") + .unwrap() + ); + assert_eq!(result.description(), "income 1"); + } + } + } + #[cfg(feature = "mysql")] mod mysql { use super::*; diff --git a/src/query/sqlitefaster.rs b/src/query/sqlitefaster.rs new file mode 100644 index 0000000..7f9ba13 --- /dev/null +++ b/src/query/sqlitefaster.rs @@ -0,0 +1,56 @@ +pub(crate) mod account; +pub(crate) mod commodity; +pub(crate) mod price; +pub(crate) mod split; +pub(crate) mod transaction; + +use rusqlite::{Connection, OpenFlags}; +use std::sync::{Arc, Mutex}; + +use super::Query; +use crate::error::Error; + +#[derive(Debug, Clone)] +pub struct SQLiteQuery { + conn: Arc>, +} + +impl SQLiteQuery { + /// Options and flags which can be used to configure a `SQLite` connection. + /// Described by [`SQLite`](https://www.sqlite.org/uri.html). + /// + /// | URI | Description | + /// | -- | -- | + /// `file::memory:` | Open an in-memory database. | + /// `path-to-db/data.db` | Open the file `data.db` | + /// `file:/path-to-db/data.db` | Open the file `data.db` | + pub fn new(uri: &str) -> Result { + let conn = Connection::open_with_flags( + uri, + OpenFlags::SQLITE_OPEN_READ_ONLY + | OpenFlags::SQLITE_OPEN_URI + | OpenFlags::SQLITE_OPEN_NO_MUTEX, + )?; + let conn = Arc::new(Mutex::new(conn)); + + Ok(Self { conn }) + } +} + +impl Query for SQLiteQuery {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQuery::new(uri).unwrap(); + } +} diff --git a/src/query/sqlitefaster/account.rs b/src/query/sqlitefaster/account.rs new file mode 100644 index 0000000..7059cd4 --- /dev/null +++ b/src/query/sqlitefaster/account.rs @@ -0,0 +1,258 @@ +// ref: https://piecash.readthedocs.io/en/master/object_model.html +// ref: https://wiki.gnucash.org/wiki/SQL + +use rusqlite::Row; + +use super::SQLiteQuery; +use crate::error::Error; +use crate::query::{AccountQ, AccountT}; + +#[allow(clippy::struct_field_names)] +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Hash)] +pub struct Account { + pub(crate) guid: String, + pub(crate) name: String, + pub(crate) account_type: String, + pub(crate) commodity_guid: Option, + pub(crate) commodity_scu: i64, + pub(crate) non_std_scu: i64, + pub(crate) parent_guid: Option, + pub(crate) code: Option, + pub(crate) description: Option, + pub(crate) hidden: Option, + pub(crate) placeholder: Option, +} + +impl<'a> TryFrom<&'a Row<'a>> for Account { + type Error = rusqlite::Error; + + fn try_from(row: &'a Row<'a>) -> Result { + Ok(Self { + guid: row.get(0)?, + name: row.get(1)?, + account_type: row.get(2)?, + commodity_guid: row.get(3)?, + commodity_scu: row.get(4)?, + non_std_scu: row.get(5)?, + parent_guid: row.get(6)?, + code: row.get(7)?, + description: row.get(8)?, + hidden: row.get(9)?, + placeholder: row.get(10)?, + }) + } +} + +impl AccountT for Account { + fn guid(&self) -> String { + self.guid.clone() + } + fn name(&self) -> String { + self.name.clone() + } + fn account_type(&self) -> String { + self.account_type.clone() + } + fn commodity_guid(&self) -> String { + self.commodity_guid.clone().unwrap_or_default() + } + fn commodity_scu(&self) -> i64 { + self.commodity_scu + } + fn non_std_scu(&self) -> bool { + self.non_std_scu != 0 + } + fn parent_guid(&self) -> String { + self.parent_guid.clone().unwrap_or_default() + } + fn code(&self) -> String { + self.code.clone().unwrap_or_default() + } + fn description(&self) -> String { + self.description.clone().unwrap_or_default() + } + fn hidden(&self) -> bool { + self.hidden.is_some_and(|x| x != 0) + } + fn placeholder(&self) -> bool { + self.placeholder.is_some_and(|x| x != 0) + } +} + +const SEL: &str = r" +SELECT +guid, +name, +account_type, +commodity_guid, +commodity_scu, +non_std_scu, +parent_guid, +code, +description, +hidden, +placeholder +FROM accounts +"; + +impl AccountQ for SQLiteQuery { + type A = Account; + + async fn all(&self) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(SEL)?; + let result = stmt + .query([])? + .mapped(|row| Account::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Account::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn commodity_guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE commodity_guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Account::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn parent_guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE parent_guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Account::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn name(&self, name: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE name = ?"))?; + let result = stmt + .query([name])? + .mapped(|row| Account::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn contains_name_ignore_case(&self, name: &str) -> Result, Error> { + let name = format!("%{name}%"); + + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE name LIKE ?"))?; + let result = stmt + .query([name])? + .mapped(|row| Account::try_from(row)) + .collect::, _>>()?; + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + use tokio::sync::OnceCell; + + static Q: OnceCell = OnceCell::const_new(); + async fn setup() -> &'static SQLiteQuery { + Q.get_or_init(|| async { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQuery::new(uri).unwrap() + }) + .await + } + + #[tokio::test] + async fn test_account() { + let query = setup().await; + let result = query + .guid("fcd795021c976ba75621ec39e75f6214") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "fcd795021c976ba75621ec39e75f6214"); + assert_eq!(result.name(), "Asset"); + assert_eq!(result.account_type(), "ASSET"); + assert_eq!(result.commodity_guid(), "346629655191dcf59a7e2c2a85b70f69"); + assert_eq!(result.commodity_scu(), 100); + assert_eq!(result.non_std_scu(), false); + assert_eq!(result.parent_guid(), "00622dda21937b29e494179de5013f82"); + assert_eq!(result.code(), ""); + assert_eq!(result.description(), ""); + assert_eq!(result.hidden(), false); + assert_eq!(result.placeholder(), true); + } + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = query.all().await.unwrap(); + assert_eq!(result.len(), 21); + } + + #[tokio::test] + async fn test_guid() { + let query = setup().await; + let result = query + .guid("fcd795021c976ba75621ec39e75f6214") + .await + .unwrap(); + + assert_eq!(result[0].name, "Asset"); + } + + #[tokio::test] + async fn test_commodity_guid() { + let query = setup().await; + let result = query + .commodity_guid("346629655191dcf59a7e2c2a85b70f69") + .await + .unwrap(); + assert_eq!(result.len(), 14); + } + + #[tokio::test] + async fn test_parent_guid() { + let query = setup().await; + let result = query + .parent_guid("fcd795021c976ba75621ec39e75f6214") + .await + .unwrap(); + assert_eq!(result.len(), 3); + } + + #[tokio::test] + async fn test_name() { + let query = setup().await; + let result = query.name("Asset").await.unwrap(); + assert_eq!(result[0].guid, "fcd795021c976ba75621ec39e75f6214"); + } + + #[tokio::test] + async fn test_contains_name_ignore_case() { + let query = setup().await; + let result = query.contains_name_ignore_case("AS").await.unwrap(); + assert_eq!(result.len(), 3); + } +} diff --git a/src/query/sqlitefaster/commodity.rs b/src/query/sqlitefaster/commodity.rs new file mode 100644 index 0000000..e591c25 --- /dev/null +++ b/src/query/sqlitefaster/commodity.rs @@ -0,0 +1,182 @@ +// ref: https://piecash.readthedocs.io/en/master/object_model.html +// ref: https://wiki.gnucash.org/wiki/SQL +use rusqlite::Row; + +use super::SQLiteQuery; +use crate::error::Error; +use crate::query::{CommodityQ, CommodityT}; + +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Hash)] +pub struct Commodity { + pub(crate) guid: String, + pub(crate) namespace: String, + pub(crate) mnemonic: String, + pub(crate) fullname: Option, + pub(crate) cusip: Option, + pub(crate) fraction: i64, + pub(crate) quote_flag: i64, + pub(crate) quote_source: Option, + pub(crate) quote_tz: Option, +} + +impl<'a> TryFrom<&'a Row<'a>> for Commodity { + type Error = rusqlite::Error; + + fn try_from(row: &'a Row<'a>) -> Result { + Ok(Self { + guid: row.get(0)?, + namespace: row.get(1)?, + mnemonic: row.get(2)?, + fullname: row.get(3)?, + cusip: row.get(4)?, + fraction: row.get(5)?, + quote_flag: row.get(6)?, + quote_source: row.get(7)?, + quote_tz: row.get(8)?, + }) + } +} + +impl CommodityT for Commodity { + fn guid(&self) -> String { + self.guid.clone() + } + fn namespace(&self) -> String { + self.namespace.clone() + } + fn mnemonic(&self) -> String { + self.mnemonic.clone() + } + fn fullname(&self) -> String { + self.fullname.clone().unwrap_or_default() + } + fn cusip(&self) -> String { + self.cusip.clone().unwrap_or_default() + } + fn fraction(&self) -> i64 { + self.fraction + } + fn quote_flag(&self) -> bool { + self.quote_flag != 0 + } + fn quote_source(&self) -> String { + self.quote_source.clone().unwrap_or_default() + } + fn quote_tz(&self) -> String { + self.quote_tz.clone().unwrap_or_default() + } +} + +const SEL: &str = r" +SELECT +guid, +namespace, +mnemonic, +fullname, +cusip, +fraction, +quote_flag, +quote_source, +quote_tz +FROM commodities +"; + +impl CommodityQ for SQLiteQuery { + type C = Commodity; + + async fn all(&self) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(SEL)?; + let result = stmt + .query([])? + .mapped(|row| Commodity::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Commodity::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn namespace(&self, namespace: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE namespace = ?"))?; + let result = stmt + .query([namespace])? + .mapped(|row| Commodity::try_from(row)) + .collect::, _>>()?; + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + use tokio::sync::OnceCell; + + static Q: OnceCell = OnceCell::const_new(); + async fn setup() -> &'static SQLiteQuery { + Q.get_or_init(|| async { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQuery::new(uri).unwrap() + }) + .await + } + + #[tokio::test] + async fn test_commodity() { + let query = setup().await; + let result = query + .guid("346629655191dcf59a7e2c2a85b70f69") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "346629655191dcf59a7e2c2a85b70f69"); + assert_eq!(result.namespace(), "CURRENCY"); + assert_eq!(result.mnemonic(), "EUR"); + assert_eq!(result.fullname(), "Euro"); + assert_eq!(result.cusip(), "978"); + assert_eq!(result.fraction(), 100); + assert_eq!(result.quote_flag(), true); + assert_eq!(result.quote_source(), "currency"); + assert_eq!(result.quote_tz(), ""); + } + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = query.all().await.unwrap(); + assert_eq!(result.len(), 5); + } + + #[tokio::test] + async fn test_guid() { + let query = setup().await; + let result = query + .guid("346629655191dcf59a7e2c2a85b70f69") + .await + .unwrap(); + assert_eq!(result[0].fullname.as_ref().unwrap(), "Euro"); + } + + #[tokio::test] + async fn test_namespace() { + let query = setup().await; + let result = query.namespace("CURRENCY").await.unwrap(); + assert_eq!(result.len(), 4); + } +} diff --git a/src/query/sqlitefaster/price.rs b/src/query/sqlitefaster/price.rs new file mode 100644 index 0000000..29a9e04 --- /dev/null +++ b/src/query/sqlitefaster/price.rs @@ -0,0 +1,246 @@ +// ref: https://piecash.readthedocs.io/en/master/object_model.html +// ref: https://wiki.gnucash.org/wiki/SQL + +use chrono::NaiveDateTime; +use rusqlite::Row; +#[cfg(feature = "decimal")] +use rust_decimal::Decimal; + +use super::SQLiteQuery; +use crate::error::Error; +use crate::query::{PriceQ, PriceT}; + +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Hash)] +pub struct Price { + pub guid: String, + pub commodity_guid: String, + pub currency_guid: String, + pub date: NaiveDateTime, + pub source: Option, + pub r#type: Option, + pub value_num: i64, + pub value_denom: i64, +} + +impl<'a> TryFrom<&'a Row<'a>> for Price { + type Error = rusqlite::Error; + + fn try_from(row: &'a Row<'a>) -> Result { + Ok(Self { + guid: row.get(0)?, + commodity_guid: row.get(1)?, + currency_guid: row.get(2)?, + date: row.get(3)?, + source: row.get(4)?, + r#type: row.get(5)?, + value_num: row.get(6)?, + value_denom: row.get(7)?, + }) + } +} + +impl PriceT for Price { + fn guid(&self) -> String { + self.guid.clone() + } + fn commodity_guid(&self) -> String { + self.commodity_guid.clone() + } + fn currency_guid(&self) -> String { + self.currency_guid.clone() + } + fn datetime(&self) -> NaiveDateTime { + self.date + } + fn source(&self) -> String { + self.source.clone().unwrap_or_default() + } + fn r#type(&self) -> String { + self.r#type.clone().unwrap_or_default() + } + + #[cfg(not(feature = "decimal"))] + #[must_use] + #[allow(clippy::cast_precision_loss)] + fn value(&self) -> f64 { + self.value_num as f64 / self.value_denom as f64 + } + + #[cfg(feature = "decimal")] + #[must_use] + fn value(&self) -> Decimal { + Decimal::new(self.value_num, 0) / Decimal::new(self.value_denom, 0) + } +} + +const SEL: &str = r" +SELECT +guid, +commodity_guid, +currency_guid, +date, +source, +type, +value_num, +value_denom +FROM prices +"; + +impl PriceQ for SQLiteQuery { + type P = Price; + + async fn all(&self) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(SEL)?; + let result = stmt + .query([])? + .mapped(|row| Price::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + async fn guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Price::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + async fn commodity_guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE commodity_guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Price::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + async fn currency_guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE currency_guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Price::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + async fn commodity_or_currency_guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!( + "{SEL}\nWHERE commodity_guid = ? OR currency_guid = ?" + ))?; + let result = stmt + .query([guid, guid])? + .mapped(|row| Price::try_from(row)) + .collect::, _>>()?; + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[cfg(not(feature = "decimal"))] + use float_cmp::assert_approx_eq; + use pretty_assertions::assert_eq; + use tokio::sync::OnceCell; + + static Q: OnceCell = OnceCell::const_new(); + async fn setup() -> &'static SQLiteQuery { + Q.get_or_init(|| async { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQuery::new(uri).unwrap() + }) + .await + } + + #[tokio::test] + async fn test_price() { + let query = setup().await; + let result = query + .guid("0d6684f44fb018e882de76094ed9c433") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "0d6684f44fb018e882de76094ed9c433"); + assert_eq!(result.commodity_guid(), "d821d6776fde9f7c2d01b67876406fd3"); + assert_eq!(result.currency_guid(), "5f586908098232e67edb1371408bfaa8"); + assert_eq!( + result.datetime(), + NaiveDateTime::parse_from_str("2018-02-20 23:00:00", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!(result.source(), "user:price-editor"); + assert_eq!(result.r#type(), "unknown"); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.value(), 1.5); + #[cfg(feature = "decimal")] + assert_eq!(result.value(), Decimal::new(15, 1)); + } + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = query.all().await.unwrap(); + assert_eq!(result.len(), 5); + } + + #[tokio::test] + async fn test_guid() { + let query = setup().await; + let result = query + .guid("0d6684f44fb018e882de76094ed9c433") + .await + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result[0].value(), 1.5); + #[cfg(feature = "decimal")] + assert_eq!(result[0].value(), Decimal::new(15, 1)); + } + + #[tokio::test] + async fn commodity_guid() { + let query = setup().await; + let result = query + .commodity_guid("d821d6776fde9f7c2d01b67876406fd3") + .await + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result[0].value(), 1.5); + #[cfg(feature = "decimal")] + assert_eq!(result[0].value(), Decimal::new(15, 1)); + } + + #[tokio::test] + async fn currency_guid() { + let query = setup().await; + let result = query + .currency_guid("5f586908098232e67edb1371408bfaa8") + .await + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result[0].value(), 1.5); + #[cfg(feature = "decimal")] + assert_eq!(result[0].value(), Decimal::new(15, 1)); + } + + #[tokio::test] + async fn commodity_or_currency_guid() { + let query = setup().await; + let result = query + .commodity_or_currency_guid("5f586908098232e67edb1371408bfaa8") + .await + .unwrap(); + assert_eq!(result.len(), 4); + } +} diff --git a/src/query/sqlitefaster/split.rs b/src/query/sqlitefaster/split.rs new file mode 100644 index 0000000..533edee --- /dev/null +++ b/src/query/sqlitefaster/split.rs @@ -0,0 +1,259 @@ +// ref: https://piecash.readthedocs.io/en/master/object_model.html +// ref: https://wiki.gnucash.org/wiki/SQL + +use chrono::NaiveDateTime; +use rusqlite::Row; +#[cfg(feature = "decimal")] +use rust_decimal::Decimal; + +use super::SQLiteQuery; +use crate::error::Error; +use crate::query::{SplitQ, SplitT}; + +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Hash)] +pub struct Split { + pub guid: String, + pub tx_guid: String, + pub account_guid: String, + pub memo: String, + pub action: String, + pub reconcile_state: String, + pub reconcile_date: Option, + pub value_num: i64, + pub value_denom: i64, + pub quantity_num: i64, + pub quantity_denom: i64, + pub lot_guid: Option, +} + +impl<'a> TryFrom<&'a Row<'a>> for Split { + type Error = rusqlite::Error; + + fn try_from(row: &'a Row<'a>) -> Result { + Ok(Self { + guid: row.get(0)?, + tx_guid: row.get(1)?, + account_guid: row.get(2)?, + memo: row.get(3)?, + action: row.get(4)?, + reconcile_state: row.get(5)?, + reconcile_date: row.get(6)?, + value_num: row.get(7)?, + value_denom: row.get(8)?, + quantity_num: row.get(9)?, + quantity_denom: row.get(10)?, + lot_guid: row.get(11)?, + }) + } +} + +impl SplitT for Split { + fn guid(&self) -> String { + self.guid.clone() + } + fn tx_guid(&self) -> String { + self.tx_guid.clone() + } + fn account_guid(&self) -> String { + self.account_guid.clone() + } + fn memo(&self) -> String { + self.memo.clone() + } + fn action(&self) -> String { + self.action.clone() + } + fn reconcile_state(&self) -> bool { + self.reconcile_state == "y" || self.reconcile_state == "Y" + } + fn reconcile_datetime(&self) -> Option { + let datetime = self.reconcile_date?; + if datetime == NaiveDateTime::UNIX_EPOCH { + return None; + } + Some(datetime) + } + fn lot_guid(&self) -> String { + self.lot_guid.clone().unwrap_or_default() + } + + #[cfg(not(feature = "decimal"))] + #[must_use] + #[allow(clippy::cast_precision_loss)] + fn value(&self) -> f64 { + self.value_num as f64 / self.value_denom as f64 + } + + #[cfg(feature = "decimal")] + #[must_use] + fn value(&self) -> Decimal { + Decimal::new(self.value_num, 0) / Decimal::new(self.value_denom, 0) + } + + #[cfg(not(feature = "decimal"))] + #[must_use] + #[allow(clippy::cast_precision_loss)] + fn quantity(&self) -> f64 { + self.quantity_num as f64 / self.quantity_denom as f64 + } + + #[cfg(feature = "decimal")] + #[must_use] + fn quantity(&self) -> Decimal { + Decimal::new(self.quantity_num, 0) / Decimal::new(self.quantity_denom, 0) + } +} + +const SEL: &str = r" +SELECT +guid, +tx_guid, +account_guid, +memo, +action, +reconcile_state, +reconcile_date, +value_num, +value_denom, +quantity_num, +quantity_denom, +lot_guid +FROM splits +"; + +impl SplitQ for SQLiteQuery { + type S = Split; + + async fn all(&self) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(SEL)?; + let result = stmt + .query([])? + .mapped(|row| Split::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Split::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn account_guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE account_guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Split::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn tx_guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE tx_guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Split::try_from(row)) + .collect::, _>>()?; + Ok(result) + } +} + +#[cfg(test)] + +mod tests { + use super::*; + + #[cfg(not(feature = "decimal"))] + use float_cmp::assert_approx_eq; + use pretty_assertions::assert_eq; + use tokio::sync::OnceCell; + + static Q: OnceCell = OnceCell::const_new(); + async fn setup() -> &'static SQLiteQuery { + Q.get_or_init(|| async { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQuery::new(uri).unwrap() + }) + .await + } + + #[tokio::test] + async fn test_split() { + let query = setup().await; + let result = query + .guid("de832fe97e37811a7fff7e28b3a43425") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "de832fe97e37811a7fff7e28b3a43425"); + assert_eq!(result.tx_guid(), "6c8876003c4a6026e38e3afb67d6f2b1"); + assert_eq!(result.account_guid(), "93fc043c3062aaa1297b30e543d2cd0d"); + assert_eq!(result.memo(), ""); + assert_eq!(result.action(), ""); + assert_eq!(result.reconcile_state(), false); + assert_eq!(result.reconcile_datetime(), None); + assert_eq!(result.lot_guid(), ""); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.value(), 150.0); + #[cfg(feature = "decimal")] + assert_eq!(result.value(), Decimal::new(150, 0)); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result.quantity(), 150.0); + #[cfg(feature = "decimal")] + assert_eq!(result.quantity(), Decimal::new(150, 0)); + } + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = query.all().await.unwrap(); + assert_eq!(result.len(), 25); + } + + #[tokio::test] + async fn test_guid() { + let query = setup().await; + let result = query + .guid("de832fe97e37811a7fff7e28b3a43425") + .await + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, result[0].value(), 150.0); + #[cfg(feature = "decimal")] + assert_eq!(result[0].value(), Decimal::new(150, 0)); + } + + #[tokio::test] + async fn test_account_guid() { + let query = setup().await; + let result = query + .account_guid("93fc043c3062aaa1297b30e543d2cd0d") + .await + .unwrap(); + assert_eq!(result.len(), 3); + } + + #[tokio::test] + async fn test_tx_guid() { + let query = setup().await; + let result = query + .tx_guid("6c8876003c4a6026e38e3afb67d6f2b1") + .await + .unwrap(); + assert_eq!(result.len(), 2); + } +} diff --git a/src/query/sqlitefaster/transaction.rs b/src/query/sqlitefaster/transaction.rs new file mode 100644 index 0000000..34c2f45 --- /dev/null +++ b/src/query/sqlitefaster/transaction.rs @@ -0,0 +1,183 @@ +// ref: https://piecash.readthedocs.io/en/master/object_model.html +// ref: https://wiki.gnucash.org/wiki/SQL + +use chrono::NaiveDateTime; +use rusqlite::Row; + +use super::SQLiteQuery; +use crate::error::Error; +use crate::query::{TransactionQ, TransactionT}; + +#[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Hash)] +pub struct Transaction { + pub guid: String, + pub currency_guid: String, + pub num: String, + pub post_date: Option, + pub enter_date: Option, + pub description: Option, +} + +impl<'a> TryFrom<&'a Row<'a>> for Transaction { + type Error = rusqlite::Error; + + fn try_from(row: &'a Row<'a>) -> Result { + Ok(Self { + guid: row.get(0)?, + currency_guid: row.get(1)?, + num: row.get(2)?, + post_date: row.get(3)?, + enter_date: row.get(4)?, + description: row.get(5)?, + }) + } +} + +impl TransactionT for Transaction { + fn guid(&self) -> String { + self.guid.clone() + } + fn currency_guid(&self) -> String { + self.currency_guid.clone() + } + fn num(&self) -> String { + self.num.clone() + } + fn post_datetime(&self) -> NaiveDateTime { + self.post_date.expect("transaction post_date should exist") + } + fn enter_datetime(&self) -> NaiveDateTime { + self.enter_date + .expect("transaction enter_date should exist") + } + fn description(&self) -> String { + self.description.clone().unwrap_or_default() + } +} + +const SEL: &str = r" +SELECT +guid, +currency_guid, +num, +post_date, +enter_date, +description +FROM transactions +"; + +impl TransactionQ for SQLiteQuery { + type T = Transaction; + + async fn all(&self) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(SEL)?; + let result = stmt + .query([])? + .mapped(|row| Transaction::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Transaction::try_from(row)) + .collect::, _>>()?; + Ok(result) + } + + async fn currency_guid(&self, guid: &str) -> Result, Error> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare(&format!("{SEL}\nWHERE currency_guid = ?"))?; + let result = stmt + .query([guid])? + .mapped(|row| Transaction::try_from(row)) + .collect::, _>>()?; + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use pretty_assertions::assert_eq; + use tokio::sync::OnceCell; + + static Q: OnceCell = OnceCell::const_new(); + async fn setup() -> &'static SQLiteQuery { + Q.get_or_init(|| async { + let uri: &str = &format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ); + + println!("work_dir: {:?}", std::env::current_dir()); + SQLiteQuery::new(uri).unwrap() + }) + .await + } + + #[tokio::test] + async fn test_transaction() { + let query = setup().await; + let result = query + .guid("6c8876003c4a6026e38e3afb67d6f2b1") + .await + .unwrap(); + + let result = &result[0]; + assert_eq!(result.guid(), "6c8876003c4a6026e38e3afb67d6f2b1"); + assert_eq!(result.currency_guid(), "346629655191dcf59a7e2c2a85b70f69"); + assert_eq!(result.num(), ""); + assert_eq!( + result.post_datetime(), + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!( + result.enter_datetime(), + NaiveDateTime::parse_from_str("2014-12-25 10:08:15", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!(result.description(), "income 1"); + } + + #[tokio::test] + async fn test_all() { + let query = setup().await; + let result = query.all().await.unwrap(); + assert_eq!(result.len(), 11); + } + + #[tokio::test] + async fn test_by_guid() { + let query = setup().await; + let result = query + .guid("6c8876003c4a6026e38e3afb67d6f2b1") + .await + .unwrap(); + + assert_eq!( + result[0].post_date.unwrap(), + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S").unwrap() + ); + + assert_eq!( + result[0].enter_date.unwrap(), + NaiveDateTime::parse_from_str("2014-12-25 10:08:15", "%Y-%m-%d %H:%M:%S").unwrap() + ); + } + + #[tokio::test] + async fn test_currency_guid() { + let query = setup().await; + let result = query + .currency_guid("346629655191dcf59a7e2c2a85b70f69") + .await + .unwrap(); + + assert_eq!(result.len(), 11); + } +} diff --git a/tests/all.rs b/tests/all.rs index 3addbc4..4e9db03 100644 --- a/tests/all.rs +++ b/tests/all.rs @@ -1,8 +1,9 @@ -use rucash::{Book, MySQLQuery, PostgreSQLQuery, SQLiteQuery, XMLQuery}; +use rucash::{Book, MySQLQuery, PostgreSQLQuery, SQLiteQuery, SQLiteQueryFaster, XMLQuery}; mod mysql; mod postgresql; mod sqlite; +mod sqlitefaster; mod xml; mod consistency { @@ -14,6 +15,11 @@ mod consistency { Book::new(query).await.unwrap() } + async fn setup_sqlitefaster() -> Book { + let query = SQLiteQueryFaster::new(&sqlitefaster::uri()).unwrap(); + Book::new(query).await.unwrap() + } + async fn setup_postgresql() -> Book { let query = PostgreSQLQuery::new(&postgresql::uri()).await.unwrap(); Book::new(query).await.unwrap() @@ -61,6 +67,9 @@ mod consistency { let mut v_sqlite = setup_sqlite().await.accounts().await.unwrap(); v_sqlite.sort_by_key(|x| x.guid.clone()); + let mut v_sqlitefaster = setup_sqlitefaster().await.accounts().await.unwrap(); + v_sqlitefaster.sort_by_key(|x| x.guid.clone()); + let mut v_postgresql = setup_postgresql().await.accounts().await.unwrap(); v_postgresql.sort_by_key(|x| x.guid.clone()); @@ -70,6 +79,8 @@ mod consistency { let mut v_xml = setup_xml().await.accounts().await.unwrap(); v_xml.sort_by_key(|x| x.guid.clone()); + println!("vec_match(&v_sqlite, &v_sqlitefaster)"); + assert!(vec_match(&v_sqlite, &v_sqlitefaster, cmp)); println!("vec_match(&v_sqlite, &v_postgresql)"); assert!(vec_match(&v_sqlite, &v_postgresql, cmp)); println!("vec_match(&v_sqlite, &v_mysql)"); @@ -108,6 +119,9 @@ mod consistency { let mut v_sqlite = setup_sqlite().await.splits().await.unwrap(); v_sqlite.sort_by_key(|x| x.guid.clone()); + let mut v_sqlitefaster = setup_sqlitefaster().await.splits().await.unwrap(); + v_sqlitefaster.sort_by_key(|x| x.guid.clone()); + let mut v_postgresql = setup_postgresql().await.splits().await.unwrap(); v_postgresql.sort_by_key(|x| x.guid.clone()); @@ -117,6 +131,8 @@ mod consistency { let mut v_xml = setup_xml().await.splits().await.unwrap(); v_xml.sort_by_key(|x| x.guid.clone()); + println!("vec_match(&v_sqlite, &v_sqlitefaster)"); + assert!(vec_match(&v_sqlite, &v_sqlitefaster, cmp)); println!("vec_match(&v_sqlite, &v_postgresql)"); assert!(vec_match(&v_sqlite, &v_postgresql, cmp)); println!("vec_match(&v_sqlite, &v_mysql)"); @@ -145,6 +161,9 @@ mod consistency { let mut v_sqlite = setup_sqlite().await.transactions().await.unwrap(); v_sqlite.sort_by_key(|x| x.guid.clone()); + let mut v_sqlitefaster = setup_sqlitefaster().await.transactions().await.unwrap(); + v_sqlitefaster.sort_by_key(|x| x.guid.clone()); + let mut v_postgresql = setup_postgresql().await.transactions().await.unwrap(); v_postgresql.sort_by_key(|x| x.guid.clone()); @@ -154,6 +173,8 @@ mod consistency { let mut v_xml = setup_xml().await.transactions().await.unwrap(); v_xml.sort_by_key(|x| x.guid.clone()); + println!("vec_match(&v_sqlite, &v_sqlitefaster)"); + assert!(vec_match(&v_sqlite, &v_sqlitefaster, cmp)); println!("vec_match(&v_sqlite, &v_postgresql)"); assert!(vec_match(&v_sqlite, &v_postgresql, cmp)); println!("vec_match(&v_sqlite, &v_mysql)"); @@ -184,6 +205,9 @@ mod consistency { let mut v_sqlite = setup_sqlite().await.prices().await.unwrap(); v_sqlite.sort_by_key(|x| x.guid.clone()); + let mut v_sqlitefaster = setup_sqlitefaster().await.prices().await.unwrap(); + v_sqlitefaster.sort_by_key(|x| x.guid.clone()); + let mut v_postgresql = setup_postgresql().await.prices().await.unwrap(); v_postgresql.sort_by_key(|x| x.guid.clone()); @@ -193,6 +217,8 @@ mod consistency { let mut v_xml = setup_xml().await.prices().await.unwrap(); v_xml.sort_by_key(|x| x.guid.clone()); + println!("vec_match(&v_sqlite, &v_sqlitefaster)"); + assert!(vec_match(&v_sqlite, &v_sqlitefaster, cmp)); println!("vec_match(&v_sqlite, &v_postgresql)"); assert!(vec_match(&v_sqlite, &v_postgresql, cmp)); println!("vec_match(&v_sqlite, &v_mysql)"); @@ -228,6 +254,9 @@ mod consistency { let mut v_sqlite = setup_sqlite().await.commodities().await.unwrap(); v_sqlite.sort_by_key(|x| x.mnemonic.clone()); + let mut v_sqlitefaster = setup_sqlitefaster().await.commodities().await.unwrap(); + v_sqlitefaster.sort_by_key(|x| x.guid.clone()); + let mut v_postgresql = setup_postgresql().await.commodities().await.unwrap(); v_postgresql.sort_by_key(|x| x.mnemonic.clone()); @@ -237,6 +266,8 @@ mod consistency { let mut v_xml = setup_xml().await.commodities().await.unwrap(); v_xml.sort_by_key(|x| x.mnemonic.clone()); + println!("vec_match(&v_sqlite, &v_sqlitefaster)"); + assert!(vec_match(&v_sqlite, &v_sqlitefaster, cmp)); println!("vec_match(&v_sqlite, &v_postgresql)"); assert!(vec_match(&v_sqlite, &v_postgresql, cmp)); println!("vec_match(&v_sqlite, &v_mysql)"); diff --git a/tests/sqlitefaster.rs b/tests/sqlitefaster.rs new file mode 100644 index 0000000..464aed9 --- /dev/null +++ b/tests/sqlitefaster.rs @@ -0,0 +1,639 @@ +use chrono::NaiveDateTime; +#[cfg(not(feature = "decimal"))] +use float_cmp::assert_approx_eq; +#[cfg(feature = "decimal")] +use rust_decimal::Decimal; + +use rucash::{Book, SQLiteQueryFaster}; + +pub fn uri() -> String { + format!( + "{}/tests/db/sqlite/complex_sample.gnucash", + env!("CARGO_MANIFEST_DIR") + ) +} + +mod book { + use super::*; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn new() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + Book::new(query).await.unwrap(); + } + + #[tokio::test] + #[should_panic] + async fn new_fail() { + let query = SQLiteQueryFaster::new("sqlite://tests/sample/no.gnucash").unwrap(); + Book::new(query).await.unwrap(); + } + + #[tokio::test] + async fn accounts() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let accounts = book.accounts().await.unwrap(); + assert_eq!(accounts.len(), 21); + } + + #[tokio::test] + async fn accounts_filter() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let accounts = book + .accounts() + .await + .unwrap() + .into_iter() + .filter(|x| x.name.to_lowercase().contains(&"aS".to_lowercase())); + assert_eq!(accounts.count(), 3); + } + + #[tokio::test] + async fn accounts_contains_name_ignore_case() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let accounts = book.accounts_contains_name_ignore_case("aS").await.unwrap(); + assert_eq!(accounts.len(), 3); + } + + #[tokio::test] + async fn account_contains_name_ignore_case() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("NAS") + .await + .unwrap() + .unwrap(); + assert_eq!(account.name, "NASDAQ"); + } + + #[tokio::test] + async fn splits() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let splits = book.splits().await.unwrap(); + assert_eq!(splits.len(), 25); + } + + #[tokio::test] + async fn transactions() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let transactions = book.transactions().await.unwrap(); + assert_eq!(transactions.len(), 11); + } + + #[tokio::test] + async fn prices() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let prices = book.prices().await.unwrap(); + assert_eq!(prices.len(), 5); + } + + #[tokio::test] + async fn commodities() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodities = book.commodities().await.unwrap(); + assert_eq!(commodities.len(), 5); + } + + #[tokio::test] + async fn currencies() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let currencies = book.currencies().await.unwrap(); + assert_eq!(currencies.len(), 4); + } +} +mod account { + use super::*; + use pretty_assertions::assert_eq; + #[tokio::test] + async fn property() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .accounts() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "fcd795021c976ba75621ec39e75f6214") + .unwrap(); + + assert_eq!(account.guid, "fcd795021c976ba75621ec39e75f6214"); + assert_eq!(account.name, "Asset"); + assert_eq!(account.r#type, "ASSET"); + assert_eq!(account.commodity_guid, "346629655191dcf59a7e2c2a85b70f69"); + assert_eq!(account.commodity_scu, 100); + assert_eq!(account.non_std_scu, false); + assert_eq!(account.parent_guid, "00622dda21937b29e494179de5013f82"); + assert_eq!(account.code, ""); + assert_eq!(account.description, ""); + assert_eq!(account.hidden, false); + assert_eq!(account.placeholder, true); + } + + #[tokio::test] + async fn balance() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .accounts() + .await + .unwrap() + .into_iter() + .find(|x| x.name == "Current") + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, account.balance(&book).await.unwrap(), 4590.0); + #[cfg(feature = "decimal")] + assert_eq!(account.balance(&book).await.unwrap(), Decimal::new(4590, 0)); + } + #[tokio::test] + async fn balance_diff_currency() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .accounts() + .await + .unwrap() + .into_iter() + .find(|x| x.name == "Asset") + .unwrap(); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, account.balance(&book).await.unwrap(), 24695.3); + #[cfg(feature = "decimal")] + assert_eq!( + account.balance(&book).await.unwrap(), + Decimal::new(246953, 1) + ); + } + #[tokio::test] + async fn splits() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Cash") + .await + .unwrap() + .unwrap(); + let splits = account.splits().await.unwrap(); + assert_eq!(splits.len(), 3); + } + + #[tokio::test] + async fn parent() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Cash") + .await + .unwrap() + .unwrap(); + let parent = account.parent().await.unwrap().unwrap(); + assert_eq!(parent.name, "Current"); + } + + #[tokio::test] + async fn no_parent() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Root Account") + .await + .unwrap() + .unwrap(); + let parent = account.parent().await.unwrap(); + dbg!(&parent); + assert!(parent.is_none()); + } + + #[tokio::test] + async fn children() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Current") + .await + .unwrap() + .unwrap(); + let children = account.children().await.unwrap(); + assert_eq!(children.len(), 3); + } + + #[tokio::test] + async fn commodity() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let account = book + .account_contains_name_ignore_case("Cash") + .await + .unwrap() + .unwrap(); + let commodity = account.commodity().await.unwrap(); + assert_eq!(commodity.mnemonic, "EUR"); + } +} + +mod split { + use super::*; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn property() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let split = book + .splits() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "de832fe97e37811a7fff7e28b3a43425") + .unwrap(); + + assert_eq!(split.guid, "de832fe97e37811a7fff7e28b3a43425"); + assert_eq!(split.tx_guid, "6c8876003c4a6026e38e3afb67d6f2b1"); + assert_eq!(split.account_guid, "93fc043c3062aaa1297b30e543d2cd0d"); + assert_eq!(split.memo, ""); + assert_eq!(split.action, ""); + assert_eq!(split.reconcile_state, false); + assert_eq!(split.reconcile_datetime, None); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, split.value, 150.0); + #[cfg(feature = "decimal")] + assert_eq!(split.value, Decimal::new(150, 0)); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, split.quantity, 150.0); + #[cfg(feature = "decimal")] + assert_eq!(split.quantity, Decimal::new(150, 0)); + + assert_eq!(split.lot_guid, ""); + } + + #[tokio::test] + async fn transaction() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let split = book + .splits() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "de832fe97e37811a7fff7e28b3a43425") + .unwrap(); + let transaction = split.transaction().await.unwrap(); + assert_eq!(transaction.description, "income 1"); + } + + #[tokio::test] + async fn account() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let split = book + .splits() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "de832fe97e37811a7fff7e28b3a43425") + .unwrap(); + let account = split.account().await.unwrap(); + assert_eq!(account.name, "Cash"); + } +} + +mod transaction { + use super::*; + + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn property() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let transaction = book + .transactions() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "6c8876003c4a6026e38e3afb67d6f2b1") + .unwrap(); + + assert_eq!(transaction.guid, "6c8876003c4a6026e38e3afb67d6f2b1"); + assert_eq!( + transaction.currency_guid, + "346629655191dcf59a7e2c2a85b70f69" + ); + assert_eq!(transaction.num, ""); + assert_eq!( + transaction.post_datetime, + NaiveDateTime::parse_from_str("2014-12-24 10:59:00", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!( + transaction.enter_datetime, + NaiveDateTime::parse_from_str("2014-12-25 10:08:15", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!(transaction.description, "income 1"); + } + + #[tokio::test] + async fn currency() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let transaction = book + .transactions() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "6c8876003c4a6026e38e3afb67d6f2b1") + .unwrap(); + let currency = transaction.currency().await.unwrap(); + assert_eq!(currency.fullname, "Euro"); + } + + #[tokio::test] + async fn splits() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let transaction = book + .transactions() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "6c8876003c4a6026e38e3afb67d6f2b1") + .unwrap(); + let splits = transaction.splits().await.unwrap(); + assert_eq!(splits.len(), 2); + } +} + +mod price { + use super::*; + + use chrono::NaiveDateTime; + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn property() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let price = book + .prices() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "0d6684f44fb018e882de76094ed9c433") + .unwrap(); + + assert_eq!(price.guid, "0d6684f44fb018e882de76094ed9c433"); + assert_eq!(price.commodity_guid, "d821d6776fde9f7c2d01b67876406fd3"); + assert_eq!(price.currency_guid, "5f586908098232e67edb1371408bfaa8"); + assert_eq!( + price.datetime, + NaiveDateTime::parse_from_str("2018-02-20 23:00:00", "%Y-%m-%d %H:%M:%S").unwrap() + ); + assert_eq!(price.source, "user:price-editor"); + assert_eq!(price.r#type, "unknown"); + + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, price.value, 1.5); + #[cfg(feature = "decimal")] + assert_eq!(price.value, Decimal::new(15, 1)); + } + + #[tokio::test] + async fn commodity() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let price = book + .prices() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "0d6684f44fb018e882de76094ed9c433") + .unwrap(); + let commodity = price.commodity().await.unwrap(); + assert_eq!(commodity.fullname, "Andorran Franc"); + } + + #[tokio::test] + async fn currency() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let price = book + .prices() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "0d6684f44fb018e882de76094ed9c433") + .unwrap(); + let currency = price.currency().await.unwrap(); + assert_eq!(currency.fullname, "UAE Dirham"); + } +} + +mod commodity { + use super::*; + + use pretty_assertions::assert_eq; + + #[tokio::test] + async fn property() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + + assert_eq!(commodity.guid, "346629655191dcf59a7e2c2a85b70f69"); + assert_eq!(commodity.namespace, "CURRENCY"); + assert_eq!(commodity.mnemonic, "EUR"); + assert_eq!(commodity.fullname, "Euro"); + assert_eq!(commodity.cusip, "978"); + assert_eq!(commodity.fraction, 100); + assert_eq!(commodity.quote_flag, true); + assert_eq!(commodity.quote_source, "currency"); + assert_eq!(commodity.quote_tz, ""); + } + + #[tokio::test] + async fn accounts() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let accounts = commodity.accounts().await.unwrap(); + assert_eq!(accounts.len(), 14); + } + + #[tokio::test] + async fn transactions() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let transactions = commodity.transactions().await.unwrap(); + assert_eq!(transactions.len(), 11); + } + + #[tokio::test] + async fn as_commodity_prices() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let prices = commodity.as_commodity_prices().await.unwrap(); + assert_eq!(prices.len(), 1); + } + + #[tokio::test] + async fn as_currency_prices() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let prices = commodity.as_currency_prices().await.unwrap(); + assert_eq!(prices.len(), 2); + } + + #[tokio::test] + async fn as_commodity_or_currency_prices() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + let prices = commodity.as_commodity_or_currency_prices().await.unwrap(); + assert_eq!(prices.len(), 3); + } + + #[tokio::test] + async fn rate_direct() { + // ADF => AED + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "d821d6776fde9f7c2d01b67876406fd3") + .unwrap(); + let currency = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "5f586908098232e67edb1371408bfaa8") + .unwrap(); + + let rate = commodity.sell(¤cy, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 1.5); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(15, 1)); + + let rate = currency.buy(&commodity, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 1.5); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(15, 1)); + + // AED => EUR + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "5f586908098232e67edb1371408bfaa8") + .unwrap(); + let currency = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "346629655191dcf59a7e2c2a85b70f69") + .unwrap(); + + let rate = commodity.sell(¤cy, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 9.0 / 10.0); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(9, 0) / Decimal::new(10, 0)); + + let rate = currency.buy(&commodity, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 9.0 / 10.0); + #[cfg(feature = "decimal")] + assert_eq!(rate, Decimal::new(9, 0) / Decimal::new(10, 0)); + } + + #[tokio::test] + async fn rate_indirect() { + let query = SQLiteQueryFaster::new(&uri()).unwrap(); + let book = Book::new(query).await.unwrap(); + // USD => AED + let commodity = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "1e5d65e2726a5d4595741cb204992991") + .unwrap(); + let currency = book + .commodities() + .await + .unwrap() + .into_iter() + .find(|x| x.guid == "5f586908098232e67edb1371408bfaa8") + .unwrap(); + + let rate = commodity.sell(¤cy, &book).await.unwrap(); + #[cfg(not(feature = "decimal"))] + assert_approx_eq!(f64, rate, 7.0 / 5.0 * 10.0 / 9.0); + #[cfg(feature = "decimal")] + assert_eq!( + rate, + (Decimal::new(7, 0) / Decimal::new(5, 0)) * (Decimal::new(10, 0) / Decimal::new(9, 0)), + ); + } +}