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