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 parking_lot::RwLock;
15
16use rustc_hash::FxHashMap;
17
18use regex::{Regex, RegexBuilder};
19use rust_decimal::Decimal;
20use rustledger_core::{Amount, Directive, 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 = self.regex_cache.read();
265 if let Some(cached) = cache.get(pattern) {
266 return cached.clone();
267 }
268 }
269 let compiled = RegexBuilder::new(pattern)
272 .case_insensitive(true)
273 .build()
274 .ok();
275 let mut cache = self.regex_cache.write();
276 if let Some(cached) = cache.get(pattern) {
278 return cached.clone();
279 }
280 cache.insert(pattern.to_string(), compiled.clone());
281 compiled
282 }
283
284 fn require_regex(&self, pattern: &str) -> Result<Regex, QueryError> {
286 self.get_or_compile_regex(pattern)
287 .ok_or_else(|| QueryError::Type(format!("invalid regex: {pattern}")))
288 }
289
290 pub fn set_target_currency(&mut self, currency: impl Into<String>) {
292 self.target_currency = Some(currency.into());
293 }
294
295 pub fn execute(&mut self, query: &Query) -> Result<QueryResult, QueryError> {
308 match query {
309 Query::Select(select) => self.execute_select(select),
310 Query::Journal(journal) => self.execute_journal(journal),
311 Query::Balances(balances) => self.execute_balances(balances),
312 Query::Print(print) => self.execute_print(print),
313 Query::CreateTable(create) => self.execute_create_table(create),
314 Query::Insert(insert) => self.execute_insert(insert),
315 }
316 }
317
318 fn build_balances_with_filter(
328 &self,
329 from: Option<&FromClause>,
330 ) -> Result<FxHashMap<rustledger_core::Account, Inventory>, QueryError> {
331 let mut balances: FxHashMap<rustledger_core::Account, Inventory> = FxHashMap::default();
332
333 let all_directives: Vec<&Directive> = if let Some(spanned) = self.spanned_directives {
340 spanned.iter().map(|s| &s.value).collect()
341 } else {
342 self.directives.iter().collect()
343 };
344
345 for directive in all_directives {
346 if let Directive::Transaction(txn) = directive {
347 if let Some(from_clause) = from
349 && let Some(filter) = &from_clause.filter
350 && !self.evaluate_from_filter(filter, txn)?
351 {
352 continue;
353 }
354
355 for posting in &txn.postings {
356 if let Some(units) = posting.amount() {
357 let balance = balances.entry(posting.account.clone()).or_default();
358
359 let pos = if let Some(cost_spec) = &posting.cost {
360 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
361 Position::with_cost(units.clone(), cost)
362 } else {
363 Position::simple(units.clone())
364 }
365 } else {
366 Position::simple(units.clone())
367 };
368 balance.add(pos);
369 }
370 }
371 }
372 }
373
374 Ok(balances)
375 }
376
377 fn collect_postings(&self, query: &SelectQuery) -> Result<Vec<PostingContext<'a>>, QueryError> {
379 let from = query.from.as_ref();
380 let where_clause = query.where_clause.as_ref();
381
382 let needs_balance = query_references_column(query, "balance");
397 let needs_account_balance = query_references_column(query, "account_balance");
398
399 let where_reads_balance =
409 where_clause.is_some_and(|w| expr_references_column(w, "balance"));
410
411 let mut postings = Vec::new();
412 let mut account_balances: FxHashMap<rustledger_core::Account, Inventory> =
416 FxHashMap::default();
417 let mut cumulative_balance: Inventory = Inventory::default();
421
422 let directive_iter: Vec<(usize, &Directive)> =
425 if let Some(spanned) = self.spanned_directives {
426 spanned
427 .iter()
428 .enumerate()
429 .map(|(i, s)| (i, &s.value))
430 .collect()
431 } else {
432 self.directives.iter().enumerate().collect()
433 };
434
435 let resolve_position = |posting: &rustledger_core::Posting, txn_date: NaiveDate| {
440 posting.amount().map(|units| {
441 if let Some(cost_spec) = &posting.cost
442 && let Some(cost) = cost_spec.resolve(units.number, txn_date)
443 {
444 return Position::with_cost(units.clone(), cost);
445 }
446 Position::simple(units.clone())
447 })
448 };
449
450 for (directive_index, directive) in directive_iter {
451 if let Directive::Transaction(txn) = directive {
452 if let Some(from) = from {
454 if let Some(open_date) = from.open_on
456 && txn.date < open_date
457 {
458 if needs_account_balance {
462 for posting in &txn.postings {
463 if let Some(pos) = resolve_position(posting, txn.date) {
464 let bal = account_balances
465 .entry(posting.account.clone())
466 .or_default();
467 bal.add(pos);
468 }
469 }
470 }
471 continue;
472 }
473 if let Some(close_date) = from.close_on
478 && txn.date >= close_date
479 {
480 continue;
481 }
482 if let Some(filter) = &from.filter
484 && !self.evaluate_from_filter(filter, txn)?
485 {
486 continue;
487 }
488 }
489
490 for (i, posting) in txn.postings.iter().enumerate() {
491 let resolved = resolve_position(posting, txn.date);
499 if needs_account_balance && let Some(pos) = resolved.clone() {
500 let bal = account_balances.entry(posting.account.clone()).or_default();
501 bal.add(pos);
502 }
503
504 let mut ctx = PostingContext {
530 transaction: txn,
531 posting_index: i,
532 balance: if where_reads_balance {
533 Some(cumulative_balance.clone())
534 } else {
535 None
536 },
537 account_balance: if needs_account_balance {
538 account_balances.get(&posting.account).cloned()
539 } else {
540 None
541 },
542 directive_index: Some(directive_index),
543 };
544
545 if let Some(where_expr) = where_clause
547 && !self.evaluate_predicate(where_expr, &ctx)?
548 {
549 continue;
550 }
551
552 if needs_balance {
557 if let Some(pos) = resolved {
558 cumulative_balance.add(pos);
559 }
560 ctx.balance = Some(cumulative_balance.clone());
561 }
562 postings.push(ctx);
563 }
564 }
565 }
566
567 Ok(postings)
568 }
569 fn evaluate_function(
570 &self,
571 func: &FunctionCall,
572 ctx: &PostingContext,
573 ) -> Result<Value, QueryError> {
574 let name = func.name.to_uppercase();
575 match name.as_str() {
576 "YEAR" | "MONTH" | "DAY" | "WEEKDAY" | "QUARTER" | "YMONTH" | "TODAY" => {
578 self.eval_date_function(&name, func, ctx)
579 }
580 "DATE" | "DATE_DIFF" | "DATE_ADD" | "DATE_TRUNC" | "DATE_PART" | "PARSE_DATE"
582 | "DATE_BIN" | "INTERVAL" => self.eval_extended_date_function(&name, func, ctx),
583 "LENGTH" | "UPPER" | "LOWER" | "SUBSTR" | "SUBSTRING" | "TRIM" | "STARTSWITH"
585 | "ENDSWITH" | "GREP" | "GREPN" | "SUBST" | "SPLITCOMP" | "JOINSTR" | "MAXWIDTH" => {
586 self.eval_string_function(&name, func, ctx)
587 }
588 "PARENT" | "LEAF" | "ROOT" | "ACCOUNT_DEPTH" | "ACCOUNT_SORTKEY" => {
590 self.eval_account_function(&name, func, ctx)
591 }
592 "OPEN_DATE" | "CLOSE_DATE" | "OPEN_META" => {
594 self.eval_account_meta_function(&name, func, ctx)
595 }
596 "ABS" | "NEG" | "ROUND" | "SAFEDIV" => self.eval_math_function(&name, func, ctx),
598 "NUMBER" | "CURRENCY" | "GETITEM" | "GET" | "UNITS" | "COST" | "WEIGHT" | "VALUE" => {
600 self.eval_position_function(&name, func, ctx)
601 }
602 "EMPTY" | "FILTER_CURRENCY" | "POSSIGN" => {
604 self.eval_inventory_function(&name, func, ctx)
605 }
606 "GETPRICE" => self.eval_getprice(func, ctx),
608 "COALESCE" => self.eval_coalesce(func, ctx),
610 "ONLY" => self.eval_only(func, ctx),
611 "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
613 self.eval_meta_function(&name, func, ctx)
614 }
615 "CONVERT" => self.eval_convert(func, ctx),
617 "INT" => self.eval_int(func, ctx),
619 "DECIMAL" => self.eval_decimal(func, ctx),
620 "STR" => self.eval_str(func, ctx),
621 "BOOL" => self.eval_bool(func, ctx),
622 "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
625 _ => Err(QueryError::UnknownFunction(func.name.clone())),
626 }
627 }
628
629 fn evaluate_function_on_values(&self, name: &str, args: &[Value]) -> Result<Value, QueryError> {
631 let name_upper = name.to_uppercase();
632 match name_upper.as_str() {
633 "TODAY" => Ok(Value::Date(jiff::Zoned::now().date())),
635 "YEAR" => {
636 Self::require_args_count(&name_upper, args, 1)?;
637 match &args[0] {
638 Value::Date(d) => Ok(Value::Integer(d.year().into())),
639 _ => Err(QueryError::Type("YEAR expects a date".to_string())),
640 }
641 }
642 "MONTH" => {
643 Self::require_args_count(&name_upper, args, 1)?;
644 match &args[0] {
645 Value::Date(d) => Ok(Value::Integer(d.month().into())),
646 _ => Err(QueryError::Type("MONTH expects a date".to_string())),
647 }
648 }
649 "DAY" => {
650 Self::require_args_count(&name_upper, args, 1)?;
651 match &args[0] {
652 Value::Date(d) => Ok(Value::Integer(d.day().into())),
653 _ => Err(QueryError::Type("DAY expects a date".to_string())),
654 }
655 }
656 "LENGTH" => {
658 Self::require_args_count(&name_upper, args, 1)?;
659 match &args[0] {
660 Value::String(s) => Ok(Value::Integer(s.len() as i64)),
661 _ => Err(QueryError::Type("LENGTH expects a string".to_string())),
662 }
663 }
664 "UPPER" => {
665 Self::require_args_count(&name_upper, args, 1)?;
666 match &args[0] {
667 Value::String(s) => Ok(Value::String(s.to_uppercase())),
668 _ => Err(QueryError::Type("UPPER expects a string".to_string())),
669 }
670 }
671 "LOWER" => {
672 Self::require_args_count(&name_upper, args, 1)?;
673 match &args[0] {
674 Value::String(s) => Ok(Value::String(s.to_lowercase())),
675 _ => Err(QueryError::Type("LOWER expects a string".to_string())),
676 }
677 }
678 "TRIM" => {
679 Self::require_args_count(&name_upper, args, 1)?;
680 match &args[0] {
681 Value::String(s) => Ok(Value::String(s.trim().to_string())),
682 _ => Err(QueryError::Type("TRIM expects a string".to_string())),
683 }
684 }
685 "ABS" => {
687 Self::require_args_count(&name_upper, args, 1)?;
688 match &args[0] {
689 Value::Number(n) => Ok(Value::Number(n.abs())),
690 Value::Integer(i) => Ok(Value::Integer(i.abs())),
691 _ => Err(QueryError::Type("ABS expects a number".to_string())),
692 }
693 }
694 "ROUND" => {
695 if args.is_empty() || args.len() > 2 {
696 return Err(QueryError::InvalidArguments(
697 "ROUND".to_string(),
698 "expected 1 or 2 arguments".to_string(),
699 ));
700 }
701 match &args[0] {
702 Value::Number(n) => {
703 let scale = if args.len() == 2 {
704 match &args[1] {
705 Value::Integer(i) => *i as u32,
706 _ => 0,
707 }
708 } else {
709 0
710 };
711 Ok(Value::Number(n.round_dp(scale)))
712 }
713 Value::Integer(i) => Ok(Value::Integer(*i)),
714 _ => Err(QueryError::Type("ROUND expects a number".to_string())),
715 }
716 }
717 "COALESCE" => {
719 for arg in args {
720 if !matches!(arg, Value::Null) {
721 return Ok(arg.clone());
722 }
723 }
724 Ok(Value::Null)
725 }
726 "NUMBER" => {
728 Self::require_args_count(&name_upper, args, 1)?;
729 match &args[0] {
730 Value::Amount(a) => Ok(Value::Number(a.number)),
731 Value::Position(p) => Ok(Value::Number(p.units.number)),
732 Value::Number(n) => Ok(Value::Number(*n)),
733 Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
734 Value::Inventory(inv) => {
735 let mut iter = inv.positions();
740 let Some(first) = iter.next() else {
741 return Ok(Value::Number(Decimal::ZERO));
742 };
743 let first_currency = &first.units.currency;
744 let mut total = first.units.number;
745 for pos in iter {
746 if &pos.units.currency != first_currency {
747 return Ok(Value::Null);
748 }
749 total += pos.units.number;
750 }
751 Ok(Value::Number(total))
752 }
753 Value::Null => Ok(Value::Null),
754 _ => Err(QueryError::Type(
755 "NUMBER expects an amount, position, or inventory".to_string(),
756 )),
757 }
758 }
759 "CURRENCY" => {
760 Self::require_args_count(&name_upper, args, 1)?;
761 match &args[0] {
762 Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
763 Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
764 Value::Inventory(inv) => {
765 if let Some(pos) = inv.positions().next() {
767 Ok(Value::String(pos.units.currency.to_string()))
768 } else {
769 Ok(Value::Null)
770 }
771 }
772 Value::Null => Ok(Value::Null),
773 _ => Err(QueryError::Type(
774 "CURRENCY expects an amount or position".to_string(),
775 )),
776 }
777 }
778 "UNITS" => {
779 Self::require_args_count(&name_upper, args, 1)?;
780 match &args[0] {
781 Value::Position(p) => Ok(Value::Amount(p.units.clone())),
782 Value::Amount(a) => Ok(Value::Amount(a.clone())),
783 Value::Inventory(inv) => {
784 let mut units_inv = Inventory::new();
786 for pos in inv.positions() {
787 units_inv.add(Position::simple(pos.units.clone()));
788 }
789 Ok(Value::Inventory(Box::new(units_inv)))
790 }
791 Value::Null => Ok(Value::Null),
792 _ => Err(QueryError::Type(
793 "UNITS expects a position or inventory".to_string(),
794 )),
795 }
796 }
797 "COST" => {
798 Self::require_args_count(&name_upper, args, 1)?;
799 match &args[0] {
800 Value::Position(p) => {
801 if let Some(cost) = &p.cost {
802 let total = p.units.number * cost.number;
804 Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
805 } else {
806 Ok(Value::Amount(p.units.clone()))
807 }
808 }
809 Value::Amount(a) => Ok(Value::Amount(a.clone())),
810 Value::Inventory(inv) => {
811 let mut total = Decimal::ZERO;
812 let mut currency: Option<rustledger_core::Currency> = None;
813 for pos in inv.positions() {
814 if let Some(cost) = &pos.cost {
815 total += pos.units.number * cost.number;
816 if currency.is_none() {
817 currency = Some(cost.currency.clone());
818 }
819 } else {
820 total += pos.units.number;
821 if currency.is_none() {
822 currency = Some(pos.units.currency.clone());
823 }
824 }
825 }
826 if let Some(curr) = currency {
827 Ok(Value::Amount(Amount::new(total, curr)))
828 } else {
829 Ok(Value::Null)
830 }
831 }
832 Value::Null => Ok(Value::Null),
833 _ => Err(QueryError::Type(
834 "COST expects a position or inventory".to_string(),
835 )),
836 }
837 }
838 "VALUE" => {
839 if args.is_empty() || args.len() > 2 {
843 return Err(QueryError::InvalidArguments(
844 "VALUE".to_string(),
845 "expected 1-2 arguments".to_string(),
846 ));
847 }
848 let (explicit_currency, at_date) = if args.len() == 2 {
849 match &args[1] {
850 Value::Date(d) => (None, Some(*d)),
851 Value::String(s) => (Some(s.as_str()), None),
852 Value::Null => {
853 return Err(QueryError::Type(
854 concat!(
855 "VALUE: second argument evaluated to NULL; ",
856 "expected a date or currency string ",
857 "(this often means an aggregate expression couldn't ",
858 "evaluate against an empty group — see issue #902)",
859 )
860 .to_string(),
861 ));
862 }
863 _ => {
864 return Err(QueryError::Type(
865 "VALUE second argument must be a date or currency string"
866 .to_string(),
867 ));
868 }
869 }
870 } else {
871 (None, None)
872 };
873 self.convert_to_market_value(&args[0], explicit_currency, at_date)
874 }
875 "SAFEDIV" => {
877 Self::require_args_count(&name_upper, args, 2)?;
878 let (dividend, divisor) = (&args[0], &args[1]);
879 match (dividend, divisor) {
880 (Value::Number(a), Value::Number(b)) => {
881 if b.is_zero() {
882 Ok(Value::Null)
883 } else {
884 Ok(Value::Number(a / b))
885 }
886 }
887 (Value::Integer(a), Value::Integer(b)) => {
888 if *b == 0 {
889 Ok(Value::Null)
890 } else {
891 Ok(Value::Number(Decimal::from(*a) / Decimal::from(*b)))
892 }
893 }
894 (Value::Number(a), Value::Integer(b)) => {
895 if *b == 0 {
896 Ok(Value::Null)
897 } else {
898 Ok(Value::Number(a / Decimal::from(*b)))
899 }
900 }
901 (Value::Integer(a), Value::Number(b)) => {
902 if b.is_zero() {
903 Ok(Value::Null)
904 } else {
905 Ok(Value::Number(Decimal::from(*a) / b))
906 }
907 }
908 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
909 _ => Err(QueryError::Type(
910 "SAFEDIV expects numeric arguments".to_string(),
911 )),
912 }
913 }
914 "NEG" => {
915 Self::require_args_count(&name_upper, args, 1)?;
916 match &args[0] {
917 Value::Number(n) => Ok(Value::Number(-n)),
918 Value::Integer(i) => Ok(Value::Integer(-i)),
919 Value::Amount(a) => {
920 Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
921 }
922 _ => Err(QueryError::Type(
923 "NEG expects a number or amount".to_string(),
924 )),
925 }
926 }
927 "ACCOUNT_SORTKEY" => {
929 Self::require_args_count(&name_upper, args, 1)?;
930 match &args[0] {
931 Value::String(s) => {
932 let type_index = Self::account_type_index(s);
933 Ok(Value::String(format!("{type_index}-{s}")))
934 }
935 _ => Err(QueryError::Type(
936 "ACCOUNT_SORTKEY expects an account string".to_string(),
937 )),
938 }
939 }
940 "PARENT" => {
941 Self::require_args_count(&name_upper, args, 1)?;
942 match &args[0] {
943 Value::String(s) => {
944 if let Some(idx) = s.rfind(':') {
945 Ok(Value::String(s[..idx].to_string()))
946 } else {
947 Ok(Value::Null)
948 }
949 }
950 _ => Err(QueryError::Type(
951 "PARENT expects an account string".to_string(),
952 )),
953 }
954 }
955 "LEAF" => {
956 Self::require_args_count(&name_upper, args, 1)?;
957 match &args[0] {
958 Value::String(s) => {
959 if let Some(idx) = s.rfind(':') {
960 Ok(Value::String(s[idx + 1..].to_string()))
961 } else {
962 Ok(Value::String(s.clone()))
963 }
964 }
965 _ => Err(QueryError::Type(
966 "LEAF expects an account string".to_string(),
967 )),
968 }
969 }
970 "ROOT" => {
971 if args.is_empty() || args.len() > 2 {
972 return Err(QueryError::InvalidArguments(
973 "ROOT".to_string(),
974 "expected 1 or 2 arguments".to_string(),
975 ));
976 }
977 let n = if args.len() == 2 {
978 match &args[1] {
979 Value::Integer(i) => *i as usize,
980 _ => 1,
981 }
982 } else {
983 1
984 };
985 match &args[0] {
986 Value::String(s) => {
987 let parts: Vec<&str> = s.split(':').collect();
988 if n >= parts.len() {
989 Ok(Value::String(s.clone()))
990 } else {
991 Ok(Value::String(parts[..n].join(":")))
992 }
993 }
994 _ => Err(QueryError::Type(
995 "ROOT expects an account string".to_string(),
996 )),
997 }
998 }
999 "ONLY" => {
1001 Self::require_args_count(&name_upper, args, 2)?;
1002 let currency = match &args[0] {
1003 Value::String(s) => s.clone(),
1004 _ => {
1005 return Err(QueryError::Type(
1006 "ONLY: first argument must be a currency string".to_string(),
1007 ));
1008 }
1009 };
1010 match &args[1] {
1011 Value::Inventory(inv) => {
1012 let total = inv.units(¤cy);
1013 if total.is_zero() {
1014 Ok(Value::Null)
1015 } else {
1016 Ok(Value::Amount(Amount::new(total, ¤cy)))
1017 }
1018 }
1019 Value::Position(p) => {
1020 if p.units.currency.as_str() == currency {
1021 Ok(Value::Amount(p.units.clone()))
1022 } else {
1023 Ok(Value::Null)
1024 }
1025 }
1026 Value::Amount(a) => {
1027 if a.currency.as_str() == currency {
1028 Ok(Value::Amount(a.clone()))
1029 } else {
1030 Ok(Value::Null)
1031 }
1032 }
1033 Value::Null => Ok(Value::Null),
1034 _ => Err(QueryError::Type(
1035 "ONLY: second argument must be an inventory, position, or amount"
1036 .to_string(),
1037 )),
1038 }
1039 }
1040 "GETPRICE" => {
1042 if args.len() < 2 || args.len() > 3 {
1043 return Err(QueryError::InvalidArguments(
1044 "GETPRICE".to_string(),
1045 "expected 2 or 3 arguments".to_string(),
1046 ));
1047 }
1048 let base = match &args[0] {
1050 Value::String(s) => s.clone(),
1051 Value::Null => return Ok(Value::Null),
1052 _ => {
1053 return Err(QueryError::Type(
1054 "GETPRICE: first argument must be a currency string".to_string(),
1055 ));
1056 }
1057 };
1058 let quote = match &args[1] {
1059 Value::String(s) => s.clone(),
1060 Value::Null => return Ok(Value::Null),
1061 _ => {
1062 return Err(QueryError::Type(
1063 "GETPRICE: second argument must be a currency string".to_string(),
1064 ));
1065 }
1066 };
1067 let date = if args.len() == 3 {
1068 match &args[2] {
1069 Value::Date(d) => *d,
1070 Value::Null => self.query_date,
1071 _ => self.query_date,
1072 }
1073 } else {
1074 self.query_date
1075 };
1076 match self.price_db.get_price(&base, "e, date) {
1077 Some(price) => Ok(Value::Number(price)),
1078 None => Ok(Value::Null),
1079 }
1080 }
1081 "EMPTY" => {
1083 Self::require_args_count(&name_upper, args, 1)?;
1084 match &args[0] {
1085 Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
1086 Value::Null => Ok(Value::Boolean(true)),
1087 _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
1088 }
1089 }
1090 "FILTER_CURRENCY" => {
1091 Self::require_args_count(&name_upper, args, 2)?;
1092 let currency = match &args[1] {
1093 Value::String(s) => s.clone(),
1094 _ => {
1095 return Err(QueryError::Type(
1096 "FILTER_CURRENCY expects (inventory, string)".to_string(),
1097 ));
1098 }
1099 };
1100 match &args[0] {
1101 Value::Inventory(inv) => {
1102 let filtered: Vec<Position> = inv
1103 .positions()
1104 .filter(|p| p.units.currency.as_str() == currency)
1105 .cloned()
1106 .collect();
1107 let mut new_inv = Inventory::new();
1108 for pos in filtered {
1109 new_inv.add(pos);
1110 }
1111 Ok(Value::Inventory(Box::new(new_inv)))
1112 }
1113 Value::Null => Ok(Value::Null),
1114 _ => Err(QueryError::Type(
1115 "FILTER_CURRENCY expects (inventory, string)".to_string(),
1116 )),
1117 }
1118 }
1119 "POSSIGN" => {
1120 Self::require_args_count(&name_upper, args, 2)?;
1121 let account_str = match &args[1] {
1122 Value::String(s) => s.clone(),
1123 _ => {
1124 return Err(QueryError::Type(
1125 "POSSIGN expects (amount, account_string)".to_string(),
1126 ));
1127 }
1128 };
1129 let first_component = account_str.split(':').next().unwrap_or("");
1130 let is_credit_normal =
1131 matches!(first_component, "Liabilities" | "Equity" | "Income");
1132 match &args[0] {
1133 Value::Amount(a) => {
1134 let mut amt = a.clone();
1135 if is_credit_normal {
1136 amt.number = -amt.number;
1137 }
1138 Ok(Value::Amount(amt))
1139 }
1140 Value::Number(n) => {
1141 let adjusted = if is_credit_normal { -n } else { *n };
1142 Ok(Value::Number(adjusted))
1143 }
1144 Value::Null => Ok(Value::Null),
1145 _ => Err(QueryError::Type(
1146 "POSSIGN expects (amount, account_string)".to_string(),
1147 )),
1148 }
1149 }
1150 "CONVERT" => {
1152 if args.len() < 2 || args.len() > 3 {
1153 return Err(QueryError::InvalidArguments(
1154 "CONVERT".to_string(),
1155 "expected 2 or 3 arguments: (value, currency[, date])".to_string(),
1156 ));
1157 }
1158
1159 let target_currency = match &args[1] {
1160 Value::String(s) => s.clone(),
1161 Value::Null => {
1162 return Err(QueryError::Type(
1163 concat!(
1164 "CONVERT: second argument evaluated to NULL; ",
1165 "expected a currency string ",
1166 "(this often means an aggregate expression couldn't ",
1167 "evaluate against an empty group — see issue #902)",
1168 )
1169 .to_string(),
1170 ));
1171 }
1172 _ => {
1173 return Err(QueryError::Type(
1174 "CONVERT: second argument must be a currency string".to_string(),
1175 ));
1176 }
1177 };
1178
1179 let date: Option<rustledger_core::NaiveDate> = if args.len() == 3 {
1181 match &args[2] {
1182 Value::Date(d) => Some(*d),
1183 Value::Null => None, _ => {
1185 return Err(QueryError::Type(
1186 "CONVERT: third argument must be a date".to_string(),
1187 ));
1188 }
1189 }
1190 } else {
1191 None
1192 };
1193
1194 let convert_amount = |amt: &Amount| -> Option<Amount> {
1196 if let Some(d) = date {
1197 self.price_db.convert(amt, &target_currency, d)
1198 } else {
1199 self.price_db.convert_latest(amt, &target_currency)
1200 }
1201 };
1202
1203 match &args[0] {
1204 Value::Position(p) => {
1205 if p.units.currency == target_currency {
1206 Ok(Value::Amount(p.units.clone()))
1207 } else if let Some(converted) = convert_amount(&p.units) {
1208 Ok(Value::Amount(converted))
1209 } else {
1210 Ok(Value::Amount(p.units.clone()))
1211 }
1212 }
1213 Value::Amount(a) => {
1214 if a.currency == target_currency {
1215 Ok(Value::Amount(a.clone()))
1216 } else if let Some(converted) = convert_amount(a) {
1217 Ok(Value::Amount(converted))
1218 } else {
1219 Ok(Value::Amount(a.clone()))
1220 }
1221 }
1222 Value::Inventory(inv) => {
1223 let mut result = Inventory::default();
1226 for pos in inv.positions() {
1227 if pos.units.currency == target_currency {
1228 result.add(Position::simple(pos.units.clone()));
1229 } else if let Some(converted) = convert_amount(&pos.units) {
1230 result.add(Position::simple(converted));
1231 } else {
1232 result.add(Position::simple(pos.units.clone()));
1234 }
1235 }
1236 let positions: Vec<&Position> = result.positions().collect();
1239 if positions.is_empty() {
1240 Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1241 } else if positions.len() == 1
1242 && positions[0].units.currency == target_currency
1243 {
1244 Ok(Value::Amount(positions[0].units.clone()))
1245 } else {
1246 Ok(Value::Inventory(Box::new(result)))
1247 }
1248 }
1249 Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
1250 Value::Null => {
1251 Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1254 }
1255 _ => Err(QueryError::Type(
1256 "CONVERT expects a position, amount, inventory, or number".to_string(),
1257 )),
1258 }
1259 }
1260 "STR" => {
1262 Self::require_args_count(&name_upper, args, 1)?;
1263 Self::value_to_str(&args[0])
1264 }
1265 "INT" => {
1266 Self::require_args_count(&name_upper, args, 1)?;
1267 Self::value_to_int(&args[0])
1268 }
1269 "DECIMAL" => {
1270 Self::require_args_count(&name_upper, args, 1)?;
1271 Self::value_to_decimal(&args[0])
1272 }
1273 "BOOL" => {
1274 Self::require_args_count(&name_upper, args, 1)?;
1275 Self::value_to_bool(&args[0])
1276 }
1277 "QUARTER" => {
1279 Self::require_args_count(&name_upper, args, 1)?;
1280 match &args[0] {
1281 Value::Date(d) => Ok(Value::Integer(((d.month() - 1) / 3 + 1).into())),
1282 _ => Err(QueryError::Type("QUARTER expects a date".to_string())),
1283 }
1284 }
1285 "WEEKDAY" => {
1286 Self::require_args_count(&name_upper, args, 1)?;
1287 match &args[0] {
1288 Value::Date(d) => Ok(Value::Integer(
1289 (d.weekday().to_monday_zero_offset() as u32).into(),
1290 )),
1291 _ => Err(QueryError::Type("WEEKDAY expects a date".to_string())),
1292 }
1293 }
1294 "YMONTH" => {
1295 Self::require_args_count(&name_upper, args, 1)?;
1296 match &args[0] {
1297 Value::Date(d) => {
1298 Ok(Value::String(format!("{:04}-{:02}", d.year(), d.month())))
1299 }
1300 _ => Err(QueryError::Type("YMONTH expects a date".to_string())),
1301 }
1302 }
1303 "SUBSTR" | "SUBSTRING" => {
1305 if args.len() < 2 || args.len() > 3 {
1306 return Err(QueryError::InvalidArguments(
1307 name_upper,
1308 "expected 2 or 3 arguments".to_string(),
1309 ));
1310 }
1311 match (&args[0], &args[1], args.get(2)) {
1312 (Value::String(s), Value::Integer(start), None) => {
1313 let start = (*start).max(0) as usize;
1314 let result: String = s.chars().skip(start).collect();
1315 Ok(Value::String(result))
1316 }
1317 (Value::String(s), Value::Integer(start), Some(Value::Integer(len))) => {
1318 let start = (*start).max(0) as usize;
1319 let len = (*len).max(0) as usize;
1320 let result: String = s.chars().skip(start).take(len).collect();
1321 Ok(Value::String(result))
1322 }
1323 _ => Err(QueryError::Type(
1324 "SUBSTR expects (string, int, [int])".to_string(),
1325 )),
1326 }
1327 }
1328 "STARTSWITH" => {
1329 Self::require_args_count(&name_upper, args, 2)?;
1330 match (&args[0], &args[1]) {
1331 (Value::String(s), Value::String(prefix)) => {
1332 Ok(Value::Boolean(s.starts_with(prefix.as_str())))
1333 }
1334 _ => Err(QueryError::Type(
1335 "STARTSWITH expects two strings".to_string(),
1336 )),
1337 }
1338 }
1339 "ENDSWITH" => {
1340 Self::require_args_count(&name_upper, args, 2)?;
1341 match (&args[0], &args[1]) {
1342 (Value::String(s), Value::String(suffix)) => {
1343 Ok(Value::Boolean(s.ends_with(suffix.as_str())))
1344 }
1345 _ => Err(QueryError::Type("ENDSWITH expects two strings".to_string())),
1346 }
1347 }
1348 "MAXWIDTH" => {
1349 Self::require_args_count(&name_upper, args, 2)?;
1350 match (&args[0], &args[1]) {
1351 (Value::String(s), Value::Integer(max)) => {
1352 let n = *max as usize;
1353 if s.chars().count() <= n {
1354 Ok(Value::String(s.clone()))
1355 } else if n <= 3 {
1356 Ok(Value::String(s.chars().take(n).collect()))
1357 } else {
1358 let truncated: String = s.chars().take(n - 3).collect();
1359 Ok(Value::String(format!("{truncated}...")))
1360 }
1361 }
1362 _ => Err(QueryError::Type(
1363 "MAXWIDTH expects (string, integer)".to_string(),
1364 )),
1365 }
1366 }
1367 "ACCOUNT_DEPTH" => {
1369 Self::require_args_count(&name_upper, args, 1)?;
1370 match &args[0] {
1371 Value::String(s) => Ok(Value::Integer(s.matches(':').count() as i64 + 1)),
1372 _ => Err(QueryError::Type(
1373 "ACCOUNT_DEPTH expects an account string".to_string(),
1374 )),
1375 }
1376 }
1377 "GETITEM" | "GET" => {
1379 Self::require_args_count(&name_upper, args, 2)?;
1380 match (&args[0], &args[1]) {
1381 (Value::Inventory(inv), Value::String(currency)) => {
1382 let amount = inv.units(currency);
1383 if amount.is_zero() {
1384 Ok(Value::Null)
1385 } else {
1386 Ok(Value::Amount(Amount::new(amount, currency.as_str())))
1387 }
1388 }
1389 (Value::Null, _) => Ok(Value::Null),
1390 _ => Err(QueryError::Type(
1391 "GETITEM expects (inventory, string)".to_string(),
1392 )),
1393 }
1394 }
1395 "WEIGHT" => {
1396 Self::require_args_count(&name_upper, args, 1)?;
1397 match &args[0] {
1398 Value::Position(p) => {
1399 if let Some(cost) = &p.cost {
1400 let total = p.units.number * cost.number;
1401 Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
1402 } else {
1403 Ok(Value::Amount(p.units.clone()))
1404 }
1405 }
1406 Value::Amount(a) => Ok(Value::Amount(a.clone())),
1407 Value::Inventory(inv) => {
1408 let mut result = Inventory::new();
1409 for pos in inv.positions() {
1410 if let Some(cost) = &pos.cost {
1411 let total = pos.units.number * cost.number;
1412 result.add(Position::simple(Amount::new(
1413 total,
1414 cost.currency.clone(),
1415 )));
1416 } else {
1417 result.add(Position::simple(pos.units.clone()));
1418 }
1419 }
1420 Ok(Value::Inventory(Box::new(result)))
1421 }
1422 Value::Null => Ok(Value::Null),
1423 _ => Err(QueryError::Type(
1424 "WEIGHT expects a position, amount, or inventory".to_string(),
1425 )),
1426 }
1427 }
1428 "DATE_DIFF" => {
1430 Self::require_args_count(&name_upper, args, 2)?;
1431 match (&args[0], &args[1]) {
1432 (Value::Date(d1), Value::Date(d2)) => Ok(Value::Integer(i64::from(
1433 d1.since(*d2).unwrap_or_default().get_days(),
1434 ))),
1435 _ => Err(QueryError::Type("DATE_DIFF expects two dates".to_string())),
1436 }
1437 }
1438 "GREP" => {
1440 Self::require_args_count(&name_upper, args, 2)?;
1441 match (&args[0], &args[1]) {
1442 (Value::String(pattern), Value::String(s)) => {
1443 let re = regex::Regex::new(pattern).map_err(|e| {
1444 QueryError::Type(format!("GREP: invalid regex '{pattern}': {e}"))
1445 })?;
1446 match re.find(s) {
1447 Some(m) => Ok(Value::String(m.as_str().to_string())),
1448 None => Ok(Value::Null),
1449 }
1450 }
1451 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1453 _ => Err(QueryError::Type("GREP expects two strings".to_string())),
1454 }
1455 }
1456 "GREPN" => {
1457 Self::require_args_count(&name_upper, args, 3)?;
1458 let n = match &args[2] {
1459 Value::Integer(i) => (*i).max(0) as usize,
1460 Value::Number(n) => {
1461 use rust_decimal::prelude::ToPrimitive;
1462 n.to_usize().unwrap_or(0)
1463 }
1464 _ => {
1465 return Err(QueryError::Type(
1466 "GREPN: third argument must be an integer".to_string(),
1467 ));
1468 }
1469 };
1470 match (&args[0], &args[1]) {
1471 (Value::String(pattern), Value::String(s)) => {
1472 let re = regex::Regex::new(pattern).map_err(|e| {
1473 QueryError::Type(format!("GREPN: invalid regex '{pattern}': {e}"))
1474 })?;
1475 match re.captures(s) {
1476 Some(caps) => match caps.get(n) {
1477 Some(m) => Ok(Value::String(m.as_str().to_string())),
1478 None => Ok(Value::Null),
1479 },
1480 None => Ok(Value::Null),
1481 }
1482 }
1483 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1484 _ => Err(QueryError::Type(
1485 "GREPN expects (pattern, string, int)".to_string(),
1486 )),
1487 }
1488 }
1489 "SUBST" => {
1490 Self::require_args_count(&name_upper, args, 3)?;
1491 match (&args[0], &args[1], &args[2]) {
1492 (Value::String(pattern), Value::String(replacement), Value::String(s)) => {
1493 let re = regex::Regex::new(pattern).map_err(|e| {
1494 QueryError::Type(format!("SUBST: invalid regex '{pattern}': {e}"))
1495 })?;
1496 Ok(Value::String(
1497 re.replace_all(s, replacement.as_str()).to_string(),
1498 ))
1499 }
1500 _ => Err(QueryError::Type(
1501 "SUBST expects (pattern, replacement, string)".to_string(),
1502 )),
1503 }
1504 }
1505 "SPLITCOMP" => {
1506 Self::require_args_count(&name_upper, args, 3)?;
1507 let n = match &args[2] {
1508 Value::Integer(i) => (*i).max(0) as usize,
1509 Value::Number(n) => {
1510 use rust_decimal::prelude::ToPrimitive;
1511 n.to_usize().unwrap_or(0)
1512 }
1513 _ => {
1514 return Err(QueryError::Type(
1515 "SPLITCOMP: third argument must be an integer".to_string(),
1516 ));
1517 }
1518 };
1519 match (&args[0], &args[1]) {
1520 (Value::String(s), Value::String(delim)) => {
1521 let parts: Vec<&str> = s.split(delim.as_str()).collect();
1522 match parts.get(n) {
1523 Some(part) => Ok(Value::String((*part).to_string())),
1524 None => Ok(Value::Null),
1525 }
1526 }
1527 _ => Err(QueryError::Type(
1528 "SPLITCOMP expects (string, delimiter, int)".to_string(),
1529 )),
1530 }
1531 }
1532 "JOINSTR" => {
1533 let mut parts = Vec::new();
1534 for v in args {
1535 match v {
1536 Value::String(s) => parts.push(s.clone()),
1537 Value::StringSet(ss) => parts.extend(ss.iter().cloned()),
1538 Value::Integer(i) => parts.push(i.to_string()),
1539 Value::Number(n) => parts.push(n.to_string()),
1540 Value::Null => {}
1541 _ => {}
1542 }
1543 }
1544 Ok(Value::String(parts.join(",")))
1545 }
1546 "OPEN_DATE" => {
1548 Self::require_args_count(&name_upper, args, 1)?;
1549 match &args[0] {
1550 Value::String(account) => Ok(self
1551 .account_info
1552 .get(account.as_str())
1553 .and_then(|info| info.open_date)
1554 .map_or(Value::Null, Value::Date)),
1555 Value::Null => Ok(Value::Null),
1556 _ => Err(QueryError::Type(
1557 "OPEN_DATE expects an account string".to_string(),
1558 )),
1559 }
1560 }
1561 "CLOSE_DATE" => {
1562 Self::require_args_count(&name_upper, args, 1)?;
1563 match &args[0] {
1564 Value::String(account) => Ok(self
1565 .account_info
1566 .get(account.as_str())
1567 .and_then(|info| info.close_date)
1568 .map_or(Value::Null, Value::Date)),
1569 Value::Null => Ok(Value::Null),
1570 _ => Err(QueryError::Type(
1571 "CLOSE_DATE expects an account string".to_string(),
1572 )),
1573 }
1574 }
1575 "OPEN_META" => {
1576 Self::require_args_count(&name_upper, args, 2)?;
1577 match (&args[0], &args[1]) {
1578 (Value::String(account), Value::String(key)) => Ok(self
1579 .account_info
1580 .get(account.as_str())
1581 .and_then(|info| info.open_meta.get(key))
1582 .map_or(Value::Null, |mv| Self::meta_value_to_value(Some(mv)))),
1583 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1584 _ => Err(QueryError::Type(
1585 "OPEN_META expects (account_string, key_string)".to_string(),
1586 )),
1587 }
1588 }
1589 "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
1593 Self::require_args_count(&name_upper, args, 1)?;
1594 match &args[0] {
1595 Value::String(_) | Value::Null => Ok(Value::Null),
1596 _ => Err(QueryError::Type(format!(
1597 "{name_upper}: argument must be a string key"
1598 ))),
1599 }
1600 }
1601 "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1603 _ => Err(QueryError::UnknownFunction(name.to_string())),
1604 }
1605 }
1606
1607 fn metadata_to_value(meta: &rustledger_core::Metadata) -> Value {
1609 if meta.is_empty() {
1610 return Value::Null;
1611 }
1612 let map: std::collections::BTreeMap<String, Value> = meta
1613 .iter()
1614 .map(|(k, v)| (k.clone(), Self::meta_value_to_value(Some(v))))
1615 .collect();
1616 Value::Object(Box::new(map))
1617 }
1618
1619 fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1621 if args.len() != expected {
1622 return Err(QueryError::InvalidArguments(
1623 name.to_string(),
1624 format!("expected {} argument(s), got {}", expected, args.len()),
1625 ));
1626 }
1627 Ok(())
1628 }
1629
1630 fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1632 if func.args.len() != expected {
1633 return Err(QueryError::InvalidArguments(
1634 name.to_string(),
1635 format!("expected {expected} argument(s)"),
1636 ));
1637 }
1638 Ok(())
1639 }
1640
1641 pub(crate) fn convert_to_market_value(
1676 &self,
1677 val: &Value,
1678 explicit_currency: Option<&str>,
1679 at_date: Option<NaiveDate>,
1680 ) -> Result<Value, QueryError> {
1681 let target_currency = if let Some(currency) = explicit_currency {
1686 currency.to_string()
1687 } else {
1688 let inferred = match val {
1690 Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1691 Value::Inventory(inv) => inv
1692 .positions()
1693 .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1694 _ => None,
1695 };
1696
1697 match inferred.or_else(|| self.target_currency.clone()) {
1698 Some(c) => c,
1699 None => {
1700 return match val {
1705 Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1706 Value::Amount(a) => Ok(Value::Amount(a.clone())),
1707 Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1708 Value::Null => Ok(Value::Null),
1709 _ => Err(QueryError::Type(
1710 "VALUE expects a position, amount, or inventory".to_string(),
1711 )),
1712 };
1713 }
1714 }
1715 };
1716
1717 let convert_one = |amount: &Amount| -> Option<Amount> {
1722 match at_date {
1723 Some(d) => self.price_db.convert(amount, &target_currency, d),
1724 None => self.price_db.convert_latest(amount, &target_currency),
1725 }
1726 };
1727
1728 match val {
1729 Value::Position(p) => {
1730 if p.units.currency == target_currency {
1731 Ok(Value::Amount(p.units.clone()))
1732 } else if let Some(converted) = convert_one(&p.units) {
1733 Ok(Value::Amount(converted))
1734 } else {
1735 Ok(Value::Amount(p.units.clone()))
1736 }
1737 }
1738 Value::Amount(a) => {
1739 if a.currency == target_currency {
1740 Ok(Value::Amount(a.clone()))
1741 } else if let Some(converted) = convert_one(a) {
1742 Ok(Value::Amount(converted))
1743 } else {
1744 Ok(Value::Amount(a.clone()))
1745 }
1746 }
1747 Value::Inventory(inv) => {
1748 let mut total = Decimal::ZERO;
1749 for pos in inv.positions() {
1750 if pos.units.currency == target_currency {
1751 total += pos.units.number;
1752 } else if let Some(converted) = convert_one(&pos.units) {
1753 total += converted.number;
1754 }
1755 }
1756 Ok(Value::Amount(Amount::new(total, &target_currency)))
1757 }
1758 Value::Null => Ok(Value::Null),
1759 _ => Err(QueryError::Type(
1760 "VALUE expects a position, amount, or inventory".to_string(),
1761 )),
1762 }
1763 }
1764
1765 pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1767 matches!(expr, Expr::Window(_))
1768 }
1769
1770 fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1772 let mut names = Vec::new();
1773 for (i, target) in targets.iter().enumerate() {
1774 if matches!(target.expr, Expr::Wildcard) {
1775 if target.alias.is_some() {
1777 return Err(QueryError::Evaluation(
1778 "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1779 ));
1780 }
1781 names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1783 } else if let Some(alias) = &target.alias {
1784 names.push(alias.clone());
1785 } else {
1786 names.push(self.expr_to_name(&target.expr, i));
1787 }
1788 }
1789 Ok(names)
1790 }
1791
1792 fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1794 match expr {
1795 Expr::Wildcard => "*".to_string(),
1796 Expr::Column(name) => name.clone(),
1797 Expr::Function(func) => func.name.clone(),
1798 Expr::Window(wf) => wf.name.clone(),
1799 _ => format!("col{index}"),
1800 }
1801 }
1802
1803 pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1822 let upper = table_name.to_uppercase();
1826 let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1827
1828 match normalized {
1829 "PRICES" => Some(self.build_prices_table()),
1830 "BALANCES" => Some(self.build_balances_table()),
1831 "COMMODITIES" => Some(self.build_commodities_table()),
1832 "EVENTS" => Some(self.build_events_table()),
1833 "NOTES" => Some(self.build_notes_table()),
1834 "DOCUMENTS" => Some(self.build_documents_table()),
1835 "ACCOUNTS" => Some(self.build_accounts_table()),
1836 "TRANSACTIONS" => Some(self.build_transactions_table()),
1837 "ENTRIES" => Some(self.build_entries_table()),
1838 "POSTINGS" => Some(self.build_postings_table()),
1839 _ => None,
1840 }
1841 }
1842
1843 fn build_prices_table(&self) -> Table {
1857 let columns = vec![
1858 "date".to_string(),
1859 "currency".to_string(),
1860 "amount".to_string(),
1861 ];
1862 let mut table = Table::new(columns);
1863
1864 let mut entries: Vec<_> = self.price_db.iter_explicit_entries().collect();
1869 entries.sort_by(|(currency_a, date_a, _, _), (currency_b, date_b, _, _)| {
1871 date_a.cmp(date_b).then_with(|| currency_a.cmp(currency_b))
1872 });
1873
1874 for (base_currency, date, price_number, quote_currency) in entries {
1875 let row = vec![
1876 Value::Date(date),
1877 Value::String(base_currency.to_string()),
1878 Value::Amount(Amount::new(price_number, quote_currency)),
1879 ];
1880 table.add_row(row);
1881 }
1882
1883 table
1884 }
1885
1886 fn build_balances_table(&self) -> Table {
1893 let columns = vec![
1894 "date".to_string(),
1895 "account".to_string(),
1896 "amount".to_string(),
1897 ];
1898 let mut table = Table::new(columns);
1899
1900 let mut balances: Vec<_> = if let Some(spanned) = self.spanned_directives {
1902 spanned
1903 .iter()
1904 .filter_map(|s| {
1905 if let Directive::Balance(b) = &s.value {
1906 Some((b.date, b.account.as_ref(), b.amount.clone()))
1907 } else {
1908 None
1909 }
1910 })
1911 .collect()
1912 } else {
1913 self.directives
1914 .iter()
1915 .filter_map(|d| {
1916 if let Directive::Balance(b) = d {
1917 Some((b.date, b.account.as_ref(), b.amount.clone()))
1918 } else {
1919 None
1920 }
1921 })
1922 .collect()
1923 };
1924
1925 balances.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
1927 date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
1928 });
1929
1930 for (date, account, amount) in balances {
1931 let row = vec![
1932 Value::Date(date),
1933 Value::String(account.to_string()),
1934 Value::Amount(amount),
1935 ];
1936 table.add_row(row);
1937 }
1938
1939 table
1940 }
1941
1942 fn build_commodities_table(&self) -> Table {
1948 let columns = vec!["date".to_string(), "name".to_string()];
1949 let mut table = Table::new(columns);
1950
1951 let mut commodities: Vec<_> = if let Some(spanned) = self.spanned_directives {
1953 spanned
1954 .iter()
1955 .filter_map(|s| {
1956 if let Directive::Commodity(c) = &s.value {
1957 Some((c.date, c.currency.as_ref()))
1958 } else {
1959 None
1960 }
1961 })
1962 .collect()
1963 } else {
1964 self.directives
1965 .iter()
1966 .filter_map(|d| {
1967 if let Directive::Commodity(c) = d {
1968 Some((c.date, c.currency.as_ref()))
1969 } else {
1970 None
1971 }
1972 })
1973 .collect()
1974 };
1975
1976 commodities.sort_by(|(date_a, name_a), (date_b, name_b)| {
1978 date_a.cmp(date_b).then_with(|| name_a.cmp(name_b))
1979 });
1980
1981 for (date, name) in commodities {
1982 let row = vec![Value::Date(date), Value::String(name.to_string())];
1983 table.add_row(row);
1984 }
1985
1986 table
1987 }
1988
1989 fn build_events_table(&self) -> Table {
1996 let columns = vec![
1997 "date".to_string(),
1998 "type".to_string(),
1999 "description".to_string(),
2000 ];
2001 let mut table = Table::new(columns);
2002
2003 let mut events: Vec<_> = if let Some(spanned) = self.spanned_directives {
2005 spanned
2006 .iter()
2007 .filter_map(|s| {
2008 if let Directive::Event(e) = &s.value {
2009 Some((e.date, e.event_type.as_str(), e.value.as_str()))
2010 } else {
2011 None
2012 }
2013 })
2014 .collect()
2015 } else {
2016 self.directives
2017 .iter()
2018 .filter_map(|d| {
2019 if let Directive::Event(e) = d {
2020 Some((e.date, e.event_type.as_str(), e.value.as_str()))
2021 } else {
2022 None
2023 }
2024 })
2025 .collect()
2026 };
2027
2028 events.sort_by(|(date_a, type_a, _), (date_b, type_b, _)| {
2030 date_a.cmp(date_b).then_with(|| type_a.cmp(type_b))
2031 });
2032
2033 for (date, event_type, description) in events {
2034 let row = vec![
2035 Value::Date(date),
2036 Value::String(event_type.to_string()),
2037 Value::String(description.to_string()),
2038 ];
2039 table.add_row(row);
2040 }
2041
2042 table
2043 }
2044
2045 fn build_notes_table(&self) -> Table {
2052 let columns = vec![
2053 "date".to_string(),
2054 "account".to_string(),
2055 "comment".to_string(),
2056 ];
2057 let mut table = Table::new(columns);
2058
2059 let mut notes: Vec<_> = if let Some(spanned) = self.spanned_directives {
2061 spanned
2062 .iter()
2063 .filter_map(|s| {
2064 if let Directive::Note(n) = &s.value {
2065 Some((n.date, n.account.as_ref(), n.comment.as_str()))
2066 } else {
2067 None
2068 }
2069 })
2070 .collect()
2071 } else {
2072 self.directives
2073 .iter()
2074 .filter_map(|d| {
2075 if let Directive::Note(n) = d {
2076 Some((n.date, n.account.as_ref(), n.comment.as_str()))
2077 } else {
2078 None
2079 }
2080 })
2081 .collect()
2082 };
2083
2084 notes.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
2086 date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
2087 });
2088
2089 for (date, account, comment) in notes {
2090 let row = vec![
2091 Value::Date(date),
2092 Value::String(account.to_string()),
2093 Value::String(comment.to_string()),
2094 ];
2095 table.add_row(row);
2096 }
2097
2098 table
2099 }
2100
2101 fn build_documents_table(&self) -> Table {
2110 let columns = vec![
2111 "date".to_string(),
2112 "account".to_string(),
2113 "filename".to_string(),
2114 "tags".to_string(),
2115 "links".to_string(),
2116 ];
2117 let mut table = Table::new(columns);
2118
2119 let mut documents: Vec<_> = if let Some(spanned) = self.spanned_directives {
2121 spanned
2122 .iter()
2123 .filter_map(|s| {
2124 if let Directive::Document(d) = &s.value {
2125 Some((
2126 d.date,
2127 d.account.as_ref(),
2128 d.path.as_str(),
2129 &d.tags,
2130 &d.links,
2131 ))
2132 } else {
2133 None
2134 }
2135 })
2136 .collect()
2137 } else {
2138 self.directives
2139 .iter()
2140 .filter_map(|d| {
2141 if let Directive::Document(doc) = d {
2142 Some((
2143 doc.date,
2144 doc.account.as_ref(),
2145 doc.path.as_str(),
2146 &doc.tags,
2147 &doc.links,
2148 ))
2149 } else {
2150 None
2151 }
2152 })
2153 .collect()
2154 };
2155
2156 documents.sort_by(
2158 |(date_a, account_a, file_a, _, _), (date_b, account_b, file_b, _, _)| {
2159 date_a
2160 .cmp(date_b)
2161 .then_with(|| account_a.cmp(account_b))
2162 .then_with(|| file_a.cmp(file_b))
2163 },
2164 );
2165
2166 for (date, account, filename, tags, links) in documents {
2167 let tags_vec: Vec<String> = tags.iter().map(ToString::to_string).collect();
2168 let links_vec: Vec<String> = links.iter().map(ToString::to_string).collect();
2169 let row = vec![
2170 Value::Date(date),
2171 Value::String(account.to_string()),
2172 Value::String(filename.to_string()),
2173 Value::StringSet(tags_vec),
2174 Value::StringSet(links_vec),
2175 ];
2176 table.add_row(row);
2177 }
2178
2179 table
2180 }
2181
2182 fn build_accounts_table(&self) -> Table {
2191 let columns = vec![
2192 "account".to_string(),
2193 "open".to_string(),
2194 "close".to_string(),
2195 "currencies".to_string(),
2196 "booking".to_string(),
2197 ];
2198 let mut table = Table::new(columns);
2199
2200 let mut accounts: FxHashMap<
2202 &str,
2203 (
2204 Option<rustledger_core::NaiveDate>,
2205 Option<rustledger_core::NaiveDate>,
2206 Vec<String>,
2207 Option<&str>,
2208 ),
2209 > = FxHashMap::default();
2210
2211 let iter: Box<dyn Iterator<Item = &Directive>> =
2213 if let Some(spanned) = self.spanned_directives {
2214 Box::new(spanned.iter().map(|s| &s.value))
2215 } else {
2216 Box::new(self.directives.iter())
2217 };
2218
2219 for directive in iter {
2220 match directive {
2221 Directive::Open(open) => {
2222 let entry = accounts.entry(open.account.as_ref()).or_insert((
2223 None,
2224 None,
2225 Vec::new(),
2226 None,
2227 ));
2228 entry.0 = Some(open.date);
2229 entry.2 = open.currencies.iter().map(ToString::to_string).collect();
2230 entry.3 = open.booking.as_deref();
2231 }
2232 Directive::Close(close) => {
2233 let entry = accounts.entry(close.account.as_ref()).or_insert((
2234 None,
2235 None,
2236 Vec::new(),
2237 None,
2238 ));
2239 entry.1 = Some(close.date);
2240 }
2241 _ => {}
2242 }
2243 }
2244
2245 let mut account_list: Vec<_> = accounts.into_iter().collect();
2247 account_list.sort_by_key(|(a, _)| *a);
2248
2249 for (account, (open_date, close_date, currencies, booking)) in account_list {
2250 let row = vec![
2251 Value::String(account.to_string()),
2252 open_date.map_or(Value::Null, Value::Date),
2253 close_date.map_or(Value::Null, Value::Date),
2254 Value::StringSet(currencies),
2255 booking.map_or(Value::Null, |b| Value::String(b.to_string())),
2256 ];
2257 table.add_row(row);
2258 }
2259
2260 table
2261 }
2262
2263 fn build_transactions_table(&self) -> Table {
2274 let columns = vec![
2275 "date".to_string(),
2276 "flag".to_string(),
2277 "payee".to_string(),
2278 "narration".to_string(),
2279 "tags".to_string(),
2280 "links".to_string(),
2281 "accounts".to_string(),
2282 ];
2283 let mut table = Table::new(columns);
2284
2285 let iter: Box<dyn Iterator<Item = &Directive>> =
2287 if let Some(spanned) = self.spanned_directives {
2288 Box::new(spanned.iter().map(|s| &s.value))
2289 } else {
2290 Box::new(self.directives.iter())
2291 };
2292
2293 let mut transactions: Vec<_> = iter
2294 .filter_map(|d| {
2295 if let Directive::Transaction(txn) = d {
2296 Some(txn)
2297 } else {
2298 None
2299 }
2300 })
2301 .collect();
2302
2303 transactions.sort_by_key(|t| t.date);
2305
2306 for txn in transactions {
2307 let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2308 let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2309 let mut accounts: Vec<String> = txn
2310 .postings
2311 .iter()
2312 .map(|p| p.account.to_string())
2313 .collect::<std::collections::HashSet<_>>()
2314 .into_iter()
2315 .collect();
2316 accounts.sort(); let row = vec![
2319 Value::Date(txn.date),
2320 Value::String(txn.flag.to_string()),
2321 txn.payee
2322 .as_ref()
2323 .map_or(Value::Null, |p| Value::String(p.to_string())),
2324 Value::String(txn.narration.to_string()),
2325 Value::StringSet(tags),
2326 Value::StringSet(links),
2327 Value::StringSet(accounts),
2328 ];
2329 table.add_row(row);
2330 }
2331
2332 table
2333 }
2334
2335 fn build_entries_table(&self) -> Table {
2340 let columns = vec![
2341 "id".to_string(),
2342 "type".to_string(),
2343 "filename".to_string(),
2344 "lineno".to_string(),
2345 "date".to_string(),
2346 "flag".to_string(),
2347 "payee".to_string(),
2348 "narration".to_string(),
2349 "tags".to_string(),
2350 "links".to_string(),
2351 "accounts".to_string(),
2352 "_entry_meta".to_string(),
2353 ];
2354 let mut table = Table::new(columns);
2355
2356 if let Some(spanned) = self.spanned_directives {
2358 for (idx, spanned_dir) in spanned.iter().enumerate() {
2359 let directive = &spanned_dir.value;
2360 let source_loc = self.get_source_location(idx);
2361 let row = self.directive_to_entry_row(idx, directive, source_loc);
2362 table.add_row(row);
2363 }
2364 } else {
2365 for (idx, directive) in self.directives.iter().enumerate() {
2366 let row = self.directive_to_entry_row(idx, directive, None);
2367 table.add_row(row);
2368 }
2369 }
2370
2371 table
2372 }
2373
2374 fn directive_to_entry_row(
2376 &self,
2377 idx: usize,
2378 directive: &Directive,
2379 source_loc: Option<&SourceLocation>,
2380 ) -> Vec<Value> {
2381 let type_name = match directive {
2382 Directive::Transaction(_) => "transaction",
2383 Directive::Balance(_) => "balance",
2384 Directive::Open(_) => "open",
2385 Directive::Close(_) => "close",
2386 Directive::Commodity(_) => "commodity",
2387 Directive::Pad(_) => "pad",
2388 Directive::Event(_) => "event",
2389 Directive::Query(_) => "query",
2390 Directive::Note(_) => "note",
2391 Directive::Document(_) => "document",
2392 Directive::Price(_) => "price",
2393 Directive::Custom(_) => "custom",
2394 };
2395
2396 let date = match directive {
2397 Directive::Transaction(t) => Value::Date(t.date),
2398 Directive::Balance(b) => Value::Date(b.date),
2399 Directive::Open(o) => Value::Date(o.date),
2400 Directive::Close(c) => Value::Date(c.date),
2401 Directive::Commodity(c) => Value::Date(c.date),
2402 Directive::Pad(p) => Value::Date(p.date),
2403 Directive::Event(e) => Value::Date(e.date),
2404 Directive::Query(q) => Value::Date(q.date),
2405 Directive::Note(n) => Value::Date(n.date),
2406 Directive::Document(d) => Value::Date(d.date),
2407 Directive::Price(p) => Value::Date(p.date),
2408 Directive::Custom(c) => Value::Date(c.date),
2409 };
2410
2411 let (flag, payee, narration, tags, links, accounts) =
2412 if let Directive::Transaction(txn) = directive {
2413 let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2414 let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2415 let mut accounts: Vec<String> = txn
2416 .postings
2417 .iter()
2418 .map(|p| p.account.to_string())
2419 .collect::<std::collections::HashSet<_>>()
2420 .into_iter()
2421 .collect();
2422 accounts.sort(); (
2424 Value::String(txn.flag.to_string()),
2425 txn.payee
2426 .as_ref()
2427 .map_or(Value::Null, |p| Value::String(p.to_string())),
2428 Value::String(txn.narration.to_string()),
2429 Value::StringSet(tags),
2430 Value::StringSet(links),
2431 Value::StringSet(accounts),
2432 )
2433 } else {
2434 (
2435 Value::Null,
2436 Value::Null,
2437 Value::Null,
2438 Value::StringSet(vec![]),
2439 Value::StringSet(vec![]),
2440 Value::StringSet(vec![]),
2441 )
2442 };
2443
2444 let filename = source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
2445 let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
2446
2447 vec![
2448 Value::Integer(idx as i64), Value::String(type_name.to_string()),
2450 filename,
2451 lineno,
2452 date,
2453 flag,
2454 payee,
2455 narration,
2456 tags,
2457 links,
2458 accounts,
2459 Self::metadata_to_value(directive.meta()),
2461 ]
2462 }
2463
2464 fn build_postings_table(&self) -> Table {
2468 let columns = vec![
2469 "type".to_string(),
2471 "id".to_string(),
2472 "date".to_string(),
2473 "year".to_string(),
2474 "month".to_string(),
2475 "day".to_string(),
2476 "filename".to_string(),
2477 "lineno".to_string(),
2478 "location".to_string(),
2479 "flag".to_string(),
2481 "payee".to_string(),
2482 "narration".to_string(),
2483 "description".to_string(),
2484 "tags".to_string(),
2485 "links".to_string(),
2486 "posting_flag".to_string(),
2488 "account".to_string(),
2489 "other_accounts".to_string(),
2490 "number".to_string(),
2491 "currency".to_string(),
2492 "cost_number".to_string(),
2493 "cost_currency".to_string(),
2494 "cost_date".to_string(),
2495 "cost_label".to_string(),
2496 "position".to_string(),
2497 "price".to_string(),
2498 "weight".to_string(),
2499 "balance".to_string(),
2500 "account_balance".to_string(),
2501 "meta".to_string(),
2503 "accounts".to_string(),
2504 "_entry_meta".to_string(),
2506 "_posting_meta".to_string(),
2507 ];
2508 let mut table = Table::new(columns);
2509
2510 let mut account_balances: FxHashMap<rustledger_core::Account, Inventory> =
2512 FxHashMap::default();
2513 let mut cumulative_balance: Inventory = Inventory::default();
2518
2519 let mut transactions: Vec<(usize, &rustledger_core::Transaction)> =
2521 if let Some(spanned) = self.spanned_directives {
2522 spanned
2523 .iter()
2524 .enumerate()
2525 .filter_map(|(idx, s)| {
2526 if let Directive::Transaction(txn) = &s.value {
2527 Some((idx, txn))
2528 } else {
2529 None
2530 }
2531 })
2532 .collect()
2533 } else {
2534 self.directives
2535 .iter()
2536 .enumerate()
2537 .filter_map(|(idx, d)| {
2538 if let Directive::Transaction(txn) = d {
2539 Some((idx, txn))
2540 } else {
2541 None
2542 }
2543 })
2544 .collect()
2545 };
2546 transactions.sort_by_key(|(_, t)| t.date);
2547
2548 for (dir_idx, txn) in &transactions {
2549 let source_loc = self.get_source_location(*dir_idx);
2551 let filename =
2552 source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
2553 let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
2554 let location = source_loc.map_or(Value::Null, |loc| {
2555 Value::String(format!("{}:{}", loc.filename, loc.lineno))
2556 });
2557
2558 let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2559 let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2560
2561 let mut all_accounts: Vec<String> = txn
2562 .postings
2563 .iter()
2564 .map(|p| p.account.to_string())
2565 .collect::<std::collections::HashSet<_>>()
2566 .into_iter()
2567 .collect();
2568 all_accounts.sort();
2569
2570 let description = match &txn.payee {
2571 Some(payee) => format!("{} | {}", payee, txn.narration),
2572 None => txn.narration.to_string(),
2573 };
2574
2575 let year = Value::Integer(i64::from(txn.date.year()));
2576 let month = Value::Integer(i64::from(txn.date.month()));
2577 let day = Value::Integer(i64::from(txn.date.day()));
2578
2579 for posting in &txn.postings {
2580 if let Some(units) = posting.amount() {
2582 let pos = if let Some(cost_spec) = &posting.cost {
2583 if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
2584 Position::with_cost(units.clone(), cost)
2585 } else {
2586 Position::simple(units.clone())
2587 }
2588 } else {
2589 Position::simple(units.clone())
2590 };
2591 account_balances
2592 .entry(posting.account.clone())
2593 .or_default()
2594 .add(pos.clone());
2595 cumulative_balance.add(pos);
2596 }
2597
2598 let (number, currency) = posting.amount().map_or((Value::Null, Value::Null), |a| {
2600 (
2601 Value::Number(a.number),
2602 Value::String(a.currency.to_string()),
2603 )
2604 });
2605
2606 let (cost_number, cost_currency, cost_date, cost_label) = if let Some(cost_spec) =
2607 &posting.cost
2608 {
2609 let units = posting.amount();
2610 if let Some(cost) = units.and_then(|u| cost_spec.resolve(u.number, txn.date)) {
2611 (
2612 Value::Number(cost.number),
2613 Value::String(cost.currency.to_string()),
2614 cost.date.map_or(Value::Null, Value::Date),
2615 cost.label
2616 .as_ref()
2617 .map_or(Value::Null, |l| Value::String(l.clone())),
2618 )
2619 } else {
2620 (Value::Null, Value::Null, Value::Null, Value::Null)
2621 }
2622 } else {
2623 (Value::Null, Value::Null, Value::Null, Value::Null)
2624 };
2625
2626 let position_val = if let Some(units) = posting.amount() {
2627 if let Some(cost_spec) = &posting.cost
2628 && let Some(cost) = cost_spec.resolve(units.number, txn.date)
2629 {
2630 Value::Position(Box::new(Position::with_cost(units.clone(), cost)))
2631 } else {
2632 Value::Position(Box::new(Position::simple(units.clone())))
2633 }
2634 } else {
2635 Value::Null
2636 };
2637
2638 let price_val = posting
2639 .price
2640 .as_ref()
2641 .and_then(|p| p.amount())
2642 .map_or(Value::Null, |a| Value::Amount(a.clone()));
2643
2644 let weight_val = compute_posting_weight(posting, txn.date);
2649
2650 let balance_val = Value::Inventory(Box::new(cumulative_balance.clone()));
2651 let account_balance_val = account_balances
2652 .get(&posting.account)
2653 .map_or(Value::Null, |inv| Value::Inventory(Box::new(inv.clone())));
2654
2655 let other_accounts: Vec<String> = all_accounts
2657 .iter()
2658 .filter(|a| a.as_str() != posting.account.as_ref())
2659 .cloned()
2660 .collect();
2661
2662 let posting_flag = posting
2663 .flag
2664 .map_or(Value::Null, |f| Value::String(f.to_string()));
2665
2666 let row = vec![
2667 Value::String("transaction".to_string()),
2669 Value::Integer(*dir_idx as i64),
2670 Value::Date(txn.date),
2671 year.clone(),
2672 month.clone(),
2673 day.clone(),
2674 filename.clone(),
2675 lineno.clone(),
2676 location.clone(),
2677 Value::String(txn.flag.to_string()),
2679 txn.payee
2680 .as_ref()
2681 .map_or(Value::Null, |p| Value::String(p.to_string())),
2682 Value::String(txn.narration.to_string()),
2683 Value::String(description.clone()),
2684 Value::StringSet(tags.clone()),
2685 Value::StringSet(links.clone()),
2686 posting_flag,
2688 Value::String(posting.account.to_string()),
2689 Value::StringSet(other_accounts),
2690 number,
2691 currency,
2692 cost_number,
2693 cost_currency,
2694 cost_date,
2695 cost_label,
2696 position_val,
2697 price_val,
2698 weight_val,
2699 balance_val,
2700 account_balance_val,
2701 Value::Metadata(Box::new(posting.meta.clone())),
2703 Value::StringSet(all_accounts.clone()),
2704 Self::metadata_to_value(&txn.meta),
2706 Self::metadata_to_value(&posting.meta),
2707 ];
2708 table.add_row(row);
2709 }
2710 }
2711
2712 table
2713 }
2714}
2715
2716fn expr_references_column(expr: &Expr, name: &str) -> bool {
2725 match expr {
2726 Expr::Column(col) => col.eq_ignore_ascii_case(name),
2727 Expr::Function(call) => call.args.iter().any(|a| expr_references_column(a, name)),
2728 Expr::Window(call) => {
2729 call.args.iter().any(|a| expr_references_column(a, name))
2735 || call
2736 .over
2737 .partition_by
2738 .as_ref()
2739 .is_some_and(|ps| ps.iter().any(|p| expr_references_column(p, name)))
2740 || call
2741 .over
2742 .order_by
2743 .as_ref()
2744 .is_some_and(|os| os.iter().any(|o| expr_references_column(&o.expr, name)))
2745 }
2746 Expr::BinaryOp(op) => {
2747 expr_references_column(&op.left, name) || expr_references_column(&op.right, name)
2748 }
2749 Expr::UnaryOp(op) => expr_references_column(&op.operand, name),
2750 Expr::Paren(inner) => expr_references_column(inner, name),
2751 Expr::Between { value, low, high } => {
2752 expr_references_column(value, name)
2753 || expr_references_column(low, name)
2754 || expr_references_column(high, name)
2755 }
2756 Expr::Set(items) => items.iter().any(|i| expr_references_column(i, name)),
2757 Expr::Wildcard | Expr::Literal(_) => false,
2758 }
2759}
2760
2761fn query_references_column(query: &SelectQuery, name: &str) -> bool {
2767 if query
2768 .targets
2769 .iter()
2770 .any(|t| expr_references_column(&t.expr, name))
2771 {
2772 return true;
2773 }
2774 if let Some(w) = &query.where_clause
2775 && expr_references_column(w, name)
2776 {
2777 return true;
2778 }
2779 if let Some(g) = &query.group_by
2780 && g.iter().any(|e| expr_references_column(e, name))
2781 {
2782 return true;
2783 }
2784 if let Some(h) = &query.having
2785 && expr_references_column(h, name)
2786 {
2787 return true;
2788 }
2789 if let Some(p) = &query.pivot_by
2790 && p.iter().any(|e| expr_references_column(e, name))
2791 {
2792 return true;
2793 }
2794 if let Some(o) = &query.order_by
2795 && o.iter().any(|s| expr_references_column(&s.expr, name))
2796 {
2797 return true;
2798 }
2799 if let Some(from) = &query.from
2800 && let Some(f) = &from.filter
2801 && expr_references_column(f, name)
2802 {
2803 return true;
2804 }
2805 false
2806}
2807
2808#[cfg(test)]
2809mod tests {
2810 use super::types::{hash_row, hash_single_value};
2811 use super::*;
2812 use crate::parse;
2813 use rust_decimal_macros::dec;
2814 use rustledger_core::Posting;
2815
2816 fn date(year: i32, month: u32, day: u32) -> NaiveDate {
2817 rustledger_core::naive_date(year, month, day).unwrap()
2818 }
2819
2820 fn sample_directives() -> Vec<Directive> {
2821 vec![
2822 Directive::Transaction(
2823 Transaction::new(date(2024, 1, 15), "Coffee")
2824 .with_flag('*')
2825 .with_payee("Coffee Shop")
2826 .with_synthesized_posting(Posting::new(
2827 "Expenses:Food:Coffee",
2828 Amount::new(dec!(5.00), "USD"),
2829 ))
2830 .with_synthesized_posting(Posting::new(
2831 "Assets:Bank:Checking",
2832 Amount::new(dec!(-5.00), "USD"),
2833 )),
2834 ),
2835 Directive::Transaction(
2836 Transaction::new(date(2024, 1, 16), "Groceries")
2837 .with_flag('*')
2838 .with_payee("Supermarket")
2839 .with_synthesized_posting(Posting::new(
2840 "Expenses:Food:Groceries",
2841 Amount::new(dec!(50.00), "USD"),
2842 ))
2843 .with_synthesized_posting(Posting::new(
2844 "Assets:Bank:Checking",
2845 Amount::new(dec!(-50.00), "USD"),
2846 )),
2847 ),
2848 ]
2849 }
2850
2851 #[test]
2852 fn test_simple_select() {
2853 let directives = sample_directives();
2854 let mut executor = Executor::new(&directives);
2855
2856 let query = parse("SELECT date, account").unwrap();
2857 let result = executor.execute(&query).unwrap();
2858
2859 assert_eq!(result.columns, vec!["date", "account"]);
2860 assert_eq!(result.len(), 4); }
2862
2863 #[test]
2864 fn test_where_clause() {
2865 let directives = sample_directives();
2866 let mut executor = Executor::new(&directives);
2867
2868 let query = parse("SELECT account WHERE account ~ \"Expenses:\"").unwrap();
2869 let result = executor.execute(&query).unwrap();
2870
2871 assert_eq!(result.len(), 2); }
2873
2874 #[test]
2875 fn test_balances() {
2876 let directives = sample_directives();
2877 let mut executor = Executor::new(&directives);
2878
2879 let query = parse("BALANCES").unwrap();
2880 let result = executor.execute(&query).unwrap();
2881
2882 assert_eq!(result.columns, vec!["account", "balance"]);
2883 assert!(result.len() >= 3); }
2885
2886 #[test]
2887 fn test_account_functions() {
2888 let directives = sample_directives();
2889 let mut executor = Executor::new(&directives);
2890
2891 let query = parse("SELECT DISTINCT LEAF(account) WHERE account ~ \"Expenses:\"").unwrap();
2893 let result = executor.execute(&query).unwrap();
2894 assert_eq!(result.len(), 2); let query = parse("SELECT DISTINCT ROOT(account)").unwrap();
2898 let result = executor.execute(&query).unwrap();
2899 assert_eq!(result.len(), 2); let query = parse("SELECT DISTINCT PARENT(account) WHERE account ~ \"Expenses:\"").unwrap();
2903 let result = executor.execute(&query).unwrap();
2904 assert!(!result.is_empty()); }
2906
2907 #[test]
2908 fn test_min_max_aggregate() {
2909 let directives = sample_directives();
2910 let mut executor = Executor::new(&directives);
2911
2912 let query = parse("SELECT MIN(date)").unwrap();
2914 let result = executor.execute(&query).unwrap();
2915 assert_eq!(result.len(), 1);
2916 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
2917
2918 let query = parse("SELECT MAX(date)").unwrap();
2920 let result = executor.execute(&query).unwrap();
2921 assert_eq!(result.len(), 1);
2922 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2923 }
2924
2925 #[test]
2926 fn test_order_by() {
2927 let directives = sample_directives();
2928 let mut executor = Executor::new(&directives);
2929
2930 let query = parse("SELECT date, account ORDER BY date DESC").unwrap();
2931 let result = executor.execute(&query).unwrap();
2932
2933 assert_eq!(result.len(), 4);
2935 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2937 }
2938
2939 #[test]
2940 fn test_hash_value_all_variants() {
2941 use rustledger_core::{Cost, Inventory, Position};
2942
2943 let values = vec![
2945 Value::String("test".to_string()),
2946 Value::Number(dec!(123.45)),
2947 Value::Integer(42),
2948 Value::Date(date(2024, 1, 15)),
2949 Value::Boolean(true),
2950 Value::Boolean(false),
2951 Value::Amount(Amount::new(dec!(100), "USD")),
2952 Value::Position(Box::new(Position::simple(Amount::new(dec!(10), "AAPL")))),
2953 Value::Position(Box::new(Position::with_cost(
2954 Amount::new(dec!(10), "AAPL"),
2955 Cost::new(dec!(150), "USD"),
2956 ))),
2957 Value::Inventory(Box::new(Inventory::new())),
2958 Value::StringSet(vec!["tag1".to_string(), "tag2".to_string()]),
2959 Value::Null,
2960 ];
2961
2962 for value in &values {
2964 let hash = hash_single_value(value);
2965 assert!(hash != 0 || matches!(value, Value::Null));
2966 }
2967
2968 let hash1 = hash_single_value(&Value::String("a".to_string()));
2970 let hash2 = hash_single_value(&Value::String("b".to_string()));
2971 assert_ne!(hash1, hash2);
2972
2973 let hash3 = hash_single_value(&Value::Integer(42));
2975 let hash4 = hash_single_value(&Value::Integer(42));
2976 assert_eq!(hash3, hash4);
2977 }
2978
2979 #[test]
2980 fn test_hash_row_distinct() {
2981 let row1 = vec![Value::String("a".to_string()), Value::Integer(1)];
2983 let row2 = vec![Value::String("a".to_string()), Value::Integer(1)];
2984 let row3 = vec![Value::String("b".to_string()), Value::Integer(1)];
2985
2986 assert_eq!(hash_row(&row1), hash_row(&row2));
2987 assert_ne!(hash_row(&row1), hash_row(&row3));
2988 }
2989
2990 #[test]
2991 fn test_string_set_hash_order_independent() {
2992 let set1 = Value::StringSet(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2994 let set2 = Value::StringSet(vec!["c".to_string(), "a".to_string(), "b".to_string()]);
2995 let set3 = Value::StringSet(vec!["b".to_string(), "c".to_string(), "a".to_string()]);
2996
2997 let hash1 = hash_single_value(&set1);
2998 let hash2 = hash_single_value(&set2);
2999 let hash3 = hash_single_value(&set3);
3000
3001 assert_eq!(hash1, hash2);
3002 assert_eq!(hash2, hash3);
3003 }
3004
3005 #[test]
3006 fn test_inventory_hash_includes_cost() {
3007 use rustledger_core::{Cost, Inventory, Position};
3008
3009 let mut inv1 = Inventory::new();
3011 inv1.add(Position::with_cost(
3012 Amount::new(dec!(10), "AAPL"),
3013 Cost::new(dec!(100), "USD"),
3014 ));
3015
3016 let mut inv2 = Inventory::new();
3017 inv2.add(Position::with_cost(
3018 Amount::new(dec!(10), "AAPL"),
3019 Cost::new(dec!(200), "USD"),
3020 ));
3021
3022 let hash1 = hash_single_value(&Value::Inventory(Box::new(inv1)));
3023 let hash2 = hash_single_value(&Value::Inventory(Box::new(inv2)));
3024
3025 assert_ne!(hash1, hash2);
3026 }
3027
3028 #[test]
3029 fn test_distinct_deduplication() {
3030 let directives = sample_directives();
3031 let mut executor = Executor::new(&directives);
3032
3033 let query = parse("SELECT flag").unwrap();
3035 let result = executor.execute(&query).unwrap();
3036 assert_eq!(result.len(), 4); let query = parse("SELECT DISTINCT flag").unwrap();
3040 let result = executor.execute(&query).unwrap();
3041 assert_eq!(result.len(), 1); }
3043
3044 #[test]
3045 fn test_limit_clause() {
3046 let directives = sample_directives();
3047 let mut executor = Executor::new(&directives);
3048
3049 let query = parse("SELECT date, account LIMIT 2").unwrap();
3051 let result = executor.execute(&query).unwrap();
3052 assert_eq!(result.len(), 2);
3053
3054 let query = parse("SELECT date LIMIT 0").unwrap();
3056 let result = executor.execute(&query).unwrap();
3057 assert_eq!(result.len(), 0);
3058
3059 let query = parse("SELECT date LIMIT 100").unwrap();
3061 let result = executor.execute(&query).unwrap();
3062 assert_eq!(result.len(), 4);
3063 }
3064
3065 #[test]
3066 fn test_group_by_with_count() {
3067 let directives = sample_directives();
3068 let mut executor = Executor::new(&directives);
3069
3070 let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
3072 let result = executor.execute(&query).unwrap();
3073
3074 assert_eq!(result.columns.len(), 2);
3075 assert_eq!(result.len(), 2);
3077 }
3078
3079 #[test]
3080 fn test_count_aggregate() {
3081 let directives = sample_directives();
3082 let mut executor = Executor::new(&directives);
3083
3084 let query = parse("SELECT COUNT(account)").unwrap();
3086 let result = executor.execute(&query).unwrap();
3087
3088 assert_eq!(result.len(), 1);
3089 assert_eq!(result.rows[0][0], Value::Integer(4));
3090
3091 let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
3093 let result = executor.execute(&query).unwrap();
3094 assert_eq!(result.len(), 2); }
3096
3097 #[test]
3098 fn test_count_wildcard_direct() {
3099 let directives = sample_directives();
3101 let mut executor = Executor::new(&directives);
3102
3103 let query = parse("SELECT count(*)").unwrap();
3105 let result = executor.execute(&query).unwrap();
3106 assert_eq!(result.len(), 1);
3107 assert_eq!(result.rows[0][0], Value::Integer(4)); let query = parse("SELECT account, count(*) GROUP BY account").unwrap();
3112 let result = executor.execute(&query).unwrap();
3113 assert_eq!(result.len(), 3); }
3115
3116 #[test]
3117 fn test_count_wildcard_from_postings_table() {
3118 let directives = sample_directives();
3120 let mut executor = Executor::new(&directives);
3121
3122 let query = parse("SELECT account, count(*) FROM postings GROUP BY account").unwrap();
3124 let result = executor.execute(&query).unwrap();
3125 assert_eq!(result.len(), 3);
3127 }
3128
3129 #[test]
3130 fn test_count_wildcard_from_entries_table() {
3131 let directives = sample_directives();
3133 let mut executor = Executor::new(&directives);
3134
3135 let query = parse("SELECT type, count(*) FROM entries GROUP BY type").unwrap();
3136 let result = executor.execute(&query).unwrap();
3137 assert_eq!(result.len(), 1);
3139 assert_eq!(result.rows[0][0], Value::String("transaction".to_string()));
3140 assert_eq!(result.rows[0][1], Value::Integer(2));
3141 }
3142
3143 #[test]
3144 fn test_count_wildcard_having() {
3145 let directives = sample_directives();
3147 let mut executor = Executor::new(&directives);
3148
3149 let query = parse(
3151 "SELECT account, count(*) AS cnt FROM postings GROUP BY account HAVING count(*) > 0",
3152 )
3153 .unwrap();
3154 let result = executor.execute(&query).unwrap();
3155 assert_eq!(result.len(), 3);
3156
3157 let query = parse(
3159 "SELECT account, count(*) AS cnt FROM postings GROUP BY account HAVING count(*) > 1",
3160 )
3161 .unwrap();
3162 let result = executor.execute(&query).unwrap();
3163 assert_eq!(result.len(), 1);
3164 assert_eq!(
3165 result.rows[0][0],
3166 Value::String("Assets:Bank:Checking".to_string())
3167 );
3168 assert_eq!(result.rows[0][1], Value::Integer(2));
3169 }
3170
3171 #[test]
3172 fn test_journal_query() {
3173 let directives = sample_directives();
3174 let mut executor = Executor::new(&directives);
3175
3176 let query = parse("JOURNAL \"Expenses\"").unwrap();
3178 let result = executor.execute(&query).unwrap();
3179
3180 assert!(result.columns.contains(&"account".to_string()));
3182 assert_eq!(result.len(), 2);
3184 }
3185
3186 #[test]
3187 fn test_print_query() {
3188 let directives = sample_directives();
3189 let mut executor = Executor::new(&directives);
3190
3191 let query = parse("PRINT").unwrap();
3193 let result = executor.execute(&query).unwrap();
3194
3195 assert_eq!(result.columns.len(), 1);
3197 assert_eq!(result.columns[0], "directive");
3198 assert_eq!(result.len(), 2);
3200 }
3201
3202 #[test]
3203 fn test_empty_directives() {
3204 let directives: Vec<Directive> = vec![];
3205 let mut executor = Executor::new(&directives);
3206
3207 let query = parse("SELECT date, account").unwrap();
3209 let result = executor.execute(&query).unwrap();
3210 assert!(result.is_empty());
3211
3212 let query = parse("BALANCES").unwrap();
3214 let result = executor.execute(&query).unwrap();
3215 assert!(result.is_empty());
3216 }
3217
3218 #[test]
3219 fn test_comparison_operators() {
3220 let directives = sample_directives();
3221 let mut executor = Executor::new(&directives);
3222
3223 let query = parse("SELECT date WHERE date < 2024-01-16").unwrap();
3225 let result = executor.execute(&query).unwrap();
3226 assert_eq!(result.len(), 2); let query = parse("SELECT date WHERE year > 2023").unwrap();
3230 let result = executor.execute(&query).unwrap();
3231 assert_eq!(result.len(), 4); let query = parse("SELECT account WHERE day = 15").unwrap();
3235 let result = executor.execute(&query).unwrap();
3236 assert_eq!(result.len(), 2); }
3238
3239 #[test]
3240 fn test_logical_operators() {
3241 let directives = sample_directives();
3242 let mut executor = Executor::new(&directives);
3243
3244 let query = parse("SELECT account WHERE account ~ \"Expenses\" AND day > 14").unwrap();
3246 let result = executor.execute(&query).unwrap();
3247 assert_eq!(result.len(), 2); let query = parse("SELECT account WHERE day = 15 OR day = 16").unwrap();
3251 let result = executor.execute(&query).unwrap();
3252 assert_eq!(result.len(), 4); }
3254
3255 #[test]
3256 fn test_arithmetic_expressions() {
3257 let directives = sample_directives();
3258 let mut executor = Executor::new(&directives);
3259
3260 let query = parse("SELECT -day WHERE day = 15").unwrap();
3262 let result = executor.execute(&query).unwrap();
3263 assert_eq!(result.len(), 2);
3264 for row in &result.rows {
3266 if let Value::Integer(n) = &row[0] {
3267 assert_eq!(*n, -15);
3268 }
3269 }
3270 }
3271
3272 #[test]
3273 fn test_first_last_aggregates() {
3274 let directives = sample_directives();
3275 let mut executor = Executor::new(&directives);
3276
3277 let query = parse("SELECT FIRST(date)").unwrap();
3279 let result = executor.execute(&query).unwrap();
3280 assert_eq!(result.len(), 1);
3281 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
3282
3283 let query = parse("SELECT LAST(date)").unwrap();
3285 let result = executor.execute(&query).unwrap();
3286 assert_eq!(result.len(), 1);
3287 assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
3288 }
3289
3290 #[test]
3291 fn test_wildcard_select() {
3292 let directives = sample_directives();
3293 let mut executor = Executor::new(&directives);
3294
3295 let query = parse("SELECT *").unwrap();
3297 let result = executor.execute(&query).unwrap();
3298
3299 assert_eq!(
3301 result.columns,
3302 vec!["date", "flag", "payee", "narration", "account", "position"]
3303 );
3304 assert_eq!(result.len(), 4);
3306 assert_eq!(result.rows[0].len(), 6);
3307 }
3308
3309 #[test]
3310 fn test_wildcard_alias_rejected() {
3311 let directives = sample_directives();
3312 let mut executor = Executor::new(&directives);
3313
3314 let query = parse("SELECT * AS data").unwrap();
3316 let result = executor.execute(&query);
3317
3318 assert!(result.is_err());
3319 let err = result.unwrap_err();
3320 assert!(
3321 err.to_string().contains("Cannot alias wildcard"),
3322 "Expected wildcard alias error, got: {err}"
3323 );
3324 }
3325
3326 #[test]
3327 fn test_query_result_methods() {
3328 let mut result = QueryResult::new(vec!["col1".to_string(), "col2".to_string()]);
3329
3330 assert!(result.is_empty());
3332 assert_eq!(result.len(), 0);
3333
3334 result.add_row(vec![Value::Integer(1), Value::String("a".to_string())]);
3336 assert!(!result.is_empty());
3337 assert_eq!(result.len(), 1);
3338
3339 result.add_row(vec![Value::Integer(2), Value::String("b".to_string())]);
3340 assert_eq!(result.len(), 2);
3341 }
3342
3343 #[test]
3344 fn test_type_cast_functions() {
3345 let directives = sample_directives();
3346 let mut executor = Executor::new(&directives);
3347
3348 let query = parse("SELECT int(5.7)").unwrap();
3350 let result = executor.execute(&query).unwrap();
3351 assert_eq!(result.rows[0][0], Value::Integer(5));
3352
3353 let query = parse("SELECT decimal(42)").unwrap();
3355 let result = executor.execute(&query).unwrap();
3356 assert_eq!(result.rows[0][0], Value::Number(dec!(42)));
3357
3358 let query = parse("SELECT str(123)").unwrap();
3360 let result = executor.execute(&query).unwrap();
3361 assert_eq!(result.rows[0][0], Value::String("123".to_string()));
3362
3363 let query = parse("SELECT bool(1)").unwrap();
3365 let result = executor.execute(&query).unwrap();
3366 assert_eq!(result.rows[0][0], Value::Boolean(true));
3367
3368 let query = parse("SELECT bool(0)").unwrap();
3369 let result = executor.execute(&query).unwrap();
3370 assert_eq!(result.rows[0][0], Value::Boolean(false));
3371 }
3372
3373 #[test]
3375 fn test_type_casting_in_aggregate_context() {
3376 let txn1 = Transaction::new(date(2024, 1, 15), "Item 1")
3377 .with_flag('*')
3378 .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(10), "USD")))
3379 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "USD")));
3380
3381 let txn2 = Transaction::new(date(2024, 1, 16), "Item 2")
3382 .with_flag('*')
3383 .with_synthesized_posting(Posting::new("Expenses:Food", Amount::new(dec!(20), "USD")))
3384 .with_synthesized_posting(Posting::new("Assets:Cash", Amount::new(dec!(-20), "USD")));
3385
3386 let directives = vec![Directive::Transaction(txn1), Directive::Transaction(txn2)];
3387 let mut executor = Executor::new(&directives);
3388
3389 let query =
3392 parse("SELECT account, str(sum(number(units))) GROUP BY account ORDER BY account")
3393 .unwrap();
3394 let result = executor.execute(&query).unwrap();
3395 assert_eq!(result.rows.len(), 2);
3396 assert_eq!(result.rows[0][0], Value::String("Assets:Cash".to_string()));
3398 assert_eq!(result.rows[0][1], Value::String("-30".to_string()));
3399 assert_eq!(
3400 result.rows[1][0],
3401 Value::String("Expenses:Food".to_string())
3402 );
3403 assert_eq!(result.rows[1][1], Value::String("30".to_string()));
3404
3405 let query =
3407 parse("SELECT account, int(sum(number(units))) GROUP BY account ORDER BY account")
3408 .unwrap();
3409 let result = executor.execute(&query).unwrap();
3410 assert_eq!(result.rows[0][1], Value::Integer(-30));
3411 assert_eq!(result.rows[1][1], Value::Integer(30));
3412
3413 let query =
3415 parse("SELECT account, decimal(count(*)) GROUP BY account ORDER BY account").unwrap();
3416 let result = executor.execute(&query).unwrap();
3417 assert_eq!(result.rows[0][1], Value::Number(dec!(2))); assert_eq!(result.rows[1][1], Value::Number(dec!(2)));
3419
3420 let query =
3422 parse("SELECT account, bool(count(*)) GROUP BY account ORDER BY account").unwrap();
3423 let result = executor.execute(&query).unwrap();
3424 assert_eq!(result.rows[0][1], Value::Boolean(true));
3425 assert_eq!(result.rows[1][1], Value::Boolean(true));
3426 }
3427
3428 #[test]
3430 fn test_int_truncation() {
3431 let directives = sample_directives();
3432 let mut executor = Executor::new(&directives);
3433
3434 let query = parse("SELECT int(5.7)").unwrap();
3436 let result = executor.execute(&query).unwrap();
3437 assert_eq!(result.rows[0][0], Value::Integer(5));
3438
3439 let query = parse("SELECT int(-5.7)").unwrap();
3440 let result = executor.execute(&query).unwrap();
3441 assert_eq!(result.rows[0][0], Value::Integer(-5));
3442
3443 let query = parse("SELECT int(0.999)").unwrap();
3444 let result = executor.execute(&query).unwrap();
3445 assert_eq!(result.rows[0][0], Value::Integer(0));
3446 }
3447
3448 #[test]
3450 fn test_type_casting_errors() {
3451 let directives = sample_directives();
3452 let mut executor = Executor::new(&directives);
3453
3454 let query = parse("SELECT int('not-a-number')").unwrap();
3456 let result = executor.execute(&query);
3457 assert!(result.is_err());
3458 assert!(
3459 result
3460 .unwrap_err()
3461 .to_string()
3462 .contains("cannot parse 'not-a-number'")
3463 );
3464
3465 let query = parse("SELECT decimal('invalid')").unwrap();
3467 let result = executor.execute(&query);
3468 assert!(result.is_err());
3469 assert!(result.unwrap_err().to_string().contains("cannot parse"));
3470
3471 let query = parse("SELECT bool('maybe')").unwrap();
3473 let result = executor.execute(&query);
3474 assert!(result.is_err());
3475 assert!(result.unwrap_err().to_string().contains("cannot parse"));
3476 }
3477
3478 #[test]
3479 fn test_meta_functions() {
3480 let mut txn_meta: Metadata = Metadata::default();
3482 txn_meta.insert(
3483 "source".to_string(),
3484 MetaValue::String("bank_import".to_string()),
3485 );
3486
3487 let mut posting_meta: Metadata = Metadata::default();
3488 posting_meta.insert(
3489 "category".to_string(),
3490 MetaValue::String("food".to_string()),
3491 );
3492
3493 let txn = Transaction {
3494 date: date(2024, 1, 15),
3495 flag: '*',
3496 payee: Some("Coffee Shop".into()),
3497 narration: "Coffee".into(),
3498 tags: vec![],
3499 links: vec![],
3500 meta: txn_meta,
3501 postings: vec![
3502 rustledger_core::Spanned::synthesized(Posting {
3503 account: "Expenses:Food".into(),
3504 units: Some(rustledger_core::IncompleteAmount::Complete(Amount::new(
3505 dec!(5),
3506 "USD",
3507 ))),
3508 cost: None,
3509 price: None,
3510 flag: None,
3511 meta: posting_meta,
3512 comments: Vec::new(),
3513 trailing_comments: Vec::new(),
3514 }),
3515 rustledger_core::Spanned::synthesized(Posting::new(
3516 "Assets:Cash",
3517 Amount::new(dec!(-5), "USD"),
3518 )),
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_synthesized_posting(Posting::new("Assets:Euro", Amount::new(dec!(100), "EUR")))
3565 .with_synthesized_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_synthesized_posting(Posting::new(
3780 "Expenses:Food",
3781 Amount::new(dec!(5.00), "USD"),
3782 ))
3783 .with_synthesized_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 rustledger_core::Spanned::synthesized(Posting::new(
3837 "Assets:Bank",
3838 Amount::new(dec!(100), "USD"),
3839 )),
3840 rustledger_core::Spanned::synthesized(Posting::new(
3841 "Expenses:Food",
3842 Amount::new(dec!(-100), "USD"),
3843 )),
3844 ],
3845 trailing_comments: Vec::new(),
3846 })];
3847
3848 let mut executor = Executor::new(&directives);
3849
3850 let query = parse("SELECT filename").unwrap();
3852 let result = executor.execute(&query).unwrap();
3853 assert_eq!(result.rows[0][0], Value::Null);
3854
3855 let query = parse("SELECT lineno").unwrap();
3857 let result = executor.execute(&query).unwrap();
3858 assert_eq!(result.rows[0][0], Value::Null);
3859
3860 let query = parse("SELECT location").unwrap();
3862 let result = executor.execute(&query).unwrap();
3863 assert_eq!(result.rows[0][0], Value::Null);
3864 }
3865
3866 #[test]
3867 fn test_source_location_columns_with_sources() {
3868 use rustledger_loader::SourceMap;
3869 use rustledger_parser::Spanned;
3870 use std::sync::Arc;
3871
3872 let mut source_map = SourceMap::new();
3874 let source: Arc<str> =
3875 "2024-01-15 * \"Test\"\n Assets:Bank 100 USD\n Expenses:Food".into();
3876 let file_id = source_map.add_file("test.beancount".into(), source);
3877
3878 let txn = Transaction {
3880 date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
3881 flag: '*',
3882 payee: Some("Test".into()),
3883 narration: "Test transaction".into(),
3884 tags: vec![],
3885 links: vec![],
3886 meta: Metadata::default(),
3887 postings: vec![
3888 rustledger_core::Spanned::synthesized(Posting::new(
3889 "Assets:Bank",
3890 Amount::new(dec!(100), "USD"),
3891 )),
3892 rustledger_core::Spanned::synthesized(Posting::new(
3893 "Expenses:Food",
3894 Amount::new(dec!(-100), "USD"),
3895 )),
3896 ],
3897 trailing_comments: Vec::new(),
3898 };
3899
3900 let spanned_directives = vec![Spanned {
3901 value: Directive::Transaction(txn),
3902 span: rustledger_parser::Span { start: 0, end: 50 },
3903 file_id: file_id as u16,
3904 }];
3905
3906 let mut executor = Executor::new_with_sources(&spanned_directives, &source_map);
3907
3908 let query = parse("SELECT filename").unwrap();
3910 let result = executor.execute(&query).unwrap();
3911 assert_eq!(
3912 result.rows[0][0],
3913 Value::String("test.beancount".to_string())
3914 );
3915
3916 let query = parse("SELECT lineno").unwrap();
3918 let result = executor.execute(&query).unwrap();
3919 assert_eq!(result.rows[0][0], Value::Integer(1));
3920
3921 let query = parse("SELECT location").unwrap();
3923 let result = executor.execute(&query).unwrap();
3924 assert_eq!(
3925 result.rows[0][0],
3926 Value::String("test.beancount:1".to_string())
3927 );
3928 }
3929
3930 #[test]
3931 fn test_interval_function() {
3932 let directives = sample_directives();
3933 let mut executor = Executor::new(&directives);
3934
3935 let query = parse("SELECT interval('month')").unwrap();
3937 let result = executor.execute(&query).unwrap();
3938 assert_eq!(
3939 result.rows[0][0],
3940 Value::Interval(Interval::new(1, IntervalUnit::Month))
3941 );
3942
3943 let query = parse("SELECT interval(3, 'day')").unwrap();
3945 let result = executor.execute(&query).unwrap();
3946 assert_eq!(
3947 result.rows[0][0],
3948 Value::Interval(Interval::new(3, IntervalUnit::Day))
3949 );
3950
3951 let query = parse("SELECT interval(-2, 'week')").unwrap();
3953 let result = executor.execute(&query).unwrap();
3954 assert_eq!(
3955 result.rows[0][0],
3956 Value::Interval(Interval::new(-2, IntervalUnit::Week))
3957 );
3958 }
3959
3960 #[test]
3961 fn test_date_add_with_interval() {
3962 let directives = sample_directives();
3963 let mut executor = Executor::new(&directives);
3964
3965 let query = parse("SELECT date_add(date(2024, 1, 15), interval(1, 'month'))").unwrap();
3967 let result = executor.execute(&query).unwrap();
3968 assert_eq!(
3969 result.rows[0][0],
3970 Value::Date(rustledger_core::naive_date(2024, 2, 15).unwrap())
3971 );
3972
3973 let query = parse("SELECT date(2024, 1, 15) + interval(1, 'year')").unwrap();
3975 let result = executor.execute(&query).unwrap();
3976 assert_eq!(
3977 result.rows[0][0],
3978 Value::Date(rustledger_core::naive_date(2025, 1, 15).unwrap())
3979 );
3980
3981 let query = parse("SELECT date(2024, 3, 15) - interval(2, 'month')").unwrap();
3983 let result = executor.execute(&query).unwrap();
3984 assert_eq!(
3985 result.rows[0][0],
3986 Value::Date(rustledger_core::naive_date(2024, 1, 15).unwrap())
3987 );
3988 }
3989
3990 #[test]
3995 fn test_query_references_column_covers_all_query_parts() {
3996 fn assert_refs(sql: &str, expected: bool) {
3998 let q = match parse(sql).unwrap() {
3999 Query::Select(s) => s,
4000 _ => panic!("expected Select for {sql:?}"),
4001 };
4002 assert_eq!(
4003 query_references_column(&q, "balance"),
4004 expected,
4005 "query_references_column(balance) wrong for {sql:?}"
4006 );
4007 }
4008
4009 assert_refs("SELECT account FROM #postings", false);
4011 assert_refs("SELECT account WHERE account ~ '^Assets' LIMIT 10", false);
4012
4013 assert_refs("SELECT balance FROM #postings", true);
4015 assert_refs("SELECT account WHERE balance > 0", true);
4016 assert_refs("SELECT account ORDER BY balance", true);
4017 assert_refs("SELECT account GROUP BY balance", true);
4018 assert_refs(
4019 "SELECT account, sum(balance) FROM #postings GROUP BY account",
4020 true,
4021 );
4022 assert_refs("SELECT BALANCE FROM #postings", true);
4024 assert_refs("SELECT account WHERE units(balance) IS NOT NULL", true);
4026 assert_refs("SELECT account WHERE balance BETWEEN 0 AND 100", true);
4028 }
4029
4030 #[test]
4037 fn test_expr_references_column_walks_window_over_clause() {
4038 use crate::ast::{
4039 BinaryOp, BinaryOperator, OrderSpec, SortDirection, WindowFunction, WindowSpec,
4040 };
4041
4042 let col_balance = Expr::Column("balance".to_string());
4043 let col_unrelated = Expr::Column("amount".to_string());
4044
4045 let win_partition = Expr::Window(WindowFunction {
4047 name: "row_number".to_string(),
4048 args: vec![],
4049 over: WindowSpec {
4050 partition_by: Some(vec![col_balance.clone()]),
4051 order_by: None,
4052 },
4053 });
4054 assert!(
4055 expr_references_column(&win_partition, "balance"),
4056 "OVER (PARTITION BY balance) must be detected"
4057 );
4058 assert!(
4059 !expr_references_column(&win_partition, "account"),
4060 "should not match unrelated column"
4061 );
4062
4063 let win_order = Expr::Window(WindowFunction {
4065 name: "row_number".to_string(),
4066 args: vec![],
4067 over: WindowSpec {
4068 partition_by: None,
4069 order_by: Some(vec![OrderSpec {
4070 expr: Expr::BinaryOp(Box::new(BinaryOp {
4071 left: col_balance,
4072 op: BinaryOperator::Add,
4073 right: col_unrelated,
4074 })),
4075 direction: SortDirection::Asc,
4076 }]),
4077 },
4078 });
4079 assert!(
4080 expr_references_column(&win_order, "balance"),
4081 "OVER (ORDER BY balance + amount) must be detected"
4082 );
4083 }
4084}