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