Skip to main content

rustledger_query/executor/
mod.rs

1//! BQL Query Executor.
2//!
3//! Executes parsed BQL queries against a set of Beancount directives.
4
5mod functions;
6mod types;
7
8use types::AccountInfo;
9pub use types::{
10    Interval, IntervalUnit, PostingContext, QueryResult, Row, SourceLocation, Table, Value,
11    WindowContext,
12};
13
14use std::sync::RwLock;
15
16use rustc_hash::FxHashMap;
17
18use regex::{Regex, RegexBuilder};
19use rust_decimal::Decimal;
20use rustledger_core::{Amount, Directive, InternedStr, Inventory, Metadata, NaiveDate, Position};
21#[cfg(test)]
22use rustledger_core::{MetaValue, Transaction};
23use rustledger_loader::SourceMap;
24use rustledger_parser::Spanned;
25
26use crate::ast::{Expr, FromClause, FunctionCall, Query, Target};
27use crate::error::QueryError;
28
29/// Query executor.
30pub struct Executor<'a> {
31    /// All directives to query over.
32    directives: &'a [Directive],
33    /// Spanned directives (optional, for source location support).
34    spanned_directives: Option<&'a [Spanned<Directive>]>,
35    /// Account balances (built up during query).
36    balances: FxHashMap<InternedStr, Inventory>,
37    /// Price database for `VALUE()` conversions.
38    price_db: crate::price::PriceDatabase,
39    /// Target currency for `VALUE()` conversions.
40    target_currency: Option<String>,
41    /// Query date for price lookups (defaults to today).
42    query_date: rustledger_core::NaiveDate,
43    /// Cache for compiled regex patterns (`RwLock` for thread-safe parallel execution).
44    regex_cache: RwLock<FxHashMap<String, Option<Regex>>>,
45    /// Account info cache from Open/Close directives.
46    account_info: FxHashMap<String, AccountInfo>,
47    /// Source locations for directives (indexed by directive index).
48    source_locations: Option<Vec<SourceLocation>>,
49    /// In-memory tables created by CREATE TABLE.
50    tables: FxHashMap<String, Table>,
51}
52
53// Sub-modules for focused functionality
54mod aggregation;
55mod evaluation;
56mod execution;
57mod operators;
58mod sort;
59mod window;
60
61/// Default column names for `SELECT *` wildcard expansion.
62/// This must match the order of values pushed in `evaluate_row()`.
63pub const WILDCARD_COLUMNS: &[&str] =
64    &["date", "flag", "payee", "narration", "account", "position"];
65
66impl<'a> Executor<'a> {
67    /// Create a new executor with the given directives.
68    pub fn new(directives: &'a [Directive]) -> Self {
69        let price_db = crate::price::PriceDatabase::from_directives(directives);
70
71        // Build account info cache from Open/Close directives
72        let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
73        for directive in directives {
74            match directive {
75                Directive::Open(open) => {
76                    let account = open.account.to_string();
77                    let info = account_info.entry(account).or_insert_with(|| AccountInfo {
78                        open_date: None,
79                        close_date: None,
80                        open_meta: Metadata::default(),
81                    });
82                    info.open_date = Some(open.date);
83                    info.open_meta.clone_from(&open.meta);
84                }
85                Directive::Close(close) => {
86                    let account = close.account.to_string();
87                    let info = account_info.entry(account).or_insert_with(|| AccountInfo {
88                        open_date: None,
89                        close_date: None,
90                        open_meta: Metadata::default(),
91                    });
92                    info.close_date = Some(close.date);
93                }
94                _ => {}
95            }
96        }
97
98        Self {
99            directives,
100            spanned_directives: None,
101            balances: FxHashMap::default(),
102            price_db,
103            target_currency: None,
104            query_date: jiff::Zoned::now().date(),
105            regex_cache: RwLock::new(FxHashMap::default()),
106            account_info,
107            source_locations: None,
108            tables: FxHashMap::default(),
109        }
110    }
111
112    /// Create a new executor with source location support.
113    ///
114    /// This constructor accepts spanned directives and a source map, enabling
115    /// the `filename`, `lineno`, and `location` columns in queries.
116    pub fn new_with_sources(
117        spanned_directives: &'a [Spanned<Directive>],
118        source_map: &SourceMap,
119    ) -> Self {
120        // Build price database from spanned directives
121        // Include both explicit prices and implicit prices from transactions
122        let mut price_db = crate::price::PriceDatabase::new();
123        for spanned in spanned_directives {
124            match &spanned.value {
125                Directive::Price(p) => {
126                    price_db.add_price(p);
127                }
128                Directive::Transaction(txn) => {
129                    price_db.add_implicit_prices_from_transaction(txn);
130                }
131                _ => {}
132            }
133        }
134        price_db.sort_prices();
135
136        // Build source locations
137        let source_locations: Vec<SourceLocation> = spanned_directives
138            .iter()
139            .map(|spanned| {
140                let file = source_map.get(spanned.file_id as usize);
141                let (line, _col) = file.map_or((0, 0), |f| f.line_col(spanned.span.start));
142                SourceLocation {
143                    filename: file.map_or_else(String::new, |f| f.path.display().to_string()),
144                    lineno: line,
145                }
146            })
147            .collect();
148
149        // Build account info cache from Open/Close directives
150        let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
151        for spanned in spanned_directives {
152            match &spanned.value {
153                Directive::Open(open) => {
154                    let account = open.account.to_string();
155                    let info = account_info.entry(account).or_insert_with(|| AccountInfo {
156                        open_date: None,
157                        close_date: None,
158                        open_meta: Metadata::default(),
159                    });
160                    info.open_date = Some(open.date);
161                    info.open_meta.clone_from(&open.meta);
162                }
163                Directive::Close(close) => {
164                    let account = close.account.to_string();
165                    let info = account_info.entry(account).or_insert_with(|| AccountInfo {
166                        open_date: None,
167                        close_date: None,
168                        open_meta: Metadata::default(),
169                    });
170                    info.close_date = Some(close.date);
171                }
172                _ => {}
173            }
174        }
175
176        Self {
177            directives: &[], // Empty - we use spanned_directives instead
178            spanned_directives: Some(spanned_directives),
179            balances: FxHashMap::default(),
180            price_db,
181            target_currency: None,
182            query_date: jiff::Zoned::now().date(),
183            regex_cache: RwLock::new(FxHashMap::default()),
184            account_info,
185            source_locations: Some(source_locations),
186            tables: FxHashMap::default(),
187        }
188    }
189
190    /// Get the source location for a directive by index.
191    fn get_source_location(&self, directive_index: usize) -> Option<&SourceLocation> {
192        self.source_locations
193            .as_ref()
194            .and_then(|locs| locs.get(directive_index))
195    }
196
197    /// Get or compile a regex pattern from the cache.
198    ///
199    /// Returns `Some(Regex)` if the pattern is valid, `None` if it's invalid.
200    /// Invalid patterns are cached as `None` to avoid repeated compilation attempts.
201    fn get_or_compile_regex(&self, pattern: &str) -> Option<Regex> {
202        // Fast path: check read lock first
203        {
204            // Handle lock poisoning gracefully - if another thread panicked while holding
205            // the lock, we can still recover the cached data via into_inner()
206            let cache = match self.regex_cache.read() {
207                Ok(guard) => guard,
208                Err(poisoned) => poisoned.into_inner(),
209            };
210            if let Some(cached) = cache.get(pattern) {
211                return cached.clone();
212            }
213        }
214        // Slow path: compile and insert with write lock
215        // Use case-insensitive matching to match Python beancount behavior
216        let compiled = RegexBuilder::new(pattern)
217            .case_insensitive(true)
218            .build()
219            .ok();
220        let mut cache = match self.regex_cache.write() {
221            Ok(guard) => guard,
222            Err(poisoned) => poisoned.into_inner(),
223        };
224        // Double-check in case another thread inserted while we waited
225        if let Some(cached) = cache.get(pattern) {
226            return cached.clone();
227        }
228        cache.insert(pattern.to_string(), compiled.clone());
229        compiled
230    }
231
232    /// Get or compile a regex pattern, returning an error if invalid.
233    fn require_regex(&self, pattern: &str) -> Result<Regex, QueryError> {
234        self.get_or_compile_regex(pattern)
235            .ok_or_else(|| QueryError::Type(format!("invalid regex: {pattern}")))
236    }
237
238    /// Set the target currency for `VALUE()` conversions.
239    pub fn set_target_currency(&mut self, currency: impl Into<String>) {
240        self.target_currency = Some(currency.into());
241    }
242
243    /// Execute a query and return the results.
244    ///
245    /// # Errors
246    ///
247    /// Returns [`QueryError`] in the following cases:
248    ///
249    /// - [`QueryError::UnknownColumn`] - A referenced column name doesn't exist
250    /// - [`QueryError::UnknownFunction`] - An unknown function is called
251    /// - [`QueryError::InvalidArguments`] - Function called with wrong arguments
252    /// - [`QueryError::Type`] - Type mismatch in expression (e.g., comparing string to number)
253    /// - [`QueryError::Aggregation`] - Error in aggregate function (SUM, COUNT, etc.)
254    /// - [`QueryError::Evaluation`] - General expression evaluation error
255    pub fn execute(&mut self, query: &Query) -> Result<QueryResult, QueryError> {
256        match query {
257            Query::Select(select) => self.execute_select(select),
258            Query::Journal(journal) => self.execute_journal(journal),
259            Query::Balances(balances) => self.execute_balances(balances),
260            Query::Print(print) => self.execute_print(print),
261            Query::CreateTable(create) => self.execute_create_table(create),
262            Query::Insert(insert) => self.execute_insert(insert),
263        }
264    }
265
266    /// Execute a SELECT query.
267    fn build_balances_with_filter(&mut self, from: Option<&FromClause>) -> Result<(), QueryError> {
268        for directive in self.directives {
269            if let Directive::Transaction(txn) = directive {
270                // Apply FROM filter if present
271                if let Some(from_clause) = from
272                    && let Some(filter) = &from_clause.filter
273                    && !self.evaluate_from_filter(filter, txn)?
274                {
275                    continue;
276                }
277
278                for posting in &txn.postings {
279                    if let Some(units) = posting.amount() {
280                        let balance = self.balances.entry(posting.account.clone()).or_default();
281
282                        let pos = if let Some(cost_spec) = &posting.cost {
283                            if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
284                                Position::with_cost(units.clone(), cost)
285                            } else {
286                                Position::simple(units.clone())
287                            }
288                        } else {
289                            Position::simple(units.clone())
290                        };
291                        balance.add(pos);
292                    }
293                }
294            }
295        }
296        Ok(())
297    }
298
299    /// Collect postings matching the FROM and WHERE clauses.
300    fn collect_postings(
301        &self,
302        from: Option<&FromClause>,
303        where_clause: Option<&Expr>,
304    ) -> Result<Vec<PostingContext<'a>>, QueryError> {
305        let mut postings = Vec::new();
306        // Track running balance per account
307        let mut running_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
308
309        // Create an iterator over (directive_index, directive) pairs
310        // Handle both spanned and unspanned directives
311        let directive_iter: Vec<(usize, &Directive)> =
312            if let Some(spanned) = self.spanned_directives {
313                spanned
314                    .iter()
315                    .enumerate()
316                    .map(|(i, s)| (i, &s.value))
317                    .collect()
318            } else {
319                self.directives.iter().enumerate().collect()
320            };
321
322        for (directive_index, directive) in directive_iter {
323            if let Directive::Transaction(txn) = directive {
324                // Check FROM clause (transaction-level filter)
325                if let Some(from) = from {
326                    // Apply date filters
327                    if let Some(open_date) = from.open_on
328                        && txn.date < open_date
329                    {
330                        // Update balances but don't include in results
331                        for posting in &txn.postings {
332                            if let Some(units) = posting.amount() {
333                                let balance =
334                                    running_balances.entry(posting.account.clone()).or_default();
335                                balance.add(Position::simple(units.clone()));
336                            }
337                        }
338                        continue;
339                    }
340                    if let Some(close_date) = from.close_on
341                        && txn.date > close_date
342                    {
343                        continue;
344                    }
345                    // Apply filter expression
346                    if let Some(filter) = &from.filter
347                        && !self.evaluate_from_filter(filter, txn)?
348                    {
349                        continue;
350                    }
351                }
352
353                // Add postings with running balance
354                for (i, posting) in txn.postings.iter().enumerate() {
355                    // Update running balance for this account
356                    if let Some(units) = posting.amount() {
357                        let balance = running_balances.entry(posting.account.clone()).or_default();
358                        balance.add(Position::simple(units.clone()));
359                    }
360
361                    let ctx = PostingContext {
362                        transaction: txn,
363                        posting_index: i,
364                        balance: running_balances.get(&posting.account).cloned(),
365                        directive_index: Some(directive_index),
366                    };
367
368                    // Check WHERE clause (posting-level filter)
369                    if let Some(where_expr) = where_clause {
370                        if self.evaluate_predicate(where_expr, &ctx)? {
371                            postings.push(ctx);
372                        }
373                    } else {
374                        postings.push(ctx);
375                    }
376                }
377            }
378        }
379
380        Ok(postings)
381    }
382    fn evaluate_function(
383        &self,
384        func: &FunctionCall,
385        ctx: &PostingContext,
386    ) -> Result<Value, QueryError> {
387        let name = func.name.to_uppercase();
388        match name.as_str() {
389            // Date functions
390            "YEAR" | "MONTH" | "DAY" | "WEEKDAY" | "QUARTER" | "YMONTH" | "TODAY" => {
391                self.eval_date_function(&name, func, ctx)
392            }
393            // Extended date functions
394            "DATE" | "DATE_DIFF" | "DATE_ADD" | "DATE_TRUNC" | "DATE_PART" | "PARSE_DATE"
395            | "DATE_BIN" | "INTERVAL" => self.eval_extended_date_function(&name, func, ctx),
396            // String functions
397            "LENGTH" | "UPPER" | "LOWER" | "SUBSTR" | "SUBSTRING" | "TRIM" | "STARTSWITH"
398            | "ENDSWITH" | "GREP" | "GREPN" | "SUBST" | "SPLITCOMP" | "JOINSTR" | "MAXWIDTH" => {
399                self.eval_string_function(&name, func, ctx)
400            }
401            // Account functions
402            "PARENT" | "LEAF" | "ROOT" | "ACCOUNT_DEPTH" | "ACCOUNT_SORTKEY" => {
403                self.eval_account_function(&name, func, ctx)
404            }
405            // Account metadata functions
406            "OPEN_DATE" | "CLOSE_DATE" | "OPEN_META" => {
407                self.eval_account_meta_function(&name, func, ctx)
408            }
409            // Math functions
410            "ABS" | "NEG" | "ROUND" | "SAFEDIV" => self.eval_math_function(&name, func, ctx),
411            // Amount/Position functions
412            "NUMBER" | "CURRENCY" | "GETITEM" | "GET" | "UNITS" | "COST" | "WEIGHT" | "VALUE" => {
413                self.eval_position_function(&name, func, ctx)
414            }
415            // Inventory functions
416            "EMPTY" | "FILTER_CURRENCY" | "POSSIGN" => {
417                self.eval_inventory_function(&name, func, ctx)
418            }
419            // Price functions
420            "GETPRICE" => self.eval_getprice(func, ctx),
421            // Utility functions
422            "COALESCE" => self.eval_coalesce(func, ctx),
423            "ONLY" => self.eval_only(func, ctx),
424            // Metadata functions
425            "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
426                self.eval_meta_function(&name, func, ctx)
427            }
428            // Currency conversion
429            "CONVERT" => self.eval_convert(func, ctx),
430            // Type casting functions
431            "INT" => self.eval_int(func, ctx),
432            "DECIMAL" => self.eval_decimal(func, ctx),
433            "STR" => self.eval_str(func, ctx),
434            "BOOL" => self.eval_bool(func, ctx),
435            // Aggregate functions return Null when evaluated on a single row
436            // They're handled specially in aggregate evaluation
437            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
438            _ => Err(QueryError::UnknownFunction(func.name.clone())),
439        }
440    }
441
442    /// Evaluate a function with pre-evaluated arguments (for subquery context).
443    fn evaluate_function_on_values(&self, name: &str, args: &[Value]) -> Result<Value, QueryError> {
444        let name_upper = name.to_uppercase();
445        match name_upper.as_str() {
446            // Date functions
447            "TODAY" => Ok(Value::Date(jiff::Zoned::now().date())),
448            "YEAR" => {
449                Self::require_args_count(&name_upper, args, 1)?;
450                match &args[0] {
451                    Value::Date(d) => Ok(Value::Integer(d.year().into())),
452                    _ => Err(QueryError::Type("YEAR expects a date".to_string())),
453                }
454            }
455            "MONTH" => {
456                Self::require_args_count(&name_upper, args, 1)?;
457                match &args[0] {
458                    Value::Date(d) => Ok(Value::Integer(d.month().into())),
459                    _ => Err(QueryError::Type("MONTH expects a date".to_string())),
460                }
461            }
462            "DAY" => {
463                Self::require_args_count(&name_upper, args, 1)?;
464                match &args[0] {
465                    Value::Date(d) => Ok(Value::Integer(d.day().into())),
466                    _ => Err(QueryError::Type("DAY expects a date".to_string())),
467                }
468            }
469            // String functions
470            "LENGTH" => {
471                Self::require_args_count(&name_upper, args, 1)?;
472                match &args[0] {
473                    Value::String(s) => Ok(Value::Integer(s.len() as i64)),
474                    _ => Err(QueryError::Type("LENGTH expects a string".to_string())),
475                }
476            }
477            "UPPER" => {
478                Self::require_args_count(&name_upper, args, 1)?;
479                match &args[0] {
480                    Value::String(s) => Ok(Value::String(s.to_uppercase())),
481                    _ => Err(QueryError::Type("UPPER expects a string".to_string())),
482                }
483            }
484            "LOWER" => {
485                Self::require_args_count(&name_upper, args, 1)?;
486                match &args[0] {
487                    Value::String(s) => Ok(Value::String(s.to_lowercase())),
488                    _ => Err(QueryError::Type("LOWER expects a string".to_string())),
489                }
490            }
491            "TRIM" => {
492                Self::require_args_count(&name_upper, args, 1)?;
493                match &args[0] {
494                    Value::String(s) => Ok(Value::String(s.trim().to_string())),
495                    _ => Err(QueryError::Type("TRIM expects a string".to_string())),
496                }
497            }
498            // Math functions
499            "ABS" => {
500                Self::require_args_count(&name_upper, args, 1)?;
501                match &args[0] {
502                    Value::Number(n) => Ok(Value::Number(n.abs())),
503                    Value::Integer(i) => Ok(Value::Integer(i.abs())),
504                    _ => Err(QueryError::Type("ABS expects a number".to_string())),
505                }
506            }
507            "ROUND" => {
508                if args.is_empty() || args.len() > 2 {
509                    return Err(QueryError::InvalidArguments(
510                        "ROUND".to_string(),
511                        "expected 1 or 2 arguments".to_string(),
512                    ));
513                }
514                match &args[0] {
515                    Value::Number(n) => {
516                        let scale = if args.len() == 2 {
517                            match &args[1] {
518                                Value::Integer(i) => *i as u32,
519                                _ => 0,
520                            }
521                        } else {
522                            0
523                        };
524                        Ok(Value::Number(n.round_dp(scale)))
525                    }
526                    Value::Integer(i) => Ok(Value::Integer(*i)),
527                    _ => Err(QueryError::Type("ROUND expects a number".to_string())),
528                }
529            }
530            // Utility functions
531            "COALESCE" => {
532                for arg in args {
533                    if !matches!(arg, Value::Null) {
534                        return Ok(arg.clone());
535                    }
536                }
537                Ok(Value::Null)
538            }
539            // Position/Amount functions
540            "NUMBER" => {
541                Self::require_args_count(&name_upper, args, 1)?;
542                match &args[0] {
543                    Value::Amount(a) => Ok(Value::Number(a.number)),
544                    Value::Position(p) => Ok(Value::Number(p.units.number)),
545                    Value::Number(n) => Ok(Value::Number(*n)),
546                    Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
547                    Value::Inventory(inv) => {
548                        // For inventory, only return a number if all positions share the same
549                        // currency. Summing across different currencies is not meaningful.
550                        let positions = inv.positions();
551                        if positions.is_empty() {
552                            return Ok(Value::Number(Decimal::ZERO));
553                        }
554                        let first_currency = &positions[0].units.currency;
555                        let all_same_currency = positions
556                            .iter()
557                            .all(|p| &p.units.currency == first_currency);
558                        if all_same_currency {
559                            let total: Decimal = positions.iter().map(|p| p.units.number).sum();
560                            Ok(Value::Number(total))
561                        } else {
562                            // Multiple currencies - return NULL rather than a meaningless sum
563                            Ok(Value::Null)
564                        }
565                    }
566                    Value::Null => Ok(Value::Null),
567                    _ => Err(QueryError::Type(
568                        "NUMBER expects an amount, position, or inventory".to_string(),
569                    )),
570                }
571            }
572            "CURRENCY" => {
573                Self::require_args_count(&name_upper, args, 1)?;
574                match &args[0] {
575                    Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
576                    Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
577                    Value::Inventory(inv) => {
578                        // Return the currency of the first position, or Null if empty
579                        if let Some(pos) = inv.positions().first() {
580                            Ok(Value::String(pos.units.currency.to_string()))
581                        } else {
582                            Ok(Value::Null)
583                        }
584                    }
585                    Value::Null => Ok(Value::Null),
586                    _ => Err(QueryError::Type(
587                        "CURRENCY expects an amount or position".to_string(),
588                    )),
589                }
590            }
591            "UNITS" => {
592                Self::require_args_count(&name_upper, args, 1)?;
593                match &args[0] {
594                    Value::Position(p) => Ok(Value::Amount(p.units.clone())),
595                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
596                    Value::Inventory(inv) => {
597                        // Return inventory with just units (no cost info)
598                        let mut units_inv = Inventory::new();
599                        for pos in inv.positions() {
600                            units_inv.add(Position::simple(pos.units.clone()));
601                        }
602                        Ok(Value::Inventory(Box::new(units_inv)))
603                    }
604                    Value::Null => Ok(Value::Null),
605                    _ => Err(QueryError::Type(
606                        "UNITS expects a position or inventory".to_string(),
607                    )),
608                }
609            }
610            "COST" => {
611                Self::require_args_count(&name_upper, args, 1)?;
612                match &args[0] {
613                    Value::Position(p) => {
614                        if let Some(cost) = &p.cost {
615                            // Preserve sign: buys give positive cost, sells give negative
616                            let total = p.units.number * cost.number;
617                            Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
618                        } else {
619                            Ok(Value::Amount(p.units.clone()))
620                        }
621                    }
622                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
623                    Value::Inventory(inv) => {
624                        let mut total = Decimal::ZERO;
625                        let mut currency: Option<InternedStr> = None;
626                        for pos in inv.positions() {
627                            if let Some(cost) = &pos.cost {
628                                total += pos.units.number * cost.number;
629                                if currency.is_none() {
630                                    currency = Some(cost.currency.clone());
631                                }
632                            } else {
633                                total += pos.units.number;
634                                if currency.is_none() {
635                                    currency = Some(pos.units.currency.clone());
636                                }
637                            }
638                        }
639                        if let Some(curr) = currency {
640                            Ok(Value::Amount(Amount::new(total, curr)))
641                        } else {
642                            Ok(Value::Null)
643                        }
644                    }
645                    Value::Null => Ok(Value::Null),
646                    _ => Err(QueryError::Type(
647                        "COST expects a position or inventory".to_string(),
648                    )),
649                }
650            }
651            "VALUE" => {
652                // Use shared VALUE implementation for consistent behavior.
653                // See `eval_value` on PositionFunctions for the full signature
654                // contract (DATE vs. currency-string dispatch).
655                if args.is_empty() || args.len() > 2 {
656                    return Err(QueryError::InvalidArguments(
657                        "VALUE".to_string(),
658                        "expected 1-2 arguments".to_string(),
659                    ));
660                }
661                let (explicit_currency, at_date) = if args.len() == 2 {
662                    match &args[1] {
663                        Value::Date(d) => (None, Some(*d)),
664                        Value::String(s) => (Some(s.as_str()), None),
665                        Value::Null => {
666                            return Err(QueryError::Type(
667                                concat!(
668                                    "VALUE: second argument evaluated to NULL; ",
669                                    "expected a date or currency string ",
670                                    "(this often means an aggregate expression couldn't ",
671                                    "evaluate against an empty group — see issue #902)",
672                                )
673                                .to_string(),
674                            ));
675                        }
676                        _ => {
677                            return Err(QueryError::Type(
678                                "VALUE second argument must be a date or currency string"
679                                    .to_string(),
680                            ));
681                        }
682                    }
683                } else {
684                    (None, None)
685                };
686                self.convert_to_market_value(&args[0], explicit_currency, at_date)
687            }
688            // Math functions
689            "SAFEDIV" => {
690                Self::require_args_count(&name_upper, args, 2)?;
691                let (dividend, divisor) = (&args[0], &args[1]);
692                match (dividend, divisor) {
693                    (Value::Number(a), Value::Number(b)) => {
694                        if b.is_zero() {
695                            Ok(Value::Null)
696                        } else {
697                            Ok(Value::Number(a / b))
698                        }
699                    }
700                    (Value::Integer(a), Value::Integer(b)) => {
701                        if *b == 0 {
702                            Ok(Value::Null)
703                        } else {
704                            Ok(Value::Number(Decimal::from(*a) / Decimal::from(*b)))
705                        }
706                    }
707                    (Value::Number(a), Value::Integer(b)) => {
708                        if *b == 0 {
709                            Ok(Value::Null)
710                        } else {
711                            Ok(Value::Number(a / Decimal::from(*b)))
712                        }
713                    }
714                    (Value::Integer(a), Value::Number(b)) => {
715                        if b.is_zero() {
716                            Ok(Value::Null)
717                        } else {
718                            Ok(Value::Number(Decimal::from(*a) / b))
719                        }
720                    }
721                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
722                    _ => Err(QueryError::Type(
723                        "SAFEDIV expects numeric arguments".to_string(),
724                    )),
725                }
726            }
727            "NEG" => {
728                Self::require_args_count(&name_upper, args, 1)?;
729                match &args[0] {
730                    Value::Number(n) => Ok(Value::Number(-n)),
731                    Value::Integer(i) => Ok(Value::Integer(-i)),
732                    Value::Amount(a) => {
733                        Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
734                    }
735                    _ => Err(QueryError::Type(
736                        "NEG expects a number or amount".to_string(),
737                    )),
738                }
739            }
740            // Account functions
741            "ACCOUNT_SORTKEY" => {
742                Self::require_args_count(&name_upper, args, 1)?;
743                match &args[0] {
744                    Value::String(s) => {
745                        let type_index = Self::account_type_index(s);
746                        Ok(Value::String(format!("{type_index}-{s}")))
747                    }
748                    _ => Err(QueryError::Type(
749                        "ACCOUNT_SORTKEY expects an account string".to_string(),
750                    )),
751                }
752            }
753            "PARENT" => {
754                Self::require_args_count(&name_upper, args, 1)?;
755                match &args[0] {
756                    Value::String(s) => {
757                        if let Some(idx) = s.rfind(':') {
758                            Ok(Value::String(s[..idx].to_string()))
759                        } else {
760                            Ok(Value::Null)
761                        }
762                    }
763                    _ => Err(QueryError::Type(
764                        "PARENT expects an account string".to_string(),
765                    )),
766                }
767            }
768            "LEAF" => {
769                Self::require_args_count(&name_upper, args, 1)?;
770                match &args[0] {
771                    Value::String(s) => {
772                        if let Some(idx) = s.rfind(':') {
773                            Ok(Value::String(s[idx + 1..].to_string()))
774                        } else {
775                            Ok(Value::String(s.clone()))
776                        }
777                    }
778                    _ => Err(QueryError::Type(
779                        "LEAF expects an account string".to_string(),
780                    )),
781                }
782            }
783            "ROOT" => {
784                if args.is_empty() || args.len() > 2 {
785                    return Err(QueryError::InvalidArguments(
786                        "ROOT".to_string(),
787                        "expected 1 or 2 arguments".to_string(),
788                    ));
789                }
790                let n = if args.len() == 2 {
791                    match &args[1] {
792                        Value::Integer(i) => *i as usize,
793                        _ => 1,
794                    }
795                } else {
796                    1
797                };
798                match &args[0] {
799                    Value::String(s) => {
800                        let parts: Vec<&str> = s.split(':').collect();
801                        if n >= parts.len() {
802                            Ok(Value::String(s.clone()))
803                        } else {
804                            Ok(Value::String(parts[..n].join(":")))
805                        }
806                    }
807                    _ => Err(QueryError::Type(
808                        "ROOT expects an account string".to_string(),
809                    )),
810                }
811            }
812            // ONLY function: extract single-currency amount from inventory
813            "ONLY" => {
814                Self::require_args_count(&name_upper, args, 2)?;
815                let currency = match &args[0] {
816                    Value::String(s) => s.clone(),
817                    _ => {
818                        return Err(QueryError::Type(
819                            "ONLY: first argument must be a currency string".to_string(),
820                        ));
821                    }
822                };
823                match &args[1] {
824                    Value::Inventory(inv) => {
825                        let total = inv.units(&currency);
826                        if total.is_zero() {
827                            Ok(Value::Null)
828                        } else {
829                            Ok(Value::Amount(Amount::new(total, &currency)))
830                        }
831                    }
832                    Value::Position(p) => {
833                        if p.units.currency.as_str() == currency {
834                            Ok(Value::Amount(p.units.clone()))
835                        } else {
836                            Ok(Value::Null)
837                        }
838                    }
839                    Value::Amount(a) => {
840                        if a.currency.as_str() == currency {
841                            Ok(Value::Amount(a.clone()))
842                        } else {
843                            Ok(Value::Null)
844                        }
845                    }
846                    Value::Null => Ok(Value::Null),
847                    _ => Err(QueryError::Type(
848                        "ONLY: second argument must be an inventory, position, or amount"
849                            .to_string(),
850                    )),
851                }
852            }
853            // GETPRICE function - needs price database
854            "GETPRICE" => {
855                if args.len() < 2 || args.len() > 3 {
856                    return Err(QueryError::InvalidArguments(
857                        "GETPRICE".to_string(),
858                        "expected 2 or 3 arguments".to_string(),
859                    ));
860                }
861                // Handle NULL arguments gracefully
862                let base = match &args[0] {
863                    Value::String(s) => s.clone(),
864                    Value::Null => return Ok(Value::Null),
865                    _ => {
866                        return Err(QueryError::Type(
867                            "GETPRICE: first argument must be a currency string".to_string(),
868                        ));
869                    }
870                };
871                let quote = match &args[1] {
872                    Value::String(s) => s.clone(),
873                    Value::Null => return Ok(Value::Null),
874                    _ => {
875                        return Err(QueryError::Type(
876                            "GETPRICE: second argument must be a currency string".to_string(),
877                        ));
878                    }
879                };
880                let date = if args.len() == 3 {
881                    match &args[2] {
882                        Value::Date(d) => *d,
883                        Value::Null => self.query_date,
884                        _ => self.query_date,
885                    }
886                } else {
887                    self.query_date
888                };
889                match self.price_db.get_price(&base, &quote, date) {
890                    Some(price) => Ok(Value::Number(price)),
891                    None => Ok(Value::Null),
892                }
893            }
894            // Inventory functions
895            "EMPTY" => {
896                Self::require_args_count(&name_upper, args, 1)?;
897                match &args[0] {
898                    Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
899                    Value::Null => Ok(Value::Boolean(true)),
900                    _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
901                }
902            }
903            "FILTER_CURRENCY" => {
904                Self::require_args_count(&name_upper, args, 2)?;
905                let currency = match &args[1] {
906                    Value::String(s) => s.clone(),
907                    _ => {
908                        return Err(QueryError::Type(
909                            "FILTER_CURRENCY expects (inventory, string)".to_string(),
910                        ));
911                    }
912                };
913                match &args[0] {
914                    Value::Inventory(inv) => {
915                        let filtered: Vec<Position> = inv
916                            .positions()
917                            .iter()
918                            .filter(|p| p.units.currency.as_str() == currency)
919                            .cloned()
920                            .collect();
921                        let mut new_inv = Inventory::new();
922                        for pos in filtered {
923                            new_inv.add(pos);
924                        }
925                        Ok(Value::Inventory(Box::new(new_inv)))
926                    }
927                    Value::Null => Ok(Value::Null),
928                    _ => Err(QueryError::Type(
929                        "FILTER_CURRENCY expects (inventory, string)".to_string(),
930                    )),
931                }
932            }
933            "POSSIGN" => {
934                Self::require_args_count(&name_upper, args, 2)?;
935                let account_str = match &args[1] {
936                    Value::String(s) => s.clone(),
937                    _ => {
938                        return Err(QueryError::Type(
939                            "POSSIGN expects (amount, account_string)".to_string(),
940                        ));
941                    }
942                };
943                let first_component = account_str.split(':').next().unwrap_or("");
944                let is_credit_normal =
945                    matches!(first_component, "Liabilities" | "Equity" | "Income");
946                match &args[0] {
947                    Value::Amount(a) => {
948                        let mut amt = a.clone();
949                        if is_credit_normal {
950                            amt.number = -amt.number;
951                        }
952                        Ok(Value::Amount(amt))
953                    }
954                    Value::Number(n) => {
955                        let adjusted = if is_credit_normal { -n } else { *n };
956                        Ok(Value::Number(adjusted))
957                    }
958                    Value::Null => Ok(Value::Null),
959                    _ => Err(QueryError::Type(
960                        "POSSIGN expects (amount, account_string)".to_string(),
961                    )),
962                }
963            }
964            // CONVERT function - convert amounts/positions/inventories to target currency
965            "CONVERT" => {
966                if args.len() < 2 || args.len() > 3 {
967                    return Err(QueryError::InvalidArguments(
968                        "CONVERT".to_string(),
969                        "expected 2 or 3 arguments: (value, currency[, date])".to_string(),
970                    ));
971                }
972
973                let target_currency = match &args[1] {
974                    Value::String(s) => s.clone(),
975                    Value::Null => {
976                        return Err(QueryError::Type(
977                            concat!(
978                                "CONVERT: second argument evaluated to NULL; ",
979                                "expected a currency string ",
980                                "(this often means an aggregate expression couldn't ",
981                                "evaluate against an empty group — see issue #902)",
982                            )
983                            .to_string(),
984                        ));
985                    }
986                    _ => {
987                        return Err(QueryError::Type(
988                            "CONVERT: second argument must be a currency string".to_string(),
989                        ));
990                    }
991                };
992
993                // Optional date argument
994                let date: Option<rustledger_core::NaiveDate> = if args.len() == 3 {
995                    match &args[2] {
996                        Value::Date(d) => Some(*d),
997                        Value::Null => None, // NULL date uses latest price
998                        _ => {
999                            return Err(QueryError::Type(
1000                                "CONVERT: third argument must be a date".to_string(),
1001                            ));
1002                        }
1003                    }
1004                } else {
1005                    None
1006                };
1007
1008                // Helper closure to convert an amount
1009                let convert_amount = |amt: &Amount| -> Option<Amount> {
1010                    if let Some(d) = date {
1011                        self.price_db.convert(amt, &target_currency, d)
1012                    } else {
1013                        self.price_db.convert_latest(amt, &target_currency)
1014                    }
1015                };
1016
1017                match &args[0] {
1018                    Value::Position(p) => {
1019                        if p.units.currency == target_currency {
1020                            Ok(Value::Amount(p.units.clone()))
1021                        } else if let Some(converted) = convert_amount(&p.units) {
1022                            Ok(Value::Amount(converted))
1023                        } else {
1024                            Ok(Value::Amount(p.units.clone()))
1025                        }
1026                    }
1027                    Value::Amount(a) => {
1028                        if a.currency == target_currency {
1029                            Ok(Value::Amount(a.clone()))
1030                        } else if let Some(converted) = convert_amount(a) {
1031                            Ok(Value::Amount(converted))
1032                        } else {
1033                            Ok(Value::Amount(a.clone()))
1034                        }
1035                    }
1036                    Value::Inventory(inv) => {
1037                        // Convert each position, keeping originals when no conversion available
1038                        // (matches Python beancount behavior)
1039                        let mut result = Inventory::default();
1040                        for pos in inv.positions() {
1041                            if pos.units.currency == target_currency {
1042                                result.add(Position::simple(pos.units.clone()));
1043                            } else if let Some(converted) = convert_amount(&pos.units) {
1044                                result.add(Position::simple(converted));
1045                            } else {
1046                                // No conversion available - keep original (Python beancount behavior)
1047                                result.add(Position::simple(pos.units.clone()));
1048                            }
1049                        }
1050                        // If result has single currency matching target, return as Amount
1051                        // If result is empty, return zero in target currency (issue #586)
1052                        let positions = result.positions();
1053                        if positions.is_empty() {
1054                            Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1055                        } else if positions.len() == 1
1056                            && positions[0].units.currency == target_currency
1057                        {
1058                            Ok(Value::Amount(positions[0].units.clone()))
1059                        } else {
1060                            Ok(Value::Inventory(Box::new(result)))
1061                        }
1062                    }
1063                    Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
1064                    Value::Null => {
1065                        // For null values (e.g., empty sum), return zero in target currency
1066                        // This matches Python beancount behavior for empty balances (issue #586)
1067                        Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1068                    }
1069                    _ => Err(QueryError::Type(
1070                        "CONVERT expects a position, amount, inventory, or number".to_string(),
1071                    )),
1072                }
1073            }
1074            // Type casting functions - use shared helpers
1075            "STR" => {
1076                Self::require_args_count(&name_upper, args, 1)?;
1077                Self::value_to_str(&args[0])
1078            }
1079            "INT" => {
1080                Self::require_args_count(&name_upper, args, 1)?;
1081                Self::value_to_int(&args[0])
1082            }
1083            "DECIMAL" => {
1084                Self::require_args_count(&name_upper, args, 1)?;
1085                Self::value_to_decimal(&args[0])
1086            }
1087            "BOOL" => {
1088                Self::require_args_count(&name_upper, args, 1)?;
1089                Self::value_to_bool(&args[0])
1090            }
1091            // Date functions for wrapping aggregates: QUARTER(MAX(date))
1092            "QUARTER" => {
1093                Self::require_args_count(&name_upper, args, 1)?;
1094                match &args[0] {
1095                    Value::Date(d) => Ok(Value::Integer(((d.month() - 1) / 3 + 1).into())),
1096                    _ => Err(QueryError::Type("QUARTER expects a date".to_string())),
1097                }
1098            }
1099            "WEEKDAY" => {
1100                Self::require_args_count(&name_upper, args, 1)?;
1101                match &args[0] {
1102                    Value::Date(d) => Ok(Value::Integer(
1103                        (d.weekday().to_monday_zero_offset() as u32).into(),
1104                    )),
1105                    _ => Err(QueryError::Type("WEEKDAY expects a date".to_string())),
1106                }
1107            }
1108            "YMONTH" => {
1109                Self::require_args_count(&name_upper, args, 1)?;
1110                match &args[0] {
1111                    Value::Date(d) => {
1112                        Ok(Value::String(format!("{:04}-{:02}", d.year(), d.month())))
1113                    }
1114                    _ => Err(QueryError::Type("YMONTH expects a date".to_string())),
1115                }
1116            }
1117            // String functions for wrapping aggregates
1118            "SUBSTR" | "SUBSTRING" => {
1119                if args.len() < 2 || args.len() > 3 {
1120                    return Err(QueryError::InvalidArguments(
1121                        name_upper,
1122                        "expected 2 or 3 arguments".to_string(),
1123                    ));
1124                }
1125                match (&args[0], &args[1], args.get(2)) {
1126                    (Value::String(s), Value::Integer(start), None) => {
1127                        let start = (*start).max(0) as usize;
1128                        let result: String = s.chars().skip(start).collect();
1129                        Ok(Value::String(result))
1130                    }
1131                    (Value::String(s), Value::Integer(start), Some(Value::Integer(len))) => {
1132                        let start = (*start).max(0) as usize;
1133                        let len = (*len).max(0) as usize;
1134                        let result: String = s.chars().skip(start).take(len).collect();
1135                        Ok(Value::String(result))
1136                    }
1137                    _ => Err(QueryError::Type(
1138                        "SUBSTR expects (string, int, [int])".to_string(),
1139                    )),
1140                }
1141            }
1142            "STARTSWITH" => {
1143                Self::require_args_count(&name_upper, args, 2)?;
1144                match (&args[0], &args[1]) {
1145                    (Value::String(s), Value::String(prefix)) => {
1146                        Ok(Value::Boolean(s.starts_with(prefix.as_str())))
1147                    }
1148                    _ => Err(QueryError::Type(
1149                        "STARTSWITH expects two strings".to_string(),
1150                    )),
1151                }
1152            }
1153            "ENDSWITH" => {
1154                Self::require_args_count(&name_upper, args, 2)?;
1155                match (&args[0], &args[1]) {
1156                    (Value::String(s), Value::String(suffix)) => {
1157                        Ok(Value::Boolean(s.ends_with(suffix.as_str())))
1158                    }
1159                    _ => Err(QueryError::Type("ENDSWITH expects two strings".to_string())),
1160                }
1161            }
1162            "MAXWIDTH" => {
1163                Self::require_args_count(&name_upper, args, 2)?;
1164                match (&args[0], &args[1]) {
1165                    (Value::String(s), Value::Integer(max)) => {
1166                        let n = *max as usize;
1167                        if s.chars().count() <= n {
1168                            Ok(Value::String(s.clone()))
1169                        } else if n <= 3 {
1170                            Ok(Value::String(s.chars().take(n).collect()))
1171                        } else {
1172                            let truncated: String = s.chars().take(n - 3).collect();
1173                            Ok(Value::String(format!("{truncated}...")))
1174                        }
1175                    }
1176                    _ => Err(QueryError::Type(
1177                        "MAXWIDTH expects (string, integer)".to_string(),
1178                    )),
1179                }
1180            }
1181            // Account function used in GROUP BY
1182            "ACCOUNT_DEPTH" => {
1183                Self::require_args_count(&name_upper, args, 1)?;
1184                match &args[0] {
1185                    Value::String(s) => Ok(Value::Integer(s.matches(':').count() as i64 + 1)),
1186                    _ => Err(QueryError::Type(
1187                        "ACCOUNT_DEPTH expects an account string".to_string(),
1188                    )),
1189                }
1190            }
1191            // Position/amount getters
1192            "GETITEM" | "GET" => {
1193                Self::require_args_count(&name_upper, args, 2)?;
1194                match (&args[0], &args[1]) {
1195                    (Value::Inventory(inv), Value::String(currency)) => {
1196                        let amount = inv.units(currency);
1197                        if amount.is_zero() {
1198                            Ok(Value::Null)
1199                        } else {
1200                            Ok(Value::Amount(Amount::new(amount, currency.as_str())))
1201                        }
1202                    }
1203                    (Value::Null, _) => Ok(Value::Null),
1204                    _ => Err(QueryError::Type(
1205                        "GETITEM expects (inventory, string)".to_string(),
1206                    )),
1207                }
1208            }
1209            "WEIGHT" => {
1210                Self::require_args_count(&name_upper, args, 1)?;
1211                match &args[0] {
1212                    Value::Position(p) => {
1213                        if let Some(cost) = &p.cost {
1214                            let total = p.units.number * cost.number;
1215                            Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
1216                        } else {
1217                            Ok(Value::Amount(p.units.clone()))
1218                        }
1219                    }
1220                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
1221                    Value::Inventory(inv) => {
1222                        let mut result = Inventory::new();
1223                        for pos in inv.positions() {
1224                            if let Some(cost) = &pos.cost {
1225                                let total = pos.units.number * cost.number;
1226                                result.add(Position::simple(Amount::new(
1227                                    total,
1228                                    cost.currency.clone(),
1229                                )));
1230                            } else {
1231                                result.add(Position::simple(pos.units.clone()));
1232                            }
1233                        }
1234                        Ok(Value::Inventory(Box::new(result)))
1235                    }
1236                    Value::Null => Ok(Value::Null),
1237                    _ => Err(QueryError::Type(
1238                        "WEIGHT expects a position, amount, or inventory".to_string(),
1239                    )),
1240                }
1241            }
1242            // Date: DATE_DIFF for wrapping aggregates like DATE_DIFF(MAX(date), MIN(date))
1243            "DATE_DIFF" => {
1244                Self::require_args_count(&name_upper, args, 2)?;
1245                match (&args[0], &args[1]) {
1246                    (Value::Date(d1), Value::Date(d2)) => Ok(Value::Integer(i64::from(
1247                        d1.since(*d2).unwrap_or_default().get_days(),
1248                    ))),
1249                    _ => Err(QueryError::Type("DATE_DIFF expects two dates".to_string())),
1250                }
1251            }
1252            // String: regex functions for wrapping aggregates
1253            "GREP" => {
1254                Self::require_args_count(&name_upper, args, 2)?;
1255                match (&args[0], &args[1]) {
1256                    (Value::String(pattern), Value::String(s)) => {
1257                        let re = regex::Regex::new(pattern).map_err(|e| {
1258                            QueryError::Type(format!("GREP: invalid regex '{pattern}': {e}"))
1259                        })?;
1260                        match re.find(s) {
1261                            Some(m) => Ok(Value::String(m.as_str().to_string())),
1262                            None => Ok(Value::Null),
1263                        }
1264                    }
1265                    // Null args → Null (e.g., narration is Null for non-transaction entries)
1266                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1267                    _ => Err(QueryError::Type("GREP expects two strings".to_string())),
1268                }
1269            }
1270            "GREPN" => {
1271                Self::require_args_count(&name_upper, args, 3)?;
1272                let n = match &args[2] {
1273                    Value::Integer(i) => (*i).max(0) as usize,
1274                    Value::Number(n) => {
1275                        use rust_decimal::prelude::ToPrimitive;
1276                        n.to_usize().unwrap_or(0)
1277                    }
1278                    _ => {
1279                        return Err(QueryError::Type(
1280                            "GREPN: third argument must be an integer".to_string(),
1281                        ));
1282                    }
1283                };
1284                match (&args[0], &args[1]) {
1285                    (Value::String(pattern), Value::String(s)) => {
1286                        let re = regex::Regex::new(pattern).map_err(|e| {
1287                            QueryError::Type(format!("GREPN: invalid regex '{pattern}': {e}"))
1288                        })?;
1289                        match re.captures(s) {
1290                            Some(caps) => match caps.get(n) {
1291                                Some(m) => Ok(Value::String(m.as_str().to_string())),
1292                                None => Ok(Value::Null),
1293                            },
1294                            None => Ok(Value::Null),
1295                        }
1296                    }
1297                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1298                    _ => Err(QueryError::Type(
1299                        "GREPN expects (pattern, string, int)".to_string(),
1300                    )),
1301                }
1302            }
1303            "SUBST" => {
1304                Self::require_args_count(&name_upper, args, 3)?;
1305                match (&args[0], &args[1], &args[2]) {
1306                    (Value::String(pattern), Value::String(replacement), Value::String(s)) => {
1307                        let re = regex::Regex::new(pattern).map_err(|e| {
1308                            QueryError::Type(format!("SUBST: invalid regex '{pattern}': {e}"))
1309                        })?;
1310                        Ok(Value::String(
1311                            re.replace_all(s, replacement.as_str()).to_string(),
1312                        ))
1313                    }
1314                    _ => Err(QueryError::Type(
1315                        "SUBST expects (pattern, replacement, string)".to_string(),
1316                    )),
1317                }
1318            }
1319            "SPLITCOMP" => {
1320                Self::require_args_count(&name_upper, args, 3)?;
1321                let n = match &args[2] {
1322                    Value::Integer(i) => (*i).max(0) as usize,
1323                    Value::Number(n) => {
1324                        use rust_decimal::prelude::ToPrimitive;
1325                        n.to_usize().unwrap_or(0)
1326                    }
1327                    _ => {
1328                        return Err(QueryError::Type(
1329                            "SPLITCOMP: third argument must be an integer".to_string(),
1330                        ));
1331                    }
1332                };
1333                match (&args[0], &args[1]) {
1334                    (Value::String(s), Value::String(delim)) => {
1335                        let parts: Vec<&str> = s.split(delim.as_str()).collect();
1336                        match parts.get(n) {
1337                            Some(part) => Ok(Value::String((*part).to_string())),
1338                            None => Ok(Value::Null),
1339                        }
1340                    }
1341                    _ => Err(QueryError::Type(
1342                        "SPLITCOMP expects (string, delimiter, int)".to_string(),
1343                    )),
1344                }
1345            }
1346            "JOINSTR" => {
1347                let mut parts = Vec::new();
1348                for v in args {
1349                    match v {
1350                        Value::String(s) => parts.push(s.clone()),
1351                        Value::StringSet(ss) => parts.extend(ss.iter().cloned()),
1352                        Value::Integer(i) => parts.push(i.to_string()),
1353                        Value::Number(n) => parts.push(n.to_string()),
1354                        Value::Null => {}
1355                        _ => {}
1356                    }
1357                }
1358                Ok(Value::String(parts.join(",")))
1359            }
1360            // Account metadata functions — look up open/close info
1361            "OPEN_DATE" => {
1362                Self::require_args_count(&name_upper, args, 1)?;
1363                match &args[0] {
1364                    Value::String(account) => Ok(self
1365                        .account_info
1366                        .get(account.as_str())
1367                        .and_then(|info| info.open_date)
1368                        .map_or(Value::Null, Value::Date)),
1369                    Value::Null => Ok(Value::Null),
1370                    _ => Err(QueryError::Type(
1371                        "OPEN_DATE expects an account string".to_string(),
1372                    )),
1373                }
1374            }
1375            "CLOSE_DATE" => {
1376                Self::require_args_count(&name_upper, args, 1)?;
1377                match &args[0] {
1378                    Value::String(account) => Ok(self
1379                        .account_info
1380                        .get(account.as_str())
1381                        .and_then(|info| info.close_date)
1382                        .map_or(Value::Null, Value::Date)),
1383                    Value::Null => Ok(Value::Null),
1384                    _ => Err(QueryError::Type(
1385                        "CLOSE_DATE expects an account string".to_string(),
1386                    )),
1387                }
1388            }
1389            "OPEN_META" => {
1390                Self::require_args_count(&name_upper, args, 2)?;
1391                match (&args[0], &args[1]) {
1392                    (Value::String(account), Value::String(key)) => Ok(self
1393                        .account_info
1394                        .get(account.as_str())
1395                        .and_then(|info| info.open_meta.get(key))
1396                        .map_or(Value::Null, |mv| Self::meta_value_to_value(Some(mv)))),
1397                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1398                    _ => Err(QueryError::Type(
1399                        "OPEN_META expects (account_string, key_string)".to_string(),
1400                    )),
1401                }
1402            }
1403            // Metadata access — returns Null in evaluate_function_on_values
1404            // because metadata is accessed via row context in eval_meta_on_table_row.
1405            // This branch handles edge cases where META is called outside table context.
1406            "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
1407                Self::require_args_count(&name_upper, args, 1)?;
1408                match &args[0] {
1409                    Value::String(_) | Value::Null => Ok(Value::Null),
1410                    _ => Err(QueryError::Type(format!(
1411                        "{name_upper}: argument must be a string key"
1412                    ))),
1413                }
1414            }
1415            // Aggregate functions return Null when evaluated on a single row
1416            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1417            _ => Err(QueryError::UnknownFunction(name.to_string())),
1418        }
1419    }
1420
1421    /// Convert a `Metadata` map to a `Value::Object` for table storage.
1422    fn metadata_to_value(meta: &rustledger_core::Metadata) -> Value {
1423        if meta.is_empty() {
1424            return Value::Null;
1425        }
1426        let map: std::collections::BTreeMap<String, Value> = meta
1427            .iter()
1428            .map(|(k, v)| (k.clone(), Self::meta_value_to_value(Some(v))))
1429            .collect();
1430        Value::Object(Box::new(map))
1431    }
1432
1433    /// Helper to require a specific number of arguments (for pre-evaluated args).
1434    fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1435        if args.len() != expected {
1436            return Err(QueryError::InvalidArguments(
1437                name.to_string(),
1438                format!("expected {} argument(s), got {}", expected, args.len()),
1439            ));
1440        }
1441        Ok(())
1442    }
1443
1444    /// Helper to require a specific number of arguments.
1445    fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1446        if func.args.len() != expected {
1447            return Err(QueryError::InvalidArguments(
1448                name.to_string(),
1449                format!("expected {expected} argument(s)"),
1450            ));
1451        }
1452        Ok(())
1453    }
1454
1455    /// Convert a value to its market value.
1456    ///
1457    /// Shared `VALUE()` implementation used by both expression evaluation and
1458    /// the aggregate/subquery path in `evaluate_function_on_values`.
1459    ///
1460    /// # Arguments
1461    /// * `val` - The value to convert (`Position`, `Amount`, `Inventory`, or `Null`).
1462    /// * `explicit_currency` - Optional explicit target currency. When `None`,
1463    ///   the currency is inferred from the position's cost basis (Python
1464    ///   beancount compatibility) or falls back to the executor's
1465    ///   `target_currency` setting.
1466    /// * `at_date` - Optional valuation date. When `Some`, prices are looked up
1467    ///   with "on or before" semantics via [`price::PriceDatabase::convert`];
1468    ///   when `None`, the latest available price is used via
1469    ///   [`price::PriceDatabase::convert_latest`] (matches Python's
1470    ///   `value(position)` with `date=None`, which may use a future-dated price).
1471    ///
1472    /// # Returns
1473    /// - `Value::Amount` when conversion succeeds, or when the input is a
1474    ///   single `Position`/`Amount` that can't be priced (raw units returned).
1475    /// - `Value::Inventory` when no target currency can be determined and the
1476    ///   input is an `Inventory`.
1477    /// - `Value::Null` when the input is null.
1478    ///
1479    /// # Inventory caveat
1480    ///
1481    /// For `Value::Inventory` inputs with a determined target currency, this
1482    /// function returns a single `Value::Amount` summed in the target currency.
1483    /// Positions within the inventory that cannot be priced at `at_date` (or
1484    /// have no latest price) are silently dropped from the sum. This differs
1485    /// from Python beancount's `inventory.reduce(get_value, ...)`, which
1486    /// preserves unpriced positions as raw units in the resulting inventory.
1487    /// Reconciling this is tracked as a separate follow-up and is out of scope
1488    /// for #892.
1489    pub(crate) fn convert_to_market_value(
1490        &self,
1491        val: &Value,
1492        explicit_currency: Option<&str>,
1493        at_date: Option<NaiveDate>,
1494    ) -> Result<Value, QueryError> {
1495        // Determine target currency:
1496        // 1. Explicit argument takes precedence
1497        // 2. Infer from position's cost currency (beancount compatibility)
1498        // 3. Fall back to executor's target_currency setting
1499        let target_currency = if let Some(currency) = explicit_currency {
1500            currency.to_string()
1501        } else {
1502            // Try to infer from cost currency
1503            let inferred = match val {
1504                Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1505                Value::Inventory(inv) => inv
1506                    .positions()
1507                    .iter()
1508                    .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1509                _ => None,
1510            };
1511
1512            match inferred.or_else(|| self.target_currency.clone()) {
1513                Some(c) => c,
1514                None => {
1515                    // No currency can be determined — return value as-is
1516                    // (matches Python beancount behavior for positions without cost).
1517                    // Note: `at_date` is ignored here because there is nothing to
1518                    // convert without a target currency.
1519                    return match val {
1520                        Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1521                        Value::Amount(a) => Ok(Value::Amount(a.clone())),
1522                        Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1523                        Value::Null => Ok(Value::Null),
1524                        _ => Err(QueryError::Type(
1525                            "VALUE expects a position, amount, or inventory".to_string(),
1526                        )),
1527                    };
1528                }
1529            }
1530        };
1531
1532        // Price lookup matches Python beancount's semantics:
1533        // - When `at_date` is None, use the latest price (which may be future-dated).
1534        // - When `at_date` is Some, use the most recent price on or before that date;
1535        //   if no such price exists, the conversion silently returns the raw units.
1536        let convert_one = |amount: &Amount| -> Option<Amount> {
1537            match at_date {
1538                Some(d) => self.price_db.convert(amount, &target_currency, d),
1539                None => self.price_db.convert_latest(amount, &target_currency),
1540            }
1541        };
1542
1543        match val {
1544            Value::Position(p) => {
1545                if p.units.currency == target_currency {
1546                    Ok(Value::Amount(p.units.clone()))
1547                } else if let Some(converted) = convert_one(&p.units) {
1548                    Ok(Value::Amount(converted))
1549                } else {
1550                    Ok(Value::Amount(p.units.clone()))
1551                }
1552            }
1553            Value::Amount(a) => {
1554                if a.currency == target_currency {
1555                    Ok(Value::Amount(a.clone()))
1556                } else if let Some(converted) = convert_one(a) {
1557                    Ok(Value::Amount(converted))
1558                } else {
1559                    Ok(Value::Amount(a.clone()))
1560                }
1561            }
1562            Value::Inventory(inv) => {
1563                let mut total = Decimal::ZERO;
1564                for pos in inv.positions() {
1565                    if pos.units.currency == target_currency {
1566                        total += pos.units.number;
1567                    } else if let Some(converted) = convert_one(&pos.units) {
1568                        total += converted.number;
1569                    }
1570                }
1571                Ok(Value::Amount(Amount::new(total, &target_currency)))
1572            }
1573            Value::Null => Ok(Value::Null),
1574            _ => Err(QueryError::Type(
1575                "VALUE expects a position, amount, or inventory".to_string(),
1576            )),
1577        }
1578    }
1579
1580    /// Check if an expression is a window function.
1581    pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1582        matches!(expr, Expr::Window(_))
1583    }
1584
1585    /// Resolve column names from targets.
1586    fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1587        let mut names = Vec::new();
1588        for (i, target) in targets.iter().enumerate() {
1589            if matches!(target.expr, Expr::Wildcard) {
1590                // Check wildcard BEFORE alias to catch `SELECT * AS alias` edge case
1591                if target.alias.is_some() {
1592                    return Err(QueryError::Evaluation(
1593                        "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1594                    ));
1595                }
1596                // Expand wildcard using shared constant (must match evaluate_row expansion)
1597                names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1598            } else if let Some(alias) = &target.alias {
1599                names.push(alias.clone());
1600            } else {
1601                names.push(self.expr_to_name(&target.expr, i));
1602            }
1603        }
1604        Ok(names)
1605    }
1606
1607    /// Convert an expression to a column name.
1608    fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1609        match expr {
1610            Expr::Wildcard => "*".to_string(),
1611            Expr::Column(name) => name.clone(),
1612            Expr::Function(func) => func.name.clone(),
1613            Expr::Window(wf) => wf.name.clone(),
1614            _ => format!("col{index}"),
1615        }
1616    }
1617
1618    /// Get a built-in system table by name.
1619    ///
1620    /// Built-in tables are virtual tables that provide access to ledger data:
1621    /// - `#prices` / `prices`: Price directives from the ledger
1622    /// - `#balances` / `balances`: Balance assertion directives from the ledger
1623    /// - `#commodities` / `commodities`: Commodity directives from the ledger
1624    /// - `#events` / `events`: Event directives from the ledger
1625    /// - `#notes` / `notes`: Note directives from the ledger
1626    /// - `#documents` / `documents`: Document directives from the ledger
1627    /// - `#accounts` / `accounts`: Open/Close directives paired by account
1628    /// - `#transactions` / `transactions`: Transaction directives from the ledger
1629    /// - `#entries` / `entries`: All directives with source location info
1630    /// - `#postings` / `postings`: All postings from transactions
1631    ///
1632    /// Both `#`-prefixed and non-prefixed names are supported for Python beancount
1633    /// compatibility (issue #632).
1634    ///
1635    /// Returns `None` if the table name is not a recognized built-in table.
1636    pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1637        // Normalize table name: strip # prefix if present for Python beancount compatibility.
1638        // Both "#transactions" (rustledger) and "transactions" (beancount) work.
1639        // Using strip_prefix avoids allocation in the common case.
1640        let upper = table_name.to_uppercase();
1641        let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1642
1643        match normalized {
1644            "PRICES" => Some(self.build_prices_table()),
1645            "BALANCES" => Some(self.build_balances_table()),
1646            "COMMODITIES" => Some(self.build_commodities_table()),
1647            "EVENTS" => Some(self.build_events_table()),
1648            "NOTES" => Some(self.build_notes_table()),
1649            "DOCUMENTS" => Some(self.build_documents_table()),
1650            "ACCOUNTS" => Some(self.build_accounts_table()),
1651            "TRANSACTIONS" => Some(self.build_transactions_table()),
1652            "ENTRIES" => Some(self.build_entries_table()),
1653            "POSTINGS" => Some(self.build_postings_table()),
1654            _ => None,
1655        }
1656    }
1657
1658    /// Build the #prices table from price directives.
1659    ///
1660    /// The table has columns: date, currency, amount
1661    /// - date: The date of the price directive
1662    /// - currency: The base currency being priced
1663    /// - amount: The price as an Amount (number + quote currency)
1664    fn build_prices_table(&self) -> Table {
1665        let columns = vec![
1666            "date".to_string(),
1667            "currency".to_string(),
1668            "amount".to_string(),
1669        ];
1670        let mut table = Table::new(columns);
1671
1672        // Collect all price entries from the price database
1673        let mut entries: Vec<_> = self.price_db.iter_entries().collect();
1674        // Sort by (date, base_currency) for consistent, deterministic output
1675        entries.sort_by(|(currency_a, date_a, _, _), (currency_b, date_b, _, _)| {
1676            date_a.cmp(date_b).then_with(|| currency_a.cmp(currency_b))
1677        });
1678
1679        for (base_currency, date, price_number, quote_currency) in entries {
1680            let row = vec![
1681                Value::Date(date),
1682                Value::String(base_currency.to_string()),
1683                Value::Amount(Amount::new(price_number, quote_currency)),
1684            ];
1685            table.add_row(row);
1686        }
1687
1688        table
1689    }
1690
1691    /// Build the #balances table from balance assertion directives.
1692    ///
1693    /// The table has columns: date, account, amount
1694    /// - date: The date of the balance assertion
1695    /// - account: The account being balanced
1696    /// - amount: The expected balance amount
1697    fn build_balances_table(&self) -> Table {
1698        let columns = vec![
1699            "date".to_string(),
1700            "account".to_string(),
1701            "amount".to_string(),
1702        ];
1703        let mut table = Table::new(columns);
1704
1705        // Collect balance directives from either spanned or unspanned directives
1706        let mut balances: Vec<_> = if let Some(spanned) = self.spanned_directives {
1707            spanned
1708                .iter()
1709                .filter_map(|s| {
1710                    if let Directive::Balance(b) = &s.value {
1711                        Some((b.date, b.account.as_ref(), b.amount.clone()))
1712                    } else {
1713                        None
1714                    }
1715                })
1716                .collect()
1717        } else {
1718            self.directives
1719                .iter()
1720                .filter_map(|d| {
1721                    if let Directive::Balance(b) = d {
1722                        Some((b.date, b.account.as_ref(), b.amount.clone()))
1723                    } else {
1724                        None
1725                    }
1726                })
1727                .collect()
1728        };
1729
1730        // Sort by (date, account) for consistent, deterministic output
1731        balances.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
1732            date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
1733        });
1734
1735        for (date, account, amount) in balances {
1736            let row = vec![
1737                Value::Date(date),
1738                Value::String(account.to_string()),
1739                Value::Amount(amount),
1740            ];
1741            table.add_row(row);
1742        }
1743
1744        table
1745    }
1746
1747    /// Build the #commodities table from commodity directives.
1748    ///
1749    /// The table has columns: date, name
1750    /// - date: The date of the commodity declaration
1751    /// - name: The currency/commodity code
1752    fn build_commodities_table(&self) -> Table {
1753        let columns = vec!["date".to_string(), "name".to_string()];
1754        let mut table = Table::new(columns);
1755
1756        // Collect commodity directives from either spanned or unspanned directives
1757        let mut commodities: Vec<_> = if let Some(spanned) = self.spanned_directives {
1758            spanned
1759                .iter()
1760                .filter_map(|s| {
1761                    if let Directive::Commodity(c) = &s.value {
1762                        Some((c.date, c.currency.as_ref()))
1763                    } else {
1764                        None
1765                    }
1766                })
1767                .collect()
1768        } else {
1769            self.directives
1770                .iter()
1771                .filter_map(|d| {
1772                    if let Directive::Commodity(c) = d {
1773                        Some((c.date, c.currency.as_ref()))
1774                    } else {
1775                        None
1776                    }
1777                })
1778                .collect()
1779        };
1780
1781        // Sort by (date, name) for consistent output
1782        commodities.sort_by(|(date_a, name_a), (date_b, name_b)| {
1783            date_a.cmp(date_b).then_with(|| name_a.cmp(name_b))
1784        });
1785
1786        for (date, name) in commodities {
1787            let row = vec![Value::Date(date), Value::String(name.to_string())];
1788            table.add_row(row);
1789        }
1790
1791        table
1792    }
1793
1794    /// Build the #events table from event directives.
1795    ///
1796    /// The table has columns: date, type, description
1797    /// - date: The date of the event
1798    /// - type: The event type
1799    /// - description: The event value/description
1800    fn build_events_table(&self) -> Table {
1801        let columns = vec![
1802            "date".to_string(),
1803            "type".to_string(),
1804            "description".to_string(),
1805        ];
1806        let mut table = Table::new(columns);
1807
1808        // Collect event directives
1809        let mut events: Vec<_> = if let Some(spanned) = self.spanned_directives {
1810            spanned
1811                .iter()
1812                .filter_map(|s| {
1813                    if let Directive::Event(e) = &s.value {
1814                        Some((e.date, e.event_type.as_str(), e.value.as_str()))
1815                    } else {
1816                        None
1817                    }
1818                })
1819                .collect()
1820        } else {
1821            self.directives
1822                .iter()
1823                .filter_map(|d| {
1824                    if let Directive::Event(e) = d {
1825                        Some((e.date, e.event_type.as_str(), e.value.as_str()))
1826                    } else {
1827                        None
1828                    }
1829                })
1830                .collect()
1831        };
1832
1833        // Sort by (date, type) for consistent output
1834        events.sort_by(|(date_a, type_a, _), (date_b, type_b, _)| {
1835            date_a.cmp(date_b).then_with(|| type_a.cmp(type_b))
1836        });
1837
1838        for (date, event_type, description) in events {
1839            let row = vec![
1840                Value::Date(date),
1841                Value::String(event_type.to_string()),
1842                Value::String(description.to_string()),
1843            ];
1844            table.add_row(row);
1845        }
1846
1847        table
1848    }
1849
1850    /// Build the #notes table from note directives.
1851    ///
1852    /// The table has columns: date, account, comment
1853    /// - date: The date of the note
1854    /// - account: The account the note is attached to
1855    /// - comment: The note text
1856    fn build_notes_table(&self) -> Table {
1857        let columns = vec![
1858            "date".to_string(),
1859            "account".to_string(),
1860            "comment".to_string(),
1861        ];
1862        let mut table = Table::new(columns);
1863
1864        // Collect note directives
1865        let mut notes: Vec<_> = if let Some(spanned) = self.spanned_directives {
1866            spanned
1867                .iter()
1868                .filter_map(|s| {
1869                    if let Directive::Note(n) = &s.value {
1870                        Some((n.date, n.account.as_ref(), n.comment.as_str()))
1871                    } else {
1872                        None
1873                    }
1874                })
1875                .collect()
1876        } else {
1877            self.directives
1878                .iter()
1879                .filter_map(|d| {
1880                    if let Directive::Note(n) = d {
1881                        Some((n.date, n.account.as_ref(), n.comment.as_str()))
1882                    } else {
1883                        None
1884                    }
1885                })
1886                .collect()
1887        };
1888
1889        // Sort by (date, account) for consistent output
1890        notes.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
1891            date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
1892        });
1893
1894        for (date, account, comment) in notes {
1895            let row = vec![
1896                Value::Date(date),
1897                Value::String(account.to_string()),
1898                Value::String(comment.to_string()),
1899            ];
1900            table.add_row(row);
1901        }
1902
1903        table
1904    }
1905
1906    /// Build the #documents table from document directives.
1907    ///
1908    /// The table has columns: date, account, filename, tags, links
1909    /// - date: The date of the document
1910    /// - account: The account the document is attached to
1911    /// - filename: The file path to the document
1912    /// - tags: The document tags (as a set)
1913    /// - links: The document links (as a set)
1914    fn build_documents_table(&self) -> Table {
1915        let columns = vec![
1916            "date".to_string(),
1917            "account".to_string(),
1918            "filename".to_string(),
1919            "tags".to_string(),
1920            "links".to_string(),
1921        ];
1922        let mut table = Table::new(columns);
1923
1924        // Collect document directives
1925        let mut documents: Vec<_> = if let Some(spanned) = self.spanned_directives {
1926            spanned
1927                .iter()
1928                .filter_map(|s| {
1929                    if let Directive::Document(d) = &s.value {
1930                        Some((
1931                            d.date,
1932                            d.account.as_ref(),
1933                            d.path.as_str(),
1934                            &d.tags,
1935                            &d.links,
1936                        ))
1937                    } else {
1938                        None
1939                    }
1940                })
1941                .collect()
1942        } else {
1943            self.directives
1944                .iter()
1945                .filter_map(|d| {
1946                    if let Directive::Document(doc) = d {
1947                        Some((
1948                            doc.date,
1949                            doc.account.as_ref(),
1950                            doc.path.as_str(),
1951                            &doc.tags,
1952                            &doc.links,
1953                        ))
1954                    } else {
1955                        None
1956                    }
1957                })
1958                .collect()
1959        };
1960
1961        // Sort by (date, account, filename) for consistent output
1962        documents.sort_by(
1963            |(date_a, account_a, file_a, _, _), (date_b, account_b, file_b, _, _)| {
1964                date_a
1965                    .cmp(date_b)
1966                    .then_with(|| account_a.cmp(account_b))
1967                    .then_with(|| file_a.cmp(file_b))
1968            },
1969        );
1970
1971        for (date, account, filename, tags, links) in documents {
1972            let tags_vec: Vec<String> = tags.iter().map(ToString::to_string).collect();
1973            let links_vec: Vec<String> = links.iter().map(ToString::to_string).collect();
1974            let row = vec![
1975                Value::Date(date),
1976                Value::String(account.to_string()),
1977                Value::String(filename.to_string()),
1978                Value::StringSet(tags_vec),
1979                Value::StringSet(links_vec),
1980            ];
1981            table.add_row(row);
1982        }
1983
1984        table
1985    }
1986
1987    /// Build the #accounts table from Open/Close directives.
1988    ///
1989    /// The table has columns: account, open, close, currencies, booking
1990    /// - account: The account name
1991    /// - open: The date the account was opened
1992    /// - close: The date the account was closed (NULL if still open)
1993    /// - currencies: Allowed currencies for the account
1994    /// - booking: Booking method (NULL if not specified)
1995    fn build_accounts_table(&self) -> Table {
1996        let columns = vec![
1997            "account".to_string(),
1998            "open".to_string(),
1999            "close".to_string(),
2000            "currencies".to_string(),
2001            "booking".to_string(),
2002        ];
2003        let mut table = Table::new(columns);
2004
2005        // Build a map of account name -> (open_date, close_date, currencies, booking)
2006        let mut accounts: FxHashMap<
2007            &str,
2008            (
2009                Option<rustledger_core::NaiveDate>,
2010                Option<rustledger_core::NaiveDate>,
2011                Vec<String>,
2012                Option<&str>,
2013            ),
2014        > = FxHashMap::default();
2015
2016        // Process directives
2017        let iter: Box<dyn Iterator<Item = &Directive>> =
2018            if let Some(spanned) = self.spanned_directives {
2019                Box::new(spanned.iter().map(|s| &s.value))
2020            } else {
2021                Box::new(self.directives.iter())
2022            };
2023
2024        for directive in iter {
2025            match directive {
2026                Directive::Open(open) => {
2027                    let entry = accounts.entry(open.account.as_ref()).or_insert((
2028                        None,
2029                        None,
2030                        Vec::new(),
2031                        None,
2032                    ));
2033                    entry.0 = Some(open.date);
2034                    entry.2 = open.currencies.iter().map(ToString::to_string).collect();
2035                    entry.3 = open.booking.as_deref();
2036                }
2037                Directive::Close(close) => {
2038                    let entry = accounts.entry(close.account.as_ref()).or_insert((
2039                        None,
2040                        None,
2041                        Vec::new(),
2042                        None,
2043                    ));
2044                    entry.1 = Some(close.date);
2045                }
2046                _ => {}
2047            }
2048        }
2049
2050        // Sort accounts by name for consistent output
2051        let mut account_list: Vec<_> = accounts.into_iter().collect();
2052        account_list.sort_by_key(|(a, _)| *a);
2053
2054        for (account, (open_date, close_date, currencies, booking)) in account_list {
2055            let row = vec![
2056                Value::String(account.to_string()),
2057                open_date.map_or(Value::Null, Value::Date),
2058                close_date.map_or(Value::Null, Value::Date),
2059                Value::StringSet(currencies),
2060                booking.map_or(Value::Null, |b| Value::String(b.to_string())),
2061            ];
2062            table.add_row(row);
2063        }
2064
2065        table
2066    }
2067
2068    /// Build the #transactions table from transaction directives.
2069    ///
2070    /// The table has columns: date, flag, payee, narration, tags, links, accounts
2071    /// - date: The transaction date
2072    /// - flag: The transaction flag (e.g., '*' or '!')
2073    /// - payee: The payee (NULL if not specified)
2074    /// - narration: The transaction description
2075    /// - tags: Transaction tags (as a set)
2076    /// - links: Transaction links (as a set)
2077    /// - accounts: Set of accounts involved in the transaction
2078    fn build_transactions_table(&self) -> Table {
2079        let columns = vec![
2080            "date".to_string(),
2081            "flag".to_string(),
2082            "payee".to_string(),
2083            "narration".to_string(),
2084            "tags".to_string(),
2085            "links".to_string(),
2086            "accounts".to_string(),
2087        ];
2088        let mut table = Table::new(columns);
2089
2090        // Collect transaction directives
2091        let iter: Box<dyn Iterator<Item = &Directive>> =
2092            if let Some(spanned) = self.spanned_directives {
2093                Box::new(spanned.iter().map(|s| &s.value))
2094            } else {
2095                Box::new(self.directives.iter())
2096            };
2097
2098        let mut transactions: Vec<_> = iter
2099            .filter_map(|d| {
2100                if let Directive::Transaction(txn) = d {
2101                    Some(txn)
2102                } else {
2103                    None
2104                }
2105            })
2106            .collect();
2107
2108        // Sort by date for consistent output
2109        transactions.sort_by_key(|t| t.date);
2110
2111        for txn in transactions {
2112            let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2113            let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2114            let mut accounts: Vec<String> = txn
2115                .postings
2116                .iter()
2117                .map(|p| p.account.to_string())
2118                .collect::<std::collections::HashSet<_>>()
2119                .into_iter()
2120                .collect();
2121            accounts.sort(); // Ensure deterministic ordering
2122
2123            let row = vec![
2124                Value::Date(txn.date),
2125                Value::String(txn.flag.to_string()),
2126                txn.payee
2127                    .as_ref()
2128                    .map_or(Value::Null, |p| Value::String(p.to_string())),
2129                Value::String(txn.narration.to_string()),
2130                Value::StringSet(tags),
2131                Value::StringSet(links),
2132                Value::StringSet(accounts),
2133            ];
2134            table.add_row(row);
2135        }
2136
2137        table
2138    }
2139
2140    /// Build the #entries table from all directives.
2141    ///
2142    /// The table has columns: id, type, filename, lineno, date, flag, payee, narration, tags, links, accounts, `_entry_meta`
2143    /// This provides access to all directives with source location information.
2144    fn build_entries_table(&self) -> Table {
2145        let columns = vec![
2146            "id".to_string(),
2147            "type".to_string(),
2148            "filename".to_string(),
2149            "lineno".to_string(),
2150            "date".to_string(),
2151            "flag".to_string(),
2152            "payee".to_string(),
2153            "narration".to_string(),
2154            "tags".to_string(),
2155            "links".to_string(),
2156            "accounts".to_string(),
2157            "_entry_meta".to_string(),
2158        ];
2159        let mut table = Table::new(columns);
2160
2161        // Process directives with optional source locations
2162        if let Some(spanned) = self.spanned_directives {
2163            for (idx, spanned_dir) in spanned.iter().enumerate() {
2164                let directive = &spanned_dir.value;
2165                let source_loc = self.get_source_location(idx);
2166                let row = self.directive_to_entry_row(idx, directive, source_loc);
2167                table.add_row(row);
2168            }
2169        } else {
2170            for (idx, directive) in self.directives.iter().enumerate() {
2171                let row = self.directive_to_entry_row(idx, directive, None);
2172                table.add_row(row);
2173            }
2174        }
2175
2176        table
2177    }
2178
2179    /// Convert a directive to a row for the #entries table.
2180    fn directive_to_entry_row(
2181        &self,
2182        idx: usize,
2183        directive: &Directive,
2184        source_loc: Option<&SourceLocation>,
2185    ) -> Vec<Value> {
2186        let type_name = match directive {
2187            Directive::Transaction(_) => "transaction",
2188            Directive::Balance(_) => "balance",
2189            Directive::Open(_) => "open",
2190            Directive::Close(_) => "close",
2191            Directive::Commodity(_) => "commodity",
2192            Directive::Pad(_) => "pad",
2193            Directive::Event(_) => "event",
2194            Directive::Query(_) => "query",
2195            Directive::Note(_) => "note",
2196            Directive::Document(_) => "document",
2197            Directive::Price(_) => "price",
2198            Directive::Custom(_) => "custom",
2199        };
2200
2201        let date = match directive {
2202            Directive::Transaction(t) => Value::Date(t.date),
2203            Directive::Balance(b) => Value::Date(b.date),
2204            Directive::Open(o) => Value::Date(o.date),
2205            Directive::Close(c) => Value::Date(c.date),
2206            Directive::Commodity(c) => Value::Date(c.date),
2207            Directive::Pad(p) => Value::Date(p.date),
2208            Directive::Event(e) => Value::Date(e.date),
2209            Directive::Query(q) => Value::Date(q.date),
2210            Directive::Note(n) => Value::Date(n.date),
2211            Directive::Document(d) => Value::Date(d.date),
2212            Directive::Price(p) => Value::Date(p.date),
2213            Directive::Custom(c) => Value::Date(c.date),
2214        };
2215
2216        let (flag, payee, narration, tags, links, accounts) =
2217            if let Directive::Transaction(txn) = directive {
2218                let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2219                let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2220                let mut accounts: Vec<String> = txn
2221                    .postings
2222                    .iter()
2223                    .map(|p| p.account.to_string())
2224                    .collect::<std::collections::HashSet<_>>()
2225                    .into_iter()
2226                    .collect();
2227                accounts.sort(); // Ensure deterministic ordering
2228                (
2229                    Value::String(txn.flag.to_string()),
2230                    txn.payee
2231                        .as_ref()
2232                        .map_or(Value::Null, |p| Value::String(p.to_string())),
2233                    Value::String(txn.narration.to_string()),
2234                    Value::StringSet(tags),
2235                    Value::StringSet(links),
2236                    Value::StringSet(accounts),
2237                )
2238            } else {
2239                (
2240                    Value::Null,
2241                    Value::Null,
2242                    Value::Null,
2243                    Value::StringSet(vec![]),
2244                    Value::StringSet(vec![]),
2245                    Value::StringSet(vec![]),
2246                )
2247            };
2248
2249        let filename = source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
2250        let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
2251
2252        vec![
2253            Value::Integer(idx as i64), // id
2254            Value::String(type_name.to_string()),
2255            filename,
2256            lineno,
2257            date,
2258            flag,
2259            payee,
2260            narration,
2261            tags,
2262            links,
2263            accounts,
2264            // Hidden metadata column
2265            Self::metadata_to_value(directive.meta()),
2266        ]
2267    }
2268
2269    /// Build the #postings table from transaction postings.
2270    ///
2271    /// Column schema matches Python beancount's `postings` table for compatibility.
2272    fn build_postings_table(&self) -> Table {
2273        let columns = vec![
2274            // Entry-level columns
2275            "type".to_string(),
2276            "id".to_string(),
2277            "date".to_string(),
2278            "year".to_string(),
2279            "month".to_string(),
2280            "day".to_string(),
2281            "filename".to_string(),
2282            "lineno".to_string(),
2283            "location".to_string(),
2284            // Transaction-level columns
2285            "flag".to_string(),
2286            "payee".to_string(),
2287            "narration".to_string(),
2288            "description".to_string(),
2289            "tags".to_string(),
2290            "links".to_string(),
2291            // Posting-level columns
2292            "posting_flag".to_string(),
2293            "account".to_string(),
2294            "other_accounts".to_string(),
2295            "number".to_string(),
2296            "currency".to_string(),
2297            "cost_number".to_string(),
2298            "cost_currency".to_string(),
2299            "cost_date".to_string(),
2300            "cost_label".to_string(),
2301            "position".to_string(),
2302            "price".to_string(),
2303            "weight".to_string(),
2304            "balance".to_string(),
2305            // Metadata and collection columns
2306            "meta".to_string(),
2307            "accounts".to_string(),
2308            // Hidden metadata columns for META/ENTRY_META functions
2309            "_entry_meta".to_string(),
2310            "_posting_meta".to_string(),
2311        ];
2312        let mut table = Table::new(columns);
2313
2314        // Track running balances per account
2315        let mut running_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
2316
2317        // Collect transactions with their directive indices for source location lookup
2318        let mut transactions: Vec<(usize, &rustledger_core::Transaction)> =
2319            if let Some(spanned) = self.spanned_directives {
2320                spanned
2321                    .iter()
2322                    .enumerate()
2323                    .filter_map(|(idx, s)| {
2324                        if let Directive::Transaction(txn) = &s.value {
2325                            Some((idx, txn))
2326                        } else {
2327                            None
2328                        }
2329                    })
2330                    .collect()
2331            } else {
2332                self.directives
2333                    .iter()
2334                    .enumerate()
2335                    .filter_map(|(idx, d)| {
2336                        if let Directive::Transaction(txn) = d {
2337                            Some((idx, txn))
2338                        } else {
2339                            None
2340                        }
2341                    })
2342                    .collect()
2343            };
2344        transactions.sort_by_key(|(_, t)| t.date);
2345
2346        for (dir_idx, txn) in &transactions {
2347            // Pre-compute transaction-level values shared across all postings
2348            let source_loc = self.get_source_location(*dir_idx);
2349            let filename =
2350                source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
2351            let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
2352            let location = source_loc.map_or(Value::Null, |loc| {
2353                Value::String(format!("{}:{}", loc.filename, loc.lineno))
2354            });
2355
2356            let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
2357            let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
2358
2359            let mut all_accounts: Vec<String> = txn
2360                .postings
2361                .iter()
2362                .map(|p| p.account.to_string())
2363                .collect::<std::collections::HashSet<_>>()
2364                .into_iter()
2365                .collect();
2366            all_accounts.sort();
2367
2368            let description = match &txn.payee {
2369                Some(payee) => format!("{} | {}", payee, txn.narration),
2370                None => txn.narration.to_string(),
2371            };
2372
2373            let year = Value::Integer(i64::from(txn.date.year()));
2374            let month = Value::Integer(i64::from(txn.date.month()));
2375            let day = Value::Integer(i64::from(txn.date.day()));
2376
2377            for posting in &txn.postings {
2378                // Update running balance
2379                if let Some(units) = posting.amount() {
2380                    let balance = running_balances.entry(posting.account.clone()).or_default();
2381                    let pos = if let Some(cost_spec) = &posting.cost {
2382                        if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
2383                            Position::with_cost(units.clone(), cost)
2384                        } else {
2385                            Position::simple(units.clone())
2386                        }
2387                    } else {
2388                        Position::simple(units.clone())
2389                    };
2390                    balance.add(pos);
2391                }
2392
2393                // Extract posting data
2394                let (number, currency) = posting.amount().map_or((Value::Null, Value::Null), |a| {
2395                    (
2396                        Value::Number(a.number),
2397                        Value::String(a.currency.to_string()),
2398                    )
2399                });
2400
2401                let (cost_number, cost_currency, cost_date, cost_label) = if let Some(cost_spec) =
2402                    &posting.cost
2403                {
2404                    let units = posting.amount();
2405                    if let Some(cost) = units.and_then(|u| cost_spec.resolve(u.number, txn.date)) {
2406                        (
2407                            Value::Number(cost.number),
2408                            Value::String(cost.currency.to_string()),
2409                            cost.date.map_or(Value::Null, Value::Date),
2410                            cost.label
2411                                .as_ref()
2412                                .map_or(Value::Null, |l| Value::String(l.clone())),
2413                        )
2414                    } else {
2415                        (Value::Null, Value::Null, Value::Null, Value::Null)
2416                    }
2417                } else {
2418                    (Value::Null, Value::Null, Value::Null, Value::Null)
2419                };
2420
2421                let position_val = if let Some(units) = posting.amount() {
2422                    if let Some(cost_spec) = &posting.cost
2423                        && let Some(cost) = cost_spec.resolve(units.number, txn.date)
2424                    {
2425                        Value::Position(Box::new(Position::with_cost(units.clone(), cost)))
2426                    } else {
2427                        Value::Position(Box::new(Position::simple(units.clone())))
2428                    }
2429                } else {
2430                    Value::Null
2431                };
2432
2433                let price_val = posting
2434                    .price
2435                    .as_ref()
2436                    .and_then(|p| p.amount())
2437                    .map_or(Value::Null, |a| Value::Amount(a.clone()));
2438
2439                // Weight: the cost-converted amount used for transaction balancing.
2440                // With cost: units × cost (in cost currency)
2441                // With @ price: units × price (in price currency)
2442                // With @@ price: the total price directly (already in target currency)
2443                // Otherwise: units as-is
2444                let weight_val = if let Some(units) = posting.amount() {
2445                    if let Some(cost_spec) = &posting.cost {
2446                        if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
2447                            Value::Amount(Amount::new(units.number * cost.number, cost.currency))
2448                        } else {
2449                            Value::Amount(units.clone())
2450                        }
2451                    } else if let Some(price_ann) = &posting.price {
2452                        if let Some(price_amt) = price_ann.amount() {
2453                            if price_ann.is_unit() {
2454                                // @ per-unit price: weight = units × price
2455                                Value::Amount(Amount::new(
2456                                    units.number * price_amt.number,
2457                                    price_amt.currency.clone(),
2458                                ))
2459                            } else {
2460                                // @@ total price: the amount IS the total weight
2461                                Value::Amount(price_amt.clone())
2462                            }
2463                        } else {
2464                            Value::Amount(units.clone())
2465                        }
2466                    } else {
2467                        Value::Amount(units.clone())
2468                    }
2469                } else {
2470                    Value::Null
2471                };
2472
2473                let balance_val = running_balances
2474                    .get(&posting.account)
2475                    .map_or(Value::Null, |inv| Value::Inventory(Box::new(inv.clone())));
2476
2477                // Other accounts: all accounts in the transaction except this posting's
2478                let other_accounts: Vec<String> = all_accounts
2479                    .iter()
2480                    .filter(|a| a.as_str() != posting.account.as_ref())
2481                    .cloned()
2482                    .collect();
2483
2484                let posting_flag = posting
2485                    .flag
2486                    .map_or(Value::Null, |f| Value::String(f.to_string()));
2487
2488                let row = vec![
2489                    // Entry-level
2490                    Value::String("transaction".to_string()),
2491                    Value::Integer(*dir_idx as i64),
2492                    Value::Date(txn.date),
2493                    year.clone(),
2494                    month.clone(),
2495                    day.clone(),
2496                    filename.clone(),
2497                    lineno.clone(),
2498                    location.clone(),
2499                    // Transaction-level
2500                    Value::String(txn.flag.to_string()),
2501                    txn.payee
2502                        .as_ref()
2503                        .map_or(Value::Null, |p| Value::String(p.to_string())),
2504                    Value::String(txn.narration.to_string()),
2505                    Value::String(description.clone()),
2506                    Value::StringSet(tags.clone()),
2507                    Value::StringSet(links.clone()),
2508                    // Posting-level
2509                    posting_flag,
2510                    Value::String(posting.account.to_string()),
2511                    Value::StringSet(other_accounts),
2512                    number,
2513                    currency,
2514                    cost_number,
2515                    cost_currency,
2516                    cost_date,
2517                    cost_label,
2518                    position_val,
2519                    price_val,
2520                    weight_val,
2521                    balance_val,
2522                    // Metadata and collection
2523                    Value::Metadata(Box::new(posting.meta.clone())),
2524                    Value::StringSet(all_accounts.clone()),
2525                    // Hidden metadata columns
2526                    Self::metadata_to_value(&txn.meta),
2527                    Self::metadata_to_value(&posting.meta),
2528                ];
2529                table.add_row(row);
2530            }
2531        }
2532
2533        table
2534    }
2535}
2536#[cfg(test)]
2537mod tests {
2538    use super::types::{hash_row, hash_single_value};
2539    use super::*;
2540    use crate::parse;
2541    use rust_decimal_macros::dec;
2542    use rustledger_core::Posting;
2543
2544    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
2545        rustledger_core::naive_date(year, month, day).unwrap()
2546    }
2547
2548    fn sample_directives() -> Vec<Directive> {
2549        vec![
2550            Directive::Transaction(
2551                Transaction::new(date(2024, 1, 15), "Coffee")
2552                    .with_flag('*')
2553                    .with_payee("Coffee Shop")
2554                    .with_posting(Posting::new(
2555                        "Expenses:Food:Coffee",
2556                        Amount::new(dec!(5.00), "USD"),
2557                    ))
2558                    .with_posting(Posting::new(
2559                        "Assets:Bank:Checking",
2560                        Amount::new(dec!(-5.00), "USD"),
2561                    )),
2562            ),
2563            Directive::Transaction(
2564                Transaction::new(date(2024, 1, 16), "Groceries")
2565                    .with_flag('*')
2566                    .with_payee("Supermarket")
2567                    .with_posting(Posting::new(
2568                        "Expenses:Food:Groceries",
2569                        Amount::new(dec!(50.00), "USD"),
2570                    ))
2571                    .with_posting(Posting::new(
2572                        "Assets:Bank:Checking",
2573                        Amount::new(dec!(-50.00), "USD"),
2574                    )),
2575            ),
2576        ]
2577    }
2578
2579    #[test]
2580    fn test_simple_select() {
2581        let directives = sample_directives();
2582        let mut executor = Executor::new(&directives);
2583
2584        let query = parse("SELECT date, account").unwrap();
2585        let result = executor.execute(&query).unwrap();
2586
2587        assert_eq!(result.columns, vec!["date", "account"]);
2588        assert_eq!(result.len(), 4); // 2 transactions × 2 postings
2589    }
2590
2591    #[test]
2592    fn test_where_clause() {
2593        let directives = sample_directives();
2594        let mut executor = Executor::new(&directives);
2595
2596        let query = parse("SELECT account WHERE account ~ \"Expenses:\"").unwrap();
2597        let result = executor.execute(&query).unwrap();
2598
2599        assert_eq!(result.len(), 2); // Only expense postings
2600    }
2601
2602    #[test]
2603    fn test_balances() {
2604        let directives = sample_directives();
2605        let mut executor = Executor::new(&directives);
2606
2607        let query = parse("BALANCES").unwrap();
2608        let result = executor.execute(&query).unwrap();
2609
2610        assert_eq!(result.columns, vec!["account", "balance"]);
2611        assert!(result.len() >= 3); // At least 3 accounts
2612    }
2613
2614    #[test]
2615    fn test_account_functions() {
2616        let directives = sample_directives();
2617        let mut executor = Executor::new(&directives);
2618
2619        // Test LEAF function
2620        let query = parse("SELECT DISTINCT LEAF(account) WHERE account ~ \"Expenses:\"").unwrap();
2621        let result = executor.execute(&query).unwrap();
2622        assert_eq!(result.len(), 2); // Coffee, Groceries
2623
2624        // Test ROOT function
2625        let query = parse("SELECT DISTINCT ROOT(account)").unwrap();
2626        let result = executor.execute(&query).unwrap();
2627        assert_eq!(result.len(), 2); // Expenses, Assets
2628
2629        // Test PARENT function
2630        let query = parse("SELECT DISTINCT PARENT(account) WHERE account ~ \"Expenses:\"").unwrap();
2631        let result = executor.execute(&query).unwrap();
2632        assert!(!result.is_empty()); // At least "Expenses:Food"
2633    }
2634
2635    #[test]
2636    fn test_min_max_aggregate() {
2637        let directives = sample_directives();
2638        let mut executor = Executor::new(&directives);
2639
2640        // Test MIN(date)
2641        let query = parse("SELECT MIN(date)").unwrap();
2642        let result = executor.execute(&query).unwrap();
2643        assert_eq!(result.len(), 1);
2644        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
2645
2646        // Test MAX(date)
2647        let query = parse("SELECT MAX(date)").unwrap();
2648        let result = executor.execute(&query).unwrap();
2649        assert_eq!(result.len(), 1);
2650        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2651    }
2652
2653    #[test]
2654    fn test_order_by() {
2655        let directives = sample_directives();
2656        let mut executor = Executor::new(&directives);
2657
2658        let query = parse("SELECT date, account ORDER BY date DESC").unwrap();
2659        let result = executor.execute(&query).unwrap();
2660
2661        // Should have all postings, ordered by date descending
2662        assert_eq!(result.len(), 4);
2663        // First row should be from 2024-01-16 (later date)
2664        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2665    }
2666
2667    #[test]
2668    fn test_hash_value_all_variants() {
2669        use rustledger_core::{Cost, Inventory, Position};
2670
2671        // Test that all Value variants can be hashed without panic
2672        let values = vec![
2673            Value::String("test".to_string()),
2674            Value::Number(dec!(123.45)),
2675            Value::Integer(42),
2676            Value::Date(date(2024, 1, 15)),
2677            Value::Boolean(true),
2678            Value::Boolean(false),
2679            Value::Amount(Amount::new(dec!(100), "USD")),
2680            Value::Position(Box::new(Position::simple(Amount::new(dec!(10), "AAPL")))),
2681            Value::Position(Box::new(Position::with_cost(
2682                Amount::new(dec!(10), "AAPL"),
2683                Cost::new(dec!(150), "USD"),
2684            ))),
2685            Value::Inventory(Box::new(Inventory::new())),
2686            Value::StringSet(vec!["tag1".to_string(), "tag2".to_string()]),
2687            Value::Null,
2688        ];
2689
2690        // Hash each value and verify no panic
2691        for value in &values {
2692            let hash = hash_single_value(value);
2693            assert!(hash != 0 || matches!(value, Value::Null));
2694        }
2695
2696        // Test that different values produce different hashes (usually)
2697        let hash1 = hash_single_value(&Value::String("a".to_string()));
2698        let hash2 = hash_single_value(&Value::String("b".to_string()));
2699        assert_ne!(hash1, hash2);
2700
2701        // Test that same values produce same hashes
2702        let hash3 = hash_single_value(&Value::Integer(42));
2703        let hash4 = hash_single_value(&Value::Integer(42));
2704        assert_eq!(hash3, hash4);
2705    }
2706
2707    #[test]
2708    fn test_hash_row_distinct() {
2709        // Test hash_row for DISTINCT deduplication
2710        let row1 = vec![Value::String("a".to_string()), Value::Integer(1)];
2711        let row2 = vec![Value::String("a".to_string()), Value::Integer(1)];
2712        let row3 = vec![Value::String("b".to_string()), Value::Integer(1)];
2713
2714        assert_eq!(hash_row(&row1), hash_row(&row2));
2715        assert_ne!(hash_row(&row1), hash_row(&row3));
2716    }
2717
2718    #[test]
2719    fn test_string_set_hash_order_independent() {
2720        // StringSet hash should be order-independent
2721        let set1 = Value::StringSet(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2722        let set2 = Value::StringSet(vec!["c".to_string(), "a".to_string(), "b".to_string()]);
2723        let set3 = Value::StringSet(vec!["b".to_string(), "c".to_string(), "a".to_string()]);
2724
2725        let hash1 = hash_single_value(&set1);
2726        let hash2 = hash_single_value(&set2);
2727        let hash3 = hash_single_value(&set3);
2728
2729        assert_eq!(hash1, hash2);
2730        assert_eq!(hash2, hash3);
2731    }
2732
2733    #[test]
2734    fn test_inventory_hash_includes_cost() {
2735        use rustledger_core::{Cost, Inventory, Position};
2736
2737        // Two inventories with same units but different costs should hash differently
2738        let mut inv1 = Inventory::new();
2739        inv1.add(Position::with_cost(
2740            Amount::new(dec!(10), "AAPL"),
2741            Cost::new(dec!(100), "USD"),
2742        ));
2743
2744        let mut inv2 = Inventory::new();
2745        inv2.add(Position::with_cost(
2746            Amount::new(dec!(10), "AAPL"),
2747            Cost::new(dec!(200), "USD"),
2748        ));
2749
2750        let hash1 = hash_single_value(&Value::Inventory(Box::new(inv1)));
2751        let hash2 = hash_single_value(&Value::Inventory(Box::new(inv2)));
2752
2753        assert_ne!(hash1, hash2);
2754    }
2755
2756    #[test]
2757    fn test_distinct_deduplication() {
2758        let directives = sample_directives();
2759        let mut executor = Executor::new(&directives);
2760
2761        // Without DISTINCT - should have duplicates (same flag '*' for all)
2762        let query = parse("SELECT flag").unwrap();
2763        let result = executor.execute(&query).unwrap();
2764        assert_eq!(result.len(), 4); // One per posting, all have flag '*'
2765
2766        // With DISTINCT - should deduplicate
2767        let query = parse("SELECT DISTINCT flag").unwrap();
2768        let result = executor.execute(&query).unwrap();
2769        assert_eq!(result.len(), 1); // Deduplicated to 1 (all '*')
2770    }
2771
2772    #[test]
2773    fn test_limit_clause() {
2774        let directives = sample_directives();
2775        let mut executor = Executor::new(&directives);
2776
2777        // Test LIMIT restricts result count
2778        let query = parse("SELECT date, account LIMIT 2").unwrap();
2779        let result = executor.execute(&query).unwrap();
2780        assert_eq!(result.len(), 2);
2781
2782        // Test LIMIT 0 returns empty
2783        let query = parse("SELECT date LIMIT 0").unwrap();
2784        let result = executor.execute(&query).unwrap();
2785        assert_eq!(result.len(), 0);
2786
2787        // Test LIMIT larger than result set returns all
2788        let query = parse("SELECT date LIMIT 100").unwrap();
2789        let result = executor.execute(&query).unwrap();
2790        assert_eq!(result.len(), 4);
2791    }
2792
2793    #[test]
2794    fn test_group_by_with_count() {
2795        let directives = sample_directives();
2796        let mut executor = Executor::new(&directives);
2797
2798        // Group by account root and count postings
2799        let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
2800        let result = executor.execute(&query).unwrap();
2801
2802        assert_eq!(result.columns.len(), 2);
2803        // Should have 2 groups: Assets and Expenses
2804        assert_eq!(result.len(), 2);
2805    }
2806
2807    #[test]
2808    fn test_count_aggregate() {
2809        let directives = sample_directives();
2810        let mut executor = Executor::new(&directives);
2811
2812        // Count all postings
2813        let query = parse("SELECT COUNT(account)").unwrap();
2814        let result = executor.execute(&query).unwrap();
2815
2816        assert_eq!(result.len(), 1);
2817        assert_eq!(result.rows[0][0], Value::Integer(4));
2818
2819        // Count with GROUP BY
2820        let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
2821        let result = executor.execute(&query).unwrap();
2822        assert_eq!(result.len(), 2); // Assets, Expenses
2823    }
2824
2825    #[test]
2826    fn test_count_wildcard_direct() {
2827        // count(*) in the direct postings path (no FROM tablename)
2828        let directives = sample_directives();
2829        let mut executor = Executor::new(&directives);
2830
2831        // Pure count(*) with no GROUP BY
2832        let query = parse("SELECT count(*)").unwrap();
2833        let result = executor.execute(&query).unwrap();
2834        assert_eq!(result.len(), 1);
2835        assert_eq!(result.rows[0][0], Value::Integer(4)); // 4 postings total
2836
2837        // count(*) with GROUP BY in direct mode
2838        // Sample: Expenses:Food:Coffee (1), Assets:Bank:Checking (2), Expenses:Food:Groceries (1)
2839        let query = parse("SELECT account, count(*) GROUP BY account").unwrap();
2840        let result = executor.execute(&query).unwrap();
2841        assert_eq!(result.len(), 3); // 3 distinct accounts
2842    }
2843
2844    #[test]
2845    fn test_count_wildcard_from_postings_table() {
2846        // count(*) against the named postings table: FROM postings
2847        let directives = sample_directives();
2848        let mut executor = Executor::new(&directives);
2849
2850        // GROUP BY with count(*)
2851        let query = parse("SELECT account, count(*) FROM postings GROUP BY account").unwrap();
2852        let result = executor.execute(&query).unwrap();
2853        // 3 distinct accounts: Expenses:Food:Coffee, Assets:Bank:Checking, Expenses:Food:Groceries
2854        assert_eq!(result.len(), 3);
2855    }
2856
2857    #[test]
2858    fn test_count_wildcard_from_entries_table() {
2859        // count(*) against the named entries table: FROM entries
2860        let directives = sample_directives();
2861        let mut executor = Executor::new(&directives);
2862
2863        let query = parse("SELECT type, count(*) FROM entries GROUP BY type").unwrap();
2864        let result = executor.execute(&query).unwrap();
2865        // Only transactions in the sample data
2866        assert_eq!(result.len(), 1);
2867        assert_eq!(result.rows[0][0], Value::String("transaction".to_string()));
2868        assert_eq!(result.rows[0][1], Value::Integer(2));
2869    }
2870
2871    #[test]
2872    fn test_count_wildcard_having() {
2873        // count(*) in HAVING clause on postings table
2874        let directives = sample_directives();
2875        let mut executor = Executor::new(&directives);
2876
2877        // Accounts with more than 0 postings (all 3 distinct accounts)
2878        let query = parse(
2879            "SELECT account, count(*) AS cnt FROM postings GROUP BY account HAVING count(*) > 0",
2880        )
2881        .unwrap();
2882        let result = executor.execute(&query).unwrap();
2883        assert_eq!(result.len(), 3);
2884
2885        // Accounts with more than 1 posting (only Assets:Bank:Checking has 2)
2886        let query = parse(
2887            "SELECT account, count(*) AS cnt FROM postings GROUP BY account HAVING count(*) > 1",
2888        )
2889        .unwrap();
2890        let result = executor.execute(&query).unwrap();
2891        assert_eq!(result.len(), 1);
2892        assert_eq!(
2893            result.rows[0][0],
2894            Value::String("Assets:Bank:Checking".to_string())
2895        );
2896        assert_eq!(result.rows[0][1], Value::Integer(2));
2897    }
2898
2899    #[test]
2900    fn test_journal_query() {
2901        let directives = sample_directives();
2902        let mut executor = Executor::new(&directives);
2903
2904        // JOURNAL for Expenses account
2905        let query = parse("JOURNAL \"Expenses\"").unwrap();
2906        let result = executor.execute(&query).unwrap();
2907
2908        // Should have columns for journal output
2909        assert!(result.columns.contains(&"account".to_string()));
2910        // Should only show expense account entries
2911        assert_eq!(result.len(), 2);
2912    }
2913
2914    #[test]
2915    fn test_print_query() {
2916        let directives = sample_directives();
2917        let mut executor = Executor::new(&directives);
2918
2919        // PRINT outputs formatted directives
2920        let query = parse("PRINT").unwrap();
2921        let result = executor.execute(&query).unwrap();
2922
2923        // PRINT returns single column "directive" with formatted output
2924        assert_eq!(result.columns.len(), 1);
2925        assert_eq!(result.columns[0], "directive");
2926        // Should have one row per directive (2 transactions)
2927        assert_eq!(result.len(), 2);
2928    }
2929
2930    #[test]
2931    fn test_empty_directives() {
2932        let directives: Vec<Directive> = vec![];
2933        let mut executor = Executor::new(&directives);
2934
2935        // SELECT on empty directives
2936        let query = parse("SELECT date, account").unwrap();
2937        let result = executor.execute(&query).unwrap();
2938        assert!(result.is_empty());
2939
2940        // BALANCES on empty directives
2941        let query = parse("BALANCES").unwrap();
2942        let result = executor.execute(&query).unwrap();
2943        assert!(result.is_empty());
2944    }
2945
2946    #[test]
2947    fn test_comparison_operators() {
2948        let directives = sample_directives();
2949        let mut executor = Executor::new(&directives);
2950
2951        // Less than comparison on dates
2952        let query = parse("SELECT date WHERE date < 2024-01-16").unwrap();
2953        let result = executor.execute(&query).unwrap();
2954        assert_eq!(result.len(), 2); // First transaction postings
2955
2956        // Greater than comparison on year
2957        let query = parse("SELECT date WHERE year > 2023").unwrap();
2958        let result = executor.execute(&query).unwrap();
2959        assert_eq!(result.len(), 4); // All 2024 postings
2960
2961        // Equality comparison on day
2962        let query = parse("SELECT account WHERE day = 15").unwrap();
2963        let result = executor.execute(&query).unwrap();
2964        assert_eq!(result.len(), 2); // First transaction postings (Jan 15)
2965    }
2966
2967    #[test]
2968    fn test_logical_operators() {
2969        let directives = sample_directives();
2970        let mut executor = Executor::new(&directives);
2971
2972        // AND operator
2973        let query = parse("SELECT account WHERE account ~ \"Expenses\" AND day > 14").unwrap();
2974        let result = executor.execute(&query).unwrap();
2975        assert_eq!(result.len(), 2); // Expense postings on Jan 15 and 16
2976
2977        // OR operator
2978        let query = parse("SELECT account WHERE day = 15 OR day = 16").unwrap();
2979        let result = executor.execute(&query).unwrap();
2980        assert_eq!(result.len(), 4); // All postings (both days)
2981    }
2982
2983    #[test]
2984    fn test_arithmetic_expressions() {
2985        let directives = sample_directives();
2986        let mut executor = Executor::new(&directives);
2987
2988        // Negation on integer
2989        let query = parse("SELECT -day WHERE day = 15").unwrap();
2990        let result = executor.execute(&query).unwrap();
2991        assert_eq!(result.len(), 2);
2992        // Day 15 negated should be -15
2993        for row in &result.rows {
2994            if let Value::Integer(n) = &row[0] {
2995                assert_eq!(*n, -15);
2996            }
2997        }
2998    }
2999
3000    #[test]
3001    fn test_first_last_aggregates() {
3002        let directives = sample_directives();
3003        let mut executor = Executor::new(&directives);
3004
3005        // FIRST aggregate
3006        let query = parse("SELECT FIRST(date)").unwrap();
3007        let result = executor.execute(&query).unwrap();
3008        assert_eq!(result.len(), 1);
3009        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
3010
3011        // LAST aggregate
3012        let query = parse("SELECT LAST(date)").unwrap();
3013        let result = executor.execute(&query).unwrap();
3014        assert_eq!(result.len(), 1);
3015        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
3016    }
3017
3018    #[test]
3019    fn test_wildcard_select() {
3020        let directives = sample_directives();
3021        let mut executor = Executor::new(&directives);
3022
3023        // SELECT * returns all postings with expanded column names
3024        let query = parse("SELECT *").unwrap();
3025        let result = executor.execute(&query).unwrap();
3026
3027        // Wildcard expands to default column names (fixes issue #577)
3028        assert_eq!(
3029            result.columns,
3030            vec!["date", "flag", "payee", "narration", "account", "position"]
3031        );
3032        // Each row has expanded values matching the column names
3033        assert_eq!(result.len(), 4);
3034        assert_eq!(result.rows[0].len(), 6);
3035    }
3036
3037    #[test]
3038    fn test_wildcard_alias_rejected() {
3039        let directives = sample_directives();
3040        let mut executor = Executor::new(&directives);
3041
3042        // SELECT * AS alias should fail - wildcard expands to multiple columns
3043        let query = parse("SELECT * AS data").unwrap();
3044        let result = executor.execute(&query);
3045
3046        assert!(result.is_err());
3047        let err = result.unwrap_err();
3048        assert!(
3049            err.to_string().contains("Cannot alias wildcard"),
3050            "Expected wildcard alias error, got: {err}"
3051        );
3052    }
3053
3054    #[test]
3055    fn test_query_result_methods() {
3056        let mut result = QueryResult::new(vec!["col1".to_string(), "col2".to_string()]);
3057
3058        // Initially empty
3059        assert!(result.is_empty());
3060        assert_eq!(result.len(), 0);
3061
3062        // Add rows
3063        result.add_row(vec![Value::Integer(1), Value::String("a".to_string())]);
3064        assert!(!result.is_empty());
3065        assert_eq!(result.len(), 1);
3066
3067        result.add_row(vec![Value::Integer(2), Value::String("b".to_string())]);
3068        assert_eq!(result.len(), 2);
3069    }
3070
3071    #[test]
3072    fn test_type_cast_functions() {
3073        let directives = sample_directives();
3074        let mut executor = Executor::new(&directives);
3075
3076        // Test INT function
3077        let query = parse("SELECT int(5.7)").unwrap();
3078        let result = executor.execute(&query).unwrap();
3079        assert_eq!(result.rows[0][0], Value::Integer(5));
3080
3081        // Test DECIMAL function
3082        let query = parse("SELECT decimal(42)").unwrap();
3083        let result = executor.execute(&query).unwrap();
3084        assert_eq!(result.rows[0][0], Value::Number(dec!(42)));
3085
3086        // Test STR function
3087        let query = parse("SELECT str(123)").unwrap();
3088        let result = executor.execute(&query).unwrap();
3089        assert_eq!(result.rows[0][0], Value::String("123".to_string()));
3090
3091        // Test BOOL function
3092        let query = parse("SELECT bool(1)").unwrap();
3093        let result = executor.execute(&query).unwrap();
3094        assert_eq!(result.rows[0][0], Value::Boolean(true));
3095
3096        let query = parse("SELECT bool(0)").unwrap();
3097        let result = executor.execute(&query).unwrap();
3098        assert_eq!(result.rows[0][0], Value::Boolean(false));
3099    }
3100
3101    /// Test that type casting functions work in aggregate context (issue #630).
3102    #[test]
3103    fn test_type_casting_in_aggregate_context() {
3104        let txn1 = Transaction::new(date(2024, 1, 15), "Item 1")
3105            .with_flag('*')
3106            .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(10), "USD")))
3107            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "USD")));
3108
3109        let txn2 = Transaction::new(date(2024, 1, 16), "Item 2")
3110            .with_flag('*')
3111            .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(20), "USD")))
3112            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-20), "USD")));
3113
3114        let directives = vec![Directive::Transaction(txn1), Directive::Transaction(txn2)];
3115        let mut executor = Executor::new(&directives);
3116
3117        // Test STR wrapping an aggregate - this was the issue in #630
3118        // Each account has 2 postings summed: Expenses:Food = 30, Assets:Cash = -30
3119        let query =
3120            parse("SELECT account, str(sum(number(units))) GROUP BY account ORDER BY account")
3121                .unwrap();
3122        let result = executor.execute(&query).unwrap();
3123        assert_eq!(result.rows.len(), 2);
3124        // Verify actual string values
3125        assert_eq!(result.rows[0][0], Value::String("Assets:Cash".to_string()));
3126        assert_eq!(result.rows[0][1], Value::String("-30".to_string()));
3127        assert_eq!(
3128            result.rows[1][0],
3129            Value::String("Expenses:Food".to_string())
3130        );
3131        assert_eq!(result.rows[1][1], Value::String("30".to_string()));
3132
3133        // Test INT in aggregate context - verify truncation works
3134        let query =
3135            parse("SELECT account, int(sum(number(units))) GROUP BY account ORDER BY account")
3136                .unwrap();
3137        let result = executor.execute(&query).unwrap();
3138        assert_eq!(result.rows[0][1], Value::Integer(-30));
3139        assert_eq!(result.rows[1][1], Value::Integer(30));
3140
3141        // Test DECIMAL in aggregate context - verify count conversion
3142        let query =
3143            parse("SELECT account, decimal(count(*)) GROUP BY account ORDER BY account").unwrap();
3144        let result = executor.execute(&query).unwrap();
3145        assert_eq!(result.rows[0][1], Value::Number(dec!(2))); // 2 postings per account
3146        assert_eq!(result.rows[1][1], Value::Number(dec!(2)));
3147
3148        // Test BOOL in aggregate context - count > 0 should be true
3149        let query =
3150            parse("SELECT account, bool(count(*)) GROUP BY account ORDER BY account").unwrap();
3151        let result = executor.execute(&query).unwrap();
3152        assert_eq!(result.rows[0][1], Value::Boolean(true));
3153        assert_eq!(result.rows[1][1], Value::Boolean(true));
3154    }
3155
3156    /// Test INT truncation behavior with decimals.
3157    #[test]
3158    fn test_int_truncation() {
3159        let directives = sample_directives();
3160        let mut executor = Executor::new(&directives);
3161
3162        // Test INT truncates toward zero (not floor/ceil)
3163        let query = parse("SELECT int(5.7)").unwrap();
3164        let result = executor.execute(&query).unwrap();
3165        assert_eq!(result.rows[0][0], Value::Integer(5));
3166
3167        let query = parse("SELECT int(-5.7)").unwrap();
3168        let result = executor.execute(&query).unwrap();
3169        assert_eq!(result.rows[0][0], Value::Integer(-5));
3170
3171        let query = parse("SELECT int(0.999)").unwrap();
3172        let result = executor.execute(&query).unwrap();
3173        assert_eq!(result.rows[0][0], Value::Integer(0));
3174    }
3175
3176    /// Test type casting error cases.
3177    #[test]
3178    fn test_type_casting_errors() {
3179        let directives = sample_directives();
3180        let mut executor = Executor::new(&directives);
3181
3182        // INT with non-numeric string should error
3183        let query = parse("SELECT int('not-a-number')").unwrap();
3184        let result = executor.execute(&query);
3185        assert!(result.is_err());
3186        assert!(
3187            result
3188                .unwrap_err()
3189                .to_string()
3190                .contains("cannot parse 'not-a-number'")
3191        );
3192
3193        // DECIMAL with invalid string should error
3194        let query = parse("SELECT decimal('invalid')").unwrap();
3195        let result = executor.execute(&query);
3196        assert!(result.is_err());
3197        assert!(result.unwrap_err().to_string().contains("cannot parse"));
3198
3199        // BOOL with unrecognized string should error
3200        let query = parse("SELECT bool('maybe')").unwrap();
3201        let result = executor.execute(&query);
3202        assert!(result.is_err());
3203        assert!(result.unwrap_err().to_string().contains("cannot parse"));
3204    }
3205
3206    #[test]
3207    fn test_meta_functions() {
3208        // Create directives with metadata
3209        let mut txn_meta: Metadata = Metadata::default();
3210        txn_meta.insert(
3211            "source".to_string(),
3212            MetaValue::String("bank_import".to_string()),
3213        );
3214
3215        let mut posting_meta: Metadata = Metadata::default();
3216        posting_meta.insert(
3217            "category".to_string(),
3218            MetaValue::String("food".to_string()),
3219        );
3220
3221        let txn = Transaction {
3222            date: date(2024, 1, 15),
3223            flag: '*',
3224            payee: Some("Coffee Shop".into()),
3225            narration: "Coffee".into(),
3226            tags: vec![],
3227            links: vec![],
3228            meta: txn_meta,
3229            postings: vec![
3230                Posting {
3231                    account: "Expenses:Food".into(),
3232                    units: Some(rustledger_core::IncompleteAmount::Complete(Amount::new(
3233                        dec!(5),
3234                        "USD",
3235                    ))),
3236                    cost: None,
3237                    price: None,
3238                    flag: None,
3239                    meta: posting_meta,
3240                    comments: Vec::new(),
3241                    trailing_comments: Vec::new(),
3242                },
3243                Posting::new("Assets:Cash", Amount::new(dec!(-5), "USD")),
3244            ],
3245            trailing_comments: Vec::new(),
3246        };
3247
3248        let directives = vec![Directive::Transaction(txn)];
3249        let mut executor = Executor::new(&directives);
3250
3251        // Test META (posting metadata)
3252        let query = parse("SELECT meta('category') WHERE account ~ 'Expenses'").unwrap();
3253        let result = executor.execute(&query).unwrap();
3254        assert_eq!(result.rows[0][0], Value::String("food".to_string()));
3255
3256        // Test ENTRY_META (transaction metadata)
3257        let query = parse("SELECT entry_meta('source') WHERE account ~ 'Expenses'").unwrap();
3258        let result = executor.execute(&query).unwrap();
3259        assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
3260
3261        // Test ANY_META (falls back to txn meta when posting meta missing)
3262        let query = parse("SELECT any_meta('source') WHERE account ~ 'Expenses'").unwrap();
3263        let result = executor.execute(&query).unwrap();
3264        assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
3265
3266        // Test ANY_META (uses posting meta when available)
3267        let query = parse("SELECT any_meta('category') WHERE account ~ 'Expenses'").unwrap();
3268        let result = executor.execute(&query).unwrap();
3269        assert_eq!(result.rows[0][0], Value::String("food".to_string()));
3270
3271        // Test missing meta returns NULL
3272        let query = parse("SELECT meta('nonexistent') WHERE account ~ 'Expenses'").unwrap();
3273        let result = executor.execute(&query).unwrap();
3274        assert_eq!(result.rows[0][0], Value::Null);
3275    }
3276
3277    #[test]
3278    fn test_convert_function() {
3279        // Create directives with price information
3280        let price = rustledger_core::Price {
3281            date: date(2024, 1, 1),
3282            currency: "EUR".into(),
3283            amount: Amount::new(dec!(1.10), "USD"),
3284            meta: Metadata::default(),
3285        };
3286
3287        let txn = Transaction::new(date(2024, 1, 15), "Test")
3288            .with_flag('*')
3289            .with_posting(Posting::new("Assets:Euro", Amount::new(dec!(100), "EUR")))
3290            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-110), "USD")));
3291
3292        let directives = vec![Directive::Price(price), Directive::Transaction(txn)];
3293        let mut executor = Executor::new(&directives);
3294
3295        // Test CONVERT with amount
3296        let query = parse("SELECT convert(position, 'USD') WHERE account ~ 'Euro'").unwrap();
3297        let result = executor.execute(&query).unwrap();
3298        // 100 EUR × 1.10 = 110 USD
3299        match &result.rows[0][0] {
3300            Value::Amount(a) => {
3301                assert_eq!(a.number, dec!(110));
3302                assert_eq!(a.currency.as_ref(), "USD");
3303            }
3304            _ => panic!("Expected Amount, got {:?}", result.rows[0][0]),
3305        }
3306    }
3307
3308    #[test]
3309    fn test_date_functions() {
3310        let directives = sample_directives();
3311        let mut executor = Executor::new(&directives);
3312
3313        // Test DATE construction from string
3314        let query = parse("SELECT date('2024-06-15')").unwrap();
3315        let result = executor.execute(&query).unwrap();
3316        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
3317
3318        // Test DATE construction from components
3319        let query = parse("SELECT date(2024, 6, 15)").unwrap();
3320        let result = executor.execute(&query).unwrap();
3321        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
3322
3323        // Test DATE_DIFF
3324        let query = parse("SELECT date_diff(date('2024-01-20'), date('2024-01-15'))").unwrap();
3325        let result = executor.execute(&query).unwrap();
3326        assert_eq!(result.rows[0][0], Value::Integer(5));
3327
3328        // Test DATE_ADD
3329        let query = parse("SELECT date_add(date('2024-01-15'), 10)").unwrap();
3330        let result = executor.execute(&query).unwrap();
3331        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 25)));
3332
3333        // Test DATE_TRUNC year
3334        let query = parse("SELECT date_trunc('year', date('2024-06-15'))").unwrap();
3335        let result = executor.execute(&query).unwrap();
3336        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1)));
3337
3338        // Test DATE_TRUNC month
3339        let query = parse("SELECT date_trunc('month', date('2024-06-15'))").unwrap();
3340        let result = executor.execute(&query).unwrap();
3341        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1)));
3342
3343        // Test DATE_PART
3344        let query = parse("SELECT date_part('month', date('2024-06-15'))").unwrap();
3345        let result = executor.execute(&query).unwrap();
3346        assert_eq!(result.rows[0][0], Value::Integer(6));
3347
3348        // Test PARSE_DATE with custom format
3349        let query = parse("SELECT parse_date('15/06/2024', '%d/%m/%Y')").unwrap();
3350        let result = executor.execute(&query).unwrap();
3351        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
3352
3353        // Test DATE_BIN with day stride
3354        let query =
3355            parse("SELECT date_bin('7 days', date('2024-01-15'), date('2024-01-01'))").unwrap();
3356        let result = executor.execute(&query).unwrap();
3357        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); // 15 is 14 days from 1, so bucket starts at 15
3358
3359        // Test DATE_BIN with week stride
3360        let query =
3361            parse("SELECT date_bin('1 week', date('2024-01-20'), date('2024-01-01'))").unwrap();
3362        let result = executor.execute(&query).unwrap();
3363        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); // Week 3 starts at day 15
3364
3365        // Test DATE_BIN with month stride
3366        let query =
3367            parse("SELECT date_bin('1 month', date('2024-06-15'), date('2024-01-01'))").unwrap();
3368        let result = executor.execute(&query).unwrap();
3369        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1))); // June bucket
3370
3371        // Test DATE_BIN with year stride
3372        let query =
3373            parse("SELECT date_bin('1 year', date('2024-06-15'), date('2020-01-01'))").unwrap();
3374        let result = executor.execute(&query).unwrap();
3375        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1))); // 2024 bucket
3376    }
3377
3378    #[test]
3379    fn test_string_functions_extended() {
3380        let directives = sample_directives();
3381        let mut executor = Executor::new(&directives);
3382
3383        // Test GREP - returns matched portion
3384        let query = parse("SELECT grep('Ex[a-z]+', 'Hello Expenses World')").unwrap();
3385        let result = executor.execute(&query).unwrap();
3386        assert_eq!(result.rows[0][0], Value::String("Expenses".to_string()));
3387
3388        // Test GREP - no match returns NULL
3389        let query = parse("SELECT grep('xyz', 'Hello World')").unwrap();
3390        let result = executor.execute(&query).unwrap();
3391        assert_eq!(result.rows[0][0], Value::Null);
3392
3393        // Test GREPN - capture group (using [0-9] since \d is not escaped in BQL strings)
3394        let query = parse("SELECT grepn('([0-9]+)-([0-9]+)', '2024-01', 1)").unwrap();
3395        let result = executor.execute(&query).unwrap();
3396        assert_eq!(result.rows[0][0], Value::String("2024".to_string()));
3397
3398        // Test SUBST - substitution
3399        let query = parse("SELECT subst('-', '/', '2024-01-15')").unwrap();
3400        let result = executor.execute(&query).unwrap();
3401        assert_eq!(result.rows[0][0], Value::String("2024/01/15".to_string()));
3402
3403        // Test SPLITCOMP
3404        let query = parse("SELECT splitcomp('a:b:c', ':', 1)").unwrap();
3405        let result = executor.execute(&query).unwrap();
3406        assert_eq!(result.rows[0][0], Value::String("b".to_string()));
3407
3408        // Test JOINSTR
3409        let query = parse("SELECT joinstr('hello', 'world')").unwrap();
3410        let result = executor.execute(&query).unwrap();
3411        assert_eq!(result.rows[0][0], Value::String("hello, world".to_string()));
3412
3413        // Test MAXWIDTH - no truncation needed
3414        let query = parse("SELECT maxwidth('hello', 10)").unwrap();
3415        let result = executor.execute(&query).unwrap();
3416        assert_eq!(result.rows[0][0], Value::String("hello".to_string()));
3417
3418        // Test MAXWIDTH - truncation with ellipsis
3419        let query = parse("SELECT maxwidth('hello world', 8)").unwrap();
3420        let result = executor.execute(&query).unwrap();
3421        assert_eq!(result.rows[0][0], Value::String("hello...".to_string()));
3422    }
3423
3424    #[test]
3425    fn test_inventory_functions() {
3426        let directives = sample_directives();
3427        let mut executor = Executor::new(&directives);
3428
3429        // Test EMPTY on sum of position (sum across all postings may cancel out)
3430        // Use a filter to get non-canceling positions
3431        let query = parse("SELECT empty(sum(position)) WHERE account ~ 'Assets'").unwrap();
3432        let result = executor.execute(&query).unwrap();
3433        // Should be a boolean (the actual value depends on sample data)
3434        assert!(matches!(result.rows[0][0], Value::Boolean(_)));
3435
3436        // Test EMPTY with null returns true
3437        // (null handling is already tested in the function)
3438
3439        // Test POSSIGN with debit account (Assets) - no sign change
3440        let query = parse("SELECT possign(100, 'Assets:Bank')").unwrap();
3441        let result = executor.execute(&query).unwrap();
3442        assert_eq!(
3443            result.rows[0][0],
3444            Value::Number(rust_decimal::Decimal::from(100))
3445        );
3446
3447        // Test POSSIGN with credit account (Income) - sign is negated
3448        let query = parse("SELECT possign(100, 'Income:Salary')").unwrap();
3449        let result = executor.execute(&query).unwrap();
3450        assert_eq!(
3451            result.rows[0][0],
3452            Value::Number(rust_decimal::Decimal::from(-100))
3453        );
3454
3455        // Test POSSIGN with Expenses (debit normal) - no sign change
3456        let query = parse("SELECT possign(50, 'Expenses:Food')").unwrap();
3457        let result = executor.execute(&query).unwrap();
3458        assert_eq!(
3459            result.rows[0][0],
3460            Value::Number(rust_decimal::Decimal::from(50))
3461        );
3462
3463        // Test POSSIGN with Liabilities (credit normal) - sign is negated
3464        let query = parse("SELECT possign(200, 'Liabilities:CreditCard')").unwrap();
3465        let result = executor.execute(&query).unwrap();
3466        assert_eq!(
3467            result.rows[0][0],
3468            Value::Number(rust_decimal::Decimal::from(-200))
3469        );
3470
3471        // Test POSSIGN with Equity (credit normal) - sign is negated
3472        let query = parse("SELECT possign(300, 'Equity:OpeningBalances')").unwrap();
3473        let result = executor.execute(&query).unwrap();
3474        assert_eq!(
3475            result.rows[0][0],
3476            Value::Number(rust_decimal::Decimal::from(-300))
3477        );
3478    }
3479
3480    #[test]
3481    fn test_account_meta_functions() {
3482        use rustledger_core::{Close, Metadata, Open};
3483
3484        // Create directives with Open/Close
3485        let mut open_meta = Metadata::default();
3486        open_meta.insert(
3487            "category".to_string(),
3488            MetaValue::String("checking".to_string()),
3489        );
3490
3491        let directives = vec![
3492            Directive::Open(Open {
3493                date: date(2020, 1, 1),
3494                account: "Assets:Bank:Checking".into(),
3495                currencies: vec![],
3496                booking: None,
3497                meta: open_meta,
3498            }),
3499            Directive::Open(Open::new(date(2020, 2, 15), "Expenses:Food")),
3500            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank:Checking")),
3501            // A transaction to have postings for the query context
3502            Directive::Transaction(
3503                Transaction::new(date(2024, 1, 15), "Coffee")
3504                    .with_posting(Posting::new(
3505                        "Expenses:Food",
3506                        Amount::new(dec!(5.00), "USD"),
3507                    ))
3508                    .with_posting(Posting::new(
3509                        "Assets:Bank:Checking",
3510                        Amount::new(dec!(-5.00), "USD"),
3511                    )),
3512            ),
3513        ];
3514
3515        let mut executor = Executor::new(&directives);
3516
3517        // Test OPEN_DATE - account with open directive
3518        let query = parse("SELECT open_date('Assets:Bank:Checking')").unwrap();
3519        let result = executor.execute(&query).unwrap();
3520        assert_eq!(result.rows[0][0], Value::Date(date(2020, 1, 1)));
3521
3522        // Test CLOSE_DATE - account with close directive
3523        let query = parse("SELECT close_date('Assets:Bank:Checking')").unwrap();
3524        let result = executor.execute(&query).unwrap();
3525        assert_eq!(result.rows[0][0], Value::Date(date(2024, 12, 31)));
3526
3527        // Test OPEN_DATE - account without close directive
3528        let query = parse("SELECT close_date('Expenses:Food')").unwrap();
3529        let result = executor.execute(&query).unwrap();
3530        assert_eq!(result.rows[0][0], Value::Null);
3531
3532        // Test OPEN_META - get metadata from open directive
3533        let query = parse("SELECT open_meta('Assets:Bank:Checking', 'category')").unwrap();
3534        let result = executor.execute(&query).unwrap();
3535        assert_eq!(result.rows[0][0], Value::String("checking".to_string()));
3536
3537        // Test OPEN_META - non-existent key
3538        let query = parse("SELECT open_meta('Assets:Bank:Checking', 'nonexistent')").unwrap();
3539        let result = executor.execute(&query).unwrap();
3540        assert_eq!(result.rows[0][0], Value::Null);
3541
3542        // Test with non-existent account
3543        let query = parse("SELECT open_date('NonExistent:Account')").unwrap();
3544        let result = executor.execute(&query).unwrap();
3545        assert_eq!(result.rows[0][0], Value::Null);
3546    }
3547
3548    #[test]
3549    fn test_source_location_columns_return_null_without_sources() {
3550        // When using the regular constructor (without source location support),
3551        // the filename, lineno, and location columns should return Null
3552        let directives = vec![Directive::Transaction(Transaction {
3553            date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
3554            flag: '*',
3555            payee: Some("Test".into()),
3556            narration: "Test transaction".into(),
3557            tags: vec![],
3558            links: vec![],
3559            meta: Metadata::default(),
3560            postings: vec![
3561                Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3562                Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
3563            ],
3564            trailing_comments: Vec::new(),
3565        })];
3566
3567        let mut executor = Executor::new(&directives);
3568
3569        // Test filename column returns Null
3570        let query = parse("SELECT filename").unwrap();
3571        let result = executor.execute(&query).unwrap();
3572        assert_eq!(result.rows[0][0], Value::Null);
3573
3574        // Test lineno column returns Null
3575        let query = parse("SELECT lineno").unwrap();
3576        let result = executor.execute(&query).unwrap();
3577        assert_eq!(result.rows[0][0], Value::Null);
3578
3579        // Test location column returns Null
3580        let query = parse("SELECT location").unwrap();
3581        let result = executor.execute(&query).unwrap();
3582        assert_eq!(result.rows[0][0], Value::Null);
3583    }
3584
3585    #[test]
3586    fn test_source_location_columns_with_sources() {
3587        use rustledger_loader::SourceMap;
3588        use rustledger_parser::Spanned;
3589        use std::sync::Arc;
3590
3591        // Create a source map with a test file
3592        let mut source_map = SourceMap::new();
3593        let source: Arc<str> =
3594            "2024-01-15 * \"Test\"\n  Assets:Bank  100 USD\n  Expenses:Food".into();
3595        let file_id = source_map.add_file("test.beancount".into(), source);
3596
3597        // Create a spanned directive
3598        let txn = Transaction {
3599            date: rustledger_core::naive_date(2024, 1, 15).unwrap(),
3600            flag: '*',
3601            payee: Some("Test".into()),
3602            narration: "Test transaction".into(),
3603            tags: vec![],
3604            links: vec![],
3605            meta: Metadata::default(),
3606            postings: vec![
3607                Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3608                Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
3609            ],
3610            trailing_comments: Vec::new(),
3611        };
3612
3613        let spanned_directives = vec![Spanned {
3614            value: Directive::Transaction(txn),
3615            span: rustledger_parser::Span { start: 0, end: 50 },
3616            file_id: file_id as u16,
3617        }];
3618
3619        let mut executor = Executor::new_with_sources(&spanned_directives, &source_map);
3620
3621        // Test filename column returns the file path
3622        let query = parse("SELECT filename").unwrap();
3623        let result = executor.execute(&query).unwrap();
3624        assert_eq!(
3625            result.rows[0][0],
3626            Value::String("test.beancount".to_string())
3627        );
3628
3629        // Test lineno column returns line number
3630        let query = parse("SELECT lineno").unwrap();
3631        let result = executor.execute(&query).unwrap();
3632        assert_eq!(result.rows[0][0], Value::Integer(1));
3633
3634        // Test location column returns formatted location
3635        let query = parse("SELECT location").unwrap();
3636        let result = executor.execute(&query).unwrap();
3637        assert_eq!(
3638            result.rows[0][0],
3639            Value::String("test.beancount:1".to_string())
3640        );
3641    }
3642
3643    #[test]
3644    fn test_interval_function() {
3645        let directives = sample_directives();
3646        let mut executor = Executor::new(&directives);
3647
3648        // Test interval with single argument (unit only, count=1)
3649        let query = parse("SELECT interval('month')").unwrap();
3650        let result = executor.execute(&query).unwrap();
3651        assert_eq!(
3652            result.rows[0][0],
3653            Value::Interval(Interval::new(1, IntervalUnit::Month))
3654        );
3655
3656        // Test interval with two arguments (count, unit)
3657        let query = parse("SELECT interval(3, 'day')").unwrap();
3658        let result = executor.execute(&query).unwrap();
3659        assert_eq!(
3660            result.rows[0][0],
3661            Value::Interval(Interval::new(3, IntervalUnit::Day))
3662        );
3663
3664        // Test interval with negative count
3665        let query = parse("SELECT interval(-2, 'week')").unwrap();
3666        let result = executor.execute(&query).unwrap();
3667        assert_eq!(
3668            result.rows[0][0],
3669            Value::Interval(Interval::new(-2, IntervalUnit::Week))
3670        );
3671    }
3672
3673    #[test]
3674    fn test_date_add_with_interval() {
3675        let directives = sample_directives();
3676        let mut executor = Executor::new(&directives);
3677
3678        // Test date_add with interval
3679        let query = parse("SELECT date_add(date(2024, 1, 15), interval(1, 'month'))").unwrap();
3680        let result = executor.execute(&query).unwrap();
3681        assert_eq!(
3682            result.rows[0][0],
3683            Value::Date(rustledger_core::naive_date(2024, 2, 15).unwrap())
3684        );
3685
3686        // Test date + interval using binary operator
3687        let query = parse("SELECT date(2024, 1, 15) + interval(1, 'year')").unwrap();
3688        let result = executor.execute(&query).unwrap();
3689        assert_eq!(
3690            result.rows[0][0],
3691            Value::Date(rustledger_core::naive_date(2025, 1, 15).unwrap())
3692        );
3693
3694        // Test date - interval
3695        let query = parse("SELECT date(2024, 3, 15) - interval(2, 'month')").unwrap();
3696        let result = executor.execute(&query).unwrap();
3697        assert_eq!(
3698            result.rows[0][0],
3699            Value::Date(rustledger_core::naive_date(2024, 1, 15).unwrap())
3700        );
3701    }
3702}