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 regex::{Regex, RegexBuilder};
19use rust_decimal::Decimal;
20use rustledger_core::{Amount, Directive, InternedStr, Inventory, Metadata, NaiveDate, Position};
21#[cfg(test)]
22use rustledger_core::{MetaValue, Transaction};
23use rustledger_loader::SourceMap;
24use rustledger_parser::Spanned;
25
26use crate::ast::{Expr, FromClause, FunctionCall, Query, SelectQuery, Target};
27use crate::error::QueryError;
28
29pub(super) fn compute_posting_weight(
53 posting: &rustledger_core::Posting,
54 txn_date: NaiveDate,
55) -> Value {
56 let Some(units) = posting.amount() else {
57 return Value::Null;
58 };
59 if let Some(cost_spec) = &posting.cost
60 && let Some(cost) = cost_spec.resolve(units.number, txn_date)
61 {
62 return Value::Amount(Amount::new(units.number * cost.number, cost.currency));
63 }
64 if let Some(price_ann) = &posting.price
65 && let Some(price_amt) = price_ann.amount()
66 {
67 return if price_ann.is_unit() {
68 Value::Amount(Amount::new(
69 units.number * price_amt.number,
70 price_amt.currency.clone(),
71 ))
72 } else {
73 let signed = if units.number.is_sign_negative() {
74 -price_amt.number
75 } else {
76 price_amt.number
77 };
78 Value::Amount(Amount::new(signed, price_amt.currency.clone()))
79 };
80 }
81 Value::Amount(units.clone())
82}
83
84pub struct Executor<'a> {
86 directives: &'a [Directive],
88 spanned_directives: Option<&'a [Spanned<Directive>]>,
90 price_db: crate::price::PriceDatabase,
92 target_currency: Option<String>,
94 query_date: rustledger_core::NaiveDate,
96 regex_cache: RwLock<FxHashMap<String, Option<Regex>>>,
98 account_info: FxHashMap<String, AccountInfo>,
100 source_locations: Option<Vec<SourceLocation>>,
102 tables: FxHashMap<String, Table>,
104}
105
106mod aggregation;
108mod evaluation;
109mod execution;
110mod operators;
111mod sort;
112mod window;
113
114pub const WILDCARD_COLUMNS: &[&str] =
117 &["date", "flag", "payee", "narration", "account", "position"];
118
119impl<'a> Executor<'a> {
120 pub fn new(directives: &'a [Directive]) -> Self {
122 let price_db = crate::price::PriceDatabase::from_directives(directives);
123
124 let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
126 for directive in directives {
127 match directive {
128 Directive::Open(open) => {
129 let account = open.account.to_string();
130 let info = account_info.entry(account).or_insert_with(|| AccountInfo {
131 open_date: None,
132 close_date: None,
133 open_meta: Metadata::default(),
134 });
135 info.open_date = Some(open.date);
136 info.open_meta.clone_from(&open.meta);
137 }
138 Directive::Close(close) => {
139 let account = close.account.to_string();
140 let info = account_info.entry(account).or_insert_with(|| AccountInfo {
141 open_date: None,
142 close_date: None,
143 open_meta: Metadata::default(),
144 });
145 info.close_date = Some(close.date);
146 }
147 _ => {}
148 }
149 }
150
151 Self {
152 directives,
153 spanned_directives: None,
154 price_db,
155 target_currency: None,
156 query_date: jiff::Zoned::now().date(),
157 regex_cache: RwLock::new(FxHashMap::default()),
158 account_info,
159 source_locations: None,
160 tables: FxHashMap::default(),
161 }
162 }
163
164 pub fn new_with_sources(
169 spanned_directives: &'a [Spanned<Directive>],
170 source_map: &SourceMap,
171 ) -> Self {
172 let mut price_db = crate::price::PriceDatabase::new();
181 for spanned in spanned_directives {
182 if let Directive::Price(p) = &spanned.value {
183 price_db.add_price(p);
184 }
185 }
186 let explicit = price_db.snapshot_keys();
187 for spanned in spanned_directives {
188 if let Directive::Transaction(txn) = &spanned.value {
189 price_db.add_implicit_prices_from_transaction(txn, &explicit);
190 }
191 }
192 price_db.sort_prices();
193
194 let source_locations: Vec<SourceLocation> = spanned_directives
196 .iter()
197 .map(|spanned| {
198 let file = source_map.get(spanned.file_id as usize);
199 let (line, _col) = file.map_or((0, 0), |f| f.line_col(spanned.span.start));
200 SourceLocation {
201 filename: file.map_or_else(String::new, |f| f.path.display().to_string()),
202 lineno: line,
203 }
204 })
205 .collect();
206
207 let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
209 for spanned in spanned_directives {
210 match &spanned.value {
211 Directive::Open(open) => {
212 let account = open.account.to_string();
213 let info = account_info.entry(account).or_insert_with(|| AccountInfo {
214 open_date: None,
215 close_date: None,
216 open_meta: Metadata::default(),
217 });
218 info.open_date = Some(open.date);
219 info.open_meta.clone_from(&open.meta);
220 }
221 Directive::Close(close) => {
222 let account = close.account.to_string();
223 let info = account_info.entry(account).or_insert_with(|| AccountInfo {
224 open_date: None,
225 close_date: None,
226 open_meta: Metadata::default(),
227 });
228 info.close_date = Some(close.date);
229 }
230 _ => {}
231 }
232 }
233
234 Self {
235 directives: &[], spanned_directives: Some(spanned_directives),
237 price_db,
238 target_currency: None,
239 query_date: jiff::Zoned::now().date(),
240 regex_cache: RwLock::new(FxHashMap::default()),
241 account_info,
242 source_locations: Some(source_locations),
243 tables: FxHashMap::default(),
244 }
245 }
246
247 fn get_source_location(&self, directive_index: usize) -> Option<&SourceLocation> {
249 self.source_locations
250 .as_ref()
251 .and_then(|locs| locs.get(directive_index))
252 }
253
254 fn get_or_compile_regex(&self, pattern: &str) -> Option<Regex> {
259 {
261 let cache = match self.regex_cache.read() {
264 Ok(guard) => guard,
265 Err(poisoned) => poisoned.into_inner(),
266 };
267 if let Some(cached) = cache.get(pattern) {
268 return cached.clone();
269 }
270 }
271 let compiled = RegexBuilder::new(pattern)
274 .case_insensitive(true)
275 .build()
276 .ok();
277 let mut cache = match self.regex_cache.write() {
278 Ok(guard) => guard,
279 Err(poisoned) => poisoned.into_inner(),
280 };
281 if let Some(cached) = cache.get(pattern) {
283 return cached.clone();
284 }
285 cache.insert(pattern.to_string(), compiled.clone());
286 compiled
287 }
288
289 fn require_regex(&self, pattern: &str) -> Result<Regex, QueryError> {
291 self.get_or_compile_regex(pattern)
292 .ok_or_else(|| QueryError::Type(format!("invalid regex: {pattern}")))
293 }
294
295 pub fn set_target_currency(&mut self, currency: impl Into<String>) {
297 self.target_currency = Some(currency.into());
298 }
299
300 pub fn execute(&mut self, query: &Query) -> Result<QueryResult, QueryError> {
313 match query {
314 Query::Select(select) => self.execute_select(select),
315 Query::Journal(journal) => self.execute_journal(journal),
316 Query::Balances(balances) => self.execute_balances(balances),
317 Query::Print(print) => self.execute_print(print),
318 Query::CreateTable(create) => self.execute_create_table(create),
319 Query::Insert(insert) => self.execute_insert(insert),
320 }
321 }
322
323 fn build_balances_with_filter(
333 &self,
334 from: Option<&FromClause>,
335 ) -> Result<FxHashMap<InternedStr, Inventory>, QueryError> {
336 let mut balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
337
338 let all_directives: Vec<&Directive> = if let Some(spanned) = self.spanned_directives {
345 spanned.iter().map(|s| &s.value).collect()
346 } else {
347 self.directives.iter().collect()
348 };
349
350 for directive in all_directives {
351 if let Directive::Transaction(txn) = directive {
352 if let Some(from_clause) = from
354 && let Some(filter) = &from_clause.filter
355 && !self.evaluate_from_filter(filter, txn)?
356 {
357 continue;
358 }
359
360 for posting in &txn.postings {
361 if let Some(units) = posting.amount() {
362 let balance = balances.entry(posting.account.clone()).or_default();
363
364 let pos = if let Some(cost_spec) = &posting.cost {
365 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
366 Position::with_cost(units.clone(), cost)
367 } else {
368 Position::simple(units.clone())
369 }
370 } else {
371 Position::simple(units.clone())
372 };
373 balance.add(pos);
374 }
375 }
376 }
377 }
378
379 Ok(balances)
380 }
381
382 fn collect_postings(&self, query: &SelectQuery) -> Result<Vec<PostingContext<'a>>, QueryError> {
384 let from = query.from.as_ref();
385 let where_clause = query.where_clause.as_ref();
386
387 let needs_balance = query_references_column(query, "balance");
402 let needs_account_balance = query_references_column(query, "account_balance");
403
404 let where_reads_balance =
414 where_clause.is_some_and(|w| expr_references_column(w, "balance"));
415
416 let mut postings = Vec::new();
417 let mut account_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
421 let mut cumulative_balance: Inventory = Inventory::default();
425
426 let directive_iter: Vec<(usize, &Directive)> =
429 if let Some(spanned) = self.spanned_directives {
430 spanned
431 .iter()
432 .enumerate()
433 .map(|(i, s)| (i, &s.value))
434 .collect()
435 } else {
436 self.directives.iter().enumerate().collect()
437 };
438
439 let resolve_position = |posting: &rustledger_core::Posting, txn_date: NaiveDate| {
444 posting.amount().map(|units| {
445 if let Some(cost_spec) = &posting.cost
446 && let Some(cost) = cost_spec.resolve(units.number, txn_date)
447 {
448 return Position::with_cost(units.clone(), cost);
449 }
450 Position::simple(units.clone())
451 })
452 };
453
454 for (directive_index, directive) in directive_iter {
455 if let Directive::Transaction(txn) = directive {
456 if let Some(from) = from {
458 if let Some(open_date) = from.open_on
460 && txn.date < open_date
461 {
462 if needs_account_balance {
466 for posting in &txn.postings {
467 if let Some(pos) = resolve_position(posting, txn.date) {
468 let bal = account_balances
469 .entry(posting.account.clone())
470 .or_default();
471 bal.add(pos);
472 }
473 }
474 }
475 continue;
476 }
477 if let Some(close_date) = from.close_on
482 && txn.date >= close_date
483 {
484 continue;
485 }
486 if let Some(filter) = &from.filter
488 && !self.evaluate_from_filter(filter, txn)?
489 {
490 continue;
491 }
492 }
493
494 for (i, posting) in txn.postings.iter().enumerate() {
495 let resolved = resolve_position(posting, txn.date);
503 if needs_account_balance && let Some(pos) = resolved.clone() {
504 let bal = account_balances.entry(posting.account.clone()).or_default();
505 bal.add(pos);
506 }
507
508 let mut ctx = PostingContext {
534 transaction: txn,
535 posting_index: i,
536 balance: if where_reads_balance {
537 Some(cumulative_balance.clone())
538 } else {
539 None
540 },
541 account_balance: if needs_account_balance {
542 account_balances.get(&posting.account).cloned()
543 } else {
544 None
545 },
546 directive_index: Some(directive_index),
547 };
548
549 if let Some(where_expr) = where_clause
551 && !self.evaluate_predicate(where_expr, &ctx)?
552 {
553 continue;
554 }
555
556 if needs_balance {
561 if let Some(pos) = resolved {
562 cumulative_balance.add(pos);
563 }
564 ctx.balance = Some(cumulative_balance.clone());
565 }
566 postings.push(ctx);
567 }
568 }
569 }
570
571 Ok(postings)
572 }
573 fn evaluate_function(
574 &self,
575 func: &FunctionCall,
576 ctx: &PostingContext,
577 ) -> Result<Value, QueryError> {
578 let name = func.name.to_uppercase();
579 match name.as_str() {
580 "YEAR" | "MONTH" | "DAY" | "WEEKDAY" | "QUARTER" | "YMONTH" | "TODAY" => {
582 self.eval_date_function(&name, func, ctx)
583 }
584 "DATE" | "DATE_DIFF" | "DATE_ADD" | "DATE_TRUNC" | "DATE_PART" | "PARSE_DATE"
586 | "DATE_BIN" | "INTERVAL" => self.eval_extended_date_function(&name, func, ctx),
587 "LENGTH" | "UPPER" | "LOWER" | "SUBSTR" | "SUBSTRING" | "TRIM" | "STARTSWITH"
589 | "ENDSWITH" | "GREP" | "GREPN" | "SUBST" | "SPLITCOMP" | "JOINSTR" | "MAXWIDTH" => {
590 self.eval_string_function(&name, func, ctx)
591 }
592 "PARENT" | "LEAF" | "ROOT" | "ACCOUNT_DEPTH" | "ACCOUNT_SORTKEY" => {
594 self.eval_account_function(&name, func, ctx)
595 }
596 "OPEN_DATE" | "CLOSE_DATE" | "OPEN_META" => {
598 self.eval_account_meta_function(&name, func, ctx)
599 }
600 "ABS" | "NEG" | "ROUND" | "SAFEDIV" => self.eval_math_function(&name, func, ctx),
602 "NUMBER" | "CURRENCY" | "GETITEM" | "GET" | "UNITS" | "COST" | "WEIGHT" | "VALUE" => {
604 self.eval_position_function(&name, func, ctx)
605 }
606 "EMPTY" | "FILTER_CURRENCY" | "POSSIGN" => {
608 self.eval_inventory_function(&name, func, ctx)
609 }
610 "GETPRICE" => self.eval_getprice(func, ctx),
612 "COALESCE" => self.eval_coalesce(func, ctx),
614 "ONLY" => self.eval_only(func, ctx),
615 "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
617 self.eval_meta_function(&name, func, ctx)
618 }
619 "CONVERT" => self.eval_convert(func, ctx),
621 "INT" => self.eval_int(func, ctx),
623 "DECIMAL" => self.eval_decimal(func, ctx),
624 "STR" => self.eval_str(func, ctx),
625 "BOOL" => self.eval_bool(func, ctx),
626 "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
629 _ => Err(QueryError::UnknownFunction(func.name.clone())),
630 }
631 }
632
633 fn evaluate_function_on_values(&self, name: &str, args: &[Value]) -> Result<Value, QueryError> {
635 let name_upper = name.to_uppercase();
636 match name_upper.as_str() {
637 "TODAY" => Ok(Value::Date(jiff::Zoned::now().date())),
639 "YEAR" => {
640 Self::require_args_count(&name_upper, args, 1)?;
641 match &args[0] {
642 Value::Date(d) => Ok(Value::Integer(d.year().into())),
643 _ => Err(QueryError::Type("YEAR expects a date".to_string())),
644 }
645 }
646 "MONTH" => {
647 Self::require_args_count(&name_upper, args, 1)?;
648 match &args[0] {
649 Value::Date(d) => Ok(Value::Integer(d.month().into())),
650 _ => Err(QueryError::Type("MONTH expects a date".to_string())),
651 }
652 }
653 "DAY" => {
654 Self::require_args_count(&name_upper, args, 1)?;
655 match &args[0] {
656 Value::Date(d) => Ok(Value::Integer(d.day().into())),
657 _ => Err(QueryError::Type("DAY expects a date".to_string())),
658 }
659 }
660 "LENGTH" => {
662 Self::require_args_count(&name_upper, args, 1)?;
663 match &args[0] {
664 Value::String(s) => Ok(Value::Integer(s.len() as i64)),
665 _ => Err(QueryError::Type("LENGTH expects a string".to_string())),
666 }
667 }
668 "UPPER" => {
669 Self::require_args_count(&name_upper, args, 1)?;
670 match &args[0] {
671 Value::String(s) => Ok(Value::String(s.to_uppercase())),
672 _ => Err(QueryError::Type("UPPER expects a string".to_string())),
673 }
674 }
675 "LOWER" => {
676 Self::require_args_count(&name_upper, args, 1)?;
677 match &args[0] {
678 Value::String(s) => Ok(Value::String(s.to_lowercase())),
679 _ => Err(QueryError::Type("LOWER expects a string".to_string())),
680 }
681 }
682 "TRIM" => {
683 Self::require_args_count(&name_upper, args, 1)?;
684 match &args[0] {
685 Value::String(s) => Ok(Value::String(s.trim().to_string())),
686 _ => Err(QueryError::Type("TRIM expects a string".to_string())),
687 }
688 }
689 "ABS" => {
691 Self::require_args_count(&name_upper, args, 1)?;
692 match &args[0] {
693 Value::Number(n) => Ok(Value::Number(n.abs())),
694 Value::Integer(i) => Ok(Value::Integer(i.abs())),
695 _ => Err(QueryError::Type("ABS expects a number".to_string())),
696 }
697 }
698 "ROUND" => {
699 if args.is_empty() || args.len() > 2 {
700 return Err(QueryError::InvalidArguments(
701 "ROUND".to_string(),
702 "expected 1 or 2 arguments".to_string(),
703 ));
704 }
705 match &args[0] {
706 Value::Number(n) => {
707 let scale = if args.len() == 2 {
708 match &args[1] {
709 Value::Integer(i) => *i as u32,
710 _ => 0,
711 }
712 } else {
713 0
714 };
715 Ok(Value::Number(n.round_dp(scale)))
716 }
717 Value::Integer(i) => Ok(Value::Integer(*i)),
718 _ => Err(QueryError::Type("ROUND expects a number".to_string())),
719 }
720 }
721 "COALESCE" => {
723 for arg in args {
724 if !matches!(arg, Value::Null) {
725 return Ok(arg.clone());
726 }
727 }
728 Ok(Value::Null)
729 }
730 "NUMBER" => {
732 Self::require_args_count(&name_upper, args, 1)?;
733 match &args[0] {
734 Value::Amount(a) => Ok(Value::Number(a.number)),
735 Value::Position(p) => Ok(Value::Number(p.units.number)),
736 Value::Number(n) => Ok(Value::Number(*n)),
737 Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
738 Value::Inventory(inv) => {
739 let mut iter = inv.positions();
744 let Some(first) = iter.next() else {
745 return Ok(Value::Number(Decimal::ZERO));
746 };
747 let first_currency = &first.units.currency;
748 let mut total = first.units.number;
749 for pos in iter {
750 if &pos.units.currency != first_currency {
751 return Ok(Value::Null);
752 }
753 total += pos.units.number;
754 }
755 Ok(Value::Number(total))
756 }
757 Value::Null => Ok(Value::Null),
758 _ => Err(QueryError::Type(
759 "NUMBER expects an amount, position, or inventory".to_string(),
760 )),
761 }
762 }
763 "CURRENCY" => {
764 Self::require_args_count(&name_upper, args, 1)?;
765 match &args[0] {
766 Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
767 Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
768 Value::Inventory(inv) => {
769 if let Some(pos) = inv.positions().next() {
771 Ok(Value::String(pos.units.currency.to_string()))
772 } else {
773 Ok(Value::Null)
774 }
775 }
776 Value::Null => Ok(Value::Null),
777 _ => Err(QueryError::Type(
778 "CURRENCY expects an amount or position".to_string(),
779 )),
780 }
781 }
782 "UNITS" => {
783 Self::require_args_count(&name_upper, args, 1)?;
784 match &args[0] {
785 Value::Position(p) => Ok(Value::Amount(p.units.clone())),
786 Value::Amount(a) => Ok(Value::Amount(a.clone())),
787 Value::Inventory(inv) => {
788 let mut units_inv = Inventory::new();
790 for pos in inv.positions() {
791 units_inv.add(Position::simple(pos.units.clone()));
792 }
793 Ok(Value::Inventory(Box::new(units_inv)))
794 }
795 Value::Null => Ok(Value::Null),
796 _ => Err(QueryError::Type(
797 "UNITS expects a position or inventory".to_string(),
798 )),
799 }
800 }
801 "COST" => {
802 Self::require_args_count(&name_upper, args, 1)?;
803 match &args[0] {
804 Value::Position(p) => {
805 if let Some(cost) = &p.cost {
806 let total = p.units.number * cost.number;
808 Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
809 } else {
810 Ok(Value::Amount(p.units.clone()))
811 }
812 }
813 Value::Amount(a) => Ok(Value::Amount(a.clone())),
814 Value::Inventory(inv) => {
815 let mut total = Decimal::ZERO;
816 let mut currency: Option<InternedStr> = None;
817 for pos in inv.positions() {
818 if let Some(cost) = &pos.cost {
819 total += pos.units.number * cost.number;
820 if currency.is_none() {
821 currency = Some(cost.currency.clone());
822 }
823 } else {
824 total += pos.units.number;
825 if currency.is_none() {
826 currency = Some(pos.units.currency.clone());
827 }
828 }
829 }
830 if let Some(curr) = currency {
831 Ok(Value::Amount(Amount::new(total, curr)))
832 } else {
833 Ok(Value::Null)
834 }
835 }
836 Value::Null => Ok(Value::Null),
837 _ => Err(QueryError::Type(
838 "COST expects a position or inventory".to_string(),
839 )),
840 }
841 }
842 "VALUE" => {
843 if args.is_empty() || args.len() > 2 {
847 return Err(QueryError::InvalidArguments(
848 "VALUE".to_string(),
849 "expected 1-2 arguments".to_string(),
850 ));
851 }
852 let (explicit_currency, at_date) = if args.len() == 2 {
853 match &args[1] {
854 Value::Date(d) => (None, Some(*d)),
855 Value::String(s) => (Some(s.as_str()), None),
856 Value::Null => {
857 return Err(QueryError::Type(
858 concat!(
859 "VALUE: second argument evaluated to NULL; ",
860 "expected a date or currency string ",
861 "(this often means an aggregate expression couldn't ",
862 "evaluate against an empty group — see issue #902)",
863 )
864 .to_string(),
865 ));
866 }
867 _ => {
868 return Err(QueryError::Type(
869 "VALUE second argument must be a date or currency string"
870 .to_string(),
871 ));
872 }
873 }
874 } else {
875 (None, None)
876 };
877 self.convert_to_market_value(&args[0], explicit_currency, at_date)
878 }
879 "SAFEDIV" => {
881 Self::require_args_count(&name_upper, args, 2)?;
882 let (dividend, divisor) = (&args[0], &args[1]);
883 match (dividend, divisor) {
884 (Value::Number(a), Value::Number(b)) => {
885 if b.is_zero() {
886 Ok(Value::Null)
887 } else {
888 Ok(Value::Number(a / b))
889 }
890 }
891 (Value::Integer(a), Value::Integer(b)) => {
892 if *b == 0 {
893 Ok(Value::Null)
894 } else {
895 Ok(Value::Number(Decimal::from(*a) / Decimal::from(*b)))
896 }
897 }
898 (Value::Number(a), Value::Integer(b)) => {
899 if *b == 0 {
900 Ok(Value::Null)
901 } else {
902 Ok(Value::Number(a / Decimal::from(*b)))
903 }
904 }
905 (Value::Integer(a), Value::Number(b)) => {
906 if b.is_zero() {
907 Ok(Value::Null)
908 } else {
909 Ok(Value::Number(Decimal::from(*a) / b))
910 }
911 }
912 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
913 _ => Err(QueryError::Type(
914 "SAFEDIV expects numeric arguments".to_string(),
915 )),
916 }
917 }
918 "NEG" => {
919 Self::require_args_count(&name_upper, args, 1)?;
920 match &args[0] {
921 Value::Number(n) => Ok(Value::Number(-n)),
922 Value::Integer(i) => Ok(Value::Integer(-i)),
923 Value::Amount(a) => {
924 Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
925 }
926 _ => Err(QueryError::Type(
927 "NEG expects a number or amount".to_string(),
928 )),
929 }
930 }
931 "ACCOUNT_SORTKEY" => {
933 Self::require_args_count(&name_upper, args, 1)?;
934 match &args[0] {
935 Value::String(s) => {
936 let type_index = Self::account_type_index(s);
937 Ok(Value::String(format!("{type_index}-{s}")))
938 }
939 _ => Err(QueryError::Type(
940 "ACCOUNT_SORTKEY expects an account string".to_string(),
941 )),
942 }
943 }
944 "PARENT" => {
945 Self::require_args_count(&name_upper, args, 1)?;
946 match &args[0] {
947 Value::String(s) => {
948 if let Some(idx) = s.rfind(':') {
949 Ok(Value::String(s[..idx].to_string()))
950 } else {
951 Ok(Value::Null)
952 }
953 }
954 _ => Err(QueryError::Type(
955 "PARENT expects an account string".to_string(),
956 )),
957 }
958 }
959 "LEAF" => {
960 Self::require_args_count(&name_upper, args, 1)?;
961 match &args[0] {
962 Value::String(s) => {
963 if let Some(idx) = s.rfind(':') {
964 Ok(Value::String(s[idx + 1..].to_string()))
965 } else {
966 Ok(Value::String(s.clone()))
967 }
968 }
969 _ => Err(QueryError::Type(
970 "LEAF expects an account string".to_string(),
971 )),
972 }
973 }
974 "ROOT" => {
975 if args.is_empty() || args.len() > 2 {
976 return Err(QueryError::InvalidArguments(
977 "ROOT".to_string(),
978 "expected 1 or 2 arguments".to_string(),
979 ));
980 }
981 let n = if args.len() == 2 {
982 match &args[1] {
983 Value::Integer(i) => *i as usize,
984 _ => 1,
985 }
986 } else {
987 1
988 };
989 match &args[0] {
990 Value::String(s) => {
991 let parts: Vec<&str> = s.split(':').collect();
992 if n >= parts.len() {
993 Ok(Value::String(s.clone()))
994 } else {
995 Ok(Value::String(parts[..n].join(":")))
996 }
997 }
998 _ => Err(QueryError::Type(
999 "ROOT expects an account string".to_string(),
1000 )),
1001 }
1002 }
1003 "ONLY" => {
1005 Self::require_args_count(&name_upper, args, 2)?;
1006 let currency = match &args[0] {
1007 Value::String(s) => s.clone(),
1008 _ => {
1009 return Err(QueryError::Type(
1010 "ONLY: first argument must be a currency string".to_string(),
1011 ));
1012 }
1013 };
1014 match &args[1] {
1015 Value::Inventory(inv) => {
1016 let total = inv.units(¤cy);
1017 if total.is_zero() {
1018 Ok(Value::Null)
1019 } else {
1020 Ok(Value::Amount(Amount::new(total, ¤cy)))
1021 }
1022 }
1023 Value::Position(p) => {
1024 if p.units.currency.as_str() == currency {
1025 Ok(Value::Amount(p.units.clone()))
1026 } else {
1027 Ok(Value::Null)
1028 }
1029 }
1030 Value::Amount(a) => {
1031 if a.currency.as_str() == currency {
1032 Ok(Value::Amount(a.clone()))
1033 } else {
1034 Ok(Value::Null)
1035 }
1036 }
1037 Value::Null => Ok(Value::Null),
1038 _ => Err(QueryError::Type(
1039 "ONLY: second argument must be an inventory, position, or amount"
1040 .to_string(),
1041 )),
1042 }
1043 }
1044 "GETPRICE" => {
1046 if args.len() < 2 || args.len() > 3 {
1047 return Err(QueryError::InvalidArguments(
1048 "GETPRICE".to_string(),
1049 "expected 2 or 3 arguments".to_string(),
1050 ));
1051 }
1052 let base = match &args[0] {
1054 Value::String(s) => s.clone(),
1055 Value::Null => return Ok(Value::Null),
1056 _ => {
1057 return Err(QueryError::Type(
1058 "GETPRICE: first argument must be a currency string".to_string(),
1059 ));
1060 }
1061 };
1062 let quote = match &args[1] {
1063 Value::String(s) => s.clone(),
1064 Value::Null => return Ok(Value::Null),
1065 _ => {
1066 return Err(QueryError::Type(
1067 "GETPRICE: second argument must be a currency string".to_string(),
1068 ));
1069 }
1070 };
1071 let date = if args.len() == 3 {
1072 match &args[2] {
1073 Value::Date(d) => *d,
1074 Value::Null => self.query_date,
1075 _ => self.query_date,
1076 }
1077 } else {
1078 self.query_date
1079 };
1080 match self.price_db.get_price(&base, "e, date) {
1081 Some(price) => Ok(Value::Number(price)),
1082 None => Ok(Value::Null),
1083 }
1084 }
1085 "EMPTY" => {
1087 Self::require_args_count(&name_upper, args, 1)?;
1088 match &args[0] {
1089 Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
1090 Value::Null => Ok(Value::Boolean(true)),
1091 _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
1092 }
1093 }
1094 "FILTER_CURRENCY" => {
1095 Self::require_args_count(&name_upper, args, 2)?;
1096 let currency = match &args[1] {
1097 Value::String(s) => s.clone(),
1098 _ => {
1099 return Err(QueryError::Type(
1100 "FILTER_CURRENCY expects (inventory, string)".to_string(),
1101 ));
1102 }
1103 };
1104 match &args[0] {
1105 Value::Inventory(inv) => {
1106 let filtered: Vec<Position> = inv
1107 .positions()
1108 .filter(|p| p.units.currency.as_str() == currency)
1109 .cloned()
1110 .collect();
1111 let mut new_inv = Inventory::new();
1112 for pos in filtered {
1113 new_inv.add(pos);
1114 }
1115 Ok(Value::Inventory(Box::new(new_inv)))
1116 }
1117 Value::Null => Ok(Value::Null),
1118 _ => Err(QueryError::Type(
1119 "FILTER_CURRENCY expects (inventory, string)".to_string(),
1120 )),
1121 }
1122 }
1123 "POSSIGN" => {
1124 Self::require_args_count(&name_upper, args, 2)?;
1125 let account_str = match &args[1] {
1126 Value::String(s) => s.clone(),
1127 _ => {
1128 return Err(QueryError::Type(
1129 "POSSIGN expects (amount, account_string)".to_string(),
1130 ));
1131 }
1132 };
1133 let first_component = account_str.split(':').next().unwrap_or("");
1134 let is_credit_normal =
1135 matches!(first_component, "Liabilities" | "Equity" | "Income");
1136 match &args[0] {
1137 Value::Amount(a) => {
1138 let mut amt = a.clone();
1139 if is_credit_normal {
1140 amt.number = -amt.number;
1141 }
1142 Ok(Value::Amount(amt))
1143 }
1144 Value::Number(n) => {
1145 let adjusted = if is_credit_normal { -n } else { *n };
1146 Ok(Value::Number(adjusted))
1147 }
1148 Value::Null => Ok(Value::Null),
1149 _ => Err(QueryError::Type(
1150 "POSSIGN expects (amount, account_string)".to_string(),
1151 )),
1152 }
1153 }
1154 "CONVERT" => {
1156 if args.len() < 2 || args.len() > 3 {
1157 return Err(QueryError::InvalidArguments(
1158 "CONVERT".to_string(),
1159 "expected 2 or 3 arguments: (value, currency[, date])".to_string(),
1160 ));
1161 }
1162
1163 let target_currency = match &args[1] {
1164 Value::String(s) => s.clone(),
1165 Value::Null => {
1166 return Err(QueryError::Type(
1167 concat!(
1168 "CONVERT: second argument evaluated to NULL; ",
1169 "expected a currency string ",
1170 "(this often means an aggregate expression couldn't ",
1171 "evaluate against an empty group — see issue #902)",
1172 )
1173 .to_string(),
1174 ));
1175 }
1176 _ => {
1177 return Err(QueryError::Type(
1178 "CONVERT: second argument must be a currency string".to_string(),
1179 ));
1180 }
1181 };
1182
1183 let date: Option<rustledger_core::NaiveDate> = if args.len() == 3 {
1185 match &args[2] {
1186 Value::Date(d) => Some(*d),
1187 Value::Null => None, _ => {
1189 return Err(QueryError::Type(
1190 "CONVERT: third argument must be a date".to_string(),
1191 ));
1192 }
1193 }
1194 } else {
1195 None
1196 };
1197
1198 let convert_amount = |amt: &Amount| -> Option<Amount> {
1200 if let Some(d) = date {
1201 self.price_db.convert(amt, &target_currency, d)
1202 } else {
1203 self.price_db.convert_latest(amt, &target_currency)
1204 }
1205 };
1206
1207 match &args[0] {
1208 Value::Position(p) => {
1209 if p.units.currency == target_currency {
1210 Ok(Value::Amount(p.units.clone()))
1211 } else if let Some(converted) = convert_amount(&p.units) {
1212 Ok(Value::Amount(converted))
1213 } else {
1214 Ok(Value::Amount(p.units.clone()))
1215 }
1216 }
1217 Value::Amount(a) => {
1218 if a.currency == target_currency {
1219 Ok(Value::Amount(a.clone()))
1220 } else if let Some(converted) = convert_amount(a) {
1221 Ok(Value::Amount(converted))
1222 } else {
1223 Ok(Value::Amount(a.clone()))
1224 }
1225 }
1226 Value::Inventory(inv) => {
1227 let mut result = Inventory::default();
1230 for pos in inv.positions() {
1231 if pos.units.currency == target_currency {
1232 result.add(Position::simple(pos.units.clone()));
1233 } else if let Some(converted) = convert_amount(&pos.units) {
1234 result.add(Position::simple(converted));
1235 } else {
1236 result.add(Position::simple(pos.units.clone()));
1238 }
1239 }
1240 let positions: Vec<&Position> = result.positions().collect();
1243 if positions.is_empty() {
1244 Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1245 } else if positions.len() == 1
1246 && positions[0].units.currency == target_currency
1247 {
1248 Ok(Value::Amount(positions[0].units.clone()))
1249 } else {
1250 Ok(Value::Inventory(Box::new(result)))
1251 }
1252 }
1253 Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
1254 Value::Null => {
1255 Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1258 }
1259 _ => Err(QueryError::Type(
1260 "CONVERT expects a position, amount, inventory, or number".to_string(),
1261 )),
1262 }
1263 }
1264 "STR" => {
1266 Self::require_args_count(&name_upper, args, 1)?;
1267 Self::value_to_str(&args[0])
1268 }
1269 "INT" => {
1270 Self::require_args_count(&name_upper, args, 1)?;
1271 Self::value_to_int(&args[0])
1272 }
1273 "DECIMAL" => {
1274 Self::require_args_count(&name_upper, args, 1)?;
1275 Self::value_to_decimal(&args[0])
1276 }
1277 "BOOL" => {
1278 Self::require_args_count(&name_upper, args, 1)?;
1279 Self::value_to_bool(&args[0])
1280 }
1281 "QUARTER" => {
1283 Self::require_args_count(&name_upper, args, 1)?;
1284 match &args[0] {
1285 Value::Date(d) => Ok(Value::Integer(((d.month() - 1) / 3 + 1).into())),
1286 _ => Err(QueryError::Type("QUARTER expects a date".to_string())),
1287 }
1288 }
1289 "WEEKDAY" => {
1290 Self::require_args_count(&name_upper, args, 1)?;
1291 match &args[0] {
1292 Value::Date(d) => Ok(Value::Integer(
1293 (d.weekday().to_monday_zero_offset() as u32).into(),
1294 )),
1295 _ => Err(QueryError::Type("WEEKDAY expects a date".to_string())),
1296 }
1297 }
1298 "YMONTH" => {
1299 Self::require_args_count(&name_upper, args, 1)?;
1300 match &args[0] {
1301 Value::Date(d) => {
1302 Ok(Value::String(format!("{:04}-{:02}", d.year(), d.month())))
1303 }
1304 _ => Err(QueryError::Type("YMONTH expects a date".to_string())),
1305 }
1306 }
1307 "SUBSTR" | "SUBSTRING" => {
1309 if args.len() < 2 || args.len() > 3 {
1310 return Err(QueryError::InvalidArguments(
1311 name_upper,
1312 "expected 2 or 3 arguments".to_string(),
1313 ));
1314 }
1315 match (&args[0], &args[1], args.get(2)) {
1316 (Value::String(s), Value::Integer(start), None) => {
1317 let start = (*start).max(0) as usize;
1318 let result: String = s.chars().skip(start).collect();
1319 Ok(Value::String(result))
1320 }
1321 (Value::String(s), Value::Integer(start), Some(Value::Integer(len))) => {
1322 let start = (*start).max(0) as usize;
1323 let len = (*len).max(0) as usize;
1324 let result: String = s.chars().skip(start).take(len).collect();
1325 Ok(Value::String(result))
1326 }
1327 _ => Err(QueryError::Type(
1328 "SUBSTR expects (string, int, [int])".to_string(),
1329 )),
1330 }
1331 }
1332 "STARTSWITH" => {
1333 Self::require_args_count(&name_upper, args, 2)?;
1334 match (&args[0], &args[1]) {
1335 (Value::String(s), Value::String(prefix)) => {
1336 Ok(Value::Boolean(s.starts_with(prefix.as_str())))
1337 }
1338 _ => Err(QueryError::Type(
1339 "STARTSWITH expects two strings".to_string(),
1340 )),
1341 }
1342 }
1343 "ENDSWITH" => {
1344 Self::require_args_count(&name_upper, args, 2)?;
1345 match (&args[0], &args[1]) {
1346 (Value::String(s), Value::String(suffix)) => {
1347 Ok(Value::Boolean(s.ends_with(suffix.as_str())))
1348 }
1349 _ => Err(QueryError::Type("ENDSWITH expects two strings".to_string())),
1350 }
1351 }
1352 "MAXWIDTH" => {
1353 Self::require_args_count(&name_upper, args, 2)?;
1354 match (&args[0], &args[1]) {
1355 (Value::String(s), Value::Integer(max)) => {
1356 let n = *max as usize;
1357 if s.chars().count() <= n {
1358 Ok(Value::String(s.clone()))
1359 } else if n <= 3 {
1360 Ok(Value::String(s.chars().take(n).collect()))
1361 } else {
1362 let truncated: String = s.chars().take(n - 3).collect();
1363 Ok(Value::String(format!("{truncated}...")))
1364 }
1365 }
1366 _ => Err(QueryError::Type(
1367 "MAXWIDTH expects (string, integer)".to_string(),
1368 )),
1369 }
1370 }
1371 "ACCOUNT_DEPTH" => {
1373 Self::require_args_count(&name_upper, args, 1)?;
1374 match &args[0] {
1375 Value::String(s) => Ok(Value::Integer(s.matches(':').count() as i64 + 1)),
1376 _ => Err(QueryError::Type(
1377 "ACCOUNT_DEPTH expects an account string".to_string(),
1378 )),
1379 }
1380 }
1381 "GETITEM" | "GET" => {
1383 Self::require_args_count(&name_upper, args, 2)?;
1384 match (&args[0], &args[1]) {
1385 (Value::Inventory(inv), Value::String(currency)) => {
1386 let amount = inv.units(currency);
1387 if amount.is_zero() {
1388 Ok(Value::Null)
1389 } else {
1390 Ok(Value::Amount(Amount::new(amount, currency.as_str())))
1391 }
1392 }
1393 (Value::Null, _) => Ok(Value::Null),
1394 _ => Err(QueryError::Type(
1395 "GETITEM expects (inventory, string)".to_string(),
1396 )),
1397 }
1398 }
1399 "WEIGHT" => {
1400 Self::require_args_count(&name_upper, args, 1)?;
1401 match &args[0] {
1402 Value::Position(p) => {
1403 if let Some(cost) = &p.cost {
1404 let total = p.units.number * cost.number;
1405 Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
1406 } else {
1407 Ok(Value::Amount(p.units.clone()))
1408 }
1409 }
1410 Value::Amount(a) => Ok(Value::Amount(a.clone())),
1411 Value::Inventory(inv) => {
1412 let mut result = Inventory::new();
1413 for pos in inv.positions() {
1414 if let Some(cost) = &pos.cost {
1415 let total = pos.units.number * cost.number;
1416 result.add(Position::simple(Amount::new(
1417 total,
1418 cost.currency.clone(),
1419 )));
1420 } else {
1421 result.add(Position::simple(pos.units.clone()));
1422 }
1423 }
1424 Ok(Value::Inventory(Box::new(result)))
1425 }
1426 Value::Null => Ok(Value::Null),
1427 _ => Err(QueryError::Type(
1428 "WEIGHT expects a position, amount, or inventory".to_string(),
1429 )),
1430 }
1431 }
1432 "DATE_DIFF" => {
1434 Self::require_args_count(&name_upper, args, 2)?;
1435 match (&args[0], &args[1]) {
1436 (Value::Date(d1), Value::Date(d2)) => Ok(Value::Integer(i64::from(
1437 d1.since(*d2).unwrap_or_default().get_days(),
1438 ))),
1439 _ => Err(QueryError::Type("DATE_DIFF expects two dates".to_string())),
1440 }
1441 }
1442 "GREP" => {
1444 Self::require_args_count(&name_upper, args, 2)?;
1445 match (&args[0], &args[1]) {
1446 (Value::String(pattern), Value::String(s)) => {
1447 let re = regex::Regex::new(pattern).map_err(|e| {
1448 QueryError::Type(format!("GREP: invalid regex '{pattern}': {e}"))
1449 })?;
1450 match re.find(s) {
1451 Some(m) => Ok(Value::String(m.as_str().to_string())),
1452 None => Ok(Value::Null),
1453 }
1454 }
1455 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1457 _ => Err(QueryError::Type("GREP expects two strings".to_string())),
1458 }
1459 }
1460 "GREPN" => {
1461 Self::require_args_count(&name_upper, args, 3)?;
1462 let n = match &args[2] {
1463 Value::Integer(i) => (*i).max(0) as usize,
1464 Value::Number(n) => {
1465 use rust_decimal::prelude::ToPrimitive;
1466 n.to_usize().unwrap_or(0)
1467 }
1468 _ => {
1469 return Err(QueryError::Type(
1470 "GREPN: third argument must be an integer".to_string(),
1471 ));
1472 }
1473 };
1474 match (&args[0], &args[1]) {
1475 (Value::String(pattern), Value::String(s)) => {
1476 let re = regex::Regex::new(pattern).map_err(|e| {
1477 QueryError::Type(format!("GREPN: invalid regex '{pattern}': {e}"))
1478 })?;
1479 match re.captures(s) {
1480 Some(caps) => match caps.get(n) {
1481 Some(m) => Ok(Value::String(m.as_str().to_string())),
1482 None => Ok(Value::Null),
1483 },
1484 None => Ok(Value::Null),
1485 }
1486 }
1487 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1488 _ => Err(QueryError::Type(
1489 "GREPN expects (pattern, string, int)".to_string(),
1490 )),
1491 }
1492 }
1493 "SUBST" => {
1494 Self::require_args_count(&name_upper, args, 3)?;
1495 match (&args[0], &args[1], &args[2]) {
1496 (Value::String(pattern), Value::String(replacement), Value::String(s)) => {
1497 let re = regex::Regex::new(pattern).map_err(|e| {
1498 QueryError::Type(format!("SUBST: invalid regex '{pattern}': {e}"))
1499 })?;
1500 Ok(Value::String(
1501 re.replace_all(s, replacement.as_str()).to_string(),
1502 ))
1503 }
1504 _ => Err(QueryError::Type(
1505 "SUBST expects (pattern, replacement, string)".to_string(),
1506 )),
1507 }
1508 }
1509 "SPLITCOMP" => {
1510 Self::require_args_count(&name_upper, args, 3)?;
1511 let n = match &args[2] {
1512 Value::Integer(i) => (*i).max(0) as usize,
1513 Value::Number(n) => {
1514 use rust_decimal::prelude::ToPrimitive;
1515 n.to_usize().unwrap_or(0)
1516 }
1517 _ => {
1518 return Err(QueryError::Type(
1519 "SPLITCOMP: third argument must be an integer".to_string(),
1520 ));
1521 }
1522 };
1523 match (&args[0], &args[1]) {
1524 (Value::String(s), Value::String(delim)) => {
1525 let parts: Vec<&str> = s.split(delim.as_str()).collect();
1526 match parts.get(n) {
1527 Some(part) => Ok(Value::String((*part).to_string())),
1528 None => Ok(Value::Null),
1529 }
1530 }
1531 _ => Err(QueryError::Type(
1532 "SPLITCOMP expects (string, delimiter, int)".to_string(),
1533 )),
1534 }
1535 }
1536 "JOINSTR" => {
1537 let mut parts = Vec::new();
1538 for v in args {
1539 match v {
1540 Value::String(s) => parts.push(s.clone()),
1541 Value::StringSet(ss) => parts.extend(ss.iter().cloned()),
1542 Value::Integer(i) => parts.push(i.to_string()),
1543 Value::Number(n) => parts.push(n.to_string()),
1544 Value::Null => {}
1545 _ => {}
1546 }
1547 }
1548 Ok(Value::String(parts.join(",")))
1549 }
1550 "OPEN_DATE" => {
1552 Self::require_args_count(&name_upper, args, 1)?;
1553 match &args[0] {
1554 Value::String(account) => Ok(self
1555 .account_info
1556 .get(account.as_str())
1557 .and_then(|info| info.open_date)
1558 .map_or(Value::Null, Value::Date)),
1559 Value::Null => Ok(Value::Null),
1560 _ => Err(QueryError::Type(
1561 "OPEN_DATE expects an account string".to_string(),
1562 )),
1563 }
1564 }
1565 "CLOSE_DATE" => {
1566 Self::require_args_count(&name_upper, args, 1)?;
1567 match &args[0] {
1568 Value::String(account) => Ok(self
1569 .account_info
1570 .get(account.as_str())
1571 .and_then(|info| info.close_date)
1572 .map_or(Value::Null, Value::Date)),
1573 Value::Null => Ok(Value::Null),
1574 _ => Err(QueryError::Type(
1575 "CLOSE_DATE expects an account string".to_string(),
1576 )),
1577 }
1578 }
1579 "OPEN_META" => {
1580 Self::require_args_count(&name_upper, args, 2)?;
1581 match (&args[0], &args[1]) {
1582 (Value::String(account), Value::String(key)) => Ok(self
1583 .account_info
1584 .get(account.as_str())
1585 .and_then(|info| info.open_meta.get(key))
1586 .map_or(Value::Null, |mv| Self::meta_value_to_value(Some(mv)))),
1587 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1588 _ => Err(QueryError::Type(
1589 "OPEN_META expects (account_string, key_string)".to_string(),
1590 )),
1591 }
1592 }
1593 "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
1597 Self::require_args_count(&name_upper, args, 1)?;
1598 match &args[0] {
1599 Value::String(_) | Value::Null => Ok(Value::Null),
1600 _ => Err(QueryError::Type(format!(
1601 "{name_upper}: argument must be a string key"
1602 ))),
1603 }
1604 }
1605 "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1607 _ => Err(QueryError::UnknownFunction(name.to_string())),
1608 }
1609 }
1610
1611 fn metadata_to_value(meta: &rustledger_core::Metadata) -> Value {
1613 if meta.is_empty() {
1614 return Value::Null;
1615 }
1616 let map: std::collections::BTreeMap<String, Value> = meta
1617 .iter()
1618 .map(|(k, v)| (k.clone(), Self::meta_value_to_value(Some(v))))
1619 .collect();
1620 Value::Object(Box::new(map))
1621 }
1622
1623 fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1625 if args.len() != expected {
1626 return Err(QueryError::InvalidArguments(
1627 name.to_string(),
1628 format!("expected {} argument(s), got {}", expected, args.len()),
1629 ));
1630 }
1631 Ok(())
1632 }
1633
1634 fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1636 if func.args.len() != expected {
1637 return Err(QueryError::InvalidArguments(
1638 name.to_string(),
1639 format!("expected {expected} argument(s)"),
1640 ));
1641 }
1642 Ok(())
1643 }
1644
1645 pub(crate) fn convert_to_market_value(
1680 &self,
1681 val: &Value,
1682 explicit_currency: Option<&str>,
1683 at_date: Option<NaiveDate>,
1684 ) -> Result<Value, QueryError> {
1685 let target_currency = if let Some(currency) = explicit_currency {
1690 currency.to_string()
1691 } else {
1692 let inferred = match val {
1694 Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1695 Value::Inventory(inv) => inv
1696 .positions()
1697 .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1698 _ => None,
1699 };
1700
1701 match inferred.or_else(|| self.target_currency.clone()) {
1702 Some(c) => c,
1703 None => {
1704 return match val {
1709 Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1710 Value::Amount(a) => Ok(Value::Amount(a.clone())),
1711 Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1712 Value::Null => Ok(Value::Null),
1713 _ => Err(QueryError::Type(
1714 "VALUE expects a position, amount, or inventory".to_string(),
1715 )),
1716 };
1717 }
1718 }
1719 };
1720
1721 let convert_one = |amount: &Amount| -> Option<Amount> {
1726 match at_date {
1727 Some(d) => self.price_db.convert(amount, &target_currency, d),
1728 None => self.price_db.convert_latest(amount, &target_currency),
1729 }
1730 };
1731
1732 match val {
1733 Value::Position(p) => {
1734 if p.units.currency == target_currency {
1735 Ok(Value::Amount(p.units.clone()))
1736 } else if let Some(converted) = convert_one(&p.units) {
1737 Ok(Value::Amount(converted))
1738 } else {
1739 Ok(Value::Amount(p.units.clone()))
1740 }
1741 }
1742 Value::Amount(a) => {
1743 if a.currency == target_currency {
1744 Ok(Value::Amount(a.clone()))
1745 } else if let Some(converted) = convert_one(a) {
1746 Ok(Value::Amount(converted))
1747 } else {
1748 Ok(Value::Amount(a.clone()))
1749 }
1750 }
1751 Value::Inventory(inv) => {
1752 let mut total = Decimal::ZERO;
1753 for pos in inv.positions() {
1754 if pos.units.currency == target_currency {
1755 total += pos.units.number;
1756 } else if let Some(converted) = convert_one(&pos.units) {
1757 total += converted.number;
1758 }
1759 }
1760 Ok(Value::Amount(Amount::new(total, &target_currency)))
1761 }
1762 Value::Null => Ok(Value::Null),
1763 _ => Err(QueryError::Type(
1764 "VALUE expects a position, amount, or inventory".to_string(),
1765 )),
1766 }
1767 }
1768
1769 pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1771 matches!(expr, Expr::Window(_))
1772 }
1773
1774 fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1776 let mut names = Vec::new();
1777 for (i, target) in targets.iter().enumerate() {
1778 if matches!(target.expr, Expr::Wildcard) {
1779 if target.alias.is_some() {
1781 return Err(QueryError::Evaluation(
1782 "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1783 ));
1784 }
1785 names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1787 } else if let Some(alias) = &target.alias {
1788 names.push(alias.clone());
1789 } else {
1790 names.push(self.expr_to_name(&target.expr, i));
1791 }
1792 }
1793 Ok(names)
1794 }
1795
1796 fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1798 match expr {
1799 Expr::Wildcard => "*".to_string(),
1800 Expr::Column(name) => name.clone(),
1801 Expr::Function(func) => func.name.clone(),
1802 Expr::Window(wf) => wf.name.clone(),
1803 _ => format!("col{index}"),
1804 }
1805 }
1806
1807 pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1826 let upper = table_name.to_uppercase();
1830 let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1831
1832 match normalized {
1833 "PRICES" => Some(self.build_prices_table()),
1834 "BALANCES" => Some(self.build_balances_table()),
1835 "COMMODITIES" => Some(self.build_commodities_table()),
1836 "EVENTS" => Some(self.build_events_table()),
1837 "NOTES" => Some(self.build_notes_table()),
1838 "DOCUMENTS" => Some(self.build_documents_table()),
1839 "ACCOUNTS" => Some(self.build_accounts_table()),
1840 "TRANSACTIONS" => Some(self.build_transactions_table()),
1841 "ENTRIES" => Some(self.build_entries_table()),
1842 "POSTINGS" => Some(self.build_postings_table()),
1843 _ => None,
1844 }
1845 }
1846
1847 fn build_prices_table(&self) -> Table {
1861 let columns = vec![
1862 "date".to_string(),
1863 "currency".to_string(),
1864 "amount".to_string(),
1865 ];
1866 let mut table = Table::new(columns);
1867
1868 let mut entries: Vec<_> = self.price_db.iter_explicit_entries().collect();
1873 entries.sort_by(|(currency_a, date_a, _, _), (currency_b, date_b, _, _)| {
1875 date_a.cmp(date_b).then_with(|| currency_a.cmp(currency_b))
1876 });
1877
1878 for (base_currency, date, price_number, quote_currency) in entries {
1879 let row = vec![
1880 Value::Date(date),
1881 Value::String(base_currency.to_string()),
1882 Value::Amount(Amount::new(price_number, quote_currency)),
1883 ];
1884 table.add_row(row);
1885 }
1886
1887 table
1888 }
1889
1890 fn build_balances_table(&self) -> Table {
1897 let columns = vec![
1898 "date".to_string(),
1899 "account".to_string(),
1900 "amount".to_string(),
1901 ];
1902 let mut table = Table::new(columns);
1903
1904 let mut balances: Vec<_> = if let Some(spanned) = self.spanned_directives {
1906 spanned
1907 .iter()
1908 .filter_map(|s| {
1909 if let Directive::Balance(b) = &s.value {
1910 Some((b.date, b.account.as_ref(), b.amount.clone()))
1911 } else {
1912 None
1913 }
1914 })
1915 .collect()
1916 } else {
1917 self.directives
1918 .iter()
1919 .filter_map(|d| {
1920 if let Directive::Balance(b) = d {
1921 Some((b.date, b.account.as_ref(), b.amount.clone()))
1922 } else {
1923 None
1924 }
1925 })
1926 .collect()
1927 };
1928
1929 balances.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
1931 date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
1932 });
1933
1934 for (date, account, amount) in balances {
1935 let row = vec![
1936 Value::Date(date),
1937 Value::String(account.to_string()),
1938 Value::Amount(amount),
1939 ];
1940 table.add_row(row);
1941 }
1942
1943 table
1944 }
1945
1946 fn build_commodities_table(&self) -> Table {
1952 let columns = vec!["date".to_string(), "name".to_string()];
1953 let mut table = Table::new(columns);
1954
1955 let mut commodities: Vec<_> = if let Some(spanned) = self.spanned_directives {
1957 spanned
1958 .iter()
1959 .filter_map(|s| {
1960 if let Directive::Commodity(c) = &s.value {
1961 Some((c.date, c.currency.as_ref()))
1962 } else {
1963 None
1964 }
1965 })
1966 .collect()
1967 } else {
1968 self.directives
1969 .iter()
1970 .filter_map(|d| {
1971 if let Directive::Commodity(c) = d {
1972 Some((c.date, c.currency.as_ref()))
1973 } else {
1974 None
1975 }
1976 })
1977 .collect()
1978 };
1979
1980 commodities.sort_by(|(date_a, name_a), (date_b, name_b)| {
1982 date_a.cmp(date_b).then_with(|| name_a.cmp(name_b))
1983 });
1984
1985 for (date, name) in commodities {
1986 let row = vec![Value::Date(date), Value::String(name.to_string())];
1987 table.add_row(row);
1988 }
1989
1990 table
1991 }
1992
1993 fn build_events_table(&self) -> Table {
2000 let columns = vec![
2001 "date".to_string(),
2002 "type".to_string(),
2003 "description".to_string(),
2004 ];
2005 let mut table = Table::new(columns);
2006
2007 let mut events: Vec<_> = if let Some(spanned) = self.spanned_directives {
2009 spanned
2010 .iter()
2011 .filter_map(|s| {
2012 if let Directive::Event(e) = &s.value {
2013 Some((e.date, e.event_type.as_str(), e.value.as_str()))
2014 } else {
2015 None
2016 }
2017 })
2018 .collect()
2019 } else {
2020 self.directives
2021 .iter()
2022 .filter_map(|d| {
2023 if let Directive::Event(e) = d {
2024 Some((e.date, e.event_type.as_str(), e.value.as_str()))
2025 } else {
2026 None
2027 }
2028 })
2029 .collect()
2030 };
2031
2032 events.sort_by(|(date_a, type_a, _), (date_b, type_b, _)| {
2034 date_a.cmp(date_b).then_with(|| type_a.cmp(type_b))
2035 });
2036
2037 for (date, event_type, description) in events {
2038 let row = vec![
2039 Value::Date(date),
2040 Value::String(event_type.to_string()),
2041 Value::String(description.to_string()),
2042 ];
2043 table.add_row(row);
2044 }
2045
2046 table
2047 }
2048
2049 fn build_notes_table(&self) -> Table {
2056 let columns = vec![
2057 "date".to_string(),
2058 "account".to_string(),
2059 "comment".to_string(),
2060 ];
2061 let mut table = Table::new(columns);
2062
2063 let mut notes: Vec<_> = if let Some(spanned) = self.spanned_directives {
2065 spanned
2066 .iter()
2067 .filter_map(|s| {
2068 if let Directive::Note(n) = &s.value {
2069 Some((n.date, n.account.as_ref(), n.comment.as_str()))
2070 } else {
2071 None
2072 }
2073 })
2074 .collect()
2075 } else {
2076 self.directives
2077 .iter()
2078 .filter_map(|d| {
2079 if let Directive::Note(n) = d {
2080 Some((n.date, n.account.as_ref(), n.comment.as_str()))
2081 } else {
2082 None
2083 }
2084 })
2085 .collect()
2086 };
2087
2088 notes.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
2090 date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
2091 });
2092
2093 for (date, account, comment) in notes {
2094 let row = vec![
2095 Value::Date(date),
2096 Value::String(account.to_string()),
2097 Value::String(comment.to_string()),
2098 ];
2099 table.add_row(row);
2100 }
2101
2102 table
2103 }
2104
2105 fn build_documents_table(&self) -> Table {
2114 let columns = vec![
2115 "date".to_string(),
2116 "account".to_string(),
2117 "filename".to_string(),
2118 "tags".to_string(),
2119 "links".to_string(),
2120 ];
2121 let mut table = Table::new(columns);
2122
2123 let mut documents: Vec<_> = if let Some(spanned) = self.spanned_directives {
2125 spanned
2126 .iter()
2127 .filter_map(|s| {
2128 if let Directive::Document(d) = &s.value {
2129 Some((
2130 d.date,
2131 d.account.as_ref(),
2132 d.path.as_str(),
2133 &d.tags,
2134 &d.links,
2135 ))
2136 } else {
2137 None
2138 }
2139 })
2140 .collect()
2141 } else {
2142 self.directives
2143 .iter()
2144 .filter_map(|d| {
2145 if let Directive::Document(doc) = d {
2146 Some((
2147 doc.date,
2148 doc.account.as_ref(),
2149 doc.path.as_str(),
2150 &doc.tags,
2151 &doc.links,
2152 ))
2153 } else {
2154 None
2155 }
2156 })
2157 .collect()
2158 };
2159
2160 documents.sort_by(
2162 |(date_a, account_a, file_a, _, _), (date_b, account_b, file_b, _, _)| {
2163 date_a
2164 .cmp(date_b)
2165 .then_with(|| account_a.cmp(account_b))
2166 .then_with(|| file_a.cmp(file_b))
2167 },
2168 );
2169
2170 for (date, account, filename, tags, links) in documents {
2171 let tags_vec: Vec<String> = tags.iter().map(ToString::to_string).collect();
2172 let links_vec: Vec<String> = links.iter().map(ToString::to_string).collect();
2173 let row = vec![
2174 Value::Date(date),
2175 Value::String(account.to_string()),
2176 Value::String(filename.to_string()),
2177 Value::StringSet(tags_vec),
2178 Value::StringSet(links_vec),
2179 ];
2180 table.add_row(row);
2181 }
2182
2183 table
2184 }
2185
2186 fn build_accounts_table(&self) -> Table {
2195 let columns = vec![
2196 "account".to_string(),
2197 "open".to_string(),
2198 "close".to_string(),
2199 "currencies".to_string(),
2200 "booking".to_string(),
2201 ];
2202 let mut table = Table::new(columns);
2203
2204 let mut accounts: FxHashMap<
2206 &str,
2207 (
2208 Option<rustledger_core::NaiveDate>,
2209 Option<rustledger_core::NaiveDate>,
2210 Vec<String>,
2211 Option<&str>,
2212 ),
2213 > = FxHashMap::default();
2214
2215 let iter: Box<dyn Iterator<Item = &Directive>> =
2217 if let Some(spanned) = self.spanned_directives {
2218 Box::new(spanned.iter().map(|s| &s.value))
2219 } else {
2220 Box::new(self.directives.iter())
2221 };
2222
2223 for directive in iter {
2224 match directive {
2225 Directive::Open(open) => {
2226 let entry = accounts.entry(open.account.as_ref()).or_insert((
2227 None,
2228 None,
2229 Vec::new(),
2230 None,
2231 ));
2232 entry.0 = Some(open.date);
2233 entry.2 = open.currencies.iter().map(ToString::to_string).collect();
2234 entry.3 = open.booking.as_deref();
2235 }
2236 Directive::Close(close) => {
2237 let entry = accounts.entry(close.account.as_ref()).or_insert((
2238 None,
2239 None,
2240 Vec::new(),
2241 None,
2242 ));
2243 entry.1 = Some(close.date);
2244 }
2245 _ => {}
2246 }
2247 }
2248
2249 let mut account_list: Vec<_> = accounts.into_iter().collect();
2251 account_list.sort_by_key(|(a, _)| *a);
2252
2253 for (account, (open_date, close_date, currencies, booking)) in account_list {
2254 let row = vec![
2255 Value::String(account.to_string()),
2256 open_date.map_or(Value::Null, Value::Date),
2257 close_date.map_or(Value::Null, Value::Date),
2258 Value::StringSet(currencies),
2259 booking.map_or(Value::Null, |b| Value::String(b.to_string())),
2260 ];
2261 table.add_row(row);
2262 }
2263
2264 table
2265 }
2266
2267 fn build_transactions_table(&self) -> Table {
2278 let columns = vec![
2279 "date".to_string(),
2280 "flag".to_string(),
2281 "payee".to_string(),
2282 "narration".to_string(),
2283 "tags".to_string(),
2284 "links".to_string(),
2285 "accounts".to_string(),
2286 ];
2287 let mut table = Table::new(columns);
2288
2289 let iter: Box<dyn Iterator<Item = &Directive>> =
2291 if let Some(spanned) = self.spanned_directives {
2292 Box::new(spanned.iter().map(|s| &s.value))
2293 } else {
2294 Box::new(self.directives.iter())
2295 };
2296
2297 let mut transactions: Vec<_> = iter
2298 .filter_map(|d| {
2299 if let Directive::Transaction(txn) = d {
2300 Some(txn)
2301 } else {
2302 None
2303 }
2304 })
2305 .collect();
2306
2307 transactions.sort_by_key(|t| t.date);
2309
2310 for txn in transactions {
2311 let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2312 let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2313 let mut accounts: Vec<String> = txn
2314 .postings
2315 .iter()
2316 .map(|p| p.account.to_string())
2317 .collect::<std::collections::HashSet<_>>()
2318 .into_iter()
2319 .collect();
2320 accounts.sort(); let row = vec![
2323 Value::Date(txn.date),
2324 Value::String(txn.flag.to_string()),
2325 txn.payee
2326 .as_ref()
2327 .map_or(Value::Null, |p| Value::String(p.to_string())),
2328 Value::String(txn.narration.to_string()),
2329 Value::StringSet(tags),
2330 Value::StringSet(links),
2331 Value::StringSet(accounts),
2332 ];
2333 table.add_row(row);
2334 }
2335
2336 table
2337 }
2338
2339 fn build_entries_table(&self) -> Table {
2344 let columns = vec![
2345 "id".to_string(),
2346 "type".to_string(),
2347 "filename".to_string(),
2348 "lineno".to_string(),
2349 "date".to_string(),
2350 "flag".to_string(),
2351 "payee".to_string(),
2352 "narration".to_string(),
2353 "tags".to_string(),
2354 "links".to_string(),
2355 "accounts".to_string(),
2356 "_entry_meta".to_string(),
2357 ];
2358 let mut table = Table::new(columns);
2359
2360 if let Some(spanned) = self.spanned_directives {
2362 for (idx, spanned_dir) in spanned.iter().enumerate() {
2363 let directive = &spanned_dir.value;
2364 let source_loc = self.get_source_location(idx);
2365 let row = self.directive_to_entry_row(idx, directive, source_loc);
2366 table.add_row(row);
2367 }
2368 } else {
2369 for (idx, directive) in self.directives.iter().enumerate() {
2370 let row = self.directive_to_entry_row(idx, directive, None);
2371 table.add_row(row);
2372 }
2373 }
2374
2375 table
2376 }
2377
2378 fn directive_to_entry_row(
2380 &self,
2381 idx: usize,
2382 directive: &Directive,
2383 source_loc: Option<&SourceLocation>,
2384 ) -> Vec<Value> {
2385 let type_name = match directive {
2386 Directive::Transaction(_) => "transaction",
2387 Directive::Balance(_) => "balance",
2388 Directive::Open(_) => "open",
2389 Directive::Close(_) => "close",
2390 Directive::Commodity(_) => "commodity",
2391 Directive::Pad(_) => "pad",
2392 Directive::Event(_) => "event",
2393 Directive::Query(_) => "query",
2394 Directive::Note(_) => "note",
2395 Directive::Document(_) => "document",
2396 Directive::Price(_) => "price",
2397 Directive::Custom(_) => "custom",
2398 };
2399
2400 let date = match directive {
2401 Directive::Transaction(t) => Value::Date(t.date),
2402 Directive::Balance(b) => Value::Date(b.date),
2403 Directive::Open(o) => Value::Date(o.date),
2404 Directive::Close(c) => Value::Date(c.date),
2405 Directive::Commodity(c) => Value::Date(c.date),
2406 Directive::Pad(p) => Value::Date(p.date),
2407 Directive::Event(e) => Value::Date(e.date),
2408 Directive::Query(q) => Value::Date(q.date),
2409 Directive::Note(n) => Value::Date(n.date),
2410 Directive::Document(d) => Value::Date(d.date),
2411 Directive::Price(p) => Value::Date(p.date),
2412 Directive::Custom(c) => Value::Date(c.date),
2413 };
2414
2415 let (flag, payee, narration, tags, links, accounts) =
2416 if let Directive::Transaction(txn) = directive {
2417 let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2418 let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2419 let mut accounts: Vec<String> = txn
2420 .postings
2421 .iter()
2422 .map(|p| p.account.to_string())
2423 .collect::<std::collections::HashSet<_>>()
2424 .into_iter()
2425 .collect();
2426 accounts.sort(); (
2428 Value::String(txn.flag.to_string()),
2429 txn.payee
2430 .as_ref()
2431 .map_or(Value::Null, |p| Value::String(p.to_string())),
2432 Value::String(txn.narration.to_string()),
2433 Value::StringSet(tags),
2434 Value::StringSet(links),
2435 Value::StringSet(accounts),
2436 )
2437 } else {
2438 (
2439 Value::Null,
2440 Value::Null,
2441 Value::Null,
2442 Value::StringSet(vec![]),
2443 Value::StringSet(vec![]),
2444 Value::StringSet(vec![]),
2445 )
2446 };
2447
2448 let filename = source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
2449 let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
2450
2451 vec![
2452 Value::Integer(idx as i64), Value::String(type_name.to_string()),
2454 filename,
2455 lineno,
2456 date,
2457 flag,
2458 payee,
2459 narration,
2460 tags,
2461 links,
2462 accounts,
2463 Self::metadata_to_value(directive.meta()),
2465 ]
2466 }
2467
2468 fn build_postings_table(&self) -> Table {
2472 let columns = vec![
2473 "type".to_string(),
2475 "id".to_string(),
2476 "date".to_string(),
2477 "year".to_string(),
2478 "month".to_string(),
2479 "day".to_string(),
2480 "filename".to_string(),
2481 "lineno".to_string(),
2482 "location".to_string(),
2483 "flag".to_string(),
2485 "payee".to_string(),
2486 "narration".to_string(),
2487 "description".to_string(),
2488 "tags".to_string(),
2489 "links".to_string(),
2490 "posting_flag".to_string(),
2492 "account".to_string(),
2493 "other_accounts".to_string(),
2494 "number".to_string(),
2495 "currency".to_string(),
2496 "cost_number".to_string(),
2497 "cost_currency".to_string(),
2498 "cost_date".to_string(),
2499 "cost_label".to_string(),
2500 "position".to_string(),
2501 "price".to_string(),
2502 "weight".to_string(),
2503 "balance".to_string(),
2504 "account_balance".to_string(),
2505 "meta".to_string(),
2507 "accounts".to_string(),
2508 "_entry_meta".to_string(),
2510 "_posting_meta".to_string(),
2511 ];
2512 let mut table = Table::new(columns);
2513
2514 let mut account_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
2516 let mut cumulative_balance: Inventory = Inventory::default();
2521
2522 let mut transactions: Vec<(usize, &rustledger_core::Transaction)> =
2524 if let Some(spanned) = self.spanned_directives {
2525 spanned
2526 .iter()
2527 .enumerate()
2528 .filter_map(|(idx, s)| {
2529 if let Directive::Transaction(txn) = &s.value {
2530 Some((idx, txn))
2531 } else {
2532 None
2533 }
2534 })
2535 .collect()
2536 } else {
2537 self.directives
2538 .iter()
2539 .enumerate()
2540 .filter_map(|(idx, d)| {
2541 if let Directive::Transaction(txn) = d {
2542 Some((idx, txn))
2543 } else {
2544 None
2545 }
2546 })
2547 .collect()
2548 };
2549 transactions.sort_by_key(|(_, t)| t.date);
2550
2551 for (dir_idx, txn) in &transactions {
2552 let source_loc = self.get_source_location(*dir_idx);
2554 let filename =
2555 source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
2556 let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
2557 let location = source_loc.map_or(Value::Null, |loc| {
2558 Value::String(format!("{}:{}", loc.filename, loc.lineno))
2559 });
2560
2561 let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2562 let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2563
2564 let mut all_accounts: Vec<String> = txn
2565 .postings
2566 .iter()
2567 .map(|p| p.account.to_string())
2568 .collect::<std::collections::HashSet<_>>()
2569 .into_iter()
2570 .collect();
2571 all_accounts.sort();
2572
2573 let description = match &txn.payee {
2574 Some(payee) => format!("{} | {}", payee, txn.narration),
2575 None => txn.narration.to_string(),
2576 };
2577
2578 let year = Value::Integer(i64::from(txn.date.year()));
2579 let month = Value::Integer(i64::from(txn.date.month()));
2580 let day = Value::Integer(i64::from(txn.date.day()));
2581
2582 for posting in &txn.postings {
2583 if let Some(units) = posting.amount() {
2585 let pos = if let Some(cost_spec) = &posting.cost {
2586 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
2587 Position::with_cost(units.clone(), cost)
2588 } else {
2589 Position::simple(units.clone())
2590 }
2591 } else {
2592 Position::simple(units.clone())
2593 };
2594 account_balances
2595 .entry(posting.account.clone())
2596 .or_default()
2597 .add(pos.clone());
2598 cumulative_balance.add(pos);
2599 }
2600
2601 let (number, currency) = posting.amount().map_or((Value::Null, Value::Null), |a| {
2603 (
2604 Value::Number(a.number),
2605 Value::String(a.currency.to_string()),
2606 )
2607 });
2608
2609 let (cost_number, cost_currency, cost_date, cost_label) = if let Some(cost_spec) =
2610 &posting.cost
2611 {
2612 let units = posting.amount();
2613 if let Some(cost) = units.and_then(|u| cost_spec.resolve(u.number, txn.date)) {
2614 (
2615 Value::Number(cost.number),
2616 Value::String(cost.currency.to_string()),
2617 cost.date.map_or(Value::Null, Value::Date),
2618 cost.label
2619 .as_ref()
2620 .map_or(Value::Null, |l| Value::String(l.clone())),
2621 )
2622 } else {
2623 (Value::Null, Value::Null, Value::Null, Value::Null)
2624 }
2625 } else {
2626 (Value::Null, Value::Null, Value::Null, Value::Null)
2627 };
2628
2629 let position_val = if let Some(units) = posting.amount() {
2630 if let Some(cost_spec) = &posting.cost
2631 && let Some(cost) = cost_spec.resolve(units.number, txn.date)
2632 {
2633 Value::Position(Box::new(Position::with_cost(units.clone(), cost)))
2634 } else {
2635 Value::Position(Box::new(Position::simple(units.clone())))
2636 }
2637 } else {
2638 Value::Null
2639 };
2640
2641 let price_val = posting
2642 .price
2643 .as_ref()
2644 .and_then(|p| p.amount())
2645 .map_or(Value::Null, |a| Value::Amount(a.clone()));
2646
2647 let weight_val = compute_posting_weight(posting, txn.date);
2652
2653 let balance_val = Value::Inventory(Box::new(cumulative_balance.clone()));
2654 let account_balance_val = account_balances
2655 .get(&posting.account)
2656 .map_or(Value::Null, |inv| Value::Inventory(Box::new(inv.clone())));
2657
2658 let other_accounts: Vec<String> = all_accounts
2660 .iter()
2661 .filter(|a| a.as_str() != posting.account.as_ref())
2662 .cloned()
2663 .collect();
2664
2665 let posting_flag = posting
2666 .flag
2667 .map_or(Value::Null, |f| Value::String(f.to_string()));
2668
2669 let row = vec![
2670 Value::String("transaction".to_string()),
2672 Value::Integer(*dir_idx as i64),
2673 Value::Date(txn.date),
2674 year.clone(),
2675 month.clone(),
2676 day.clone(),
2677 filename.clone(),
2678 lineno.clone(),
2679 location.clone(),
2680 Value::String(txn.flag.to_string()),
2682 txn.payee
2683 .as_ref()
2684 .map_or(Value::Null, |p| Value::String(p.to_string())),
2685 Value::String(txn.narration.to_string()),
2686 Value::String(description.clone()),
2687 Value::StringSet(tags.clone()),
2688 Value::StringSet(links.clone()),
2689 posting_flag,
2691 Value::String(posting.account.to_string()),
2692 Value::StringSet(other_accounts),
2693 number,
2694 currency,
2695 cost_number,
2696 cost_currency,
2697 cost_date,
2698 cost_label,
2699 position_val,
2700 price_val,
2701 weight_val,
2702 balance_val,
2703 account_balance_val,
2704 Value::Metadata(Box::new(posting.meta.clone())),
2706 Value::StringSet(all_accounts.clone()),
2707 Self::metadata_to_value(&txn.meta),
2709 Self::metadata_to_value(&posting.meta),
2710 ];
2711 table.add_row(row);
2712 }
2713 }
2714
2715 table
2716 }
2717}
2718
2719fn expr_references_column(expr: &Expr, name: &str) -> bool {
2728 match expr {
2729 Expr::Column(col) => col.eq_ignore_ascii_case(name),
2730 Expr::Function(call) => call.args.iter().any(|a| expr_references_column(a, name)),
2731 Expr::Window(call) => {
2732 call.args.iter().any(|a| expr_references_column(a, name))
2738 || call
2739 .over
2740 .partition_by
2741 .as_ref()
2742 .is_some_and(|ps| ps.iter().any(|p| expr_references_column(p, name)))
2743 || call
2744 .over
2745 .order_by
2746 .as_ref()
2747 .is_some_and(|os| os.iter().any(|o| expr_references_column(&o.expr, name)))
2748 }
2749 Expr::BinaryOp(op) => {
2750 expr_references_column(&op.left, name) || expr_references_column(&op.right, name)
2751 }
2752 Expr::UnaryOp(op) => expr_references_column(&op.operand, name),
2753 Expr::Paren(inner) => expr_references_column(inner, name),
2754 Expr::Between { value, low, high } => {
2755 expr_references_column(value, name)
2756 || expr_references_column(low, name)
2757 || expr_references_column(high, name)
2758 }
2759 Expr::Set(items) => items.iter().any(|i| expr_references_column(i, name)),
2760 Expr::Wildcard | Expr::Literal(_) => false,
2761 }
2762}
2763
2764fn query_references_column(query: &SelectQuery, name: &str) -> bool {
2770 if query
2771 .targets
2772 .iter()
2773 .any(|t| expr_references_column(&t.expr, name))
2774 {
2775 return true;
2776 }
2777 if let Some(w) = &query.where_clause
2778 && expr_references_column(w, name)
2779 {
2780 return true;
2781 }
2782 if let Some(g) = &query.group_by
2783 && g.iter().any(|e| expr_references_column(e, name))
2784 {
2785 return true;
2786 }
2787 if let Some(h) = &query.having
2788 && expr_references_column(h, name)
2789 {
2790 return true;
2791 }
2792 if let Some(p) = &query.pivot_by
2793 && p.iter().any(|e| expr_references_column(e, name))
2794 {
2795 return true;
2796 }
2797 if let Some(o) = &query.order_by
2798 && o.iter().any(|s| expr_references_column(&s.expr, name))
2799 {
2800 return true;
2801 }
2802 if let Some(from) = &query.from
2803 && let Some(f) = &from.filter
2804 && expr_references_column(f, name)
2805 {
2806 return true;
2807 }
2808 false
2809}
2810
2811#[cfg(test)]
2812mod tests {
2813 use super::types::{hash_row, hash_single_value};
2814 use super::*;
2815 use crate::parse;
2816 use rust_decimal_macros::dec;
2817 use rustledger_core::Posting;
2818
2819 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
2820 rustledger_core::naive_date(year, month, day).unwrap()
2821 }
2822
2823 fn sample_directives() -> Vec<Directive> {
2824 vec![
2825 Directive::Transaction(
2826 Transaction::new(date(2024, 1, 15), "Coffee")
2827 .with_flag('*')
2828 .with_payee("Coffee Shop")
2829 .with_posting(Posting::new(
2830 "Expenses:Food:Coffee",
2831 Amount::new(dec!(5.00), "USD"),
2832 ))
2833 .with_posting(Posting::new(
2834 "Assets:Bank:Checking",
2835 Amount::new(dec!(-5.00), "USD"),
2836 )),
2837 ),
2838 Directive::Transaction(
2839 Transaction::new(date(2024, 1, 16), "Groceries")
2840 .with_flag('*')
2841 .with_payee("Supermarket")
2842 .with_posting(Posting::new(
2843 "Expenses:Food:Groceries",
2844 Amount::new(dec!(50.00), "USD"),
2845 ))
2846 .with_posting(Posting::new(
2847 "Assets:Bank:Checking",
2848 Amount::new(dec!(-50.00), "USD"),
2849 )),
2850 ),
2851 ]
2852 }
2853
2854 #[test]
2855 fn test_simple_select() {
2856 let directives = sample_directives();
2857 let mut executor = Executor::new(&directives);
2858
2859 let query = parse("SELECT date, account").unwrap();
2860 let result = executor.execute(&query).unwrap();
2861
2862 assert_eq!(result.columns, vec!["date", "account"]);
2863 assert_eq!(result.len(), 4); }
2865
2866 #[test]
2867 fn test_where_clause() {
2868 let directives = sample_directives();
2869 let mut executor = Executor::new(&directives);
2870
2871 let query = parse("SELECT account WHERE account ~ \"Expenses:\"").unwrap();
2872 let result = executor.execute(&query).unwrap();
2873
2874 assert_eq!(result.len(), 2); }
2876
2877 #[test]
2878 fn test_balances() {
2879 let directives = sample_directives();
2880 let mut executor = Executor::new(&directives);
2881
2882 let query = parse("BALANCES").unwrap();
2883 let result = executor.execute(&query).unwrap();
2884
2885 assert_eq!(result.columns, vec!["account", "balance"]);
2886 assert!(result.len() >= 3); }
2888
2889 #[test]
2890 fn test_account_functions() {
2891 let directives = sample_directives();
2892 let mut executor = Executor::new(&directives);
2893
2894 let query = parse("SELECT DISTINCT LEAF(account) WHERE account ~ \"Expenses:\"").unwrap();
2896 let result = executor.execute(&query).unwrap();
2897 assert_eq!(result.len(), 2); let query = parse("SELECT DISTINCT ROOT(account)").unwrap();
2901 let result = executor.execute(&query).unwrap();
2902 assert_eq!(result.len(), 2); let query = parse("SELECT DISTINCT PARENT(account) WHERE account ~ \"Expenses:\"").unwrap();
2906 let result = executor.execute(&query).unwrap();
2907 assert!(!result.is_empty()); }
2909
2910 #[test]
2911 fn test_min_max_aggregate() {
2912 let directives = sample_directives();
2913 let mut executor = Executor::new(&directives);
2914
2915 let query = parse("SELECT MIN(date)").unwrap();
2917 let result = executor.execute(&query).unwrap();
2918 assert_eq!(result.len(), 1);
2919 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
2920
2921 let query = parse("SELECT MAX(date)").unwrap();
2923 let result = executor.execute(&query).unwrap();
2924 assert_eq!(result.len(), 1);
2925 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2926 }
2927
2928 #[test]
2929 fn test_order_by() {
2930 let directives = sample_directives();
2931 let mut executor = Executor::new(&directives);
2932
2933 let query = parse("SELECT date, account ORDER BY date DESC").unwrap();
2934 let result = executor.execute(&query).unwrap();
2935
2936 assert_eq!(result.len(), 4);
2938 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2940 }
2941
2942 #[test]
2943 fn test_hash_value_all_variants() {
2944 use rustledger_core::{Cost, Inventory, Position};
2945
2946 let values = vec![
2948 Value::String("test".to_string()),
2949 Value::Number(dec!(123.45)),
2950 Value::Integer(42),
2951 Value::Date(date(2024, 1, 15)),
2952 Value::Boolean(true),
2953 Value::Boolean(false),
2954 Value::Amount(Amount::new(dec!(100), "USD")),
2955 Value::Position(Box::new(Position::simple(Amount::new(dec!(10), "AAPL")))),
2956 Value::Position(Box::new(Position::with_cost(
2957 Amount::new(dec!(10), "AAPL"),
2958 Cost::new(dec!(150), "USD"),
2959 ))),
2960 Value::Inventory(Box::new(Inventory::new())),
2961 Value::StringSet(vec!["tag1".to_string(), "tag2".to_string()]),
2962 Value::Null,
2963 ];
2964
2965 for value in &values {
2967 let hash = hash_single_value(value);
2968 assert!(hash != 0 || matches!(value, Value::Null));
2969 }
2970
2971 let hash1 = hash_single_value(&Value::String("a".to_string()));
2973 let hash2 = hash_single_value(&Value::String("b".to_string()));
2974 assert_ne!(hash1, hash2);
2975
2976 let hash3 = hash_single_value(&Value::Integer(42));
2978 let hash4 = hash_single_value(&Value::Integer(42));
2979 assert_eq!(hash3, hash4);
2980 }
2981
2982 #[test]
2983 fn test_hash_row_distinct() {
2984 let row1 = vec![Value::String("a".to_string()), Value::Integer(1)];
2986 let row2 = vec![Value::String("a".to_string()), Value::Integer(1)];
2987 let row3 = vec![Value::String("b".to_string()), Value::Integer(1)];
2988
2989 assert_eq!(hash_row(&row1), hash_row(&row2));
2990 assert_ne!(hash_row(&row1), hash_row(&row3));
2991 }
2992
2993 #[test]
2994 fn test_string_set_hash_order_independent() {
2995 let set1 = Value::StringSet(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2997 let set2 = Value::StringSet(vec!["c".to_string(), "a".to_string(), "b".to_string()]);
2998 let set3 = Value::StringSet(vec!["b".to_string(), "c".to_string(), "a".to_string()]);
2999
3000 let hash1 = hash_single_value(&set1);
3001 let hash2 = hash_single_value(&set2);
3002 let hash3 = hash_single_value(&set3);
3003
3004 assert_eq!(hash1, hash2);
3005 assert_eq!(hash2, hash3);
3006 }
3007
3008 #[test]
3009 fn test_inventory_hash_includes_cost() {
3010 use rustledger_core::{Cost, Inventory, Position};
3011
3012 let mut inv1 = Inventory::new();
3014 inv1.add(Position::with_cost(
3015 Amount::new(dec!(10), "AAPL"),
3016 Cost::new(dec!(100), "USD"),
3017 ));
3018
3019 let mut inv2 = Inventory::new();
3020 inv2.add(Position::with_cost(
3021 Amount::new(dec!(10), "AAPL"),
3022 Cost::new(dec!(200), "USD"),
3023 ));
3024
3025 let hash1 = hash_single_value(&Value::Inventory(Box::new(inv1)));
3026 let hash2 = hash_single_value(&Value::Inventory(Box::new(inv2)));
3027
3028 assert_ne!(hash1, hash2);
3029 }
3030
3031 #[test]
3032 fn test_distinct_deduplication() {
3033 let directives = sample_directives();
3034 let mut executor = Executor::new(&directives);
3035
3036 let query = parse("SELECT flag").unwrap();
3038 let result = executor.execute(&query).unwrap();
3039 assert_eq!(result.len(), 4); let query = parse("SELECT DISTINCT flag").unwrap();
3043 let result = executor.execute(&query).unwrap();
3044 assert_eq!(result.len(), 1); }
3046
3047 #[test]
3048 fn test_limit_clause() {
3049 let directives = sample_directives();
3050 let mut executor = Executor::new(&directives);
3051
3052 let query = parse("SELECT date, account LIMIT 2").unwrap();
3054 let result = executor.execute(&query).unwrap();
3055 assert_eq!(result.len(), 2);
3056
3057 let query = parse("SELECT date LIMIT 0").unwrap();
3059 let result = executor.execute(&query).unwrap();
3060 assert_eq!(result.len(), 0);
3061
3062 let query = parse("SELECT date LIMIT 100").unwrap();
3064 let result = executor.execute(&query).unwrap();
3065 assert_eq!(result.len(), 4);
3066 }
3067
3068 #[test]
3069 fn test_group_by_with_count() {
3070 let directives = sample_directives();
3071 let mut executor = Executor::new(&directives);
3072
3073 let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
3075 let result = executor.execute(&query).unwrap();
3076
3077 assert_eq!(result.columns.len(), 2);
3078 assert_eq!(result.len(), 2);
3080 }
3081
3082 #[test]
3083 fn test_count_aggregate() {
3084 let directives = sample_directives();
3085 let mut executor = Executor::new(&directives);
3086
3087 let query = parse("SELECT COUNT(account)").unwrap();
3089 let result = executor.execute(&query).unwrap();
3090
3091 assert_eq!(result.len(), 1);
3092 assert_eq!(result.rows[0][0], Value::Integer(4));
3093
3094 let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
3096 let result = executor.execute(&query).unwrap();
3097 assert_eq!(result.len(), 2); }
3099
3100 #[test]
3101 fn test_count_wildcard_direct() {
3102 let directives = sample_directives();
3104 let mut executor = Executor::new(&directives);
3105
3106 let query = parse("SELECT count(*)").unwrap();
3108 let result = executor.execute(&query).unwrap();
3109 assert_eq!(result.len(), 1);
3110 assert_eq!(result.rows[0][0], Value::Integer(4)); let query = parse("SELECT account, count(*) GROUP BY account").unwrap();
3115 let result = executor.execute(&query).unwrap();
3116 assert_eq!(result.len(), 3); }
3118
3119 #[test]
3120 fn test_count_wildcard_from_postings_table() {
3121 let directives = sample_directives();
3123 let mut executor = Executor::new(&directives);
3124
3125 let query = parse("SELECT account, count(*) FROM postings GROUP BY account").unwrap();
3127 let result = executor.execute(&query).unwrap();
3128 assert_eq!(result.len(), 3);
3130 }
3131
3132 #[test]
3133 fn test_count_wildcard_from_entries_table() {
3134 let directives = sample_directives();
3136 let mut executor = Executor::new(&directives);
3137
3138 let query = parse("SELECT type, count(*) FROM entries GROUP BY type").unwrap();
3139 let result = executor.execute(&query).unwrap();
3140 assert_eq!(result.len(), 1);
3142 assert_eq!(result.rows[0][0], Value::String("transaction".to_string()));
3143 assert_eq!(result.rows[0][1], Value::Integer(2));
3144 }
3145
3146 #[test]
3147 fn test_count_wildcard_having() {
3148 let directives = sample_directives();
3150 let mut executor = Executor::new(&directives);
3151
3152 let query = parse(
3154 "SELECT account, count(*) AS cnt FROM postings GROUP BY account HAVING count(*) > 0",
3155 )
3156 .unwrap();
3157 let result = executor.execute(&query).unwrap();
3158 assert_eq!(result.len(), 3);
3159
3160 let query = parse(
3162 "SELECT account, count(*) AS cnt FROM postings GROUP BY account HAVING count(*) > 1",
3163 )
3164 .unwrap();
3165 let result = executor.execute(&query).unwrap();
3166 assert_eq!(result.len(), 1);
3167 assert_eq!(
3168 result.rows[0][0],
3169 Value::String("Assets:Bank:Checking".to_string())
3170 );
3171 assert_eq!(result.rows[0][1], Value::Integer(2));
3172 }
3173
3174 #[test]
3175 fn test_journal_query() {
3176 let directives = sample_directives();
3177 let mut executor = Executor::new(&directives);
3178
3179 let query = parse("JOURNAL \"Expenses\"").unwrap();
3181 let result = executor.execute(&query).unwrap();
3182
3183 assert!(result.columns.contains(&"account".to_string()));
3185 assert_eq!(result.len(), 2);
3187 }
3188
3189 #[test]
3190 fn test_print_query() {
3191 let directives = sample_directives();
3192 let mut executor = Executor::new(&directives);
3193
3194 let query = parse("PRINT").unwrap();
3196 let result = executor.execute(&query).unwrap();
3197
3198 assert_eq!(result.columns.len(), 1);
3200 assert_eq!(result.columns[0], "directive");
3201 assert_eq!(result.len(), 2);
3203 }
3204
3205 #[test]
3206 fn test_empty_directives() {
3207 let directives: Vec<Directive> = vec![];
3208 let mut executor = Executor::new(&directives);
3209
3210 let query = parse("SELECT date, account").unwrap();
3212 let result = executor.execute(&query).unwrap();
3213 assert!(result.is_empty());
3214
3215 let query = parse("BALANCES").unwrap();
3217 let result = executor.execute(&query).unwrap();
3218 assert!(result.is_empty());
3219 }
3220
3221 #[test]
3222 fn test_comparison_operators() {
3223 let directives = sample_directives();
3224 let mut executor = Executor::new(&directives);
3225
3226 let query = parse("SELECT date WHERE date < 2024-01-16").unwrap();
3228 let result = executor.execute(&query).unwrap();
3229 assert_eq!(result.len(), 2); let query = parse("SELECT date WHERE year > 2023").unwrap();
3233 let result = executor.execute(&query).unwrap();
3234 assert_eq!(result.len(), 4); let query = parse("SELECT account WHERE day = 15").unwrap();
3238 let result = executor.execute(&query).unwrap();
3239 assert_eq!(result.len(), 2); }
3241
3242 #[test]
3243 fn test_logical_operators() {
3244 let directives = sample_directives();
3245 let mut executor = Executor::new(&directives);
3246
3247 let query = parse("SELECT account WHERE account ~ \"Expenses\" AND day > 14").unwrap();
3249 let result = executor.execute(&query).unwrap();
3250 assert_eq!(result.len(), 2); let query = parse("SELECT account WHERE day = 15 OR day = 16").unwrap();
3254 let result = executor.execute(&query).unwrap();
3255 assert_eq!(result.len(), 4); }
3257
3258 #[test]
3259 fn test_arithmetic_expressions() {
3260 let directives = sample_directives();
3261 let mut executor = Executor::new(&directives);
3262
3263 let query = parse("SELECT -day WHERE day = 15").unwrap();
3265 let result = executor.execute(&query).unwrap();
3266 assert_eq!(result.len(), 2);
3267 for row in &result.rows {
3269 if let Value::Integer(n) = &row[0] {
3270 assert_eq!(*n, -15);
3271 }
3272 }
3273 }
3274
3275 #[test]
3276 fn test_first_last_aggregates() {
3277 let directives = sample_directives();
3278 let mut executor = Executor::new(&directives);
3279
3280 let query = parse("SELECT FIRST(date)").unwrap();
3282 let result = executor.execute(&query).unwrap();
3283 assert_eq!(result.len(), 1);
3284 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
3285
3286 let query = parse("SELECT LAST(date)").unwrap();
3288 let result = executor.execute(&query).unwrap();
3289 assert_eq!(result.len(), 1);
3290 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
3291 }
3292
3293 #[test]
3294 fn test_wildcard_select() {
3295 let directives = sample_directives();
3296 let mut executor = Executor::new(&directives);
3297
3298 let query = parse("SELECT *").unwrap();
3300 let result = executor.execute(&query).unwrap();
3301
3302 assert_eq!(
3304 result.columns,
3305 vec!["date", "flag", "payee", "narration", "account", "position"]
3306 );
3307 assert_eq!(result.len(), 4);
3309 assert_eq!(result.rows[0].len(), 6);
3310 }
3311
3312 #[test]
3313 fn test_wildcard_alias_rejected() {
3314 let directives = sample_directives();
3315 let mut executor = Executor::new(&directives);
3316
3317 let query = parse("SELECT * AS data").unwrap();
3319 let result = executor.execute(&query);
3320
3321 assert!(result.is_err());
3322 let err = result.unwrap_err();
3323 assert!(
3324 err.to_string().contains("Cannot alias wildcard"),
3325 "Expected wildcard alias error, got: {err}"
3326 );
3327 }
3328
3329 #[test]
3330 fn test_query_result_methods() {
3331 let mut result = QueryResult::new(vec!["col1".to_string(), "col2".to_string()]);
3332
3333 assert!(result.is_empty());
3335 assert_eq!(result.len(), 0);
3336
3337 result.add_row(vec![Value::Integer(1), Value::String("a".to_string())]);
3339 assert!(!result.is_empty());
3340 assert_eq!(result.len(), 1);
3341
3342 result.add_row(vec![Value::Integer(2), Value::String("b".to_string())]);
3343 assert_eq!(result.len(), 2);
3344 }
3345
3346 #[test]
3347 fn test_type_cast_functions() {
3348 let directives = sample_directives();
3349 let mut executor = Executor::new(&directives);
3350
3351 let query = parse("SELECT int(5.7)").unwrap();
3353 let result = executor.execute(&query).unwrap();
3354 assert_eq!(result.rows[0][0], Value::Integer(5));
3355
3356 let query = parse("SELECT decimal(42)").unwrap();
3358 let result = executor.execute(&query).unwrap();
3359 assert_eq!(result.rows[0][0], Value::Number(dec!(42)));
3360
3361 let query = parse("SELECT str(123)").unwrap();
3363 let result = executor.execute(&query).unwrap();
3364 assert_eq!(result.rows[0][0], Value::String("123".to_string()));
3365
3366 let query = parse("SELECT bool(1)").unwrap();
3368 let result = executor.execute(&query).unwrap();
3369 assert_eq!(result.rows[0][0], Value::Boolean(true));
3370
3371 let query = parse("SELECT bool(0)").unwrap();
3372 let result = executor.execute(&query).unwrap();
3373 assert_eq!(result.rows[0][0], Value::Boolean(false));
3374 }
3375
3376 #[test]
3378 fn test_type_casting_in_aggregate_context() {
3379 let txn1 = Transaction::new(date(2024, 1, 15), "Item 1")
3380 .with_flag('*')
3381 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(10), "USD")))
3382 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "USD")));
3383
3384 let txn2 = Transaction::new(date(2024, 1, 16), "Item 2")
3385 .with_flag('*')
3386 .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(20), "USD")))
3387 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-20), "USD")));
3388
3389 let directives = vec![Directive::Transaction(txn1), Directive::Transaction(txn2)];
3390 let mut executor = Executor::new(&directives);
3391
3392 let query =
3395 parse("SELECT account, str(sum(number(units))) GROUP BY account ORDER BY account")
3396 .unwrap();
3397 let result = executor.execute(&query).unwrap();
3398 assert_eq!(result.rows.len(), 2);
3399 assert_eq!(result.rows[0][0], Value::String("Assets:Cash".to_string()));
3401 assert_eq!(result.rows[0][1], Value::String("-30".to_string()));
3402 assert_eq!(
3403 result.rows[1][0],
3404 Value::String("Expenses:Food".to_string())
3405 );
3406 assert_eq!(result.rows[1][1], Value::String("30".to_string()));
3407
3408 let query =
3410 parse("SELECT account, int(sum(number(units))) GROUP BY account ORDER BY account")
3411 .unwrap();
3412 let result = executor.execute(&query).unwrap();
3413 assert_eq!(result.rows[0][1], Value::Integer(-30));
3414 assert_eq!(result.rows[1][1], Value::Integer(30));
3415
3416 let query =
3418 parse("SELECT account, decimal(count(*)) GROUP BY account ORDER BY account").unwrap();
3419 let result = executor.execute(&query).unwrap();
3420 assert_eq!(result.rows[0][1], Value::Number(dec!(2))); assert_eq!(result.rows[1][1], Value::Number(dec!(2)));
3422
3423 let query =
3425 parse("SELECT account, bool(count(*)) GROUP BY account ORDER BY account").unwrap();
3426 let result = executor.execute(&query).unwrap();
3427 assert_eq!(result.rows[0][1], Value::Boolean(true));
3428 assert_eq!(result.rows[1][1], Value::Boolean(true));
3429 }
3430
3431 #[test]
3433 fn test_int_truncation() {
3434 let directives = sample_directives();
3435 let mut executor = Executor::new(&directives);
3436
3437 let query = parse("SELECT int(5.7)").unwrap();
3439 let result = executor.execute(&query).unwrap();
3440 assert_eq!(result.rows[0][0], Value::Integer(5));
3441
3442 let query = parse("SELECT int(-5.7)").unwrap();
3443 let result = executor.execute(&query).unwrap();
3444 assert_eq!(result.rows[0][0], Value::Integer(-5));
3445
3446 let query = parse("SELECT int(0.999)").unwrap();
3447 let result = executor.execute(&query).unwrap();
3448 assert_eq!(result.rows[0][0], Value::Integer(0));
3449 }
3450
3451 #[test]
3453 fn test_type_casting_errors() {
3454 let directives = sample_directives();
3455 let mut executor = Executor::new(&directives);
3456
3457 let query = parse("SELECT int('not-a-number')").unwrap();
3459 let result = executor.execute(&query);
3460 assert!(result.is_err());
3461 assert!(
3462 result
3463 .unwrap_err()
3464 .to_string()
3465 .contains("cannot parse 'not-a-number'")
3466 );
3467
3468 let query = parse("SELECT decimal('invalid')").unwrap();
3470 let result = executor.execute(&query);
3471 assert!(result.is_err());
3472 assert!(result.unwrap_err().to_string().contains("cannot parse"));
3473
3474 let query = parse("SELECT bool('maybe')").unwrap();
3476 let result = executor.execute(&query);
3477 assert!(result.is_err());
3478 assert!(result.unwrap_err().to_string().contains("cannot parse"));
3479 }
3480
3481 #[test]
3482 fn test_meta_functions() {
3483 let mut txn_meta: Metadata = Metadata::default();
3485 txn_meta.insert(
3486 "source".to_string(),
3487 MetaValue::String("bank_import".to_string()),
3488 );
3489
3490 let mut posting_meta: Metadata = Metadata::default();
3491 posting_meta.insert(
3492 "category".to_string(),
3493 MetaValue::String("food".to_string()),
3494 );
3495
3496 let txn = Transaction {
3497 date: date(2024, 1, 15),
3498 flag: '*',
3499 payee: Some("Coffee Shop".into()),
3500 narration: "Coffee".into(),
3501 tags: vec![],
3502 links: vec![],
3503 meta: txn_meta,
3504 postings: vec![
3505 Posting {
3506 account: "Expenses:Food".into(),
3507 units: Some(rustledger_core::IncompleteAmount::Complete(Amount::new(
3508 dec!(5),
3509 "USD",
3510 ))),
3511 cost: None,
3512 price: None,
3513 flag: None,
3514 meta: posting_meta,
3515 comments: Vec::new(),
3516 trailing_comments: Vec::new(),
3517 },
3518 Posting::new("Assets:Cash", Amount::new(dec!(-5), "USD")),
3519 ],
3520 trailing_comments: Vec::new(),
3521 };
3522
3523 let directives = vec![Directive::Transaction(txn)];
3524 let mut executor = Executor::new(&directives);
3525
3526 let query = parse("SELECT meta('category') WHERE account ~ 'Expenses'").unwrap();
3528 let result = executor.execute(&query).unwrap();
3529 assert_eq!(result.rows[0][0], Value::String("food".to_string()));
3530
3531 let query = parse("SELECT entry_meta('source') WHERE account ~ 'Expenses'").unwrap();
3533 let result = executor.execute(&query).unwrap();
3534 assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
3535
3536 let query = parse("SELECT any_meta('source') WHERE account ~ 'Expenses'").unwrap();
3538 let result = executor.execute(&query).unwrap();
3539 assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
3540
3541 let query = parse("SELECT any_meta('category') WHERE account ~ 'Expenses'").unwrap();
3543 let result = executor.execute(&query).unwrap();
3544 assert_eq!(result.rows[0][0], Value::String("food".to_string()));
3545
3546 let query = parse("SELECT meta('nonexistent') WHERE account ~ 'Expenses'").unwrap();
3548 let result = executor.execute(&query).unwrap();
3549 assert_eq!(result.rows[0][0], Value::Null);
3550 }
3551
3552 #[test]
3553 fn test_convert_function() {
3554 let price = rustledger_core::Price {
3556 date: date(2024, 1, 1),
3557 currency: "EUR".into(),
3558 amount: Amount::new(dec!(1.10), "USD"),
3559 meta: Metadata::default(),
3560 };
3561
3562 let txn = Transaction::new(date(2024, 1, 15), "Test")
3563 .with_flag('*')
3564 .with_posting(Posting::new("Assets:Euro", Amount::new(dec!(100), "EUR")))
3565 .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-110), "USD")));
3566
3567 let directives = vec![Directive::Price(price), Directive::Transaction(txn)];
3568 let mut executor = Executor::new(&directives);
3569
3570 let query = parse("SELECT convert(position, 'USD') WHERE account ~ 'Euro'").unwrap();
3572 let result = executor.execute(&query).unwrap();
3573 match &result.rows[0][0] {
3575 Value::Amount(a) => {
3576 assert_eq!(a.number, dec!(110));
3577 assert_eq!(a.currency.as_ref(), "USD");
3578 }
3579 _ => panic!("Expected Amount, got {:?}", result.rows[0][0]),
3580 }
3581 }
3582
3583 #[test]
3584 fn test_date_functions() {
3585 let directives = sample_directives();
3586 let mut executor = Executor::new(&directives);
3587
3588 let query = parse("SELECT date('2024-06-15')").unwrap();
3590 let result = executor.execute(&query).unwrap();
3591 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
3592
3593 let query = parse("SELECT date(2024, 6, 15)").unwrap();
3595 let result = executor.execute(&query).unwrap();
3596 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
3597
3598 let query = parse("SELECT date_diff(date('2024-01-20'), date('2024-01-15'))").unwrap();
3600 let result = executor.execute(&query).unwrap();
3601 assert_eq!(result.rows[0][0], Value::Integer(5));
3602
3603 let query = parse("SELECT date_add(date('2024-01-15'), 10)").unwrap();
3605 let result = executor.execute(&query).unwrap();
3606 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 25)));
3607
3608 let query = parse("SELECT date_trunc('year', date('2024-06-15'))").unwrap();
3610 let result = executor.execute(&query).unwrap();
3611 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1)));
3612
3613 let query = parse("SELECT date_trunc('month', date('2024-06-15'))").unwrap();
3615 let result = executor.execute(&query).unwrap();
3616 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1)));
3617
3618 let query = parse("SELECT date_part('month', date('2024-06-15'))").unwrap();
3620 let result = executor.execute(&query).unwrap();
3621 assert_eq!(result.rows[0][0], Value::Integer(6));
3622
3623 let query = parse("SELECT parse_date('15/06/2024', '%d/%m/%Y')").unwrap();
3625 let result = executor.execute(&query).unwrap();
3626 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
3627
3628 let query =
3630 parse("SELECT date_bin('7 days', date('2024-01-15'), date('2024-01-01'))").unwrap();
3631 let result = executor.execute(&query).unwrap();
3632 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); let query =
3636 parse("SELECT date_bin('1 week', date('2024-01-20'), date('2024-01-01'))").unwrap();
3637 let result = executor.execute(&query).unwrap();
3638 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); let query =
3642 parse("SELECT date_bin('1 month', date('2024-06-15'), date('2024-01-01'))").unwrap();
3643 let result = executor.execute(&query).unwrap();
3644 assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1))); let query =
3648 parse("SELECT date_bin('1 year', date('2024-06-15'), date('2020-01-01'))").unwrap();
3649 let result = executor.execute(&query).unwrap();
3650 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1))); }
3652
3653 #[test]
3654 fn test_string_functions_extended() {
3655 let directives = sample_directives();
3656 let mut executor = Executor::new(&directives);
3657
3658 let query = parse("SELECT grep('Ex[a-z]+', 'Hello Expenses World')").unwrap();
3660 let result = executor.execute(&query).unwrap();
3661 assert_eq!(result.rows[0][0], Value::String("Expenses".to_string()));
3662
3663 let query = parse("SELECT grep('xyz', 'Hello World')").unwrap();
3665 let result = executor.execute(&query).unwrap();
3666 assert_eq!(result.rows[0][0], Value::Null);
3667
3668 let query = parse("SELECT grepn('([0-9]+)-([0-9]+)', '2024-01', 1)").unwrap();
3670 let result = executor.execute(&query).unwrap();
3671 assert_eq!(result.rows[0][0], Value::String("2024".to_string()));
3672
3673 let query = parse("SELECT subst('-', '/', '2024-01-15')").unwrap();
3675 let result = executor.execute(&query).unwrap();
3676 assert_eq!(result.rows[0][0], Value::String("2024/01/15".to_string()));
3677
3678 let query = parse("SELECT splitcomp('a:b:c', ':', 1)").unwrap();
3680 let result = executor.execute(&query).unwrap();
3681 assert_eq!(result.rows[0][0], Value::String("b".to_string()));
3682
3683 let query = parse("SELECT joinstr('hello', 'world')").unwrap();
3685 let result = executor.execute(&query).unwrap();
3686 assert_eq!(result.rows[0][0], Value::String("hello, world".to_string()));
3687
3688 let query = parse("SELECT maxwidth('hello', 10)").unwrap();
3690 let result = executor.execute(&query).unwrap();
3691 assert_eq!(result.rows[0][0], Value::String("hello".to_string()));
3692
3693 let query = parse("SELECT maxwidth('hello world', 8)").unwrap();
3695 let result = executor.execute(&query).unwrap();
3696 assert_eq!(result.rows[0][0], Value::String("hello...".to_string()));
3697 }
3698
3699 #[test]
3700 fn test_inventory_functions() {
3701 let directives = sample_directives();
3702 let mut executor = Executor::new(&directives);
3703
3704 let query = parse("SELECT empty(sum(position)) WHERE account ~ 'Assets'").unwrap();
3707 let result = executor.execute(&query).unwrap();
3708 assert!(matches!(result.rows[0][0], Value::Boolean(_)));
3710
3711 let query = parse("SELECT possign(100, 'Assets:Bank')").unwrap();
3716 let result = executor.execute(&query).unwrap();
3717 assert_eq!(
3718 result.rows[0][0],
3719 Value::Number(rust_decimal::Decimal::from(100))
3720 );
3721
3722 let query = parse("SELECT possign(100, 'Income:Salary')").unwrap();
3724 let result = executor.execute(&query).unwrap();
3725 assert_eq!(
3726 result.rows[0][0],
3727 Value::Number(rust_decimal::Decimal::from(-100))
3728 );
3729
3730 let query = parse("SELECT possign(50, 'Expenses:Food')").unwrap();
3732 let result = executor.execute(&query).unwrap();
3733 assert_eq!(
3734 result.rows[0][0],
3735 Value::Number(rust_decimal::Decimal::from(50))
3736 );
3737
3738 let query = parse("SELECT possign(200, 'Liabilities:CreditCard')").unwrap();
3740 let result = executor.execute(&query).unwrap();
3741 assert_eq!(
3742 result.rows[0][0],
3743 Value::Number(rust_decimal::Decimal::from(-200))
3744 );
3745
3746 let query = parse("SELECT possign(300, 'Equity:OpeningBalances')").unwrap();
3748 let result = executor.execute(&query).unwrap();
3749 assert_eq!(
3750 result.rows[0][0],
3751 Value::Number(rust_decimal::Decimal::from(-300))
3752 );
3753 }
3754
3755 #[test]
3756 fn test_account_meta_functions() {
3757 use rustledger_core::{Close, Metadata, Open};
3758
3759 let mut open_meta = Metadata::default();
3761 open_meta.insert(
3762 "category".to_string(),
3763 MetaValue::String("checking".to_string()),
3764 );
3765
3766 let directives = vec![
3767 Directive::Open(Open {
3768 date: date(2020, 1, 1),
3769 account: "Assets:Bank:Checking".into(),
3770 currencies: vec![],
3771 booking: None,
3772 meta: open_meta,
3773 }),
3774 Directive::Open(Open::new(date(2020, 2, 15), "Expenses:Food")),
3775 Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank:Checking")),
3776 Directive::Transaction(
3778 Transaction::new(date(2024, 1, 15), "Coffee")
3779 .with_posting(Posting::new(
3780 "Expenses:Food",
3781 Amount::new(dec!(5.00), "USD"),
3782 ))
3783 .with_posting(Posting::new(
3784 "Assets:Bank:Checking",
3785 Amount::new(dec!(-5.00), "USD"),
3786 )),
3787 ),
3788 ];
3789
3790 let mut executor = Executor::new(&directives);
3791
3792 let query = parse("SELECT open_date('Assets:Bank:Checking')").unwrap();
3794 let result = executor.execute(&query).unwrap();
3795 assert_eq!(result.rows[0][0], Value::Date(date(2020, 1, 1)));
3796
3797 let query = parse("SELECT close_date('Assets:Bank:Checking')").unwrap();
3799 let result = executor.execute(&query).unwrap();
3800 assert_eq!(result.rows[0][0], Value::Date(date(2024, 12, 31)));
3801
3802 let query = parse("SELECT close_date('Expenses:Food')").unwrap();
3804 let result = executor.execute(&query).unwrap();
3805 assert_eq!(result.rows[0][0], Value::Null);
3806
3807 let query = parse("SELECT open_meta('Assets:Bank:Checking', 'category')").unwrap();
3809 let result = executor.execute(&query).unwrap();
3810 assert_eq!(result.rows[0][0], Value::String("checking".to_string()));
3811
3812 let query = parse("SELECT open_meta('Assets:Bank:Checking', 'nonexistent')").unwrap();
3814 let result = executor.execute(&query).unwrap();
3815 assert_eq!(result.rows[0][0], Value::Null);
3816
3817 let query = parse("SELECT open_date('NonExistent:Account')").unwrap();
3819 let result = executor.execute(&query).unwrap();
3820 assert_eq!(result.rows[0][0], Value::Null);
3821 }
3822
3823 #[test]
3824 fn test_source_location_columns_return_null_without_sources() {
3825 let directives = vec![Directive::Transaction(Transaction {
3828 date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
3829 flag: '*',
3830 payee: Some("Test".into()),
3831 narration: "Test transaction".into(),
3832 tags: vec![],
3833 links: vec![],
3834 meta: Metadata::default(),
3835 postings: vec![
3836 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3837 Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
3838 ],
3839 trailing_comments: Vec::new(),
3840 })];
3841
3842 let mut executor = Executor::new(&directives);
3843
3844 let query = parse("SELECT filename").unwrap();
3846 let result = executor.execute(&query).unwrap();
3847 assert_eq!(result.rows[0][0], Value::Null);
3848
3849 let query = parse("SELECT lineno").unwrap();
3851 let result = executor.execute(&query).unwrap();
3852 assert_eq!(result.rows[0][0], Value::Null);
3853
3854 let query = parse("SELECT location").unwrap();
3856 let result = executor.execute(&query).unwrap();
3857 assert_eq!(result.rows[0][0], Value::Null);
3858 }
3859
3860 #[test]
3861 fn test_source_location_columns_with_sources() {
3862 use rustledger_loader::SourceMap;
3863 use rustledger_parser::Spanned;
3864 use std::sync::Arc;
3865
3866 let mut source_map = SourceMap::new();
3868 let source: Arc<str> =
3869 "2024-01-15 * \"Test\"\n Assets:Bank 100 USD\n Expenses:Food".into();
3870 let file_id = source_map.add_file("test.beancount".into(), source);
3871
3872 let txn = Transaction {
3874 date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
3875 flag: '*',
3876 payee: Some("Test".into()),
3877 narration: "Test transaction".into(),
3878 tags: vec![],
3879 links: vec![],
3880 meta: Metadata::default(),
3881 postings: vec![
3882 Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3883 Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
3884 ],
3885 trailing_comments: Vec::new(),
3886 };
3887
3888 let spanned_directives = vec![Spanned {
3889 value: Directive::Transaction(txn),
3890 span: rustledger_parser::Span { start: 0, end: 50 },
3891 file_id: file_id as u16,
3892 }];
3893
3894 let mut executor = Executor::new_with_sources(&spanned_directives, &source_map);
3895
3896 let query = parse("SELECT filename").unwrap();
3898 let result = executor.execute(&query).unwrap();
3899 assert_eq!(
3900 result.rows[0][0],
3901 Value::String("test.beancount".to_string())
3902 );
3903
3904 let query = parse("SELECT lineno").unwrap();
3906 let result = executor.execute(&query).unwrap();
3907 assert_eq!(result.rows[0][0], Value::Integer(1));
3908
3909 let query = parse("SELECT location").unwrap();
3911 let result = executor.execute(&query).unwrap();
3912 assert_eq!(
3913 result.rows[0][0],
3914 Value::String("test.beancount:1".to_string())
3915 );
3916 }
3917
3918 #[test]
3919 fn test_interval_function() {
3920 let directives = sample_directives();
3921 let mut executor = Executor::new(&directives);
3922
3923 let query = parse("SELECT interval('month')").unwrap();
3925 let result = executor.execute(&query).unwrap();
3926 assert_eq!(
3927 result.rows[0][0],
3928 Value::Interval(Interval::new(1, IntervalUnit::Month))
3929 );
3930
3931 let query = parse("SELECT interval(3, 'day')").unwrap();
3933 let result = executor.execute(&query).unwrap();
3934 assert_eq!(
3935 result.rows[0][0],
3936 Value::Interval(Interval::new(3, IntervalUnit::Day))
3937 );
3938
3939 let query = parse("SELECT interval(-2, 'week')").unwrap();
3941 let result = executor.execute(&query).unwrap();
3942 assert_eq!(
3943 result.rows[0][0],
3944 Value::Interval(Interval::new(-2, IntervalUnit::Week))
3945 );
3946 }
3947
3948 #[test]
3949 fn test_date_add_with_interval() {
3950 let directives = sample_directives();
3951 let mut executor = Executor::new(&directives);
3952
3953 let query = parse("SELECT date_add(date(2024, 1, 15), interval(1, 'month'))").unwrap();
3955 let result = executor.execute(&query).unwrap();
3956 assert_eq!(
3957 result.rows[0][0],
3958 Value::Date(rustledger_core::naive_date(2024, 2, 15).unwrap())
3959 );
3960
3961 let query = parse("SELECT date(2024, 1, 15) + interval(1, 'year')").unwrap();
3963 let result = executor.execute(&query).unwrap();
3964 assert_eq!(
3965 result.rows[0][0],
3966 Value::Date(rustledger_core::naive_date(2025, 1, 15).unwrap())
3967 );
3968
3969 let query = parse("SELECT date(2024, 3, 15) - interval(2, 'month')").unwrap();
3971 let result = executor.execute(&query).unwrap();
3972 assert_eq!(
3973 result.rows[0][0],
3974 Value::Date(rustledger_core::naive_date(2024, 1, 15).unwrap())
3975 );
3976 }
3977
3978 #[test]
3983 fn test_query_references_column_covers_all_query_parts() {
3984 fn assert_refs(sql: &str, expected: bool) {
3986 let q = match parse(sql).unwrap() {
3987 Query::Select(s) => s,
3988 _ => panic!("expected Select for {sql:?}"),
3989 };
3990 assert_eq!(
3991 query_references_column(&q, "balance"),
3992 expected,
3993 "query_references_column(balance) wrong for {sql:?}"
3994 );
3995 }
3996
3997 assert_refs("SELECT account FROM #postings", false);
3999 assert_refs("SELECT account WHERE account ~ '^Assets' LIMIT 10", false);
4000
4001 assert_refs("SELECT balance FROM #postings", true);
4003 assert_refs("SELECT account WHERE balance > 0", true);
4004 assert_refs("SELECT account ORDER BY balance", true);
4005 assert_refs("SELECT account GROUP BY balance", true);
4006 assert_refs(
4007 "SELECT account, sum(balance) FROM #postings GROUP BY account",
4008 true,
4009 );
4010 assert_refs("SELECT BALANCE FROM #postings", true);
4012 assert_refs("SELECT account WHERE units(balance) IS NOT NULL", true);
4014 assert_refs("SELECT account WHERE balance BETWEEN 0 AND 100", true);
4016 }
4017
4018 #[test]
4025 fn test_expr_references_column_walks_window_over_clause() {
4026 use crate::ast::{
4027 BinaryOp, BinaryOperator, OrderSpec, SortDirection, WindowFunction, WindowSpec,
4028 };
4029
4030 let col_balance = Expr::Column("balance".to_string());
4031 let col_unrelated = Expr::Column("amount".to_string());
4032
4033 let win_partition = Expr::Window(WindowFunction {
4035 name: "row_number".to_string(),
4036 args: vec![],
4037 over: WindowSpec {
4038 partition_by: Some(vec![col_balance.clone()]),
4039 order_by: None,
4040 },
4041 });
4042 assert!(
4043 expr_references_column(&win_partition, "balance"),
4044 "OVER (PARTITION BY balance) must be detected"
4045 );
4046 assert!(
4047 !expr_references_column(&win_partition, "account"),
4048 "should not match unrelated column"
4049 );
4050
4051 let win_order = Expr::Window(WindowFunction {
4053 name: "row_number".to_string(),
4054 args: vec![],
4055 over: WindowSpec {
4056 partition_by: None,
4057 order_by: Some(vec![OrderSpec {
4058 expr: Expr::BinaryOp(Box::new(BinaryOp {
4059 left: col_balance,
4060 op: BinaryOperator::Add,
4061 right: col_unrelated,
4062 })),
4063 direction: SortDirection::Asc,
4064 }]),
4065 },
4066 });
4067 assert!(
4068 expr_references_column(&win_order, "balance"),
4069 "OVER (ORDER BY balance + amount) must be detected"
4070 );
4071 }
4072}