use rust_decimal_macros::dec;
use rustledger_core::{
Amount, Close, Commodity, CostSpec, Directive, Document, Event, Inventory, NaiveDate, Note,
Open, Posting, PriceAnnotation, Transaction,
};
use rustledger_query::{Executor, QueryResult, Value, parse};
#[allow(clippy::missing_const_for_fn)]
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
rustledger_core::naive_date(year, month, day).unwrap()
}
fn make_test_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:Checking")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:Savings")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Transport")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Monthly salary")
.with_payee("Employer")
.with_synthesized_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-5000), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(5000), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 20), "Weekly groceries")
.with_payee("Grocery Store")
.with_tag("food")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(150), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-150), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 22), "Fill up")
.with_payee("Gas Station")
.with_synthesized_posting(Posting::new(
"Expenses:Transport",
Amount::new(dec!(45), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-45), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 25), "Transfer to savings")
.with_synthesized_posting(Posting::new(
"Assets:Bank:Savings",
Amount::new(dec!(1000), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-1000), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 27), "More groceries")
.with_payee("Grocery Store")
.with_tag("food")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(80), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-80), "USD"),
)),
),
]
}
fn execute_query(query_str: &str, directives: &[Directive]) -> QueryResult {
let query = parse(query_str).expect("query should parse");
let mut executor = Executor::new(directives);
executor.execute(&query).expect("query should execute")
}
#[allow(dead_code)] fn execute_query_err(query_str: &str, directives: &[Directive]) -> rustledger_query::QueryError {
let query = parse(query_str).expect("query should parse");
let mut executor = Executor::new(directives);
executor
.execute(&query)
.expect_err("query should fail at execution")
}
#[test]
fn test_parse_simple_select() {
let query = parse("SELECT account, number").expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Select(_)));
}
#[test]
fn test_parse_select_with_where() {
let query = parse(r#"SELECT account WHERE account ~ "Expenses""#).expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Select(_)));
}
#[test]
fn test_parse_select_with_group_by() {
let query = parse("SELECT account, SUM(number) GROUP BY account").expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Select(_)));
}
#[test]
fn test_parse_select_with_order_by() {
let query = parse("SELECT account, number ORDER BY number DESC").expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Select(_)));
}
#[test]
fn test_parse_journal_query() {
let query = parse(r#"JOURNAL "Assets:Bank""#).expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Journal(_)));
}
#[test]
fn test_parse_balances_query() {
let query = parse("BALANCES").expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Balances(_)));
}
#[test]
fn test_parse_balances_where_query() {
let query = parse(r#"BALANCES WHERE account ~ "Assets:""#).expect("should parse");
if let rustledger_query::ast::Query::Balances(b) = query {
assert!(b.where_clause.is_some());
} else {
panic!("Expected BALANCES query");
}
}
#[test]
fn test_parse_balances_at_cost_where_query() {
let query = parse(r#"BALANCES AT cost WHERE account ~ "Assets:""#).expect("should parse");
if let rustledger_query::ast::Query::Balances(b) = query {
assert_eq!(b.at_function, Some("cost".to_string()));
assert!(b.where_clause.is_some());
} else {
panic!("Expected BALANCES query");
}
}
#[test]
fn test_execute_balances_where() {
let directives = make_test_directives();
let result = execute_query(r#"BALANCES WHERE account ~ "Expenses:""#, &directives);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::String(account) = &row[0] {
assert!(account.starts_with("Expenses:"), "got {account}");
} else {
panic!("expected Value::String, got {:?}", row[0]);
}
}
}
#[test]
fn test_parse_print_query() {
let query = parse("PRINT").expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Print(_)));
}
#[test]
fn test_parse_error_invalid_query() {
let result = parse("INVALID QUERY SYNTAX");
assert!(result.is_err());
}
#[test]
fn test_execute_select_account() {
let directives = make_test_directives();
let result = execute_query("SELECT account", &directives);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0], "account");
}
#[test]
fn test_execute_select_multiple_columns() {
let directives = make_test_directives();
let result = execute_query("SELECT account, position", &directives);
assert_eq!(result.columns.len(), 2);
assert!(result.columns.contains(&"account".to_string()));
assert!(result.columns.contains(&"position".to_string()));
}
#[test]
fn test_execute_select_with_filter() {
let directives = make_test_directives();
let result = execute_query(r#"SELECT account WHERE account ~ "Expenses""#, &directives);
for row in &result.rows {
if let Value::String(account) = &row[0] {
assert!(
account.starts_with("Expenses"),
"expected Expenses account, got {account}"
);
}
}
}
#[test]
fn test_division_by_zero_returns_null_not_panic() {
let directives = make_test_directives();
for q in [
"SELECT 1 / 0",
"SELECT 5 % 0",
"SELECT 1.0 / 0",
"SELECT 7 % 0",
] {
let result = execute_query(q, &directives);
assert!(!result.rows.is_empty(), "query {q} returned no rows");
for row in &result.rows {
assert!(
matches!(row[0], Value::Null),
"{q}: expected NULL, got {:?}",
row[0]
);
}
}
let result = execute_query("SELECT 10 / 4", &directives);
assert!(matches!(result.rows[0][0], Value::Number(_)));
}
#[test]
fn test_length_counts_chars_not_bytes() {
let directives = make_test_directives();
let result = execute_query(r#"SELECT length("Café")"#, &directives);
assert_eq!(result.rows[0][0], Value::Integer(4));
let result = execute_query(r#"SELECT length("Coffee ☕ unicode")"#, &directives);
assert_eq!(result.rows[0][0], Value::Integer(16));
}
#[test]
fn test_string_funcs_in_aggregate_context_use_char_semantics() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:O")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Café ☕")
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(5), "USD")))
.with_synthesized_posting(Posting::new("Equity:O", Amount::new(dec!(-5), "USD"))),
),
];
let result = execute_query("SELECT length(max(narration))", &directives);
assert_eq!(result.rows[0][0], Value::Integer(6));
let result = execute_query("SELECT substr(max(narration), 1, 4)", &directives);
assert_eq!(result.rows[0][0], Value::String("afé".to_string()));
}
#[test]
fn test_substr_python_slice_semantics() {
let directives = make_test_directives();
let cases = [
(r#"substr("hello", 1, 3)"#, "el"),
(r#"substr("hello", 1, -1)"#, "ell"),
(r#"substr("hello", -10, 5)"#, "hello"), (r#"substr("hello", 10, 20)"#, ""),
(r#"substr("hello", 3, 1)"#, ""),
(r#"substr("Café", 1, 3)"#, "af"),
(r#"substr("hello", 1)"#, "ello"), ];
for (expr, expected) in cases {
let result = execute_query(&format!("SELECT {expr}"), &directives);
assert_eq!(
result.rows[0][0],
Value::String(expected.to_string()),
"for {expr}"
);
}
}
#[test]
fn test_execute_select_with_date_filter() {
let directives = make_test_directives();
let result = execute_query(
"SELECT date, narration WHERE date >= 2024-01-20",
&directives,
);
for row in &result.rows {
if let Value::Date(d) = &row[0] {
assert!(
*d >= date(2024, 1, 20),
"expected date >= 2024-01-20, got {d}"
);
}
}
}
#[test]
fn test_execute_sum_aggregation() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account, SUM(position) WHERE account ~ "Expenses:Food" GROUP BY account"#,
&directives,
);
assert!(!result.is_empty());
let food_row = result.rows.iter().find(|row| {
if let Value::String(account) = &row[0] {
account == "Expenses:Food"
} else {
false
}
});
assert!(food_row.is_some(), "should have Expenses:Food row");
}
#[test]
fn test_execute_count_aggregation() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account, COUNT(*) WHERE account ~ "Expenses" GROUP BY account"#,
&directives,
);
assert!(!result.is_empty());
}
#[test]
fn test_execute_group_by_account() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, SUM(position) GROUP BY account",
&directives,
);
assert!(!result.is_empty());
let accounts: Vec<&String> = result
.rows
.iter()
.filter_map(|row| {
if let Value::String(s) = &row[0] {
Some(s)
} else {
None
}
})
.collect();
let unique_accounts: std::collections::HashSet<_> = accounts.iter().collect();
assert_eq!(accounts.len(), unique_accounts.len());
}
#[test]
fn test_group_by_function_alias() {
let directives = make_test_directives();
let result = execute_query(
"SELECT year(date) AS y, COUNT(*) AS cnt GROUP BY y ORDER BY y",
&directives,
);
assert!(!result.is_empty());
assert_eq!(result.columns[0], "y");
assert_eq!(result.columns[1], "cnt");
for row in &result.rows {
assert!(matches!(row[0], Value::Integer(_)));
}
}
#[test]
fn test_group_by_month_alias() {
let directives = make_test_directives();
let result = execute_query(
"SELECT month(date) AS m, COUNT(*) AS cnt GROUP BY m ORDER BY m",
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::Integer(m) = &row[0] {
assert!((1..=12).contains(m));
} else {
panic!("Expected integer month");
}
}
}
#[test]
fn test_group_by_parent_alias() {
let directives = make_test_directives();
let result = execute_query(
"SELECT PARENT(account) AS parent, COUNT(*) AS cnt GROUP BY parent ORDER BY parent",
&directives,
);
assert!(!result.is_empty());
assert_eq!(result.columns[0], "parent");
}
#[test]
fn test_execute_order_by_date() {
let directives = make_test_directives();
let result = execute_query("SELECT date, narration ORDER BY date ASC", &directives);
let dates: Vec<NaiveDate> = result
.rows
.iter()
.filter_map(|row| {
if let Value::Date(d) = &row[0] {
Some(*d)
} else {
None
}
})
.collect();
for i in 1..dates.len() {
assert!(
dates[i] >= dates[i - 1],
"dates should be in ascending order"
);
}
}
#[test]
fn test_execute_order_by_desc() {
let directives = make_test_directives();
let result = execute_query("SELECT date, narration ORDER BY date DESC", &directives);
let dates: Vec<NaiveDate> = result
.rows
.iter()
.filter_map(|row| {
if let Value::Date(d) = &row[0] {
Some(*d)
} else {
None
}
})
.collect();
for i in 1..dates.len() {
assert!(
dates[i] <= dates[i - 1],
"dates should be in descending order"
);
}
}
#[test]
fn test_execute_year_function() {
let directives = make_test_directives();
let result = execute_query("SELECT YEAR(date), narration", &directives);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::Integer(year) = &row[0] {
assert_eq!(*year, 2024);
}
}
}
#[test]
fn test_execute_month_function() {
let directives = make_test_directives();
let result = execute_query("SELECT MONTH(date), narration", &directives);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::Integer(month) = &row[0] {
assert_eq!(*month, 1);
}
}
}
#[test]
fn test_execute_account_functions() {
let directives = make_test_directives();
let result = execute_query("SELECT account, ROOT(account), LEAF(account)", &directives);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 3);
}
#[test]
fn test_root_with_segment_count() {
let directives = make_test_directives();
let result = execute_query("SELECT DISTINCT ROOT(account, 2)", &directives);
assert_eq!(result.columns.len(), 1);
let roots: std::collections::HashSet<String> = result
.rows
.iter()
.filter_map(|row| match &row[0] {
Value::String(s) => Some(s.clone()),
_ => None,
})
.collect();
assert!(roots.contains("Assets:Bank"));
assert!(roots.contains("Expenses:Food"));
assert!(roots.contains("Expenses:Transport"));
assert!(roots.contains("Income:Salary"));
}
#[test]
fn test_root_rejects_negative_segment_count() {
let directives = make_test_directives();
let query = parse("SELECT ROOT(account, -1)").expect("query should parse");
let mut executor = Executor::new(&directives);
let err = executor
.execute(&query)
.expect_err("ROOT with negative segment count should error");
let msg = err.to_string();
assert!(
msg.contains("non-negative"),
"error should mention non-negative, got: {msg}"
);
}
#[test]
fn test_possign_with_integer_literal_arg() {
let directives = make_test_directives();
let result = execute_query("SELECT POSSIGN(100, 'Income:Salary')", &directives);
assert!(matches!(result.rows[0][0], Value::Number(n) if n == dec!(-100)));
let result = execute_query("SELECT POSSIGN(100, 'Assets:Bank:Checking')", &directives);
assert!(matches!(result.rows[0][0], Value::Number(n) if n == dec!(100)));
}
#[test]
fn test_substr_with_integer_literal_args() {
let directives = make_test_directives();
let result = execute_query("SELECT DISTINCT SUBSTR(account, 0, 6)", &directives);
let prefixes: std::collections::HashSet<String> = result
.rows
.iter()
.filter_map(|row| match &row[0] {
Value::String(s) => Some(s.clone()),
_ => None,
})
.collect();
assert!(prefixes.contains("Assets"));
assert!(prefixes.contains("Expens"));
assert!(prefixes.contains("Income"));
}
#[test]
fn test_execute_journal_query() {
let directives = make_test_directives();
let query = parse(r#"JOURNAL "Assets:Bank:Checking""#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert!(!result.is_empty());
}
#[test]
fn test_journal_balance_is_cumulative_across_matched_accounts() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Deposit")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(1000), "USD"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1000), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 1), "Buy AAPL")
.with_synthesized_posting(Posting::new(
"Assets:Brokerage",
Amount::new(dec!(10), "AAPL"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let query = parse(r#"JOURNAL "Assets""#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.rows.len(), 2, "two assets postings, two rows");
let balance_0 = match &result.rows[0][6] {
Value::Inventory(inv) => inv,
other => panic!("expected Inventory, got {other:?}"),
};
let positions_0 = balance_0.position_list();
assert_eq!(positions_0.len(), 1);
assert_eq!(positions_0[0].units.currency.as_str(), "USD");
assert_eq!(positions_0[0].units.number, dec!(1000));
let balance_1 = match &result.rows[1][6] {
Value::Inventory(inv) => inv,
other => panic!("expected Inventory, got {other:?}"),
};
let mut currencies: Vec<&str> = balance_1
.positions()
.map(|p| p.units.currency.as_str())
.collect();
currencies.sort_unstable();
assert_eq!(
currencies,
vec!["AAPL", "USD"],
"JOURNAL balance must be cumulative across matched accounts"
);
}
#[test]
fn test_journal_position_column_preserves_cost() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let query = parse(r#"JOURNAL "Assets:Brokerage""#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.rows.len(), 1, "expected one matching posting");
let position = &result.rows[0][5];
let position_with_cost = match position {
Value::Position(p) => p,
other => panic!("expected Value::Position, got {other:?}"),
};
assert_eq!(position_with_cost.units.number, dec!(10));
assert_eq!(position_with_cost.units.currency.as_str(), "AAPL");
let cost = position_with_cost
.cost
.as_ref()
.expect("position column must preserve cost annotation");
assert_eq!(cost.number, dec!(150));
assert_eq!(cost.currency.as_str(), "USD");
}
#[test]
fn test_average_account_sum_position_merges_to_single_pool() {
fn buy(account: &str, qty: i64, price: i64) -> Posting {
Posting::new(
account,
Amount::new(rust_decimal::Decimal::from(qty), "AAPL"),
)
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: rust_decimal::Decimal::from(price),
})
.with_currency("USD"),
)
}
fn ledger(booking: &str) -> Vec<Directive> {
let mut open = Open::new(date(2024, 1, 1), "Assets:B");
open.booking = Some(booking.to_string());
vec![
Directive::Open(open),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "b1")
.with_synthesized_posting(buy("Assets:B", 10, 150))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1500), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 1), "b2")
.with_synthesized_posting(buy("Assets:B", 10, 170))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1700), "USD"),
)),
),
]
}
let q = "SELECT account, sum(position) WHERE account = 'Assets:B' GROUP BY account";
let avg = execute_query(q, &ledger("AVERAGE"));
let inv = match &avg.rows[0][1] {
Value::Inventory(i) => i,
other => panic!("expected inventory, got {other:?}"),
};
let lots: Vec<_> = inv.positions().collect();
assert_eq!(
lots.len(),
1,
"AVERAGE must merge to one pool, got {lots:?}"
);
assert_eq!(lots[0].units.number, dec!(20));
assert_eq!(lots[0].cost.as_ref().unwrap().number, dec!(160));
let fifo = execute_query(q, &ledger("FIFO"));
let inv = match &fifo.rows[0][1] {
Value::Inventory(i) => i,
other => panic!("expected inventory, got {other:?}"),
};
assert_eq!(inv.positions().count(), 2, "FIFO must keep separate lots");
}
#[test]
fn test_journal_from_clause_filters_cumulative_balance() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 6, 1), "Deposit 2024")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(100), "USD"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 6, 1), "Deposit 2025")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(500), "USD"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-500), "USD"),
)),
),
];
let query = parse(r#"JOURNAL "Assets" FROM year = 2024"#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.rows.len(), 1, "FROM filter should drop the 2025 row");
let balance = match &result.rows[0][6] {
Value::Inventory(inv) => inv,
other => panic!("expected Inventory, got {other:?}"),
};
let positions = balance.position_list();
assert_eq!(positions.len(), 1);
assert_eq!(
positions[0].units.number,
dec!(100),
"cumulative balance must only include FROM-matched postings"
);
}
#[test]
fn test_journal_at_cost_position_is_amount_not_position() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let query = parse(r#"JOURNAL "Assets:Brokerage" AT cost"#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.rows.len(), 1);
let position = &result.rows[0][5];
match position {
Value::Amount(a) => {
assert_eq!(a.number, dec!(1500));
assert_eq!(a.currency.as_str(), "USD");
}
other => panic!("AT cost should produce Value::Amount, got {other:?}"),
}
}
#[test]
fn test_journal_at_units_position_is_amount_not_position() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let query = parse(r#"JOURNAL "Assets:Brokerage" AT units"#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.rows.len(), 1);
let position = &result.rows[0][5];
match position {
Value::Amount(a) => {
assert_eq!(a.number, dec!(10));
assert_eq!(a.currency.as_str(), "AAPL");
}
other => panic!("AT units should produce Value::Amount, got {other:?}"),
}
}
#[test]
fn test_journal_at_cost_collapses_balance_to_cost_currency() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let query = parse(r#"JOURNAL "Assets:Brokerage" AT cost"#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.rows.len(), 1);
let balance = match &result.rows[0][6] {
Value::Inventory(inv) => inv,
other => panic!("expected Inventory, got {other:?}"),
};
let positions = balance.position_list();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].units.number, dec!(1500));
assert_eq!(positions[0].units.currency.as_str(), "USD");
assert!(
positions[0].cost.is_none(),
"AT cost balance must drop the lot annotation; got {:?}",
positions[0].cost
);
}
#[test]
fn test_journal_at_cost_with_from_clause_filters_then_collapses() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 6, 1), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1500), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 6, 1), "Buy MSFT")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(20), "MSFT")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(300) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-6000), "USD"),
)),
),
];
let query =
parse(r#"JOURNAL "Assets:Brokerage" AT cost FROM year = 2024"#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.rows.len(), 1);
let balance = match &result.rows[0][6] {
Value::Inventory(inv) => inv,
other => panic!("expected Inventory, got {other:?}"),
};
let positions = balance.position_list();
assert_eq!(
positions.len(),
1,
"only 2024 transaction should contribute"
);
assert_eq!(positions[0].units.number, dec!(1500));
assert_eq!(positions[0].units.currency.as_str(), "USD");
}
#[test]
fn test_journal_at_units_strips_cost_from_balance() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let query = parse(r#"JOURNAL "Assets:Brokerage" AT units"#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
let balance = match &result.rows[0][6] {
Value::Inventory(inv) => inv,
other => panic!("expected Inventory, got {other:?}"),
};
let positions = balance.position_list();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].units.number, dec!(10));
assert_eq!(positions[0].units.currency.as_str(), "AAPL");
assert!(
positions[0].cost.is_none(),
"AT units balance must drop the lot annotation; got {:?}",
positions[0].cost
);
}
#[test]
fn test_journal_at_cost_balance_preserves_no_cost_positions() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Deposit")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(100), "USD"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "USD"),
)),
),
];
let query = parse(r#"JOURNAL "Assets:Cash" AT cost"#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
let balance = match &result.rows[0][6] {
Value::Inventory(inv) => inv,
other => panic!("expected Inventory, got {other:?}"),
};
let positions = balance.position_list();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].units.number, dec!(100));
assert_eq!(positions[0].units.currency.as_str(), "USD");
}
#[test]
fn test_journal_at_cost_balance_keeps_mixed_cost_currencies() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Buy AAPL USD lot")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1500), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 1), "Buy AAPL EUR lot")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(5), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(130) })
.with_currency("EUR"),
),
)
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-650), "EUR"),
)),
),
];
let query = parse(r#"JOURNAL "Assets:Brokerage" AT cost"#).expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
let last_balance = match &result.rows[1][6] {
Value::Inventory(inv) => inv,
other => panic!("expected Inventory, got {other:?}"),
};
let mut by_currency: std::collections::HashMap<&str, rust_decimal::Decimal> =
std::collections::HashMap::new();
for p in last_balance.positions() {
by_currency.insert(p.units.currency.as_str(), p.units.number);
}
assert_eq!(by_currency.get("USD"), Some(&dec!(1500)));
assert_eq!(by_currency.get("EUR"), Some(&dec!(650)));
assert_eq!(
by_currency.len(),
2,
"AT cost must not collapse across cost currencies; got {by_currency:?}"
);
}
#[test]
fn test_execute_balances_query() {
let directives = make_test_directives();
let query = parse("BALANCES").expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert!(!result.is_empty());
}
#[test]
fn test_execute_balances_with_from() {
let directives = make_test_directives();
let query = parse(r"BALANCES FROM OPEN ON 2024-01-01").expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert!(!result.is_empty());
}
#[test]
fn test_balances_idempotent_across_sequential_runs() {
let directives = make_test_directives();
let query = parse("BALANCES").expect("should parse");
let mut executor = Executor::new(&directives);
let r1 = executor.execute(&query).expect("first run");
let r2 = executor.execute(&query).expect("second run");
assert_eq!(
r1.rows, r2.rows,
"BALANCES must return identical results when run twice on the same Executor"
);
}
#[test]
fn test_balances_works_with_spanned_directives_executor() {
use rustledger_loader::SourceMap;
use rustledger_parser::{Span, Spanned};
let dirs = make_test_directives();
let spanned: Vec<Spanned<rustledger_core::Directive>> = dirs
.iter()
.cloned()
.map(|d| Spanned {
value: d,
span: Span::new(0, 50),
file_id: 0,
})
.collect();
let source_map = SourceMap::new();
let mut executor = Executor::new_with_sources(&spanned, &source_map);
let result = executor
.execute(&parse("BALANCES").expect("should parse"))
.expect("BALANCES on source-mapped Executor should work");
assert!(
!result.is_empty(),
"source-mapped Executor must return non-empty BALANCES; previously returned empty"
);
}
#[test]
fn test_balances_with_different_from_filters_are_independent() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 6, 1), "2024 deposit")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(100), "USD"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-100), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 6, 1), "2025 deposit")
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(500), "USD"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-500), "USD"),
)),
),
];
let mut executor = Executor::new(&directives);
let q_2024 = parse("BALANCES FROM year = 2024").expect("should parse");
let _ = executor.execute(&q_2024).expect("2024 run");
let q_2025 = parse("BALANCES FROM year = 2025").expect("should parse");
let r_2025 = executor.execute(&q_2025).expect("2025 run");
let cash_row = r_2025
.rows
.iter()
.find(|row| matches!(&row[0], Value::String(s) if s == "Assets:Cash"))
.expect("Assets:Cash should appear in 2025 results");
let inv = match &cash_row[1] {
Value::Inventory(i) => i,
other => panic!("expected Inventory, got {other:?}"),
};
let positions = inv.position_list();
assert_eq!(positions.len(), 1);
assert_eq!(
positions[0].units.number,
dec!(500),
"year=2025 BALANCES must show 500 USD, not 600 USD (2024 + 2025 union)"
);
}
#[test]
fn test_balance_is_cumulative_across_accounts() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT date, balance WHERE account ~ "^Assets" ORDER BY date"#,
&directives,
);
assert!(
result.len() >= 3,
"expected at least 3 rows, got {}",
result.len()
);
if let Value::Inventory(inv) = &result.rows[2][1] {
let positions = inv.position_list();
assert_eq!(
positions.len(),
1,
"expected single-currency total, got {positions:?}"
);
assert_eq!(positions[0].units.number, dec!(4805));
assert_eq!(positions[0].units.currency.as_ref(), "USD");
} else {
panic!("expected Inventory, got {:?}", result.rows[2][1]);
}
}
fn find_balance_by_account<'a>(
result: &'a QueryResult,
account: &str,
balance_col_idx: usize,
) -> &'a Inventory {
for row in &result.rows {
if let Value::String(a) = &row[1]
&& a == account
&& let Value::Inventory(inv) = &row[balance_col_idx]
{
return inv;
}
}
panic!("no row with account={account} and Inventory balance found")
}
#[test]
fn test_balance_carries_across_different_accounts() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT date, account, balance WHERE account ~ "^Assets" ORDER BY date, account"#,
&directives,
);
let inv = find_balance_by_account(&result, "Assets:Bank:Savings", 2);
assert_eq!(inv.position_list()[0].units.number, dec!(5805));
}
#[test]
fn test_account_balance_is_per_account() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT date, account, account_balance WHERE account ~ "^Assets" ORDER BY date, account"#,
&directives,
);
let savings = find_balance_by_account(&result, "Assets:Bank:Savings", 2);
assert_eq!(savings.position_list()[0].units.number, dec!(1000));
let mut last_checking_balance = None;
for row in &result.rows {
if let Value::String(a) = &row[1]
&& a == "Assets:Bank:Checking"
&& let Value::Inventory(inv) = &row[2]
{
last_checking_balance = Some(inv.position_list()[0].units.number);
}
}
assert_eq!(last_checking_balance, Some(dec!(3725)));
}
#[test]
fn test_where_rejected_postings_do_not_pollute_cumulative_balance() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT date, balance WHERE account ~ "^Assets" ORDER BY date"#,
&directives,
);
if let Value::Inventory(inv) = &result.rows[0][1] {
assert_eq!(inv.position_list()[0].units.number, dec!(5000));
} else {
panic!("expected Inventory at first row");
}
}
#[test]
fn test_close_on_is_exclusive() {
let directives = make_test_directives();
let result = execute_query("SELECT date FROM CLOSE ON 2024-01-22", &directives);
assert_eq!(
result.len(),
4,
"expected 4 rows (txns before 2024-01-22, 2 postings each); got {}",
result.len()
);
let boundary = date(2024, 1, 22);
for row in &result.rows {
match &row[0] {
Value::Date(d) => assert!(
*d < boundary,
"row at {d} violates exclusive close: should be < {boundary}"
),
other => panic!("expected Value::Date in column 0, got {other:?}"),
}
}
}
#[test]
fn test_close_on_first_txn_date_yields_empty() {
let directives = make_test_directives();
let result = execute_query("SELECT date FROM CLOSE ON 2024-01-15", &directives);
assert!(
result.is_empty(),
"expected no rows for CLOSE ON the earliest txn date; got {}",
result.len()
);
}
#[test]
fn test_balances_honors_close_on_window() {
let directives = make_test_directives();
let windowed = execute_query("BALANCES FROM CLOSE ON 2024-01-15", &directives);
assert!(
windowed.is_empty(),
"BALANCES FROM CLOSE ON the earliest txn date must exclude all txns; got {} rows",
windowed.len()
);
let full = execute_query("BALANCES", &directives);
assert!(!full.is_empty(), "unfiltered BALANCES should have balances");
}
#[test]
fn test_balances_close_on_partial_window() {
let directives = make_test_directives();
let result = execute_query("BALANCES FROM CLOSE ON 2024-01-22", &directives);
let accounts: Vec<&str> = result
.rows
.iter()
.filter_map(|r| match &r[0] {
Value::String(a) => Some(a.as_str()),
_ => None,
})
.collect();
assert!(
accounts.contains(&"Expenses:Food") && accounts.contains(&"Income:Salary"),
"pre-boundary accounts must remain; got {accounts:?}"
);
assert!(
!accounts.contains(&"Expenses:Transport") && !accounts.contains(&"Assets:Bank:Savings"),
"on/after-boundary-only accounts must be excluded; got {accounts:?}"
);
}
#[test]
fn test_balances_open_on_is_noop_for_totals() {
let directives = make_test_directives();
let full = execute_query("BALANCES", &directives);
let windowed = execute_query("BALANCES FROM OPEN ON 2024-01-22", &directives);
assert_eq!(
format!("{:?}", full.rows),
format!("{:?}", windowed.rows),
"OPEN ON must not change BALANCES totals (carry-in keeps pre-date postings)"
);
}
#[test]
fn test_execute_balances_with_where() {
let directives = make_test_directives();
let query = parse("BALANCES WHERE account ~ 'Assets:'").expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.len(), 2);
for row in &result.rows {
if let Value::String(acct) = &row[0] {
assert!(
acct.starts_with("Assets:"),
"Expected Assets: account, got {acct}"
);
} else {
panic!("Expected string account");
}
}
}
#[test]
fn test_execute_arithmetic_expression() {
let directives = make_test_directives();
let result = execute_query("SELECT NUMBER(position), NUMBER(position) * 2", &directives);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 2);
}
#[test]
fn test_execute_comparison_in_where() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, NUMBER(position) WHERE NUMBER(position) > 100",
&directives,
);
for row in &result.rows {
if let Value::Number(n) = &row[1] {
assert!(*n > dec!(100), "expected number > 100, got {n}");
}
}
}
#[test]
fn test_execute_and_condition() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account, NUMBER(position) WHERE account ~ "Expenses" AND NUMBER(position) > 50"#,
&directives,
);
for row in &result.rows {
if let (Value::String(account), Value::Number(n)) = (&row[0], &row[1]) {
assert!(account.starts_with("Expenses"));
assert!(*n > dec!(50));
}
}
}
#[test]
fn test_execute_or_condition() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account WHERE account ~ "Food" OR account ~ "Transport""#,
&directives,
);
for row in &result.rows {
if let Value::String(account) = &row[0] {
assert!(
account.contains("Food") || account.contains("Transport"),
"expected Food or Transport account, got {account}"
);
}
}
}
#[test]
fn test_execute_empty_result() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account WHERE account ~ "NonExistent""#,
&directives,
);
assert!(result.is_empty());
}
#[test]
fn test_execute_with_no_directives() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT account", &directives);
assert!(result.is_empty());
}
#[test]
fn test_execute_distinct() {
let directives = make_test_directives();
let result = execute_query("SELECT DISTINCT payee", &directives);
let payees: Vec<&String> = result
.rows
.iter()
.filter_map(|row| {
if let Value::String(s) = &row[0] {
Some(s)
} else {
None
}
})
.collect();
let unique_payees: std::collections::HashSet<_> = payees.iter().collect();
assert_eq!(payees.len(), unique_payees.len());
}
#[test]
fn test_distinct_coalesce_deduplicates_rows() {
let directives = make_test_directives();
let all_rows = execute_query(
r"SELECT COALESCE(payee, narration) AS payee FROM transactions ORDER BY payee",
&directives,
);
let distinct_rows = execute_query(
r"SELECT DISTINCT(COALESCE(payee, narration)) AS payee FROM transactions ORDER BY payee",
&directives,
);
assert!(
distinct_rows.len() < all_rows.len(),
"DISTINCT should produce fewer rows than the full result set ({} vs {})",
distinct_rows.len(),
all_rows.len(),
);
let values: Vec<&Value> = distinct_rows.rows.iter().map(|row| &row[0]).collect();
let unique: std::collections::HashSet<String> =
values.iter().map(|v| format!("{v:?}")).collect();
assert_eq!(
values.len(),
unique.len(),
"DISTINCT result should contain no duplicate values"
);
}
#[test]
fn test_expense_summary_by_category() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account, SUM(position) WHERE account ~ "Expenses" GROUP BY account ORDER BY account"#,
&directives,
);
assert!(!result.is_empty());
}
#[test]
fn test_monthly_spending() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT YEAR(date), MONTH(date), SUM(position) WHERE account ~ "Expenses" GROUP BY YEAR(date), MONTH(date)"#,
&directives,
);
assert!(!result.is_empty());
}
#[test]
fn test_payee_analysis() {
let directives = make_test_directives();
let result = execute_query(
"SELECT payee, COUNT(*), SUM(position) GROUP BY payee",
&directives,
);
assert!(!result.is_empty());
}
#[test]
fn test_subquery_basic() {
let directives = make_test_directives();
let result = execute_query(
"SELECT * FROM (SELECT account, position WHERE account ~ \"Expenses:\")",
&directives,
);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 2); }
#[test]
fn test_subquery_with_aggregation() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, total FROM (SELECT account, SUM(position) AS total GROUP BY account)",
&directives,
);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 2);
}
#[test]
fn test_subquery_with_inner_filter() {
let directives = make_test_directives();
let result = execute_query(
"SELECT * FROM (SELECT account, SUM(position) AS total WHERE account ~ \"Expenses:\" GROUP BY account)",
&directives,
);
assert!(!result.is_empty());
}
#[test]
fn test_having_basic() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, COUNT(*) AS cnt GROUP BY account HAVING cnt >= 2",
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::Integer(cnt) = &row[1] {
assert!(*cnt >= 2, "expected count >= 2, got {cnt}");
}
}
}
#[test]
fn test_having_with_count() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, COUNT(*) AS cnt GROUP BY account HAVING cnt > 1",
&directives,
);
for row in &result.rows {
if let Value::Integer(cnt) = &row[1] {
assert!(*cnt > 1, "expected count > 1, got {cnt}");
}
}
}
#[test]
fn test_having_filters_all() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, COUNT(*) AS cnt GROUP BY account HAVING cnt > 999999",
&directives,
);
assert!(
result.is_empty(),
"expected no results with very high threshold"
);
}
#[test]
fn test_parse_pivot_by_two_columns() {
let query = parse(
"SELECT account, YEAR(date), SUM(position) GROUP BY 1, 2 \
ORDER BY account PIVOT BY YEAR(date), account",
)
.expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Select(_)));
}
#[test]
fn test_parse_pivot_by_one_column_parses_but_executes_with_arity_error() {
let query = parse("SELECT account, currency, SUM(number) GROUP BY 1, 2 PIVOT BY currency")
.expect("should parse one-arg form");
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let err = executor.execute(&query).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("PIVOT BY requires exactly two columns"),
"expected PivotWrongArity message; got: {msg}"
);
}
#[test]
fn test_pivot_by_same_column_rejected() {
let query = parse(
"SELECT account, currency, SUM(number) GROUP BY 1, 2 \
PIVOT BY currency, currency",
)
.expect("should parse");
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let err = executor.execute(&query).unwrap_err();
assert!(
err.to_string()
.contains("the two PIVOT BY columns cannot be the same column"),
"got: {err}"
);
}
#[test]
fn test_pivot_by_second_column_must_be_in_group_by() {
let query = parse(
"SELECT account, currency, SUM(number) GROUP BY currency \
PIVOT BY currency, account",
)
.expect("should parse");
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let err = executor.execute(&query).unwrap_err();
assert!(
err.to_string()
.contains("the second PIVOT BY column must be a GROUP BY column"),
"got: {err}"
);
}
#[test]
fn test_pivot_by_with_order_by_works() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, currency, SUM(number) GROUP BY 1, 2 \
ORDER BY account PIVOT BY account, currency",
&directives,
);
assert!(
result.columns.iter().any(|c| c == "account"),
"account column should survive; got: {:?}",
result.columns
);
assert!(
result.columns.iter().any(|c| c == "USD"),
"expected pivoted USD column post-PIVOT; got: {:?}",
result.columns
);
assert!(
!result.columns.iter().any(|c| c == "currency"),
"currency column should be gone (its values became headers); got: {:?}",
result.columns
);
assert!(
!result
.columns
.iter()
.any(|c| c == "SUM(number)" || c == "SUM"),
"value column should be gone (values moved into pivot cells); got: {:?}",
result.columns
);
}
#[test]
fn test_pivot_by_without_group_by_clause_rejected() {
let query = parse("SELECT SUM(number) PIVOT BY currency, account").expect("should parse");
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let err = executor.execute(&query).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("PIVOT BY requires an explicit GROUP BY clause"),
"expected PivotWithoutGroupBy message; got: {msg}"
);
}
#[test]
fn test_pivot_by_multi_value_column_qualifies_headers() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, currency, SUM(number), COUNT(*) GROUP BY 1, 2 \
PIVOT BY currency, account",
&directives,
);
let columns_joined = result.columns.join(",");
assert!(
result.columns.iter().any(|c| c.contains(" / ")),
"expected qualified headers in multi-value-column case; got {columns_joined}"
);
assert!(
result.columns.iter().any(|c| c.starts_with("SUM /")),
"missing SUM-qualified columns in multi-value case; got {columns_joined}"
);
assert!(
result.columns.iter().any(|c| c.starts_with("COUNT /")),
"missing COUNT-qualified columns in multi-value case; got {columns_joined}"
);
}
#[test]
fn test_pivot_by_empty_result_yields_key_column_no_rows() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, currency, SUM(number) WHERE account = 'NoSuchAccount' \
GROUP BY 1, 2 PIVOT BY account, currency",
&directives,
);
assert!(result.rows.is_empty(), "expected no data rows");
assert_eq!(
result.columns,
vec!["account".to_string()],
"empty PIVOT should yield only the key column header"
);
}
#[test]
fn test_pivot_by_duplicate_key_pivot_pairs_first_wins() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, currency, SUM(number) GROUP BY 1, 2 \
PIVOT BY account, currency",
&directives,
);
assert!(!result.rows.is_empty(), "expected pivoted rows");
assert!(
result.columns.iter().any(|c| c == "USD"),
"expected USD pivot column; got: {:?}",
result.columns
);
}
#[test]
fn test_pivot_by_with_order_by_on_hidden_column_works() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, currency, SUM(number) GROUP BY 1, 2 \
ORDER BY MIN(date) PIVOT BY account, currency",
&directives,
);
assert!(!result.columns.is_empty());
assert!(
result.columns.iter().any(|c| c == "USD"),
"expected USD pivot column post-fix; got columns: {:?}",
result.columns
);
assert!(
!result.columns.iter().any(|c| c.contains("date")),
"hidden ORDER BY column should be stripped; got columns: {:?}",
result.columns
);
}
#[test]
fn test_parse_window_function_row_number() {
let query = parse("SELECT account, ROW_NUMBER() OVER (ORDER BY date)").expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Select(_)));
}
#[test]
fn test_parse_window_function_with_partition() {
let query = parse("SELECT account, ROW_NUMBER() OVER (PARTITION BY account ORDER BY date)")
.expect("should parse");
assert!(matches!(query, rustledger_query::ast::Query::Select(_)));
}
#[test]
fn test_execute_window_row_number() {
let directives = make_test_directives();
let result = execute_query(
"SELECT date, narration, ROW_NUMBER() OVER (ORDER BY date) AS rn",
&directives,
);
assert!(!result.is_empty());
let row_nums: Vec<i64> = result
.rows
.iter()
.filter_map(|row| {
if let Value::Integer(n) = &row[2] {
Some(*n)
} else {
None
}
})
.collect();
for (i, &rn) in row_nums.iter().enumerate() {
assert_eq!(
rn,
(i + 1) as i64,
"expected row_number {}, got {rn}",
i + 1
);
}
}
#[test]
fn test_execute_window_rank() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, RANK() OVER (ORDER BY account)",
&directives,
);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 2);
}
#[test]
fn test_execute_window_dense_rank() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, DENSE_RANK() OVER (ORDER BY account)",
&directives,
);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 2);
}
#[test]
fn test_execute_window_with_partition_by() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, date, ROW_NUMBER() OVER (PARTITION BY account ORDER BY date) AS rn",
&directives,
);
assert!(!result.is_empty());
}
#[test]
fn test_select_tags() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT date, narration, tags WHERE "food" IN tags"#,
&directives,
);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 3);
for row in &result.rows {
if let Value::StringSet(tags) = &row[2] {
assert!(
tags.contains(&"food".to_string()),
"expected 'food' in tags"
);
}
}
}
#[test]
fn test_select_links() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Linked transaction")
.with_link("invoice-123")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "USD"),
)),
),
];
let result = execute_query(
r#"SELECT date, narration, links WHERE "invoice-123" IN links"#,
&directives,
);
assert!(!result.is_empty());
assert_eq!(result.columns.len(), 3);
for row in &result.rows {
if let Value::StringSet(links) = &row[2] {
assert!(
links.contains(&"invoice-123".to_string()),
"expected 'invoice-123' in links"
);
}
}
}
#[test]
fn test_select_payee_and_narration() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT date, payee, narration WHERE payee = "Grocery Store""#,
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::String(payee) = &row[1] {
assert_eq!(payee, "Grocery Store");
}
if let Value::String(narration) = &row[2] {
assert!(!narration.is_empty(), "narration should not be empty");
}
}
}
#[test]
fn test_create_table_simple() {
let directives = make_test_directives();
let create_query = parse("CREATE TABLE test_table (col1, col2, col3)").expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&create_query).expect("should execute");
assert_eq!(result.columns, vec!["result"]);
assert_eq!(result.rows.len(), 1);
if let Value::String(msg) = &result.rows[0][0] {
assert!(msg.contains("Created table"));
}
}
#[test]
fn test_create_table_as_select() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query =
parse("CREATE TABLE balances AS SELECT account, sum(number) GROUP BY account")
.expect("should parse");
let result = executor.execute(&create_query).expect("should execute");
assert_eq!(result.columns, vec!["result"]);
if let Value::String(msg) = &result.rows[0][0] {
assert!(msg.contains("Created table 'balances'"));
}
let select_query = parse("SELECT * FROM balances").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert!(!result.is_empty());
assert_eq!(result.columns, vec!["account", "sum"]);
}
#[test]
fn test_insert_values() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE accounts (name, balance)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse("INSERT INTO accounts VALUES ('Checking', 100), ('Savings', 500)")
.expect("should parse");
let result = executor.execute(&insert_query).expect("should execute");
if let Value::String(msg) = &result.rows[0][0] {
assert!(msg.contains("Inserted 2 row(s)"));
}
let select_query = parse("SELECT * FROM accounts").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][0], Value::String("Checking".to_string()));
assert_eq!(result.rows[0][1], Value::Integer(100));
assert_eq!(result.rows[1][0], Value::String("Savings".to_string()));
assert_eq!(result.rows[1][1], Value::Integer(500));
}
#[test]
fn test_insert_select() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE expenses (account)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO expenses SELECT DISTINCT account WHERE account ~ 'Expenses:'")
.expect("should parse");
let result = executor.execute(&insert_query).expect("should execute");
if let Value::String(msg) = &result.rows[0][0] {
assert!(msg.contains("Inserted"));
}
let select_query = parse("SELECT * FROM expenses").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert!(!result.is_empty());
for row in &result.rows {
if let Value::String(acct) = &row[0] {
assert!(acct.starts_with("Expenses:"));
}
}
}
#[test]
fn test_select_from_table_with_where() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE items (name, price)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse("INSERT INTO items VALUES ('Apple', 1), ('Banana', 2), ('Cherry', 5)")
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT name FROM items WHERE price > 1").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.rows.len(), 2);
let names: Vec<_> = result.rows.iter().map(|r| &r[0]).collect();
assert!(names.contains(&&Value::String("Banana".to_string())));
assert!(names.contains(&&Value::String("Cherry".to_string())));
}
#[test]
fn test_select_from_table_with_order_limit() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE nums (value)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO nums VALUES (3), (1), (4), (1), (5)").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query =
parse("SELECT value FROM nums ORDER BY value DESC LIMIT 3").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.rows.len(), 3);
assert_eq!(result.rows[0][0], Value::Integer(5));
assert_eq!(result.rows[1][0], Value::Integer(4));
assert_eq!(result.rows[2][0], Value::Integer(3));
}
#[test]
fn test_create_table_duplicate_error() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE mytable (col1)").expect("should parse");
executor
.execute(&create_query)
.expect("should execute first time");
let result = executor.execute(&create_query);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.to_string().contains("already exists"));
}
}
#[test]
fn test_insert_table_not_exists_error() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let insert_query = parse("INSERT INTO nonexistent VALUES (1)").expect("should parse");
let result = executor.execute(&insert_query);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.to_string().contains("does not exist"));
}
}
#[test]
fn test_select_table_not_exists_error() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let select_query = parse("SELECT * FROM nonexistent").expect("should parse");
let result = executor.execute(&select_query);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.to_string().contains("does not exist"));
}
}
#[test]
fn test_interval_basic_construction() {
use rustledger_query::{Interval, IntervalUnit};
let directives = make_test_directives();
let result = execute_query("SELECT interval(1, 'day') LIMIT 1", &directives);
assert_eq!(result.len(), 1);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(1, IntervalUnit::Day))
);
}
#[test]
fn test_interval_all_units() {
use rustledger_query::{Interval, IntervalUnit};
let directives = make_test_directives();
let result = execute_query("SELECT interval(5, 'day') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(5, IntervalUnit::Day))
);
let result = execute_query("SELECT interval(2, 'week') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(2, IntervalUnit::Week))
);
let result = execute_query("SELECT interval(3, 'month') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(3, IntervalUnit::Month))
);
let result = execute_query("SELECT interval(4, 'quarter') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(4, IntervalUnit::Quarter))
);
let result = execute_query("SELECT interval(1, 'year') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(1, IntervalUnit::Year))
);
}
#[test]
fn test_interval_negative() {
use rustledger_query::{Interval, IntervalUnit};
let directives = make_test_directives();
let result = execute_query("SELECT interval(-7, 'day') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(-7, IntervalUnit::Day))
);
}
#[test]
fn test_interval_invalid_unit() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT interval(1, 'invalid_unit')").expect("should parse");
let result = executor.execute(&query);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.to_string().contains("invalid interval unit"));
}
}
#[test]
fn test_interval_date_arithmetic() {
let directives = make_test_directives();
let result = execute_query(
"SELECT date('2024-01-15') + interval(10, 'day') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 25)));
let result = execute_query(
"SELECT date('2024-01-15') + interval(2, 'month') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 3, 15)));
let result = execute_query(
"SELECT date('2024-01-15') - interval(5, 'day') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 10)));
let result = execute_query(
"SELECT date('2024-03-15') - interval(1, 'month') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 2, 15)));
}
#[test]
fn test_interval_decimal_count_error() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT interval(3.5, 'day')").expect("should parse");
let result = executor.execute(&query);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.to_string().contains("must be an integer"));
}
}
#[test]
fn test_insert_with_reordered_columns() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE test_reorder (col1, col2)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse("INSERT INTO test_reorder (col2, col1) VALUES ('second', 'first')")
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT col1, col2 FROM test_reorder").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("first".to_string()));
assert_eq!(result.rows[0][1], Value::String("second".to_string()));
}
#[test]
fn test_insert_with_column_subset() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE test_subset (a, b, c)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO test_subset (b) VALUES ('middle')").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT a, b, c FROM test_subset").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Null);
assert_eq!(result.rows[0][1], Value::String("middle".to_string()));
assert_eq!(result.rows[0][2], Value::Null);
}
#[test]
fn test_insert_invalid_column_error() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE test_invalid (col1, col2)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO test_invalid (nonexistent) VALUES ('value')").expect("should parse");
let result = executor.execute(&insert_query);
assert!(result.is_err());
if let Err(e) = result {
assert!(e.to_string().contains("does not exist"));
}
}
#[test]
fn test_select_from_table_all_rows() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE numbers (value)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO numbers VALUES (1), (2), (3), (4), (5)").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let result = executor
.execute(&parse("SELECT value FROM numbers").expect("should parse"))
.expect("should execute");
assert_eq!(result.len(), 5);
}
#[test]
fn test_select_from_table_filter() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE prices (category, price)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse(
"INSERT INTO prices VALUES ('food', 10), ('food', 20), ('transport', 15), ('transport', 25)",
)
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let result = executor
.execute(
&parse("SELECT price FROM prices WHERE category = 'food' ORDER BY price")
.expect("should parse"),
)
.expect("should execute");
assert_eq!(result.len(), 2);
assert_eq!(result.rows[0][0], Value::Integer(10));
assert_eq!(result.rows[1][0], Value::Integer(20));
}
#[test]
fn test_select_from_table_distinct() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE items (name)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO items VALUES ('apple'), ('banana'), ('apple'), ('cherry'), ('banana')")
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let result = executor
.execute(&parse("SELECT DISTINCT name FROM items ORDER BY name").expect("should parse"))
.expect("should execute");
assert_eq!(result.len(), 3);
assert_eq!(result.rows[0][0], Value::String("apple".to_string()));
assert_eq!(result.rows[1][0], Value::String("banana".to_string()));
assert_eq!(result.rows[2][0], Value::String("cherry".to_string()));
}
#[test]
fn test_interval_zero() {
use rustledger_query::{Interval, IntervalUnit};
let directives = make_test_directives();
let result = execute_query("SELECT interval(0, 'day') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(0, IntervalUnit::Day))
);
let result = execute_query(
"SELECT date('2024-01-15') + interval(0, 'day') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
let result = execute_query(
"SELECT date('2024-01-15') + interval(0, 'month') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
}
#[test]
fn test_interval_month_end_arithmetic() {
let directives = make_test_directives();
let result = execute_query(
"SELECT date('2024-01-31') + interval(1, 'month') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 2, 29)));
let result = execute_query(
"SELECT date('2024-03-31') - interval(1, 'month') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 2, 29)));
let result = execute_query(
"SELECT date('2023-01-31') + interval(1, 'month') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2023, 2, 28)));
}
#[test]
fn test_interval_quarter_arithmetic() {
let directives = make_test_directives();
let result = execute_query(
"SELECT date('2024-01-15') + interval(1, 'quarter') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 4, 15)));
let result = execute_query(
"SELECT date('2024-01-15') + interval(2, 'quarter') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 7, 15)));
let result = execute_query(
"SELECT date('2024-10-15') - interval(2, 'quarter') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 4, 15)));
}
#[test]
fn test_interval_year_arithmetic() {
let directives = make_test_directives();
let result = execute_query(
"SELECT date('2024-06-15') + interval(1, 'year') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2025, 6, 15)));
let result = execute_query(
"SELECT date('2024-02-29') + interval(1, 'year') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2025, 2, 28)));
let result = execute_query(
"SELECT date('2024-06-15') - interval(2, 'year') LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2022, 6, 15)));
}
#[test]
fn test_interval_case_insensitive_unit() {
use rustledger_query::{Interval, IntervalUnit};
let directives = make_test_directives();
let result = execute_query("SELECT interval(1, 'DAY') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(1, IntervalUnit::Day))
);
let result = execute_query("SELECT interval(1, 'Month') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(1, IntervalUnit::Month))
);
let result = execute_query("SELECT interval(1, 'd') LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(1, IntervalUnit::Day))
);
}
#[test]
fn test_insert_multiple_rows_with_columns() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE multi_insert (col1, col2)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO multi_insert (col2, col1) VALUES ('a', 'b'), ('c', 'd'), ('e', 'f')")
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query =
parse("SELECT col1, col2 FROM multi_insert ORDER BY col1").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 3);
assert_eq!(result.rows[0][0], Value::String("b".to_string()));
assert_eq!(result.rows[0][1], Value::String("a".to_string()));
assert_eq!(result.rows[1][0], Value::String("d".to_string()));
assert_eq!(result.rows[1][1], Value::String("c".to_string()));
assert_eq!(result.rows[2][0], Value::String("f".to_string()));
assert_eq!(result.rows[2][1], Value::String("e".to_string()));
}
#[test]
fn test_insert_column_case_insensitive() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE case_test (name, value)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO case_test (NAME, VALUE) VALUES ('test', 123)").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT name, value FROM case_test").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("test".to_string()));
}
#[test]
fn test_insert_natural_column_order() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE natural_order (a, b, c)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO natural_order (a, b, c) VALUES ('x', 'y', 'z')").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT a, b, c FROM natural_order").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("x".to_string()));
assert_eq!(result.rows[0][1], Value::String("y".to_string()));
assert_eq!(result.rows[0][2], Value::String("z".to_string()));
}
#[test]
fn test_select_from_empty_table() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE empty_table (col)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let select_query = parse("SELECT col FROM empty_table").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 0);
}
#[test]
fn test_select_multi_column() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query =
parse("CREATE TABLE products (name, price, category)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO products VALUES ('Apple', 1.50, 'fruit'), ('Bread', 2.00, 'bakery')")
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query =
parse("SELECT name, price, category FROM products ORDER BY name").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 2);
assert_eq!(result.columns, vec!["name", "price", "category"]);
assert_eq!(result.rows[0][0], Value::String("Apple".to_string()));
assert_eq!(result.rows[0][1], Value::Number(dec!(1.50)));
assert_eq!(result.rows[0][2], Value::String("fruit".to_string()));
}
#[test]
fn test_select_order_by_desc() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE scores (name, score)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse("INSERT INTO scores VALUES ('Alice', 85), ('Bob', 92), ('Carol', 78)")
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query =
parse("SELECT name, score FROM scores ORDER BY score DESC").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 3);
assert_eq!(result.rows[0][0], Value::String("Bob".to_string())); assert_eq!(result.rows[1][0], Value::String("Alice".to_string())); assert_eq!(result.rows[2][0], Value::String("Carol".to_string())); }
#[test]
fn test_order_by_group_by_expression_not_in_select() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let query = parse(
"SELECT account, sum(number) \
GROUP BY account, account_sortkey(account) \
ORDER BY account_sortkey(account)",
)
.expect("should parse");
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.columns.len(), 2);
assert_eq!(result.columns[0], "account");
assert_eq!(result.columns[1], "sum");
for row in &result.rows {
assert_eq!(
row.len(),
2,
"Row should have 2 columns, not hidden columns"
);
}
}
#[test]
fn test_order_by_multiple_hidden_columns() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let query = parse(
"SELECT account, sum(number), currency \
GROUP BY account, currency, account_sortkey(account) \
ORDER BY account_sortkey(account), currency",
)
.expect("should parse");
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.columns.len(), 3);
assert_eq!(result.columns[0], "account");
assert_eq!(result.columns[1], "sum");
assert_eq!(result.columns[2], "currency");
for row in &result.rows {
assert_eq!(
row.len(),
3,
"Row should have 3 columns, not hidden columns"
);
}
}
#[test]
fn test_order_by_hidden_column_non_aggregate() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let query = parse(
"SELECT account \
GROUP BY account, account_sortkey(account) \
ORDER BY account_sortkey(account)",
)
.expect("should parse");
let result = executor.execute(&query).expect("should execute");
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0], "account");
for row in &result.rows {
assert_eq!(
row.len(),
1,
"Row should have 1 column, hidden column removed"
);
}
}
#[test]
fn test_select_with_limit() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE many_rows (val)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO many_rows VALUES (1), (2), (3), (4), (5), (6), (7), (8), (9), (10)")
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT val FROM many_rows LIMIT 3").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 3);
}
#[test]
fn test_select_distinct_with_nulls() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE nulls_test (a, b)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO nulls_test (a) VALUES ('x'), ('y'), ('x')").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT DISTINCT b FROM nulls_test").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Null);
}
#[test]
fn test_select_where_is_null() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE null_filter (name, value)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO null_filter (name) VALUES ('has_null')").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let insert_query2 =
parse("INSERT INTO null_filter VALUES ('has_value', 42)").expect("should parse");
executor.execute(&insert_query2).expect("should execute");
let select_query =
parse("SELECT name FROM null_filter WHERE value IS NULL").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("has_null".to_string()));
}
#[test]
fn test_select_complex_where() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query =
parse("CREATE TABLE inventory (item, price, category)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse(
"INSERT INTO inventory VALUES ('Apple', 1, 'fruit'), ('Steak', 15, 'meat'), ('Banana', 2, 'fruit'), ('Chicken', 8, 'meat')",
)
.expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query =
parse("SELECT item FROM inventory WHERE price > 5 AND category = 'meat' ORDER BY item")
.expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 2);
assert_eq!(result.rows[0][0], Value::String("Chicken".to_string()));
assert_eq!(result.rows[1][0], Value::String("Steak".to_string()));
}
#[test]
fn test_error_unknown_column() {
let directives = make_test_directives();
let query = parse("SELECT nonexistent_column").expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query);
assert!(result.is_err());
}
#[test]
fn test_error_unknown_function() {
let directives = make_test_directives();
let query = parse("SELECT NONEXISTENT_FUNC(account)").expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query);
assert!(result.is_err());
}
#[test]
fn test_error_type_mismatch_comparison() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE types (name, value)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse("INSERT INTO types VALUES ('text', 42)").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT name FROM types WHERE name > 10").expect("should parse");
let _ = executor.execute(&select_query);
}
#[test]
fn test_division_behavior() {
let directives = make_test_directives();
let result = execute_query("SELECT 10 / 2 LIMIT 1", &directives);
assert_eq!(result.len(), 1);
if let Value::Integer(val) = &result.rows[0][0] {
assert_eq!(*val, 5);
} else if let Value::Number(val) = &result.rows[0][0] {
assert_eq!(*val, dec!(5));
}
}
#[test]
fn test_error_invalid_function_args_year() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE func_test (val)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse("INSERT INTO func_test VALUES ('not a date')").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT YEAR(val) FROM func_test").expect("should parse");
let result = executor.execute(&select_query);
assert!(result.is_err());
}
#[test]
fn test_error_invalid_function_args_length() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE len_test (val)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse("INSERT INTO len_test VALUES (12345)").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT LENGTH(val) FROM len_test").expect("should parse");
let result = executor.execute(&select_query);
assert!(result.is_err());
}
#[test]
fn test_aggregate_sum_on_ledger() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT SUM(number) WHERE account ~ "Expenses:Food""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(sum) = &result.rows[0][0] {
assert_eq!(*sum, dec!(230));
}
}
#[test]
fn test_aggregate_count_on_ledger() {
let directives = make_test_directives();
let result = execute_query(r#"SELECT COUNT(*) WHERE account ~ "Expenses""#, &directives);
if let Value::Integer(count) = &result.rows[0][0] {
assert_eq!(*count, 3); }
}
#[test]
fn test_aggregate_avg_on_ledger() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT AVG(number) WHERE account ~ "Expenses:Food""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(avg) = &result.rows[0][0] {
assert_eq!(*avg, dec!(115));
}
}
#[test]
fn test_aggregate_min_max_on_ledger() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT MIN(number), MAX(number) WHERE account ~ "Expenses""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(min) = &result.rows[0][0] {
assert_eq!(*min, dec!(45));
}
if let Value::Number(max) = &result.rows[0][1] {
assert_eq!(*max, dec!(150));
}
}
#[test]
fn test_aggregate_filtered() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT SUM(number), COUNT(*), AVG(number) WHERE account = "Expenses:Food""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Integer(count) = &result.rows[0][1] {
assert_eq!(*count, 2);
}
}
#[test]
fn test_group_by_multiple_columns_ledger() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, currency, SUM(number) AS total GROUP BY account, currency ORDER BY account",
&directives,
);
assert!(result.len() >= 3);
}
#[test]
fn test_group_by_with_having_ledger() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, COUNT(*) AS cnt GROUP BY account HAVING cnt > 1 ORDER BY account",
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::Integer(cnt) = &row[1] {
assert!(*cnt > 1);
}
}
}
#[test]
fn test_window_rank_with_ties() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, RANK() OVER (ORDER BY account) AS rnk WHERE account ~ 'Assets' ORDER BY account",
&directives,
);
assert!(result.len() >= 2);
if let Value::Integer(rank) = &result.rows[0][1] {
assert_eq!(*rank, 1);
}
}
#[test]
fn test_window_dense_rank_with_ties() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, DENSE_RANK() OVER (ORDER BY account) AS drnk WHERE account ~ 'Expenses' ORDER BY account",
&directives,
);
assert!(result.len() >= 2);
if let Value::Integer(rank) = &result.rows[0][1] {
assert!(*rank >= 1);
}
}
#[test]
fn test_window_row_number_on_ledger() {
let directives = make_test_directives();
let result = execute_query(
"SELECT date, narration, ROW_NUMBER() OVER (ORDER BY date) AS rn ORDER BY date LIMIT 5",
&directives,
);
assert!(result.len() >= 3);
for (i, row) in result.rows.iter().enumerate() {
if let Value::Integer(rn) = &row[2] {
assert_eq!(*rn, (i + 1) as i64, "Row number should be sequential");
}
}
}
#[test]
fn test_string_upper_lower_ledger() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT UPPER(narration), LOWER(narration) WHERE narration = "Monthly salary" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(
result.rows[0][0],
Value::String("MONTHLY SALARY".to_string())
);
assert_eq!(
result.rows[0][1],
Value::String("monthly salary".to_string())
);
}
#[test]
fn test_string_length_ledger() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account, LENGTH(account) AS len WHERE account ~ "Assets" LIMIT 3"#,
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::Integer(len) = &row[1] {
assert!(*len > 0);
}
}
}
#[test]
fn test_string_trim_literal() {
let directives = make_test_directives();
let result = execute_query(r#"SELECT TRIM(" hello ") LIMIT 1"#, &directives);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("hello".to_string()));
}
#[test]
fn test_math_abs_ledger() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT number, ABS(number) AS abs_val WHERE account ~ "Income" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(abs_val) = &result.rows[0][1] {
assert!(*abs_val > dec!(0));
}
}
#[test]
fn test_math_round_ledger() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT number, ROUND(number) AS rounded WHERE account ~ "Expenses:Food" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
assert!(!matches!(result.rows[0][1], Value::Null));
}
#[test]
fn test_coalesce_with_payee() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT COALESCE(payee, narration) AS description LIMIT 5",
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
assert!(!matches!(row[0], Value::Null));
}
}
#[test]
fn test_coalesce_first_non_null() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT payee, narration, COALESCE(payee, narration) AS desc LIMIT 5",
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
assert!(!matches!(row[2], Value::Null));
}
}
#[test]
fn test_boolean_and_or_not() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE bools (a, b)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO bools VALUES (1, 0), (1, 1), (0, 0), (0, 1)").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT a, b FROM bools WHERE a = 1 AND b = 1").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 1);
let select_query2 = parse("SELECT a, b FROM bools WHERE a = 1 OR b = 1").expect("should parse");
let result2 = executor.execute(&select_query2).expect("should execute");
assert_eq!(result2.len(), 3);
let select_query3 = parse("SELECT a, b FROM bools WHERE NOT (a = 1)").expect("should parse");
let result3 = executor.execute(&select_query3).expect("should execute");
assert_eq!(result3.len(), 2);
}
#[test]
fn test_between_clause() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE range_test (val)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query =
parse("INSERT INTO range_test VALUES (1), (5), (10), (15), (20)").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT val FROM range_test WHERE val BETWEEN 5 AND 15 ORDER BY val")
.expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.len(), 3);
if let Value::Integer(v) = &result.rows[0][0] {
assert_eq!(*v, 5);
}
if let Value::Integer(v) = &result.rows[1][0] {
assert_eq!(*v, 10);
}
if let Value::Integer(v) = &result.rows[2][0] {
assert_eq!(*v, 15);
}
}
#[test]
fn test_in_clause_with_accounts() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account WHERE account = "Expenses:Food" OR account = "Expenses:Transport""#,
&directives,
);
assert!(result.len() >= 2);
for row in &result.rows {
if let Value::String(acc) = &row[0] {
assert!(acc.contains("Expenses"));
}
}
}
#[test]
fn test_issue_580_in_operator_with_set_literal() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:EUR")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:USD")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:GBP")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "EUR expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank:EUR",
Amount::new(dec!(-100), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 16), "USD expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank:USD",
Amount::new(dec!(-50), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 17), "GBP expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(30), "GBP"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank:GBP",
Amount::new(dec!(-30), "GBP"),
)),
),
];
let result = execute_query(
r"SELECT account, currency, number WHERE currency IN ('EUR', 'USD')",
&directives,
);
assert_eq!(result.rows.len(), 4, "Expected 4 postings (2 EUR + 2 USD)");
for row in &result.rows {
let currency = match &row[1] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for currency, got {other:?}"),
};
assert!(
currency == "EUR" || currency == "USD",
"Expected EUR or USD, got {currency}"
);
}
}
#[test]
fn test_not_in_operator_with_set_literal() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "EUR expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 16), "USD expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 17), "GBP expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(30), "GBP"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-30), "GBP"),
)),
),
];
let result = execute_query(
r"SELECT currency, number WHERE currency NOT IN ('EUR', 'USD')",
&directives,
);
assert_eq!(result.rows.len(), 2, "Expected 2 GBP postings");
for row in &result.rows {
let currency = match &row[0] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for currency, got {other:?}"),
};
assert_eq!(currency, "GBP", "Expected only GBP postings");
}
}
#[test]
fn test_in_operator_single_element_set() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "EUR expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 16), "USD expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "USD"),
)),
),
];
let result = execute_query(r"SELECT currency WHERE currency IN ('EUR',)", &directives);
assert_eq!(result.rows.len(), 2, "Expected 2 EUR postings");
for row in &result.rows {
let currency = match &row[0] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for currency, got {other:?}"),
};
assert_eq!(currency, "EUR");
}
}
#[test]
fn test_in_operator_single_element_no_trailing_comma() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "EUR expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 16), "USD expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "USD"),
)),
),
];
let result = execute_query(r"SELECT currency WHERE currency IN ('EUR')", &directives);
assert_eq!(result.rows.len(), 2, "Expected 2 EUR postings");
for row in &result.rows {
let currency = match &row[0] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for currency, got {other:?}"),
};
assert_eq!(currency, "EUR");
}
let result = execute_query(
r"SELECT currency WHERE currency NOT IN ('EUR')",
&directives,
);
assert_eq!(result.rows.len(), 2, "Expected 2 non-EUR postings");
for row in &result.rows {
let currency = match &row[0] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for currency, got {other:?}"),
};
assert_eq!(currency, "USD");
}
let result = execute_query(
r"SELECT currency, sum(number) AS total
GROUP BY currency
HAVING currency IN ('EUR')",
&directives,
);
assert_eq!(result.rows.len(), 1, "Expected 1 EUR group");
let currency = match &result.rows[0][0] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for currency, got {other:?}"),
};
assert_eq!(currency, "EUR");
}
#[test]
fn test_in_operator_parenthesized_column() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Tagged expense")
.with_tag("food")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 16), "Untagged expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "EUR"),
)),
),
];
let result = execute_query(r#"SELECT narration WHERE "food" IN (tags)"#, &directives);
assert_eq!(result.rows.len(), 2, "Expected 2 postings with 'food' tag");
for row in &result.rows {
let narration = match &row[0] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for narration, got {other:?}"),
};
assert_eq!(narration, "Tagged expense");
}
}
#[test]
fn test_in_operator_numeric_set() {
let directives = vec![
Directive::Open(Open::new(date(2023, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2023, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2023, 6, 15), "2023 expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 20), "2024 expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 9, 10), "2025 expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(30), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-30), "EUR"),
)),
),
];
let result = execute_query(
r"SELECT year, account, number WHERE year IN (2023, 2024)",
&directives,
);
assert_eq!(
result.rows.len(),
4,
"Expected 4 postings (2 from 2023 + 2 from 2024)"
);
for row in &result.rows {
let year = match &row[0] {
Value::Integer(y) => *y,
other => panic!("Expected Integer for year, got {other:?}"),
};
assert!(
year == 2023 || year == 2024,
"Expected year 2023 or 2024, got {year}"
);
}
}
#[test]
fn test_not_in_operator_numeric_set() {
let directives = vec![
Directive::Open(Open::new(date(2023, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2023, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2023, 6, 15), "2023 expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 20), "2024 expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 9, 10), "2025 expense")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(30), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-30), "EUR"),
)),
),
];
let result = execute_query(
r"SELECT year, account, number WHERE year NOT IN (2023, 2024)",
&directives,
);
assert_eq!(result.rows.len(), 2, "Expected 2 postings from 2025");
for row in &result.rows {
let year = match &row[0] {
Value::Integer(y) => *y,
other => panic!("Expected Integer for year, got {other:?}"),
};
assert_eq!(year, 2025, "Expected only 2025 postings");
}
}
#[test]
fn test_filter_with_not_equal() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT DISTINCT account WHERE account ~ "Assets" AND account != "Assets:Bank:Savings""#,
&directives,
);
for row in &result.rows {
if let Value::String(acc) = &row[0] {
assert!(!acc.contains("Savings"));
}
}
}
use rustledger_core::{Balance, Price};
fn make_holdings_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Price(Price::new(
date(2024, 1, 1),
"AAPL",
Amount::new(dec!(150), "USD"),
)),
Directive::Price(Price::new(
date(2024, 6, 1),
"AAPL",
Amount::new(dec!(180), "USD"),
)),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
.with_currency("USD")
.with_date(date(2024, 1, 15)),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1000), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 20), "Buy more AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(5), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(120) })
.with_currency("USD")
.with_date(date(2024, 3, 20)),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-600), "USD"),
)),
),
]
}
#[test]
fn test_units_sum_position() {
let directives = make_holdings_directives();
let result = execute_query(
r"SELECT account, units(sum(position)) as units GROUP BY account",
&directives,
);
assert_eq!(result.len(), 2);
}
#[test]
fn test_cost_sum_position() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT account, cost(sum(position)) as book_value
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Amount(amt) = &result.rows[0][1] {
assert_eq!(amt.number, dec!(1600));
assert_eq!(amt.currency.as_str(), "USD");
} else {
panic!("Expected Amount value for book_value");
}
}
#[test]
fn test_number_cost_sum_position() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT number(cost(sum(position))) as cost_number
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(1600));
} else {
panic!("Expected Number value");
}
}
#[test]
fn test_safediv_with_aggregates() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT safediv(number(cost(sum(position))), 100) as cost_pct
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(16)); } else {
panic!("Expected Number value");
}
}
#[test]
fn test_parenthesized_aggregate_expression() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT (cost(sum(position))) as book_value
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Amount(amt) = &result.rows[0][0] {
assert_eq!(amt.number, dec!(1600));
} else {
panic!("Expected Amount value");
}
}
#[test]
fn test_complex_arithmetic_with_aggregates() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT (number(cost(sum(position))) - 1000) * 2 as calc
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(1200));
} else {
panic!("Expected Number value");
}
}
#[test]
fn test_multiple_nested_aggregates_in_select() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT
account,
units(sum(position)) as units,
cost(sum(position)) as book_value,
number(cost(sum(position))) as cost_num
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][3] {
assert_eq!(*n, dec!(1600));
} else {
panic!("Expected Number value for cost_num");
}
}
#[test]
fn test_currency_on_aggregate() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT currency(cost(sum(position))) as cost_curr
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "USD");
} else {
panic!("Expected String value for currency");
}
}
#[test]
fn test_deeply_nested_aggregate_functions() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT abs(number(cost(sum(position)))) as abs_cost
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(1600));
} else {
panic!("Expected Number value");
}
}
#[test]
fn test_safediv_with_two_aggregate_args() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT safediv(number(cost(sum(position))), count(1)) as avg_cost
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(800));
} else {
panic!("Expected Number value");
}
}
#[test]
fn test_null_propagation_in_nested_aggregates() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT number(cost(sum(position))) as cost_num
WHERE account ~ "Cash"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(-1600));
} else {
panic!("Expected Number value, got {:?}", result.rows[0][0]);
}
}
#[test]
fn test_number_cost_position_without_cost() {
let directives = vec![
Directive::Open(Open::new(date(2020, 1, 1), "Assets:Checking")),
Directive::Open(Open::new(date(2020, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2020, 1, 2), "Grocery")
.with_synthesized_posting(Posting::new(
"Assets:Checking",
Amount::new(dec!(-10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
)),
),
];
let result = execute_query("SELECT number(cost(position))", &directives);
assert_eq!(result.len(), 2);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(-10));
} else {
panic!("Expected Number, got {:?}", result.rows[0][0]);
}
if let Value::Number(n) = &result.rows[1][0] {
assert_eq!(*n, dec!(10));
} else {
panic!("Expected Number, got {:?}", result.rows[1][0]);
}
}
#[test]
fn test_cost_mixed_inventory_with_and_without_cost() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1000), "USD"),
)),
),
];
let result = execute_query(
r#"SELECT number(cost(sum(position))) as cost_num
WHERE account = "Assets:Cash"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(-1000));
} else {
panic!("Expected Number, got {:?}", result.rows[0][0]);
}
}
#[test]
fn test_unary_negation_on_aggregate() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT -number(cost(sum(position))) as neg_cost
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(-1600));
} else {
panic!("Expected Number value");
}
}
#[test]
fn test_number_on_single_currency_inventory() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT currency, number(units(sum(position))) as units_num
WHERE account ~ "Brokerage"
GROUP BY currency"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][1] {
assert_eq!(*n, dec!(15)); } else {
panic!("Expected Number value");
}
}
fn make_multi_currency_holdings() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy AAPL")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
.with_currency("USD")
.with_date(date(2024, 1, 15)),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1000), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 2, 10), "Buy GOOG")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(5), "GOOG")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD")
.with_date(date(2024, 2, 10)),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-750), "USD"),
)),
),
]
}
#[test]
fn test_number_returns_null_for_mixed_currency_inventory() {
let directives = make_multi_currency_holdings();
let result = execute_query(
r#"SELECT number(units(sum(position))) as units_num
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
assert!(
matches!(&result.rows[0][0], Value::Null),
"Expected Null for multi-currency inventory, got {:?}",
result.rows[0][0]
);
}
#[test]
fn test_safediv_division_by_zero() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT safediv(number(cost(sum(position))), 0) as div_zero
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Number(dec!(0)));
}
#[test]
fn test_safediv_with_null() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT safediv(number(cost(sum(position))), number(cost(sum(position)))) as ratio
WHERE account ~ "Cash"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(1));
} else {
panic!("Expected Number value, got {:?}", result.rows[0][0]);
}
}
#[test]
fn test_value_function_with_conversion() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT number(value(sum(position), "USD")) as market_value
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(2700));
} else {
panic!("Expected Number value for market value");
}
}
#[test]
fn test_empty_function() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT empty(sum(position)) as is_empty GROUP BY account LIMIT 1",
&directives,
);
assert!(!result.is_empty());
assert!(matches!(&result.rows[0][0], Value::Boolean(_)));
}
#[test]
fn test_only_function_with_inventory() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT only(currency, sum(position)) as only_amt
WHERE account ~ "Brokerage"
GROUP BY currency"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Amount(a) = &result.rows[0][0] {
assert_eq!(a.number, dec!(15)); } else {
panic!("Expected Amount value");
}
}
#[test]
fn test_only_null_first_argument_propagates() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT only(first(cost_currency), cost(sum(position))) as avg_cost_ccy
WHERE account ~ "Cash"
GROUP BY currency"#,
&directives,
);
assert_eq!(result.len(), 1);
assert!(
matches!(&result.rows[0][0], Value::Null),
"only(NULL, ...) must be NULL, got {:?}",
result.rows[0][0]
);
}
#[test]
fn test_value_one_arg_inventory_is_type_stable() {
let directives = make_holdings_directives();
let result = execute_query(
r"SELECT value(sum(position)) as market_value GROUP BY currency",
&directives,
);
assert!(result.len() >= 2, "need both a costed and a costless group");
for (i, row) in result.rows.iter().enumerate() {
assert!(
matches!(&row[0], Value::Inventory(_)),
"row {i}: one-arg value() must be Inventory for every row, got {:?}",
row[0]
);
}
}
#[test]
fn test_value_two_arg_stays_amount() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT value(sum(position), "USD") as market_value GROUP BY currency"#,
&directives,
);
assert!(!result.is_empty());
for (i, row) in result.rows.iter().enumerate() {
assert!(
matches!(&row[0], Value::Amount(_)),
"row {i}: two-arg value() must stay Amount, got {:?}",
row[0]
);
}
}
#[test]
fn test_filter_currency_function() {
let directives = make_multi_currency_holdings();
let result = execute_query(
r#"SELECT number(units(filter_currency(sum(position), "AAPL"))) as aapl_units
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(10)); } else {
panic!("Expected Number value");
}
}
#[test]
fn test_currency_on_inventory() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT currency(units(sum(position))) as curr
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "AAPL");
} else {
panic!("Expected String value for currency");
}
}
#[test]
fn test_number_on_empty_inventory() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT number(sum(position)) as num
WHERE account = "NonExistent:Account"
GROUP BY account"#,
&directives,
);
assert!(result.is_empty());
}
#[test]
fn test_string_startswith() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account WHERE STARTSWITH(account, "Assets")"#,
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::String(s) = &row[0] {
assert!(s.starts_with("Assets"));
}
}
}
#[test]
fn test_string_endswith() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT account WHERE ENDSWITH(account, "Checking")"#,
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::String(s) = &row[0] {
assert!(s.ends_with("Checking"));
}
}
}
#[test]
fn test_string_grep_match() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT GREP("Bank", account) WHERE account ~ "Bank" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "Bank");
}
}
#[test]
fn test_string_grep_no_match() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT GREP("XYZ", account) WHERE account ~ "Bank" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
assert!(matches!(&result.rows[0][0], Value::Null));
}
#[test]
fn test_string_grepn_capture_group() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT GREPN("Assets:([^:]+)", account, 1) WHERE account ~ "Assets" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "Bank");
}
}
#[test]
fn test_string_subst() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT SUBST("Bank", "Institution", account) WHERE account ~ "Bank" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert!(s.contains("Institution"));
assert!(!s.contains("Bank"));
}
}
#[test]
fn test_string_splitcomp() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT SPLITCOMP(account, ":", 1) WHERE account ~ "Assets:Bank" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "Bank");
}
}
#[test]
fn test_string_splitcomp_out_of_bounds() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT SPLITCOMP(account, ":", 100) WHERE account ~ "Assets" LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
assert!(matches!(&result.rows[0][0], Value::Null));
}
#[test]
fn test_string_joinstr() {
let directives = make_test_directives();
let result = execute_query(r#"SELECT JOINSTR("A", "B", "C") LIMIT 1"#, &directives);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "A, B, C");
}
}
#[test]
fn test_string_maxwidth_truncate() {
let directives = make_test_directives();
let result = execute_query(r"SELECT MAXWIDTH(narration, 10) LIMIT 1", &directives);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert!(s.len() <= 10);
if s.len() == 10 {
assert!(s.ends_with("..."));
}
}
}
#[test]
fn test_string_maxwidth_no_truncate() {
let directives = make_test_directives();
let result = execute_query(r#"SELECT MAXWIDTH("short", 100) LIMIT 1"#, &directives);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "short");
}
}
#[test]
fn test_string_length_on_set() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT LENGTH(tags) WHERE LENGTH(tags) > 0 LIMIT 1",
&directives,
);
if !result.is_empty()
&& let Value::Integer(n) = &result.rows[0][0]
{
assert!(*n > 0);
}
}
#[test]
fn test_aggregation_avg() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT AVG(number) as avg_amount WHERE account ~ "Expenses:Food""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(115));
} else {
panic!("Expected Number value for AVG");
}
}
#[test]
fn test_aggregation_min_number() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT MIN(number) as min_amount WHERE account ~ "Expenses" AND number > 0"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(45));
} else {
panic!("Expected Number value for MIN");
}
}
#[test]
fn test_aggregation_max_number() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT MAX(number) as max_amount WHERE account ~ "Expenses" AND number > 0"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(150));
} else {
panic!("Expected Number value for MAX");
}
}
#[test]
fn test_aggregation_min_date() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT MIN(date) as earliest_date WHERE account ~ "Expenses""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Date(d) = &result.rows[0][0] {
assert_eq!(*d, date(2024, 1, 20)); } else {
panic!("Expected Date value for MIN(date)");
}
}
#[test]
fn test_aggregation_max_date() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT MAX(date) as latest_date WHERE account ~ "Expenses""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Date(d) = &result.rows[0][0] {
assert_eq!(*d, date(2024, 1, 27)); } else {
panic!("Expected Date value for MAX(date)");
}
}
#[test]
fn test_aggregation_first() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT FIRST(narration) as first_narration WHERE account ~ "Expenses""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "Weekly groceries");
} else {
panic!("Expected String value for FIRST");
}
}
#[test]
fn test_aggregation_last() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT LAST(narration) as last_narration WHERE account ~ "Expenses""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::String(s) = &result.rows[0][0] {
assert_eq!(s, "More groceries");
} else {
panic!("Expected String value for LAST");
}
}
#[test]
fn test_aggregation_group_key_types() {
let directives = make_test_directives();
let result = execute_query(r"SELECT account, COUNT(*) GROUP BY account", &directives);
assert!(!result.is_empty());
let result = execute_query(r"SELECT date, COUNT(*) GROUP BY date", &directives);
assert!(!result.is_empty());
}
#[test]
fn test_aggregation_having_with_alias() {
let directives = make_test_directives();
let result = execute_query(
r"SELECT account, COUNT(*) as cnt
GROUP BY account
HAVING cnt > 1",
&directives,
);
for row in &result.rows {
if let Value::Integer(n) = &row[1] {
assert!(*n > 1);
}
}
}
#[test]
fn test_aggregation_nested_function() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT units(sum(position)) as total_units
WHERE account ~ "Expenses:Food"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Amount(a) = &result.rows[0][0] {
assert_eq!(a.number, dec!(230));
}
}
#[test]
fn test_aggregation_sum_integers() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT SUM(1) as total_count WHERE account ~ "Expenses""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(3));
}
}
fn make_large_directives() -> Vec<Directive> {
let mut directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Test")),
];
for i in 0u32..510 {
let day = (i % 28) + 1; let txn = Transaction::new(date(2024, 1, day), format!("Transaction {i}"))
.with_synthesized_posting(Posting::new(
"Expenses:Test",
Amount::new(dec!(10) + rust_decimal::Decimal::from(i64::from(i)), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-10) - rust_decimal::Decimal::from(i64::from(i)), "USD"),
));
directives.push(Directive::Transaction(txn));
}
directives
}
#[test]
fn test_parallel_execution_simple_select() {
let directives = make_large_directives();
let result = execute_query(r"SELECT account, number", &directives);
assert_eq!(
result.len(),
1020,
"expected 1020 postings for parallel path"
);
}
#[test]
fn test_parallel_execution_with_filter() {
let directives = make_large_directives();
let result = execute_query(
r#"SELECT account, number WHERE account ~ "Expenses""#,
&directives,
);
assert_eq!(result.len(), 510, "expected 510 expense postings");
}
#[test]
fn test_parallel_execution_with_distinct() {
let directives = make_large_directives();
let result = execute_query(r"SELECT DISTINCT account", &directives);
assert_eq!(result.len(), 2, "expected 2 distinct accounts");
}
#[test]
fn test_parallel_execution_aggregation() {
let directives = make_large_directives();
let result = execute_query(r"SELECT account, SUM(number) GROUP BY account", &directives);
assert_eq!(result.len(), 2, "expected 2 account groups");
}
#[test]
fn test_parallel_execution_matches_sequential() {
let directives = make_large_directives();
let result = execute_query(
r#"SELECT SUM(number) WHERE account ~ "Expenses""#,
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Number(n) => {
assert_eq!(*n, dec!(134895), "parallel SUM should equal expected total");
}
Value::Amount(a) => {
assert_eq!(
a.number,
dec!(134895),
"parallel SUM should equal expected total"
);
}
other => panic!("expected Number or Amount result, got {other:?}"),
}
}
#[test]
fn test_regex_case_insensitive() {
let directives = make_test_directives();
let result = execute_query(
r#"SELECT DISTINCT account WHERE account ~ "food""#,
&directives,
);
assert_eq!(
result.len(),
1,
"lowercase 'food' should match 'Expenses:Food'"
);
let result = execute_query(
r#"SELECT DISTINCT account WHERE account ~ "EXPENSES""#,
&directives,
);
assert_eq!(
result.len(),
2,
"uppercase 'EXPENSES' should match 'Expenses:*' accounts"
);
let result = execute_query(
r#"SELECT DISTINCT account WHERE account ~ "eXpEnSeS""#,
&directives,
);
assert_eq!(
result.len(),
2,
"mixed-case pattern should match case-insensitively"
);
}
#[test]
fn test_value_infers_currency_from_cost() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT number(value(position)) as market_value
WHERE account ~ "Brokerage"
LIMIT 1"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert!(
*n == dec!(1800) || *n == dec!(900),
"Expected 1800 or 900, got {n}"
);
} else {
panic!("Expected Number value for market value");
}
}
#[test]
fn test_value_uses_latest_price_not_transaction_date() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT number(value(sum(position), "USD")) as market_value
WHERE account ~ "Brokerage"
GROUP BY account"#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(
*n,
dec!(2700),
"VALUE() should use latest price, not transaction date"
);
} else {
panic!("Expected Number value for market value");
}
}
#[test]
fn test_value_individual_positions_use_latest_price() {
let directives = make_holdings_directives();
let result = execute_query(
r#"SELECT date, number(value(position, "USD")) as val
WHERE account ~ "Brokerage"
ORDER BY date"#,
&directives,
);
assert_eq!(result.len(), 2);
if let Value::Number(n) = &result.rows[0][1] {
assert_eq!(
*n,
dec!(1800),
"First position should use latest price (180), not price at transaction date (150)"
);
} else {
panic!("Expected Number value for first position market value");
}
if let Value::Number(n) = &result.rows[1][1] {
assert_eq!(
*n,
dec!(900),
"Second position should use latest price (180)"
);
} else {
panic!("Expected Number value for second position market value");
}
}
#[test]
fn test_value_chained_price_conversion() {
let directives = make_chained_price_directives();
let result = execute_query(
r#"SELECT number(value(position, "USD")) as val
WHERE account ~ "Stocks""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(
*n,
dec!(660),
"VALUE() should support chained price conversion (GOOG→EUR→USD)"
);
} else {
panic!("Expected Number value for chained conversion");
}
}
fn make_chained_price_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stocks")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Price(Price::new(
date(2024, 1, 1),
"GOOG",
Amount::new(dec!(100), "EUR"),
)),
Directive::Price(Price::new(
date(2024, 6, 1),
"GOOG",
Amount::new(dec!(120), "EUR"),
)),
Directive::Price(Price::new(
date(2024, 6, 1),
"EUR",
Amount::new(dec!(1.10), "USD"),
)),
Directive::Transaction(
Transaction::new(date(2024, 2, 15), "Buy GOOG")
.with_synthesized_posting(
Posting::new("Assets:Stocks", Amount::new(dec!(5), "GOOG")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(80) })
.with_currency("EUR")
.with_date(date(2024, 2, 15)),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-400), "EUR"),
)),
),
]
}
#[test]
fn test_value_no_currency_returns_as_is() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Grocery store")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-50), "USD"),
)),
),
];
let result = execute_query(
r#"SELECT account, value(position) as val
WHERE account = "Expenses:Food""#,
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Amount(a) = &result.rows[0][1] {
assert_eq!(a.number, dec!(50));
assert_eq!(a.currency, "USD");
} else {
panic!(
"Expected Amount value when VALUE() has no target currency, got {:?}",
result.rows[0][1]
);
}
}
#[test]
fn test_value_no_currency_aggregated_returns_as_is() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Grocery store")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-50), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 2, 10), "Restaurant")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(30), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-30), "USD"),
)),
),
];
let result = execute_query(
r#"SELECT account, value(sum(position)) as val
GROUP BY account
HAVING account = "Expenses:Food""#,
&directives,
);
assert_eq!(result.len(), 1);
let expected = Amount::new(dec!(80), "USD");
match &result.rows[0][1] {
Value::Amount(a) => {
assert_eq!(*a, expected, "Expected 80 USD amount");
}
Value::Inventory(inv) => {
let positions = inv.position_list();
assert_eq!(positions.len(), 1, "Expected single-currency inventory");
assert_eq!(positions[0].units, expected, "Expected 80 USD in inventory");
}
other => panic!(
"Expected Inventory or Amount when VALUE() has no target currency, got {other:?}",
),
}
}
fn make_issue_892_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2020, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2020, 1, 1), "Equity:Opening")),
Directive::Price(Price::new(
date(2020, 1, 1),
"SP",
Amount::new(dec!(250), "USD"),
)),
Directive::Price(Price::new(
date(2020, 6, 1),
"SP",
Amount::new(dec!(300), "USD"),
)),
Directive::Price(Price::new(
date(2021, 1, 1),
"SP",
Amount::new(dec!(500), "USD"),
)),
Directive::Price(Price::new(
date(2099, 1, 1),
"SP",
Amount::new(dec!(9999), "USD"),
)),
Directive::Transaction(
Transaction::new(date(2020, 1, 1), "Buy stock").with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(4), "SP")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(250) })
.with_currency("USD")
.with_date(date(2020, 1, 1)),
),
),
),
]
}
#[test]
fn test_value_date_arg_returns_price_at_or_before() {
let directives = make_issue_892_directives();
let result = execute_query(
r"SELECT number(value(position, 2020-06-01)) AS v
WHERE account ~ 'Brokerage'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Number(dec!(1200)));
}
#[test]
fn test_value_date_arg_uses_earlier_price_when_no_match_on_date() {
let directives = make_issue_892_directives();
let result = execute_query(
r"SELECT number(value(position, 2020-02-15)) AS v
WHERE account ~ 'Brokerage'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Number(dec!(1000)));
}
#[test]
fn test_value_date_arg_returns_raw_units_when_no_price_available() {
let directives = make_issue_892_directives();
let result = execute_query(
r"SELECT number(value(position, 2019-01-01)) AS v
WHERE account ~ 'Brokerage'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Number(dec!(4)));
}
#[test]
fn test_value_no_date_arg_still_uses_latest_price() {
let directives = make_issue_892_directives();
let result = execute_query(
r"SELECT number(value(position)) AS v
WHERE account ~ 'Brokerage'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Number(dec!(39996)));
}
#[test]
fn test_value_date_arg_in_aggregate_context() {
let directives = make_issue_892_directives();
let result = execute_query(
r"SELECT account, number(value(sum(position), 2020-06-01)) AS v
WHERE account ~ 'Brokerage'
GROUP BY account",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][1], Value::Number(dec!(1200)));
}
#[test]
fn test_value_currency_string_is_rustledger_extension() {
let directives = make_issue_892_directives();
let result = execute_query(
r"SELECT number(value(position, 'USD')) AS v
WHERE account ~ 'Brokerage'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Number(dec!(39996)));
}
#[test]
fn test_value_rejects_invalid_second_argument_type() {
let directives = make_issue_892_directives();
let query = parse(r"SELECT value(position, 42) AS v WHERE account ~ 'Brokerage'")
.expect("query should parse");
let mut executor = Executor::new(&directives);
let err = executor.execute(&query).expect_err("should reject integer");
let msg = format!("{err}");
assert!(
msg.contains("date") && msg.contains("currency"),
"error should mention both accepted types, got: {msg}"
);
}
#[test]
fn test_value_rejects_invalid_second_argument_in_aggregate_context() {
let directives = make_issue_892_directives();
let query = parse(
r"SELECT account, value(sum(position), 42) AS v
WHERE account ~ 'Brokerage'
GROUP BY account",
)
.expect("query should parse");
let mut executor = Executor::new(&directives);
let err = executor
.execute(&query)
.expect_err("aggregate-context should reject integer too");
let msg = format!("{err}");
assert!(
msg.contains("date") && msg.contains("currency"),
"aggregate error should mention both accepted types, got: {msg}"
);
}
fn make_prices_test_directives() -> Vec<Directive> {
vec![
Directive::Price(Price::new(
date(2025, 1, 1),
"EUR",
Amount::new(dec!(1.95583), "BAM"),
)),
Directive::Price(Price::new(
date(2025, 1, 1),
"EUR",
Amount::new(dec!(1.0268), "USD"),
)),
Directive::Price(Price::new(
date(2025, 1, 1),
"EUR",
Amount::new(dec!(1.1325), "USD"),
)),
Directive::Price(Price::new(
date(2025, 1, 10),
"CHF",
Amount::new(dec!(1.0647), "EUR"),
)),
Directive::Price(Price::new(
date(2025, 3, 30),
"ABC",
Amount::new(dec!(1.20), "EUR"),
)),
Directive::Price(Price::new(
date(2025, 4, 15),
"ABC",
Amount::new(dec!(1.35), "EUR"),
)),
]
}
#[test]
fn test_prices_table_basic_select() {
let directives = make_prices_test_directives();
let result = execute_query("SELECT date, currency, amount FROM #prices", &directives);
assert_eq!(result.columns, vec!["date", "currency", "amount"]);
assert_eq!(result.len(), 6);
assert_eq!(result.rows[0][0], Value::Date(date(2025, 1, 1)));
}
#[test]
fn test_prices_table_excludes_transaction_derived_implicit_prices() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 10), "Buy")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(520) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-5200), "USD"),
)),
),
];
let result = execute_query("SELECT count(*) FROM #prices", &directives);
assert_eq!(result.len(), 1);
assert_eq!(
result.rows[0][0],
Value::Integer(0),
"#prices must be empty when no Price directive is declared, even if \
transactions carry cost annotations (bean-query compat — issue #1048)"
);
}
#[test]
fn test_value_works_without_explicit_price_directive() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 10), "Buy")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "HOOL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(520) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-5200), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, value(position) WHERE account = 'Assets:Stock'",
&directives,
);
assert_eq!(result.len(), 1);
if let Value::Amount(a) = &result.rows[0][1] {
assert_eq!(a.number, dec!(5200));
assert_eq!(a.currency, "USD");
} else {
panic!(
"VALUE() should resolve via implicit prices even without an \
explicit Price directive; got {:?}",
result.rows[0][1]
);
}
}
#[test]
fn test_prices_table_select_all() {
let directives = make_prices_test_directives();
let result = execute_query("SELECT * FROM #prices", &directives);
assert_eq!(result.len(), 6);
}
#[test]
fn test_prices_table_with_where_clause() {
let directives = make_prices_test_directives();
let result = execute_query("SELECT * FROM #prices WHERE currency = 'EUR'", &directives);
assert_eq!(result.len(), 3);
}
#[test]
fn test_prices_table_with_date_filter() {
let directives = make_prices_test_directives();
let result = execute_query("SELECT * FROM #prices WHERE date > 2025-01-01", &directives);
assert_eq!(result.len(), 3);
}
#[test]
fn test_prices_table_with_order_by() {
let directives = make_prices_test_directives();
let result = execute_query("SELECT * FROM #prices ORDER BY date DESC", &directives);
assert_eq!(result.rows[0][0], Value::Date(date(2025, 4, 15)));
assert_eq!(
result.rows[result.len() - 1][0],
Value::Date(date(2025, 1, 1))
);
}
#[test]
fn test_prices_table_with_limit() {
let directives = make_prices_test_directives();
let result = execute_query("SELECT * FROM #prices LIMIT 2", &directives);
assert_eq!(result.len(), 2);
}
#[test]
fn test_prices_table_currency_column_value() {
let directives = vec![Directive::Price(Price::new(
date(2024, 1, 1),
"AAPL",
Amount::new(dec!(150), "USD"),
))];
let result = execute_query("SELECT currency FROM #prices", &directives);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("AAPL".to_string()));
}
#[test]
fn test_prices_table_amount_column_value() {
let directives = vec![Directive::Price(Price::new(
date(2024, 1, 1),
"AAPL",
Amount::new(dec!(150.50), "USD"),
))];
let result = execute_query("SELECT amount FROM #prices", &directives);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.number, dec!(150.50));
assert_eq!(amt.currency.as_ref(), "USD");
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_prices_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #prices", &directives);
assert!(result.is_empty());
}
#[test]
fn test_prices_table_with_distinct() {
let directives = make_prices_test_directives();
let result = execute_query("SELECT DISTINCT currency FROM #prices", &directives);
assert_eq!(result.len(), 3);
}
#[test]
fn test_prices_table_all_columns() {
let directives = vec![Directive::Price(Price::new(
date(2024, 6, 15),
"MSFT",
Amount::new(dec!(400.50), "USD"),
))];
let result = execute_query("SELECT date, currency, amount FROM #prices", &directives);
assert_eq!(result.len(), 1);
assert_eq!(result.columns, vec!["date", "currency", "amount"]);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
assert_eq!(result.rows[0][1], Value::String("MSFT".to_string()));
match &result.rows[0][2] {
Value::Amount(amt) => {
assert_eq!(amt.number, dec!(400.50));
assert_eq!(amt.currency.as_ref(), "USD");
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_prices_table_case_insensitive() {
let directives = vec![Directive::Price(Price::new(
date(2024, 6, 15),
"EUR",
Amount::new(dec!(1.10), "USD"),
))];
let result_lower = execute_query("SELECT * FROM #prices", &directives);
assert_eq!(result_lower.len(), 1);
let result_upper = execute_query("SELECT * FROM #PRICES", &directives);
assert_eq!(result_upper.len(), 1);
let result_mixed = execute_query("SELECT * FROM #Prices", &directives);
assert_eq!(result_mixed.len(), 1);
assert_eq!(result_lower.rows, result_upper.rows);
assert_eq!(result_lower.rows, result_mixed.rows);
}
#[test]
fn test_prices_table_unknown_system_table_error() {
let directives: Vec<Directive> = vec![];
let query = parse("SELECT * FROM #unknown").expect("query should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query);
match result {
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("#unknown"),
"Error should mention the table name"
);
assert!(
msg.contains("#balances") && msg.contains("#prices"),
"Error should hint about available system tables"
);
}
Ok(_) => panic!("Expected error for unknown system table"),
}
}
#[test]
fn test_prices_table_deterministic_ordering() {
let directives = vec![
Directive::Price(Price::new(
date(2024, 1, 1),
"EUR",
Amount::new(dec!(1.10), "USD"),
)),
Directive::Price(Price::new(
date(2024, 1, 1),
"CHF",
Amount::new(dec!(1.15), "USD"),
)),
Directive::Price(Price::new(
date(2024, 1, 1),
"ABC",
Amount::new(dec!(50.00), "USD"),
)),
];
let result = execute_query("SELECT currency FROM #prices", &directives);
assert_eq!(result.len(), 3);
assert_eq!(result.rows[0][0], Value::String("ABC".to_string()));
assert_eq!(result.rows[1][0], Value::String("CHF".to_string()));
assert_eq!(result.rows[2][0], Value::String("EUR".to_string()));
}
fn make_balances_test_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:Checking")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:Savings")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Balance(Balance::new(
date(2024, 11, 7),
"Assets:Bank:Checking",
Amount::new(dec!(595.47), "EUR"),
)),
Directive::Balance(Balance::new(
date(2024, 11, 8),
"Assets:Bank:Savings",
Amount::new(dec!(5775.09), "EUR"),
)),
Directive::Balance(Balance::new(
date(2024, 11, 9),
"Assets:Cash",
Amount::new(dec!(0.00), "EUR"),
)),
]
}
#[test]
fn test_balances_table_basic_select() {
let directives = make_balances_test_directives();
let result = execute_query("SELECT date, account, amount FROM #balances", &directives);
assert_eq!(result.len(), 3);
assert_eq!(result.columns, vec!["date", "account", "amount"]);
}
#[test]
fn test_balances_table_select_all() {
let directives = make_balances_test_directives();
let result = execute_query("SELECT * FROM #balances", &directives);
assert_eq!(result.len(), 3);
assert_eq!(result.columns, vec!["date", "account", "amount"]);
}
#[test]
fn test_balances_table_with_where_clause() {
let directives = make_balances_test_directives();
let result = execute_query(
"SELECT * FROM #balances WHERE account ~ 'Checking'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(
result.rows[0][1],
Value::String("Assets:Bank:Checking".to_string())
);
}
#[test]
fn test_balances_table_with_date_filter() {
let directives = make_balances_test_directives();
let result = execute_query(
"SELECT * FROM #balances WHERE date >= 2024-11-08",
&directives,
);
assert_eq!(result.len(), 2);
}
#[test]
fn test_balances_table_with_order_by() {
let directives = make_balances_test_directives();
let result = execute_query(
"SELECT account FROM #balances ORDER BY account",
&directives,
);
assert_eq!(result.len(), 3);
assert_eq!(
result.rows[0][0],
Value::String("Assets:Bank:Checking".to_string())
);
assert_eq!(
result.rows[1][0],
Value::String("Assets:Bank:Savings".to_string())
);
assert_eq!(result.rows[2][0], Value::String("Assets:Cash".to_string()));
}
#[test]
fn test_balances_table_with_limit() {
let directives = make_balances_test_directives();
let result = execute_query("SELECT * FROM #balances LIMIT 2", &directives);
assert_eq!(result.len(), 2);
}
#[test]
fn test_balances_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #balances", &directives);
assert!(result.is_empty());
}
#[test]
fn test_balances_table_amount_column_value() {
let directives = vec![Directive::Balance(Balance::new(
date(2024, 6, 15),
"Assets:Checking",
Amount::new(dec!(1234.56), "USD"),
))];
let result = execute_query("SELECT amount FROM #balances", &directives);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.number, dec!(1234.56));
assert_eq!(amt.currency.as_ref(), "USD");
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_balances_table_all_columns() {
let directives = vec![Directive::Balance(Balance::new(
date(2024, 11, 7),
"Assets:Bank:Checking",
Amount::new(dec!(595.47), "EUR"),
))];
let result = execute_query("SELECT date, account, amount FROM #balances", &directives);
assert_eq!(result.len(), 1);
assert_eq!(result.columns, vec!["date", "account", "amount"]);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 11, 7)));
assert_eq!(
result.rows[0][1],
Value::String("Assets:Bank:Checking".to_string())
);
match &result.rows[0][2] {
Value::Amount(amt) => {
assert_eq!(amt.number, dec!(595.47));
assert_eq!(amt.currency.as_ref(), "EUR");
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_balances_table_case_insensitive() {
let directives = vec![Directive::Balance(Balance::new(
date(2024, 6, 15),
"Assets:Checking",
Amount::new(dec!(100.00), "USD"),
))];
let result_lower = execute_query("SELECT * FROM #balances", &directives);
assert_eq!(result_lower.len(), 1);
let result_upper = execute_query("SELECT * FROM #BALANCES", &directives);
assert_eq!(result_upper.len(), 1);
let result_mixed = execute_query("SELECT * FROM #Balances", &directives);
assert_eq!(result_mixed.len(), 1);
assert_eq!(result_lower.rows, result_upper.rows);
assert_eq!(result_lower.rows, result_mixed.rows);
}
#[test]
fn test_balances_table_deterministic_ordering() {
let directives = vec![
Directive::Balance(Balance::new(
date(2024, 1, 1),
"Assets:Zebra",
Amount::new(dec!(100.00), "USD"),
)),
Directive::Balance(Balance::new(
date(2024, 1, 1),
"Assets:Apple",
Amount::new(dec!(200.00), "USD"),
)),
Directive::Balance(Balance::new(
date(2024, 1, 1),
"Assets:Banana",
Amount::new(dec!(300.00), "USD"),
)),
];
let result = execute_query("SELECT account FROM #balances", &directives);
assert_eq!(result.len(), 3);
assert_eq!(result.rows[0][0], Value::String("Assets:Apple".to_string()));
assert_eq!(
result.rows[1][0],
Value::String("Assets:Banana".to_string())
);
assert_eq!(result.rows[2][0], Value::String("Assets:Zebra".to_string()));
}
fn make_commodities_test_directives() -> Vec<Directive> {
vec![
Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
Directive::Commodity(Commodity::new(date(2024, 2, 1), "AAPL")),
Directive::Commodity(Commodity::new(date(2024, 2, 15), "BTC")),
]
}
#[test]
fn test_commodities_table_basic_select() {
let directives = make_commodities_test_directives();
let result = execute_query("SELECT date, name FROM #commodities", &directives);
assert_eq!(result.columns, vec!["date", "name"]);
assert_eq!(result.len(), 4);
}
#[test]
fn test_commodities_table_select_all() {
let directives = make_commodities_test_directives();
let result = execute_query("SELECT * FROM #commodities", &directives);
assert_eq!(result.len(), 4);
}
#[test]
fn test_commodities_table_with_where_clause() {
let directives = make_commodities_test_directives();
let result = execute_query("SELECT * FROM #commodities WHERE name = 'EUR'", &directives);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][1], Value::String("EUR".to_string()));
}
#[test]
fn test_commodities_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #commodities", &directives);
assert!(result.is_empty());
}
#[test]
fn test_commodities_table_case_insensitive() {
let directives = make_commodities_test_directives();
let result_lower = execute_query("SELECT * FROM #commodities", &directives);
let result_upper = execute_query("SELECT * FROM #COMMODITIES", &directives);
let result_mixed = execute_query("SELECT * FROM #Commodities", &directives);
assert_eq!(result_lower.rows, result_upper.rows);
assert_eq!(result_lower.rows, result_mixed.rows);
}
#[test]
fn test_commodities_table_deterministic_ordering() {
let directives = vec![
Directive::Commodity(Commodity::new(date(2024, 1, 1), "ZZZ")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "AAA")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "MMM")),
];
let result = execute_query("SELECT name FROM #commodities", &directives);
assert_eq!(result.rows[0][0], Value::String("AAA".to_string()));
assert_eq!(result.rows[1][0], Value::String("MMM".to_string()));
assert_eq!(result.rows[2][0], Value::String("ZZZ".to_string()));
}
fn make_events_test_directives() -> Vec<Directive> {
vec![
Directive::Event(Event::new(date(2024, 1, 1), "location", "New York")),
Directive::Event(Event::new(date(2024, 3, 15), "employer", "Acme Corp")),
Directive::Event(Event::new(date(2024, 6, 1), "location", "San Francisco")),
]
}
#[test]
fn test_events_table_basic_select() {
let directives = make_events_test_directives();
let result = execute_query("SELECT date, type, description FROM #events", &directives);
assert_eq!(result.columns, vec!["date", "type", "description"]);
assert_eq!(result.len(), 3);
}
#[test]
fn test_events_table_select_all() {
let directives = make_events_test_directives();
let result = execute_query("SELECT * FROM #events", &directives);
assert_eq!(result.len(), 3);
}
#[test]
fn test_events_table_with_where_clause() {
let directives = make_events_test_directives();
let result = execute_query("SELECT * FROM #events WHERE type = 'location'", &directives);
assert_eq!(result.len(), 2);
}
#[test]
fn test_events_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #events", &directives);
assert!(result.is_empty());
}
#[test]
fn test_events_table_case_insensitive() {
let directives = make_events_test_directives();
let result_lower = execute_query("SELECT * FROM #events", &directives);
let result_upper = execute_query("SELECT * FROM #EVENTS", &directives);
assert_eq!(result_lower.rows, result_upper.rows);
}
fn make_notes_test_directives() -> Vec<Directive> {
vec![
Directive::Note(Note::new(
date(2024, 1, 15),
"Assets:Bank:Checking",
"Opened checking account",
)),
Directive::Note(Note::new(
date(2024, 2, 20),
"Expenses:Food",
"Started tracking food expenses",
)),
Directive::Note(Note::new(
date(2024, 3, 1),
"Assets:Bank:Checking",
"Changed overdraft settings",
)),
]
}
#[test]
fn test_notes_table_basic_select() {
let directives = make_notes_test_directives();
let result = execute_query("SELECT date, account, comment FROM #notes", &directives);
assert_eq!(result.columns, vec!["date", "account", "comment"]);
assert_eq!(result.len(), 3);
}
#[test]
fn test_notes_table_select_all() {
let directives = make_notes_test_directives();
let result = execute_query("SELECT * FROM #notes", &directives);
assert_eq!(result.len(), 3);
}
#[test]
fn test_notes_table_with_where_clause() {
let directives = make_notes_test_directives();
let result = execute_query(
"SELECT * FROM #notes WHERE account ~ 'Checking'",
&directives,
);
assert_eq!(result.len(), 2);
}
#[test]
fn test_notes_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #notes", &directives);
assert!(result.is_empty());
}
#[test]
fn test_notes_table_case_insensitive() {
let directives = make_notes_test_directives();
let result_lower = execute_query("SELECT * FROM #notes", &directives);
let result_upper = execute_query("SELECT * FROM #NOTES", &directives);
assert_eq!(result_lower.rows, result_upper.rows);
}
fn make_documents_test_directives() -> Vec<Directive> {
vec![
Directive::Document(
Document::new(
date(2024, 1, 15),
"Assets:Bank:Checking",
"/docs/statement-jan.pdf",
)
.with_tag("statement")
.with_link("doc-001"),
),
Directive::Document(Document::new(
date(2024, 2, 15),
"Assets:Bank:Checking",
"/docs/statement-feb.pdf",
)),
Directive::Document(
Document::new(date(2024, 3, 1), "Expenses:Food", "/receipts/grocery.jpg")
.with_tag("receipt"),
),
]
}
#[test]
fn test_documents_table_basic_select() {
let directives = make_documents_test_directives();
let result = execute_query(
"SELECT date, account, filename, tags, links FROM #documents",
&directives,
);
assert_eq!(
result.columns,
vec!["date", "account", "filename", "tags", "links"]
);
assert_eq!(result.len(), 3);
}
#[test]
fn test_documents_table_select_all() {
let directives = make_documents_test_directives();
let result = execute_query("SELECT * FROM #documents", &directives);
assert_eq!(result.len(), 3);
}
#[test]
fn test_documents_table_with_where_clause() {
let directives = make_documents_test_directives();
let result = execute_query(
"SELECT * FROM #documents WHERE account ~ 'Checking'",
&directives,
);
assert_eq!(result.len(), 2);
}
#[test]
fn test_documents_table_tags_column() {
let directives = make_documents_test_directives();
let result = execute_query(
"SELECT filename, tags FROM #documents WHERE filename ~ 'jan'",
&directives,
);
assert_eq!(result.len(), 1);
if let Value::StringSet(tags) = &result.rows[0][1] {
assert!(tags.contains(&"statement".to_string()));
} else {
panic!("Expected StringSet for tags");
}
}
#[test]
fn test_documents_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #documents", &directives);
assert!(result.is_empty());
}
#[test]
fn test_documents_table_case_insensitive() {
let directives = make_documents_test_directives();
let result_lower = execute_query("SELECT * FROM #documents", &directives);
let result_upper = execute_query("SELECT * FROM #DOCUMENTS", &directives);
assert_eq!(result_lower.rows, result_upper.rows);
}
fn make_accounts_test_directives() -> Vec<Directive> {
vec![
Directive::Open(
Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
.with_currencies(vec!["USD".into(), "EUR".into()]),
),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:Savings")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Investment").with_booking("FIFO")),
Directive::Open(Open::new(date(2024, 2, 1), "Expenses:Food")),
Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank:Savings")),
]
}
#[test]
fn test_accounts_table_basic_select() {
let directives = make_accounts_test_directives();
let result = execute_query(
"SELECT account, open, close, currencies, booking FROM #accounts",
&directives,
);
assert_eq!(
result.columns,
vec!["account", "open", "close", "currencies", "booking"]
);
assert_eq!(result.len(), 4);
}
#[test]
fn test_accounts_table_select_all() {
let directives = make_accounts_test_directives();
let result = execute_query("SELECT * FROM #accounts", &directives);
assert_eq!(result.len(), 4);
}
#[test]
fn test_accounts_table_open_close_dates() {
let directives = make_accounts_test_directives();
let result = execute_query(
"SELECT account, open, close FROM #accounts WHERE account = 'Assets:Bank:Savings'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][1], Value::Date(date(2024, 1, 1)));
assert_eq!(result.rows[0][2], Value::Date(date(2024, 12, 31)));
}
#[test]
fn test_accounts_table_currencies_column() {
let directives = make_accounts_test_directives();
let result = execute_query(
"SELECT account, currencies FROM #accounts WHERE account = 'Assets:Bank:Checking'",
&directives,
);
assert_eq!(result.len(), 1);
if let Value::StringSet(currencies) = &result.rows[0][1] {
assert!(currencies.contains(&"USD".to_string()));
assert!(currencies.contains(&"EUR".to_string()));
} else {
panic!("Expected StringSet for currencies");
}
}
#[test]
fn test_accounts_table_booking_column() {
let directives = make_accounts_test_directives();
let result = execute_query(
"SELECT account, booking FROM #accounts WHERE account = 'Assets:Investment'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][1], Value::String("FIFO".to_string()));
}
#[test]
fn test_accounts_table_null_values() {
let directives = make_accounts_test_directives();
let result = execute_query(
"SELECT account, close, booking FROM #accounts WHERE account = 'Expenses:Food'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][1], Value::Null);
assert_eq!(result.rows[0][2], Value::Null);
}
#[test]
fn test_accounts_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #accounts", &directives);
assert!(result.is_empty());
}
#[test]
fn test_accounts_table_case_insensitive() {
let directives = make_accounts_test_directives();
let result_lower = execute_query("SELECT * FROM #accounts", &directives);
let result_upper = execute_query("SELECT * FROM #ACCOUNTS", &directives);
assert_eq!(result_lower.rows, result_upper.rows);
}
#[test]
fn test_accounts_table_deterministic_ordering() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Zebra")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Apple")),
Directive::Open(Open::new(date(2024, 1, 1), "Liabilities:Banana")),
];
let result = execute_query("SELECT account FROM #accounts", &directives);
assert_eq!(result.rows[0][0], Value::String("Assets:Apple".to_string()));
assert_eq!(
result.rows[1][0],
Value::String("Expenses:Zebra".to_string())
);
assert_eq!(
result.rows[2][0],
Value::String("Liabilities:Banana".to_string())
);
}
#[test]
fn test_transactions_table_basic_select() {
let directives = make_test_directives();
let result = execute_query(
"SELECT date, flag, payee, narration, tags, links, accounts FROM #transactions",
&directives,
);
assert_eq!(
result.columns,
vec![
"date",
"flag",
"payee",
"narration",
"tags",
"links",
"accounts"
]
);
assert_eq!(result.len(), 5);
}
#[test]
fn test_transactions_table_select_all() {
let directives = make_test_directives();
let result = execute_query("SELECT * FROM #transactions", &directives);
assert_eq!(result.len(), 5);
}
#[test]
fn test_transactions_table_with_where_clause() {
let directives = make_test_directives();
let result = execute_query(
"SELECT * FROM #transactions WHERE payee = 'Grocery Store'",
&directives,
);
assert_eq!(result.len(), 2);
}
#[test]
fn test_transactions_table_tags_column() {
let directives = make_test_directives();
let result = execute_query(
"SELECT narration, tags FROM #transactions WHERE narration ~ 'groceries'",
&directives,
);
assert!(!result.is_empty());
for row in &result.rows {
if let Value::StringSet(tags) = &row[1] {
assert!(tags.contains(&"food".to_string()));
}
}
}
#[test]
fn test_transactions_table_accounts_column() {
let directives = make_test_directives();
let result = execute_query(
"SELECT narration, accounts FROM #transactions WHERE narration = 'Monthly salary'",
&directives,
);
assert_eq!(result.len(), 1);
if let Value::StringSet(accounts) = &result.rows[0][1] {
assert!(accounts.contains(&"Income:Salary".to_string()));
assert!(accounts.contains(&"Assets:Bank:Checking".to_string()));
} else {
panic!("Expected StringSet for accounts");
}
}
#[test]
fn test_transactions_table_null_payee() {
let directives = make_test_directives();
let result = execute_query(
"SELECT narration, payee FROM #transactions WHERE narration = 'Transfer to savings'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][1], Value::Null);
}
#[test]
fn test_transactions_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #transactions", &directives);
assert!(result.is_empty());
}
#[test]
fn test_transactions_table_case_insensitive() {
let directives = make_test_directives();
let result_lower = execute_query("SELECT * FROM #transactions", &directives);
let result_upper = execute_query("SELECT * FROM #TRANSACTIONS", &directives);
assert_eq!(result_lower.rows, result_upper.rows);
}
fn make_entries_test_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Test transaction")
.with_payee("Test Payee")
.with_tag("testtag")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(100), "USD"),
)),
),
Directive::Note(Note::new(date(2024, 2, 1), "Assets:Bank", "A note")),
Directive::Event(Event::new(date(2024, 3, 1), "location", "NYC")),
]
}
#[test]
fn test_entries_table_basic_select() {
let directives = make_entries_test_directives();
let result = execute_query(
"SELECT id, type, date, flag, payee, narration FROM #entries",
&directives,
);
assert!(result.columns.contains(&"id".to_string()));
assert!(result.columns.contains(&"type".to_string()));
assert!(result.columns.contains(&"date".to_string()));
assert_eq!(result.len(), 5);
}
#[test]
fn test_entries_table_select_all() {
let directives = make_entries_test_directives();
let result = execute_query("SELECT * FROM #entries", &directives);
assert_eq!(result.len(), 5);
}
#[test]
fn test_entries_table_type_column() {
let directives = make_entries_test_directives();
let result = execute_query("SELECT type FROM #entries", &directives);
let types: Vec<&Value> = result.rows.iter().map(|r| &r[0]).collect();
assert!(types.contains(&&Value::String("open".to_string())));
assert!(types.contains(&&Value::String("commodity".to_string())));
assert!(types.contains(&&Value::String("transaction".to_string())));
assert!(types.contains(&&Value::String("note".to_string())));
assert!(types.contains(&&Value::String("event".to_string())));
}
#[test]
fn test_entries_table_with_where_clause() {
let directives = make_entries_test_directives();
let result = execute_query(
"SELECT * FROM #entries WHERE type = 'transaction'",
&directives,
);
assert_eq!(result.len(), 1);
}
#[test]
fn test_entries_table_transaction_fields() {
let directives = make_entries_test_directives();
let result = execute_query(
"SELECT flag, payee, narration, tags, accounts FROM #entries WHERE type = 'transaction'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("*".to_string()));
assert_eq!(result.rows[0][1], Value::String("Test Payee".to_string()));
assert_eq!(
result.rows[0][2],
Value::String("Test transaction".to_string())
);
if let Value::StringSet(tags) = &result.rows[0][3] {
assert!(tags.contains(&"testtag".to_string()));
}
}
#[test]
fn test_entries_table_non_transaction_nulls() {
let directives = make_entries_test_directives();
let result = execute_query(
"SELECT flag, payee, narration FROM #entries WHERE type = 'open'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Null);
assert_eq!(result.rows[0][1], Value::Null);
assert_eq!(result.rows[0][2], Value::Null);
}
#[test]
fn test_entries_table_id_column() {
let directives = make_entries_test_directives();
let result = execute_query("SELECT id FROM #entries", &directives);
for (i, row) in result.rows.iter().enumerate() {
assert_eq!(row[0], Value::Integer(i as i64));
}
}
#[test]
fn test_entries_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #entries", &directives);
assert!(result.is_empty());
}
#[test]
fn test_entries_table_case_insensitive() {
let directives = make_entries_test_directives();
let result_lower = execute_query("SELECT * FROM #entries", &directives);
let result_upper = execute_query("SELECT * FROM #ENTRIES", &directives);
assert_eq!(result_lower.rows, result_upper.rows);
}
fn make_postings_test_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Groceries")
.with_payee("Store")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 20), "More food")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(30), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-30), "USD"),
)),
),
]
}
#[test]
fn test_postings_table_basic_select() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT date, account, number, currency FROM #postings",
&directives,
);
assert!(result.columns.contains(&"date".to_string()));
assert!(result.columns.contains(&"account".to_string()));
assert!(result.columns.contains(&"number".to_string()));
assert!(result.columns.contains(&"currency".to_string()));
assert_eq!(result.len(), 4);
}
#[test]
fn test_postings_table_select_all() {
let directives = make_postings_test_directives();
let result = execute_query("SELECT * FROM #postings", &directives);
assert_eq!(result.len(), 4);
assert!(result.columns.contains(&"date".to_string()));
assert!(result.columns.contains(&"flag".to_string()));
assert!(result.columns.contains(&"payee".to_string()));
assert!(result.columns.contains(&"narration".to_string()));
assert!(result.columns.contains(&"account".to_string()));
assert!(result.columns.contains(&"number".to_string()));
assert!(result.columns.contains(&"currency".to_string()));
assert!(result.columns.contains(&"balance".to_string()));
}
#[test]
fn test_postings_table_with_where_clause() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT * FROM #postings WHERE account = 'Expenses:Food'",
&directives,
);
assert_eq!(result.len(), 2);
}
#[test]
fn test_postings_table_running_balance() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT account, number, balance FROM #postings WHERE account = 'Expenses:Food'",
&directives,
);
assert_eq!(result.len(), 2);
for row in &result.rows {
if let Value::Inventory(_inv) = &row[2] {
} else if row[2] != Value::Null {
panic!("Expected Inventory for balance, got {:?}", row[2]);
}
}
}
#[test]
fn test_postings_table_parent_transaction_columns() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT date, flag, payee, narration, account FROM #postings WHERE payee = 'Store'",
&directives,
);
assert_eq!(result.len(), 2); for row in &result.rows {
assert_eq!(row[0], Value::Date(date(2024, 1, 15)));
assert_eq!(row[1], Value::String("*".to_string()));
assert_eq!(row[2], Value::String("Store".to_string()));
assert_eq!(row[3], Value::String("Groceries".to_string()));
}
}
#[test]
fn test_postings_table_null_payee() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT payee, narration FROM #postings WHERE narration = 'More food'",
&directives,
);
assert_eq!(result.len(), 2);
assert_eq!(result.rows[0][0], Value::Null);
}
#[test]
fn test_postings_table_empty() {
let directives: Vec<Directive> = vec![];
let result = execute_query("SELECT * FROM #postings", &directives);
assert!(result.is_empty());
}
#[test]
fn test_postings_table_case_insensitive() {
let directives = make_postings_test_directives();
let result_lower = execute_query("SELECT * FROM #postings", &directives);
let result_upper = execute_query("SELECT * FROM #POSTINGS", &directives);
assert_eq!(result_lower.rows, result_upper.rows);
}
#[test]
fn test_postings_table_with_order_by() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT date, account FROM #postings ORDER BY date DESC",
&directives,
);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 20)));
}
#[test]
fn test_postings_table_with_limit() {
let directives = make_postings_test_directives();
let result = execute_query("SELECT * FROM #postings LIMIT 2", &directives);
assert_eq!(result.len(), 2);
}
#[test]
fn test_postings_table_cost_columns() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy stock")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD")
.with_date(date(2024, 1, 15))
.with_label("lot1"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, number, currency, cost_number, cost_currency, cost_date, cost_label FROM #postings WHERE account = 'Assets:Brokerage'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(
result.rows[0][0],
Value::String("Assets:Brokerage".to_string())
);
assert_eq!(result.rows[0][1], Value::Number(dec!(10)));
assert_eq!(result.rows[0][2], Value::String("AAPL".to_string()));
assert_eq!(result.rows[0][3], Value::Number(dec!(150)));
assert_eq!(result.rows[0][4], Value::String("USD".to_string()));
assert_eq!(result.rows[0][5], Value::Date(date(2024, 1, 15)));
assert_eq!(result.rows[0][6], Value::String("lot1".to_string()));
}
#[test]
fn test_postings_table_cost_columns_null_when_no_cost() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Groceries")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, cost_number, cost_currency, cost_date, cost_label FROM #postings WHERE account = 'Expenses:Food'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(
result.rows[0][0],
Value::String("Expenses:Food".to_string())
);
assert_eq!(result.rows[0][1], Value::Null);
assert_eq!(result.rows[0][2], Value::Null);
assert_eq!(result.rows[0][3], Value::Null);
assert_eq!(result.rows[0][4], Value::Null);
}
#[test]
fn test_postings_table_price_column() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy at price")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL"))
.with_price(PriceAnnotation::unit(Amount::new(dec!(150), "USD"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, price FROM #postings WHERE account = 'Assets:Brokerage'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][1] {
Value::Amount(amt) => {
assert_eq!(amt.number, dec!(150));
assert_eq!(amt.currency.as_ref(), "USD");
}
other => panic!("Expected Amount for price, got {other:?}"),
}
}
#[test]
fn test_aggregate_context_non_aggregate_function_short_circuit() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Q1")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-10), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 4, 15), "Q2")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(20), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-20), "USD"),
)),
),
];
let result = execute_query(
"SELECT quarter(date) AS q, sum(number) FROM #postings WHERE account = 'Expenses:Food' GROUP BY q",
&directives,
);
assert_eq!(result.len(), 2, "should have 2 quarters");
let quarters: Vec<_> = result.rows.iter().map(|r| &r[0]).collect();
assert!(
quarters.contains(&&Value::String("2024-Q1".to_string())),
"should contain 2024-Q1, got {quarters:?}"
);
assert!(
quarters.contains(&&Value::String("2024-Q2".to_string())),
"should contain 2024-Q2, got {quarters:?}"
);
}
#[test]
fn test_aggregate_context_function_wrapping_aggregate() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Jan")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-10), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 20), "Mar")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(20), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-20), "USD"),
)),
),
];
let result = execute_query(
"SELECT ymonth(max(date)) WHERE account = 'Expenses:Food'",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("2024-03".to_string()));
}
#[test]
fn test_aggregate_context_account_depth() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food:Restaurant")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Transport")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Lunch")
.with_synthesized_posting(Posting::new(
"Expenses:Food:Restaurant",
Amount::new(dec!(25), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-25), "USD"),
)),
),
];
let result = execute_query(
"SELECT account_depth(account), count(*) GROUP BY account_depth(account)",
&directives,
);
assert!(!result.rows.is_empty());
}
#[test]
fn test_aggregate_context_weight_on_values() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy")
.with_synthesized_posting(
Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let query =
parse("SELECT account, weight(sum(position)) GROUP BY account").expect("should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query);
assert!(
result.is_ok(),
"weight(sum(position)) should not error: {result:?}"
);
}
#[test]
fn test_postings_table_position_column_simple() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Groceries")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, position FROM #postings WHERE account = 'Expenses:Food'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][1] {
Value::Position(pos) => {
assert_eq!(pos.units.number, dec!(50));
assert_eq!(pos.units.currency.as_ref(), "USD");
assert!(pos.cost.is_none());
}
other => panic!("Expected Position, got {other:?}"),
}
}
#[test]
fn test_postings_table_position_column_with_cost() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy stock")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, position FROM #postings WHERE account = 'Assets:Brokerage'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][1] {
Value::Position(pos) => {
assert_eq!(pos.units.number, dec!(10));
assert_eq!(pos.units.currency.as_ref(), "AAPL");
let cost = pos.cost.as_ref().expect("should have cost");
assert_eq!(cost.number, dec!(150));
assert_eq!(cost.currency.as_ref(), "USD");
}
other => panic!("Expected Position, got {other:?}"),
}
}
#[test]
fn test_postings_table_position_in_select_star() {
let directives = make_postings_test_directives();
let result = execute_query("SELECT * FROM #postings", &directives);
assert!(
result.columns.contains(&"position".to_string()),
"position should be in SELECT * columns"
);
}
#[test]
fn test_postings_table_type_and_id_columns() {
let directives = make_postings_test_directives();
let result = execute_query("SELECT type, id FROM #postings LIMIT 1", &directives);
assert_eq!(result.rows[0][0], Value::String("transaction".to_string()));
assert!(matches!(result.rows[0][1], Value::Integer(_)));
}
#[test]
fn test_postings_table_date_parts() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT year, month, day FROM #postings WHERE narration = 'Groceries' LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::Integer(2024));
assert_eq!(result.rows[0][1], Value::Integer(1));
assert_eq!(result.rows[0][2], Value::Integer(15));
}
#[test]
fn test_postings_table_description_column() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT description FROM #postings WHERE payee = 'Store' LIMIT 1",
&directives,
);
assert_eq!(
result.rows[0][0],
Value::String("Store | Groceries".to_string())
);
let result = execute_query(
"SELECT description FROM #postings WHERE narration = 'More food' LIMIT 1",
&directives,
);
assert_eq!(result.rows[0][0], Value::String("More food".to_string()));
}
#[test]
fn test_postings_table_posting_flag_column() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Test")
.with_synthesized_posting(
Posting::new("Expenses:Food", Amount::new(dec!(50), "USD")).with_flag('!'),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, posting_flag FROM #postings ORDER BY account",
&directives,
);
assert_eq!(result.rows[0][1], Value::Null);
assert_eq!(result.rows[1][1], Value::String("!".to_string()));
}
#[test]
fn test_postings_table_other_accounts_column() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT account, other_accounts FROM #postings WHERE account = 'Expenses:Food' LIMIT 1",
&directives,
);
assert_eq!(
result.rows[0][1],
Value::StringSet(vec!["Assets:Bank".to_string()])
);
}
#[test]
fn test_postings_table_accounts_column() {
let directives = make_postings_test_directives();
let result = execute_query("SELECT accounts FROM #postings LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::StringSet(vec!["Assets:Bank".to_string(), "Expenses:Food".to_string(),])
);
}
#[test]
fn test_postings_table_tags_links_columns() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Tagged")
.with_tag("trip")
.with_link("receipt-123")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "USD"),
)),
),
];
let result = execute_query("SELECT tags, links FROM #postings LIMIT 1", &directives);
assert_eq!(
result.rows[0][0],
Value::StringSet(vec!["trip".to_string()])
);
assert_eq!(
result.rows[0][1],
Value::StringSet(vec!["receipt-123".to_string()])
);
}
#[test]
fn test_postings_table_weight_column() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy stock")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(10), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, weight FROM #postings WHERE account = 'Assets:Brokerage'",
&directives,
);
assert_eq!(
result.rows[0][1],
Value::Amount(Amount::new(dec!(1500), "USD"))
);
}
#[test]
fn test_postings_table_weight_uses_preserved_total() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Brokerage")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy at total cost")
.with_synthesized_posting(
Posting::new("Assets:Brokerage", Amount::new(dec!(3), "AAPL")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnitFromTotal(
rustledger_core::BookedCost {
per_unit: dec!(100.00) / dec!(3),
total: dec!(100.00),
},
))
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100.00), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, weight FROM #postings WHERE account = 'Assets:Brokerage'",
&directives,
);
assert_eq!(
result.rows[0][1],
Value::Amount(Amount::new(dec!(100.00), "USD")),
"weight must use the preserved {{{{total}}}}, not units x per_unit",
);
}
#[test]
fn test_postings_table_weight_no_cost() {
let directives = make_postings_test_directives();
let result = execute_query(
"SELECT account, number, weight FROM #postings WHERE account = 'Expenses:Food' LIMIT 1",
&directives,
);
assert_eq!(
result.rows[0][2],
Value::Amount(Amount::new(dec!(50), "USD"))
);
}
#[test]
fn test_postings_table_weight_per_unit_price() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Foreign")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy euros")
.with_synthesized_posting(
Posting::new("Assets:Foreign", Amount::new(dec!(100), "EUR"))
.with_price(PriceAnnotation::unit(Amount::new(dec!(1.10), "USD"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-110), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, weight FROM #postings WHERE account = 'Assets:Foreign'",
&directives,
);
assert_eq!(
result.rows[0][1],
Value::Amount(Amount::new(dec!(110.00), "USD"))
);
}
#[test]
fn test_postings_table_weight_total_price() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Foreign")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy euros")
.with_synthesized_posting(
Posting::new("Assets:Foreign", Amount::new(dec!(100), "EUR"))
.with_price(PriceAnnotation::total(Amount::new(dec!(110), "USD"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-110), "USD"),
)),
),
];
let result = execute_query(
"SELECT account, weight FROM #postings WHERE account = 'Assets:Foreign'",
&directives,
);
assert_eq!(
result.rows[0][1],
Value::Amount(Amount::new(dec!(110), "USD"))
);
}
#[test]
fn test_weight_column_total_price_default_from() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Foreign")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Buy euros")
.with_synthesized_posting(
Posting::new("Assets:Foreign", Amount::new(dec!(100), "EUR"))
.with_price(PriceAnnotation::total(Amount::new(dec!(110), "USD"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-110), "USD"),
)),
),
];
let result = execute_query(
"SELECT weight WHERE account = 'Assets:Foreign'",
&directives,
);
assert_eq!(
result.rows[0][0],
Value::Amount(Amount::new(dec!(110), "USD"))
);
}
#[test]
fn test_weight_total_price_credit_side_flips_sign() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Insurance")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2025, 1, 23), "insurance matured")
.with_synthesized_posting(
Posting::new("Assets:Insurance", Amount::new(dec!(-27204.53), "BAM"))
.with_price(PriceAnnotation::total(Amount::new(dec!(15152.07), "EUR"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(15152.07), "EUR"),
)),
),
];
let result = execute_query(
"SELECT weight FROM #postings WHERE account = 'Assets:Insurance'",
&directives,
);
assert_eq!(
result.rows[0][0],
Value::Amount(Amount::new(dec!(-15152.07), "EUR")),
"weight on a credit-side @@ posting must flip sign (#postings path)"
);
let result = execute_query(
"SELECT weight WHERE account = 'Assets:Insurance'",
&directives,
);
assert_eq!(
result.rows[0][0],
Value::Amount(Amount::new(dec!(-15152.07), "EUR")),
"weight on a credit-side @@ posting must flip sign (default-FROM path)"
);
let result = execute_query("SELECT weight WHERE account = 'Assets:Cash'", &directives);
assert_eq!(
result.rows[0][0],
Value::Amount(Amount::new(dec!(15152.07), "EUR")),
"Cash posting has positive units, weight should be +amount"
);
}
#[test]
fn test_postings_table_lineno_query() {
let directives = make_postings_test_directives();
let result = execute_query("SELECT lineno FROM #postings LIMIT 1", &directives);
assert_eq!(result.columns, vec!["lineno"]);
assert_eq!(result.rows.len(), 1);
}
#[test]
fn test_postings_table_all_beancount_columns() {
let directives = make_postings_test_directives();
let result = execute_query("SELECT * FROM #postings LIMIT 1", &directives);
let expected_columns = [
"type",
"id",
"date",
"year",
"month",
"day",
"filename",
"lineno",
"location",
"flag",
"payee",
"narration",
"description",
"tags",
"links",
"posting_flag",
"account",
"other_accounts",
"number",
"currency",
"cost_number",
"cost_currency",
"cost_date",
"cost_label",
"position",
"price",
"weight",
"balance",
"meta",
"accounts",
];
for col in &expected_columns {
assert!(
result.columns.contains(&col.to_string()),
"Missing column: {col}"
);
}
}
#[test]
fn test_unknown_system_table_error_lists_all_tables() {
let directives: Vec<Directive> = vec![];
let query = parse("SELECT * FROM #unknown").expect("query should parse");
let mut executor = Executor::new(&directives);
let result = executor.execute(&query);
match result {
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("#unknown"),
"Error should mention the table name"
);
assert!(msg.contains("#accounts"), "Error should list #accounts");
assert!(msg.contains("#balances"), "Error should list #balances");
assert!(
msg.contains("#commodities"),
"Error should list #commodities"
);
assert!(msg.contains("#documents"), "Error should list #documents");
assert!(msg.contains("#entries"), "Error should list #entries");
assert!(msg.contains("#events"), "Error should list #events");
assert!(msg.contains("#notes"), "Error should list #notes");
assert!(msg.contains("#postings"), "Error should list #postings");
assert!(msg.contains("#prices"), "Error should list #prices");
assert!(
msg.contains("#transactions"),
"Error should list #transactions"
);
}
Ok(_) => panic!("Expected error for unknown system table"),
}
}
fn make_convert_test_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:CHF")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Other")),
Directive::Price(Price::new(
date(2025, 1, 10),
"CHF",
Amount::new(dec!(1.0647), "EUR"),
)),
Directive::Transaction(
Transaction::new(date(2025, 7, 15), "Incoming transfer")
.with_synthesized_posting(Posting::new(
"Assets:Bank:CHF",
Amount::new(dec!(3000), "CHF"),
))
.with_synthesized_posting(Posting::new(
"Income:Other",
Amount::new(dec!(-3000), "CHF"),
)),
),
]
}
#[test]
fn test_issue_565_convert_sum_position() {
let directives = make_convert_test_directives();
let result = execute_query(
"SELECT account, convert(sum(position), 'EUR') WHERE account = 'Assets:Bank:CHF' GROUP BY account",
&directives,
);
assert_eq!(result.len(), 1);
assert_eq!(
result.rows[0][0],
Value::String("Assets:Bank:CHF".to_string())
);
match &result.rows[0][1] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "EUR");
assert_eq!(amt.number, dec!(3194.1)); }
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_sum_position_already_target_currency() {
let directives = make_convert_test_directives();
let result = execute_query(
"SELECT account, convert(sum(position), 'CHF') WHERE account = 'Assets:Bank:CHF' GROUP BY account",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][1] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "CHF");
assert_eq!(amt.number, dec!(3000)); }
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_with_explicit_date() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Other")),
Directive::Price(Price::new(
date(2024, 1, 1),
"USD",
Amount::new(dec!(0.90), "EUR"),
)),
Directive::Price(Price::new(
date(2024, 6, 1),
"USD",
Amount::new(dec!(0.95), "EUR"),
)),
Directive::Transaction(
Transaction::new(date(2024, 3, 15), "Deposit")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
))
.with_synthesized_posting(Posting::new(
"Income:Other",
Amount::new(dec!(-1000), "USD"),
)),
),
];
let result = execute_query(
"SELECT convert(sum(position), 'EUR', 2024-01-15) WHERE account = 'Assets:Bank' GROUP BY account",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "EUR");
assert_eq!(amt.number, dec!(900)); }
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_multiple_currencies_in_inventory() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Other")),
Directive::Price(Price::new(
date(2024, 1, 1),
"USD",
Amount::new(dec!(0.92), "EUR"),
)),
Directive::Price(Price::new(
date(2024, 1, 1),
"GBP",
Amount::new(dec!(1.17), "EUR"),
)),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "USD Deposit")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
))
.with_synthesized_posting(Posting::new(
"Income:Other",
Amount::new(dec!(-1000), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 20), "GBP Deposit")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(500), "GBP"),
))
.with_synthesized_posting(Posting::new(
"Income:Other",
Amount::new(dec!(-500), "GBP"),
)),
),
];
let result = execute_query(
"SELECT convert(sum(position), 'EUR') WHERE account = 'Assets:Bank' GROUP BY account",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "EUR");
assert_eq!(amt.number, dec!(1505));
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_basic_amount() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Price(Price::new(
date(2024, 1, 1),
"USD",
Amount::new(dec!(0.92), "EUR"),
)),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Groceries")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "USD"),
)),
),
];
let result = execute_query(
"SELECT convert(position, 'EUR') WHERE account = 'Expenses:Food'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "EUR");
assert_eq!(amt.number, dec!(92)); }
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_number_to_currency() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Groceries")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-100), "USD"),
)),
),
];
let result = execute_query(
"SELECT convert(sum(number(position)), 'EUR') WHERE account = 'Expenses:Food' GROUP BY account",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "EUR");
assert_eq!(amt.number, dec!(100)); }
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_unconvertible_currency_kept_original() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:EUR")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:JPY")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank:USD")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 1, 1), "Opening EUR")
.with_synthesized_posting(Posting::new(
"Assets:Bank:EUR",
Amount::new(dec!(1000), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-1000), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 1), "Opening JPY")
.with_synthesized_posting(Posting::new(
"Assets:Bank:JPY",
Amount::new(dec!(50000), "JPY"),
))
.with_synthesized_posting(Posting::new(
"Equity:Opening",
Amount::new(dec!(-50000), "JPY"),
)),
),
];
let result = execute_query(
"SELECT convert(sum(position), 'EUR') WHERE account ~ '^Assets:Bank' GROUP BY 1",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Inventory(inv) => {
let positions = inv.position_list();
assert_eq!(
positions.len(),
2,
"Expected 2 positions (EUR + unconverted JPY)"
);
let currencies: Vec<_> = positions
.iter()
.map(|p| p.units.currency.as_ref())
.collect();
assert!(currencies.contains(&"EUR"), "Should have EUR");
assert!(currencies.contains(&"JPY"), "Should have JPY (unconverted)");
}
other => panic!("Expected Inventory with mixed currencies, got {other:?}"),
}
}
#[test]
fn test_issue_567_value_uses_implicit_price_from_annotation() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stocks")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
Directive::Transaction(
Transaction::new(date(2024, 1, 10), "Buy stock")
.with_synthesized_posting(
Posting::new("Assets:Stocks", Amount::new(dec!(5), "ABC")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.25) })
.with_currency("EUR")
.with_date(date(2024, 1, 10)),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-6.25), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Sell stock")
.with_synthesized_posting(
Posting::new("Assets:Stocks", Amount::new(dec!(-5), "ABC"))
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(1.25),
})
.with_currency("EUR")
.with_date(date(2024, 1, 10)),
)
.with_price(PriceAnnotation::unit(Amount::new(dec!(1.40), "EUR"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(7.00), "EUR"),
)),
),
];
let result = execute_query(
"SELECT cost(position), value(position, 'EUR') WHERE account = 'Assets:Stocks' AND number > 0",
&directives,
);
assert_eq!(result.len(), 1, "Should have 1 row for buy transaction");
match &result.rows[0][0] {
Value::Amount(cost) => {
assert_eq!(
cost.number,
dec!(6.25),
"Cost should be 5 * 1.25 = 6.25 EUR"
);
assert_eq!(cost.currency.as_ref(), "EUR");
}
other => panic!("Expected Amount for cost, got {other:?}"),
}
match &result.rows[0][1] {
Value::Amount(market_value) => {
assert_eq!(
market_value.number,
dec!(7.00),
"VALUE should use implicit price 1.40 from @ annotation, not cost 1.25. Got: {} EUR",
market_value.number
);
assert_eq!(market_value.currency.as_ref(), "EUR");
}
other => panic!("Expected Amount for value, got {other:?}"),
}
}
#[test]
fn test_issue_567_value_sum_position_with_implicit_price() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stocks")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 10), "Buy XYZ")
.with_synthesized_posting(
Posting::new("Assets:Stocks", Amount::new(dec!(10), "XYZ")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(50) })
.with_currency("USD"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-500), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 2, 15), "Sell XYZ")
.with_synthesized_posting(
Posting::new("Assets:Stocks", Amount::new(dec!(-5), "XYZ"))
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(50),
})
.with_currency("USD"),
)
.with_price(PriceAnnotation::unit(Amount::new(dec!(60), "USD"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(300), "USD"),
)),
),
];
let result = execute_query(
"SELECT value(sum(position), 'USD') WHERE account = 'Assets:Stocks' GROUP BY account",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(market_value) => {
assert_eq!(
market_value.number,
dec!(300),
"value(sum(position)) should use implicit price 60, giving 5 * 60 = 300 USD"
);
assert_eq!(market_value.currency.as_ref(), "USD");
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_issue_575_implicit_group_by() {
let directives = make_issue_575_directives();
let result = execute_query(
r"SELECT sum(number), currency, account ORDER BY account",
&directives,
);
assert_eq!(
result.len(),
3,
"Should return 3 rows when implicitly grouping by currency and account"
);
let accounts: Vec<&str> = result
.rows
.iter()
.map(|row| match &row[2] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for account name in column 2, got {other:?}"),
})
.collect();
assert_eq!(
accounts,
vec!["Assets:Bank", "Assets:Investment", "Expenses:Food"]
);
if let Value::Number(n) = &result.rows[0][0] {
assert_eq!(*n, dec!(-550), "Assets:Bank should have sum -550");
} else {
panic!("Expected Number for Assets:Bank sum");
}
if let Value::Number(n) = &result.rows[1][0] {
assert_eq!(*n, dec!(5), "Assets:Investment should have sum 5");
} else {
panic!("Expected Number for Assets:Investment sum");
}
if let Value::Number(n) = &result.rows[2][0] {
assert_eq!(*n, dec!(50), "Expenses:Food should have sum 50");
} else {
panic!("Expected Number for Expenses:Food sum");
}
}
#[test]
fn test_pure_aggregate_no_implicit_group_by() {
let directives = make_issue_575_directives();
let result = execute_query(r"SELECT count(*)", &directives);
assert_eq!(result.len(), 1, "Pure aggregate should return 1 row");
if let Value::Integer(n) = &result.rows[0][0] {
assert_eq!(*n, 4, "Should count all 4 postings");
} else {
panic!("Expected Integer for count(*)");
}
}
#[test]
fn test_explicit_group_by_overrides_implicit() {
let directives = make_issue_575_directives();
let result = execute_query(
r"SELECT sum(number), currency GROUP BY currency ORDER BY currency",
&directives,
);
assert_eq!(
result.len(),
2,
"Explicit GROUP BY currency should return 2 rows"
);
let currencies: Vec<&str> = result
.rows
.iter()
.map(|row| match &row[1] {
Value::String(s) => s.as_str(),
other => panic!("Expected String for currency in column 1, got {other:?}"),
})
.collect();
assert_eq!(currencies, vec!["ABC", "EUR"]);
}
fn make_issue_575_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2026, 3, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2026, 3, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2026, 3, 1), "Assets:Investment")),
Directive::Open(Open::new(date(2026, 3, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2026, 3, 1), "Income:Salary")),
Directive::Transaction(
Transaction::new(date(2026, 3, 26), "Grocery shopping")
.with_payee("Grocery Store")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-50), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2026, 3, 27), "Buy Stock")
.with_synthesized_posting(
Posting::new("Assets:Investment", Amount::new(dec!(5), "ABC")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(100) })
.with_currency("EUR"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-500), "EUR"),
)),
),
]
}
#[test]
fn test_issue_586_convert_null_returns_zero() {
let directives = vec![
Directive::Open(Open::new(
date(2024, 1, 1),
"Liabilities:CreditCards:WithBalance",
)),
Directive::Open(Open::new(
date(2024, 1, 1),
"Liabilities:CreditCards:ZeroBalance",
)),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Refund")),
Directive::Price(Price::new(
date(2024, 1, 1),
"EUR",
Amount::new(dec!(0.85), "GBP"),
)),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Groceries")
.with_synthesized_posting(Posting::new(
"Liabilities:CreditCards:WithBalance",
Amount::new(dec!(-100), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(100), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 16), "Purchase")
.with_synthesized_posting(Posting::new(
"Liabilities:CreditCards:ZeroBalance",
Amount::new(dec!(-50), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(50), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 17), "Refund")
.with_synthesized_posting(Posting::new(
"Liabilities:CreditCards:ZeroBalance",
Amount::new(dec!(50), "EUR"),
))
.with_synthesized_posting(Posting::new(
"Income:Refund",
Amount::new(dec!(-50), "EUR"),
)),
),
];
let result = execute_query(
r"SELECT account, units(sum(position)) as Balance, convert(sum(position), 'GBP') as Converted
WHERE account ~ 'CreditCards'
GROUP BY account
ORDER BY account",
&directives,
);
assert_eq!(
result.rows.len(),
2,
"Should return both accounts with transactions"
);
assert_eq!(result.columns, vec!["account", "Balance", "Converted"]);
match &result.rows[0][2] {
Value::Amount(a) => {
assert_eq!(
a.number,
dec!(-85),
"convert(sum(position), 'GBP') should convert EUR to GBP"
);
assert_eq!(a.currency.as_ref(), "GBP");
}
other => panic!("Expected Amount for WithBalance converted, got {other:?}"),
}
match &result.rows[1][2] {
Value::Amount(a) => {
assert_eq!(
a.number,
dec!(0),
"convert() should return 0.00 GBP for account with zero balance"
);
assert_eq!(a.currency.as_ref(), "GBP");
}
other => panic!("Expected Amount for ZeroBalance converted, got {other:?}"),
}
}
#[test]
fn test_convert_no_price_fallback() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Deposit")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(100), "USD"),
))
.with_synthesized_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-100), "USD"),
)),
),
];
let result = execute_query(
r"SELECT account, convert(sum(position), 'GBP') as converted
WHERE account = 'Assets:Bank'
GROUP BY account",
&directives,
);
assert_eq!(
result.rows.len(),
1,
"Expected exactly one row for Assets:Bank"
);
match &result.rows[0][1] {
Value::Inventory(inv) => {
let positions = inv.position_list();
assert_eq!(positions.len(), 1);
assert_eq!(positions[0].units.number, dec!(100));
assert_eq!(positions[0].units.currency.as_ref(), "USD");
}
Value::Amount(a) => {
assert_eq!(a.number, dec!(100));
assert_eq!(a.currency.as_ref(), "USD");
}
other => panic!("Expected Inventory or Amount with original USD, got {other:?}"),
}
}
#[test]
fn test_issue_593_cost_preserves_sign_for_sells() {
let directives = vec![
Directive::Open(Open::new(date(2025, 1, 1), "Equity:Stocks")),
Directive::Open(Open::new(date(2025, 1, 1), "Assets:Bank:Checking")),
Directive::Transaction(
Transaction::new(date(2025, 4, 1), "Buy Stocks")
.with_synthesized_posting(
Posting::new("Equity:Stocks", Amount::new(dec!(5), "ABC")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.25) })
.with_currency("EUR"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-6.25), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 4, 2), "Buy more stocks")
.with_synthesized_posting(
Posting::new("Equity:Stocks", Amount::new(dec!(7), "ABC")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.30) })
.with_currency("EUR"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-9.10), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 9, 9), "Sell complete lot")
.with_synthesized_posting(
Posting::new("Equity:Stocks", Amount::new(dec!(-5), "ABC"))
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(1.25),
})
.with_currency("EUR")
.with_date(date(2025, 4, 1)),
)
.with_price(PriceAnnotation::unit(Amount::new(dec!(1.35), "EUR"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(6.75), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 9, 10), "Sell some stock")
.with_synthesized_posting(
Posting::new("Equity:Stocks", Amount::new(dec!(-3), "ABC"))
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(1.30),
})
.with_currency("EUR")
.with_date(date(2025, 4, 2)),
)
.with_price(PriceAnnotation::unit(Amount::new(dec!(1.40), "EUR"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(4.20), "EUR"),
)),
),
];
let result = execute_query(
"SELECT date, cost(position) WHERE account = 'Equity:Stocks' ORDER BY date",
&directives,
);
assert_eq!(result.rows.len(), 4, "Should have 4 posting rows");
match &result.rows[0][1] {
Value::Amount(a) => {
assert_eq!(a.number, dec!(6.25), "Buy cost should be positive");
assert_eq!(a.currency.as_ref(), "EUR");
}
other => panic!("Expected Amount, got {other:?}"),
}
match &result.rows[1][1] {
Value::Amount(a) => {
assert_eq!(a.number, dec!(9.10), "Buy cost should be positive");
}
other => panic!("Expected Amount, got {other:?}"),
}
match &result.rows[2][1] {
Value::Amount(a) => {
assert_eq!(
a.number,
dec!(-6.25),
"Sell cost should be NEGATIVE (this was the bug - it was positive due to .abs())"
);
}
other => panic!("Expected Amount, got {other:?}"),
}
match &result.rows[3][1] {
Value::Amount(a) => {
assert_eq!(a.number, dec!(-3.90), "Sell cost should be NEGATIVE");
}
other => panic!("Expected Amount, got {other:?}"),
}
let sum_result = execute_query(
"SELECT SUM(cost(position)) WHERE account = 'Equity:Stocks'",
&directives,
);
assert_eq!(sum_result.rows.len(), 1);
let sum_value = match &sum_result.rows[0][0] {
Value::Amount(a) => a.number,
Value::Inventory(inv) => {
let positions = inv.position_list();
assert_eq!(positions.len(), 1, "Expected single position in inventory");
assert_eq!(positions[0].units.currency.as_ref(), "EUR");
positions[0].units.number
}
other => panic!("Expected Amount or Inventory, got {other:?}"),
};
assert_eq!(
sum_value,
dec!(5.20),
"SUM(cost(position)) should be 5.20 EUR (net cost of remaining 4 ABC at 1.30)"
);
let cost_sum_result = execute_query(
"SELECT cost(SUM(position)) WHERE account = 'Equity:Stocks'",
&directives,
);
assert_eq!(cost_sum_result.rows.len(), 1);
let cost_sum_value = match &cost_sum_result.rows[0][0] {
Value::Amount(a) => a.number,
Value::Inventory(inv) => {
let positions = inv.position_list();
assert_eq!(positions.len(), 1, "Expected single position in inventory");
assert_eq!(positions[0].units.currency.as_ref(), "EUR");
positions[0].units.number
}
other => panic!("Expected Amount or Inventory, got {other:?}"),
};
assert_eq!(
cost_sum_value,
dec!(5.20),
"cost(SUM(position)) should be 5.20 EUR - this is the issue #593 pattern"
);
}
#[test]
fn test_issue_593_value_uses_latest_implicit_price() {
let directives = vec![
Directive::Open(Open::new(date(2025, 1, 1), "Equity:Stocks")),
Directive::Open(Open::new(date(2025, 1, 1), "Assets:Bank:Checking")),
Directive::Transaction(
Transaction::new(date(2025, 4, 1), "Buy Stocks")
.with_synthesized_posting(
Posting::new("Equity:Stocks", Amount::new(dec!(5), "ABC")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.25) })
.with_currency("EUR"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-6.25), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 4, 2), "Buy more stocks")
.with_synthesized_posting(
Posting::new("Equity:Stocks", Amount::new(dec!(7), "ABC")).with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit { value: dec!(1.30) })
.with_currency("EUR"),
),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-9.10), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 9, 9), "Sell at 1.35")
.with_synthesized_posting(
Posting::new("Equity:Stocks", Amount::new(dec!(-5), "ABC"))
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(1.25),
})
.with_currency("EUR")
.with_date(date(2025, 4, 1)),
)
.with_price(PriceAnnotation::unit(Amount::new(dec!(1.35), "EUR"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(6.75), "EUR"),
)),
),
Directive::Transaction(
Transaction::new(date(2025, 9, 10), "Sell at 1.40")
.with_synthesized_posting(
Posting::new("Equity:Stocks", Amount::new(dec!(-3), "ABC"))
.with_cost(
CostSpec::empty()
.with_number(rustledger_core::CostNumber::PerUnit {
value: dec!(1.30),
})
.with_currency("EUR")
.with_date(date(2025, 4, 2)),
)
.with_price(PriceAnnotation::unit(Amount::new(dec!(1.40), "EUR"))),
)
.with_synthesized_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(4.20), "EUR"),
)),
),
];
let result = execute_query(
"SELECT date, value(position, 'EUR') WHERE account = 'Equity:Stocks' AND number < 0 ORDER BY date",
&directives,
);
assert_eq!(result.rows.len(), 2, "Should have 2 sell transactions");
match &result.rows[0][1] {
Value::Amount(a) => {
assert_eq!(
a.number,
dec!(-7.00),
"value(-5 ABC) should use latest price 1.40, giving -7.00 EUR"
);
assert_eq!(a.currency.as_ref(), "EUR");
}
other => panic!("Expected Amount, got {other:?}"),
}
match &result.rows[1][1] {
Value::Amount(a) => {
assert_eq!(
a.number,
dec!(-4.20),
"value(-3 ABC) should use latest price 1.40, giving -4.20 EUR"
);
}
other => panic!("Expected Amount, got {other:?}"),
}
let sum_result = execute_query(
"SELECT SUM(value(position, 'EUR')) WHERE account = 'Equity:Stocks'",
&directives,
);
assert_eq!(sum_result.rows.len(), 1);
let sum_value = match &sum_result.rows[0][0] {
Value::Amount(a) => a.number,
Value::Inventory(inv) => {
let positions = inv.position_list();
assert_eq!(positions.len(), 1, "Expected single position in inventory");
assert_eq!(positions[0].units.currency.as_ref(), "EUR");
positions[0].units.number
}
other => panic!("Expected Amount or Inventory, got {other:?}"),
};
assert_eq!(
sum_value,
dec!(5.60),
"SUM(value(position)) should be 5.60 EUR (4 ABC * 1.40 latest price)"
);
}
#[test]
fn test_issue_632_table_aliases_without_hash_prefix() {
let directives = make_test_directives();
let tables_to_test = [
("transactions", "#transactions"),
("entries", "#entries"),
("postings", "#postings"),
("prices", "#prices"),
("balances", "#balances"),
("accounts", "#accounts"),
("events", "#events"),
("notes", "#notes"),
("documents", "#documents"),
("commodities", "#commodities"),
];
for (alias, canonical) in tables_to_test {
let query_alias = format!("SELECT * FROM {alias}");
let query_canonical = format!("SELECT * FROM {canonical}");
let result_alias = execute_query(&query_alias, &directives);
let result_canonical = execute_query(&query_canonical, &directives);
assert_eq!(
result_alias.columns, result_canonical.columns,
"Columns should match for '{alias}' vs '{canonical}'"
);
assert_eq!(
result_alias.rows.len(),
result_canonical.rows.len(),
"Row count should match for '{alias}' vs '{canonical}'"
);
}
}
#[test]
fn test_issue_632_user_table_takes_precedence_over_alias() {
let directives = make_test_directives();
let mut executor = Executor::new(&directives);
let create_query = parse("CREATE TABLE balances (name, value)").expect("should parse");
executor.execute(&create_query).expect("should execute");
let insert_query = parse("INSERT INTO balances VALUES ('test', 123)").expect("should parse");
executor.execute(&insert_query).expect("should execute");
let select_query = parse("SELECT * FROM balances").expect("should parse");
let result = executor.execute(&select_query).expect("should execute");
assert_eq!(result.columns, vec!["name", "value"]);
assert_eq!(result.rows.len(), 1);
if let Value::String(name) = &result.rows[0][0] {
assert_eq!(name, "test");
} else {
panic!("Expected String value for name column");
}
}
#[test]
fn test_order_by_expression_not_in_select() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Lunch")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-10), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 20), "Pay")
.with_synthesized_posting(Posting::new(
"Income:Salary",
Amount::new(dec!(-1000), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(1000), "USD"),
)),
),
];
let result = execute_query(
"SELECT account FROM #postings ORDER BY account_sortkey(account)",
&directives,
);
assert!(!result.rows.is_empty(), "should return rows");
assert_eq!(result.columns.len(), 1, "hidden column should be stripped");
assert_eq!(result.columns[0], "account");
let accounts: Vec<&str> = result
.rows
.iter()
.filter_map(|r| match &r[0] {
Value::String(s) => Some(s.as_str()),
_ => None,
})
.collect();
let first_assets = accounts
.iter()
.position(|a| a.starts_with("Assets"))
.expect("expected an Assets account in query results");
let first_expenses = accounts
.iter()
.position(|a| a.starts_with("Expenses"))
.expect("expected an Expenses account in query results");
let first_income = accounts
.iter()
.position(|a| a.starts_with("Income"))
.expect("expected an Income account in query results");
assert!(
first_assets < first_income && first_income < first_expenses,
"accounts should be sorted by type via account_sortkey: got {accounts:?}"
);
}
#[test]
fn test_order_by_function_not_in_select_simple() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:A")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:FoodAndDrink")),
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "t1")
.with_synthesized_posting(Posting::new(
"Expenses:FoodAndDrink",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Cash",
Amount::new(dec!(-10), "USD"),
)),
),
];
let result = execute_query(
"SELECT account FROM #postings ORDER BY length(account)",
&directives,
);
assert!(!result.rows.is_empty());
assert_eq!(result.columns.len(), 1, "hidden column should be stripped");
let accounts: Vec<&str> = result
.rows
.iter()
.filter_map(|r| match &r[0] {
Value::String(s) => Some(s.as_str()),
_ => None,
})
.collect();
assert!(
accounts
.windows(2)
.all(|pair| pair[0].len() <= pair[1].len()),
"accounts should be sorted by ascending length: got {accounts:?}"
);
}
#[test]
fn test_open_date_from_postings_table() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 2, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 3, 15), "Lunch")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-10), "USD"),
)),
),
];
let result = execute_query(
"SELECT DISTINCT account, open_date(account) FROM #postings ORDER BY account",
&directives,
);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][1], Value::Date(date(2024, 1, 1)));
assert_eq!(result.rows[1][1], Value::Date(date(2024, 2, 1)));
}
#[test]
fn test_close_date_from_postings_table() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Old")),
Directive::Close(Close::new(date(2024, 6, 30), "Assets:Old")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 3, 15), "Lunch")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Old",
Amount::new(dec!(-10), "USD"),
)),
),
];
let result = execute_query(
"SELECT DISTINCT account, close_date(account) FROM #postings ORDER BY account",
&directives,
);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][1], Value::Date(date(2024, 6, 30)));
assert_eq!(result.rows[1][1], Value::Null);
}
#[test]
fn test_grep_with_null_narration() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction(
Transaction::new(date(2024, 3, 15), "Salary Payment")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
))
.with_synthesized_posting(Posting::auto("Income:Salary")),
),
];
let result = execute_query(
"SELECT type, narration FROM #entries WHERE grep('Salary', narration) IS NOT NULL",
&directives,
);
assert_eq!(result.rows.len(), 1);
if let Value::String(narration) = &result.rows[0][1] {
assert!(narration.contains("Salary"));
}
}
#[test]
fn test_grep_in_where_clause_truthy() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Coffee")),
Directive::Transaction(
Transaction::new(date(2024, 3, 15), "Salary Payment")
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
))
.with_synthesized_posting(Posting::auto("Income:Salary")),
),
Directive::Transaction(
Transaction::new(date(2024, 3, 16), "Coffee shop")
.with_synthesized_posting(Posting::new(
"Expenses:Coffee",
Amount::new(dec!(5), "USD"),
))
.with_synthesized_posting(Posting::auto("Assets:Bank")),
),
];
let result = execute_query(
"SELECT narration FROM #entries WHERE grep('Salary', narration)",
&directives,
);
assert_eq!(
result.rows.len(),
1,
"expected only the Salary transaction, got rows: {:?}",
result.rows
);
if let Value::String(narration) = &result.rows[0][0] {
assert!(narration.contains("Salary"), "got narration: {narration}");
} else {
panic!("expected String narration, got {:?}", result.rows[0][0]);
}
}
#[test]
fn test_open_meta_from_postings_table() {
let mut open = Open::new(date(2024, 1, 1), "Assets:Bank");
open.meta.insert(
"institution".to_string(),
rustledger_core::MetaValue::String("Chase".to_string()),
);
let directives = vec![
Directive::Open(open),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 3, 15), "Lunch")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-10), "USD"),
)),
),
];
let result = execute_query(
"SELECT DISTINCT account, open_meta(account, 'institution') FROM #postings ORDER BY account",
&directives,
);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][1], Value::String("Chase".to_string()));
assert_eq!(result.rows[1][1], Value::Null);
}
#[test]
fn test_entry_meta_from_postings_table() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction({
let mut txn = Transaction::new(date(2024, 3, 15), "Lunch");
txn.meta.insert(
"category".to_string(),
rustledger_core::MetaValue::String("dining".to_string()),
);
txn.postings = vec![
rustledger_core::Spanned::synthesized(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
)),
rustledger_core::Spanned::synthesized(Posting::new(
"Assets:Bank",
Amount::new(dec!(-10), "USD"),
)),
];
txn
}),
];
let result = execute_query(
"SELECT account, entry_meta('category') FROM #postings",
&directives,
);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][1], Value::String("dining".to_string()));
assert_eq!(result.rows[1][1], Value::String("dining".to_string()));
}
#[test]
fn test_getitem_reads_posting_metadata() {
let mut food = Posting::new("Expenses:Food", Amount::new(dec!(10), "USD"));
food.meta.insert(
"rating".to_string(),
rustledger_core::MetaValue::String("good".to_string()),
);
let directives = vec![
Directive::Open(Open::new(date(2022, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2022, 1, 1), "Expenses:Food")),
Directive::Transaction({
let mut txn = Transaction::new(date(2022, 4, 1), "Lunch");
txn.postings = vec![
rustledger_core::Spanned::synthesized(food),
rustledger_core::Spanned::synthesized(Posting::new(
"Assets:Cash",
Amount::new(dec!(-10), "USD"),
)),
];
txn
}),
];
let result = execute_query("SELECT account, getitem(meta, 'rating')", &directives);
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][1], Value::String("good".to_string()));
assert_eq!(result.rows[1][1], Value::Null);
}
#[test]
fn test_order_by_positional_ordinal() {
let directives = vec![
Directive::Open(Open::new(date(2022, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2022, 1, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2022, 1, 1), "Expenses:Auto")),
Directive::Transaction({
let mut txn = Transaction::new(date(2022, 4, 1), "x");
txn.postings = vec![
rustledger_core::Spanned::synthesized(Posting::new(
"Expenses:Food",
Amount::new(dec!(30), "USD"),
)),
rustledger_core::Spanned::synthesized(Posting::new(
"Expenses:Auto",
Amount::new(dec!(10), "USD"),
)),
rustledger_core::Spanned::synthesized(Posting::new(
"Assets:Cash",
Amount::new(dec!(-40), "USD"),
)),
];
txn
}),
];
let accounts = |q: &str| -> Vec<Value> {
execute_query(q, &directives)
.rows
.iter()
.map(|row| row[0].clone())
.collect()
};
assert_eq!(
accounts("SELECT account, number ORDER BY 1"),
vec![
Value::String("Assets:Cash".to_string()),
Value::String("Expenses:Auto".to_string()),
Value::String("Expenses:Food".to_string()),
]
);
assert_eq!(
accounts("SELECT account, number ORDER BY 2 DESC"),
vec![
Value::String("Expenses:Food".to_string()),
Value::String("Expenses:Auto".to_string()),
Value::String("Assets:Cash".to_string()),
]
);
let err = execute_query_err("SELECT account ORDER BY 5", &directives);
assert!(err.to_string().contains("out of range"), "got: {err}");
}
#[test]
fn test_group_by_positional_ordinal() {
let directives = vec![
Directive::Open(Open::new(date(2022, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2022, 1, 1), "Expenses:Food")),
Directive::Open(Open::new(date(2022, 1, 1), "Expenses:Auto")),
Directive::Transaction({
let mut txn = Transaction::new(date(2022, 4, 1), "x");
txn.postings = vec![
rustledger_core::Spanned::synthesized(Posting::new(
"Expenses:Food",
Amount::new(dec!(30), "USD"),
)),
rustledger_core::Spanned::synthesized(Posting::new(
"Expenses:Auto",
Amount::new(dec!(10), "USD"),
)),
rustledger_core::Spanned::synthesized(Posting::new(
"Assets:Cash",
Amount::new(dec!(-40), "USD"),
)),
];
txn
}),
];
let result = execute_query("SELECT account, sum(number) GROUP BY 1", &directives);
assert_eq!(result.rows.len(), 3);
let mut accounts: Vec<String> = result
.rows
.iter()
.map(|r| match &r[0] {
Value::String(s) => s.clone(),
other => panic!("expected account string, got {other:?}"),
})
.collect();
accounts.sort();
assert_eq!(
accounts,
vec!["Assets:Cash", "Expenses:Auto", "Expenses:Food"]
);
let err = execute_query_err("SELECT account, sum(number) GROUP BY 5", &directives);
assert!(err.to_string().contains("out of range"), "got: {err}");
}
#[test]
fn test_entry_meta_from_entries_table() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Transaction({
let mut txn = Transaction::new(date(2024, 3, 15), "Paycheck");
txn.meta.insert(
"source".to_string(),
rustledger_core::MetaValue::String("employer".to_string()),
);
txn.postings = vec![
rustledger_core::Spanned::synthesized(Posting::new(
"Assets:Bank",
Amount::new(dec!(1000), "USD"),
)),
rustledger_core::Spanned::synthesized(Posting::auto("Income:Salary")),
];
txn
}),
];
let result = execute_query(
"SELECT type, entry_meta('source') FROM #entries WHERE type = 'transaction'",
&directives,
);
assert_eq!(result.rows.len(), 1);
assert_eq!(result.rows[0][1], Value::String("employer".to_string()));
}
#[test]
fn test_convert_sum_with_literal_currency_on_empty_where() {
let result = execute_query(
"SELECT convert(sum(position), 'USD') WHERE account ~ '^Income'",
&[],
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(a) => {
assert_eq!(a.number, dec!(0));
assert_eq!(a.currency.as_ref(), "USD");
}
other => panic!("expected Amount(0 USD), got {other:?}"),
}
}
#[test]
fn test_value_sum_with_literal_date_on_empty_where() {
let result = execute_query(
"SELECT value(sum(position), 2020-06-01) WHERE account ~ '^Nothing'",
&[],
);
assert_eq!(result.len(), 1);
assert!(
matches!(
&result.rows[0][0],
Value::Null | Value::Amount(_) | Value::Inventory(_)
),
"expected Null or empty Amount/Inventory, got {:?}",
result.rows[0][0]
);
}
#[test]
fn test_convert_with_null_second_arg_has_helpful_error_message() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Lunch")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-10), "USD"),
)),
),
];
let query = parse("SELECT convert(position, meta('nonexistent_key'))").expect("should parse");
let mut executor = Executor::new(&directives);
let err = executor
.execute(&query)
.expect_err("CONVERT with NULL second arg should error");
let msg = format!("{err}");
assert!(
msg.contains("NULL") && msg.contains("currency string"),
"error should explicitly mention NULL + what was expected, got: {msg}"
);
}
#[test]
fn test_query_with_order_by_above_parallel_threshold() {
let mut directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
];
for i in 0..550 {
let day = u32::try_from((i % 28) + 1).unwrap();
let month = u32::try_from((i % 12) + 1).unwrap();
directives.push(Directive::Transaction(
Transaction::new(date(2024, month, day), "grocery shopping")
.with_payee("Grocery Store")
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(10), "USD"),
))
.with_synthesized_posting(Posting::new(
"Assets:Bank",
Amount::new(dec!(-10), "USD"),
)),
));
}
let result = execute_query(
r#"SELECT date, payee, narration, account, number, currency
WHERE (payee ~ "gro" OR narration ~ "gro" OR account ~ "gro")
ORDER BY date DESC"#,
&directives,
);
assert_eq!(result.rows.len(), 1100, "expected 1100 matching rows");
let first_date = match &result.rows[0][0] {
Value::Date(d) => *d,
v => panic!("first row[0] not a Date: {v:?}"),
};
let last_date = match &result.rows[result.rows.len() - 1][0] {
Value::Date(d) => *d,
v => panic!("last row[0] not a Date: {v:?}"),
};
assert!(
first_date >= last_date,
"ORDER BY date DESC: first={first_date}, last={last_date}"
);
}
fn make_convert_string_test_directives() -> Vec<Directive> {
vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Other")),
Directive::Price(Price::new(
date(2024, 1, 10),
"USD",
Amount::new(dec!(0.85), "EUR"),
)),
Directive::Transaction(
Transaction::new(date(2024, 2, 1), "Trigger")
.with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(1), "USD")))
.with_synthesized_posting(Posting::new(
"Income:Other",
Amount::new(dec!(-1), "USD"),
)),
),
]
}
#[test]
fn test_convert_string_input_1179_reporter_example() {
let directives = make_convert_string_test_directives();
let result = execute_query(
"SELECT CONVERT('100 USD', 'EUR') as conversion \
WHERE account = 'Assets:Bank'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "EUR");
assert_eq!(amt.number, dec!(85.00)); }
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_string_input_same_currency_no_op() {
let directives = make_convert_string_test_directives();
let result = execute_query(
"SELECT CONVERT('250.50 USD', 'USD') WHERE account = 'Assets:Bank'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "USD");
assert_eq!(amt.number, dec!(250.50));
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_string_input_no_rate_returns_original() {
let directives = make_convert_string_test_directives();
let result = execute_query(
"SELECT CONVERT('100 USD', 'GBP') WHERE account = 'Assets:Bank'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "USD");
assert_eq!(amt.number, dec!(100));
}
other => panic!("Expected Amount with original currency, got {other:?}"),
}
}
#[test]
fn test_convert_string_input_negative_amount() {
let directives = make_convert_string_test_directives();
let result = execute_query(
"SELECT CONVERT('-100 USD', 'EUR') WHERE account = 'Assets:Bank'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "EUR");
assert_eq!(amt.number, dec!(-85.00));
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_string_input_with_explicit_date() {
let directives = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
Directive::Open(Open::new(date(2024, 1, 1), "Income:Other")),
Directive::Price(Price::new(
date(2024, 1, 1),
"USD",
Amount::new(dec!(0.80), "EUR"),
)),
Directive::Price(Price::new(
date(2024, 6, 1),
"USD",
Amount::new(dec!(0.95), "EUR"),
)),
Directive::Transaction(
Transaction::new(date(2024, 3, 15), "Trigger")
.with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(1), "USD")))
.with_synthesized_posting(Posting::new(
"Income:Other",
Amount::new(dec!(-1), "USD"),
)),
),
];
let result = execute_query(
"SELECT CONVERT('100 USD', 'EUR', 2024-03-15) WHERE account = 'Assets:Bank'",
&directives,
);
assert_eq!(result.len(), 1);
match &result.rows[0][0] {
Value::Amount(amt) => {
assert_eq!(amt.currency.as_ref(), "EUR");
assert_eq!(amt.number, dec!(80.00));
}
other => panic!("Expected Amount, got {other:?}"),
}
}
#[test]
fn test_convert_string_input_rejects_garbage() {
let directives = make_convert_string_test_directives();
for bad in [
"garbage", "USD 100", "1,000 USD", "1e2 USD", "100 usd", ] {
let query = format!("SELECT CONVERT('{bad}', 'EUR') WHERE account = 'Assets:Bank'");
let err = execute_query_err(&query, &directives);
let msg = err.to_string();
assert!(
msg.contains("CONVERT"),
"input {bad:?} must surface a CONVERT-prefixed error, got: {msg}"
);
assert!(
msg.contains(bad),
"input {bad:?} must be echoed in the error, got: {msg}"
);
}
}
#[test]
fn test_null_comparison_excludes_rows() {
let dirs = vec![
Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
Directive::Transaction(
Transaction::new(date(2024, 1, 2), "with payee")
.with_payee("Alpha")
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-5), "USD")))
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(5), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 3), "no payee")
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-7), "USD")))
.with_synthesized_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(7), "USD"),
)),
),
];
let count = |q: &str| -> i64 {
let r = execute_query(q, &dirs);
match r.rows.first().and_then(|row| row.first()) {
Some(Value::Integer(n)) => *n,
other => panic!("expected an integer count, got {other:?}"),
}
};
assert_eq!(count(r#"SELECT count(*) WHERE payee != "Alpha""#), 0);
assert_eq!(count(r#"SELECT count(*) WHERE payee = "Alpha""#), 2);
assert_eq!(count(r#"SELECT count(*) WHERE payee > "A""#), 2);
assert_eq!(count(r#"SELECT count(*) WHERE payee < "z""#), 2);
}
#[test]
fn test_safediv_accepts_mixed_int_and_decimal() {
let directives = make_test_directives();
for q in ["safediv(10.0, 4)", "safediv(10, 4.0)"] {
let result = execute_query(&format!("SELECT {q}"), &directives);
assert_eq!(result.rows[0][0], Value::Number(dec!(2.5)), "for {q}");
}
let result = execute_query("SELECT safediv(10.0, 0)", &directives);
assert_eq!(result.rows[0][0], Value::Number(dec!(0)));
let result = execute_query("SELECT safediv(max(number), 0)", &directives);
assert_eq!(result.rows[0][0], Value::Number(dec!(0)));
}
#[test]
fn test_getprice_two_arg_returns_latest_price() {
use rustledger_core::Price;
let directives = vec![
Directive::Open(Open::new(date(2020, 1, 1), "Assets:Stock")),
Directive::Open(Open::new(date(2020, 1, 1), "Equity:O")),
Directive::Price(Price {
date: date(2020, 1, 15),
currency: "AAPL".into(),
amount: Amount::new(dec!(150.00), "USD"),
meta: Default::default(),
}),
Directive::Price(Price {
date: date(2020, 4, 1),
currency: "AAPL".into(),
amount: Amount::new(dec!(160.00), "USD"),
meta: Default::default(),
}),
Directive::Transaction(
Transaction::new(date(2020, 2, 1), "buy")
.with_synthesized_posting(Posting::new(
"Assets:Stock",
Amount::new(dec!(10), "AAPL"),
))
.with_synthesized_posting(Posting::new(
"Equity:O",
Amount::new(dec!(-1500), "USD"),
)),
),
];
let result = execute_query(r#"SELECT getprice("AAPL", "USD")"#, &directives);
assert!(!result.rows.is_empty(), "query returned no rows");
for row in &result.rows {
assert_eq!(
row[0],
Value::Number(dec!(160.00)),
"expected the latest price 160.00, got {:?}",
row[0]
);
}
}
#[test]
fn test_date_add_large_offset_errors_gracefully() {
let directives = make_test_directives();
for q in [
"SELECT date_add(date, 99999999999)",
"SELECT date_add(date, -99999999999)",
] {
let err = execute_query_err(q, &directives);
assert!(
err.to_string().contains("out of range"),
"{q}: expected an out-of-range error, got {err:?}"
);
}
let result = execute_query("SELECT date_add(date, 5)", &directives);
assert!(matches!(result.rows[0][0], Value::Date(_)));
}
#[test]
fn test_round_negative_precision() {
let directives = make_test_directives();
let cases = [
("round(1234.56, -2)", dec!(1200)),
("round(123.45, -1)", dec!(120)),
("round(1234.56, 0)", dec!(1235)),
("round(2.5, 0)", dec!(2)), ("round(1234.5, -100)", dec!(0)), ];
for (q, expected) in cases {
let result = execute_query(&format!("SELECT {q}"), &directives);
assert_eq!(result.rows[0][0], Value::Number(expected), "for {q}");
}
let result = execute_query("SELECT round(1234, -2)", &directives);
assert_eq!(result.rows[0][0], Value::Integer(1200));
let result = execute_query("SELECT round(1234, 2)", &directives);
assert_eq!(result.rows[0][0], Value::Integer(1234));
}
#[test]
fn test_quarter_returns_year_quarter_string() {
use rustledger_core::{Open, Transaction};
let directives = vec![
Directive::Open(Open::new(date(2020, 1, 1), "Assets:Cash")),
Directive::Open(Open::new(date(2020, 1, 1), "Equity:O")),
Directive::Transaction(
Transaction::new(date(2020, 11, 5), "x")
.with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(1), "USD")))
.with_synthesized_posting(Posting::new("Equity:O", Amount::new(dec!(-1), "USD"))),
),
];
let result = execute_query("SELECT quarter(date)", &directives);
assert_eq!(result.rows[0][0], Value::String("2020-Q4".to_string()));
let result = execute_query("SELECT quarter(max(date))", &directives);
assert_eq!(result.rows[0][0], Value::String("2020-Q4".to_string()));
}
#[test]
fn test_boolean_and_null_literals_in_expressions() {
let directives = make_test_directives();
let total = match &execute_query("SELECT count(*)", &directives).rows[0][0] {
Value::Integer(n) => *n,
v => panic!("expected integer count, got {v:?}"),
};
assert_eq!(
execute_query("SELECT count(*) WHERE TRUE", &directives).rows[0][0],
Value::Integer(total),
"WHERE TRUE should match all rows"
);
assert_eq!(
execute_query("SELECT count(*) WHERE FALSE", &directives).rows[0][0],
Value::Integer(0),
"WHERE FALSE should match no rows"
);
assert!(matches!(
execute_query("SELECT NULL", &directives).rows[0][0],
Value::Null
));
}
#[test]
fn test_maxwidth_textwrap_shorten() {
let directives = make_test_directives();
let cases = [
(r#"maxwidth("hello world foo", 12)"#, "hello [...]"),
(r#"maxwidth("hello world foo", 20)"#, "hello world foo"),
(r#"maxwidth("abcdefghij", 5)"#, "[...]"),
];
for (q, expected) in cases {
let result = execute_query(&format!("SELECT {q}"), &directives);
assert_eq!(
result.rows[0][0],
Value::String(expected.to_string()),
"for {q}"
);
}
let _ = execute_query_err(r#"SELECT maxwidth("hello", 3)"#, &directives);
}
#[test]
fn test_parse_date_one_arg() {
let directives = make_test_directives();
let cases = [
(r#"parse_date("2021-07-01")"#, (2021, 7, 1)),
(r#"parse_date("2021/07/01")"#, (2021, 7, 1)),
(r#"parse_date("01-07-2021")"#, (2021, 1, 7)),
(r#"parse_date("July 1 2021")"#, (2021, 7, 1)),
];
for (q, (y, m, d)) in cases {
let result = execute_query(&format!("SELECT {q}"), &directives);
assert_eq!(result.rows[0][0], Value::Date(date(y, m, d)), "for {q}");
}
}
#[test]
fn test_pivot_by_first_column_is_the_row_key() {
let directives = make_test_directives();
let result = execute_query(
"SELECT account, currency, SUM(number) GROUP BY 1, 2 PIVOT BY account, currency",
&directives,
);
assert_eq!(result.columns[0], "account");
assert!(
result.columns.iter().any(|c| c == "USD"),
"currency value should be a column header; got {:?}",
result.columns
);
for row in &result.rows {
assert_ne!(
row[0],
Value::String("USD".to_string()),
"row key must be an account, not the pivoted currency header"
);
}
}