mod functions;
mod types;
use types::AccountInfo;
pub use types::{
Interval, IntervalUnit, PostingContext, QueryResult, Row, SourceLocation, Table, Value,
WindowContext,
};
use std::sync::RwLock;
use rustc_hash::FxHashMap;
use regex::{Regex, RegexBuilder};
use rust_decimal::Decimal;
use rustledger_core::{Amount, Directive, InternedStr, Inventory, Metadata, Position};
#[cfg(test)]
use rustledger_core::{MetaValue, NaiveDate, Transaction};
use rustledger_loader::SourceMap;
use rustledger_parser::Spanned;
use crate::ast::{Expr, FromClause, FunctionCall, Query, Target};
use crate::error::QueryError;
pub struct Executor<'a> {
directives: &'a [Directive],
spanned_directives: Option<&'a [Spanned<Directive>]>,
balances: FxHashMap<InternedStr, Inventory>,
price_db: crate::price::PriceDatabase,
target_currency: Option<String>,
query_date: rustledger_core::NaiveDate,
regex_cache: RwLock<FxHashMap<String, Option<Regex>>>,
account_info: FxHashMap<String, AccountInfo>,
source_locations: Option<Vec<SourceLocation>>,
tables: FxHashMap<String, Table>,
}
mod aggregation;
mod evaluation;
mod execution;
mod operators;
mod sort;
mod window;
pub const WILDCARD_COLUMNS: &[&str] =
&["date", "flag", "payee", "narration", "account", "position"];
impl<'a> Executor<'a> {
pub fn new(directives: &'a [Directive]) -> Self {
let price_db = crate::price::PriceDatabase::from_directives(directives);
let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
for directive in directives {
match directive {
Directive::Open(open) => {
let account = open.account.to_string();
let info = account_info.entry(account).or_insert_with(|| AccountInfo {
open_date: None,
close_date: None,
open_meta: Metadata::default(),
});
info.open_date = Some(open.date);
info.open_meta.clone_from(&open.meta);
}
Directive::Close(close) => {
let account = close.account.to_string();
let info = account_info.entry(account).or_insert_with(|| AccountInfo {
open_date: None,
close_date: None,
open_meta: Metadata::default(),
});
info.close_date = Some(close.date);
}
_ => {}
}
}
Self {
directives,
spanned_directives: None,
balances: FxHashMap::default(),
price_db,
target_currency: None,
query_date: jiff::Zoned::now().date(),
regex_cache: RwLock::new(FxHashMap::default()),
account_info,
source_locations: None,
tables: FxHashMap::default(),
}
}
pub fn new_with_sources(
spanned_directives: &'a [Spanned<Directive>],
source_map: &SourceMap,
) -> Self {
let mut price_db = crate::price::PriceDatabase::new();
for spanned in spanned_directives {
match &spanned.value {
Directive::Price(p) => {
price_db.add_price(p);
}
Directive::Transaction(txn) => {
price_db.add_implicit_prices_from_transaction(txn);
}
_ => {}
}
}
price_db.sort_prices();
let source_locations: Vec<SourceLocation> = spanned_directives
.iter()
.map(|spanned| {
let file = source_map.get(spanned.file_id as usize);
let (line, _col) = file.map_or((0, 0), |f| f.line_col(spanned.span.start));
SourceLocation {
filename: file.map_or_else(String::new, |f| f.path.display().to_string()),
lineno: line,
}
})
.collect();
let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
for spanned in spanned_directives {
match &spanned.value {
Directive::Open(open) => {
let account = open.account.to_string();
let info = account_info.entry(account).or_insert_with(|| AccountInfo {
open_date: None,
close_date: None,
open_meta: Metadata::default(),
});
info.open_date = Some(open.date);
info.open_meta.clone_from(&open.meta);
}
Directive::Close(close) => {
let account = close.account.to_string();
let info = account_info.entry(account).or_insert_with(|| AccountInfo {
open_date: None,
close_date: None,
open_meta: Metadata::default(),
});
info.close_date = Some(close.date);
}
_ => {}
}
}
Self {
directives: &[], spanned_directives: Some(spanned_directives),
balances: FxHashMap::default(),
price_db,
target_currency: None,
query_date: jiff::Zoned::now().date(),
regex_cache: RwLock::new(FxHashMap::default()),
account_info,
source_locations: Some(source_locations),
tables: FxHashMap::default(),
}
}
fn get_source_location(&self, directive_index: usize) -> Option<&SourceLocation> {
self.source_locations
.as_ref()
.and_then(|locs| locs.get(directive_index))
}
fn get_or_compile_regex(&self, pattern: &str) -> Option<Regex> {
{
let cache = match self.regex_cache.read() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(cached) = cache.get(pattern) {
return cached.clone();
}
}
let compiled = RegexBuilder::new(pattern)
.case_insensitive(true)
.build()
.ok();
let mut cache = match self.regex_cache.write() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(cached) = cache.get(pattern) {
return cached.clone();
}
cache.insert(pattern.to_string(), compiled.clone());
compiled
}
fn require_regex(&self, pattern: &str) -> Result<Regex, QueryError> {
self.get_or_compile_regex(pattern)
.ok_or_else(|| QueryError::Type(format!("invalid regex: {pattern}")))
}
pub fn set_target_currency(&mut self, currency: impl Into<String>) {
self.target_currency = Some(currency.into());
}
pub fn execute(&mut self, query: &Query) -> Result<QueryResult, QueryError> {
match query {
Query::Select(select) => self.execute_select(select),
Query::Journal(journal) => self.execute_journal(journal),
Query::Balances(balances) => self.execute_balances(balances),
Query::Print(print) => self.execute_print(print),
Query::CreateTable(create) => self.execute_create_table(create),
Query::Insert(insert) => self.execute_insert(insert),
}
}
fn build_balances_with_filter(&mut self, from: Option<&FromClause>) -> Result<(), QueryError> {
for directive in self.directives {
if let Directive::Transaction(txn) = directive {
if let Some(from_clause) = from
&& let Some(filter) = &from_clause.filter
&& !self.evaluate_from_filter(filter, txn)?
{
continue;
}
for posting in &txn.postings {
if let Some(units) = posting.amount() {
let balance = self.balances.entry(posting.account.clone()).or_default();
let pos = if let Some(cost_spec) = &posting.cost {
if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
Position::with_cost(units.clone(), cost)
} else {
Position::simple(units.clone())
}
} else {
Position::simple(units.clone())
};
balance.add(pos);
}
}
}
}
Ok(())
}
fn collect_postings(
&self,
from: Option<&FromClause>,
where_clause: Option<&Expr>,
) -> Result<Vec<PostingContext<'a>>, QueryError> {
let mut postings = Vec::new();
let mut running_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
let directive_iter: Vec<(usize, &Directive)> =
if let Some(spanned) = self.spanned_directives {
spanned
.iter()
.enumerate()
.map(|(i, s)| (i, &s.value))
.collect()
} else {
self.directives.iter().enumerate().collect()
};
for (directive_index, directive) in directive_iter {
if let Directive::Transaction(txn) = directive {
if let Some(from) = from {
if let Some(open_date) = from.open_on
&& txn.date < open_date
{
for posting in &txn.postings {
if let Some(units) = posting.amount() {
let balance =
running_balances.entry(posting.account.clone()).or_default();
balance.add(Position::simple(units.clone()));
}
}
continue;
}
if let Some(close_date) = from.close_on
&& txn.date > close_date
{
continue;
}
if let Some(filter) = &from.filter
&& !self.evaluate_from_filter(filter, txn)?
{
continue;
}
}
for (i, posting) in txn.postings.iter().enumerate() {
if let Some(units) = posting.amount() {
let balance = running_balances.entry(posting.account.clone()).or_default();
balance.add(Position::simple(units.clone()));
}
let ctx = PostingContext {
transaction: txn,
posting_index: i,
balance: running_balances.get(&posting.account).cloned(),
directive_index: Some(directive_index),
};
if let Some(where_expr) = where_clause {
if self.evaluate_predicate(where_expr, &ctx)? {
postings.push(ctx);
}
} else {
postings.push(ctx);
}
}
}
}
Ok(postings)
}
fn evaluate_function(
&self,
func: &FunctionCall,
ctx: &PostingContext,
) -> Result<Value, QueryError> {
let name = func.name.to_uppercase();
match name.as_str() {
"YEAR" | "MONTH" | "DAY" | "WEEKDAY" | "QUARTER" | "YMONTH" | "TODAY" => {
self.eval_date_function(&name, func, ctx)
}
"DATE" | "DATE_DIFF" | "DATE_ADD" | "DATE_TRUNC" | "DATE_PART" | "PARSE_DATE"
| "DATE_BIN" | "INTERVAL" => self.eval_extended_date_function(&name, func, ctx),
"LENGTH" | "UPPER" | "LOWER" | "SUBSTR" | "SUBSTRING" | "TRIM" | "STARTSWITH"
| "ENDSWITH" | "GREP" | "GREPN" | "SUBST" | "SPLITCOMP" | "JOINSTR" | "MAXWIDTH" => {
self.eval_string_function(&name, func, ctx)
}
"PARENT" | "LEAF" | "ROOT" | "ACCOUNT_DEPTH" | "ACCOUNT_SORTKEY" => {
self.eval_account_function(&name, func, ctx)
}
"OPEN_DATE" | "CLOSE_DATE" | "OPEN_META" => {
self.eval_account_meta_function(&name, func, ctx)
}
"ABS" | "NEG" | "ROUND" | "SAFEDIV" => self.eval_math_function(&name, func, ctx),
"NUMBER" | "CURRENCY" | "GETITEM" | "GET" | "UNITS" | "COST" | "WEIGHT" | "VALUE" => {
self.eval_position_function(&name, func, ctx)
}
"EMPTY" | "FILTER_CURRENCY" | "POSSIGN" => {
self.eval_inventory_function(&name, func, ctx)
}
"GETPRICE" => self.eval_getprice(func, ctx),
"COALESCE" => self.eval_coalesce(func, ctx),
"ONLY" => self.eval_only(func, ctx),
"META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
self.eval_meta_function(&name, func, ctx)
}
"CONVERT" => self.eval_convert(func, ctx),
"INT" => self.eval_int(func, ctx),
"DECIMAL" => self.eval_decimal(func, ctx),
"STR" => self.eval_str(func, ctx),
"BOOL" => self.eval_bool(func, ctx),
"SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
_ => Err(QueryError::UnknownFunction(func.name.clone())),
}
}
fn evaluate_function_on_values(&self, name: &str, args: &[Value]) -> Result<Value, QueryError> {
let name_upper = name.to_uppercase();
match name_upper.as_str() {
"TODAY" => Ok(Value::Date(jiff::Zoned::now().date())),
"YEAR" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Date(d) => Ok(Value::Integer(d.year().into())),
_ => Err(QueryError::Type("YEAR expects a date".to_string())),
}
}
"MONTH" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Date(d) => Ok(Value::Integer(d.month().into())),
_ => Err(QueryError::Type("MONTH expects a date".to_string())),
}
}
"DAY" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Date(d) => Ok(Value::Integer(d.day().into())),
_ => Err(QueryError::Type("DAY expects a date".to_string())),
}
}
"LENGTH" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(s) => Ok(Value::Integer(s.len() as i64)),
_ => Err(QueryError::Type("LENGTH expects a string".to_string())),
}
}
"UPPER" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(s) => Ok(Value::String(s.to_uppercase())),
_ => Err(QueryError::Type("UPPER expects a string".to_string())),
}
}
"LOWER" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(s) => Ok(Value::String(s.to_lowercase())),
_ => Err(QueryError::Type("LOWER expects a string".to_string())),
}
}
"TRIM" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(s) => Ok(Value::String(s.trim().to_string())),
_ => Err(QueryError::Type("TRIM expects a string".to_string())),
}
}
"ABS" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Number(n) => Ok(Value::Number(n.abs())),
Value::Integer(i) => Ok(Value::Integer(i.abs())),
_ => Err(QueryError::Type("ABS expects a number".to_string())),
}
}
"ROUND" => {
if args.is_empty() || args.len() > 2 {
return Err(QueryError::InvalidArguments(
"ROUND".to_string(),
"expected 1 or 2 arguments".to_string(),
));
}
match &args[0] {
Value::Number(n) => {
let scale = if args.len() == 2 {
match &args[1] {
Value::Integer(i) => *i as u32,
_ => 0,
}
} else {
0
};
Ok(Value::Number(n.round_dp(scale)))
}
Value::Integer(i) => Ok(Value::Integer(*i)),
_ => Err(QueryError::Type("ROUND expects a number".to_string())),
}
}
"COALESCE" => {
for arg in args {
if !matches!(arg, Value::Null) {
return Ok(arg.clone());
}
}
Ok(Value::Null)
}
"NUMBER" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Amount(a) => Ok(Value::Number(a.number)),
Value::Position(p) => Ok(Value::Number(p.units.number)),
Value::Number(n) => Ok(Value::Number(*n)),
Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
Value::Inventory(inv) => {
let positions = inv.positions();
if positions.is_empty() {
return Ok(Value::Number(Decimal::ZERO));
}
let first_currency = &positions[0].units.currency;
let all_same_currency = positions
.iter()
.all(|p| &p.units.currency == first_currency);
if all_same_currency {
let total: Decimal = positions.iter().map(|p| p.units.number).sum();
Ok(Value::Number(total))
} else {
Ok(Value::Null)
}
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"NUMBER expects an amount, position, or inventory".to_string(),
)),
}
}
"CURRENCY" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
Value::Inventory(inv) => {
if let Some(pos) = inv.positions().first() {
Ok(Value::String(pos.units.currency.to_string()))
} else {
Ok(Value::Null)
}
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"CURRENCY expects an amount or position".to_string(),
)),
}
}
"UNITS" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Position(p) => Ok(Value::Amount(p.units.clone())),
Value::Amount(a) => Ok(Value::Amount(a.clone())),
Value::Inventory(inv) => {
let mut units_inv = Inventory::new();
for pos in inv.positions() {
units_inv.add(Position::simple(pos.units.clone()));
}
Ok(Value::Inventory(Box::new(units_inv)))
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"UNITS expects a position or inventory".to_string(),
)),
}
}
"COST" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Position(p) => {
if let Some(cost) = &p.cost {
let total = p.units.number * cost.number;
Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
} else {
Ok(Value::Amount(p.units.clone()))
}
}
Value::Amount(a) => Ok(Value::Amount(a.clone())),
Value::Inventory(inv) => {
let mut total = Decimal::ZERO;
let mut currency: Option<InternedStr> = None;
for pos in inv.positions() {
if let Some(cost) = &pos.cost {
total += pos.units.number * cost.number;
if currency.is_none() {
currency = Some(cost.currency.clone());
}
} else {
total += pos.units.number;
if currency.is_none() {
currency = Some(pos.units.currency.clone());
}
}
}
if let Some(curr) = currency {
Ok(Value::Amount(Amount::new(total, curr)))
} else {
Ok(Value::Null)
}
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"COST expects a position or inventory".to_string(),
)),
}
}
"VALUE" => {
if args.is_empty() || args.len() > 2 {
return Err(QueryError::InvalidArguments(
"VALUE".to_string(),
"expected 1-2 arguments".to_string(),
));
}
let explicit_currency = if args.len() == 2 {
match &args[1] {
Value::String(s) => Some(s.as_str()),
_ => None,
}
} else {
None
};
self.convert_to_market_value(&args[0], explicit_currency)
}
"SAFEDIV" => {
Self::require_args_count(&name_upper, args, 2)?;
let (dividend, divisor) = (&args[0], &args[1]);
match (dividend, divisor) {
(Value::Number(a), Value::Number(b)) => {
if b.is_zero() {
Ok(Value::Null)
} else {
Ok(Value::Number(a / b))
}
}
(Value::Integer(a), Value::Integer(b)) => {
if *b == 0 {
Ok(Value::Null)
} else {
Ok(Value::Number(Decimal::from(*a) / Decimal::from(*b)))
}
}
(Value::Number(a), Value::Integer(b)) => {
if *b == 0 {
Ok(Value::Null)
} else {
Ok(Value::Number(a / Decimal::from(*b)))
}
}
(Value::Integer(a), Value::Number(b)) => {
if b.is_zero() {
Ok(Value::Null)
} else {
Ok(Value::Number(Decimal::from(*a) / b))
}
}
(Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
_ => Err(QueryError::Type(
"SAFEDIV expects numeric arguments".to_string(),
)),
}
}
"NEG" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Number(n) => Ok(Value::Number(-n)),
Value::Integer(i) => Ok(Value::Integer(-i)),
Value::Amount(a) => {
Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
}
_ => Err(QueryError::Type(
"NEG expects a number or amount".to_string(),
)),
}
}
"ACCOUNT_SORTKEY" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(s) => {
let type_index = Self::account_type_index(s);
Ok(Value::String(format!("{type_index}-{s}")))
}
_ => Err(QueryError::Type(
"ACCOUNT_SORTKEY expects an account string".to_string(),
)),
}
}
"PARENT" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(s) => {
if let Some(idx) = s.rfind(':') {
Ok(Value::String(s[..idx].to_string()))
} else {
Ok(Value::Null)
}
}
_ => Err(QueryError::Type(
"PARENT expects an account string".to_string(),
)),
}
}
"LEAF" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(s) => {
if let Some(idx) = s.rfind(':') {
Ok(Value::String(s[idx + 1..].to_string()))
} else {
Ok(Value::String(s.clone()))
}
}
_ => Err(QueryError::Type(
"LEAF expects an account string".to_string(),
)),
}
}
"ROOT" => {
if args.is_empty() || args.len() > 2 {
return Err(QueryError::InvalidArguments(
"ROOT".to_string(),
"expected 1 or 2 arguments".to_string(),
));
}
let n = if args.len() == 2 {
match &args[1] {
Value::Integer(i) => *i as usize,
_ => 1,
}
} else {
1
};
match &args[0] {
Value::String(s) => {
let parts: Vec<&str> = s.split(':').collect();
if n >= parts.len() {
Ok(Value::String(s.clone()))
} else {
Ok(Value::String(parts[..n].join(":")))
}
}
_ => Err(QueryError::Type(
"ROOT expects an account string".to_string(),
)),
}
}
"ONLY" => {
Self::require_args_count(&name_upper, args, 2)?;
let currency = match &args[0] {
Value::String(s) => s.clone(),
_ => {
return Err(QueryError::Type(
"ONLY: first argument must be a currency string".to_string(),
));
}
};
match &args[1] {
Value::Inventory(inv) => {
let total = inv.units(¤cy);
if total.is_zero() {
Ok(Value::Null)
} else {
Ok(Value::Amount(Amount::new(total, ¤cy)))
}
}
Value::Position(p) => {
if p.units.currency.as_str() == currency {
Ok(Value::Amount(p.units.clone()))
} else {
Ok(Value::Null)
}
}
Value::Amount(a) => {
if a.currency.as_str() == currency {
Ok(Value::Amount(a.clone()))
} else {
Ok(Value::Null)
}
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"ONLY: second argument must be an inventory, position, or amount"
.to_string(),
)),
}
}
"GETPRICE" => {
if args.len() < 2 || args.len() > 3 {
return Err(QueryError::InvalidArguments(
"GETPRICE".to_string(),
"expected 2 or 3 arguments".to_string(),
));
}
let base = match &args[0] {
Value::String(s) => s.clone(),
Value::Null => return Ok(Value::Null),
_ => {
return Err(QueryError::Type(
"GETPRICE: first argument must be a currency string".to_string(),
));
}
};
let quote = match &args[1] {
Value::String(s) => s.clone(),
Value::Null => return Ok(Value::Null),
_ => {
return Err(QueryError::Type(
"GETPRICE: second argument must be a currency string".to_string(),
));
}
};
let date = if args.len() == 3 {
match &args[2] {
Value::Date(d) => *d,
Value::Null => self.query_date,
_ => self.query_date,
}
} else {
self.query_date
};
match self.price_db.get_price(&base, "e, date) {
Some(price) => Ok(Value::Number(price)),
None => Ok(Value::Null),
}
}
"EMPTY" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
Value::Null => Ok(Value::Boolean(true)),
_ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
}
}
"FILTER_CURRENCY" => {
Self::require_args_count(&name_upper, args, 2)?;
let currency = match &args[1] {
Value::String(s) => s.clone(),
_ => {
return Err(QueryError::Type(
"FILTER_CURRENCY expects (inventory, string)".to_string(),
));
}
};
match &args[0] {
Value::Inventory(inv) => {
let filtered: Vec<Position> = inv
.positions()
.iter()
.filter(|p| p.units.currency.as_str() == currency)
.cloned()
.collect();
let mut new_inv = Inventory::new();
for pos in filtered {
new_inv.add(pos);
}
Ok(Value::Inventory(Box::new(new_inv)))
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"FILTER_CURRENCY expects (inventory, string)".to_string(),
)),
}
}
"POSSIGN" => {
Self::require_args_count(&name_upper, args, 2)?;
let account_str = match &args[1] {
Value::String(s) => s.clone(),
_ => {
return Err(QueryError::Type(
"POSSIGN expects (amount, account_string)".to_string(),
));
}
};
let first_component = account_str.split(':').next().unwrap_or("");
let is_credit_normal =
matches!(first_component, "Liabilities" | "Equity" | "Income");
match &args[0] {
Value::Amount(a) => {
let mut amt = a.clone();
if is_credit_normal {
amt.number = -amt.number;
}
Ok(Value::Amount(amt))
}
Value::Number(n) => {
let adjusted = if is_credit_normal { -n } else { *n };
Ok(Value::Number(adjusted))
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"POSSIGN expects (amount, account_string)".to_string(),
)),
}
}
"CONVERT" => {
if args.len() < 2 || args.len() > 3 {
return Err(QueryError::InvalidArguments(
"CONVERT".to_string(),
"expected 2 or 3 arguments: (value, currency[, date])".to_string(),
));
}
let target_currency = match &args[1] {
Value::String(s) => s.clone(),
_ => {
return Err(QueryError::Type(
"CONVERT: second argument must be a currency string".to_string(),
));
}
};
let date: Option<rustledger_core::NaiveDate> = if args.len() == 3 {
match &args[2] {
Value::Date(d) => Some(*d),
Value::Null => None, _ => {
return Err(QueryError::Type(
"CONVERT: third argument must be a date".to_string(),
));
}
}
} else {
None
};
let convert_amount = |amt: &Amount| -> Option<Amount> {
if let Some(d) = date {
self.price_db.convert(amt, &target_currency, d)
} else {
self.price_db.convert_latest(amt, &target_currency)
}
};
match &args[0] {
Value::Position(p) => {
if p.units.currency == target_currency {
Ok(Value::Amount(p.units.clone()))
} else if let Some(converted) = convert_amount(&p.units) {
Ok(Value::Amount(converted))
} else {
Ok(Value::Amount(p.units.clone()))
}
}
Value::Amount(a) => {
if a.currency == target_currency {
Ok(Value::Amount(a.clone()))
} else if let Some(converted) = convert_amount(a) {
Ok(Value::Amount(converted))
} else {
Ok(Value::Amount(a.clone()))
}
}
Value::Inventory(inv) => {
let mut result = Inventory::default();
for pos in inv.positions() {
if pos.units.currency == target_currency {
result.add(Position::simple(pos.units.clone()));
} else if let Some(converted) = convert_amount(&pos.units) {
result.add(Position::simple(converted));
} else {
result.add(Position::simple(pos.units.clone()));
}
}
let positions = result.positions();
if positions.is_empty() {
Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
} else if positions.len() == 1
&& positions[0].units.currency == target_currency
{
Ok(Value::Amount(positions[0].units.clone()))
} else {
Ok(Value::Inventory(Box::new(result)))
}
}
Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
Value::Null => {
Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
}
_ => Err(QueryError::Type(
"CONVERT expects a position, amount, inventory, or number".to_string(),
)),
}
}
"STR" => {
Self::require_args_count(&name_upper, args, 1)?;
Self::value_to_str(&args[0])
}
"INT" => {
Self::require_args_count(&name_upper, args, 1)?;
Self::value_to_int(&args[0])
}
"DECIMAL" => {
Self::require_args_count(&name_upper, args, 1)?;
Self::value_to_decimal(&args[0])
}
"BOOL" => {
Self::require_args_count(&name_upper, args, 1)?;
Self::value_to_bool(&args[0])
}
"QUARTER" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Date(d) => Ok(Value::Integer(((d.month() - 1) / 3 + 1).into())),
_ => Err(QueryError::Type("QUARTER expects a date".to_string())),
}
}
"WEEKDAY" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Date(d) => Ok(Value::Integer(
(d.weekday().to_monday_zero_offset() as u32).into(),
)),
_ => Err(QueryError::Type("WEEKDAY expects a date".to_string())),
}
}
"YMONTH" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Date(d) => {
Ok(Value::String(format!("{:04}-{:02}", d.year(), d.month())))
}
_ => Err(QueryError::Type("YMONTH expects a date".to_string())),
}
}
"SUBSTR" | "SUBSTRING" => {
if args.len() < 2 || args.len() > 3 {
return Err(QueryError::InvalidArguments(
name_upper,
"expected 2 or 3 arguments".to_string(),
));
}
match (&args[0], &args[1], args.get(2)) {
(Value::String(s), Value::Integer(start), None) => {
let start = (*start).max(0) as usize;
let result: String = s.chars().skip(start).collect();
Ok(Value::String(result))
}
(Value::String(s), Value::Integer(start), Some(Value::Integer(len))) => {
let start = (*start).max(0) as usize;
let len = (*len).max(0) as usize;
let result: String = s.chars().skip(start).take(len).collect();
Ok(Value::String(result))
}
_ => Err(QueryError::Type(
"SUBSTR expects (string, int, [int])".to_string(),
)),
}
}
"STARTSWITH" => {
Self::require_args_count(&name_upper, args, 2)?;
match (&args[0], &args[1]) {
(Value::String(s), Value::String(prefix)) => {
Ok(Value::Boolean(s.starts_with(prefix.as_str())))
}
_ => Err(QueryError::Type(
"STARTSWITH expects two strings".to_string(),
)),
}
}
"ENDSWITH" => {
Self::require_args_count(&name_upper, args, 2)?;
match (&args[0], &args[1]) {
(Value::String(s), Value::String(suffix)) => {
Ok(Value::Boolean(s.ends_with(suffix.as_str())))
}
_ => Err(QueryError::Type("ENDSWITH expects two strings".to_string())),
}
}
"MAXWIDTH" => {
Self::require_args_count(&name_upper, args, 2)?;
match (&args[0], &args[1]) {
(Value::String(s), Value::Integer(max)) => {
let n = *max as usize;
if s.chars().count() <= n {
Ok(Value::String(s.clone()))
} else if n <= 3 {
Ok(Value::String(s.chars().take(n).collect()))
} else {
let truncated: String = s.chars().take(n - 3).collect();
Ok(Value::String(format!("{truncated}...")))
}
}
_ => Err(QueryError::Type(
"MAXWIDTH expects (string, integer)".to_string(),
)),
}
}
"ACCOUNT_DEPTH" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(s) => Ok(Value::Integer(s.matches(':').count() as i64 + 1)),
_ => Err(QueryError::Type(
"ACCOUNT_DEPTH expects an account string".to_string(),
)),
}
}
"GETITEM" | "GET" => {
Self::require_args_count(&name_upper, args, 2)?;
match (&args[0], &args[1]) {
(Value::Inventory(inv), Value::String(currency)) => {
let amount = inv.units(currency);
if amount.is_zero() {
Ok(Value::Null)
} else {
Ok(Value::Amount(Amount::new(amount, currency.as_str())))
}
}
(Value::Null, _) => Ok(Value::Null),
_ => Err(QueryError::Type(
"GETITEM expects (inventory, string)".to_string(),
)),
}
}
"WEIGHT" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::Position(p) => {
if let Some(cost) = &p.cost {
let total = p.units.number * cost.number;
Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
} else {
Ok(Value::Amount(p.units.clone()))
}
}
Value::Amount(a) => Ok(Value::Amount(a.clone())),
Value::Inventory(inv) => {
let mut result = Inventory::new();
for pos in inv.positions() {
if let Some(cost) = &pos.cost {
let total = pos.units.number * cost.number;
result.add(Position::simple(Amount::new(
total,
cost.currency.clone(),
)));
} else {
result.add(Position::simple(pos.units.clone()));
}
}
Ok(Value::Inventory(Box::new(result)))
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"WEIGHT expects a position, amount, or inventory".to_string(),
)),
}
}
"DATE_DIFF" => {
Self::require_args_count(&name_upper, args, 2)?;
match (&args[0], &args[1]) {
(Value::Date(d1), Value::Date(d2)) => Ok(Value::Integer(i64::from(
d1.since(*d2).unwrap_or_default().get_days(),
))),
_ => Err(QueryError::Type("DATE_DIFF expects two dates".to_string())),
}
}
"GREP" => {
Self::require_args_count(&name_upper, args, 2)?;
match (&args[0], &args[1]) {
(Value::String(pattern), Value::String(s)) => {
let re = regex::Regex::new(pattern).map_err(|e| {
QueryError::Type(format!("GREP: invalid regex '{pattern}': {e}"))
})?;
match re.find(s) {
Some(m) => Ok(Value::String(m.as_str().to_string())),
None => Ok(Value::Null),
}
}
(Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
_ => Err(QueryError::Type("GREP expects two strings".to_string())),
}
}
"GREPN" => {
Self::require_args_count(&name_upper, args, 3)?;
let n = match &args[2] {
Value::Integer(i) => (*i).max(0) as usize,
Value::Number(n) => {
use rust_decimal::prelude::ToPrimitive;
n.to_usize().unwrap_or(0)
}
_ => {
return Err(QueryError::Type(
"GREPN: third argument must be an integer".to_string(),
));
}
};
match (&args[0], &args[1]) {
(Value::String(pattern), Value::String(s)) => {
let re = regex::Regex::new(pattern).map_err(|e| {
QueryError::Type(format!("GREPN: invalid regex '{pattern}': {e}"))
})?;
match re.captures(s) {
Some(caps) => match caps.get(n) {
Some(m) => Ok(Value::String(m.as_str().to_string())),
None => Ok(Value::Null),
},
None => Ok(Value::Null),
}
}
(Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
_ => Err(QueryError::Type(
"GREPN expects (pattern, string, int)".to_string(),
)),
}
}
"SUBST" => {
Self::require_args_count(&name_upper, args, 3)?;
match (&args[0], &args[1], &args[2]) {
(Value::String(pattern), Value::String(replacement), Value::String(s)) => {
let re = regex::Regex::new(pattern).map_err(|e| {
QueryError::Type(format!("SUBST: invalid regex '{pattern}': {e}"))
})?;
Ok(Value::String(
re.replace_all(s, replacement.as_str()).to_string(),
))
}
_ => Err(QueryError::Type(
"SUBST expects (pattern, replacement, string)".to_string(),
)),
}
}
"SPLITCOMP" => {
Self::require_args_count(&name_upper, args, 3)?;
let n = match &args[2] {
Value::Integer(i) => (*i).max(0) as usize,
Value::Number(n) => {
use rust_decimal::prelude::ToPrimitive;
n.to_usize().unwrap_or(0)
}
_ => {
return Err(QueryError::Type(
"SPLITCOMP: third argument must be an integer".to_string(),
));
}
};
match (&args[0], &args[1]) {
(Value::String(s), Value::String(delim)) => {
let parts: Vec<&str> = s.split(delim.as_str()).collect();
match parts.get(n) {
Some(part) => Ok(Value::String((*part).to_string())),
None => Ok(Value::Null),
}
}
_ => Err(QueryError::Type(
"SPLITCOMP expects (string, delimiter, int)".to_string(),
)),
}
}
"JOINSTR" => {
let mut parts = Vec::new();
for v in args {
match v {
Value::String(s) => parts.push(s.clone()),
Value::StringSet(ss) => parts.extend(ss.iter().cloned()),
Value::Integer(i) => parts.push(i.to_string()),
Value::Number(n) => parts.push(n.to_string()),
Value::Null => {}
_ => {}
}
}
Ok(Value::String(parts.join(",")))
}
"OPEN_DATE" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(account) => Ok(self
.account_info
.get(account.as_str())
.and_then(|info| info.open_date)
.map_or(Value::Null, Value::Date)),
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"OPEN_DATE expects an account string".to_string(),
)),
}
}
"CLOSE_DATE" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(account) => Ok(self
.account_info
.get(account.as_str())
.and_then(|info| info.close_date)
.map_or(Value::Null, Value::Date)),
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"CLOSE_DATE expects an account string".to_string(),
)),
}
}
"OPEN_META" => {
Self::require_args_count(&name_upper, args, 2)?;
match (&args[0], &args[1]) {
(Value::String(account), Value::String(key)) => Ok(self
.account_info
.get(account.as_str())
.and_then(|info| info.open_meta.get(key))
.map_or(Value::Null, |mv| Self::meta_value_to_value(Some(mv)))),
(Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
_ => Err(QueryError::Type(
"OPEN_META expects (account_string, key_string)".to_string(),
)),
}
}
"META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
Self::require_args_count(&name_upper, args, 1)?;
match &args[0] {
Value::String(_) | Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(format!(
"{name_upper}: argument must be a string key"
))),
}
}
"SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
_ => Err(QueryError::UnknownFunction(name.to_string())),
}
}
fn metadata_to_value(meta: &rustledger_core::Metadata) -> Value {
if meta.is_empty() {
return Value::Null;
}
let map: std::collections::BTreeMap<String, Value> = meta
.iter()
.map(|(k, v)| (k.clone(), Self::meta_value_to_value(Some(v))))
.collect();
Value::Object(Box::new(map))
}
fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
if args.len() != expected {
return Err(QueryError::InvalidArguments(
name.to_string(),
format!("expected {} argument(s), got {}", expected, args.len()),
));
}
Ok(())
}
fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
if func.args.len() != expected {
return Err(QueryError::InvalidArguments(
name.to_string(),
format!("expected {expected} argument(s)"),
));
}
Ok(())
}
pub(crate) fn convert_to_market_value(
&self,
val: &Value,
explicit_currency: Option<&str>,
) -> Result<Value, QueryError> {
let target_currency = if let Some(currency) = explicit_currency {
currency.to_string()
} else {
let inferred = match val {
Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
Value::Inventory(inv) => inv
.positions()
.iter()
.find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
_ => None,
};
match inferred.or_else(|| self.target_currency.clone()) {
Some(c) => c,
None => {
return match val {
Value::Position(p) => Ok(Value::Amount(p.units.clone())),
Value::Amount(a) => Ok(Value::Amount(a.clone())),
Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"VALUE expects a position, amount, or inventory".to_string(),
)),
};
}
}
};
match val {
Value::Position(p) => {
if p.units.currency == target_currency {
Ok(Value::Amount(p.units.clone()))
} else if let Some(converted) =
self.price_db.convert_latest(&p.units, &target_currency)
{
Ok(Value::Amount(converted))
} else {
Ok(Value::Amount(p.units.clone()))
}
}
Value::Amount(a) => {
if a.currency == target_currency {
Ok(Value::Amount(a.clone()))
} else if let Some(converted) = self.price_db.convert_latest(a, &target_currency) {
Ok(Value::Amount(converted))
} else {
Ok(Value::Amount(a.clone()))
}
}
Value::Inventory(inv) => {
let mut total = Decimal::ZERO;
for pos in inv.positions() {
if pos.units.currency == target_currency {
total += pos.units.number;
} else if let Some(converted) =
self.price_db.convert_latest(&pos.units, &target_currency)
{
total += converted.number;
}
}
Ok(Value::Amount(Amount::new(total, &target_currency)))
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"VALUE expects a position, amount, or inventory".to_string(),
)),
}
}
pub(super) const fn is_window_expr(expr: &Expr) -> bool {
matches!(expr, Expr::Window(_))
}
fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
let mut names = Vec::new();
for (i, target) in targets.iter().enumerate() {
if matches!(target.expr, Expr::Wildcard) {
if target.alias.is_some() {
return Err(QueryError::Evaluation(
"Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
));
}
names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
} else if let Some(alias) = &target.alias {
names.push(alias.clone());
} else {
names.push(self.expr_to_name(&target.expr, i));
}
}
Ok(names)
}
fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
match expr {
Expr::Wildcard => "*".to_string(),
Expr::Column(name) => name.clone(),
Expr::Function(func) => func.name.clone(),
Expr::Window(wf) => wf.name.clone(),
_ => format!("col{index}"),
}
}
pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
let upper = table_name.to_uppercase();
let normalized = upper.strip_prefix('#').unwrap_or(&upper);
match normalized {
"PRICES" => Some(self.build_prices_table()),
"BALANCES" => Some(self.build_balances_table()),
"COMMODITIES" => Some(self.build_commodities_table()),
"EVENTS" => Some(self.build_events_table()),
"NOTES" => Some(self.build_notes_table()),
"DOCUMENTS" => Some(self.build_documents_table()),
"ACCOUNTS" => Some(self.build_accounts_table()),
"TRANSACTIONS" => Some(self.build_transactions_table()),
"ENTRIES" => Some(self.build_entries_table()),
"POSTINGS" => Some(self.build_postings_table()),
_ => None,
}
}
fn build_prices_table(&self) -> Table {
let columns = vec![
"date".to_string(),
"currency".to_string(),
"amount".to_string(),
];
let mut table = Table::new(columns);
let mut entries: Vec<_> = self.price_db.iter_entries().collect();
entries.sort_by(|(currency_a, date_a, _, _), (currency_b, date_b, _, _)| {
date_a.cmp(date_b).then_with(|| currency_a.cmp(currency_b))
});
for (base_currency, date, price_number, quote_currency) in entries {
let row = vec![
Value::Date(date),
Value::String(base_currency.to_string()),
Value::Amount(Amount::new(price_number, quote_currency)),
];
table.add_row(row);
}
table
}
fn build_balances_table(&self) -> Table {
let columns = vec![
"date".to_string(),
"account".to_string(),
"amount".to_string(),
];
let mut table = Table::new(columns);
let mut balances: Vec<_> = if let Some(spanned) = self.spanned_directives {
spanned
.iter()
.filter_map(|s| {
if let Directive::Balance(b) = &s.value {
Some((b.date, b.account.as_ref(), b.amount.clone()))
} else {
None
}
})
.collect()
} else {
self.directives
.iter()
.filter_map(|d| {
if let Directive::Balance(b) = d {
Some((b.date, b.account.as_ref(), b.amount.clone()))
} else {
None
}
})
.collect()
};
balances.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
});
for (date, account, amount) in balances {
let row = vec![
Value::Date(date),
Value::String(account.to_string()),
Value::Amount(amount),
];
table.add_row(row);
}
table
}
fn build_commodities_table(&self) -> Table {
let columns = vec!["date".to_string(), "name".to_string()];
let mut table = Table::new(columns);
let mut commodities: Vec<_> = if let Some(spanned) = self.spanned_directives {
spanned
.iter()
.filter_map(|s| {
if let Directive::Commodity(c) = &s.value {
Some((c.date, c.currency.as_ref()))
} else {
None
}
})
.collect()
} else {
self.directives
.iter()
.filter_map(|d| {
if let Directive::Commodity(c) = d {
Some((c.date, c.currency.as_ref()))
} else {
None
}
})
.collect()
};
commodities.sort_by(|(date_a, name_a), (date_b, name_b)| {
date_a.cmp(date_b).then_with(|| name_a.cmp(name_b))
});
for (date, name) in commodities {
let row = vec![Value::Date(date), Value::String(name.to_string())];
table.add_row(row);
}
table
}
fn build_events_table(&self) -> Table {
let columns = vec![
"date".to_string(),
"type".to_string(),
"description".to_string(),
];
let mut table = Table::new(columns);
let mut events: Vec<_> = if let Some(spanned) = self.spanned_directives {
spanned
.iter()
.filter_map(|s| {
if let Directive::Event(e) = &s.value {
Some((e.date, e.event_type.as_str(), e.value.as_str()))
} else {
None
}
})
.collect()
} else {
self.directives
.iter()
.filter_map(|d| {
if let Directive::Event(e) = d {
Some((e.date, e.event_type.as_str(), e.value.as_str()))
} else {
None
}
})
.collect()
};
events.sort_by(|(date_a, type_a, _), (date_b, type_b, _)| {
date_a.cmp(date_b).then_with(|| type_a.cmp(type_b))
});
for (date, event_type, description) in events {
let row = vec![
Value::Date(date),
Value::String(event_type.to_string()),
Value::String(description.to_string()),
];
table.add_row(row);
}
table
}
fn build_notes_table(&self) -> Table {
let columns = vec![
"date".to_string(),
"account".to_string(),
"comment".to_string(),
];
let mut table = Table::new(columns);
let mut notes: Vec<_> = if let Some(spanned) = self.spanned_directives {
spanned
.iter()
.filter_map(|s| {
if let Directive::Note(n) = &s.value {
Some((n.date, n.account.as_ref(), n.comment.as_str()))
} else {
None
}
})
.collect()
} else {
self.directives
.iter()
.filter_map(|d| {
if let Directive::Note(n) = d {
Some((n.date, n.account.as_ref(), n.comment.as_str()))
} else {
None
}
})
.collect()
};
notes.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
});
for (date, account, comment) in notes {
let row = vec![
Value::Date(date),
Value::String(account.to_string()),
Value::String(comment.to_string()),
];
table.add_row(row);
}
table
}
fn build_documents_table(&self) -> Table {
let columns = vec![
"date".to_string(),
"account".to_string(),
"filename".to_string(),
"tags".to_string(),
"links".to_string(),
];
let mut table = Table::new(columns);
let mut documents: Vec<_> = if let Some(spanned) = self.spanned_directives {
spanned
.iter()
.filter_map(|s| {
if let Directive::Document(d) = &s.value {
Some((
d.date,
d.account.as_ref(),
d.path.as_str(),
&d.tags,
&d.links,
))
} else {
None
}
})
.collect()
} else {
self.directives
.iter()
.filter_map(|d| {
if let Directive::Document(doc) = d {
Some((
doc.date,
doc.account.as_ref(),
doc.path.as_str(),
&doc.tags,
&doc.links,
))
} else {
None
}
})
.collect()
};
documents.sort_by(
|(date_a, account_a, file_a, _, _), (date_b, account_b, file_b, _, _)| {
date_a
.cmp(date_b)
.then_with(|| account_a.cmp(account_b))
.then_with(|| file_a.cmp(file_b))
},
);
for (date, account, filename, tags, links) in documents {
let tags_vec: Vec<String> = tags.iter().map(ToString::to_string).collect();
let links_vec: Vec<String> = links.iter().map(ToString::to_string).collect();
let row = vec![
Value::Date(date),
Value::String(account.to_string()),
Value::String(filename.to_string()),
Value::StringSet(tags_vec),
Value::StringSet(links_vec),
];
table.add_row(row);
}
table
}
fn build_accounts_table(&self) -> Table {
let columns = vec![
"account".to_string(),
"open".to_string(),
"close".to_string(),
"currencies".to_string(),
"booking".to_string(),
];
let mut table = Table::new(columns);
let mut accounts: FxHashMap<
&str,
(
Option<rustledger_core::NaiveDate>,
Option<rustledger_core::NaiveDate>,
Vec<String>,
Option<&str>,
),
> = FxHashMap::default();
let iter: Box<dyn Iterator<Item = &Directive>> =
if let Some(spanned) = self.spanned_directives {
Box::new(spanned.iter().map(|s| &s.value))
} else {
Box::new(self.directives.iter())
};
for directive in iter {
match directive {
Directive::Open(open) => {
let entry = accounts.entry(open.account.as_ref()).or_insert((
None,
None,
Vec::new(),
None,
));
entry.0 = Some(open.date);
entry.2 = open.currencies.iter().map(ToString::to_string).collect();
entry.3 = open.booking.as_deref();
}
Directive::Close(close) => {
let entry = accounts.entry(close.account.as_ref()).or_insert((
None,
None,
Vec::new(),
None,
));
entry.1 = Some(close.date);
}
_ => {}
}
}
let mut account_list: Vec<_> = accounts.into_iter().collect();
account_list.sort_by_key(|(a, _)| *a);
for (account, (open_date, close_date, currencies, booking)) in account_list {
let row = vec![
Value::String(account.to_string()),
open_date.map_or(Value::Null, Value::Date),
close_date.map_or(Value::Null, Value::Date),
Value::StringSet(currencies),
booking.map_or(Value::Null, |b| Value::String(b.to_string())),
];
table.add_row(row);
}
table
}
fn build_transactions_table(&self) -> Table {
let columns = vec![
"date".to_string(),
"flag".to_string(),
"payee".to_string(),
"narration".to_string(),
"tags".to_string(),
"links".to_string(),
"accounts".to_string(),
];
let mut table = Table::new(columns);
let iter: Box<dyn Iterator<Item = &Directive>> =
if let Some(spanned) = self.spanned_directives {
Box::new(spanned.iter().map(|s| &s.value))
} else {
Box::new(self.directives.iter())
};
let mut transactions: Vec<_> = iter
.filter_map(|d| {
if let Directive::Transaction(txn) = d {
Some(txn)
} else {
None
}
})
.collect();
transactions.sort_by_key(|t| t.date);
for txn in transactions {
let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
let mut accounts: Vec<String> = txn
.postings
.iter()
.map(|p| p.account.to_string())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
accounts.sort();
let row = vec![
Value::Date(txn.date),
Value::String(txn.flag.to_string()),
txn.payee
.as_ref()
.map_or(Value::Null, |p| Value::String(p.to_string())),
Value::String(txn.narration.to_string()),
Value::StringSet(tags),
Value::StringSet(links),
Value::StringSet(accounts),
];
table.add_row(row);
}
table
}
fn build_entries_table(&self) -> Table {
let columns = vec![
"id".to_string(),
"type".to_string(),
"filename".to_string(),
"lineno".to_string(),
"date".to_string(),
"flag".to_string(),
"payee".to_string(),
"narration".to_string(),
"tags".to_string(),
"links".to_string(),
"accounts".to_string(),
"_entry_meta".to_string(),
];
let mut table = Table::new(columns);
if let Some(spanned) = self.spanned_directives {
for (idx, spanned_dir) in spanned.iter().enumerate() {
let directive = &spanned_dir.value;
let source_loc = self.get_source_location(idx);
let row = self.directive_to_entry_row(idx, directive, source_loc);
table.add_row(row);
}
} else {
for (idx, directive) in self.directives.iter().enumerate() {
let row = self.directive_to_entry_row(idx, directive, None);
table.add_row(row);
}
}
table
}
fn directive_to_entry_row(
&self,
idx: usize,
directive: &Directive,
source_loc: Option<&SourceLocation>,
) -> Vec<Value> {
let type_name = match directive {
Directive::Transaction(_) => "transaction",
Directive::Balance(_) => "balance",
Directive::Open(_) => "open",
Directive::Close(_) => "close",
Directive::Commodity(_) => "commodity",
Directive::Pad(_) => "pad",
Directive::Event(_) => "event",
Directive::Query(_) => "query",
Directive::Note(_) => "note",
Directive::Document(_) => "document",
Directive::Price(_) => "price",
Directive::Custom(_) => "custom",
};
let date = match directive {
Directive::Transaction(t) => Value::Date(t.date),
Directive::Balance(b) => Value::Date(b.date),
Directive::Open(o) => Value::Date(o.date),
Directive::Close(c) => Value::Date(c.date),
Directive::Commodity(c) => Value::Date(c.date),
Directive::Pad(p) => Value::Date(p.date),
Directive::Event(e) => Value::Date(e.date),
Directive::Query(q) => Value::Date(q.date),
Directive::Note(n) => Value::Date(n.date),
Directive::Document(d) => Value::Date(d.date),
Directive::Price(p) => Value::Date(p.date),
Directive::Custom(c) => Value::Date(c.date),
};
let (flag, payee, narration, tags, links, accounts) =
if let Directive::Transaction(txn) = directive {
let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
let mut accounts: Vec<String> = txn
.postings
.iter()
.map(|p| p.account.to_string())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
accounts.sort(); (
Value::String(txn.flag.to_string()),
txn.payee
.as_ref()
.map_or(Value::Null, |p| Value::String(p.to_string())),
Value::String(txn.narration.to_string()),
Value::StringSet(tags),
Value::StringSet(links),
Value::StringSet(accounts),
)
} else {
(
Value::Null,
Value::Null,
Value::Null,
Value::StringSet(vec![]),
Value::StringSet(vec![]),
Value::StringSet(vec![]),
)
};
let filename = source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
vec![
Value::Integer(idx as i64), Value::String(type_name.to_string()),
filename,
lineno,
date,
flag,
payee,
narration,
tags,
links,
accounts,
Self::metadata_to_value(directive.meta()),
]
}
fn build_postings_table(&self) -> Table {
let columns = vec![
"type".to_string(),
"id".to_string(),
"date".to_string(),
"year".to_string(),
"month".to_string(),
"day".to_string(),
"filename".to_string(),
"lineno".to_string(),
"location".to_string(),
"flag".to_string(),
"payee".to_string(),
"narration".to_string(),
"description".to_string(),
"tags".to_string(),
"links".to_string(),
"posting_flag".to_string(),
"account".to_string(),
"other_accounts".to_string(),
"number".to_string(),
"currency".to_string(),
"cost_number".to_string(),
"cost_currency".to_string(),
"cost_date".to_string(),
"cost_label".to_string(),
"position".to_string(),
"price".to_string(),
"weight".to_string(),
"balance".to_string(),
"meta".to_string(),
"accounts".to_string(),
"_entry_meta".to_string(),
"_posting_meta".to_string(),
];
let mut table = Table::new(columns);
let mut running_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
let mut transactions: Vec<(usize, &rustledger_core::Transaction)> =
if let Some(spanned) = self.spanned_directives {
spanned
.iter()
.enumerate()
.filter_map(|(idx, s)| {
if let Directive::Transaction(txn) = &s.value {
Some((idx, txn))
} else {
None
}
})
.collect()
} else {
self.directives
.iter()
.enumerate()
.filter_map(|(idx, d)| {
if let Directive::Transaction(txn) = d {
Some((idx, txn))
} else {
None
}
})
.collect()
};
transactions.sort_by_key(|(_, t)| t.date);
for (dir_idx, txn) in &transactions {
let source_loc = self.get_source_location(*dir_idx);
let filename =
source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
let location = source_loc.map_or(Value::Null, |loc| {
Value::String(format!("{}:{}", loc.filename, loc.lineno))
});
let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
let mut all_accounts: Vec<String> = txn
.postings
.iter()
.map(|p| p.account.to_string())
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
all_accounts.sort();
let description = match &txn.payee {
Some(payee) => format!("{} | {}", payee, txn.narration),
None => txn.narration.to_string(),
};
let year = Value::Integer(i64::from(txn.date.year()));
let month = Value::Integer(i64::from(txn.date.month()));
let day = Value::Integer(i64::from(txn.date.day()));
for posting in &txn.postings {
if let Some(units) = posting.amount() {
let balance = running_balances.entry(posting.account.clone()).or_default();
let pos = if let Some(cost_spec) = &posting.cost {
if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
Position::with_cost(units.clone(), cost)
} else {
Position::simple(units.clone())
}
} else {
Position::simple(units.clone())
};
balance.add(pos);
}
let (number, currency) = posting.amount().map_or((Value::Null, Value::Null), |a| {
(
Value::Number(a.number),
Value::String(a.currency.to_string()),
)
});
let (cost_number, cost_currency, cost_date, cost_label) = if let Some(cost_spec) =
&posting.cost
{
let units = posting.amount();
if let Some(cost) = units.and_then(|u| cost_spec.resolve(u.number, txn.date)) {
(
Value::Number(cost.number),
Value::String(cost.currency.to_string()),
cost.date.map_or(Value::Null, Value::Date),
cost.label
.as_ref()
.map_or(Value::Null, |l| Value::String(l.clone())),
)
} else {
(Value::Null, Value::Null, Value::Null, Value::Null)
}
} else {
(Value::Null, Value::Null, Value::Null, Value::Null)
};
let position_val = if let Some(units) = posting.amount() {
if let Some(cost_spec) = &posting.cost
&& let Some(cost) = cost_spec.resolve(units.number, txn.date)
{
Value::Position(Box::new(Position::with_cost(units.clone(), cost)))
} else {
Value::Position(Box::new(Position::simple(units.clone())))
}
} else {
Value::Null
};
let price_val = posting
.price
.as_ref()
.and_then(|p| p.amount())
.map_or(Value::Null, |a| Value::Amount(a.clone()));
let weight_val = if let Some(units) = posting.amount() {
if let Some(cost_spec) = &posting.cost {
if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
Value::Amount(Amount::new(units.number * cost.number, cost.currency))
} else {
Value::Amount(units.clone())
}
} else if let Some(price_ann) = &posting.price {
if let Some(price_amt) = price_ann.amount() {
if price_ann.is_unit() {
Value::Amount(Amount::new(
units.number * price_amt.number,
price_amt.currency.clone(),
))
} else {
Value::Amount(price_amt.clone())
}
} else {
Value::Amount(units.clone())
}
} else {
Value::Amount(units.clone())
}
} else {
Value::Null
};
let balance_val = running_balances
.get(&posting.account)
.map_or(Value::Null, |inv| Value::Inventory(Box::new(inv.clone())));
let other_accounts: Vec<String> = all_accounts
.iter()
.filter(|a| a.as_str() != posting.account.as_ref())
.cloned()
.collect();
let posting_flag = posting
.flag
.map_or(Value::Null, |f| Value::String(f.to_string()));
let row = vec![
Value::String("transaction".to_string()),
Value::Integer(*dir_idx as i64),
Value::Date(txn.date),
year.clone(),
month.clone(),
day.clone(),
filename.clone(),
lineno.clone(),
location.clone(),
Value::String(txn.flag.to_string()),
txn.payee
.as_ref()
.map_or(Value::Null, |p| Value::String(p.to_string())),
Value::String(txn.narration.to_string()),
Value::String(description.clone()),
Value::StringSet(tags.clone()),
Value::StringSet(links.clone()),
posting_flag,
Value::String(posting.account.to_string()),
Value::StringSet(other_accounts),
number,
currency,
cost_number,
cost_currency,
cost_date,
cost_label,
position_val,
price_val,
weight_val,
balance_val,
Value::Metadata(Box::new(posting.meta.clone())),
Value::StringSet(all_accounts.clone()),
Self::metadata_to_value(&txn.meta),
Self::metadata_to_value(&posting.meta),
];
table.add_row(row);
}
}
table
}
}
#[cfg(test)]
mod tests {
use super::types::{hash_row, hash_single_value};
use super::*;
use crate::parse;
use rust_decimal_macros::dec;
use rustledger_core::Posting;
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
rustledger_core::naive_date(year, month, day).unwrap()
}
fn sample_directives() -> Vec<Directive> {
vec![
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Coffee")
.with_flag('*')
.with_payee("Coffee Shop")
.with_posting(Posting::new(
"Expenses:Food:Coffee",
Amount::new(dec!(5.00), "USD"),
))
.with_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-5.00), "USD"),
)),
),
Directive::Transaction(
Transaction::new(date(2024, 1, 16), "Groceries")
.with_flag('*')
.with_payee("Supermarket")
.with_posting(Posting::new(
"Expenses:Food:Groceries",
Amount::new(dec!(50.00), "USD"),
))
.with_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-50.00), "USD"),
)),
),
]
}
#[test]
fn test_simple_select() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT date, account").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.columns, vec!["date", "account"]);
assert_eq!(result.len(), 4); }
#[test]
fn test_where_clause() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT account WHERE account ~ \"Expenses:\"").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2); }
#[test]
fn test_balances() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("BALANCES").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.columns, vec!["account", "balance"]);
assert!(result.len() >= 3); }
#[test]
fn test_account_functions() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT DISTINCT LEAF(account) WHERE account ~ \"Expenses:\"").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2);
let query = parse("SELECT DISTINCT ROOT(account)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2);
let query = parse("SELECT DISTINCT PARENT(account) WHERE account ~ \"Expenses:\"").unwrap();
let result = executor.execute(&query).unwrap();
assert!(!result.is_empty()); }
#[test]
fn test_min_max_aggregate() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT MIN(date)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
let query = parse("SELECT MAX(date)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
}
#[test]
fn test_order_by() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT date, account ORDER BY date DESC").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 4);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
}
#[test]
fn test_hash_value_all_variants() {
use rustledger_core::{Cost, Inventory, Position};
let values = vec![
Value::String("test".to_string()),
Value::Number(dec!(123.45)),
Value::Integer(42),
Value::Date(date(2024, 1, 15)),
Value::Boolean(true),
Value::Boolean(false),
Value::Amount(Amount::new(dec!(100), "USD")),
Value::Position(Box::new(Position::simple(Amount::new(dec!(10), "AAPL")))),
Value::Position(Box::new(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(150), "USD"),
))),
Value::Inventory(Box::new(Inventory::new())),
Value::StringSet(vec!["tag1".to_string(), "tag2".to_string()]),
Value::Null,
];
for value in &values {
let hash = hash_single_value(value);
assert!(hash != 0 || matches!(value, Value::Null));
}
let hash1 = hash_single_value(&Value::String("a".to_string()));
let hash2 = hash_single_value(&Value::String("b".to_string()));
assert_ne!(hash1, hash2);
let hash3 = hash_single_value(&Value::Integer(42));
let hash4 = hash_single_value(&Value::Integer(42));
assert_eq!(hash3, hash4);
}
#[test]
fn test_hash_row_distinct() {
let row1 = vec![Value::String("a".to_string()), Value::Integer(1)];
let row2 = vec![Value::String("a".to_string()), Value::Integer(1)];
let row3 = vec![Value::String("b".to_string()), Value::Integer(1)];
assert_eq!(hash_row(&row1), hash_row(&row2));
assert_ne!(hash_row(&row1), hash_row(&row3));
}
#[test]
fn test_string_set_hash_order_independent() {
let set1 = Value::StringSet(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
let set2 = Value::StringSet(vec!["c".to_string(), "a".to_string(), "b".to_string()]);
let set3 = Value::StringSet(vec!["b".to_string(), "c".to_string(), "a".to_string()]);
let hash1 = hash_single_value(&set1);
let hash2 = hash_single_value(&set2);
let hash3 = hash_single_value(&set3);
assert_eq!(hash1, hash2);
assert_eq!(hash2, hash3);
}
#[test]
fn test_inventory_hash_includes_cost() {
use rustledger_core::{Cost, Inventory, Position};
let mut inv1 = Inventory::new();
inv1.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(100), "USD"),
));
let mut inv2 = Inventory::new();
inv2.add(Position::with_cost(
Amount::new(dec!(10), "AAPL"),
Cost::new(dec!(200), "USD"),
));
let hash1 = hash_single_value(&Value::Inventory(Box::new(inv1)));
let hash2 = hash_single_value(&Value::Inventory(Box::new(inv2)));
assert_ne!(hash1, hash2);
}
#[test]
fn test_distinct_deduplication() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT flag").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 4);
let query = parse("SELECT DISTINCT flag").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1); }
#[test]
fn test_limit_clause() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT date, account LIMIT 2").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2);
let query = parse("SELECT date LIMIT 0").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 0);
let query = parse("SELECT date LIMIT 100").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 4);
}
#[test]
fn test_group_by_with_count() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.columns.len(), 2);
assert_eq!(result.len(), 2);
}
#[test]
fn test_count_aggregate() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT COUNT(account)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Integer(4));
let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2); }
#[test]
fn test_count_wildcard_direct() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT count(*)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Integer(4));
let query = parse("SELECT account, count(*) GROUP BY account").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 3); }
#[test]
fn test_count_wildcard_from_postings_table() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT account, count(*) FROM postings GROUP BY account").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 3);
}
#[test]
fn test_count_wildcard_from_entries_table() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT type, count(*) FROM entries GROUP BY type").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::String("transaction".to_string()));
assert_eq!(result.rows[0][1], Value::Integer(2));
}
#[test]
fn test_count_wildcard_having() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse(
"SELECT account, count(*) AS cnt FROM postings GROUP BY account HAVING count(*) > 0",
)
.unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 3);
let query = parse(
"SELECT account, count(*) AS cnt FROM postings GROUP BY account HAVING count(*) > 1",
)
.unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(
result.rows[0][0],
Value::String("Assets:Bank:Checking".to_string())
);
assert_eq!(result.rows[0][1], Value::Integer(2));
}
#[test]
fn test_journal_query() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("JOURNAL \"Expenses\"").unwrap();
let result = executor.execute(&query).unwrap();
assert!(result.columns.contains(&"account".to_string()));
assert_eq!(result.len(), 2);
}
#[test]
fn test_print_query() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("PRINT").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.columns.len(), 1);
assert_eq!(result.columns[0], "directive");
assert_eq!(result.len(), 2);
}
#[test]
fn test_empty_directives() {
let directives: Vec<Directive> = vec![];
let mut executor = Executor::new(&directives);
let query = parse("SELECT date, account").unwrap();
let result = executor.execute(&query).unwrap();
assert!(result.is_empty());
let query = parse("BALANCES").unwrap();
let result = executor.execute(&query).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_comparison_operators() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT date WHERE date < 2024-01-16").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2);
let query = parse("SELECT date WHERE year > 2023").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 4);
let query = parse("SELECT account WHERE day = 15").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2); }
#[test]
fn test_logical_operators() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT account WHERE account ~ \"Expenses\" AND day > 14").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2);
let query = parse("SELECT account WHERE day = 15 OR day = 16").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 4); }
#[test]
fn test_arithmetic_expressions() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT -day WHERE day = 15").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 2);
for row in &result.rows {
if let Value::Integer(n) = &row[0] {
assert_eq!(*n, -15);
}
}
}
#[test]
fn test_first_last_aggregates() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT FIRST(date)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
let query = parse("SELECT LAST(date)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
}
#[test]
fn test_wildcard_select() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT *").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.columns,
vec!["date", "flag", "payee", "narration", "account", "position"]
);
assert_eq!(result.len(), 4);
assert_eq!(result.rows[0].len(), 6);
}
#[test]
fn test_wildcard_alias_rejected() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT * AS data").unwrap();
let result = executor.execute(&query);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("Cannot alias wildcard"),
"Expected wildcard alias error, got: {err}"
);
}
#[test]
fn test_query_result_methods() {
let mut result = QueryResult::new(vec!["col1".to_string(), "col2".to_string()]);
assert!(result.is_empty());
assert_eq!(result.len(), 0);
result.add_row(vec![Value::Integer(1), Value::String("a".to_string())]);
assert!(!result.is_empty());
assert_eq!(result.len(), 1);
result.add_row(vec![Value::Integer(2), Value::String("b".to_string())]);
assert_eq!(result.len(), 2);
}
#[test]
fn test_type_cast_functions() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT int(5.7)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Integer(5));
let query = parse("SELECT decimal(42)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Number(dec!(42)));
let query = parse("SELECT str(123)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("123".to_string()));
let query = parse("SELECT bool(1)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Boolean(true));
let query = parse("SELECT bool(0)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Boolean(false));
}
#[test]
fn test_type_casting_in_aggregate_context() {
let txn1 = Transaction::new(date(2024, 1, 15), "Item 1")
.with_flag('*')
.with_posting(Posting::new("Expenses:Food", Amount::new(dec!(10), "USD")))
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "USD")));
let txn2 = Transaction::new(date(2024, 1, 16), "Item 2")
.with_flag('*')
.with_posting(Posting::new("Expenses:Food", Amount::new(dec!(20), "USD")))
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-20), "USD")));
let directives = vec![Directive::Transaction(txn1), Directive::Transaction(txn2)];
let mut executor = Executor::new(&directives);
let query =
parse("SELECT account, str(sum(number(units))) GROUP BY account ORDER BY account")
.unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows.len(), 2);
assert_eq!(result.rows[0][0], Value::String("Assets:Cash".to_string()));
assert_eq!(result.rows[0][1], Value::String("-30".to_string()));
assert_eq!(
result.rows[1][0],
Value::String("Expenses:Food".to_string())
);
assert_eq!(result.rows[1][1], Value::String("30".to_string()));
let query =
parse("SELECT account, int(sum(number(units))) GROUP BY account ORDER BY account")
.unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][1], Value::Integer(-30));
assert_eq!(result.rows[1][1], Value::Integer(30));
let query =
parse("SELECT account, decimal(count(*)) GROUP BY account ORDER BY account").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][1], Value::Number(dec!(2))); assert_eq!(result.rows[1][1], Value::Number(dec!(2)));
let query =
parse("SELECT account, bool(count(*)) GROUP BY account ORDER BY account").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][1], Value::Boolean(true));
assert_eq!(result.rows[1][1], Value::Boolean(true));
}
#[test]
fn test_int_truncation() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT int(5.7)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Integer(5));
let query = parse("SELECT int(-5.7)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Integer(-5));
let query = parse("SELECT int(0.999)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Integer(0));
}
#[test]
fn test_type_casting_errors() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT int('not-a-number')").unwrap();
let result = executor.execute(&query);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("cannot parse 'not-a-number'")
);
let query = parse("SELECT decimal('invalid')").unwrap();
let result = executor.execute(&query);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot parse"));
let query = parse("SELECT bool('maybe')").unwrap();
let result = executor.execute(&query);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot parse"));
}
#[test]
fn test_meta_functions() {
let mut txn_meta: Metadata = Metadata::default();
txn_meta.insert(
"source".to_string(),
MetaValue::String("bank_import".to_string()),
);
let mut posting_meta: Metadata = Metadata::default();
posting_meta.insert(
"category".to_string(),
MetaValue::String("food".to_string()),
);
let txn = Transaction {
date: date(2024, 1, 15),
flag: '*',
payee: Some("Coffee Shop".into()),
narration: "Coffee".into(),
tags: vec![],
links: vec![],
meta: txn_meta,
postings: vec![
Posting {
account: "Expenses:Food".into(),
units: Some(rustledger_core::IncompleteAmount::Complete(Amount::new(
dec!(5),
"USD",
))),
cost: None,
price: None,
flag: None,
meta: posting_meta,
comments: Vec::new(),
trailing_comments: Vec::new(),
},
Posting::new("Assets:Cash", Amount::new(dec!(-5), "USD")),
],
trailing_comments: Vec::new(),
};
let directives = vec![Directive::Transaction(txn)];
let mut executor = Executor::new(&directives);
let query = parse("SELECT meta('category') WHERE account ~ 'Expenses'").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("food".to_string()));
let query = parse("SELECT entry_meta('source') WHERE account ~ 'Expenses'").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
let query = parse("SELECT any_meta('source') WHERE account ~ 'Expenses'").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
let query = parse("SELECT any_meta('category') WHERE account ~ 'Expenses'").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("food".to_string()));
let query = parse("SELECT meta('nonexistent') WHERE account ~ 'Expenses'").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Null);
}
#[test]
fn test_convert_function() {
let price = rustledger_core::Price {
date: date(2024, 1, 1),
currency: "EUR".into(),
amount: Amount::new(dec!(1.10), "USD"),
meta: Metadata::default(),
};
let txn = Transaction::new(date(2024, 1, 15), "Test")
.with_flag('*')
.with_posting(Posting::new("Assets:Euro", Amount::new(dec!(100), "EUR")))
.with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-110), "USD")));
let directives = vec![Directive::Price(price), Directive::Transaction(txn)];
let mut executor = Executor::new(&directives);
let query = parse("SELECT convert(position, 'USD') WHERE account ~ 'Euro'").unwrap();
let result = executor.execute(&query).unwrap();
match &result.rows[0][0] {
Value::Amount(a) => {
assert_eq!(a.number, dec!(110));
assert_eq!(a.currency.as_ref(), "USD");
}
_ => panic!("Expected Amount, got {:?}", result.rows[0][0]),
}
}
#[test]
fn test_date_functions() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT date('2024-06-15')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
let query = parse("SELECT date(2024, 6, 15)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
let query = parse("SELECT date_diff(date('2024-01-20'), date('2024-01-15'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Integer(5));
let query = parse("SELECT date_add(date('2024-01-15'), 10)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 25)));
let query = parse("SELECT date_trunc('year', date('2024-06-15'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1)));
let query = parse("SELECT date_trunc('month', date('2024-06-15'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1)));
let query = parse("SELECT date_part('month', date('2024-06-15'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Integer(6));
let query = parse("SELECT parse_date('15/06/2024', '%d/%m/%Y')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
let query =
parse("SELECT date_bin('7 days', date('2024-01-15'), date('2024-01-01'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
let query =
parse("SELECT date_bin('1 week', date('2024-01-20'), date('2024-01-01'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
let query =
parse("SELECT date_bin('1 month', date('2024-06-15'), date('2024-01-01'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1)));
let query =
parse("SELECT date_bin('1 year', date('2024-06-15'), date('2020-01-01'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1))); }
#[test]
fn test_string_functions_extended() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT grep('Ex[a-z]+', 'Hello Expenses World')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("Expenses".to_string()));
let query = parse("SELECT grep('xyz', 'Hello World')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Null);
let query = parse("SELECT grepn('([0-9]+)-([0-9]+)', '2024-01', 1)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("2024".to_string()));
let query = parse("SELECT subst('-', '/', '2024-01-15')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("2024/01/15".to_string()));
let query = parse("SELECT splitcomp('a:b:c', ':', 1)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("b".to_string()));
let query = parse("SELECT joinstr('hello', 'world')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("hello, world".to_string()));
let query = parse("SELECT maxwidth('hello', 10)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("hello".to_string()));
let query = parse("SELECT maxwidth('hello world', 8)").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("hello...".to_string()));
}
#[test]
fn test_inventory_functions() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT empty(sum(position)) WHERE account ~ 'Assets'").unwrap();
let result = executor.execute(&query).unwrap();
assert!(matches!(result.rows[0][0], Value::Boolean(_)));
let query = parse("SELECT possign(100, 'Assets:Bank')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Number(rust_decimal::Decimal::from(100))
);
let query = parse("SELECT possign(100, 'Income:Salary')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Number(rust_decimal::Decimal::from(-100))
);
let query = parse("SELECT possign(50, 'Expenses:Food')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Number(rust_decimal::Decimal::from(50))
);
let query = parse("SELECT possign(200, 'Liabilities:CreditCard')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Number(rust_decimal::Decimal::from(-200))
);
let query = parse("SELECT possign(300, 'Equity:OpeningBalances')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Number(rust_decimal::Decimal::from(-300))
);
}
#[test]
fn test_account_meta_functions() {
use rustledger_core::{Close, Metadata, Open};
let mut open_meta = Metadata::default();
open_meta.insert(
"category".to_string(),
MetaValue::String("checking".to_string()),
);
let directives = vec![
Directive::Open(Open {
date: date(2020, 1, 1),
account: "Assets:Bank:Checking".into(),
currencies: vec![],
booking: None,
meta: open_meta,
}),
Directive::Open(Open::new(date(2020, 2, 15), "Expenses:Food")),
Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank:Checking")),
Directive::Transaction(
Transaction::new(date(2024, 1, 15), "Coffee")
.with_posting(Posting::new(
"Expenses:Food",
Amount::new(dec!(5.00), "USD"),
))
.with_posting(Posting::new(
"Assets:Bank:Checking",
Amount::new(dec!(-5.00), "USD"),
)),
),
];
let mut executor = Executor::new(&directives);
let query = parse("SELECT open_date('Assets:Bank:Checking')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2020, 1, 1)));
let query = parse("SELECT close_date('Assets:Bank:Checking')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Date(date(2024, 12, 31)));
let query = parse("SELECT close_date('Expenses:Food')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Null);
let query = parse("SELECT open_meta('Assets:Bank:Checking', 'category')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::String("checking".to_string()));
let query = parse("SELECT open_meta('Assets:Bank:Checking', 'nonexistent')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Null);
let query = parse("SELECT open_date('NonExistent:Account')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Null);
}
#[test]
fn test_source_location_columns_return_null_without_sources() {
let directives = vec![Directive::Transaction(Transaction {
date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
flag: '*',
payee: Some("Test".into()),
narration: "Test transaction".into(),
tags: vec![],
links: vec![],
meta: Metadata::default(),
postings: vec![
Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
],
trailing_comments: Vec::new(),
})];
let mut executor = Executor::new(&directives);
let query = parse("SELECT filename").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Null);
let query = parse("SELECT lineno").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Null);
let query = parse("SELECT location").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Null);
}
#[test]
fn test_source_location_columns_with_sources() {
use rustledger_loader::SourceMap;
use rustledger_parser::Spanned;
use std::sync::Arc;
let mut source_map = SourceMap::new();
let source: Arc<str> =
"2024-01-15 * \"Test\"\n Assets:Bank 100 USD\n Expenses:Food".into();
let file_id = source_map.add_file("test.beancount".into(), source);
let txn = Transaction {
date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
flag: '*',
payee: Some("Test".into()),
narration: "Test transaction".into(),
tags: vec![],
links: vec![],
meta: Metadata::default(),
postings: vec![
Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
],
trailing_comments: Vec::new(),
};
let spanned_directives = vec![Spanned {
value: Directive::Transaction(txn),
span: rustledger_parser::Span { start: 0, end: 50 },
file_id: file_id as u16,
}];
let mut executor = Executor::new_with_sources(&spanned_directives, &source_map);
let query = parse("SELECT filename").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::String("test.beancount".to_string())
);
let query = parse("SELECT lineno").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(result.rows[0][0], Value::Integer(1));
let query = parse("SELECT location").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::String("test.beancount:1".to_string())
);
}
#[test]
fn test_interval_function() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT interval('month')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(1, IntervalUnit::Month))
);
let query = parse("SELECT interval(3, 'day')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(3, IntervalUnit::Day))
);
let query = parse("SELECT interval(-2, 'week')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Interval(Interval::new(-2, IntervalUnit::Week))
);
}
#[test]
fn test_date_add_with_interval() {
let directives = sample_directives();
let mut executor = Executor::new(&directives);
let query = parse("SELECT date_add(date(2024, 1, 15), interval(1, 'month'))").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Date(rustledger_core::naive_date(2024, 2, 15).unwrap())
);
let query = parse("SELECT date(2024, 1, 15) + interval(1, 'year')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Date(rustledger_core::naive_date(2025, 1, 15).unwrap())
);
let query = parse("SELECT date(2024, 3, 15) - interval(2, 'month')").unwrap();
let result = executor.execute(&query).unwrap();
assert_eq!(
result.rows[0][0],
Value::Date(rustledger_core::naive_date(2024, 1, 15).unwrap())
);
}
}