Skip to main content

rustledger_query/executor/
mod.rs

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