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