1mod functions;
6mod types;
7
8use types::AccountInfo;
9pub use types::{
10 Interval, IntervalUnit, PostingContext, QueryResult, Row, SourceLocation, Table, Value,
11 WindowContext,
12};
13
14use std::sync::RwLock;
15
16use rustc_hash::FxHashMap;
17
18use chrono::Datelike;
19use regex::{Regex, RegexBuilder};
20use rust_decimal::Decimal;
21use rustledger_core::{Amount, Directive, InternedStr, Inventory, Metadata, Position};
22#[cfg(test)]
23use rustledger_core::{MetaValue, NaiveDate, Transaction};
24use rustledger_loader::SourceMap;
25use rustledger_parser::Spanned;
26
27use crate::ast::{Expr, FromClause, FunctionCall, Query, Target};
28use crate::error::QueryError;
29
30pub struct Executor<'a> {
32 directives: &'a [Directive],
34 spanned_directives: Option<&'a [Spanned<Directive>]>,
36 balances: FxHashMap<InternedStr, Inventory>,
38 price_db: crate::price::PriceDatabase,
40 target_currency: Option<String>,
42 query_date: chrono::NaiveDate,
44 regex_cache: RwLock<FxHashMap<String, Option<Regex>>>,
46 account_info: FxHashMap<String, AccountInfo>,
48 source_locations: Option<Vec<SourceLocation>>,
50 tables: FxHashMap<String, Table>,
52}
53
54mod aggregation;
56mod evaluation;
57mod execution;
58mod operators;
59mod sort;
60mod window;
61
62pub const WILDCARD_COLUMNS: &[&str] =
65 &["date", "flag", "payee", "narration", "account", "position"];
66
67impl<'a> Executor<'a> {
68 pub fn new(directives: &'a [Directive]) -> Self {
70 let price_db = crate::price::PriceDatabase::from_directives(directives);
71
72 let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
74 for directive in directives {
75 match directive {
76 Directive::Open(open) => {
77 let account = open.account.to_string();
78 let info = account_info.entry(account).or_insert_with(|| AccountInfo {
79 open_date: None,
80 close_date: None,
81 open_meta: Metadata::default(),
82 });
83 info.open_date = Some(open.date);
84 info.open_meta.clone_from(&open.meta);
85 }
86 Directive::Close(close) => {
87 let account = close.account.to_string();
88 let info = account_info.entry(account).or_insert_with(|| AccountInfo {
89 open_date: None,
90 close_date: None,
91 open_meta: Metadata::default(),
92 });
93 info.close_date = Some(close.date);
94 }
95 _ => {}
96 }
97 }
98
99 Self {
100 directives,
101 spanned_directives: None,
102 balances: FxHashMap::default(),
103 price_db,
104 target_currency: None,
105 query_date: chrono::Local::now().date_naive(),
106 regex_cache: RwLock::new(FxHashMap::default()),
107 account_info,
108 source_locations: None,
109 tables: FxHashMap::default(),
110 }
111 }
112
113 pub fn new_with_sources(
118 spanned_directives: &'a [Spanned<Directive>],
119 source_map: &SourceMap,
120 ) -> Self {
121 let mut price_db = crate::price::PriceDatabase::new();
124 for spanned in spanned_directives {
125 match &spanned.value {
126 Directive::Price(p) => {
127 price_db.add_price(p);
128 }
129 Directive::Transaction(txn) => {
130 price_db.add_implicit_prices_from_transaction(txn);
131 }
132 _ => {}
133 }
134 }
135 price_db.sort_prices();
136
137 let source_locations: Vec<SourceLocation> = spanned_directives
139 .iter()
140 .map(|spanned| {
141 let file = source_map.get(spanned.file_id as usize);
142 let (line, _col) = file.map_or((0, 0), |f| f.line_col(spanned.span.start));
143 SourceLocation {
144 filename: file.map_or_else(String::new, |f| f.path.display().to_string()),
145 lineno: line,
146 }
147 })
148 .collect();
149
150 let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
152 for spanned in spanned_directives {
153 match &spanned.value {
154 Directive::Open(open) => {
155 let account = open.account.to_string();
156 let info = account_info.entry(account).or_insert_with(|| AccountInfo {
157 open_date: None,
158 close_date: None,
159 open_meta: Metadata::default(),
160 });
161 info.open_date = Some(open.date);
162 info.open_meta.clone_from(&open.meta);
163 }
164 Directive::Close(close) => {
165 let account = close.account.to_string();
166 let info = account_info.entry(account).or_insert_with(|| AccountInfo {
167 open_date: None,
168 close_date: None,
169 open_meta: Metadata::default(),
170 });
171 info.close_date = Some(close.date);
172 }
173 _ => {}
174 }
175 }
176
177 Self {
178 directives: &[], spanned_directives: Some(spanned_directives),
180 balances: FxHashMap::default(),
181 price_db,
182 target_currency: None,
183 query_date: chrono::Local::now().date_naive(),
184 regex_cache: RwLock::new(FxHashMap::default()),
185 account_info,
186 source_locations: Some(source_locations),
187 tables: FxHashMap::default(),
188 }
189 }
190
191 fn get_source_location(&self, directive_index: usize) -> Option<&SourceLocation> {
193 self.source_locations
194 .as_ref()
195 .and_then(|locs| locs.get(directive_index))
196 }
197
198 fn get_or_compile_regex(&self, pattern: &str) -> Option<Regex> {
203 {
205 let cache = match self.regex_cache.read() {
208 Ok(guard) => guard,
209 Err(poisoned) => poisoned.into_inner(),
210 };
211 if let Some(cached) = cache.get(pattern) {
212 return cached.clone();
213 }
214 }
215 let compiled = RegexBuilder::new(pattern)
218 .case_insensitive(true)
219 .build()
220 .ok();
221 let mut cache = match self.regex_cache.write() {
222 Ok(guard) => guard,
223 Err(poisoned) => poisoned.into_inner(),
224 };
225 if let Some(cached) = cache.get(pattern) {
227 return cached.clone();
228 }
229 cache.insert(pattern.to_string(), compiled.clone());
230 compiled
231 }
232
233 fn require_regex(&self, pattern: &str) -> Result<Regex, QueryError> {
235 self.get_or_compile_regex(pattern)
236 .ok_or_else(|| QueryError::Type(format!("invalid regex: {pattern}")))
237 }
238
239 pub fn set_target_currency(&mut self, currency: impl Into<String>) {
241 self.target_currency = Some(currency.into());
242 }
243
244 pub fn execute(&mut self, query: &Query) -> Result<QueryResult, QueryError> {
257 match query {
258 Query::Select(select) => self.execute_select(select),
259 Query::Journal(journal) => self.execute_journal(journal),
260 Query::Balances(balances) => self.execute_balances(balances),
261 Query::Print(print) => self.execute_print(print),
262 Query::CreateTable(create) => self.execute_create_table(create),
263 Query::Insert(insert) => self.execute_insert(insert),
264 }
265 }
266
267 fn build_balances_with_filter(&mut self, from: Option<&FromClause>) -> Result<(), QueryError> {
269 for directive in self.directives {
270 if let Directive::Transaction(txn) = directive {
271 if let Some(from_clause) = from
273 && let Some(filter) = &from_clause.filter
274 && !self.evaluate_from_filter(filter, txn)?
275 {
276 continue;
277 }
278
279 for posting in &txn.postings {
280 if let Some(units) = posting.amount() {
281 let balance = self.balances.entry(posting.account.clone()).or_default();
282
283 let pos = if let Some(cost_spec) = &posting.cost {
284 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
285 Position::with_cost(units.clone(), cost)
286 } else {
287 Position::simple(units.clone())
288 }
289 } else {
290 Position::simple(units.clone())
291 };
292 balance.add(pos);
293 }
294 }
295 }
296 }
297 Ok(())
298 }
299
300 fn collect_postings(
302 &self,
303 from: Option<&FromClause>,
304 where_clause: Option<&Expr>,
305 ) -> Result<Vec<PostingContext<'a>>, QueryError> {
306 let mut postings = Vec::new();
307 let mut running_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
309
310 let directive_iter: Vec<(usize, &Directive)> =
313 if let Some(spanned) = self.spanned_directives {
314 spanned
315 .iter()
316 .enumerate()
317 .map(|(i, s)| (i, &s.value))
318 .collect()
319 } else {
320 self.directives.iter().enumerate().collect()
321 };
322
323 for (directive_index, directive) in directive_iter {
324 if let Directive::Transaction(txn) = directive {
325 if let Some(from) = from {
327 if let Some(open_date) = from.open_on
329 && txn.date < open_date
330 {
331 for posting in &txn.postings {
333 if let Some(units) = posting.amount() {
334 let balance =
335 running_balances.entry(posting.account.clone()).or_default();
336 balance.add(Position::simple(units.clone()));
337 }
338 }
339 continue;
340 }
341 if let Some(close_date) = from.close_on
342 && txn.date > close_date
343 {
344 continue;
345 }
346 if let Some(filter) = &from.filter
348 && !self.evaluate_from_filter(filter, txn)?
349 {
350 continue;
351 }
352 }
353
354 for (i, posting) in txn.postings.iter().enumerate() {
356 if let Some(units) = posting.amount() {
358 let balance = running_balances.entry(posting.account.clone()).or_default();
359 balance.add(Position::simple(units.clone()));
360 }
361
362 let ctx = PostingContext {
363 transaction: txn,
364 posting_index: i,
365 balance: running_balances.get(&posting.account).cloned(),
366 directive_index: Some(directive_index),
367 };
368
369 if let Some(where_expr) = where_clause {
371 if self.evaluate_predicate(where_expr, &ctx)? {
372 postings.push(ctx);
373 }
374 } else {
375 postings.push(ctx);
376 }
377 }
378 }
379 }
380
381 Ok(postings)
382 }
383 fn evaluate_function(
384 &self,
385 func: &FunctionCall,
386 ctx: &PostingContext,
387 ) -> Result<Value, QueryError> {
388 let name = func.name.to_uppercase();
389 match name.as_str() {
390 "YEAR" | "MONTH" | "DAY" | "WEEKDAY" | "QUARTER" | "YMONTH" | "TODAY" => {
392 self.eval_date_function(&name, func, ctx)
393 }
394 "DATE" | "DATE_DIFF" | "DATE_ADD" | "DATE_TRUNC" | "DATE_PART" | "PARSE_DATE"
396 | "DATE_BIN" | "INTERVAL" => self.eval_extended_date_function(&name, func, ctx),
397 "LENGTH" | "UPPER" | "LOWER" | "SUBSTR" | "SUBSTRING" | "TRIM" | "STARTSWITH"
399 | "ENDSWITH" | "GREP" | "GREPN" | "SUBST" | "SPLITCOMP" | "JOINSTR" | "MAXWIDTH" => {
400 self.eval_string_function(&name, func, ctx)
401 }
402 "PARENT" | "LEAF" | "ROOT" | "ACCOUNT_DEPTH" | "ACCOUNT_SORTKEY" => {
404 self.eval_account_function(&name, func, ctx)
405 }
406 "OPEN_DATE" | "CLOSE_DATE" | "OPEN_META" => {
408 self.eval_account_meta_function(&name, func, ctx)
409 }
410 "ABS" | "NEG" | "ROUND" | "SAFEDIV" => self.eval_math_function(&name, func, ctx),
412 "NUMBER" | "CURRENCY" | "GETITEM" | "GET" | "UNITS" | "COST" | "WEIGHT" | "VALUE" => {
414 self.eval_position_function(&name, func, ctx)
415 }
416 "EMPTY" | "FILTER_CURRENCY" | "POSSIGN" => {
418 self.eval_inventory_function(&name, func, ctx)
419 }
420 "GETPRICE" => self.eval_getprice(func, ctx),
422 "COALESCE" => self.eval_coalesce(func, ctx),
424 "ONLY" => self.eval_only(func, ctx),
425 "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
427 self.eval_meta_function(&name, func, ctx)
428 }
429 "CONVERT" => self.eval_convert(func, ctx),
431 "INT" => self.eval_int(func, ctx),
433 "DECIMAL" => self.eval_decimal(func, ctx),
434 "STR" => self.eval_str(func, ctx),
435 "BOOL" => self.eval_bool(func, ctx),
436 "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
439 _ => Err(QueryError::UnknownFunction(func.name.clone())),
440 }
441 }
442
443 fn evaluate_function_on_values(&self, name: &str, args: &[Value]) -> Result<Value, QueryError> {
445 let name_upper = name.to_uppercase();
446 match name_upper.as_str() {
447 "TODAY" => Ok(Value::Date(chrono::Local::now().date_naive())),
449 "YEAR" => {
450 Self::require_args_count(&name_upper, args, 1)?;
451 match &args[0] {
452 Value::Date(d) => Ok(Value::Integer(d.year().into())),
453 _ => Err(QueryError::Type("YEAR expects a date".to_string())),
454 }
455 }
456 "MONTH" => {
457 Self::require_args_count(&name_upper, args, 1)?;
458 match &args[0] {
459 Value::Date(d) => Ok(Value::Integer(d.month().into())),
460 _ => Err(QueryError::Type("MONTH expects a date".to_string())),
461 }
462 }
463 "DAY" => {
464 Self::require_args_count(&name_upper, args, 1)?;
465 match &args[0] {
466 Value::Date(d) => Ok(Value::Integer(d.day().into())),
467 _ => Err(QueryError::Type("DAY expects a date".to_string())),
468 }
469 }
470 "LENGTH" => {
472 Self::require_args_count(&name_upper, args, 1)?;
473 match &args[0] {
474 Value::String(s) => Ok(Value::Integer(s.len() as i64)),
475 _ => Err(QueryError::Type("LENGTH expects a string".to_string())),
476 }
477 }
478 "UPPER" => {
479 Self::require_args_count(&name_upper, args, 1)?;
480 match &args[0] {
481 Value::String(s) => Ok(Value::String(s.to_uppercase())),
482 _ => Err(QueryError::Type("UPPER expects a string".to_string())),
483 }
484 }
485 "LOWER" => {
486 Self::require_args_count(&name_upper, args, 1)?;
487 match &args[0] {
488 Value::String(s) => Ok(Value::String(s.to_lowercase())),
489 _ => Err(QueryError::Type("LOWER expects a string".to_string())),
490 }
491 }
492 "TRIM" => {
493 Self::require_args_count(&name_upper, args, 1)?;
494 match &args[0] {
495 Value::String(s) => Ok(Value::String(s.trim().to_string())),
496 _ => Err(QueryError::Type("TRIM expects a string".to_string())),
497 }
498 }
499 "ABS" => {
501 Self::require_args_count(&name_upper, args, 1)?;
502 match &args[0] {
503 Value::Number(n) => Ok(Value::Number(n.abs())),
504 Value::Integer(i) => Ok(Value::Integer(i.abs())),
505 _ => Err(QueryError::Type("ABS expects a number".to_string())),
506 }
507 }
508 "ROUND" => {
509 if args.is_empty() || args.len() > 2 {
510 return Err(QueryError::InvalidArguments(
511 "ROUND".to_string(),
512 "expected 1 or 2 arguments".to_string(),
513 ));
514 }
515 match &args[0] {
516 Value::Number(n) => {
517 let scale = if args.len() == 2 {
518 match &args[1] {
519 Value::Integer(i) => *i as u32,
520 _ => 0,
521 }
522 } else {
523 0
524 };
525 Ok(Value::Number(n.round_dp(scale)))
526 }
527 Value::Integer(i) => Ok(Value::Integer(*i)),
528 _ => Err(QueryError::Type("ROUND expects a number".to_string())),
529 }
530 }
531 "COALESCE" => {
533 for arg in args {
534 if !matches!(arg, Value::Null) {
535 return Ok(arg.clone());
536 }
537 }
538 Ok(Value::Null)
539 }
540 "NUMBER" => {
542 Self::require_args_count(&name_upper, args, 1)?;
543 match &args[0] {
544 Value::Amount(a) => Ok(Value::Number(a.number)),
545 Value::Position(p) => Ok(Value::Number(p.units.number)),
546 Value::Number(n) => Ok(Value::Number(*n)),
547 Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
548 Value::Inventory(inv) => {
549 let positions = inv.positions();
552 if positions.is_empty() {
553 return Ok(Value::Number(Decimal::ZERO));
554 }
555 let first_currency = &positions[0].units.currency;
556 let all_same_currency = positions
557 .iter()
558 .all(|p| &p.units.currency == first_currency);
559 if all_same_currency {
560 let total: Decimal = positions.iter().map(|p| p.units.number).sum();
561 Ok(Value::Number(total))
562 } else {
563 Ok(Value::Null)
565 }
566 }
567 Value::Null => Ok(Value::Null),
568 _ => Err(QueryError::Type(
569 "NUMBER expects an amount, position, or inventory".to_string(),
570 )),
571 }
572 }
573 "CURRENCY" => {
574 Self::require_args_count(&name_upper, args, 1)?;
575 match &args[0] {
576 Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
577 Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
578 Value::Inventory(inv) => {
579 if let Some(pos) = inv.positions().first() {
581 Ok(Value::String(pos.units.currency.to_string()))
582 } else {
583 Ok(Value::Null)
584 }
585 }
586 Value::Null => Ok(Value::Null),
587 _ => Err(QueryError::Type(
588 "CURRENCY expects an amount or position".to_string(),
589 )),
590 }
591 }
592 "UNITS" => {
593 Self::require_args_count(&name_upper, args, 1)?;
594 match &args[0] {
595 Value::Position(p) => Ok(Value::Amount(p.units.clone())),
596 Value::Amount(a) => Ok(Value::Amount(a.clone())),
597 Value::Inventory(inv) => {
598 let mut units_inv = Inventory::new();
600 for pos in inv.positions() {
601 units_inv.add(Position::simple(pos.units.clone()));
602 }
603 Ok(Value::Inventory(Box::new(units_inv)))
604 }
605 Value::Null => Ok(Value::Null),
606 _ => Err(QueryError::Type(
607 "UNITS expects a position or inventory".to_string(),
608 )),
609 }
610 }
611 "COST" => {
612 Self::require_args_count(&name_upper, args, 1)?;
613 match &args[0] {
614 Value::Position(p) => {
615 if let Some(cost) = &p.cost {
616 let total = p.units.number * cost.number;
619 Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
620 } else {
621 Ok(Value::Null)
622 }
623 }
624 Value::Amount(a) => Ok(Value::Amount(a.clone())),
625 Value::Inventory(inv) => {
626 let mut total = Decimal::ZERO;
627 let mut currency: Option<InternedStr> = None;
628 for pos in inv.positions() {
629 if let Some(cost) = &pos.cost {
630 total += pos.units.number * cost.number;
632 if currency.is_none() {
633 currency = Some(cost.currency.clone());
634 }
635 }
636 }
637 if let Some(curr) = currency {
638 Ok(Value::Amount(Amount::new(total, curr)))
639 } else {
640 Ok(Value::Null)
641 }
642 }
643 Value::Null => Ok(Value::Null),
644 _ => Err(QueryError::Type(
645 "COST expects a position or inventory".to_string(),
646 )),
647 }
648 }
649 "VALUE" => {
650 if args.is_empty() || args.len() > 2 {
652 return Err(QueryError::InvalidArguments(
653 "VALUE".to_string(),
654 "expected 1-2 arguments".to_string(),
655 ));
656 }
657 let explicit_currency = if args.len() == 2 {
658 match &args[1] {
659 Value::String(s) => Some(s.as_str()),
660 _ => None,
661 }
662 } else {
663 None
664 };
665 self.convert_to_market_value(&args[0], explicit_currency)
666 }
667 "SAFEDIV" => {
669 Self::require_args_count(&name_upper, args, 2)?;
670 let (dividend, divisor) = (&args[0], &args[1]);
671 match (dividend, divisor) {
672 (Value::Number(a), Value::Number(b)) => {
673 if b.is_zero() {
674 Ok(Value::Null)
675 } else {
676 Ok(Value::Number(a / b))
677 }
678 }
679 (Value::Integer(a), Value::Integer(b)) => {
680 if *b == 0 {
681 Ok(Value::Null)
682 } else {
683 Ok(Value::Number(Decimal::from(*a) / Decimal::from(*b)))
684 }
685 }
686 (Value::Number(a), Value::Integer(b)) => {
687 if *b == 0 {
688 Ok(Value::Null)
689 } else {
690 Ok(Value::Number(a / Decimal::from(*b)))
691 }
692 }
693 (Value::Integer(a), Value::Number(b)) => {
694 if b.is_zero() {
695 Ok(Value::Null)
696 } else {
697 Ok(Value::Number(Decimal::from(*a) / b))
698 }
699 }
700 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
701 _ => Err(QueryError::Type(
702 "SAFEDIV expects numeric arguments".to_string(),
703 )),
704 }
705 }
706 "NEG" => {
707 Self::require_args_count(&name_upper, args, 1)?;
708 match &args[0] {
709 Value::Number(n) => Ok(Value::Number(-n)),
710 Value::Integer(i) => Ok(Value::Integer(-i)),
711 Value::Amount(a) => {
712 Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
713 }
714 _ => Err(QueryError::Type(
715 "NEG expects a number or amount".to_string(),
716 )),
717 }
718 }
719 "ACCOUNT_SORTKEY" => {
721 Self::require_args_count(&name_upper, args, 1)?;
722 match &args[0] {
723 Value::String(s) => {
724 let type_index = Self::account_type_index(s);
725 Ok(Value::String(format!("{type_index}-{s}")))
726 }
727 _ => Err(QueryError::Type(
728 "ACCOUNT_SORTKEY expects an account string".to_string(),
729 )),
730 }
731 }
732 "PARENT" => {
733 Self::require_args_count(&name_upper, args, 1)?;
734 match &args[0] {
735 Value::String(s) => {
736 if let Some(idx) = s.rfind(':') {
737 Ok(Value::String(s[..idx].to_string()))
738 } else {
739 Ok(Value::Null)
740 }
741 }
742 _ => Err(QueryError::Type(
743 "PARENT expects an account string".to_string(),
744 )),
745 }
746 }
747 "LEAF" => {
748 Self::require_args_count(&name_upper, args, 1)?;
749 match &args[0] {
750 Value::String(s) => {
751 if let Some(idx) = s.rfind(':') {
752 Ok(Value::String(s[idx + 1..].to_string()))
753 } else {
754 Ok(Value::String(s.clone()))
755 }
756 }
757 _ => Err(QueryError::Type(
758 "LEAF expects an account string".to_string(),
759 )),
760 }
761 }
762 "ROOT" => {
763 if args.is_empty() || args.len() > 2 {
764 return Err(QueryError::InvalidArguments(
765 "ROOT".to_string(),
766 "expected 1 or 2 arguments".to_string(),
767 ));
768 }
769 let n = if args.len() == 2 {
770 match &args[1] {
771 Value::Integer(i) => *i as usize,
772 _ => 1,
773 }
774 } else {
775 1
776 };
777 match &args[0] {
778 Value::String(s) => {
779 let parts: Vec<&str> = s.split(':').collect();
780 if n >= parts.len() {
781 Ok(Value::String(s.clone()))
782 } else {
783 Ok(Value::String(parts[..n].join(":")))
784 }
785 }
786 _ => Err(QueryError::Type(
787 "ROOT expects an account string".to_string(),
788 )),
789 }
790 }
791 "ONLY" => {
793 Self::require_args_count(&name_upper, args, 2)?;
794 let currency = match &args[0] {
795 Value::String(s) => s.clone(),
796 _ => {
797 return Err(QueryError::Type(
798 "ONLY: first argument must be a currency string".to_string(),
799 ));
800 }
801 };
802 match &args[1] {
803 Value::Inventory(inv) => {
804 let total = inv.units(¤cy);
805 if total.is_zero() {
806 Ok(Value::Null)
807 } else {
808 Ok(Value::Amount(Amount::new(total, ¤cy)))
809 }
810 }
811 Value::Position(p) => {
812 if p.units.currency.as_str() == currency {
813 Ok(Value::Amount(p.units.clone()))
814 } else {
815 Ok(Value::Null)
816 }
817 }
818 Value::Amount(a) => {
819 if a.currency.as_str() == currency {
820 Ok(Value::Amount(a.clone()))
821 } else {
822 Ok(Value::Null)
823 }
824 }
825 Value::Null => Ok(Value::Null),
826 _ => Err(QueryError::Type(
827 "ONLY: second argument must be an inventory, position, or amount"
828 .to_string(),
829 )),
830 }
831 }
832 "GETPRICE" => {
834 if args.len() < 2 || args.len() > 3 {
835 return Err(QueryError::InvalidArguments(
836 "GETPRICE".to_string(),
837 "expected 2 or 3 arguments".to_string(),
838 ));
839 }
840 let base = match &args[0] {
842 Value::String(s) => s.clone(),
843 Value::Null => return Ok(Value::Null),
844 _ => {
845 return Err(QueryError::Type(
846 "GETPRICE: first argument must be a currency string".to_string(),
847 ));
848 }
849 };
850 let quote = match &args[1] {
851 Value::String(s) => s.clone(),
852 Value::Null => return Ok(Value::Null),
853 _ => {
854 return Err(QueryError::Type(
855 "GETPRICE: second argument must be a currency string".to_string(),
856 ));
857 }
858 };
859 let date = if args.len() == 3 {
860 match &args[2] {
861 Value::Date(d) => *d,
862 Value::Null => self.query_date,
863 _ => self.query_date,
864 }
865 } else {
866 self.query_date
867 };
868 match self.price_db.get_price(&base, "e, date) {
869 Some(price) => Ok(Value::Number(price)),
870 None => Ok(Value::Null),
871 }
872 }
873 "EMPTY" => {
875 Self::require_args_count(&name_upper, args, 1)?;
876 match &args[0] {
877 Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
878 Value::Null => Ok(Value::Boolean(true)),
879 _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
880 }
881 }
882 "FILTER_CURRENCY" => {
883 Self::require_args_count(&name_upper, args, 2)?;
884 let currency = match &args[1] {
885 Value::String(s) => s.clone(),
886 _ => {
887 return Err(QueryError::Type(
888 "FILTER_CURRENCY expects (inventory, string)".to_string(),
889 ));
890 }
891 };
892 match &args[0] {
893 Value::Inventory(inv) => {
894 let filtered: Vec<Position> = inv
895 .positions()
896 .iter()
897 .filter(|p| p.units.currency.as_str() == currency)
898 .cloned()
899 .collect();
900 let mut new_inv = Inventory::new();
901 for pos in filtered {
902 new_inv.add(pos);
903 }
904 Ok(Value::Inventory(Box::new(new_inv)))
905 }
906 Value::Null => Ok(Value::Null),
907 _ => Err(QueryError::Type(
908 "FILTER_CURRENCY expects (inventory, string)".to_string(),
909 )),
910 }
911 }
912 "POSSIGN" => {
913 Self::require_args_count(&name_upper, args, 2)?;
914 let account_str = match &args[1] {
915 Value::String(s) => s.clone(),
916 _ => {
917 return Err(QueryError::Type(
918 "POSSIGN expects (amount, account_string)".to_string(),
919 ));
920 }
921 };
922 let first_component = account_str.split(':').next().unwrap_or("");
923 let is_credit_normal =
924 matches!(first_component, "Liabilities" | "Equity" | "Income");
925 match &args[0] {
926 Value::Amount(a) => {
927 let mut amt = a.clone();
928 if is_credit_normal {
929 amt.number = -amt.number;
930 }
931 Ok(Value::Amount(amt))
932 }
933 Value::Number(n) => {
934 let adjusted = if is_credit_normal { -n } else { *n };
935 Ok(Value::Number(adjusted))
936 }
937 Value::Null => Ok(Value::Null),
938 _ => Err(QueryError::Type(
939 "POSSIGN expects (amount, account_string)".to_string(),
940 )),
941 }
942 }
943 "CONVERT" => {
945 if args.len() < 2 || args.len() > 3 {
946 return Err(QueryError::InvalidArguments(
947 "CONVERT".to_string(),
948 "expected 2 or 3 arguments: (value, currency[, date])".to_string(),
949 ));
950 }
951
952 let target_currency = match &args[1] {
953 Value::String(s) => s.clone(),
954 _ => {
955 return Err(QueryError::Type(
956 "CONVERT: second argument must be a currency string".to_string(),
957 ));
958 }
959 };
960
961 let date: Option<chrono::NaiveDate> = if args.len() == 3 {
963 match &args[2] {
964 Value::Date(d) => Some(*d),
965 Value::Null => None, _ => {
967 return Err(QueryError::Type(
968 "CONVERT: third argument must be a date".to_string(),
969 ));
970 }
971 }
972 } else {
973 None
974 };
975
976 let convert_amount = |amt: &Amount| -> Option<Amount> {
978 if let Some(d) = date {
979 self.price_db.convert(amt, &target_currency, d)
980 } else {
981 self.price_db.convert_latest(amt, &target_currency)
982 }
983 };
984
985 match &args[0] {
986 Value::Position(p) => {
987 if p.units.currency == target_currency {
988 Ok(Value::Amount(p.units.clone()))
989 } else if let Some(converted) = convert_amount(&p.units) {
990 Ok(Value::Amount(converted))
991 } else {
992 Ok(Value::Amount(p.units.clone()))
993 }
994 }
995 Value::Amount(a) => {
996 if a.currency == target_currency {
997 Ok(Value::Amount(a.clone()))
998 } else if let Some(converted) = convert_amount(a) {
999 Ok(Value::Amount(converted))
1000 } else {
1001 Ok(Value::Amount(a.clone()))
1002 }
1003 }
1004 Value::Inventory(inv) => {
1005 let mut result = Inventory::default();
1008 for pos in inv.positions() {
1009 if pos.units.currency == target_currency {
1010 result.add(Position::simple(pos.units.clone()));
1011 } else if let Some(converted) = convert_amount(&pos.units) {
1012 result.add(Position::simple(converted));
1013 } else {
1014 result.add(Position::simple(pos.units.clone()));
1016 }
1017 }
1018 let positions = result.positions();
1021 if positions.is_empty() {
1022 Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1023 } else if positions.len() == 1
1024 && positions[0].units.currency == target_currency
1025 {
1026 Ok(Value::Amount(positions[0].units.clone()))
1027 } else {
1028 Ok(Value::Inventory(Box::new(result)))
1029 }
1030 }
1031 Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
1032 Value::Null => {
1033 Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1036 }
1037 _ => Err(QueryError::Type(
1038 "CONVERT expects a position, amount, inventory, or number".to_string(),
1039 )),
1040 }
1041 }
1042 "STR" => {
1044 Self::require_args_count(&name_upper, args, 1)?;
1045 Self::value_to_str(&args[0])
1046 }
1047 "INT" => {
1048 Self::require_args_count(&name_upper, args, 1)?;
1049 Self::value_to_int(&args[0])
1050 }
1051 "DECIMAL" => {
1052 Self::require_args_count(&name_upper, args, 1)?;
1053 Self::value_to_decimal(&args[0])
1054 }
1055 "BOOL" => {
1056 Self::require_args_count(&name_upper, args, 1)?;
1057 Self::value_to_bool(&args[0])
1058 }
1059 "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1061 _ => Err(QueryError::UnknownFunction(name.to_string())),
1062 }
1063 }
1064
1065 fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1067 if args.len() != expected {
1068 return Err(QueryError::InvalidArguments(
1069 name.to_string(),
1070 format!("expected {} argument(s), got {}", expected, args.len()),
1071 ));
1072 }
1073 Ok(())
1074 }
1075
1076 fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1078 if func.args.len() != expected {
1079 return Err(QueryError::InvalidArguments(
1080 name.to_string(),
1081 format!("expected {expected} argument(s)"),
1082 ));
1083 }
1084 Ok(())
1085 }
1086
1087 pub(crate) fn convert_to_market_value(
1108 &self,
1109 val: &Value,
1110 explicit_currency: Option<&str>,
1111 ) -> Result<Value, QueryError> {
1112 let target_currency = if let Some(currency) = explicit_currency {
1117 currency.to_string()
1118 } else {
1119 let inferred = match val {
1121 Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1122 Value::Inventory(inv) => inv
1123 .positions()
1124 .iter()
1125 .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1126 _ => None,
1127 };
1128
1129 match inferred.or_else(|| self.target_currency.clone()) {
1130 Some(c) => c,
1131 None => {
1132 return match val {
1135 Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1136 Value::Amount(a) => Ok(Value::Amount(a.clone())),
1137 Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1138 Value::Null => Ok(Value::Null),
1139 _ => Err(QueryError::Type(
1140 "VALUE expects a position, amount, or inventory".to_string(),
1141 )),
1142 };
1143 }
1144 }
1145 };
1146
1147 match val {
1150 Value::Position(p) => {
1151 if p.units.currency == target_currency {
1152 Ok(Value::Amount(p.units.clone()))
1153 } else if let Some(converted) =
1154 self.price_db.convert_latest(&p.units, &target_currency)
1155 {
1156 Ok(Value::Amount(converted))
1157 } else {
1158 Ok(Value::Amount(p.units.clone()))
1159 }
1160 }
1161 Value::Amount(a) => {
1162 if a.currency == target_currency {
1163 Ok(Value::Amount(a.clone()))
1164 } else if let Some(converted) = self.price_db.convert_latest(a, &target_currency) {
1165 Ok(Value::Amount(converted))
1166 } else {
1167 Ok(Value::Amount(a.clone()))
1168 }
1169 }
1170 Value::Inventory(inv) => {
1171 let mut total = Decimal::ZERO;
1172 for pos in inv.positions() {
1173 if pos.units.currency == target_currency {
1174 total += pos.units.number;
1175 } else if let Some(converted) =
1176 self.price_db.convert_latest(&pos.units, &target_currency)
1177 {
1178 total += converted.number;
1179 }
1180 }
1181 Ok(Value::Amount(Amount::new(total, &target_currency)))
1182 }
1183 Value::Null => Ok(Value::Null),
1184 _ => Err(QueryError::Type(
1185 "VALUE expects a position, amount, or inventory".to_string(),
1186 )),
1187 }
1188 }
1189
1190 pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1192 matches!(expr, Expr::Window(_))
1193 }
1194
1195 fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1197 let mut names = Vec::new();
1198 for (i, target) in targets.iter().enumerate() {
1199 if matches!(target.expr, Expr::Wildcard) {
1200 if target.alias.is_some() {
1202 return Err(QueryError::Evaluation(
1203 "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1204 ));
1205 }
1206 names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1208 } else if let Some(alias) = &target.alias {
1209 names.push(alias.clone());
1210 } else {
1211 names.push(self.expr_to_name(&target.expr, i));
1212 }
1213 }
1214 Ok(names)
1215 }
1216
1217 fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1219 match expr {
1220 Expr::Wildcard => "*".to_string(),
1221 Expr::Column(name) => name.clone(),
1222 Expr::Function(func) => func.name.clone(),
1223 Expr::Window(wf) => wf.name.clone(),
1224 _ => format!("col{index}"),
1225 }
1226 }
1227
1228 pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1247 let upper = table_name.to_uppercase();
1251 let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1252
1253 match normalized {
1254 "PRICES" => Some(self.build_prices_table()),
1255 "BALANCES" => Some(self.build_balances_table()),
1256 "COMMODITIES" => Some(self.build_commodities_table()),
1257 "EVENTS" => Some(self.build_events_table()),
1258 "NOTES" => Some(self.build_notes_table()),
1259 "DOCUMENTS" => Some(self.build_documents_table()),
1260 "ACCOUNTS" => Some(self.build_accounts_table()),
1261 "TRANSACTIONS" => Some(self.build_transactions_table()),
1262 "ENTRIES" => Some(self.build_entries_table()),
1263 "POSTINGS" => Some(self.build_postings_table()),
1264 _ => None,
1265 }
1266 }
1267
1268 fn build_prices_table(&self) -> Table {
1275 let columns = vec![
1276 "date".to_string(),
1277 "currency".to_string(),
1278 "amount".to_string(),
1279 ];
1280 let mut table = Table::new(columns);
1281
1282 let mut entries: Vec<_> = self.price_db.iter_entries().collect();
1284 entries.sort_by(|(currency_a, date_a, _, _), (currency_b, date_b, _, _)| {
1286 date_a.cmp(date_b).then_with(|| currency_a.cmp(currency_b))
1287 });
1288
1289 for (base_currency, date, price_number, quote_currency) in entries {
1290 let row = vec![
1291 Value::Date(date),
1292 Value::String(base_currency.to_string()),
1293 Value::Amount(Amount::new(price_number, quote_currency)),
1294 ];
1295 table.add_row(row);
1296 }
1297
1298 table
1299 }
1300
1301 fn build_balances_table(&self) -> Table {
1308 let columns = vec![
1309 "date".to_string(),
1310 "account".to_string(),
1311 "amount".to_string(),
1312 ];
1313 let mut table = Table::new(columns);
1314
1315 let mut balances: Vec<_> = if let Some(spanned) = self.spanned_directives {
1317 spanned
1318 .iter()
1319 .filter_map(|s| {
1320 if let Directive::Balance(b) = &s.value {
1321 Some((b.date, b.account.as_ref(), b.amount.clone()))
1322 } else {
1323 None
1324 }
1325 })
1326 .collect()
1327 } else {
1328 self.directives
1329 .iter()
1330 .filter_map(|d| {
1331 if let Directive::Balance(b) = d {
1332 Some((b.date, b.account.as_ref(), b.amount.clone()))
1333 } else {
1334 None
1335 }
1336 })
1337 .collect()
1338 };
1339
1340 balances.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
1342 date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
1343 });
1344
1345 for (date, account, amount) in balances {
1346 let row = vec![
1347 Value::Date(date),
1348 Value::String(account.to_string()),
1349 Value::Amount(amount),
1350 ];
1351 table.add_row(row);
1352 }
1353
1354 table
1355 }
1356
1357 fn build_commodities_table(&self) -> Table {
1363 let columns = vec!["date".to_string(), "name".to_string()];
1364 let mut table = Table::new(columns);
1365
1366 let mut commodities: Vec<_> = if let Some(spanned) = self.spanned_directives {
1368 spanned
1369 .iter()
1370 .filter_map(|s| {
1371 if let Directive::Commodity(c) = &s.value {
1372 Some((c.date, c.currency.as_ref()))
1373 } else {
1374 None
1375 }
1376 })
1377 .collect()
1378 } else {
1379 self.directives
1380 .iter()
1381 .filter_map(|d| {
1382 if let Directive::Commodity(c) = d {
1383 Some((c.date, c.currency.as_ref()))
1384 } else {
1385 None
1386 }
1387 })
1388 .collect()
1389 };
1390
1391 commodities.sort_by(|(date_a, name_a), (date_b, name_b)| {
1393 date_a.cmp(date_b).then_with(|| name_a.cmp(name_b))
1394 });
1395
1396 for (date, name) in commodities {
1397 let row = vec![Value::Date(date), Value::String(name.to_string())];
1398 table.add_row(row);
1399 }
1400
1401 table
1402 }
1403
1404 fn build_events_table(&self) -> Table {
1411 let columns = vec![
1412 "date".to_string(),
1413 "type".to_string(),
1414 "description".to_string(),
1415 ];
1416 let mut table = Table::new(columns);
1417
1418 let mut events: Vec<_> = if let Some(spanned) = self.spanned_directives {
1420 spanned
1421 .iter()
1422 .filter_map(|s| {
1423 if let Directive::Event(e) = &s.value {
1424 Some((e.date, e.event_type.as_str(), e.value.as_str()))
1425 } else {
1426 None
1427 }
1428 })
1429 .collect()
1430 } else {
1431 self.directives
1432 .iter()
1433 .filter_map(|d| {
1434 if let Directive::Event(e) = d {
1435 Some((e.date, e.event_type.as_str(), e.value.as_str()))
1436 } else {
1437 None
1438 }
1439 })
1440 .collect()
1441 };
1442
1443 events.sort_by(|(date_a, type_a, _), (date_b, type_b, _)| {
1445 date_a.cmp(date_b).then_with(|| type_a.cmp(type_b))
1446 });
1447
1448 for (date, event_type, description) in events {
1449 let row = vec![
1450 Value::Date(date),
1451 Value::String(event_type.to_string()),
1452 Value::String(description.to_string()),
1453 ];
1454 table.add_row(row);
1455 }
1456
1457 table
1458 }
1459
1460 fn build_notes_table(&self) -> Table {
1467 let columns = vec![
1468 "date".to_string(),
1469 "account".to_string(),
1470 "comment".to_string(),
1471 ];
1472 let mut table = Table::new(columns);
1473
1474 let mut notes: Vec<_> = if let Some(spanned) = self.spanned_directives {
1476 spanned
1477 .iter()
1478 .filter_map(|s| {
1479 if let Directive::Note(n) = &s.value {
1480 Some((n.date, n.account.as_ref(), n.comment.as_str()))
1481 } else {
1482 None
1483 }
1484 })
1485 .collect()
1486 } else {
1487 self.directives
1488 .iter()
1489 .filter_map(|d| {
1490 if let Directive::Note(n) = d {
1491 Some((n.date, n.account.as_ref(), n.comment.as_str()))
1492 } else {
1493 None
1494 }
1495 })
1496 .collect()
1497 };
1498
1499 notes.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
1501 date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
1502 });
1503
1504 for (date, account, comment) in notes {
1505 let row = vec![
1506 Value::Date(date),
1507 Value::String(account.to_string()),
1508 Value::String(comment.to_string()),
1509 ];
1510 table.add_row(row);
1511 }
1512
1513 table
1514 }
1515
1516 fn build_documents_table(&self) -> Table {
1525 let columns = vec![
1526 "date".to_string(),
1527 "account".to_string(),
1528 "filename".to_string(),
1529 "tags".to_string(),
1530 "links".to_string(),
1531 ];
1532 let mut table = Table::new(columns);
1533
1534 let mut documents: Vec<_> = if let Some(spanned) = self.spanned_directives {
1536 spanned
1537 .iter()
1538 .filter_map(|s| {
1539 if let Directive::Document(d) = &s.value {
1540 Some((
1541 d.date,
1542 d.account.as_ref(),
1543 d.path.as_str(),
1544 &d.tags,
1545 &d.links,
1546 ))
1547 } else {
1548 None
1549 }
1550 })
1551 .collect()
1552 } else {
1553 self.directives
1554 .iter()
1555 .filter_map(|d| {
1556 if let Directive::Document(doc) = d {
1557 Some((
1558 doc.date,
1559 doc.account.as_ref(),
1560 doc.path.as_str(),
1561 &doc.tags,
1562 &doc.links,
1563 ))
1564 } else {
1565 None
1566 }
1567 })
1568 .collect()
1569 };
1570
1571 documents.sort_by(
1573 |(date_a, account_a, file_a, _, _), (date_b, account_b, file_b, _, _)| {
1574 date_a
1575 .cmp(date_b)
1576 .then_with(|| account_a.cmp(account_b))
1577 .then_with(|| file_a.cmp(file_b))
1578 },
1579 );
1580
1581 for (date, account, filename, tags, links) in documents {
1582 let tags_vec: Vec<String> = tags.iter().map(ToString::to_string).collect();
1583 let links_vec: Vec<String> = links.iter().map(ToString::to_string).collect();
1584 let row = vec![
1585 Value::Date(date),
1586 Value::String(account.to_string()),
1587 Value::String(filename.to_string()),
1588 Value::StringSet(tags_vec),
1589 Value::StringSet(links_vec),
1590 ];
1591 table.add_row(row);
1592 }
1593
1594 table
1595 }
1596
1597 fn build_accounts_table(&self) -> Table {
1606 let columns = vec![
1607 "account".to_string(),
1608 "open".to_string(),
1609 "close".to_string(),
1610 "currencies".to_string(),
1611 "booking".to_string(),
1612 ];
1613 let mut table = Table::new(columns);
1614
1615 let mut accounts: FxHashMap<
1617 &str,
1618 (
1619 Option<chrono::NaiveDate>,
1620 Option<chrono::NaiveDate>,
1621 Vec<String>,
1622 Option<&str>,
1623 ),
1624 > = FxHashMap::default();
1625
1626 let iter: Box<dyn Iterator<Item = &Directive>> =
1628 if let Some(spanned) = self.spanned_directives {
1629 Box::new(spanned.iter().map(|s| &s.value))
1630 } else {
1631 Box::new(self.directives.iter())
1632 };
1633
1634 for directive in iter {
1635 match directive {
1636 Directive::Open(open) => {
1637 let entry = accounts.entry(open.account.as_ref()).or_insert((
1638 None,
1639 None,
1640 Vec::new(),
1641 None,
1642 ));
1643 entry.0 = Some(open.date);
1644 entry.2 = open.currencies.iter().map(ToString::to_string).collect();
1645 entry.3 = open.booking.as_deref();
1646 }
1647 Directive::Close(close) => {
1648 let entry = accounts.entry(close.account.as_ref()).or_insert((
1649 None,
1650 None,
1651 Vec::new(),
1652 None,
1653 ));
1654 entry.1 = Some(close.date);
1655 }
1656 _ => {}
1657 }
1658 }
1659
1660 let mut account_list: Vec<_> = accounts.into_iter().collect();
1662 account_list.sort_by(|(a, _), (b, _)| a.cmp(b));
1663
1664 for (account, (open_date, close_date, currencies, booking)) in account_list {
1665 let row = vec![
1666 Value::String(account.to_string()),
1667 open_date.map_or(Value::Null, Value::Date),
1668 close_date.map_or(Value::Null, Value::Date),
1669 Value::StringSet(currencies),
1670 booking.map_or(Value::Null, |b| Value::String(b.to_string())),
1671 ];
1672 table.add_row(row);
1673 }
1674
1675 table
1676 }
1677
1678 fn build_transactions_table(&self) -> Table {
1689 let columns = vec![
1690 "date".to_string(),
1691 "flag".to_string(),
1692 "payee".to_string(),
1693 "narration".to_string(),
1694 "tags".to_string(),
1695 "links".to_string(),
1696 "accounts".to_string(),
1697 ];
1698 let mut table = Table::new(columns);
1699
1700 let iter: Box<dyn Iterator<Item = &Directive>> =
1702 if let Some(spanned) = self.spanned_directives {
1703 Box::new(spanned.iter().map(|s| &s.value))
1704 } else {
1705 Box::new(self.directives.iter())
1706 };
1707
1708 let mut transactions: Vec<_> = iter
1709 .filter_map(|d| {
1710 if let Directive::Transaction(txn) = d {
1711 Some(txn)
1712 } else {
1713 None
1714 }
1715 })
1716 .collect();
1717
1718 transactions.sort_by_key(|t| t.date);
1720
1721 for txn in transactions {
1722 let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
1723 let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
1724 let mut accounts: Vec<String> = txn
1725 .postings
1726 .iter()
1727 .map(|p| p.account.to_string())
1728 .collect::<std::collections::HashSet<_>>()
1729 .into_iter()
1730 .collect();
1731 accounts.sort(); let row = vec![
1734 Value::Date(txn.date),
1735 Value::String(txn.flag.to_string()),
1736 txn.payee
1737 .as_ref()
1738 .map_or(Value::Null, |p| Value::String(p.to_string())),
1739 Value::String(txn.narration.to_string()),
1740 Value::StringSet(tags),
1741 Value::StringSet(links),
1742 Value::StringSet(accounts),
1743 ];
1744 table.add_row(row);
1745 }
1746
1747 table
1748 }
1749
1750 fn build_entries_table(&self) -> Table {
1755 let columns = vec![
1756 "id".to_string(),
1757 "type".to_string(),
1758 "filename".to_string(),
1759 "lineno".to_string(),
1760 "date".to_string(),
1761 "flag".to_string(),
1762 "payee".to_string(),
1763 "narration".to_string(),
1764 "tags".to_string(),
1765 "links".to_string(),
1766 "accounts".to_string(),
1767 ];
1768 let mut table = Table::new(columns);
1769
1770 if let Some(spanned) = self.spanned_directives {
1772 for (idx, spanned_dir) in spanned.iter().enumerate() {
1773 let directive = &spanned_dir.value;
1774 let source_loc = self.get_source_location(idx);
1775 let row = self.directive_to_entry_row(idx, directive, source_loc);
1776 table.add_row(row);
1777 }
1778 } else {
1779 for (idx, directive) in self.directives.iter().enumerate() {
1780 let row = self.directive_to_entry_row(idx, directive, None);
1781 table.add_row(row);
1782 }
1783 }
1784
1785 table
1786 }
1787
1788 fn directive_to_entry_row(
1790 &self,
1791 idx: usize,
1792 directive: &Directive,
1793 source_loc: Option<&SourceLocation>,
1794 ) -> Vec<Value> {
1795 let type_name = match directive {
1796 Directive::Transaction(_) => "Transaction",
1797 Directive::Balance(_) => "Balance",
1798 Directive::Open(_) => "Open",
1799 Directive::Close(_) => "Close",
1800 Directive::Commodity(_) => "Commodity",
1801 Directive::Pad(_) => "Pad",
1802 Directive::Event(_) => "Event",
1803 Directive::Query(_) => "Query",
1804 Directive::Note(_) => "Note",
1805 Directive::Document(_) => "Document",
1806 Directive::Price(_) => "Price",
1807 Directive::Custom(_) => "Custom",
1808 };
1809
1810 let date = match directive {
1811 Directive::Transaction(t) => Value::Date(t.date),
1812 Directive::Balance(b) => Value::Date(b.date),
1813 Directive::Open(o) => Value::Date(o.date),
1814 Directive::Close(c) => Value::Date(c.date),
1815 Directive::Commodity(c) => Value::Date(c.date),
1816 Directive::Pad(p) => Value::Date(p.date),
1817 Directive::Event(e) => Value::Date(e.date),
1818 Directive::Query(q) => Value::Date(q.date),
1819 Directive::Note(n) => Value::Date(n.date),
1820 Directive::Document(d) => Value::Date(d.date),
1821 Directive::Price(p) => Value::Date(p.date),
1822 Directive::Custom(c) => Value::Date(c.date),
1823 };
1824
1825 let (flag, payee, narration, tags, links, accounts) =
1826 if let Directive::Transaction(txn) = directive {
1827 let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
1828 let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
1829 let mut accounts: Vec<String> = txn
1830 .postings
1831 .iter()
1832 .map(|p| p.account.to_string())
1833 .collect::<std::collections::HashSet<_>>()
1834 .into_iter()
1835 .collect();
1836 accounts.sort(); (
1838 Value::String(txn.flag.to_string()),
1839 txn.payee
1840 .as_ref()
1841 .map_or(Value::Null, |p| Value::String(p.to_string())),
1842 Value::String(txn.narration.to_string()),
1843 Value::StringSet(tags),
1844 Value::StringSet(links),
1845 Value::StringSet(accounts),
1846 )
1847 } else {
1848 (
1849 Value::Null,
1850 Value::Null,
1851 Value::Null,
1852 Value::StringSet(vec![]),
1853 Value::StringSet(vec![]),
1854 Value::StringSet(vec![]),
1855 )
1856 };
1857
1858 let filename = source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
1859 let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
1860
1861 vec![
1862 Value::Integer(idx as i64), Value::String(type_name.to_string()),
1864 filename,
1865 lineno,
1866 date,
1867 flag,
1868 payee,
1869 narration,
1870 tags,
1871 links,
1872 accounts,
1873 ]
1874 }
1875
1876 fn build_postings_table(&self) -> Table {
1885 let columns = vec![
1886 "date".to_string(),
1887 "flag".to_string(),
1888 "payee".to_string(),
1889 "narration".to_string(),
1890 "account".to_string(),
1891 "number".to_string(),
1892 "currency".to_string(),
1893 "cost_number".to_string(),
1894 "cost_currency".to_string(),
1895 "cost_date".to_string(),
1896 "cost_label".to_string(),
1897 "price".to_string(),
1898 "balance".to_string(),
1899 ];
1900 let mut table = Table::new(columns);
1901
1902 let mut running_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
1904
1905 let iter: Box<dyn Iterator<Item = &Directive>> =
1907 if let Some(spanned) = self.spanned_directives {
1908 Box::new(spanned.iter().map(|s| &s.value))
1909 } else {
1910 Box::new(self.directives.iter())
1911 };
1912
1913 let mut transactions: Vec<_> = iter
1915 .filter_map(|d| {
1916 if let Directive::Transaction(txn) = d {
1917 Some(txn)
1918 } else {
1919 None
1920 }
1921 })
1922 .collect();
1923 transactions.sort_by_key(|t| t.date);
1924
1925 for txn in transactions {
1926 for posting in &txn.postings {
1927 if let Some(units) = posting.amount() {
1929 let balance = running_balances.entry(posting.account.clone()).or_default();
1930 let pos = if let Some(cost_spec) = &posting.cost {
1931 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
1932 Position::with_cost(units.clone(), cost)
1933 } else {
1934 Position::simple(units.clone())
1935 }
1936 } else {
1937 Position::simple(units.clone())
1938 };
1939 balance.add(pos);
1940 }
1941
1942 let (number, currency) = posting.amount().map_or((Value::Null, Value::Null), |a| {
1944 (
1945 Value::Number(a.number),
1946 Value::String(a.currency.to_string()),
1947 )
1948 });
1949
1950 let (cost_number, cost_currency, cost_date, cost_label) = if let Some(cost_spec) =
1951 &posting.cost
1952 {
1953 let units = posting.amount();
1954 if let Some(cost) = units.and_then(|u| cost_spec.resolve(u.number, txn.date)) {
1955 (
1956 Value::Number(cost.number),
1957 Value::String(cost.currency.to_string()),
1958 cost.date.map_or(Value::Null, Value::Date),
1959 cost.label
1960 .as_ref()
1961 .map_or(Value::Null, |l| Value::String(l.clone())),
1962 )
1963 } else {
1964 (Value::Null, Value::Null, Value::Null, Value::Null)
1965 }
1966 } else {
1967 (Value::Null, Value::Null, Value::Null, Value::Null)
1968 };
1969
1970 let price_val = posting
1971 .price
1972 .as_ref()
1973 .and_then(|p| p.amount())
1974 .map_or(Value::Null, |a| Value::Amount(a.clone()));
1975
1976 let balance_val = running_balances
1977 .get(&posting.account)
1978 .map_or(Value::Null, |inv| Value::Inventory(Box::new(inv.clone())));
1979
1980 let row = vec![
1981 Value::Date(txn.date),
1982 Value::String(txn.flag.to_string()),
1983 txn.payee
1984 .as_ref()
1985 .map_or(Value::Null, |p| Value::String(p.to_string())),
1986 Value::String(txn.narration.to_string()),
1987 Value::String(posting.account.to_string()),
1988 number,
1989 currency,
1990 cost_number,
1991 cost_currency,
1992 cost_date,
1993 cost_label,
1994 price_val,
1995 balance_val,
1996 ];
1997 table.add_row(row);
1998 }
1999 }
2000
2001 table
2002 }
2003}
2004#[cfg(test)]
2005mod tests {
2006 use super::types::{hash_row, hash_single_value};
2007 use super::*;
2008 use crate::parse;
2009 use rust_decimal_macros::dec;
2010 use rustledger_core::Posting;
2011
2012 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
2013 NaiveDate::from_ymd_opt(year, month, day).unwrap()
2014 }
2015
2016 fn sample_directives() -> Vec<Directive> {
2017 vec![
2018 Directive::Transaction(
2019 Transaction::new(date(2024, 1, 15), "Coffee")
2020 .with_flag('*')
2021 .with_payee("Coffee Shop")
2022 .with_posting(Posting::new(
2023 "Expenses:Food:Coffee",
2024 Amount::new(dec!(5.00), "USD"),
2025 ))
2026 .with_posting(Posting::new(
2027 "Assets:Bank:Checking",
2028 Amount::new(dec!(-5.00), "USD"),
2029 )),
2030 ),
2031 Directive::Transaction(
2032 Transaction::new(date(2024, 1, 16), "Groceries")
2033 .with_flag('*')
2034 .with_payee("Supermarket")
2035 .with_posting(Posting::new(
2036 "Expenses:Food:Groceries",
2037 Amount::new(dec!(50.00), "USD"),
2038 ))
2039 .with_posting(Posting::new(
2040 "Assets:Bank:Checking",
2041 Amount::new(dec!(-50.00), "USD"),
2042 )),
2043 ),
2044 ]
2045 }
2046
2047 #[test]
2048 fn test_simple_select() {
2049 let directives = sample_directives();
2050 let mut executor = Executor::new(&directives);
2051
2052 let query = parse("SELECT date, account").unwrap();
2053 let result = executor.execute(&query).unwrap();
2054
2055 assert_eq!(result.columns, vec!["date", "account"]);
2056 assert_eq!(result.len(), 4); }
2058
2059 #[test]
2060 fn test_where_clause() {
2061 let directives = sample_directives();
2062 let mut executor = Executor::new(&directives);
2063
2064 let query = parse("SELECT account WHERE account ~ \"Expenses:\"").unwrap();
2065 let result = executor.execute(&query).unwrap();
2066
2067 assert_eq!(result.len(), 2); }
2069
2070 #[test]
2071 fn test_balances() {
2072 let directives = sample_directives();
2073 let mut executor = Executor::new(&directives);
2074
2075 let query = parse("BALANCES").unwrap();
2076 let result = executor.execute(&query).unwrap();
2077
2078 assert_eq!(result.columns, vec!["account", "balance"]);
2079 assert!(result.len() >= 3); }
2081
2082 #[test]
2083 fn test_account_functions() {
2084 let directives = sample_directives();
2085 let mut executor = Executor::new(&directives);
2086
2087 let query = parse("SELECT DISTINCT LEAF(account) WHERE account ~ \"Expenses:\"").unwrap();
2089 let result = executor.execute(&query).unwrap();
2090 assert_eq!(result.len(), 2); let query = parse("SELECT DISTINCT ROOT(account)").unwrap();
2094 let result = executor.execute(&query).unwrap();
2095 assert_eq!(result.len(), 2); let query = parse("SELECT DISTINCT PARENT(account) WHERE account ~ \"Expenses:\"").unwrap();
2099 let result = executor.execute(&query).unwrap();
2100 assert!(!result.is_empty()); }
2102
2103 #[test]
2104 fn test_min_max_aggregate() {
2105 let directives = sample_directives();
2106 let mut executor = Executor::new(&directives);
2107
2108 let query = parse("SELECT MIN(date)").unwrap();
2110 let result = executor.execute(&query).unwrap();
2111 assert_eq!(result.len(), 1);
2112 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
2113
2114 let query = parse("SELECT MAX(date)").unwrap();
2116 let result = executor.execute(&query).unwrap();
2117 assert_eq!(result.len(), 1);
2118 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2119 }
2120
2121 #[test]
2122 fn test_order_by() {
2123 let directives = sample_directives();
2124 let mut executor = Executor::new(&directives);
2125
2126 let query = parse("SELECT date, account ORDER BY date DESC").unwrap();
2127 let result = executor.execute(&query).unwrap();
2128
2129 assert_eq!(result.len(), 4);
2131 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2133 }
2134
2135 #[test]
2136 fn test_hash_value_all_variants() {
2137 use rustledger_core::{Cost, Inventory, Position};
2138
2139 let values = vec![
2141 Value::String("test".to_string()),
2142 Value::Number(dec!(123.45)),
2143 Value::Integer(42),
2144 Value::Date(date(2024, 1, 15)),
2145 Value::Boolean(true),
2146 Value::Boolean(false),
2147 Value::Amount(Amount::new(dec!(100), "USD")),
2148 Value::Position(Box::new(Position::simple(Amount::new(dec!(10), "AAPL")))),
2149 Value::Position(Box::new(Position::with_cost(
2150 Amount::new(dec!(10), "AAPL"),
2151 Cost::new(dec!(150), "USD"),
2152 ))),
2153 Value::Inventory(Box::new(Inventory::new())),
2154 Value::StringSet(vec!["tag1".to_string(), "tag2".to_string()]),
2155 Value::Null,
2156 ];
2157
2158 for value in &values {
2160 let hash = hash_single_value(value);
2161 assert!(hash != 0 || matches!(value, Value::Null));
2162 }
2163
2164 let hash1 = hash_single_value(&Value::String("a".to_string()));
2166 let hash2 = hash_single_value(&Value::String("b".to_string()));
2167 assert_ne!(hash1, hash2);
2168
2169 let hash3 = hash_single_value(&Value::Integer(42));
2171 let hash4 = hash_single_value(&Value::Integer(42));
2172 assert_eq!(hash3, hash4);
2173 }
2174
2175 #[test]
2176 fn test_hash_row_distinct() {
2177 let row1 = vec![Value::String("a".to_string()), Value::Integer(1)];
2179 let row2 = vec![Value::String("a".to_string()), Value::Integer(1)];
2180 let row3 = vec![Value::String("b".to_string()), Value::Integer(1)];
2181
2182 assert_eq!(hash_row(&row1), hash_row(&row2));
2183 assert_ne!(hash_row(&row1), hash_row(&row3));
2184 }
2185
2186 #[test]
2187 fn test_string_set_hash_order_independent() {
2188 let set1 = Value::StringSet(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2190 let set2 = Value::StringSet(vec!["c".to_string(), "a".to_string(), "b".to_string()]);
2191 let set3 = Value::StringSet(vec!["b".to_string(), "c".to_string(), "a".to_string()]);
2192
2193 let hash1 = hash_single_value(&set1);
2194 let hash2 = hash_single_value(&set2);
2195 let hash3 = hash_single_value(&set3);
2196
2197 assert_eq!(hash1, hash2);
2198 assert_eq!(hash2, hash3);
2199 }
2200
2201 #[test]
2202 fn test_inventory_hash_includes_cost() {
2203 use rustledger_core::{Cost, Inventory, Position};
2204
2205 let mut inv1 = Inventory::new();
2207 inv1.add(Position::with_cost(
2208 Amount::new(dec!(10), "AAPL"),
2209 Cost::new(dec!(100), "USD"),
2210 ));
2211
2212 let mut inv2 = Inventory::new();
2213 inv2.add(Position::with_cost(
2214 Amount::new(dec!(10), "AAPL"),
2215 Cost::new(dec!(200), "USD"),
2216 ));
2217
2218 let hash1 = hash_single_value(&Value::Inventory(Box::new(inv1)));
2219 let hash2 = hash_single_value(&Value::Inventory(Box::new(inv2)));
2220
2221 assert_ne!(hash1, hash2);
2222 }
2223
2224 #[test]
2225 fn test_distinct_deduplication() {
2226 let directives = sample_directives();
2227 let mut executor = Executor::new(&directives);
2228
2229 let query = parse("SELECT flag").unwrap();
2231 let result = executor.execute(&query).unwrap();
2232 assert_eq!(result.len(), 4); let query = parse("SELECT DISTINCT flag").unwrap();
2236 let result = executor.execute(&query).unwrap();
2237 assert_eq!(result.len(), 1); }
2239
2240 #[test]
2241 fn test_limit_clause() {
2242 let directives = sample_directives();
2243 let mut executor = Executor::new(&directives);
2244
2245 let query = parse("SELECT date, account LIMIT 2").unwrap();
2247 let result = executor.execute(&query).unwrap();
2248 assert_eq!(result.len(), 2);
2249
2250 let query = parse("SELECT date LIMIT 0").unwrap();
2252 let result = executor.execute(&query).unwrap();
2253 assert_eq!(result.len(), 0);
2254
2255 let query = parse("SELECT date LIMIT 100").unwrap();
2257 let result = executor.execute(&query).unwrap();
2258 assert_eq!(result.len(), 4);
2259 }
2260
2261 #[test]
2262 fn test_group_by_with_count() {
2263 let directives = sample_directives();
2264 let mut executor = Executor::new(&directives);
2265
2266 let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
2268 let result = executor.execute(&query).unwrap();
2269
2270 assert_eq!(result.columns.len(), 2);
2271 assert_eq!(result.len(), 2);
2273 }
2274
2275 #[test]
2276 fn test_count_aggregate() {
2277 let directives = sample_directives();
2278 let mut executor = Executor::new(&directives);
2279
2280 let query = parse("SELECT COUNT(account)").unwrap();
2282 let result = executor.execute(&query).unwrap();
2283
2284 assert_eq!(result.len(), 1);
2285 assert_eq!(result.rows[0][0], Value::Integer(4));
2286
2287 let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
2289 let result = executor.execute(&query).unwrap();
2290 assert_eq!(result.len(), 2); }
2292
2293 #[test]
2294 fn test_journal_query() {
2295 let directives = sample_directives();
2296 let mut executor = Executor::new(&directives);
2297
2298 let query = parse("JOURNAL \"Expenses\"").unwrap();
2300 let result = executor.execute(&query).unwrap();
2301
2302 assert!(result.columns.contains(&"account".to_string()));
2304 assert_eq!(result.len(), 2);
2306 }
2307
2308 #[test]
2309 fn test_print_query() {
2310 let directives = sample_directives();
2311 let mut executor = Executor::new(&directives);
2312
2313 let query = parse("PRINT").unwrap();
2315 let result = executor.execute(&query).unwrap();
2316
2317 assert_eq!(result.columns.len(), 1);
2319 assert_eq!(result.columns[0], "directive");
2320 assert_eq!(result.len(), 2);
2322 }
2323
2324 #[test]
2325 fn test_empty_directives() {
2326 let directives: Vec<Directive> = vec![];
2327 let mut executor = Executor::new(&directives);
2328
2329 let query = parse("SELECT date, account").unwrap();
2331 let result = executor.execute(&query).unwrap();
2332 assert!(result.is_empty());
2333
2334 let query = parse("BALANCES").unwrap();
2336 let result = executor.execute(&query).unwrap();
2337 assert!(result.is_empty());
2338 }
2339
2340 #[test]
2341 fn test_comparison_operators() {
2342 let directives = sample_directives();
2343 let mut executor = Executor::new(&directives);
2344
2345 let query = parse("SELECT date WHERE date < 2024-01-16").unwrap();
2347 let result = executor.execute(&query).unwrap();
2348 assert_eq!(result.len(), 2); let query = parse("SELECT date WHERE year > 2023").unwrap();
2352 let result = executor.execute(&query).unwrap();
2353 assert_eq!(result.len(), 4); let query = parse("SELECT account WHERE day = 15").unwrap();
2357 let result = executor.execute(&query).unwrap();
2358 assert_eq!(result.len(), 2); }
2360
2361 #[test]
2362 fn test_logical_operators() {
2363 let directives = sample_directives();
2364 let mut executor = Executor::new(&directives);
2365
2366 let query = parse("SELECT account WHERE account ~ \"Expenses\" AND day > 14").unwrap();
2368 let result = executor.execute(&query).unwrap();
2369 assert_eq!(result.len(), 2); let query = parse("SELECT account WHERE day = 15 OR day = 16").unwrap();
2373 let result = executor.execute(&query).unwrap();
2374 assert_eq!(result.len(), 4); }
2376
2377 #[test]
2378 fn test_arithmetic_expressions() {
2379 let directives = sample_directives();
2380 let mut executor = Executor::new(&directives);
2381
2382 let query = parse("SELECT -day WHERE day = 15").unwrap();
2384 let result = executor.execute(&query).unwrap();
2385 assert_eq!(result.len(), 2);
2386 for row in &result.rows {
2388 if let Value::Integer(n) = &row[0] {
2389 assert_eq!(*n, -15);
2390 }
2391 }
2392 }
2393
2394 #[test]
2395 fn test_first_last_aggregates() {
2396 let directives = sample_directives();
2397 let mut executor = Executor::new(&directives);
2398
2399 let query = parse("SELECT FIRST(date)").unwrap();
2401 let result = executor.execute(&query).unwrap();
2402 assert_eq!(result.len(), 1);
2403 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
2404
2405 let query = parse("SELECT LAST(date)").unwrap();
2407 let result = executor.execute(&query).unwrap();
2408 assert_eq!(result.len(), 1);
2409 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2410 }
2411
2412 #[test]
2413 fn test_wildcard_select() {
2414 let directives = sample_directives();
2415 let mut executor = Executor::new(&directives);
2416
2417 let query = parse("SELECT *").unwrap();
2419 let result = executor.execute(&query).unwrap();
2420
2421 assert_eq!(
2423 result.columns,
2424 vec!["date", "flag", "payee", "narration", "account", "position"]
2425 );
2426 assert_eq!(result.len(), 4);
2428 assert_eq!(result.rows[0].len(), 6);
2429 }
2430
2431 #[test]
2432 fn test_wildcard_alias_rejected() {
2433 let directives = sample_directives();
2434 let mut executor = Executor::new(&directives);
2435
2436 let query = parse("SELECT * AS data").unwrap();
2438 let result = executor.execute(&query);
2439
2440 assert!(result.is_err());
2441 let err = result.unwrap_err();
2442 assert!(
2443 err.to_string().contains("Cannot alias wildcard"),
2444 "Expected wildcard alias error, got: {err}"
2445 );
2446 }
2447
2448 #[test]
2449 fn test_query_result_methods() {
2450 let mut result = QueryResult::new(vec!["col1".to_string(), "col2".to_string()]);
2451
2452 assert!(result.is_empty());
2454 assert_eq!(result.len(), 0);
2455
2456 result.add_row(vec![Value::Integer(1), Value::String("a".to_string())]);
2458 assert!(!result.is_empty());
2459 assert_eq!(result.len(), 1);
2460
2461 result.add_row(vec![Value::Integer(2), Value::String("b".to_string())]);
2462 assert_eq!(result.len(), 2);
2463 }
2464
2465 #[test]
2466 fn test_type_cast_functions() {
2467 let directives = sample_directives();
2468 let mut executor = Executor::new(&directives);
2469
2470 let query = parse("SELECT int(5.7)").unwrap();
2472 let result = executor.execute(&query).unwrap();
2473 assert_eq!(result.rows[0][0], Value::Integer(5));
2474
2475 let query = parse("SELECT decimal(42)").unwrap();
2477 let result = executor.execute(&query).unwrap();
2478 assert_eq!(result.rows[0][0], Value::Number(dec!(42)));
2479
2480 let query = parse("SELECT str(123)").unwrap();
2482 let result = executor.execute(&query).unwrap();
2483 assert_eq!(result.rows[0][0], Value::String("123".to_string()));
2484
2485 let query = parse("SELECT bool(1)").unwrap();
2487 let result = executor.execute(&query).unwrap();
2488 assert_eq!(result.rows[0][0], Value::Boolean(true));
2489
2490 let query = parse("SELECT bool(0)").unwrap();
2491 let result = executor.execute(&query).unwrap();
2492 assert_eq!(result.rows[0][0], Value::Boolean(false));
2493 }
2494
2495 #[test]
2497 fn test_type_casting_in_aggregate_context() {
2498 let txn1 = Transaction::new(date(2024, 1, 15), "Item 1")
2499 .with_flag('*')
2500 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(10), "USD")))
2501 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "USD")));
2502
2503 let txn2 = Transaction::new(date(2024, 1, 16), "Item 2")
2504 .with_flag('*')
2505 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(20), "USD")))
2506 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-20), "USD")));
2507
2508 let directives = vec![Directive::Transaction(txn1), Directive::Transaction(txn2)];
2509 let mut executor = Executor::new(&directives);
2510
2511 let query =
2514 parse("SELECT account, str(sum(number(units))) GROUP BY account ORDER BY account")
2515 .unwrap();
2516 let result = executor.execute(&query).unwrap();
2517 assert_eq!(result.rows.len(), 2);
2518 assert_eq!(result.rows[0][0], Value::String("Assets:Cash".to_string()));
2520 assert_eq!(result.rows[0][1], Value::String("-30".to_string()));
2521 assert_eq!(
2522 result.rows[1][0],
2523 Value::String("Expenses:Food".to_string())
2524 );
2525 assert_eq!(result.rows[1][1], Value::String("30".to_string()));
2526
2527 let query =
2529 parse("SELECT account, int(sum(number(units))) GROUP BY account ORDER BY account")
2530 .unwrap();
2531 let result = executor.execute(&query).unwrap();
2532 assert_eq!(result.rows[0][1], Value::Integer(-30));
2533 assert_eq!(result.rows[1][1], Value::Integer(30));
2534
2535 let query =
2537 parse("SELECT account, decimal(count(*)) GROUP BY account ORDER BY account").unwrap();
2538 let result = executor.execute(&query).unwrap();
2539 assert_eq!(result.rows[0][1], Value::Number(dec!(2))); assert_eq!(result.rows[1][1], Value::Number(dec!(2)));
2541
2542 let query =
2544 parse("SELECT account, bool(count(*)) GROUP BY account ORDER BY account").unwrap();
2545 let result = executor.execute(&query).unwrap();
2546 assert_eq!(result.rows[0][1], Value::Boolean(true));
2547 assert_eq!(result.rows[1][1], Value::Boolean(true));
2548 }
2549
2550 #[test]
2552 fn test_int_truncation() {
2553 let directives = sample_directives();
2554 let mut executor = Executor::new(&directives);
2555
2556 let query = parse("SELECT int(5.7)").unwrap();
2558 let result = executor.execute(&query).unwrap();
2559 assert_eq!(result.rows[0][0], Value::Integer(5));
2560
2561 let query = parse("SELECT int(-5.7)").unwrap();
2562 let result = executor.execute(&query).unwrap();
2563 assert_eq!(result.rows[0][0], Value::Integer(-5));
2564
2565 let query = parse("SELECT int(0.999)").unwrap();
2566 let result = executor.execute(&query).unwrap();
2567 assert_eq!(result.rows[0][0], Value::Integer(0));
2568 }
2569
2570 #[test]
2572 fn test_type_casting_errors() {
2573 let directives = sample_directives();
2574 let mut executor = Executor::new(&directives);
2575
2576 let query = parse("SELECT int('not-a-number')").unwrap();
2578 let result = executor.execute(&query);
2579 assert!(result.is_err());
2580 assert!(
2581 result
2582 .unwrap_err()
2583 .to_string()
2584 .contains("cannot parse 'not-a-number'")
2585 );
2586
2587 let query = parse("SELECT decimal('invalid')").unwrap();
2589 let result = executor.execute(&query);
2590 assert!(result.is_err());
2591 assert!(result.unwrap_err().to_string().contains("cannot parse"));
2592
2593 let query = parse("SELECT bool('maybe')").unwrap();
2595 let result = executor.execute(&query);
2596 assert!(result.is_err());
2597 assert!(result.unwrap_err().to_string().contains("cannot parse"));
2598 }
2599
2600 #[test]
2601 fn test_meta_functions() {
2602 let mut txn_meta: Metadata = Metadata::default();
2604 txn_meta.insert(
2605 "source".to_string(),
2606 MetaValue::String("bank_import".to_string()),
2607 );
2608
2609 let mut posting_meta: Metadata = Metadata::default();
2610 posting_meta.insert(
2611 "category".to_string(),
2612 MetaValue::String("food".to_string()),
2613 );
2614
2615 let txn = Transaction {
2616 date: date(2024, 1, 15),
2617 flag: '*',
2618 payee: Some("Coffee Shop".into()),
2619 narration: "Coffee".into(),
2620 tags: vec![],
2621 links: vec![],
2622 meta: txn_meta,
2623 postings: vec![
2624 Posting {
2625 account: "Expenses:Food".into(),
2626 units: Some(rustledger_core::IncompleteAmount::Complete(Amount::new(
2627 dec!(5),
2628 "USD",
2629 ))),
2630 cost: None,
2631 price: None,
2632 flag: None,
2633 meta: posting_meta,
2634 comments: Vec::new(),
2635 trailing_comments: Vec::new(),
2636 },
2637 Posting::new("Assets:Cash", Amount::new(dec!(-5), "USD")),
2638 ],
2639 trailing_comments: Vec::new(),
2640 };
2641
2642 let directives = vec![Directive::Transaction(txn)];
2643 let mut executor = Executor::new(&directives);
2644
2645 let query = parse("SELECT meta('category') WHERE account ~ 'Expenses'").unwrap();
2647 let result = executor.execute(&query).unwrap();
2648 assert_eq!(result.rows[0][0], Value::String("food".to_string()));
2649
2650 let query = parse("SELECT entry_meta('source') WHERE account ~ 'Expenses'").unwrap();
2652 let result = executor.execute(&query).unwrap();
2653 assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
2654
2655 let query = parse("SELECT any_meta('source') WHERE account ~ 'Expenses'").unwrap();
2657 let result = executor.execute(&query).unwrap();
2658 assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
2659
2660 let query = parse("SELECT any_meta('category') WHERE account ~ 'Expenses'").unwrap();
2662 let result = executor.execute(&query).unwrap();
2663 assert_eq!(result.rows[0][0], Value::String("food".to_string()));
2664
2665 let query = parse("SELECT meta('nonexistent') WHERE account ~ 'Expenses'").unwrap();
2667 let result = executor.execute(&query).unwrap();
2668 assert_eq!(result.rows[0][0], Value::Null);
2669 }
2670
2671 #[test]
2672 fn test_convert_function() {
2673 let price = rustledger_core::Price {
2675 date: date(2024, 1, 1),
2676 currency: "EUR".into(),
2677 amount: Amount::new(dec!(1.10), "USD"),
2678 meta: Metadata::default(),
2679 };
2680
2681 let txn = Transaction::new(date(2024, 1, 15), "Test")
2682 .with_flag('*')
2683 .with_posting(Posting::new("Assets:Euro", Amount::new(dec!(100), "EUR")))
2684 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-110), "USD")));
2685
2686 let directives = vec![Directive::Price(price), Directive::Transaction(txn)];
2687 let mut executor = Executor::new(&directives);
2688
2689 let query = parse("SELECT convert(position, 'USD') WHERE account ~ 'Euro'").unwrap();
2691 let result = executor.execute(&query).unwrap();
2692 match &result.rows[0][0] {
2694 Value::Amount(a) => {
2695 assert_eq!(a.number, dec!(110));
2696 assert_eq!(a.currency.as_ref(), "USD");
2697 }
2698 _ => panic!("Expected Amount, got {:?}", result.rows[0][0]),
2699 }
2700 }
2701
2702 #[test]
2703 fn test_date_functions() {
2704 let directives = sample_directives();
2705 let mut executor = Executor::new(&directives);
2706
2707 let query = parse("SELECT date('2024-06-15')").unwrap();
2709 let result = executor.execute(&query).unwrap();
2710 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
2711
2712 let query = parse("SELECT date(2024, 6, 15)").unwrap();
2714 let result = executor.execute(&query).unwrap();
2715 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
2716
2717 let query = parse("SELECT date_diff(date('2024-01-20'), date('2024-01-15'))").unwrap();
2719 let result = executor.execute(&query).unwrap();
2720 assert_eq!(result.rows[0][0], Value::Integer(5));
2721
2722 let query = parse("SELECT date_add(date('2024-01-15'), 10)").unwrap();
2724 let result = executor.execute(&query).unwrap();
2725 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 25)));
2726
2727 let query = parse("SELECT date_trunc('year', date('2024-06-15'))").unwrap();
2729 let result = executor.execute(&query).unwrap();
2730 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1)));
2731
2732 let query = parse("SELECT date_trunc('month', date('2024-06-15'))").unwrap();
2734 let result = executor.execute(&query).unwrap();
2735 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1)));
2736
2737 let query = parse("SELECT date_part('month', date('2024-06-15'))").unwrap();
2739 let result = executor.execute(&query).unwrap();
2740 assert_eq!(result.rows[0][0], Value::Integer(6));
2741
2742 let query = parse("SELECT parse_date('15/06/2024', '%d/%m/%Y')").unwrap();
2744 let result = executor.execute(&query).unwrap();
2745 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
2746
2747 let query =
2749 parse("SELECT date_bin('7 days', date('2024-01-15'), date('2024-01-01'))").unwrap();
2750 let result = executor.execute(&query).unwrap();
2751 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); let query =
2755 parse("SELECT date_bin('1 week', date('2024-01-20'), date('2024-01-01'))").unwrap();
2756 let result = executor.execute(&query).unwrap();
2757 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); let query =
2761 parse("SELECT date_bin('1 month', date('2024-06-15'), date('2024-01-01'))").unwrap();
2762 let result = executor.execute(&query).unwrap();
2763 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1))); let query =
2767 parse("SELECT date_bin('1 year', date('2024-06-15'), date('2020-01-01'))").unwrap();
2768 let result = executor.execute(&query).unwrap();
2769 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1))); }
2771
2772 #[test]
2773 fn test_string_functions_extended() {
2774 let directives = sample_directives();
2775 let mut executor = Executor::new(&directives);
2776
2777 let query = parse("SELECT grep('Ex[a-z]+', 'Hello Expenses World')").unwrap();
2779 let result = executor.execute(&query).unwrap();
2780 assert_eq!(result.rows[0][0], Value::String("Expenses".to_string()));
2781
2782 let query = parse("SELECT grep('xyz', 'Hello World')").unwrap();
2784 let result = executor.execute(&query).unwrap();
2785 assert_eq!(result.rows[0][0], Value::Null);
2786
2787 let query = parse("SELECT grepn('([0-9]+)-([0-9]+)', '2024-01', 1)").unwrap();
2789 let result = executor.execute(&query).unwrap();
2790 assert_eq!(result.rows[0][0], Value::String("2024".to_string()));
2791
2792 let query = parse("SELECT subst('-', '/', '2024-01-15')").unwrap();
2794 let result = executor.execute(&query).unwrap();
2795 assert_eq!(result.rows[0][0], Value::String("2024/01/15".to_string()));
2796
2797 let query = parse("SELECT splitcomp('a:b:c', ':', 1)").unwrap();
2799 let result = executor.execute(&query).unwrap();
2800 assert_eq!(result.rows[0][0], Value::String("b".to_string()));
2801
2802 let query = parse("SELECT joinstr('hello', 'world')").unwrap();
2804 let result = executor.execute(&query).unwrap();
2805 assert_eq!(result.rows[0][0], Value::String("hello, world".to_string()));
2806
2807 let query = parse("SELECT maxwidth('hello', 10)").unwrap();
2809 let result = executor.execute(&query).unwrap();
2810 assert_eq!(result.rows[0][0], Value::String("hello".to_string()));
2811
2812 let query = parse("SELECT maxwidth('hello world', 8)").unwrap();
2814 let result = executor.execute(&query).unwrap();
2815 assert_eq!(result.rows[0][0], Value::String("hello...".to_string()));
2816 }
2817
2818 #[test]
2819 fn test_inventory_functions() {
2820 let directives = sample_directives();
2821 let mut executor = Executor::new(&directives);
2822
2823 let query = parse("SELECT empty(sum(position)) WHERE account ~ 'Assets'").unwrap();
2826 let result = executor.execute(&query).unwrap();
2827 assert!(matches!(result.rows[0][0], Value::Boolean(_)));
2829
2830 let query = parse("SELECT possign(100, 'Assets:Bank')").unwrap();
2835 let result = executor.execute(&query).unwrap();
2836 assert_eq!(
2837 result.rows[0][0],
2838 Value::Number(rust_decimal::Decimal::from(100))
2839 );
2840
2841 let query = parse("SELECT possign(100, 'Income:Salary')").unwrap();
2843 let result = executor.execute(&query).unwrap();
2844 assert_eq!(
2845 result.rows[0][0],
2846 Value::Number(rust_decimal::Decimal::from(-100))
2847 );
2848
2849 let query = parse("SELECT possign(50, 'Expenses:Food')").unwrap();
2851 let result = executor.execute(&query).unwrap();
2852 assert_eq!(
2853 result.rows[0][0],
2854 Value::Number(rust_decimal::Decimal::from(50))
2855 );
2856
2857 let query = parse("SELECT possign(200, 'Liabilities:CreditCard')").unwrap();
2859 let result = executor.execute(&query).unwrap();
2860 assert_eq!(
2861 result.rows[0][0],
2862 Value::Number(rust_decimal::Decimal::from(-200))
2863 );
2864
2865 let query = parse("SELECT possign(300, 'Equity:OpeningBalances')").unwrap();
2867 let result = executor.execute(&query).unwrap();
2868 assert_eq!(
2869 result.rows[0][0],
2870 Value::Number(rust_decimal::Decimal::from(-300))
2871 );
2872 }
2873
2874 #[test]
2875 fn test_account_meta_functions() {
2876 use rustledger_core::{Close, Metadata, Open};
2877
2878 let mut open_meta = Metadata::default();
2880 open_meta.insert(
2881 "category".to_string(),
2882 MetaValue::String("checking".to_string()),
2883 );
2884
2885 let directives = vec![
2886 Directive::Open(Open {
2887 date: date(2020, 1, 1),
2888 account: "Assets:Bank:Checking".into(),
2889 currencies: vec![],
2890 booking: None,
2891 meta: open_meta,
2892 }),
2893 Directive::Open(Open::new(date(2020, 2, 15), "Expenses:Food")),
2894 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank:Checking")),
2895 Directive::Transaction(
2897 Transaction::new(date(2024, 1, 15), "Coffee")
2898 .with_posting(Posting::new(
2899 "Expenses:Food",
2900 Amount::new(dec!(5.00), "USD"),
2901 ))
2902 .with_posting(Posting::new(
2903 "Assets:Bank:Checking",
2904 Amount::new(dec!(-5.00), "USD"),
2905 )),
2906 ),
2907 ];
2908
2909 let mut executor = Executor::new(&directives);
2910
2911 let query = parse("SELECT open_date('Assets:Bank:Checking')").unwrap();
2913 let result = executor.execute(&query).unwrap();
2914 assert_eq!(result.rows[0][0], Value::Date(date(2020, 1, 1)));
2915
2916 let query = parse("SELECT close_date('Assets:Bank:Checking')").unwrap();
2918 let result = executor.execute(&query).unwrap();
2919 assert_eq!(result.rows[0][0], Value::Date(date(2024, 12, 31)));
2920
2921 let query = parse("SELECT close_date('Expenses:Food')").unwrap();
2923 let result = executor.execute(&query).unwrap();
2924 assert_eq!(result.rows[0][0], Value::Null);
2925
2926 let query = parse("SELECT open_meta('Assets:Bank:Checking', 'category')").unwrap();
2928 let result = executor.execute(&query).unwrap();
2929 assert_eq!(result.rows[0][0], Value::String("checking".to_string()));
2930
2931 let query = parse("SELECT open_meta('Assets:Bank:Checking', 'nonexistent')").unwrap();
2933 let result = executor.execute(&query).unwrap();
2934 assert_eq!(result.rows[0][0], Value::Null);
2935
2936 let query = parse("SELECT open_date('NonExistent:Account')").unwrap();
2938 let result = executor.execute(&query).unwrap();
2939 assert_eq!(result.rows[0][0], Value::Null);
2940 }
2941
2942 #[test]
2943 fn test_source_location_columns_return_null_without_sources() {
2944 let directives = vec![Directive::Transaction(Transaction {
2947 date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2948 flag: '*',
2949 payee: Some("Test".into()),
2950 narration: "Test transaction".into(),
2951 tags: vec![],
2952 links: vec![],
2953 meta: Metadata::default(),
2954 postings: vec![
2955 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
2956 Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
2957 ],
2958 trailing_comments: Vec::new(),
2959 })];
2960
2961 let mut executor = Executor::new(&directives);
2962
2963 let query = parse("SELECT filename").unwrap();
2965 let result = executor.execute(&query).unwrap();
2966 assert_eq!(result.rows[0][0], Value::Null);
2967
2968 let query = parse("SELECT lineno").unwrap();
2970 let result = executor.execute(&query).unwrap();
2971 assert_eq!(result.rows[0][0], Value::Null);
2972
2973 let query = parse("SELECT location").unwrap();
2975 let result = executor.execute(&query).unwrap();
2976 assert_eq!(result.rows[0][0], Value::Null);
2977 }
2978
2979 #[test]
2980 fn test_source_location_columns_with_sources() {
2981 use rustledger_loader::SourceMap;
2982 use rustledger_parser::Spanned;
2983 use std::sync::Arc;
2984
2985 let mut source_map = SourceMap::new();
2987 let source: Arc<str> =
2988 "2024-01-15 * \"Test\"\n Assets:Bank 100 USD\n Expenses:Food".into();
2989 let file_id = source_map.add_file("test.beancount".into(), source);
2990
2991 let txn = Transaction {
2993 date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2994 flag: '*',
2995 payee: Some("Test".into()),
2996 narration: "Test transaction".into(),
2997 tags: vec![],
2998 links: vec![],
2999 meta: Metadata::default(),
3000 postings: vec![
3001 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3002 Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
3003 ],
3004 trailing_comments: Vec::new(),
3005 };
3006
3007 let spanned_directives = vec![Spanned {
3008 value: Directive::Transaction(txn),
3009 span: rustledger_parser::Span { start: 0, end: 50 },
3010 file_id: file_id as u16,
3011 }];
3012
3013 let mut executor = Executor::new_with_sources(&spanned_directives, &source_map);
3014
3015 let query = parse("SELECT filename").unwrap();
3017 let result = executor.execute(&query).unwrap();
3018 assert_eq!(
3019 result.rows[0][0],
3020 Value::String("test.beancount".to_string())
3021 );
3022
3023 let query = parse("SELECT lineno").unwrap();
3025 let result = executor.execute(&query).unwrap();
3026 assert_eq!(result.rows[0][0], Value::Integer(1));
3027
3028 let query = parse("SELECT location").unwrap();
3030 let result = executor.execute(&query).unwrap();
3031 assert_eq!(
3032 result.rows[0][0],
3033 Value::String("test.beancount:1".to_string())
3034 );
3035 }
3036
3037 #[test]
3038 fn test_interval_function() {
3039 let directives = sample_directives();
3040 let mut executor = Executor::new(&directives);
3041
3042 let query = parse("SELECT interval('month')").unwrap();
3044 let result = executor.execute(&query).unwrap();
3045 assert_eq!(
3046 result.rows[0][0],
3047 Value::Interval(Interval::new(1, IntervalUnit::Month))
3048 );
3049
3050 let query = parse("SELECT interval(3, 'day')").unwrap();
3052 let result = executor.execute(&query).unwrap();
3053 assert_eq!(
3054 result.rows[0][0],
3055 Value::Interval(Interval::new(3, IntervalUnit::Day))
3056 );
3057
3058 let query = parse("SELECT interval(-2, 'week')").unwrap();
3060 let result = executor.execute(&query).unwrap();
3061 assert_eq!(
3062 result.rows[0][0],
3063 Value::Interval(Interval::new(-2, IntervalUnit::Week))
3064 );
3065 }
3066
3067 #[test]
3068 fn test_date_add_with_interval() {
3069 let directives = sample_directives();
3070 let mut executor = Executor::new(&directives);
3071
3072 let query = parse("SELECT date_add(date(2024, 1, 15), interval(1, 'month'))").unwrap();
3074 let result = executor.execute(&query).unwrap();
3075 assert_eq!(
3076 result.rows[0][0],
3077 Value::Date(NaiveDate::from_ymd_opt(2024, 2, 15).unwrap())
3078 );
3079
3080 let query = parse("SELECT date(2024, 1, 15) + interval(1, 'year')").unwrap();
3082 let result = executor.execute(&query).unwrap();
3083 assert_eq!(
3084 result.rows[0][0],
3085 Value::Date(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
3086 );
3087
3088 let query = parse("SELECT date(2024, 3, 15) - interval(2, 'month')").unwrap();
3090 let result = executor.execute(&query).unwrap();
3091 assert_eq!(
3092 result.rows[0][0],
3093 Value::Date(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap())
3094 );
3095 }
3096}