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 chrono::Datelike;
use regex::Regex;
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: chrono::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;
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: chrono::Local::now().date_naive(),
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 {
if let Directive::Price(p) = &spanned.value {
price_db.add_price(p);
}
}
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: chrono::Local::now().date_naive(),
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 = Regex::new(pattern).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(chrono::Local::now().date_naive())),
"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.abs() * cost.number;
Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
} else {
Ok(Value::Null)
}
}
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.abs() * cost.number;
if currency.is_none() {
currency = Some(cost.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 target_currency = if args.len() == 2 {
match &args[1] {
Value::String(s) => Some(s.clone()),
_ => None,
}
} else {
self.target_currency.clone()
};
match &args[0] {
Value::Position(p) => {
if let Some(ref target) = target_currency {
if p.units.currency.as_str() == target {
return Ok(Value::Amount(p.units.clone()));
}
if let Some(converted) =
self.price_db.convert(&p.units, target, self.query_date)
{
return Ok(Value::Amount(converted));
}
}
Ok(Value::Amount(p.units.clone()))
}
Value::Amount(a) => {
if let Some(ref target) = target_currency {
if a.currency.as_str() == target {
return Ok(Value::Amount(a.clone()));
}
if let Some(converted) =
self.price_db.convert(a, target, self.query_date)
{
return Ok(Value::Amount(converted));
}
}
Ok(Value::Amount(a.clone()))
}
Value::Inventory(inv) => {
if let Some(ref target) = target_currency {
let mut total = Decimal::ZERO;
for pos in inv.positions() {
if pos.units.currency.as_str() == target {
total += pos.units.number;
} else if let Some(converted) =
self.price_db.convert(&pos.units, target, self.query_date)
{
total += converted.number;
}
}
return Ok(Value::Amount(Amount::new(total, target)));
}
Ok(Value::Inventory(inv.clone()))
}
Value::Null => Ok(Value::Null),
_ => Err(QueryError::Type(
"VALUE expects a position or inventory".to_string(),
)),
}
}
"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(),
)),
}
}
"SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
_ => Err(QueryError::UnknownFunction(name.to_string())),
}
}
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(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 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}"),
}
}
}
#[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 {
NaiveDate::from_ymd_opt(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_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!["*"]);
assert_eq!(result.len(), 4);
assert_eq!(result.rows[0].len(), 6); }
#[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_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: NaiveDate::from_ymd_opt(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: NaiveDate::from_ymd_opt(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(NaiveDate::from_ymd_opt(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(NaiveDate::from_ymd_opt(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(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap())
);
}
}