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            // Aggregate functions return Null when evaluated on a single row
1060            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1061            _ => Err(QueryError::UnknownFunction(name.to_string())),
1062        }
1063    }
1064
1065    /// Helper to require a specific number of arguments (for pre-evaluated args).
1066    fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1067        if args.len() != expected {
1068            return Err(QueryError::InvalidArguments(
1069                name.to_string(),
1070                format!("expected {} argument(s), got {}", expected, args.len()),
1071            ));
1072        }
1073        Ok(())
1074    }
1075
1076    /// Helper to require a specific number of arguments.
1077    fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1078        if func.args.len() != expected {
1079            return Err(QueryError::InvalidArguments(
1080                name.to_string(),
1081                format!("expected {expected} argument(s)"),
1082            ));
1083        }
1084        Ok(())
1085    }
1086
1087    /// Convert a value to its market value using the latest available price.
1088    ///
1089    /// This is the core `VALUE()` implementation shared by both expression evaluation
1090    /// and aggregate/subquery contexts. It matches Python beancount's behavior:
1091    /// - Uses the latest available price (not a specific date)
1092    /// - Infers target currency from position's cost basis if not provided
1093    ///
1094    /// # Arguments
1095    /// * `val` - The value to convert (Position, Amount, or Inventory)
1096    /// * `explicit_currency` - Optional explicit target currency
1097    ///
1098    /// # Returns
1099    /// - `Value::Amount` when conversion succeeds or the input is a single amount/position
1100    /// - `Value::Inventory` when no target currency can be determined and the input is an inventory
1101    /// - `Value::Null` when the input is null
1102    ///
1103    /// When no explicit currency is provided and none can be inferred from the
1104    /// cost basis or executor settings, the input value is returned as-is rather
1105    /// than producing an error. This matches Python beancount behavior for
1106    /// positions without cost.
1107    pub(crate) fn convert_to_market_value(
1108        &self,
1109        val: &Value,
1110        explicit_currency: Option<&str>,
1111    ) -> Result<Value, QueryError> {
1112        // Determine target currency:
1113        // 1. Explicit argument takes precedence
1114        // 2. Infer from position's cost currency (beancount compatibility)
1115        // 3. Fall back to executor's target_currency setting
1116        let target_currency = if let Some(currency) = explicit_currency {
1117            currency.to_string()
1118        } else {
1119            // Try to infer from cost currency
1120            let inferred = match val {
1121                Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1122                Value::Inventory(inv) => inv
1123                    .positions()
1124                    .iter()
1125                    .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1126                _ => None,
1127            };
1128
1129            match inferred.or_else(|| self.target_currency.clone()) {
1130                Some(c) => c,
1131                None => {
1132                    // No currency can be determined — return value as-is
1133                    // (matches Python beancount behavior for positions without cost)
1134                    return match val {
1135                        Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1136                        Value::Amount(a) => Ok(Value::Amount(a.clone())),
1137                        Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1138                        Value::Null => Ok(Value::Null),
1139                        _ => Err(QueryError::Type(
1140                            "VALUE expects a position, amount, or inventory".to_string(),
1141                        )),
1142                    };
1143                }
1144            }
1145        };
1146
1147        // Use latest available price for conversion (beancount compatibility).
1148        // Python beancount's value() passes None as the date, which means "use latest price".
1149        match val {
1150            Value::Position(p) => {
1151                if p.units.currency == target_currency {
1152                    Ok(Value::Amount(p.units.clone()))
1153                } else if let Some(converted) =
1154                    self.price_db.convert_latest(&p.units, &target_currency)
1155                {
1156                    Ok(Value::Amount(converted))
1157                } else {
1158                    Ok(Value::Amount(p.units.clone()))
1159                }
1160            }
1161            Value::Amount(a) => {
1162                if a.currency == target_currency {
1163                    Ok(Value::Amount(a.clone()))
1164                } else if let Some(converted) = self.price_db.convert_latest(a, &target_currency) {
1165                    Ok(Value::Amount(converted))
1166                } else {
1167                    Ok(Value::Amount(a.clone()))
1168                }
1169            }
1170            Value::Inventory(inv) => {
1171                let mut total = Decimal::ZERO;
1172                for pos in inv.positions() {
1173                    if pos.units.currency == target_currency {
1174                        total += pos.units.number;
1175                    } else if let Some(converted) =
1176                        self.price_db.convert_latest(&pos.units, &target_currency)
1177                    {
1178                        total += converted.number;
1179                    }
1180                }
1181                Ok(Value::Amount(Amount::new(total, &target_currency)))
1182            }
1183            Value::Null => Ok(Value::Null),
1184            _ => Err(QueryError::Type(
1185                "VALUE expects a position, amount, or inventory".to_string(),
1186            )),
1187        }
1188    }
1189
1190    /// Check if an expression is a window function.
1191    pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1192        matches!(expr, Expr::Window(_))
1193    }
1194
1195    /// Resolve column names from targets.
1196    fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1197        let mut names = Vec::new();
1198        for (i, target) in targets.iter().enumerate() {
1199            if matches!(target.expr, Expr::Wildcard) {
1200                // Check wildcard BEFORE alias to catch `SELECT * AS alias` edge case
1201                if target.alias.is_some() {
1202                    return Err(QueryError::Evaluation(
1203                        "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1204                    ));
1205                }
1206                // Expand wildcard using shared constant (must match evaluate_row expansion)
1207                names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1208            } else if let Some(alias) = &target.alias {
1209                names.push(alias.clone());
1210            } else {
1211                names.push(self.expr_to_name(&target.expr, i));
1212            }
1213        }
1214        Ok(names)
1215    }
1216
1217    /// Convert an expression to a column name.
1218    fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1219        match expr {
1220            Expr::Wildcard => "*".to_string(),
1221            Expr::Column(name) => name.clone(),
1222            Expr::Function(func) => func.name.clone(),
1223            Expr::Window(wf) => wf.name.clone(),
1224            _ => format!("col{index}"),
1225        }
1226    }
1227
1228    /// Get a built-in system table by name.
1229    ///
1230    /// Built-in tables are virtual tables that provide access to ledger data:
1231    /// - `#prices` / `prices`: Price directives from the ledger
1232    /// - `#balances` / `balances`: Balance assertion directives from the ledger
1233    /// - `#commodities` / `commodities`: Commodity directives from the ledger
1234    /// - `#events` / `events`: Event directives from the ledger
1235    /// - `#notes` / `notes`: Note directives from the ledger
1236    /// - `#documents` / `documents`: Document directives from the ledger
1237    /// - `#accounts` / `accounts`: Open/Close directives paired by account
1238    /// - `#transactions` / `transactions`: Transaction directives from the ledger
1239    /// - `#entries` / `entries`: All directives with source location info
1240    /// - `#postings` / `postings`: All postings from transactions
1241    ///
1242    /// Both `#`-prefixed and non-prefixed names are supported for Python beancount
1243    /// compatibility (issue #632).
1244    ///
1245    /// Returns `None` if the table name is not a recognized built-in table.
1246    pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1247        // Normalize table name: strip # prefix if present for Python beancount compatibility.
1248        // Both "#transactions" (rustledger) and "transactions" (beancount) work.
1249        // Using strip_prefix avoids allocation in the common case.
1250        let upper = table_name.to_uppercase();
1251        let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1252
1253        match normalized {
1254            "PRICES" => Some(self.build_prices_table()),
1255            "BALANCES" => Some(self.build_balances_table()),
1256            "COMMODITIES" => Some(self.build_commodities_table()),
1257            "EVENTS" => Some(self.build_events_table()),
1258            "NOTES" => Some(self.build_notes_table()),
1259            "DOCUMENTS" => Some(self.build_documents_table()),
1260            "ACCOUNTS" => Some(self.build_accounts_table()),
1261            "TRANSACTIONS" => Some(self.build_transactions_table()),
1262            "ENTRIES" => Some(self.build_entries_table()),
1263            "POSTINGS" => Some(self.build_postings_table()),
1264            _ => None,
1265        }
1266    }
1267
1268    /// Build the #prices table from price directives.
1269    ///
1270    /// The table has columns: date, currency, amount
1271    /// - date: The date of the price directive
1272    /// - currency: The base currency being priced
1273    /// - amount: The price as an Amount (number + quote currency)
1274    fn build_prices_table(&self) -> Table {
1275        let columns = vec![
1276            "date".to_string(),
1277            "currency".to_string(),
1278            "amount".to_string(),
1279        ];
1280        let mut table = Table::new(columns);
1281
1282        // Collect all price entries from the price database
1283        let mut entries: Vec<_> = self.price_db.iter_entries().collect();
1284        // Sort by (date, base_currency) for consistent, deterministic output
1285        entries.sort_by(|(currency_a, date_a, _, _), (currency_b, date_b, _, _)| {
1286            date_a.cmp(date_b).then_with(|| currency_a.cmp(currency_b))
1287        });
1288
1289        for (base_currency, date, price_number, quote_currency) in entries {
1290            let row = vec![
1291                Value::Date(date),
1292                Value::String(base_currency.to_string()),
1293                Value::Amount(Amount::new(price_number, quote_currency)),
1294            ];
1295            table.add_row(row);
1296        }
1297
1298        table
1299    }
1300
1301    /// Build the #balances table from balance assertion directives.
1302    ///
1303    /// The table has columns: date, account, amount
1304    /// - date: The date of the balance assertion
1305    /// - account: The account being balanced
1306    /// - amount: The expected balance amount
1307    fn build_balances_table(&self) -> Table {
1308        let columns = vec![
1309            "date".to_string(),
1310            "account".to_string(),
1311            "amount".to_string(),
1312        ];
1313        let mut table = Table::new(columns);
1314
1315        // Collect balance directives from either spanned or unspanned directives
1316        let mut balances: Vec<_> = if let Some(spanned) = self.spanned_directives {
1317            spanned
1318                .iter()
1319                .filter_map(|s| {
1320                    if let Directive::Balance(b) = &s.value {
1321                        Some((b.date, b.account.as_ref(), b.amount.clone()))
1322                    } else {
1323                        None
1324                    }
1325                })
1326                .collect()
1327        } else {
1328            self.directives
1329                .iter()
1330                .filter_map(|d| {
1331                    if let Directive::Balance(b) = d {
1332                        Some((b.date, b.account.as_ref(), b.amount.clone()))
1333                    } else {
1334                        None
1335                    }
1336                })
1337                .collect()
1338        };
1339
1340        // Sort by (date, account) for consistent, deterministic output
1341        balances.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
1342            date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
1343        });
1344
1345        for (date, account, amount) in balances {
1346            let row = vec![
1347                Value::Date(date),
1348                Value::String(account.to_string()),
1349                Value::Amount(amount),
1350            ];
1351            table.add_row(row);
1352        }
1353
1354        table
1355    }
1356
1357    /// Build the #commodities table from commodity directives.
1358    ///
1359    /// The table has columns: date, name
1360    /// - date: The date of the commodity declaration
1361    /// - name: The currency/commodity code
1362    fn build_commodities_table(&self) -> Table {
1363        let columns = vec!["date".to_string(), "name".to_string()];
1364        let mut table = Table::new(columns);
1365
1366        // Collect commodity directives from either spanned or unspanned directives
1367        let mut commodities: Vec<_> = if let Some(spanned) = self.spanned_directives {
1368            spanned
1369                .iter()
1370                .filter_map(|s| {
1371                    if let Directive::Commodity(c) = &s.value {
1372                        Some((c.date, c.currency.as_ref()))
1373                    } else {
1374                        None
1375                    }
1376                })
1377                .collect()
1378        } else {
1379            self.directives
1380                .iter()
1381                .filter_map(|d| {
1382                    if let Directive::Commodity(c) = d {
1383                        Some((c.date, c.currency.as_ref()))
1384                    } else {
1385                        None
1386                    }
1387                })
1388                .collect()
1389        };
1390
1391        // Sort by (date, name) for consistent output
1392        commodities.sort_by(|(date_a, name_a), (date_b, name_b)| {
1393            date_a.cmp(date_b).then_with(|| name_a.cmp(name_b))
1394        });
1395
1396        for (date, name) in commodities {
1397            let row = vec![Value::Date(date), Value::String(name.to_string())];
1398            table.add_row(row);
1399        }
1400
1401        table
1402    }
1403
1404    /// Build the #events table from event directives.
1405    ///
1406    /// The table has columns: date, type, description
1407    /// - date: The date of the event
1408    /// - type: The event type
1409    /// - description: The event value/description
1410    fn build_events_table(&self) -> Table {
1411        let columns = vec![
1412            "date".to_string(),
1413            "type".to_string(),
1414            "description".to_string(),
1415        ];
1416        let mut table = Table::new(columns);
1417
1418        // Collect event directives
1419        let mut events: Vec<_> = if let Some(spanned) = self.spanned_directives {
1420            spanned
1421                .iter()
1422                .filter_map(|s| {
1423                    if let Directive::Event(e) = &s.value {
1424                        Some((e.date, e.event_type.as_str(), e.value.as_str()))
1425                    } else {
1426                        None
1427                    }
1428                })
1429                .collect()
1430        } else {
1431            self.directives
1432                .iter()
1433                .filter_map(|d| {
1434                    if let Directive::Event(e) = d {
1435                        Some((e.date, e.event_type.as_str(), e.value.as_str()))
1436                    } else {
1437                        None
1438                    }
1439                })
1440                .collect()
1441        };
1442
1443        // Sort by (date, type) for consistent output
1444        events.sort_by(|(date_a, type_a, _), (date_b, type_b, _)| {
1445            date_a.cmp(date_b).then_with(|| type_a.cmp(type_b))
1446        });
1447
1448        for (date, event_type, description) in events {
1449            let row = vec![
1450                Value::Date(date),
1451                Value::String(event_type.to_string()),
1452                Value::String(description.to_string()),
1453            ];
1454            table.add_row(row);
1455        }
1456
1457        table
1458    }
1459
1460    /// Build the #notes table from note directives.
1461    ///
1462    /// The table has columns: date, account, comment
1463    /// - date: The date of the note
1464    /// - account: The account the note is attached to
1465    /// - comment: The note text
1466    fn build_notes_table(&self) -> Table {
1467        let columns = vec![
1468            "date".to_string(),
1469            "account".to_string(),
1470            "comment".to_string(),
1471        ];
1472        let mut table = Table::new(columns);
1473
1474        // Collect note directives
1475        let mut notes: Vec<_> = if let Some(spanned) = self.spanned_directives {
1476            spanned
1477                .iter()
1478                .filter_map(|s| {
1479                    if let Directive::Note(n) = &s.value {
1480                        Some((n.date, n.account.as_ref(), n.comment.as_str()))
1481                    } else {
1482                        None
1483                    }
1484                })
1485                .collect()
1486        } else {
1487            self.directives
1488                .iter()
1489                .filter_map(|d| {
1490                    if let Directive::Note(n) = d {
1491                        Some((n.date, n.account.as_ref(), n.comment.as_str()))
1492                    } else {
1493                        None
1494                    }
1495                })
1496                .collect()
1497        };
1498
1499        // Sort by (date, account) for consistent output
1500        notes.sort_by(|(date_a, account_a, _), (date_b, account_b, _)| {
1501            date_a.cmp(date_b).then_with(|| account_a.cmp(account_b))
1502        });
1503
1504        for (date, account, comment) in notes {
1505            let row = vec![
1506                Value::Date(date),
1507                Value::String(account.to_string()),
1508                Value::String(comment.to_string()),
1509            ];
1510            table.add_row(row);
1511        }
1512
1513        table
1514    }
1515
1516    /// Build the #documents table from document directives.
1517    ///
1518    /// The table has columns: date, account, filename, tags, links
1519    /// - date: The date of the document
1520    /// - account: The account the document is attached to
1521    /// - filename: The file path to the document
1522    /// - tags: The document tags (as a set)
1523    /// - links: The document links (as a set)
1524    fn build_documents_table(&self) -> Table {
1525        let columns = vec![
1526            "date".to_string(),
1527            "account".to_string(),
1528            "filename".to_string(),
1529            "tags".to_string(),
1530            "links".to_string(),
1531        ];
1532        let mut table = Table::new(columns);
1533
1534        // Collect document directives
1535        let mut documents: Vec<_> = if let Some(spanned) = self.spanned_directives {
1536            spanned
1537                .iter()
1538                .filter_map(|s| {
1539                    if let Directive::Document(d) = &s.value {
1540                        Some((
1541                            d.date,
1542                            d.account.as_ref(),
1543                            d.path.as_str(),
1544                            &d.tags,
1545                            &d.links,
1546                        ))
1547                    } else {
1548                        None
1549                    }
1550                })
1551                .collect()
1552        } else {
1553            self.directives
1554                .iter()
1555                .filter_map(|d| {
1556                    if let Directive::Document(doc) = d {
1557                        Some((
1558                            doc.date,
1559                            doc.account.as_ref(),
1560                            doc.path.as_str(),
1561                            &doc.tags,
1562                            &doc.links,
1563                        ))
1564                    } else {
1565                        None
1566                    }
1567                })
1568                .collect()
1569        };
1570
1571        // Sort by (date, account, filename) for consistent output
1572        documents.sort_by(
1573            |(date_a, account_a, file_a, _, _), (date_b, account_b, file_b, _, _)| {
1574                date_a
1575                    .cmp(date_b)
1576                    .then_with(|| account_a.cmp(account_b))
1577                    .then_with(|| file_a.cmp(file_b))
1578            },
1579        );
1580
1581        for (date, account, filename, tags, links) in documents {
1582            let tags_vec: Vec<String> = tags.iter().map(ToString::to_string).collect();
1583            let links_vec: Vec<String> = links.iter().map(ToString::to_string).collect();
1584            let row = vec![
1585                Value::Date(date),
1586                Value::String(account.to_string()),
1587                Value::String(filename.to_string()),
1588                Value::StringSet(tags_vec),
1589                Value::StringSet(links_vec),
1590            ];
1591            table.add_row(row);
1592        }
1593
1594        table
1595    }
1596
1597    /// Build the #accounts table from Open/Close directives.
1598    ///
1599    /// The table has columns: account, open, close, currencies, booking
1600    /// - account: The account name
1601    /// - open: The date the account was opened
1602    /// - close: The date the account was closed (NULL if still open)
1603    /// - currencies: Allowed currencies for the account
1604    /// - booking: Booking method (NULL if not specified)
1605    fn build_accounts_table(&self) -> Table {
1606        let columns = vec![
1607            "account".to_string(),
1608            "open".to_string(),
1609            "close".to_string(),
1610            "currencies".to_string(),
1611            "booking".to_string(),
1612        ];
1613        let mut table = Table::new(columns);
1614
1615        // Build a map of account name -> (open_date, close_date, currencies, booking)
1616        let mut accounts: FxHashMap<
1617            &str,
1618            (
1619                Option<chrono::NaiveDate>,
1620                Option<chrono::NaiveDate>,
1621                Vec<String>,
1622                Option<&str>,
1623            ),
1624        > = FxHashMap::default();
1625
1626        // Process directives
1627        let iter: Box<dyn Iterator<Item = &Directive>> =
1628            if let Some(spanned) = self.spanned_directives {
1629                Box::new(spanned.iter().map(|s| &s.value))
1630            } else {
1631                Box::new(self.directives.iter())
1632            };
1633
1634        for directive in iter {
1635            match directive {
1636                Directive::Open(open) => {
1637                    let entry = accounts.entry(open.account.as_ref()).or_insert((
1638                        None,
1639                        None,
1640                        Vec::new(),
1641                        None,
1642                    ));
1643                    entry.0 = Some(open.date);
1644                    entry.2 = open.currencies.iter().map(ToString::to_string).collect();
1645                    entry.3 = open.booking.as_deref();
1646                }
1647                Directive::Close(close) => {
1648                    let entry = accounts.entry(close.account.as_ref()).or_insert((
1649                        None,
1650                        None,
1651                        Vec::new(),
1652                        None,
1653                    ));
1654                    entry.1 = Some(close.date);
1655                }
1656                _ => {}
1657            }
1658        }
1659
1660        // Sort accounts by name for consistent output
1661        let mut account_list: Vec<_> = accounts.into_iter().collect();
1662        account_list.sort_by(|(a, _), (b, _)| a.cmp(b));
1663
1664        for (account, (open_date, close_date, currencies, booking)) in account_list {
1665            let row = vec![
1666                Value::String(account.to_string()),
1667                open_date.map_or(Value::Null, Value::Date),
1668                close_date.map_or(Value::Null, Value::Date),
1669                Value::StringSet(currencies),
1670                booking.map_or(Value::Null, |b| Value::String(b.to_string())),
1671            ];
1672            table.add_row(row);
1673        }
1674
1675        table
1676    }
1677
1678    /// Build the #transactions table from transaction directives.
1679    ///
1680    /// The table has columns: date, flag, payee, narration, tags, links, accounts
1681    /// - date: The transaction date
1682    /// - flag: The transaction flag (e.g., '*' or '!')
1683    /// - payee: The payee (NULL if not specified)
1684    /// - narration: The transaction description
1685    /// - tags: Transaction tags (as a set)
1686    /// - links: Transaction links (as a set)
1687    /// - accounts: Set of accounts involved in the transaction
1688    fn build_transactions_table(&self) -> Table {
1689        let columns = vec![
1690            "date".to_string(),
1691            "flag".to_string(),
1692            "payee".to_string(),
1693            "narration".to_string(),
1694            "tags".to_string(),
1695            "links".to_string(),
1696            "accounts".to_string(),
1697        ];
1698        let mut table = Table::new(columns);
1699
1700        // Collect transaction directives
1701        let iter: Box<dyn Iterator<Item = &Directive>> =
1702            if let Some(spanned) = self.spanned_directives {
1703                Box::new(spanned.iter().map(|s| &s.value))
1704            } else {
1705                Box::new(self.directives.iter())
1706            };
1707
1708        let mut transactions: Vec<_> = iter
1709            .filter_map(|d| {
1710                if let Directive::Transaction(txn) = d {
1711                    Some(txn)
1712                } else {
1713                    None
1714                }
1715            })
1716            .collect();
1717
1718        // Sort by date for consistent output
1719        transactions.sort_by_key(|t| t.date);
1720
1721        for txn in transactions {
1722            let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
1723            let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
1724            let mut accounts: Vec<String> = txn
1725                .postings
1726                .iter()
1727                .map(|p| p.account.to_string())
1728                .collect::<std::collections::HashSet<_>>()
1729                .into_iter()
1730                .collect();
1731            accounts.sort(); // Ensure deterministic ordering
1732
1733            let row = vec![
1734                Value::Date(txn.date),
1735                Value::String(txn.flag.to_string()),
1736                txn.payee
1737                    .as_ref()
1738                    .map_or(Value::Null, |p| Value::String(p.to_string())),
1739                Value::String(txn.narration.to_string()),
1740                Value::StringSet(tags),
1741                Value::StringSet(links),
1742                Value::StringSet(accounts),
1743            ];
1744            table.add_row(row);
1745        }
1746
1747        table
1748    }
1749
1750    /// Build the #entries table from all directives.
1751    ///
1752    /// The table has columns: id, type, filename, lineno, date, flag, payee, narration, tags, links, accounts
1753    /// This provides access to all directives with source location information.
1754    fn build_entries_table(&self) -> Table {
1755        let columns = vec![
1756            "id".to_string(),
1757            "type".to_string(),
1758            "filename".to_string(),
1759            "lineno".to_string(),
1760            "date".to_string(),
1761            "flag".to_string(),
1762            "payee".to_string(),
1763            "narration".to_string(),
1764            "tags".to_string(),
1765            "links".to_string(),
1766            "accounts".to_string(),
1767        ];
1768        let mut table = Table::new(columns);
1769
1770        // Process directives with optional source locations
1771        if let Some(spanned) = self.spanned_directives {
1772            for (idx, spanned_dir) in spanned.iter().enumerate() {
1773                let directive = &spanned_dir.value;
1774                let source_loc = self.get_source_location(idx);
1775                let row = self.directive_to_entry_row(idx, directive, source_loc);
1776                table.add_row(row);
1777            }
1778        } else {
1779            for (idx, directive) in self.directives.iter().enumerate() {
1780                let row = self.directive_to_entry_row(idx, directive, None);
1781                table.add_row(row);
1782            }
1783        }
1784
1785        table
1786    }
1787
1788    /// Convert a directive to a row for the #entries table.
1789    fn directive_to_entry_row(
1790        &self,
1791        idx: usize,
1792        directive: &Directive,
1793        source_loc: Option<&SourceLocation>,
1794    ) -> Vec<Value> {
1795        let type_name = match directive {
1796            Directive::Transaction(_) => "Transaction",
1797            Directive::Balance(_) => "Balance",
1798            Directive::Open(_) => "Open",
1799            Directive::Close(_) => "Close",
1800            Directive::Commodity(_) => "Commodity",
1801            Directive::Pad(_) => "Pad",
1802            Directive::Event(_) => "Event",
1803            Directive::Query(_) => "Query",
1804            Directive::Note(_) => "Note",
1805            Directive::Document(_) => "Document",
1806            Directive::Price(_) => "Price",
1807            Directive::Custom(_) => "Custom",
1808        };
1809
1810        let date = match directive {
1811            Directive::Transaction(t) => Value::Date(t.date),
1812            Directive::Balance(b) => Value::Date(b.date),
1813            Directive::Open(o) => Value::Date(o.date),
1814            Directive::Close(c) => Value::Date(c.date),
1815            Directive::Commodity(c) => Value::Date(c.date),
1816            Directive::Pad(p) => Value::Date(p.date),
1817            Directive::Event(e) => Value::Date(e.date),
1818            Directive::Query(q) => Value::Date(q.date),
1819            Directive::Note(n) => Value::Date(n.date),
1820            Directive::Document(d) => Value::Date(d.date),
1821            Directive::Price(p) => Value::Date(p.date),
1822            Directive::Custom(c) => Value::Date(c.date),
1823        };
1824
1825        let (flag, payee, narration, tags, links, accounts) =
1826            if let Directive::Transaction(txn) = directive {
1827                let tags: Vec<String> = txn.tags.iter().map(ToString::to_string).collect();
1828                let links: Vec<String> = txn.links.iter().map(ToString::to_string).collect();
1829                let mut accounts: Vec<String> = txn
1830                    .postings
1831                    .iter()
1832                    .map(|p| p.account.to_string())
1833                    .collect::<std::collections::HashSet<_>>()
1834                    .into_iter()
1835                    .collect();
1836                accounts.sort(); // Ensure deterministic ordering
1837                (
1838                    Value::String(txn.flag.to_string()),
1839                    txn.payee
1840                        .as_ref()
1841                        .map_or(Value::Null, |p| Value::String(p.to_string())),
1842                    Value::String(txn.narration.to_string()),
1843                    Value::StringSet(tags),
1844                    Value::StringSet(links),
1845                    Value::StringSet(accounts),
1846                )
1847            } else {
1848                (
1849                    Value::Null,
1850                    Value::Null,
1851                    Value::Null,
1852                    Value::StringSet(vec![]),
1853                    Value::StringSet(vec![]),
1854                    Value::StringSet(vec![]),
1855                )
1856            };
1857
1858        let filename = source_loc.map_or(Value::Null, |loc| Value::String(loc.filename.clone()));
1859        let lineno = source_loc.map_or(Value::Null, |loc| Value::Integer(loc.lineno as i64));
1860
1861        vec![
1862            Value::Integer(idx as i64), // id
1863            Value::String(type_name.to_string()),
1864            filename,
1865            lineno,
1866            date,
1867            flag,
1868            payee,
1869            narration,
1870            tags,
1871            links,
1872            accounts,
1873        ]
1874    }
1875
1876    /// Build the #postings table from transaction postings.
1877    ///
1878    /// The table has columns:
1879    /// - `date`, `flag`, `payee`, `narration`: from parent transaction
1880    /// - `account`, `number`, `currency`: posting units
1881    /// - `cost_number`, `cost_currency`, `cost_date`, `cost_label`: cost basis info
1882    /// - `price`: posting price
1883    /// - `balance`: running balance for the account
1884    fn build_postings_table(&self) -> Table {
1885        let columns = vec![
1886            "date".to_string(),
1887            "flag".to_string(),
1888            "payee".to_string(),
1889            "narration".to_string(),
1890            "account".to_string(),
1891            "number".to_string(),
1892            "currency".to_string(),
1893            "cost_number".to_string(),
1894            "cost_currency".to_string(),
1895            "cost_date".to_string(),
1896            "cost_label".to_string(),
1897            "price".to_string(),
1898            "balance".to_string(),
1899        ];
1900        let mut table = Table::new(columns);
1901
1902        // Track running balances per account
1903        let mut running_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
1904
1905        // Process directives
1906        let iter: Box<dyn Iterator<Item = &Directive>> =
1907            if let Some(spanned) = self.spanned_directives {
1908                Box::new(spanned.iter().map(|s| &s.value))
1909            } else {
1910                Box::new(self.directives.iter())
1911            };
1912
1913        // Collect transactions sorted by date
1914        let mut transactions: Vec<_> = iter
1915            .filter_map(|d| {
1916                if let Directive::Transaction(txn) = d {
1917                    Some(txn)
1918                } else {
1919                    None
1920                }
1921            })
1922            .collect();
1923        transactions.sort_by_key(|t| t.date);
1924
1925        for txn in transactions {
1926            for posting in &txn.postings {
1927                // Update running balance
1928                if let Some(units) = posting.amount() {
1929                    let balance = running_balances.entry(posting.account.clone()).or_default();
1930                    let pos = if let Some(cost_spec) = &posting.cost {
1931                        if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
1932                            Position::with_cost(units.clone(), cost)
1933                        } else {
1934                            Position::simple(units.clone())
1935                        }
1936                    } else {
1937                        Position::simple(units.clone())
1938                    };
1939                    balance.add(pos);
1940                }
1941
1942                // Extract posting data
1943                let (number, currency) = posting.amount().map_or((Value::Null, Value::Null), |a| {
1944                    (
1945                        Value::Number(a.number),
1946                        Value::String(a.currency.to_string()),
1947                    )
1948                });
1949
1950                let (cost_number, cost_currency, cost_date, cost_label) = if let Some(cost_spec) =
1951                    &posting.cost
1952                {
1953                    let units = posting.amount();
1954                    if let Some(cost) = units.and_then(|u| cost_spec.resolve(u.number, txn.date)) {
1955                        (
1956                            Value::Number(cost.number),
1957                            Value::String(cost.currency.to_string()),
1958                            cost.date.map_or(Value::Null, Value::Date),
1959                            cost.label
1960                                .as_ref()
1961                                .map_or(Value::Null, |l| Value::String(l.clone())),
1962                        )
1963                    } else {
1964                        (Value::Null, Value::Null, Value::Null, Value::Null)
1965                    }
1966                } else {
1967                    (Value::Null, Value::Null, Value::Null, Value::Null)
1968                };
1969
1970                let price_val = posting
1971                    .price
1972                    .as_ref()
1973                    .and_then(|p| p.amount())
1974                    .map_or(Value::Null, |a| Value::Amount(a.clone()));
1975
1976                let balance_val = running_balances
1977                    .get(&posting.account)
1978                    .map_or(Value::Null, |inv| Value::Inventory(Box::new(inv.clone())));
1979
1980                let row = vec![
1981                    Value::Date(txn.date),
1982                    Value::String(txn.flag.to_string()),
1983                    txn.payee
1984                        .as_ref()
1985                        .map_or(Value::Null, |p| Value::String(p.to_string())),
1986                    Value::String(txn.narration.to_string()),
1987                    Value::String(posting.account.to_string()),
1988                    number,
1989                    currency,
1990                    cost_number,
1991                    cost_currency,
1992                    cost_date,
1993                    cost_label,
1994                    price_val,
1995                    balance_val,
1996                ];
1997                table.add_row(row);
1998            }
1999        }
2000
2001        table
2002    }
2003}
2004#[cfg(test)]
2005mod tests {
2006    use super::types::{hash_row, hash_single_value};
2007    use super::*;
2008    use crate::parse;
2009    use rust_decimal_macros::dec;
2010    use rustledger_core::Posting;
2011
2012    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
2013        NaiveDate::from_ymd_opt(year, month, day).unwrap()
2014    }
2015
2016    fn sample_directives() -> Vec<Directive> {
2017        vec![
2018            Directive::Transaction(
2019                Transaction::new(date(2024, 1, 15), "Coffee")
2020                    .with_flag('*')
2021                    .with_payee("Coffee Shop")
2022                    .with_posting(Posting::new(
2023                        "Expenses:Food:Coffee",
2024                        Amount::new(dec!(5.00), "USD"),
2025                    ))
2026                    .with_posting(Posting::new(
2027                        "Assets:Bank:Checking",
2028                        Amount::new(dec!(-5.00), "USD"),
2029                    )),
2030            ),
2031            Directive::Transaction(
2032                Transaction::new(date(2024, 1, 16), "Groceries")
2033                    .with_flag('*')
2034                    .with_payee("Supermarket")
2035                    .with_posting(Posting::new(
2036                        "Expenses:Food:Groceries",
2037                        Amount::new(dec!(50.00), "USD"),
2038                    ))
2039                    .with_posting(Posting::new(
2040                        "Assets:Bank:Checking",
2041                        Amount::new(dec!(-50.00), "USD"),
2042                    )),
2043            ),
2044        ]
2045    }
2046
2047    #[test]
2048    fn test_simple_select() {
2049        let directives = sample_directives();
2050        let mut executor = Executor::new(&directives);
2051
2052        let query = parse("SELECT date, account").unwrap();
2053        let result = executor.execute(&query).unwrap();
2054
2055        assert_eq!(result.columns, vec!["date", "account"]);
2056        assert_eq!(result.len(), 4); // 2 transactions × 2 postings
2057    }
2058
2059    #[test]
2060    fn test_where_clause() {
2061        let directives = sample_directives();
2062        let mut executor = Executor::new(&directives);
2063
2064        let query = parse("SELECT account WHERE account ~ \"Expenses:\"").unwrap();
2065        let result = executor.execute(&query).unwrap();
2066
2067        assert_eq!(result.len(), 2); // Only expense postings
2068    }
2069
2070    #[test]
2071    fn test_balances() {
2072        let directives = sample_directives();
2073        let mut executor = Executor::new(&directives);
2074
2075        let query = parse("BALANCES").unwrap();
2076        let result = executor.execute(&query).unwrap();
2077
2078        assert_eq!(result.columns, vec!["account", "balance"]);
2079        assert!(result.len() >= 3); // At least 3 accounts
2080    }
2081
2082    #[test]
2083    fn test_account_functions() {
2084        let directives = sample_directives();
2085        let mut executor = Executor::new(&directives);
2086
2087        // Test LEAF function
2088        let query = parse("SELECT DISTINCT LEAF(account) WHERE account ~ \"Expenses:\"").unwrap();
2089        let result = executor.execute(&query).unwrap();
2090        assert_eq!(result.len(), 2); // Coffee, Groceries
2091
2092        // Test ROOT function
2093        let query = parse("SELECT DISTINCT ROOT(account)").unwrap();
2094        let result = executor.execute(&query).unwrap();
2095        assert_eq!(result.len(), 2); // Expenses, Assets
2096
2097        // Test PARENT function
2098        let query = parse("SELECT DISTINCT PARENT(account) WHERE account ~ \"Expenses:\"").unwrap();
2099        let result = executor.execute(&query).unwrap();
2100        assert!(!result.is_empty()); // At least "Expenses:Food"
2101    }
2102
2103    #[test]
2104    fn test_min_max_aggregate() {
2105        let directives = sample_directives();
2106        let mut executor = Executor::new(&directives);
2107
2108        // Test MIN(date)
2109        let query = parse("SELECT MIN(date)").unwrap();
2110        let result = executor.execute(&query).unwrap();
2111        assert_eq!(result.len(), 1);
2112        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
2113
2114        // Test MAX(date)
2115        let query = parse("SELECT MAX(date)").unwrap();
2116        let result = executor.execute(&query).unwrap();
2117        assert_eq!(result.len(), 1);
2118        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2119    }
2120
2121    #[test]
2122    fn test_order_by() {
2123        let directives = sample_directives();
2124        let mut executor = Executor::new(&directives);
2125
2126        let query = parse("SELECT date, account ORDER BY date DESC").unwrap();
2127        let result = executor.execute(&query).unwrap();
2128
2129        // Should have all postings, ordered by date descending
2130        assert_eq!(result.len(), 4);
2131        // First row should be from 2024-01-16 (later date)
2132        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2133    }
2134
2135    #[test]
2136    fn test_hash_value_all_variants() {
2137        use rustledger_core::{Cost, Inventory, Position};
2138
2139        // Test that all Value variants can be hashed without panic
2140        let values = vec![
2141            Value::String("test".to_string()),
2142            Value::Number(dec!(123.45)),
2143            Value::Integer(42),
2144            Value::Date(date(2024, 1, 15)),
2145            Value::Boolean(true),
2146            Value::Boolean(false),
2147            Value::Amount(Amount::new(dec!(100), "USD")),
2148            Value::Position(Box::new(Position::simple(Amount::new(dec!(10), "AAPL")))),
2149            Value::Position(Box::new(Position::with_cost(
2150                Amount::new(dec!(10), "AAPL"),
2151                Cost::new(dec!(150), "USD"),
2152            ))),
2153            Value::Inventory(Box::new(Inventory::new())),
2154            Value::StringSet(vec!["tag1".to_string(), "tag2".to_string()]),
2155            Value::Null,
2156        ];
2157
2158        // Hash each value and verify no panic
2159        for value in &values {
2160            let hash = hash_single_value(value);
2161            assert!(hash != 0 || matches!(value, Value::Null));
2162        }
2163
2164        // Test that different values produce different hashes (usually)
2165        let hash1 = hash_single_value(&Value::String("a".to_string()));
2166        let hash2 = hash_single_value(&Value::String("b".to_string()));
2167        assert_ne!(hash1, hash2);
2168
2169        // Test that same values produce same hashes
2170        let hash3 = hash_single_value(&Value::Integer(42));
2171        let hash4 = hash_single_value(&Value::Integer(42));
2172        assert_eq!(hash3, hash4);
2173    }
2174
2175    #[test]
2176    fn test_hash_row_distinct() {
2177        // Test hash_row for DISTINCT deduplication
2178        let row1 = vec![Value::String("a".to_string()), Value::Integer(1)];
2179        let row2 = vec![Value::String("a".to_string()), Value::Integer(1)];
2180        let row3 = vec![Value::String("b".to_string()), Value::Integer(1)];
2181
2182        assert_eq!(hash_row(&row1), hash_row(&row2));
2183        assert_ne!(hash_row(&row1), hash_row(&row3));
2184    }
2185
2186    #[test]
2187    fn test_string_set_hash_order_independent() {
2188        // StringSet hash should be order-independent
2189        let set1 = Value::StringSet(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
2190        let set2 = Value::StringSet(vec!["c".to_string(), "a".to_string(), "b".to_string()]);
2191        let set3 = Value::StringSet(vec!["b".to_string(), "c".to_string(), "a".to_string()]);
2192
2193        let hash1 = hash_single_value(&set1);
2194        let hash2 = hash_single_value(&set2);
2195        let hash3 = hash_single_value(&set3);
2196
2197        assert_eq!(hash1, hash2);
2198        assert_eq!(hash2, hash3);
2199    }
2200
2201    #[test]
2202    fn test_inventory_hash_includes_cost() {
2203        use rustledger_core::{Cost, Inventory, Position};
2204
2205        // Two inventories with same units but different costs should hash differently
2206        let mut inv1 = Inventory::new();
2207        inv1.add(Position::with_cost(
2208            Amount::new(dec!(10), "AAPL"),
2209            Cost::new(dec!(100), "USD"),
2210        ));
2211
2212        let mut inv2 = Inventory::new();
2213        inv2.add(Position::with_cost(
2214            Amount::new(dec!(10), "AAPL"),
2215            Cost::new(dec!(200), "USD"),
2216        ));
2217
2218        let hash1 = hash_single_value(&Value::Inventory(Box::new(inv1)));
2219        let hash2 = hash_single_value(&Value::Inventory(Box::new(inv2)));
2220
2221        assert_ne!(hash1, hash2);
2222    }
2223
2224    #[test]
2225    fn test_distinct_deduplication() {
2226        let directives = sample_directives();
2227        let mut executor = Executor::new(&directives);
2228
2229        // Without DISTINCT - should have duplicates (same flag '*' for all)
2230        let query = parse("SELECT flag").unwrap();
2231        let result = executor.execute(&query).unwrap();
2232        assert_eq!(result.len(), 4); // One per posting, all have flag '*'
2233
2234        // With DISTINCT - should deduplicate
2235        let query = parse("SELECT DISTINCT flag").unwrap();
2236        let result = executor.execute(&query).unwrap();
2237        assert_eq!(result.len(), 1); // Deduplicated to 1 (all '*')
2238    }
2239
2240    #[test]
2241    fn test_limit_clause() {
2242        let directives = sample_directives();
2243        let mut executor = Executor::new(&directives);
2244
2245        // Test LIMIT restricts result count
2246        let query = parse("SELECT date, account LIMIT 2").unwrap();
2247        let result = executor.execute(&query).unwrap();
2248        assert_eq!(result.len(), 2);
2249
2250        // Test LIMIT 0 returns empty
2251        let query = parse("SELECT date LIMIT 0").unwrap();
2252        let result = executor.execute(&query).unwrap();
2253        assert_eq!(result.len(), 0);
2254
2255        // Test LIMIT larger than result set returns all
2256        let query = parse("SELECT date LIMIT 100").unwrap();
2257        let result = executor.execute(&query).unwrap();
2258        assert_eq!(result.len(), 4);
2259    }
2260
2261    #[test]
2262    fn test_group_by_with_count() {
2263        let directives = sample_directives();
2264        let mut executor = Executor::new(&directives);
2265
2266        // Group by account root and count postings
2267        let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
2268        let result = executor.execute(&query).unwrap();
2269
2270        assert_eq!(result.columns.len(), 2);
2271        // Should have 2 groups: Assets and Expenses
2272        assert_eq!(result.len(), 2);
2273    }
2274
2275    #[test]
2276    fn test_count_aggregate() {
2277        let directives = sample_directives();
2278        let mut executor = Executor::new(&directives);
2279
2280        // Count all postings
2281        let query = parse("SELECT COUNT(account)").unwrap();
2282        let result = executor.execute(&query).unwrap();
2283
2284        assert_eq!(result.len(), 1);
2285        assert_eq!(result.rows[0][0], Value::Integer(4));
2286
2287        // Count with GROUP BY
2288        let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
2289        let result = executor.execute(&query).unwrap();
2290        assert_eq!(result.len(), 2); // Assets, Expenses
2291    }
2292
2293    #[test]
2294    fn test_journal_query() {
2295        let directives = sample_directives();
2296        let mut executor = Executor::new(&directives);
2297
2298        // JOURNAL for Expenses account
2299        let query = parse("JOURNAL \"Expenses\"").unwrap();
2300        let result = executor.execute(&query).unwrap();
2301
2302        // Should have columns for journal output
2303        assert!(result.columns.contains(&"account".to_string()));
2304        // Should only show expense account entries
2305        assert_eq!(result.len(), 2);
2306    }
2307
2308    #[test]
2309    fn test_print_query() {
2310        let directives = sample_directives();
2311        let mut executor = Executor::new(&directives);
2312
2313        // PRINT outputs formatted directives
2314        let query = parse("PRINT").unwrap();
2315        let result = executor.execute(&query).unwrap();
2316
2317        // PRINT returns single column "directive" with formatted output
2318        assert_eq!(result.columns.len(), 1);
2319        assert_eq!(result.columns[0], "directive");
2320        // Should have one row per directive (2 transactions)
2321        assert_eq!(result.len(), 2);
2322    }
2323
2324    #[test]
2325    fn test_empty_directives() {
2326        let directives: Vec<Directive> = vec![];
2327        let mut executor = Executor::new(&directives);
2328
2329        // SELECT on empty directives
2330        let query = parse("SELECT date, account").unwrap();
2331        let result = executor.execute(&query).unwrap();
2332        assert!(result.is_empty());
2333
2334        // BALANCES on empty directives
2335        let query = parse("BALANCES").unwrap();
2336        let result = executor.execute(&query).unwrap();
2337        assert!(result.is_empty());
2338    }
2339
2340    #[test]
2341    fn test_comparison_operators() {
2342        let directives = sample_directives();
2343        let mut executor = Executor::new(&directives);
2344
2345        // Less than comparison on dates
2346        let query = parse("SELECT date WHERE date < 2024-01-16").unwrap();
2347        let result = executor.execute(&query).unwrap();
2348        assert_eq!(result.len(), 2); // First transaction postings
2349
2350        // Greater than comparison on year
2351        let query = parse("SELECT date WHERE year > 2023").unwrap();
2352        let result = executor.execute(&query).unwrap();
2353        assert_eq!(result.len(), 4); // All 2024 postings
2354
2355        // Equality comparison on day
2356        let query = parse("SELECT account WHERE day = 15").unwrap();
2357        let result = executor.execute(&query).unwrap();
2358        assert_eq!(result.len(), 2); // First transaction postings (Jan 15)
2359    }
2360
2361    #[test]
2362    fn test_logical_operators() {
2363        let directives = sample_directives();
2364        let mut executor = Executor::new(&directives);
2365
2366        // AND operator
2367        let query = parse("SELECT account WHERE account ~ \"Expenses\" AND day > 14").unwrap();
2368        let result = executor.execute(&query).unwrap();
2369        assert_eq!(result.len(), 2); // Expense postings on Jan 15 and 16
2370
2371        // OR operator
2372        let query = parse("SELECT account WHERE day = 15 OR day = 16").unwrap();
2373        let result = executor.execute(&query).unwrap();
2374        assert_eq!(result.len(), 4); // All postings (both days)
2375    }
2376
2377    #[test]
2378    fn test_arithmetic_expressions() {
2379        let directives = sample_directives();
2380        let mut executor = Executor::new(&directives);
2381
2382        // Negation on integer
2383        let query = parse("SELECT -day WHERE day = 15").unwrap();
2384        let result = executor.execute(&query).unwrap();
2385        assert_eq!(result.len(), 2);
2386        // Day 15 negated should be -15
2387        for row in &result.rows {
2388            if let Value::Integer(n) = &row[0] {
2389                assert_eq!(*n, -15);
2390            }
2391        }
2392    }
2393
2394    #[test]
2395    fn test_first_last_aggregates() {
2396        let directives = sample_directives();
2397        let mut executor = Executor::new(&directives);
2398
2399        // FIRST aggregate
2400        let query = parse("SELECT FIRST(date)").unwrap();
2401        let result = executor.execute(&query).unwrap();
2402        assert_eq!(result.len(), 1);
2403        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
2404
2405        // LAST aggregate
2406        let query = parse("SELECT LAST(date)").unwrap();
2407        let result = executor.execute(&query).unwrap();
2408        assert_eq!(result.len(), 1);
2409        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
2410    }
2411
2412    #[test]
2413    fn test_wildcard_select() {
2414        let directives = sample_directives();
2415        let mut executor = Executor::new(&directives);
2416
2417        // SELECT * returns all postings with expanded column names
2418        let query = parse("SELECT *").unwrap();
2419        let result = executor.execute(&query).unwrap();
2420
2421        // Wildcard expands to default column names (fixes issue #577)
2422        assert_eq!(
2423            result.columns,
2424            vec!["date", "flag", "payee", "narration", "account", "position"]
2425        );
2426        // Each row has expanded values matching the column names
2427        assert_eq!(result.len(), 4);
2428        assert_eq!(result.rows[0].len(), 6);
2429    }
2430
2431    #[test]
2432    fn test_wildcard_alias_rejected() {
2433        let directives = sample_directives();
2434        let mut executor = Executor::new(&directives);
2435
2436        // SELECT * AS alias should fail - wildcard expands to multiple columns
2437        let query = parse("SELECT * AS data").unwrap();
2438        let result = executor.execute(&query);
2439
2440        assert!(result.is_err());
2441        let err = result.unwrap_err();
2442        assert!(
2443            err.to_string().contains("Cannot alias wildcard"),
2444            "Expected wildcard alias error, got: {err}"
2445        );
2446    }
2447
2448    #[test]
2449    fn test_query_result_methods() {
2450        let mut result = QueryResult::new(vec!["col1".to_string(), "col2".to_string()]);
2451
2452        // Initially empty
2453        assert!(result.is_empty());
2454        assert_eq!(result.len(), 0);
2455
2456        // Add rows
2457        result.add_row(vec![Value::Integer(1), Value::String("a".to_string())]);
2458        assert!(!result.is_empty());
2459        assert_eq!(result.len(), 1);
2460
2461        result.add_row(vec![Value::Integer(2), Value::String("b".to_string())]);
2462        assert_eq!(result.len(), 2);
2463    }
2464
2465    #[test]
2466    fn test_type_cast_functions() {
2467        let directives = sample_directives();
2468        let mut executor = Executor::new(&directives);
2469
2470        // Test INT function
2471        let query = parse("SELECT int(5.7)").unwrap();
2472        let result = executor.execute(&query).unwrap();
2473        assert_eq!(result.rows[0][0], Value::Integer(5));
2474
2475        // Test DECIMAL function
2476        let query = parse("SELECT decimal(42)").unwrap();
2477        let result = executor.execute(&query).unwrap();
2478        assert_eq!(result.rows[0][0], Value::Number(dec!(42)));
2479
2480        // Test STR function
2481        let query = parse("SELECT str(123)").unwrap();
2482        let result = executor.execute(&query).unwrap();
2483        assert_eq!(result.rows[0][0], Value::String("123".to_string()));
2484
2485        // Test BOOL function
2486        let query = parse("SELECT bool(1)").unwrap();
2487        let result = executor.execute(&query).unwrap();
2488        assert_eq!(result.rows[0][0], Value::Boolean(true));
2489
2490        let query = parse("SELECT bool(0)").unwrap();
2491        let result = executor.execute(&query).unwrap();
2492        assert_eq!(result.rows[0][0], Value::Boolean(false));
2493    }
2494
2495    /// Test that type casting functions work in aggregate context (issue #630).
2496    #[test]
2497    fn test_type_casting_in_aggregate_context() {
2498        let txn1 = Transaction::new(date(2024, 1, 15), "Item 1")
2499            .with_flag('*')
2500            .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(10), "USD")))
2501            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-10), "USD")));
2502
2503        let txn2 = Transaction::new(date(2024, 1, 16), "Item 2")
2504            .with_flag('*')
2505            .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(20), "USD")))
2506            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-20), "USD")));
2507
2508        let directives = vec![Directive::Transaction(txn1), Directive::Transaction(txn2)];
2509        let mut executor = Executor::new(&directives);
2510
2511        // Test STR wrapping an aggregate - this was the issue in #630
2512        // Each account has 2 postings summed: Expenses:Food = 30, Assets:Cash = -30
2513        let query =
2514            parse("SELECT account, str(sum(number(units))) GROUP BY account ORDER BY account")
2515                .unwrap();
2516        let result = executor.execute(&query).unwrap();
2517        assert_eq!(result.rows.len(), 2);
2518        // Verify actual string values
2519        assert_eq!(result.rows[0][0], Value::String("Assets:Cash".to_string()));
2520        assert_eq!(result.rows[0][1], Value::String("-30".to_string()));
2521        assert_eq!(
2522            result.rows[1][0],
2523            Value::String("Expenses:Food".to_string())
2524        );
2525        assert_eq!(result.rows[1][1], Value::String("30".to_string()));
2526
2527        // Test INT in aggregate context - verify truncation works
2528        let query =
2529            parse("SELECT account, int(sum(number(units))) GROUP BY account ORDER BY account")
2530                .unwrap();
2531        let result = executor.execute(&query).unwrap();
2532        assert_eq!(result.rows[0][1], Value::Integer(-30));
2533        assert_eq!(result.rows[1][1], Value::Integer(30));
2534
2535        // Test DECIMAL in aggregate context - verify count conversion
2536        let query =
2537            parse("SELECT account, decimal(count(*)) GROUP BY account ORDER BY account").unwrap();
2538        let result = executor.execute(&query).unwrap();
2539        assert_eq!(result.rows[0][1], Value::Number(dec!(2))); // 2 postings per account
2540        assert_eq!(result.rows[1][1], Value::Number(dec!(2)));
2541
2542        // Test BOOL in aggregate context - count > 0 should be true
2543        let query =
2544            parse("SELECT account, bool(count(*)) GROUP BY account ORDER BY account").unwrap();
2545        let result = executor.execute(&query).unwrap();
2546        assert_eq!(result.rows[0][1], Value::Boolean(true));
2547        assert_eq!(result.rows[1][1], Value::Boolean(true));
2548    }
2549
2550    /// Test INT truncation behavior with decimals.
2551    #[test]
2552    fn test_int_truncation() {
2553        let directives = sample_directives();
2554        let mut executor = Executor::new(&directives);
2555
2556        // Test INT truncates toward zero (not floor/ceil)
2557        let query = parse("SELECT int(5.7)").unwrap();
2558        let result = executor.execute(&query).unwrap();
2559        assert_eq!(result.rows[0][0], Value::Integer(5));
2560
2561        let query = parse("SELECT int(-5.7)").unwrap();
2562        let result = executor.execute(&query).unwrap();
2563        assert_eq!(result.rows[0][0], Value::Integer(-5));
2564
2565        let query = parse("SELECT int(0.999)").unwrap();
2566        let result = executor.execute(&query).unwrap();
2567        assert_eq!(result.rows[0][0], Value::Integer(0));
2568    }
2569
2570    /// Test type casting error cases.
2571    #[test]
2572    fn test_type_casting_errors() {
2573        let directives = sample_directives();
2574        let mut executor = Executor::new(&directives);
2575
2576        // INT with non-numeric string should error
2577        let query = parse("SELECT int('not-a-number')").unwrap();
2578        let result = executor.execute(&query);
2579        assert!(result.is_err());
2580        assert!(
2581            result
2582                .unwrap_err()
2583                .to_string()
2584                .contains("cannot parse 'not-a-number'")
2585        );
2586
2587        // DECIMAL with invalid string should error
2588        let query = parse("SELECT decimal('invalid')").unwrap();
2589        let result = executor.execute(&query);
2590        assert!(result.is_err());
2591        assert!(result.unwrap_err().to_string().contains("cannot parse"));
2592
2593        // BOOL with unrecognized string should error
2594        let query = parse("SELECT bool('maybe')").unwrap();
2595        let result = executor.execute(&query);
2596        assert!(result.is_err());
2597        assert!(result.unwrap_err().to_string().contains("cannot parse"));
2598    }
2599
2600    #[test]
2601    fn test_meta_functions() {
2602        // Create directives with metadata
2603        let mut txn_meta: Metadata = Metadata::default();
2604        txn_meta.insert(
2605            "source".to_string(),
2606            MetaValue::String("bank_import".to_string()),
2607        );
2608
2609        let mut posting_meta: Metadata = Metadata::default();
2610        posting_meta.insert(
2611            "category".to_string(),
2612            MetaValue::String("food".to_string()),
2613        );
2614
2615        let txn = Transaction {
2616            date: date(2024, 1, 15),
2617            flag: '*',
2618            payee: Some("Coffee Shop".into()),
2619            narration: "Coffee".into(),
2620            tags: vec![],
2621            links: vec![],
2622            meta: txn_meta,
2623            postings: vec![
2624                Posting {
2625                    account: "Expenses:Food".into(),
2626                    units: Some(rustledger_core::IncompleteAmount::Complete(Amount::new(
2627                        dec!(5),
2628                        "USD",
2629                    ))),
2630                    cost: None,
2631                    price: None,
2632                    flag: None,
2633                    meta: posting_meta,
2634                    comments: Vec::new(),
2635                    trailing_comments: Vec::new(),
2636                },
2637                Posting::new("Assets:Cash", Amount::new(dec!(-5), "USD")),
2638            ],
2639            trailing_comments: Vec::new(),
2640        };
2641
2642        let directives = vec![Directive::Transaction(txn)];
2643        let mut executor = Executor::new(&directives);
2644
2645        // Test META (posting metadata)
2646        let query = parse("SELECT meta('category') WHERE account ~ 'Expenses'").unwrap();
2647        let result = executor.execute(&query).unwrap();
2648        assert_eq!(result.rows[0][0], Value::String("food".to_string()));
2649
2650        // Test ENTRY_META (transaction metadata)
2651        let query = parse("SELECT entry_meta('source') WHERE account ~ 'Expenses'").unwrap();
2652        let result = executor.execute(&query).unwrap();
2653        assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
2654
2655        // Test ANY_META (falls back to txn meta when posting meta missing)
2656        let query = parse("SELECT any_meta('source') WHERE account ~ 'Expenses'").unwrap();
2657        let result = executor.execute(&query).unwrap();
2658        assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
2659
2660        // Test ANY_META (uses posting meta when available)
2661        let query = parse("SELECT any_meta('category') WHERE account ~ 'Expenses'").unwrap();
2662        let result = executor.execute(&query).unwrap();
2663        assert_eq!(result.rows[0][0], Value::String("food".to_string()));
2664
2665        // Test missing meta returns NULL
2666        let query = parse("SELECT meta('nonexistent') WHERE account ~ 'Expenses'").unwrap();
2667        let result = executor.execute(&query).unwrap();
2668        assert_eq!(result.rows[0][0], Value::Null);
2669    }
2670
2671    #[test]
2672    fn test_convert_function() {
2673        // Create directives with price information
2674        let price = rustledger_core::Price {
2675            date: date(2024, 1, 1),
2676            currency: "EUR".into(),
2677            amount: Amount::new(dec!(1.10), "USD"),
2678            meta: Metadata::default(),
2679        };
2680
2681        let txn = Transaction::new(date(2024, 1, 15), "Test")
2682            .with_flag('*')
2683            .with_posting(Posting::new("Assets:Euro", Amount::new(dec!(100), "EUR")))
2684            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-110), "USD")));
2685
2686        let directives = vec![Directive::Price(price), Directive::Transaction(txn)];
2687        let mut executor = Executor::new(&directives);
2688
2689        // Test CONVERT with amount
2690        let query = parse("SELECT convert(position, 'USD') WHERE account ~ 'Euro'").unwrap();
2691        let result = executor.execute(&query).unwrap();
2692        // 100 EUR × 1.10 = 110 USD
2693        match &result.rows[0][0] {
2694            Value::Amount(a) => {
2695                assert_eq!(a.number, dec!(110));
2696                assert_eq!(a.currency.as_ref(), "USD");
2697            }
2698            _ => panic!("Expected Amount, got {:?}", result.rows[0][0]),
2699        }
2700    }
2701
2702    #[test]
2703    fn test_date_functions() {
2704        let directives = sample_directives();
2705        let mut executor = Executor::new(&directives);
2706
2707        // Test DATE construction from string
2708        let query = parse("SELECT date('2024-06-15')").unwrap();
2709        let result = executor.execute(&query).unwrap();
2710        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
2711
2712        // Test DATE construction from components
2713        let query = parse("SELECT date(2024, 6, 15)").unwrap();
2714        let result = executor.execute(&query).unwrap();
2715        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
2716
2717        // Test DATE_DIFF
2718        let query = parse("SELECT date_diff(date('2024-01-20'), date('2024-01-15'))").unwrap();
2719        let result = executor.execute(&query).unwrap();
2720        assert_eq!(result.rows[0][0], Value::Integer(5));
2721
2722        // Test DATE_ADD
2723        let query = parse("SELECT date_add(date('2024-01-15'), 10)").unwrap();
2724        let result = executor.execute(&query).unwrap();
2725        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 25)));
2726
2727        // Test DATE_TRUNC year
2728        let query = parse("SELECT date_trunc('year', date('2024-06-15'))").unwrap();
2729        let result = executor.execute(&query).unwrap();
2730        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1)));
2731
2732        // Test DATE_TRUNC month
2733        let query = parse("SELECT date_trunc('month', date('2024-06-15'))").unwrap();
2734        let result = executor.execute(&query).unwrap();
2735        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1)));
2736
2737        // Test DATE_PART
2738        let query = parse("SELECT date_part('month', date('2024-06-15'))").unwrap();
2739        let result = executor.execute(&query).unwrap();
2740        assert_eq!(result.rows[0][0], Value::Integer(6));
2741
2742        // Test PARSE_DATE with custom format
2743        let query = parse("SELECT parse_date('15/06/2024', '%d/%m/%Y')").unwrap();
2744        let result = executor.execute(&query).unwrap();
2745        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
2746
2747        // Test DATE_BIN with day stride
2748        let query =
2749            parse("SELECT date_bin('7 days', date('2024-01-15'), date('2024-01-01'))").unwrap();
2750        let result = executor.execute(&query).unwrap();
2751        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); // 15 is 14 days from 1, so bucket starts at 15
2752
2753        // Test DATE_BIN with week stride
2754        let query =
2755            parse("SELECT date_bin('1 week', date('2024-01-20'), date('2024-01-01'))").unwrap();
2756        let result = executor.execute(&query).unwrap();
2757        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); // Week 3 starts at day 15
2758
2759        // Test DATE_BIN with month stride
2760        let query =
2761            parse("SELECT date_bin('1 month', date('2024-06-15'), date('2024-01-01'))").unwrap();
2762        let result = executor.execute(&query).unwrap();
2763        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1))); // June bucket
2764
2765        // Test DATE_BIN with year stride
2766        let query =
2767            parse("SELECT date_bin('1 year', date('2024-06-15'), date('2020-01-01'))").unwrap();
2768        let result = executor.execute(&query).unwrap();
2769        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1))); // 2024 bucket
2770    }
2771
2772    #[test]
2773    fn test_string_functions_extended() {
2774        let directives = sample_directives();
2775        let mut executor = Executor::new(&directives);
2776
2777        // Test GREP - returns matched portion
2778        let query = parse("SELECT grep('Ex[a-z]+', 'Hello Expenses World')").unwrap();
2779        let result = executor.execute(&query).unwrap();
2780        assert_eq!(result.rows[0][0], Value::String("Expenses".to_string()));
2781
2782        // Test GREP - no match returns NULL
2783        let query = parse("SELECT grep('xyz', 'Hello World')").unwrap();
2784        let result = executor.execute(&query).unwrap();
2785        assert_eq!(result.rows[0][0], Value::Null);
2786
2787        // Test GREPN - capture group (using [0-9] since \d is not escaped in BQL strings)
2788        let query = parse("SELECT grepn('([0-9]+)-([0-9]+)', '2024-01', 1)").unwrap();
2789        let result = executor.execute(&query).unwrap();
2790        assert_eq!(result.rows[0][0], Value::String("2024".to_string()));
2791
2792        // Test SUBST - substitution
2793        let query = parse("SELECT subst('-', '/', '2024-01-15')").unwrap();
2794        let result = executor.execute(&query).unwrap();
2795        assert_eq!(result.rows[0][0], Value::String("2024/01/15".to_string()));
2796
2797        // Test SPLITCOMP
2798        let query = parse("SELECT splitcomp('a:b:c', ':', 1)").unwrap();
2799        let result = executor.execute(&query).unwrap();
2800        assert_eq!(result.rows[0][0], Value::String("b".to_string()));
2801
2802        // Test JOINSTR
2803        let query = parse("SELECT joinstr('hello', 'world')").unwrap();
2804        let result = executor.execute(&query).unwrap();
2805        assert_eq!(result.rows[0][0], Value::String("hello, world".to_string()));
2806
2807        // Test MAXWIDTH - no truncation needed
2808        let query = parse("SELECT maxwidth('hello', 10)").unwrap();
2809        let result = executor.execute(&query).unwrap();
2810        assert_eq!(result.rows[0][0], Value::String("hello".to_string()));
2811
2812        // Test MAXWIDTH - truncation with ellipsis
2813        let query = parse("SELECT maxwidth('hello world', 8)").unwrap();
2814        let result = executor.execute(&query).unwrap();
2815        assert_eq!(result.rows[0][0], Value::String("hello...".to_string()));
2816    }
2817
2818    #[test]
2819    fn test_inventory_functions() {
2820        let directives = sample_directives();
2821        let mut executor = Executor::new(&directives);
2822
2823        // Test EMPTY on sum of position (sum across all postings may cancel out)
2824        // Use a filter to get non-canceling positions
2825        let query = parse("SELECT empty(sum(position)) WHERE account ~ 'Assets'").unwrap();
2826        let result = executor.execute(&query).unwrap();
2827        // Should be a boolean (the actual value depends on sample data)
2828        assert!(matches!(result.rows[0][0], Value::Boolean(_)));
2829
2830        // Test EMPTY with null returns true
2831        // (null handling is already tested in the function)
2832
2833        // Test POSSIGN with debit account (Assets) - no sign change
2834        let query = parse("SELECT possign(100, 'Assets:Bank')").unwrap();
2835        let result = executor.execute(&query).unwrap();
2836        assert_eq!(
2837            result.rows[0][0],
2838            Value::Number(rust_decimal::Decimal::from(100))
2839        );
2840
2841        // Test POSSIGN with credit account (Income) - sign is negated
2842        let query = parse("SELECT possign(100, 'Income:Salary')").unwrap();
2843        let result = executor.execute(&query).unwrap();
2844        assert_eq!(
2845            result.rows[0][0],
2846            Value::Number(rust_decimal::Decimal::from(-100))
2847        );
2848
2849        // Test POSSIGN with Expenses (debit normal) - no sign change
2850        let query = parse("SELECT possign(50, 'Expenses:Food')").unwrap();
2851        let result = executor.execute(&query).unwrap();
2852        assert_eq!(
2853            result.rows[0][0],
2854            Value::Number(rust_decimal::Decimal::from(50))
2855        );
2856
2857        // Test POSSIGN with Liabilities (credit normal) - sign is negated
2858        let query = parse("SELECT possign(200, 'Liabilities:CreditCard')").unwrap();
2859        let result = executor.execute(&query).unwrap();
2860        assert_eq!(
2861            result.rows[0][0],
2862            Value::Number(rust_decimal::Decimal::from(-200))
2863        );
2864
2865        // Test POSSIGN with Equity (credit normal) - sign is negated
2866        let query = parse("SELECT possign(300, 'Equity:OpeningBalances')").unwrap();
2867        let result = executor.execute(&query).unwrap();
2868        assert_eq!(
2869            result.rows[0][0],
2870            Value::Number(rust_decimal::Decimal::from(-300))
2871        );
2872    }
2873
2874    #[test]
2875    fn test_account_meta_functions() {
2876        use rustledger_core::{Close, Metadata, Open};
2877
2878        // Create directives with Open/Close
2879        let mut open_meta = Metadata::default();
2880        open_meta.insert(
2881            "category".to_string(),
2882            MetaValue::String("checking".to_string()),
2883        );
2884
2885        let directives = vec![
2886            Directive::Open(Open {
2887                date: date(2020, 1, 1),
2888                account: "Assets:Bank:Checking".into(),
2889                currencies: vec![],
2890                booking: None,
2891                meta: open_meta,
2892            }),
2893            Directive::Open(Open::new(date(2020, 2, 15), "Expenses:Food")),
2894            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank:Checking")),
2895            // A transaction to have postings for the query context
2896            Directive::Transaction(
2897                Transaction::new(date(2024, 1, 15), "Coffee")
2898                    .with_posting(Posting::new(
2899                        "Expenses:Food",
2900                        Amount::new(dec!(5.00), "USD"),
2901                    ))
2902                    .with_posting(Posting::new(
2903                        "Assets:Bank:Checking",
2904                        Amount::new(dec!(-5.00), "USD"),
2905                    )),
2906            ),
2907        ];
2908
2909        let mut executor = Executor::new(&directives);
2910
2911        // Test OPEN_DATE - account with open directive
2912        let query = parse("SELECT open_date('Assets:Bank:Checking')").unwrap();
2913        let result = executor.execute(&query).unwrap();
2914        assert_eq!(result.rows[0][0], Value::Date(date(2020, 1, 1)));
2915
2916        // Test CLOSE_DATE - account with close directive
2917        let query = parse("SELECT close_date('Assets:Bank:Checking')").unwrap();
2918        let result = executor.execute(&query).unwrap();
2919        assert_eq!(result.rows[0][0], Value::Date(date(2024, 12, 31)));
2920
2921        // Test OPEN_DATE - account without close directive
2922        let query = parse("SELECT close_date('Expenses:Food')").unwrap();
2923        let result = executor.execute(&query).unwrap();
2924        assert_eq!(result.rows[0][0], Value::Null);
2925
2926        // Test OPEN_META - get metadata from open directive
2927        let query = parse("SELECT open_meta('Assets:Bank:Checking', 'category')").unwrap();
2928        let result = executor.execute(&query).unwrap();
2929        assert_eq!(result.rows[0][0], Value::String("checking".to_string()));
2930
2931        // Test OPEN_META - non-existent key
2932        let query = parse("SELECT open_meta('Assets:Bank:Checking', 'nonexistent')").unwrap();
2933        let result = executor.execute(&query).unwrap();
2934        assert_eq!(result.rows[0][0], Value::Null);
2935
2936        // Test with non-existent account
2937        let query = parse("SELECT open_date('NonExistent:Account')").unwrap();
2938        let result = executor.execute(&query).unwrap();
2939        assert_eq!(result.rows[0][0], Value::Null);
2940    }
2941
2942    #[test]
2943    fn test_source_location_columns_return_null_without_sources() {
2944        // When using the regular constructor (without source location support),
2945        // the filename, lineno, and location columns should return Null
2946        let directives = vec![Directive::Transaction(Transaction {
2947            date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2948            flag: '*',
2949            payee: Some("Test".into()),
2950            narration: "Test transaction".into(),
2951            tags: vec![],
2952            links: vec![],
2953            meta: Metadata::default(),
2954            postings: vec![
2955                Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
2956                Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
2957            ],
2958            trailing_comments: Vec::new(),
2959        })];
2960
2961        let mut executor = Executor::new(&directives);
2962
2963        // Test filename column returns Null
2964        let query = parse("SELECT filename").unwrap();
2965        let result = executor.execute(&query).unwrap();
2966        assert_eq!(result.rows[0][0], Value::Null);
2967
2968        // Test lineno column returns Null
2969        let query = parse("SELECT lineno").unwrap();
2970        let result = executor.execute(&query).unwrap();
2971        assert_eq!(result.rows[0][0], Value::Null);
2972
2973        // Test location column returns Null
2974        let query = parse("SELECT location").unwrap();
2975        let result = executor.execute(&query).unwrap();
2976        assert_eq!(result.rows[0][0], Value::Null);
2977    }
2978
2979    #[test]
2980    fn test_source_location_columns_with_sources() {
2981        use rustledger_loader::SourceMap;
2982        use rustledger_parser::Spanned;
2983        use std::sync::Arc;
2984
2985        // Create a source map with a test file
2986        let mut source_map = SourceMap::new();
2987        let source: Arc<str> =
2988            "2024-01-15 * \"Test\"\n  Assets:Bank  100 USD\n  Expenses:Food".into();
2989        let file_id = source_map.add_file("test.beancount".into(), source);
2990
2991        // Create a spanned directive
2992        let txn = Transaction {
2993            date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
2994            flag: '*',
2995            payee: Some("Test".into()),
2996            narration: "Test transaction".into(),
2997            tags: vec![],
2998            links: vec![],
2999            meta: Metadata::default(),
3000            postings: vec![
3001                Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
3002                Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
3003            ],
3004            trailing_comments: Vec::new(),
3005        };
3006
3007        let spanned_directives = vec![Spanned {
3008            value: Directive::Transaction(txn),
3009            span: rustledger_parser::Span { start: 0, end: 50 },
3010            file_id: file_id as u16,
3011        }];
3012
3013        let mut executor = Executor::new_with_sources(&spanned_directives, &source_map);
3014
3015        // Test filename column returns the file path
3016        let query = parse("SELECT filename").unwrap();
3017        let result = executor.execute(&query).unwrap();
3018        assert_eq!(
3019            result.rows[0][0],
3020            Value::String("test.beancount".to_string())
3021        );
3022
3023        // Test lineno column returns line number
3024        let query = parse("SELECT lineno").unwrap();
3025        let result = executor.execute(&query).unwrap();
3026        assert_eq!(result.rows[0][0], Value::Integer(1));
3027
3028        // Test location column returns formatted location
3029        let query = parse("SELECT location").unwrap();
3030        let result = executor.execute(&query).unwrap();
3031        assert_eq!(
3032            result.rows[0][0],
3033            Value::String("test.beancount:1".to_string())
3034        );
3035    }
3036
3037    #[test]
3038    fn test_interval_function() {
3039        let directives = sample_directives();
3040        let mut executor = Executor::new(&directives);
3041
3042        // Test interval with single argument (unit only, count=1)
3043        let query = parse("SELECT interval('month')").unwrap();
3044        let result = executor.execute(&query).unwrap();
3045        assert_eq!(
3046            result.rows[0][0],
3047            Value::Interval(Interval::new(1, IntervalUnit::Month))
3048        );
3049
3050        // Test interval with two arguments (count, unit)
3051        let query = parse("SELECT interval(3, 'day')").unwrap();
3052        let result = executor.execute(&query).unwrap();
3053        assert_eq!(
3054            result.rows[0][0],
3055            Value::Interval(Interval::new(3, IntervalUnit::Day))
3056        );
3057
3058        // Test interval with negative count
3059        let query = parse("SELECT interval(-2, 'week')").unwrap();
3060        let result = executor.execute(&query).unwrap();
3061        assert_eq!(
3062            result.rows[0][0],
3063            Value::Interval(Interval::new(-2, IntervalUnit::Week))
3064        );
3065    }
3066
3067    #[test]
3068    fn test_date_add_with_interval() {
3069        let directives = sample_directives();
3070        let mut executor = Executor::new(&directives);
3071
3072        // Test date_add with interval
3073        let query = parse("SELECT date_add(date(2024, 1, 15), interval(1, 'month'))").unwrap();
3074        let result = executor.execute(&query).unwrap();
3075        assert_eq!(
3076            result.rows[0][0],
3077            Value::Date(NaiveDate::from_ymd_opt(2024, 2, 15).unwrap())
3078        );
3079
3080        // Test date + interval using binary operator
3081        let query = parse("SELECT date(2024, 1, 15) + interval(1, 'year')").unwrap();
3082        let result = executor.execute(&query).unwrap();
3083        assert_eq!(
3084            result.rows[0][0],
3085            Value::Date(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
3086        );
3087
3088        // Test date - interval
3089        let query = parse("SELECT date(2024, 3, 15) - interval(2, 'month')").unwrap();
3090        let result = executor.execute(&query).unwrap();
3091        assert_eq!(
3092            result.rows[0][0],
3093            Value::Date(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap())
3094        );
3095    }
3096}