Skip to main content

rustledger_query/executor/
mod.rs

1//! BQL Query Executor.
2//!
3//! Executes parsed BQL queries against a set of Beancount directives.
4
5mod functions;
6mod types;
7
8use types::AccountInfo;
9pub use types::{
10    Interval, IntervalUnit, PostingContext, QueryResult, Row, SourceLocation, Table, Value,
11    WindowContext,
12};
13
14use parking_lot::RwLock;
15
16use rustc_hash::FxHashMap;
17
18use regex::{Regex, RegexBuilder};
19use rust_decimal::Decimal;
20use rustledger_core::{Amount, Directive, Inventory, NaiveDate, Position};
21#[cfg(test)]
22use rustledger_core::{MetaValue, Transaction};
23use rustledger_loader::SourceMap;
24use rustledger_parser::Spanned;
25use std::sync::Arc;
26
27use crate::ast::{Expr, FromClause, FunctionCall, Query, SelectQuery, Target};
28use crate::error::QueryError;
29
30/// Compute a posting's `weight` — the cost-converted amount used for
31/// transaction balancing.
32///
33/// Rules (matching Beancount):
34/// - Cost annotation present and resolvable: `units × cost_per_unit` in
35///   the cost currency. `CostSpec::resolve` handles `{{total ...}}` by
36///   dividing the total by `|units|`, so callers don't need to special-case
37///   that shape.
38/// - `@` per-unit price: `units × price` in the price currency. Sign
39///   carries through `units` naturally.
40/// - `@@` total price: `sign(units) × total` in the price currency. The
41///   `@@` amount is written as a positive magnitude in the source, so
42///   credit-side postings need an explicit sign flip — without it,
43///   `weight` returns `+T` where bean-query returns `−T` and the
44///   transaction can't balance against the matching cash side
45///   (issue #1052).
46/// - Otherwise (or if a cost spec was present but couldn't resolve and
47///   no usable price annotation either): `units` as-is.
48///
49/// Returns `Value::Null` for postings without resolved units. Used by
50/// both [`Executor::build_postings_table`] (the `#postings` table
51/// builder) and [`Executor::evaluate_column`] (the default-FROM column
52/// accessor) so the two paths can't drift again.
53pub(super) fn compute_posting_weight(
54    posting: &rustledger_core::Posting,
55    txn_date: NaiveDate,
56) -> Value {
57    let Some(units) = posting.amount() else {
58        return Value::Null;
59    };
60    if let Some(cost_spec) = &posting.cost
61        && let Some(cost) = cost_spec.resolve(units.number, txn_date)
62    {
63        return Value::Amount(Amount::new(units.number * cost.number, cost.currency));
64    }
65    if let Some(price_ann) = &posting.price
66        && let Some(price_amt) = price_ann.amount()
67    {
68        return if price_ann.is_unit() {
69            Value::Amount(Amount::new(
70                units.number * price_amt.number,
71                price_amt.currency.clone(),
72            ))
73        } else {
74            let signed = if units.number.is_sign_negative() {
75                -price_amt.number
76            } else {
77                price_amt.number
78            };
79            Value::Amount(Amount::new(signed, price_amt.currency.clone()))
80        };
81    }
82    Value::Amount(units.clone())
83}
84
85/// Query executor.
86pub struct Executor<'a> {
87    /// All directives to query over.
88    directives: &'a [Directive],
89    /// Spanned directives (optional, for source location support).
90    spanned_directives: Option<&'a [Spanned<Directive>]>,
91    /// Price database for `VALUE()` conversions.
92    price_db: crate::price::PriceDatabase,
93    /// Target currency for `VALUE()` conversions.
94    target_currency: Option<String>,
95    /// Query date for price lookups (defaults to today).
96    query_date: rustledger_core::NaiveDate,
97    /// Cache for compiled regex patterns (`RwLock` for thread-safe parallel execution).
98    // `Arc<Regex>`, not `Regex`: the `~`/`!~` operators look the regex up per
99    // row, and cloning a `Regex` gives the clone a fresh, empty lazy-DFA cache
100    // pool — so every row rebuilt the DFA from scratch (`Lazy::init_cache` was
101    // ~18% of a regex-filter query). Cloning the `Arc` shares the one regex (and
102    // its cache), so the DFA is built once per query, not once per row.
103    regex_cache: RwLock<FxHashMap<String, Option<Arc<Regex>>>>,
104    /// Account info cache from Open/Close directives.
105    account_info: FxHashMap<String, AccountInfo>,
106    /// Source locations for directives (indexed by directive index).
107    source_locations: Option<Vec<SourceLocation>>,
108    /// The source map, kept so per-posting source locations (the `lineno` /
109    /// `filename` / `location` columns on posting rows) can be resolved from
110    /// each posting's own span, not just the enclosing directive's.
111    source_map: Option<&'a SourceMap>,
112    /// In-memory tables created by CREATE TABLE.
113    tables: FxHashMap<String, Table>,
114}
115
116// Sub-modules for focused functionality
117mod aggregation;
118mod evaluation;
119mod execution;
120mod operators;
121mod sort;
122mod system_tables;
123mod window;
124
125/// Default column names for `SELECT *` wildcard expansion.
126/// This must match the order of values pushed in `evaluate_row()`.
127pub const WILDCARD_COLUMNS: &[&str] =
128    &["date", "flag", "payee", "narration", "account", "position"];
129
130/// Result of [`Executor::scan_postings`]: the per-posting contexts plus the
131/// final per-account running balances. `account_balances` is only meaningful
132/// when the scan was asked for it (`needs_account_balance`); it honors the same
133/// `FROM` window (`open_on`/`close_on`) as the rest of the scan, so consumers
134/// like `BALANCES` get the windowed per-account totals for free.
135pub(crate) struct PostingScan<'a> {
136    pub(crate) postings: Vec<PostingContext<'a>>,
137    pub(crate) account_balances: FxHashMap<rustledger_core::Account, Inventory>,
138}
139
140impl<'a> Executor<'a> {
141    /// Create a new executor with the given directives.
142    pub fn new(directives: &'a [Directive]) -> Self {
143        let price_db = crate::price::PriceDatabase::from_directives(directives);
144
145        // Build account info cache from Open/Close directives
146        let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
147        for directive in directives {
148            match directive {
149                Directive::Open(open) => {
150                    let account = open.account.to_string();
151                    let info = account_info.entry(account).or_default();
152                    info.open_date = Some(open.date);
153                    info.open_meta.clone_from(&open.meta);
154                    info.booking.clone_from(&open.booking);
155                }
156                Directive::Close(close) => {
157                    let account = close.account.to_string();
158                    let info = account_info.entry(account).or_default();
159                    info.close_date = Some(close.date);
160                }
161                _ => {}
162            }
163        }
164
165        Self {
166            directives,
167            spanned_directives: None,
168            price_db,
169            target_currency: None,
170            query_date: jiff::Zoned::now().date(),
171            regex_cache: RwLock::new(FxHashMap::default()),
172            account_info,
173            source_locations: None,
174            source_map: None,
175            tables: FxHashMap::default(),
176        }
177    }
178
179    /// Create a new executor with source location support.
180    ///
181    /// This constructor accepts spanned directives and a source map, enabling
182    /// the `filename`, `lineno`, and `location` columns in queries.
183    pub fn new_with_sources(
184        spanned_directives: &'a [Spanned<Directive>],
185        source_map: &'a SourceMap,
186    ) -> Self {
187        // Build price database from spanned directives — two passes
188        // (mirrors `PriceDatabase::from_directives`).
189        // Pass 1: explicit Price directives.
190        // Pass 2: implicit prices from transactions, gated on the
191        // `(base, quote, date)` tuples already added by pass 1 so the
192        // plugin's output (which lands as explicit Price directives in
193        // pass 1) isn't duplicated by pass 2's transaction walk
194        // (issue #1006).
195        let mut price_db = crate::price::PriceDatabase::new();
196        for spanned in spanned_directives {
197            if let Directive::Price(p) = &spanned.value {
198                price_db.add_price(p);
199            }
200        }
201        let explicit = price_db.snapshot_keys();
202        for spanned in spanned_directives {
203            if let Directive::Transaction(txn) = &spanned.value {
204                price_db.add_implicit_prices_from_transaction(txn, &explicit);
205            }
206        }
207        price_db.sort_prices();
208
209        // Build source locations
210        let source_locations: Vec<SourceLocation> = spanned_directives
211            .iter()
212            .map(|spanned| {
213                let file = source_map.get(spanned.file_id as usize);
214                let (line, _col) = file.map_or((0, 0), |f| f.line_col(spanned.span.start));
215                SourceLocation {
216                    filename: file.map_or_else(String::new, |f| f.path.display().to_string()),
217                    lineno: line,
218                }
219            })
220            .collect();
221
222        // Build account info cache from Open/Close directives
223        let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
224        for spanned in spanned_directives {
225            match &spanned.value {
226                Directive::Open(open) => {
227                    let account = open.account.to_string();
228                    let info = account_info.entry(account).or_default();
229                    info.open_date = Some(open.date);
230                    info.open_meta.clone_from(&open.meta);
231                    info.booking.clone_from(&open.booking);
232                }
233                Directive::Close(close) => {
234                    let account = close.account.to_string();
235                    let info = account_info.entry(account).or_default();
236                    info.close_date = Some(close.date);
237                }
238                _ => {}
239            }
240        }
241
242        Self {
243            directives: &[], // Empty - we use spanned_directives instead
244            spanned_directives: Some(spanned_directives),
245            price_db,
246            target_currency: None,
247            query_date: jiff::Zoned::now().date(),
248            regex_cache: RwLock::new(FxHashMap::default()),
249            account_info,
250            source_locations: Some(source_locations),
251            source_map: Some(source_map),
252            tables: FxHashMap::default(),
253        }
254    }
255
256    /// Get the source location for a directive by index.
257    fn get_source_location(&self, directive_index: usize) -> Option<&SourceLocation> {
258        self.source_locations
259            .as_ref()
260            .and_then(|locs| locs.get(directive_index))
261    }
262
263    /// Resolve a (file + line) source location from a span's start offset.
264    /// Returns `None` for synthesized spans (pad/booking-generated, which carry
265    /// no real source) or when no source map is available.
266    pub(super) fn span_source_location(
267        &self,
268        file_id: u16,
269        span_start: usize,
270    ) -> Option<SourceLocation> {
271        if file_id == rustledger_core::SYNTHESIZED_FILE_ID {
272            return None;
273        }
274        let file = self.source_map?.get(file_id as usize)?;
275        let (line, _) = file.line_col(span_start);
276        Some(SourceLocation {
277            filename: file.path.display().to_string(),
278            lineno: line,
279        })
280    }
281
282    /// Resolve a posting's OWN source location from its span, rather than the
283    /// enclosing transaction's. Matches beanquery, which reports each posting's
284    /// own line; callers fall back to the directive location when this is
285    /// `None` (synthesized posting / no source map).
286    fn posting_source_location(&self, ctx: &PostingContext) -> Option<SourceLocation> {
287        let posting = ctx.transaction.postings.get(ctx.posting_index)?;
288        self.span_source_location(posting.file_id, posting.span.start)
289    }
290
291    /// Resolve the source location for a posting row's `filename`/`lineno`/
292    /// `location` columns: prefer the posting's own location, falling back to
293    /// the enclosing directive's (for synthesized postings or when no source
294    /// map is present).
295    pub(super) fn resolved_source_location(&self, ctx: &PostingContext) -> Option<SourceLocation> {
296        self.posting_source_location(ctx).or_else(|| {
297            ctx.directive_index
298                .and_then(|idx| self.get_source_location(idx).cloned())
299        })
300    }
301
302    /// The directives to iterate over, regardless of which constructor built
303    /// the `Executor`.
304    ///
305    /// The source-location-aware constructor ([`Self::new_with_sources`], used
306    /// by the CLI and LSP) stores directives in `spanned_directives` and leaves
307    /// `directives` **empty**. Any command that walks directives MUST go through
308    /// here — iterating `self.directives` directly silently yields an empty
309    /// result under that constructor. That exact omission regressed `JOURNAL`
310    /// (issue: BQL compat 93%→77%), after `SELECT`, `PRINT`, and `BALANCES` each
311    /// had to be fixed the same way. Routing every generic walk through one
312    /// accessor keeps the next command from re-introducing the bug.
313    pub(super) fn resolved_directives(&self) -> impl Iterator<Item = &'a Directive> {
314        // The two sources are mutually exclusive (see the constructors):
315        // `new_with_sources` leaves `directives` empty and fills
316        // `spanned_directives`; `new` leaves `spanned_directives` None. Chaining
317        // them therefore yields exactly the populated source — with no
318        // allocation, unlike collecting into a `Vec`.
319        self.spanned_directives
320            .unwrap_or(&[])
321            .iter()
322            .map(|s| &s.value)
323            .chain(self.directives.iter())
324    }
325
326    /// Get or compile a regex pattern from the cache.
327    ///
328    /// Returns `Some(Arc<Regex>)` if the pattern is valid, `None` if it's invalid.
329    /// Invalid patterns are cached as `None` to avoid repeated compilation attempts.
330    fn get_or_compile_regex(&self, pattern: &str) -> Option<Arc<Regex>> {
331        // Fast path: check read lock first
332        {
333            // parking_lot's RwLock does not poison, so the read guard is
334            // returned directly (this matches the previous std behavior,
335            // which recovered from poisoning via into_inner()).
336            let cache = self.regex_cache.read();
337            if let Some(cached) = cache.get(pattern) {
338                return cached.clone();
339            }
340        }
341        // Slow path: compile and insert with write lock
342        // Use case-insensitive matching to match Python beancount behavior
343        let compiled = RegexBuilder::new(pattern)
344            .case_insensitive(true)
345            .build()
346            .ok()
347            .map(Arc::new);
348        let mut cache = self.regex_cache.write();
349        // Double-check in case another thread inserted while we waited
350        if let Some(cached) = cache.get(pattern) {
351            return cached.clone();
352        }
353        cache.insert(pattern.to_string(), compiled.clone());
354        compiled
355    }
356
357    /// Get or compile a regex pattern, returning an error if invalid.
358    fn require_regex(&self, pattern: &str) -> Result<Arc<Regex>, QueryError> {
359        self.get_or_compile_regex(pattern)
360            .ok_or_else(|| QueryError::Type(format!("invalid regex: {pattern}")))
361    }
362
363    /// Set the target currency for `VALUE()` conversions.
364    pub fn set_target_currency(&mut self, currency: impl Into<String>) {
365        self.target_currency = Some(currency.into());
366    }
367
368    /// Execute a query and return the results.
369    ///
370    /// # Errors
371    ///
372    /// Returns [`QueryError`] in the following cases:
373    ///
374    /// - [`QueryError::UnknownColumn`] - A referenced column name doesn't exist
375    /// - [`QueryError::UnknownFunction`] - An unknown function is called
376    /// - [`QueryError::InvalidArguments`] - Function called with wrong arguments
377    /// - [`QueryError::Type`] - Type mismatch in expression (e.g., comparing string to number)
378    /// - [`QueryError::Aggregation`] - Error in aggregate function (SUM, COUNT, etc.)
379    /// - [`QueryError::Evaluation`] - General expression evaluation error
380    pub fn execute(&mut self, query: &Query) -> Result<QueryResult, QueryError> {
381        match query {
382            Query::Select(select) => self.execute_select(select),
383            Query::Journal(journal) => self.execute_journal(journal),
384            Query::Balances(balances) => self.execute_balances(balances),
385            Query::Print(print) => self.execute_print(print),
386            Query::CreateTable(create) => self.execute_create_table(create),
387            Query::Insert(insert) => self.execute_insert(insert),
388        }
389    }
390
391    /// Compute per-account inventories for a `BALANCES` query.
392    ///
393    /// Returns a fresh map rather than mutating shared state on `self` so that
394    /// sequential queries on the same `Executor` produce independent results.
395    /// See issue #958 for the bug that motivated this signature: a previous
396    /// implementation accumulated into `self.balances` without clearing,
397    /// causing a second `BALANCES` call to double-count and a `BALANCES FROM
398    /// year=2024` followed by `BALANCES FROM year=2025` to return a confused
399    /// union of both filters.
400    fn build_balances_with_filter(
401        &self,
402        from: Option<&FromClause>,
403    ) -> Result<FxHashMap<rustledger_core::Account, Inventory>, QueryError> {
404        // Delegate to the shared posting scan so BALANCES uses the SAME cost
405        // resolution AND the SAME `FROM` window (`open_on` / `close_on`) as the
406        // default SELECT path. `scan_postings`' per-account `account_balances`
407        // (requested via `needs_account_balance = true`) is exactly the windowed
408        // per-account total BALANCES wants. Previously this re-iterated postings
409        // and applied only `from.filter`, silently ignoring `OPEN ON`/`CLOSE ON`.
410        //
411        // `needs_balance = false` (no cumulative needed); `where_clause = None`
412        // — `BALANCES` applies its own `WHERE` to the result afterward, and
413        // `account_balances` is WHERE-independent by construction anyway.
414        Ok(self
415            .scan_postings(from, None, false, true, false, false)?
416            .account_balances)
417    }
418
419    /// Collect postings matching the FROM and WHERE clauses.
420    fn collect_postings(&self, query: &SelectQuery) -> Result<Vec<PostingContext<'a>>, QueryError> {
421        let from = query.from.as_ref();
422        let where_clause = query.where_clause.as_ref();
423
424        // Both `balance` (cumulative, WHERE-filtered) and `account_balance`
425        // (per-account, raw) are running-state columns. Each PostingContext
426        // built below carries snapshots — and `cumulative_balance` grows
427        // monotonically across the iteration, so cloning it per posting on
428        // a 100k-posting ledger was the runaway-allocation regression in
429        // issue #1080.
430        //
431        // Gate the clones on whether the query actually references the
432        // columns anywhere (SELECT / WHERE / ORDER BY / HAVING / GROUP BY /
433        // FROM filter). Queries that don't touch them (the common case —
434        // `SELECT account WHERE account ~ "^Assets"` references neither)
435        // skip the entire state-tracking + clone path. Pre-fix, the only
436        // gate was `where_clause.is_some()` for the pre-WHERE snapshot,
437        // which fired even when the WHERE didn't read balance.
438        let needs_balance = query_references_column(query, "balance");
439        let needs_account_balance = query_references_column(query, "account_balance");
440
441        // Tighter gate for the *pre-WHERE* `balance` clone — only
442        // required when the WHERE clause itself reads `balance`. For
443        // queries like `SELECT balance FROM #postings` (`balance` in
444        // SELECT, no WHERE-time read), the pre-snapshot is never
445        // observed; we skip the extra clone and let the post-WHERE
446        // refresh fill `ctx.balance`. Caught by Copilot review on
447        // PR #1085. `account_balance` doesn't need an analogous gate
448        // because it isn't refreshed post-WHERE — it's already the
449        // running total after the eager update above.
450        let where_reads_balance =
451            where_clause.is_some_and(|w| expr_references_column(w, "balance"));
452
453        Ok(self
454            .scan_postings(
455                from,
456                where_clause,
457                needs_balance,
458                needs_account_balance,
459                where_reads_balance,
460                true,
461            )?
462            .postings)
463    }
464
465    /// The single posting-source scan, shared by the default `SELECT` path
466    /// ([`Self::collect_postings`]) and the `#postings` table
467    /// ([`Self::build_postings_table`]).
468    ///
469    /// Iterates the resolved directives in order, applies the optional `FROM` and
470    /// posting-level `WHERE` filters, accumulates the running cumulative `balance`
471    /// (over `WHERE`-passed postings) and the per-account `account_balance`, and
472    /// yields one [`PostingContext`] per surviving posting. The `needs_*` flags
473    /// gate the per-posting Inventory clones (issue #1080); pass them all `true`
474    /// with no filter to materialize the full unfiltered table.
475    ///
476    /// `collect_contexts` controls whether the per-posting [`PostingContext`]
477    /// stream is built at all. Callers that only consume `account_balances` (the
478    /// `BALANCES` command) pass `false` to skip materializing — and immediately
479    /// discarding — a context per posting; the returned `postings` is then empty.
480    // Four independent, individually-documented scan toggles on one internal hot
481    // path; a flags struct would add per-call construction churn here without
482    // changing the boolean nature of the configuration.
483    #[allow(clippy::fn_params_excessive_bools)]
484    fn scan_postings(
485        &self,
486        from: Option<&FromClause>,
487        where_clause: Option<&Expr>,
488        needs_balance: bool,
489        needs_account_balance: bool,
490        where_reads_balance: bool,
491        collect_contexts: bool,
492    ) -> Result<PostingScan<'a>, QueryError> {
493        let mut postings = Vec::new();
494        // Per-account running balance — accumulates every posting the FROM clause
495        // keeps (plus the pre-`open_on` carry-in below), independent of the WHERE
496        // filter, so `account_balance` always reflects the account's true ledger
497        // balance at the point of the posting.
498        let mut account_balances: FxHashMap<rustledger_core::Account, Inventory> =
499            FxHashMap::default();
500        // Single cumulative running balance across WHERE-filtered postings in
501        // iteration order. This is the bean-query `balance` semantic: a snapshot
502        // of "everything selected so far" rather than a per-account view.
503        let mut cumulative_balance: Inventory = Inventory::default();
504
505        // Create an iterator over (directive_index, directive) pairs
506        // Handle both spanned and unspanned directives
507        let directive_iter: Vec<(usize, &Directive)> =
508            self.resolved_directives().enumerate().collect();
509
510        // Resolve a posting to a Position that preserves cost basis when present.
511        // The single cost-resolve lives in `Position::from_posting`, shared with
512        // every other balance accumulator in this crate so lot details can't be
513        // dropped by a divergent copy.
514        let resolve_position = |posting: &rustledger_core::Posting, txn_date: NaiveDate| {
515            posting
516                .amount()
517                .map(|units| Position::from_posting(units, posting.cost.as_ref(), txn_date))
518        };
519
520        for (directive_index, directive) in directive_iter {
521            if let Directive::Transaction(txn) = directive {
522                // Check FROM clause (transaction-level filter)
523                if let Some(from) = from {
524                    // Apply date filters
525                    if let Some(open_date) = from.open_on
526                        && txn.date < open_date
527                    {
528                        // Update per-account balances but don't include in results
529                        // and don't touch the cumulative balance — these postings
530                        // didn't make it past the FROM filter.
531                        if needs_account_balance {
532                            for posting in &txn.postings {
533                                if let Some(pos) = resolve_position(posting, txn.date) {
534                                    let bal = account_balances
535                                        .entry(posting.account.clone())
536                                        .or_default();
537                                    bal.add(pos);
538                                }
539                            }
540                        }
541                        continue;
542                    }
543                    // `close on D` is exclusive (matches bean-query): the books
544                    // are closed AT D, so a transaction stamped exactly on D is
545                    // not part of the closing period. Combined with `open on D`
546                    // being inclusive, the resulting range is `[open, close)`.
547                    if let Some(close_date) = from.close_on
548                        && txn.date >= close_date
549                    {
550                        continue;
551                    }
552                    // Apply filter expression
553                    if let Some(filter) = &from.filter
554                        && !self.evaluate_from_filter(filter, txn)?
555                    {
556                        continue;
557                    }
558                }
559
560                for (i, posting) in txn.postings.iter().enumerate() {
561                    // Update the account-level running balance regardless of
562                    // whether this posting passes WHERE — `account_balance`
563                    // should always reflect the underlying ledger truth.
564                    // Skip the update entirely when the query doesn't read
565                    // account_balance (saves the `.clone()` + map probe per
566                    // posting; `Inventory::add` allocates internally so the
567                    // saving compounds across a long run).
568                    let resolved = resolve_position(posting, txn.date);
569                    if needs_account_balance && let Some(pos) = resolved.clone() {
570                        let bal = account_balances.entry(posting.account.clone()).or_default();
571                        bal.add(pos);
572                    }
573
574                    // Callers that only want the per-account totals (BALANCES, via
575                    // `build_balances_with_filter`) pass `collect_contexts = false`:
576                    // `account_balances` is already updated above, so skip building
577                    // and pushing a `PostingContext` (and its per-posting Inventory
578                    // clone) for every posting — a large-ledger CPU/memory win that
579                    // avoids materializing a stream BALANCES would just discard.
580                    if !collect_contexts {
581                        continue;
582                    }
583
584                    // Build the context with both balance views. The cumulative
585                    // snapshot is the running total *before* this posting; we
586                    // update it after WHERE passes so postings rejected by WHERE
587                    // don't pollute the cumulative. Cloning the cumulative
588                    // `Inventory` is the hot allocation — it grows monotonically
589                    // across the iteration, so a 22k-posting WHERE-filtered
590                    // query was producing ~3 clones × thousands of positions per
591                    // posting (issue #1080 — multi-GB WASM heap growth).
592                    //
593                    // `balance` and `account_balance` have asymmetric pre/post
594                    // semantics so they gate differently:
595                    //
596                    // * `balance` is refreshed post-WHERE below — its pre-WHERE
597                    //   slot only matters when the WHERE clause itself reads
598                    //   the column. For `SELECT balance FROM #postings` (no
599                    //   WHERE-time read), we skip the pre-WHERE clone entirely
600                    //   and let the post-WHERE refresh fill it. Saves one
601                    //   clone-per-posting versus the gating logic
602                    //   in the first cut of this fix (Copilot review on PR #1085).
603                    //
604                    // * `account_balance` is NOT refreshed post-WHERE —
605                    //   account_balances is updated *before* this block, so
606                    //   the value here is already the post-update running
607                    //   total. We populate it eagerly when `needs_account_balance`
608                    //   so SELECT / ORDER BY / HAVING / etc. can read it.
609                    let mut ctx = PostingContext {
610                        transaction: txn,
611                        posting_index: i,
612                        balance: if where_reads_balance {
613                            Some(cumulative_balance.clone())
614                        } else {
615                            None
616                        },
617                        account_balance: if needs_account_balance {
618                            account_balances.get(&posting.account).cloned()
619                        } else {
620                            None
621                        },
622                        directive_index: Some(directive_index),
623                    };
624
625                    // Check WHERE clause (posting-level filter)
626                    if let Some(where_expr) = where_clause
627                        && !self.evaluate_predicate(where_expr, &ctx)?
628                    {
629                        continue;
630                    }
631
632                    // WHERE passed: contribute this posting to the cumulative
633                    // balance and refresh the snapshot in ctx so SELECT sees
634                    // the post-update value. Both steps are no-ops when the
635                    // query doesn't read `balance`.
636                    if needs_balance {
637                        if let Some(pos) = resolved {
638                            cumulative_balance.add(pos);
639                        }
640                        ctx.balance = Some(cumulative_balance.clone());
641                    }
642                    postings.push(ctx);
643                }
644            }
645        }
646
647        Ok(PostingScan {
648            postings,
649            account_balances,
650        })
651    }
652    fn evaluate_function(
653        &self,
654        func: &FunctionCall,
655        ctx: &PostingContext,
656    ) -> Result<Value, QueryError> {
657        let name = func.name.to_uppercase();
658        match name.as_str() {
659            // Metadata functions read the row's `PostingContext`, so they stay
660            // on the lazy path rather than routing through the value registry.
661            "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
662                self.eval_meta_function(&name, func, ctx)
663            }
664            // COALESCE short-circuits on its raw argument expressions and must
665            // NOT pre-evaluate every argument, so it stays on the lazy path.
666            "COALESCE" => self.eval_coalesce(func, ctx),
667            // Aggregates evaluate to Null per row; real aggregation happens in
668            // the aggregation pass.
669            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
670            // Every other function: evaluate the arguments, then dispatch through
671            // the single value-based registry shared with `#postings`, aggregates,
672            // and subqueries. Unknown names fall through to its `UnknownFunction`
673            // arm. This is the collapse of the formerly-duplicated lazy dispatch
674            // onto `evaluate_function_on_values` (dual-eval-path unification).
675            _ => {
676                let args = func
677                    .args
678                    .iter()
679                    .map(|a| self.evaluate_expr(a, ctx))
680                    .collect::<Result<Vec<_>, _>>()?;
681                self.evaluate_function_on_values(&name, &args)
682            }
683        }
684    }
685
686    /// Evaluate a function with pre-evaluated arguments (for subquery context).
687    fn evaluate_function_on_values(&self, name: &str, args: &[Value]) -> Result<Value, QueryError> {
688        let name_upper = name.to_uppercase();
689        match name_upper.as_str() {
690            // Date functions
691            "TODAY" => {
692                // Takes no arguments; reject extras to match the lazy path.
693                Self::require_args_count(&name_upper, args, 0)?;
694                Ok(Value::Date(jiff::Zoned::now().date()))
695            }
696            "YEAR" => {
697                Self::require_args_count(&name_upper, args, 1)?;
698                match &args[0] {
699                    Value::Date(d) => Ok(Value::Integer(d.year().into())),
700                    Value::Null => Ok(Value::Null),
701                    _ => Err(QueryError::Type("YEAR expects a date".to_string())),
702                }
703            }
704            "MONTH" => {
705                Self::require_args_count(&name_upper, args, 1)?;
706                match &args[0] {
707                    Value::Date(d) => Ok(Value::Integer(d.month().into())),
708                    Value::Null => Ok(Value::Null),
709                    _ => Err(QueryError::Type("MONTH expects a date".to_string())),
710                }
711            }
712            "DAY" => {
713                Self::require_args_count(&name_upper, args, 1)?;
714                match &args[0] {
715                    Value::Date(d) => Ok(Value::Integer(d.day().into())),
716                    Value::Null => Ok(Value::Null),
717                    _ => Err(QueryError::Type("DAY expects a date".to_string())),
718                }
719            }
720            // String functions
721            "LENGTH" => {
722                Self::require_args_count(&name_upper, args, 1)?;
723                match &args[0] {
724                    // Count Unicode characters, not UTF-8 bytes (matches beanquery).
725                    Value::String(s) => Ok(Value::Integer(s.chars().count() as i64)),
726                    Value::StringSet(s) => Ok(Value::Integer(s.len() as i64)),
727                    Value::Null => Ok(Value::Null),
728                    _ => Err(QueryError::Type(
729                        "LENGTH expects a string or set".to_string(),
730                    )),
731                }
732            }
733            "UPPER" => {
734                Self::require_args_count(&name_upper, args, 1)?;
735                match &args[0] {
736                    Value::String(s) => Ok(Value::String(s.to_uppercase())),
737                    Value::Null => Ok(Value::Null),
738                    _ => Err(QueryError::Type("UPPER expects a string".to_string())),
739                }
740            }
741            "LOWER" => {
742                Self::require_args_count(&name_upper, args, 1)?;
743                match &args[0] {
744                    Value::String(s) => Ok(Value::String(s.to_lowercase())),
745                    Value::Null => Ok(Value::Null),
746                    _ => Err(QueryError::Type("LOWER expects a string".to_string())),
747                }
748            }
749            "TRIM" => {
750                Self::require_args_count(&name_upper, args, 1)?;
751                match &args[0] {
752                    Value::String(s) => Ok(Value::String(s.trim().to_string())),
753                    Value::Null => Ok(Value::Null),
754                    _ => Err(QueryError::Type("TRIM expects a string".to_string())),
755                }
756            }
757            // Math functions
758            "ABS" => {
759                Self::require_args_count(&name_upper, args, 1)?;
760                match &args[0] {
761                    Value::Number(n) => Ok(Value::Number(n.abs())),
762                    Value::Integer(i) => Ok(Value::Integer(i.abs())),
763                    Value::Null => Ok(Value::Null),
764                    _ => Err(QueryError::Type("ABS expects a number".to_string())),
765                }
766            }
767            "ROUND" => Self::round_on_values(args),
768            // Utility functions
769            "COALESCE" => {
770                for arg in args {
771                    if !matches!(arg, Value::Null) {
772                        return Ok(arg.clone());
773                    }
774                }
775                Ok(Value::Null)
776            }
777            // Position/Amount functions
778            "NUMBER" => {
779                Self::require_args_count(&name_upper, args, 1)?;
780                match &args[0] {
781                    Value::Amount(a) => Ok(Value::Number(a.number)),
782                    Value::Position(p) => Ok(Value::Number(p.units.number)),
783                    Value::Number(n) => Ok(Value::Number(*n)),
784                    Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
785                    Value::Inventory(inv) => {
786                        // For inventory, only return a number if all positions share the same
787                        // currency. Summing across different currencies is not meaningful.
788                        // Single pass: track the first currency and running total, bail out
789                        // to Null on any currency mismatch.
790                        let mut iter = inv.positions();
791                        let Some(first) = iter.next() else {
792                            return Ok(Value::Number(Decimal::ZERO));
793                        };
794                        let first_currency = &first.units.currency;
795                        let mut total = first.units.number;
796                        for pos in iter {
797                            if &pos.units.currency != first_currency {
798                                return Ok(Value::Null);
799                            }
800                            total += pos.units.number;
801                        }
802                        Ok(Value::Number(total))
803                    }
804                    Value::Null => Ok(Value::Null),
805                    _ => Err(QueryError::Type(
806                        "NUMBER expects an amount, position, or inventory".to_string(),
807                    )),
808                }
809            }
810            "CURRENCY" => {
811                Self::require_args_count(&name_upper, args, 1)?;
812                match &args[0] {
813                    Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
814                    Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
815                    Value::Inventory(inv) => {
816                        // Return the currency of the first position, or Null if empty
817                        if let Some(pos) = inv.positions().next() {
818                            Ok(Value::String(pos.units.currency.to_string()))
819                        } else {
820                            Ok(Value::Null)
821                        }
822                    }
823                    Value::Null => Ok(Value::Null),
824                    _ => Err(QueryError::Type(
825                        "CURRENCY expects an amount or position".to_string(),
826                    )),
827                }
828            }
829            "UNITS" => {
830                Self::require_args_count(&name_upper, args, 1)?;
831                match &args[0] {
832                    Value::Position(p) => Ok(Value::Amount(p.units.clone())),
833                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
834                    Value::Inventory(inv) => {
835                        // Return inventory with just units (no cost info)
836                        let mut units_inv = Inventory::new();
837                        for pos in inv.positions() {
838                            units_inv.add(Position::simple(pos.units.clone()));
839                        }
840                        Ok(Value::Inventory(Box::new(units_inv)))
841                    }
842                    Value::Null => Ok(Value::Null),
843                    _ => Err(QueryError::Type(
844                        "UNITS expects a position or inventory".to_string(),
845                    )),
846                }
847            }
848            "COST" => {
849                Self::require_args_count(&name_upper, args, 1)?;
850                match &args[0] {
851                    Value::Position(p) => {
852                        if let Some(cost) = &p.cost {
853                            // Preserve sign: buys give positive cost, sells give negative
854                            let total = p.units.number * cost.number;
855                            Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
856                        } else {
857                            Ok(Value::Amount(p.units.clone()))
858                        }
859                    }
860                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
861                    Value::Inventory(inv) => {
862                        let mut total = Decimal::ZERO;
863                        let mut currency: Option<rustledger_core::Currency> = None;
864                        for pos in inv.positions() {
865                            if let Some(cost) = &pos.cost {
866                                total += pos.units.number * cost.number;
867                                if currency.is_none() {
868                                    currency = Some(cost.currency.clone());
869                                }
870                            } else {
871                                total += pos.units.number;
872                                if currency.is_none() {
873                                    currency = Some(pos.units.currency.clone());
874                                }
875                            }
876                        }
877                        if let Some(curr) = currency {
878                            Ok(Value::Amount(Amount::new(total, curr)))
879                        } else {
880                            Ok(Value::Null)
881                        }
882                    }
883                    Value::Null => Ok(Value::Null),
884                    _ => Err(QueryError::Type(
885                        "COST expects a position or inventory".to_string(),
886                    )),
887                }
888            }
889            "VALUE" => {
890                // Use shared VALUE implementation for consistent behavior.
891                // See `eval_value` on PositionFunctions for the full signature
892                // contract (DATE vs. currency-string dispatch).
893                if args.is_empty() || args.len() > 2 {
894                    return Err(QueryError::InvalidArguments(
895                        "VALUE".to_string(),
896                        "expected 1-2 arguments".to_string(),
897                    ));
898                }
899                let (explicit_currency, at_date) = if args.len() == 2 {
900                    match &args[1] {
901                        Value::Date(d) => (None, Some(*d)),
902                        Value::String(s) => (Some(s.as_str()), None),
903                        Value::Null => {
904                            return Err(QueryError::Type(
905                                concat!(
906                                    "VALUE: second argument evaluated to NULL; ",
907                                    "expected a date or currency string ",
908                                    "(this often means an aggregate expression couldn't ",
909                                    "evaluate against an empty group — see issue #902)",
910                                )
911                                .to_string(),
912                            ));
913                        }
914                        _ => {
915                            return Err(QueryError::Type(
916                                "VALUE second argument must be a date or currency string"
917                                    .to_string(),
918                            ));
919                        }
920                    }
921                } else {
922                    (None, None)
923                };
924                self.convert_to_market_value(&args[0], explicit_currency, at_date)
925            }
926            // Math functions
927            "SAFEDIV" => {
928                Self::require_args_count(&name_upper, args, 2)?;
929                let (dividend, divisor) = (&args[0], &args[1]);
930                match (dividend, divisor) {
931                    // NULL propagates.
932                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
933                    // Any numeric pair: coerce to Decimal and divide. A zero
934                    // divisor yields 0 (the "safe" in SAFEDIV) — matching
935                    // beanquery and the per-row `eval_safediv` path, which used to
936                    // disagree (this path returned NULL on a zero divisor).
937                    _ => {
938                        let to_dec = |v: &Value| match v {
939                            Value::Number(n) => Some(*n),
940                            Value::Integer(i) => Some(Decimal::from(*i)),
941                            _ => None,
942                        };
943                        match (to_dec(dividend), to_dec(divisor)) {
944                            (Some(a), Some(b)) => Ok(Value::Number(if b.is_zero() {
945                                Decimal::ZERO
946                            } else {
947                                a / b
948                            })),
949                            _ => Err(QueryError::Type(
950                                "SAFEDIV expects numeric arguments".to_string(),
951                            )),
952                        }
953                    }
954                }
955            }
956            "NEG" => {
957                Self::require_args_count(&name_upper, args, 1)?;
958                match &args[0] {
959                    Value::Number(n) => Ok(Value::Number(-n)),
960                    Value::Integer(i) => Ok(Value::Integer(-i)),
961                    Value::Amount(a) => {
962                        Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
963                    }
964                    Value::Null => Ok(Value::Null),
965                    _ => Err(QueryError::Type(
966                        "NEG expects a number or amount".to_string(),
967                    )),
968                }
969            }
970            // Account functions
971            "ACCOUNT_SORTKEY" => {
972                Self::require_args_count(&name_upper, args, 1)?;
973                match &args[0] {
974                    Value::String(s) => {
975                        let type_index = Self::account_type_index(s);
976                        Ok(Value::String(format!("{type_index}-{s}")))
977                    }
978                    Value::Null => Ok(Value::Null),
979                    _ => Err(QueryError::Type(
980                        "ACCOUNT_SORTKEY expects an account string".to_string(),
981                    )),
982                }
983            }
984            "PARENT" => {
985                Self::require_args_count(&name_upper, args, 1)?;
986                match &args[0] {
987                    Value::String(s) => {
988                        if let Some(idx) = s.rfind(':') {
989                            Ok(Value::String(s[..idx].to_string()))
990                        } else {
991                            Ok(Value::Null)
992                        }
993                    }
994                    Value::Null => Ok(Value::Null),
995                    _ => Err(QueryError::Type(
996                        "PARENT expects an account string".to_string(),
997                    )),
998                }
999            }
1000            "LEAF" => {
1001                Self::require_args_count(&name_upper, args, 1)?;
1002                match &args[0] {
1003                    Value::String(s) => {
1004                        if let Some(idx) = s.rfind(':') {
1005                            Ok(Value::String(s[idx + 1..].to_string()))
1006                        } else {
1007                            Ok(Value::String(s.clone()))
1008                        }
1009                    }
1010                    Value::Null => Ok(Value::Null),
1011                    _ => Err(QueryError::Type(
1012                        "LEAF expects an account string".to_string(),
1013                    )),
1014                }
1015            }
1016            "ROOT" => {
1017                if args.is_empty() || args.len() > 2 {
1018                    return Err(QueryError::InvalidArguments(
1019                        "ROOT".to_string(),
1020                        "expected 1 or 2 arguments".to_string(),
1021                    ));
1022                }
1023                let n = if args.len() == 2 {
1024                    let raw = match &args[1] {
1025                        Value::Integer(i) => *i,
1026                        _ => {
1027                            return Err(QueryError::Type(
1028                                "ROOT second arg must be integer".to_string(),
1029                            ));
1030                        }
1031                    };
1032                    // Reject negatives explicitly — `i as usize` would silently
1033                    // turn -1 into `usize::MAX` and return the whole account.
1034                    // Mirrors the lazy `eval_root` guard so both paths agree.
1035                    usize::try_from(raw).map_err(|_| {
1036                        QueryError::Type(format!(
1037                            "ROOT second arg must be a non-negative integer, got {raw}"
1038                        ))
1039                    })?
1040                } else {
1041                    1
1042                };
1043                match &args[0] {
1044                    Value::String(s) => {
1045                        let parts: Vec<&str> = s.split(':').collect();
1046                        if n >= parts.len() {
1047                            Ok(Value::String(s.clone()))
1048                        } else {
1049                            Ok(Value::String(parts[..n].join(":")))
1050                        }
1051                    }
1052                    Value::Null => Ok(Value::Null),
1053                    _ => Err(QueryError::Type(
1054                        "ROOT expects an account string".to_string(),
1055                    )),
1056                }
1057            }
1058            // ONLY function: extract single-currency amount from inventory
1059            "ONLY" => {
1060                Self::require_args_count(&name_upper, args, 2)?;
1061                let currency = match &args[0] {
1062                    Value::String(s) => s.clone(),
1063                    // NULL propagates (beanquery parity): `first(cost_currency)`
1064                    // is NULL for groups without costs, and fava's Holdings
1065                    // by_currency query feeds exactly that into only() — see
1066                    // #1699. The second-argument match below already
1067                    // propagates; the asymmetry was the bug.
1068                    Value::Null => return Ok(Value::Null),
1069                    _ => {
1070                        return Err(QueryError::Type(
1071                            "ONLY: first argument must be a currency string".to_string(),
1072                        ));
1073                    }
1074                };
1075                match &args[1] {
1076                    Value::Inventory(inv) => {
1077                        let total = inv.units(&currency);
1078                        if total.is_zero() {
1079                            Ok(Value::Null)
1080                        } else {
1081                            Ok(Value::Amount(Amount::new(total, &currency)))
1082                        }
1083                    }
1084                    Value::Position(p) => {
1085                        if p.units.currency.as_str() == currency {
1086                            Ok(Value::Amount(p.units.clone()))
1087                        } else {
1088                            Ok(Value::Null)
1089                        }
1090                    }
1091                    Value::Amount(a) => {
1092                        if a.currency.as_str() == currency {
1093                            Ok(Value::Amount(a.clone()))
1094                        } else {
1095                            Ok(Value::Null)
1096                        }
1097                    }
1098                    Value::Null => Ok(Value::Null),
1099                    _ => Err(QueryError::Type(
1100                        "ONLY: second argument must be an inventory, position, or amount"
1101                            .to_string(),
1102                    )),
1103                }
1104            }
1105            // GETPRICE function - needs price database
1106            "GETPRICE" => {
1107                if args.len() < 2 || args.len() > 3 {
1108                    return Err(QueryError::InvalidArguments(
1109                        "GETPRICE".to_string(),
1110                        "expected 2 or 3 arguments".to_string(),
1111                    ));
1112                }
1113                // Handle NULL arguments gracefully
1114                let base = match &args[0] {
1115                    Value::String(s) => s.clone(),
1116                    Value::Null => return Ok(Value::Null),
1117                    _ => {
1118                        return Err(QueryError::Type(
1119                            "GETPRICE: first argument must be a currency string".to_string(),
1120                        ));
1121                    }
1122                };
1123                let quote = match &args[1] {
1124                    Value::String(s) => s.clone(),
1125                    Value::Null => return Ok(Value::Null),
1126                    _ => {
1127                        return Err(QueryError::Type(
1128                            "GETPRICE: second argument must be a currency string".to_string(),
1129                        ));
1130                    }
1131                };
1132                let date = if args.len() == 3 {
1133                    match &args[2] {
1134                        Value::Date(d) => *d,
1135                        Value::Null => self.query_date,
1136                        _ => self.query_date,
1137                    }
1138                } else {
1139                    self.query_date
1140                };
1141                match self.price_db.get_price(&base, &quote, date) {
1142                    Some(price) => Ok(Value::Number(price)),
1143                    None => Ok(Value::Null),
1144                }
1145            }
1146            // Inventory functions
1147            "EMPTY" => {
1148                Self::require_args_count(&name_upper, args, 1)?;
1149                match &args[0] {
1150                    Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
1151                    Value::Null => Ok(Value::Boolean(true)),
1152                    _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
1153                }
1154            }
1155            "FILTER_CURRENCY" => {
1156                Self::require_args_count(&name_upper, args, 2)?;
1157                let currency = match &args[1] {
1158                    Value::String(s) => s.clone(),
1159                    Value::Null => return Ok(Value::Null),
1160                    _ => {
1161                        return Err(QueryError::Type(
1162                            "FILTER_CURRENCY expects (inventory, string)".to_string(),
1163                        ));
1164                    }
1165                };
1166                match &args[0] {
1167                    Value::Inventory(inv) => {
1168                        let filtered: Vec<Position> = inv
1169                            .positions()
1170                            .filter(|p| p.units.currency.as_str() == currency)
1171                            .cloned()
1172                            .collect();
1173                        let mut new_inv = Inventory::new();
1174                        for pos in filtered {
1175                            new_inv.add(pos);
1176                        }
1177                        Ok(Value::Inventory(Box::new(new_inv)))
1178                    }
1179                    Value::Null => Ok(Value::Null),
1180                    _ => Err(QueryError::Type(
1181                        "FILTER_CURRENCY expects (inventory, string)".to_string(),
1182                    )),
1183                }
1184            }
1185            "POSSIGN" => {
1186                Self::require_args_count(&name_upper, args, 2)?;
1187                let account_str = match &args[1] {
1188                    Value::String(s) => s.clone(),
1189                    Value::Null => return Ok(Value::Null),
1190                    _ => {
1191                        return Err(QueryError::Type(
1192                            "POSSIGN expects (amount, account_string)".to_string(),
1193                        ));
1194                    }
1195                };
1196                let first_component = account_str.split(':').next().unwrap_or("");
1197                let is_credit_normal =
1198                    matches!(first_component, "Liabilities" | "Equity" | "Income");
1199                match &args[0] {
1200                    Value::Amount(a) => {
1201                        let mut amt = a.clone();
1202                        if is_credit_normal {
1203                            amt.number = -amt.number;
1204                        }
1205                        Ok(Value::Amount(amt))
1206                    }
1207                    Value::Number(n) => {
1208                        let adjusted = if is_credit_normal { -n } else { *n };
1209                        Ok(Value::Number(adjusted))
1210                    }
1211                    // Mirror the lazy `POSSIGN`: an integer amount is treated as
1212                    // a number and sign-adjusted.
1213                    Value::Integer(i) => {
1214                        let n = Decimal::from(*i);
1215                        let adjusted = if is_credit_normal { -n } else { n };
1216                        Ok(Value::Number(adjusted))
1217                    }
1218                    Value::Null => Ok(Value::Null),
1219                    _ => Err(QueryError::Type(
1220                        "POSSIGN expects (amount, account_string)".to_string(),
1221                    )),
1222                }
1223            }
1224            // CONVERT function - convert amounts/positions/inventories to target currency
1225            "CONVERT" => {
1226                if args.len() < 2 || args.len() > 3 {
1227                    return Err(QueryError::InvalidArguments(
1228                        "CONVERT".to_string(),
1229                        "expected 2 or 3 arguments: (value, currency[, date])".to_string(),
1230                    ));
1231                }
1232
1233                let target_currency = match &args[1] {
1234                    Value::String(s) => s.clone(),
1235                    Value::Null => {
1236                        return Err(QueryError::Type(
1237                            concat!(
1238                                "CONVERT: second argument evaluated to NULL; ",
1239                                "expected a currency string ",
1240                                "(this often means an aggregate expression couldn't ",
1241                                "evaluate against an empty group — see issue #902)",
1242                            )
1243                            .to_string(),
1244                        ));
1245                    }
1246                    _ => {
1247                        return Err(QueryError::Type(
1248                            "CONVERT: second argument must be a currency string".to_string(),
1249                        ));
1250                    }
1251                };
1252
1253                // Optional date argument
1254                let date: Option<rustledger_core::NaiveDate> = if args.len() == 3 {
1255                    match &args[2] {
1256                        Value::Date(d) => Some(*d),
1257                        Value::Null => None, // NULL date uses latest price
1258                        _ => {
1259                            return Err(QueryError::Type(
1260                                "CONVERT: third argument must be a date".to_string(),
1261                            ));
1262                        }
1263                    }
1264                } else {
1265                    None
1266                };
1267
1268                // Helper closure to convert an amount
1269                let convert_amount = |amt: &Amount| -> Option<Amount> {
1270                    if let Some(d) = date {
1271                        self.price_db.convert(amt, &target_currency, d)
1272                    } else {
1273                        self.price_db.convert_latest(amt, &target_currency)
1274                    }
1275                };
1276
1277                match &args[0] {
1278                    Value::Position(p) => {
1279                        if p.units.currency == target_currency {
1280                            Ok(Value::Amount(p.units.clone()))
1281                        } else if let Some(converted) = convert_amount(&p.units) {
1282                            Ok(Value::Amount(converted))
1283                        } else {
1284                            Ok(Value::Amount(p.units.clone()))
1285                        }
1286                    }
1287                    Value::Amount(a) => {
1288                        if a.currency == target_currency {
1289                            Ok(Value::Amount(a.clone()))
1290                        } else if let Some(converted) = convert_amount(a) {
1291                            Ok(Value::Amount(converted))
1292                        } else {
1293                            Ok(Value::Amount(a.clone()))
1294                        }
1295                    }
1296                    Value::Inventory(inv) => {
1297                        // Convert each position, keeping originals when no conversion available
1298                        // (matches Python beancount behavior)
1299                        let mut result = Inventory::default();
1300                        for pos in inv.positions() {
1301                            if pos.units.currency == target_currency {
1302                                result.add(Position::simple(pos.units.clone()));
1303                            } else if let Some(converted) = convert_amount(&pos.units) {
1304                                result.add(Position::simple(converted));
1305                            } else {
1306                                // No conversion available - keep original (Python beancount behavior)
1307                                result.add(Position::simple(pos.units.clone()));
1308                            }
1309                        }
1310                        // If result has single currency matching target, return as Amount
1311                        // If result is empty, return zero in target currency (issue #586)
1312                        let positions: Vec<&Position> = result.positions().collect();
1313                        if positions.is_empty() {
1314                            Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1315                        } else if positions.len() == 1
1316                            && positions[0].units.currency == target_currency
1317                        {
1318                            Ok(Value::Amount(positions[0].units.clone()))
1319                        } else {
1320                            Ok(Value::Inventory(Box::new(result)))
1321                        }
1322                    }
1323                    Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
1324                    Value::String(s) => {
1325                        // String input is a rustledger extension (issue #1179),
1326                        // not present in Python beancount. Lets users write
1327                        // ad-hoc currency conversions like
1328                        // `SELECT CONVERT('100 USD', 'EUR')` without anchoring
1329                        // them to a posting. Strict parser (see
1330                        // `Amount::from_str`): malformed input surfaces as a
1331                        // typed `QueryError` rather than a silent zero or a
1332                        // panic.
1333                        let amt: Amount = s.parse().map_err(|e| {
1334                            QueryError::Type(format!(
1335                                "CONVERT: first argument {e} (e.g. \"100 USD\")"
1336                            ))
1337                        })?;
1338                        if amt.currency == target_currency {
1339                            Ok(Value::Amount(amt))
1340                        } else if let Some(converted) = convert_amount(&amt) {
1341                            Ok(Value::Amount(converted))
1342                        } else {
1343                            // Match the `Value::Amount` arm: no price available
1344                            // → return original unchanged.
1345                            Ok(Value::Amount(amt))
1346                        }
1347                    }
1348                    Value::Null => {
1349                        // For null values (e.g., empty sum), return zero in target currency
1350                        // This matches Python beancount behavior for empty balances (issue #586)
1351                        Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1352                    }
1353                    _ => Err(QueryError::Type(
1354                        "CONVERT expects a position, amount, inventory, number, or amount-string"
1355                            .to_string(),
1356                    )),
1357                }
1358            }
1359            // Type casting functions - use shared helpers
1360            "STR" => {
1361                Self::require_args_count(&name_upper, args, 1)?;
1362                Self::value_to_str(&args[0])
1363            }
1364            "INT" => {
1365                Self::require_args_count(&name_upper, args, 1)?;
1366                Self::value_to_int(&args[0])
1367            }
1368            "DECIMAL" => {
1369                Self::require_args_count(&name_upper, args, 1)?;
1370                Self::value_to_decimal(&args[0])
1371            }
1372            "BOOL" => {
1373                Self::require_args_count(&name_upper, args, 1)?;
1374                Self::value_to_bool(&args[0])
1375            }
1376            // Date functions for wrapping aggregates: QUARTER(MAX(date))
1377            "QUARTER" => {
1378                Self::require_args_count(&name_upper, args, 1)?;
1379                match &args[0] {
1380                    // beanquery returns a `YYYY-Qn` string, not an integer.
1381                    Value::Date(d) => Ok(Value::String(format!(
1382                        "{:04}-Q{}",
1383                        d.year(),
1384                        (d.month() - 1) / 3 + 1
1385                    ))),
1386                    Value::Null => Ok(Value::Null),
1387                    _ => Err(QueryError::Type("QUARTER expects a date".to_string())),
1388                }
1389            }
1390            "WEEKDAY" => {
1391                Self::require_args_count(&name_upper, args, 1)?;
1392                match &args[0] {
1393                    Value::Date(d) => Ok(Value::String(
1394                        functions::weekday_abbrev(d.weekday().to_monday_zero_offset() as u32)
1395                            .to_string(),
1396                    )),
1397                    _ => Err(QueryError::Type("WEEKDAY expects a date".to_string())),
1398                }
1399            }
1400            "YMONTH" => {
1401                Self::require_args_count(&name_upper, args, 1)?;
1402                match &args[0] {
1403                    Value::Date(d) => {
1404                        Ok(Value::String(format!("{:04}-{:02}", d.year(), d.month())))
1405                    }
1406                    _ => Err(QueryError::Type("YMONTH expects a date".to_string())),
1407                }
1408            }
1409            // String functions for wrapping aggregates
1410            "SUBSTR" | "SUBSTRING" => {
1411                if args.len() < 2 || args.len() > 3 {
1412                    return Err(QueryError::InvalidArguments(
1413                        name_upper,
1414                        "expected 2 or 3 arguments".to_string(),
1415                    ));
1416                }
1417                // Python slice semantics s[start:end] — see `py_slice` /
1418                // `eval_substr`. arg3 is the END index, not a length.
1419                match (&args[0], &args[1], args.get(2)) {
1420                    (Value::String(s), Value::Integer(start), None) => Ok(Value::String(
1421                        functions::string::py_slice(&s.chars().collect::<Vec<_>>(), *start, None),
1422                    )),
1423                    (Value::String(s), Value::Integer(start), Some(Value::Integer(end))) => {
1424                        Ok(Value::String(functions::string::py_slice(
1425                            &s.chars().collect::<Vec<_>>(),
1426                            *start,
1427                            Some(*end),
1428                        )))
1429                    }
1430                    _ => Err(QueryError::Type(
1431                        "SUBSTR expects (string, int, [int])".to_string(),
1432                    )),
1433                }
1434            }
1435            "STARTSWITH" => {
1436                Self::require_args_count(&name_upper, args, 2)?;
1437                match (&args[0], &args[1]) {
1438                    (Value::String(s), Value::String(prefix)) => {
1439                        Ok(Value::Boolean(s.starts_with(prefix.as_str())))
1440                    }
1441                    _ => Err(QueryError::Type(
1442                        "STARTSWITH expects two strings".to_string(),
1443                    )),
1444                }
1445            }
1446            "ENDSWITH" => {
1447                Self::require_args_count(&name_upper, args, 2)?;
1448                match (&args[0], &args[1]) {
1449                    (Value::String(s), Value::String(suffix)) => {
1450                        Ok(Value::Boolean(s.ends_with(suffix.as_str())))
1451                    }
1452                    _ => Err(QueryError::Type("ENDSWITH expects two strings".to_string())),
1453                }
1454            }
1455            "MAXWIDTH" => Self::maxwidth_on_values(args),
1456            // Account function used in GROUP BY
1457            "ACCOUNT_DEPTH" => {
1458                Self::require_args_count(&name_upper, args, 1)?;
1459                match &args[0] {
1460                    Value::String(s) => Ok(Value::Integer(s.matches(':').count() as i64 + 1)),
1461                    _ => Err(QueryError::Type(
1462                        "ACCOUNT_DEPTH expects an account string".to_string(),
1463                    )),
1464                }
1465            }
1466            // Position/amount getters
1467            "GETITEM" | "GET" => {
1468                Self::require_args_count(&name_upper, args, 2)?;
1469                match (&args[0], &args[1]) {
1470                    (Value::Inventory(inv), Value::String(currency)) => {
1471                        let amount = inv.units(currency);
1472                        if amount.is_zero() {
1473                            Ok(Value::Null)
1474                        } else {
1475                            Ok(Value::Amount(Amount::new(amount, currency.as_str())))
1476                        }
1477                    }
1478                    // Metadata / object lookup — mirror the per-row path
1479                    // (`eval_getitem`). Previously only the lazy path handled
1480                    // these, so `getitem(meta, key)` errored in the eager /
1481                    // `#postings` evaluation path.
1482                    (Value::Metadata(meta), Value::String(key)) => {
1483                        Ok(Self::meta_value_to_value(meta.get(key)))
1484                    }
1485                    (Value::Object(obj), Value::String(key)) => {
1486                        Ok(obj.get(key).cloned().unwrap_or(Value::Null))
1487                    }
1488                    (Value::Null, _) => Ok(Value::Null),
1489                    _ => Err(QueryError::Type(
1490                        "GETITEM expects (inventory, string), (metadata, string), or (object, string)"
1491                            .to_string(),
1492                    )),
1493                }
1494            }
1495            "WEIGHT" => {
1496                Self::require_args_count(&name_upper, args, 1)?;
1497                match &args[0] {
1498                    Value::Position(p) => {
1499                        if let Some(cost) = &p.cost {
1500                            let total = p.units.number * cost.number;
1501                            Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
1502                        } else {
1503                            Ok(Value::Amount(p.units.clone()))
1504                        }
1505                    }
1506                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
1507                    Value::Inventory(inv) => {
1508                        let mut result = Inventory::new();
1509                        for pos in inv.positions() {
1510                            if let Some(cost) = &pos.cost {
1511                                let total = pos.units.number * cost.number;
1512                                result.add(Position::simple(Amount::new(
1513                                    total,
1514                                    cost.currency.clone(),
1515                                )));
1516                            } else {
1517                                result.add(Position::simple(pos.units.clone()));
1518                            }
1519                        }
1520                        Ok(Value::Inventory(Box::new(result)))
1521                    }
1522                    Value::Null => Ok(Value::Null),
1523                    _ => Err(QueryError::Type(
1524                        "WEIGHT expects a position, amount, or inventory".to_string(),
1525                    )),
1526                }
1527            }
1528            "DATE" => Self::date_construct_on_values(args),
1529            "DATE_ADD" => Self::date_add_on_values(args),
1530            "DATE_TRUNC" => Self::date_trunc_on_values(args),
1531            "DATE_PART" => Self::date_part_on_values(args),
1532            "PARSE_DATE" => Self::parse_date_on_values(args),
1533            "DATE_BIN" => Self::date_bin_on_values(args),
1534            "INTERVAL" => Self::interval_on_values(args),
1535            // Date: DATE_DIFF for wrapping aggregates like DATE_DIFF(MAX(date), MIN(date))
1536            "DATE_DIFF" => {
1537                Self::require_args_count(&name_upper, args, 2)?;
1538                match (&args[0], &args[1]) {
1539                    (Value::Date(d1), Value::Date(d2)) => Ok(Value::Integer(i64::from(
1540                        d1.since(*d2).unwrap_or_default().get_days(),
1541                    ))),
1542                    _ => Err(QueryError::Type("DATE_DIFF expects two dates".to_string())),
1543                }
1544            }
1545            // String: regex functions for wrapping aggregates
1546            "GREP" => {
1547                Self::require_args_count(&name_upper, args, 2)?;
1548                match (&args[0], &args[1]) {
1549                    (Value::String(pattern), Value::String(s)) => {
1550                        let re = regex::Regex::new(pattern).map_err(|e| {
1551                            QueryError::Type(format!("GREP: invalid regex '{pattern}': {e}"))
1552                        })?;
1553                        match re.find(s) {
1554                            Some(m) => Ok(Value::String(m.as_str().to_string())),
1555                            None => Ok(Value::Null),
1556                        }
1557                    }
1558                    // Null args → Null (e.g., narration is Null for non-transaction entries)
1559                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1560                    _ => Err(QueryError::Type("GREP expects two strings".to_string())),
1561                }
1562            }
1563            "GREPN" => {
1564                Self::require_args_count(&name_upper, args, 3)?;
1565                let n = match &args[2] {
1566                    Value::Integer(i) => (*i).max(0) as usize,
1567                    Value::Number(n) => {
1568                        use rust_decimal::prelude::ToPrimitive;
1569                        n.to_usize().unwrap_or(0)
1570                    }
1571                    _ => {
1572                        return Err(QueryError::Type(
1573                            "GREPN: third argument must be an integer".to_string(),
1574                        ));
1575                    }
1576                };
1577                match (&args[0], &args[1]) {
1578                    (Value::String(pattern), Value::String(s)) => {
1579                        let re = regex::Regex::new(pattern).map_err(|e| {
1580                            QueryError::Type(format!("GREPN: invalid regex '{pattern}': {e}"))
1581                        })?;
1582                        match re.captures(s) {
1583                            Some(caps) => match caps.get(n) {
1584                                Some(m) => Ok(Value::String(m.as_str().to_string())),
1585                                None => Ok(Value::Null),
1586                            },
1587                            None => Ok(Value::Null),
1588                        }
1589                    }
1590                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1591                    _ => Err(QueryError::Type(
1592                        "GREPN expects (pattern, string, int)".to_string(),
1593                    )),
1594                }
1595            }
1596            "SUBST" => {
1597                Self::require_args_count(&name_upper, args, 3)?;
1598                match (&args[0], &args[1], &args[2]) {
1599                    (Value::String(pattern), Value::String(replacement), Value::String(s)) => {
1600                        let re = regex::Regex::new(pattern).map_err(|e| {
1601                            QueryError::Type(format!("SUBST: invalid regex '{pattern}': {e}"))
1602                        })?;
1603                        Ok(Value::String(
1604                            re.replace_all(s, replacement.as_str()).to_string(),
1605                        ))
1606                    }
1607                    _ => Err(QueryError::Type(
1608                        "SUBST expects (pattern, replacement, string)".to_string(),
1609                    )),
1610                }
1611            }
1612            "SPLITCOMP" => {
1613                Self::require_args_count(&name_upper, args, 3)?;
1614                let n = match &args[2] {
1615                    Value::Integer(i) => (*i).max(0) as usize,
1616                    Value::Number(n) => {
1617                        use rust_decimal::prelude::ToPrimitive;
1618                        n.to_usize().unwrap_or(0)
1619                    }
1620                    _ => {
1621                        return Err(QueryError::Type(
1622                            "SPLITCOMP: third argument must be an integer".to_string(),
1623                        ));
1624                    }
1625                };
1626                match (&args[0], &args[1]) {
1627                    (Value::String(s), Value::String(delim)) => {
1628                        let parts: Vec<&str> = s.split(delim.as_str()).collect();
1629                        match parts.get(n) {
1630                            Some(part) => Ok(Value::String((*part).to_string())),
1631                            None => Ok(Value::Null),
1632                        }
1633                    }
1634                    _ => Err(QueryError::Type(
1635                        "SPLITCOMP expects (string, delimiter, int)".to_string(),
1636                    )),
1637                }
1638            }
1639            "JOINSTR" => {
1640                // Mirror the former lazy `eval_joinstr`: require >=1 argument,
1641                // SKIP nulls, and stringify every other non-String/Set arg via
1642                // `value_to_string`, joining with ", " (a comma+space).
1643                if args.is_empty() {
1644                    return Err(QueryError::InvalidArguments(
1645                        "JOINSTR".to_string(),
1646                        "expected at least 1 argument".to_string(),
1647                    ));
1648                }
1649                let mut parts = Vec::new();
1650                for v in args {
1651                    match v {
1652                        Value::String(s) => parts.push(s.clone()),
1653                        Value::StringSet(ss) => parts.extend(ss.iter().cloned()),
1654                        Value::Null => {}
1655                        other => parts.push(Self::value_to_string(other)),
1656                    }
1657                }
1658                Ok(Value::String(parts.join(", ")))
1659            }
1660            // Account metadata functions — look up open/close info
1661            "OPEN_DATE" => {
1662                Self::require_args_count(&name_upper, args, 1)?;
1663                match &args[0] {
1664                    Value::String(account) => Ok(self
1665                        .account_info
1666                        .get(account.as_str())
1667                        .and_then(|info| info.open_date)
1668                        .map_or(Value::Null, Value::Date)),
1669                    Value::Null => Ok(Value::Null),
1670                    _ => Err(QueryError::Type(
1671                        "OPEN_DATE expects an account string".to_string(),
1672                    )),
1673                }
1674            }
1675            "CLOSE_DATE" => {
1676                Self::require_args_count(&name_upper, args, 1)?;
1677                match &args[0] {
1678                    Value::String(account) => Ok(self
1679                        .account_info
1680                        .get(account.as_str())
1681                        .and_then(|info| info.close_date)
1682                        .map_or(Value::Null, Value::Date)),
1683                    Value::Null => Ok(Value::Null),
1684                    _ => Err(QueryError::Type(
1685                        "CLOSE_DATE expects an account string".to_string(),
1686                    )),
1687                }
1688            }
1689            "OPEN_META" => {
1690                Self::require_args_count(&name_upper, args, 2)?;
1691                match (&args[0], &args[1]) {
1692                    (Value::String(account), Value::String(key)) => Ok(self
1693                        .account_info
1694                        .get(account.as_str())
1695                        .and_then(|info| info.open_meta.get(key))
1696                        .map_or(Value::Null, |mv| Self::meta_value_to_value(Some(mv)))),
1697                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1698                    _ => Err(QueryError::Type(
1699                        "OPEN_META expects (account_string, key_string)".to_string(),
1700                    )),
1701                }
1702            }
1703            // Metadata access — returns Null in evaluate_function_on_values
1704            // because metadata is accessed via row context in eval_meta_on_table_row.
1705            // This branch handles edge cases where META is called outside table context.
1706            "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
1707                Self::require_args_count(&name_upper, args, 1)?;
1708                match &args[0] {
1709                    Value::String(_) | Value::Null => Ok(Value::Null),
1710                    _ => Err(QueryError::Type(format!(
1711                        "{name_upper}: argument must be a string key"
1712                    ))),
1713                }
1714            }
1715            // Aggregate functions return Null when evaluated on a single row
1716            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1717            _ => Err(QueryError::UnknownFunction(name.to_string())),
1718        }
1719    }
1720
1721    /// Convert a `Metadata` map to a `Value::Object` for table storage.
1722    fn metadata_to_value(meta: &rustledger_core::Metadata) -> Value {
1723        if meta.is_empty() {
1724            return Value::Null;
1725        }
1726        let map: std::collections::BTreeMap<String, Value> = meta
1727            .iter()
1728            .map(|(k, v)| (k.clone(), Self::meta_value_to_value(Some(v))))
1729            .collect();
1730        Value::Object(Box::new(map))
1731    }
1732
1733    /// Helper to require a specific number of arguments (for pre-evaluated args).
1734    fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1735        if args.len() != expected {
1736            return Err(QueryError::InvalidArguments(
1737                name.to_string(),
1738                format!("expected {} argument(s), got {}", expected, args.len()),
1739            ));
1740        }
1741        Ok(())
1742    }
1743
1744    /// Helper to require a specific number of arguments.
1745    fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1746        if func.args.len() != expected {
1747            return Err(QueryError::InvalidArguments(
1748                name.to_string(),
1749                format!("expected {expected} argument(s)"),
1750            ));
1751        }
1752        Ok(())
1753    }
1754
1755    /// Convert a value to its market value.
1756    ///
1757    /// Shared `VALUE()` implementation used by both expression evaluation and
1758    /// the aggregate/subquery path in `evaluate_function_on_values`.
1759    ///
1760    /// # Arguments
1761    /// * `val` - The value to convert (`Position`, `Amount`, `Inventory`, or `Null`).
1762    /// * `explicit_currency` - Optional explicit target currency. When `None`,
1763    ///   the currency is inferred from the position's cost basis (Python
1764    ///   beancount compatibility) or falls back to the executor's
1765    ///   `target_currency` setting.
1766    /// * `at_date` - Optional valuation date. When `Some`, prices are looked up
1767    ///   with "on or before" semantics via [`price::PriceDatabase::convert`];
1768    ///   when `None`, the latest available price is used via
1769    ///   [`price::PriceDatabase::convert_latest`] (matches Python's
1770    ///   `value(position)` with `date=None`, which may use a future-dated price).
1771    ///
1772    /// # Returns
1773    /// - `Value::Amount` when conversion succeeds, or when the input is a
1774    ///   single `Position`/`Amount` that can't be priced (raw units returned).
1775    /// - `Value::Inventory` when no target currency can be determined and the
1776    ///   input is an `Inventory`.
1777    /// - `Value::Null` when the input is null.
1778    ///
1779    /// # Inventory caveat
1780    ///
1781    /// For `Value::Inventory` inputs with a determined target currency, this
1782    /// function returns a single `Value::Amount` summed in the target currency.
1783    /// Positions within the inventory that cannot be priced at `at_date` (or
1784    /// have no latest price) are silently dropped from the sum. This differs
1785    /// from Python beancount's `inventory.reduce(get_value, ...)`, which
1786    /// preserves unpriced positions as raw units in the resulting inventory.
1787    /// Reconciling this is tracked as a separate follow-up and is out of scope
1788    /// for #892.
1789    pub(crate) fn convert_to_market_value(
1790        &self,
1791        val: &Value,
1792        explicit_currency: Option<&str>,
1793        at_date: Option<NaiveDate>,
1794    ) -> Result<Value, QueryError> {
1795        // Column-type stability (#1701): the one-argument form infers the
1796        // target currency PER ROW (cost currency, else executor default), so
1797        // an Amount-vs-Inventory return that depends on the row's data makes
1798        // the column type unstable — the FFI layer declares the type from one
1799        // row and other rows then contradict it. The rule:
1800        //   - explicit currency (two-arg form): target is constant across the
1801        //     query -> Amount for every row (existing behavior, stable);
1802        //   - one-arg form over an Inventory: ALWAYS return an Inventory
1803        //     (beanquery parity: value(inventory) is inventory-typed), whether
1804        //     or not a target currency could be inferred for this row.
1805        let inventory_stays_inventory = explicit_currency.is_none();
1806        // Determine target currency:
1807        // 1. Explicit argument takes precedence
1808        // 2. Infer from position's cost currency (beancount compatibility)
1809        // 3. Fall back to executor's target_currency setting
1810        let target_currency = if let Some(currency) = explicit_currency {
1811            currency.to_string()
1812        } else {
1813            // Try to infer from cost currency
1814            let inferred = match val {
1815                Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1816                Value::Inventory(inv) => inv
1817                    .positions()
1818                    .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1819                _ => None,
1820            };
1821
1822            match inferred.or_else(|| self.target_currency.clone()) {
1823                Some(c) => c,
1824                None => {
1825                    // No currency can be determined — return value as-is
1826                    // (matches Python beancount behavior for positions without cost).
1827                    // Note: `at_date` is ignored here because there is nothing to
1828                    // convert without a target currency.
1829                    return match val {
1830                        Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1831                        Value::Amount(a) => Ok(Value::Amount(a.clone())),
1832                        Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1833                        Value::Null => Ok(Value::Null),
1834                        _ => Err(QueryError::Type(
1835                            "VALUE expects a position, amount, or inventory".to_string(),
1836                        )),
1837                    };
1838                }
1839            }
1840        };
1841
1842        // Price lookup matches Python beancount's semantics:
1843        // - When `at_date` is None, use the latest price (which may be future-dated).
1844        // - When `at_date` is Some, use the most recent price on or before that date;
1845        //   if no such price exists, the conversion silently returns the raw units.
1846        let convert_one = |amount: &Amount| -> Option<Amount> {
1847            match at_date {
1848                Some(d) => self.price_db.convert(amount, &target_currency, d),
1849                None => self.price_db.convert_latest(amount, &target_currency),
1850            }
1851        };
1852
1853        match val {
1854            Value::Position(p) => {
1855                if p.units.currency == target_currency {
1856                    Ok(Value::Amount(p.units.clone()))
1857                } else if let Some(converted) = convert_one(&p.units) {
1858                    Ok(Value::Amount(converted))
1859                } else {
1860                    Ok(Value::Amount(p.units.clone()))
1861                }
1862            }
1863            Value::Amount(a) => {
1864                if a.currency == target_currency {
1865                    Ok(Value::Amount(a.clone()))
1866                } else if let Some(converted) = convert_one(a) {
1867                    Ok(Value::Amount(converted))
1868                } else {
1869                    Ok(Value::Amount(a.clone()))
1870                }
1871            }
1872            Value::Inventory(inv) => {
1873                if inventory_stays_inventory {
1874                    // Convert per position; a position with no available price
1875                    // keeps its raw units (matching the Position/Amount arms
1876                    // above and beanquery, which never drops positions).
1877                    let mut out = rustledger_core::Inventory::new();
1878                    for pos in inv.positions() {
1879                        let units = if pos.units.currency == target_currency {
1880                            pos.units.clone()
1881                        } else if let Some(converted) = convert_one(&pos.units) {
1882                            converted
1883                        } else {
1884                            pos.units.clone()
1885                        };
1886                        out.add(rustledger_core::Position::simple(units));
1887                    }
1888                    return Ok(Value::Inventory(Box::new(out)));
1889                }
1890                // Two-arg form: collapse to a single Amount in the explicit
1891                // target currency. NOTE (pre-existing beanquery divergence,
1892                // out of #1701's scope): positions with no available price are
1893                // dropped from the total here; beanquery would keep them as
1894                // their original units in an Inventory result.
1895                let mut total = Decimal::ZERO;
1896                for pos in inv.positions() {
1897                    if pos.units.currency == target_currency {
1898                        total += pos.units.number;
1899                    } else if let Some(converted) = convert_one(&pos.units) {
1900                        total += converted.number;
1901                    }
1902                }
1903                Ok(Value::Amount(Amount::new(total, &target_currency)))
1904            }
1905            Value::Null => Ok(Value::Null),
1906            _ => Err(QueryError::Type(
1907                "VALUE expects a position, amount, or inventory".to_string(),
1908            )),
1909        }
1910    }
1911
1912    /// Check if an expression is a window function.
1913    pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1914        matches!(expr, Expr::Window(_))
1915    }
1916
1917    /// Resolve column names from targets.
1918    fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1919        let mut names = Vec::new();
1920        for (i, target) in targets.iter().enumerate() {
1921            if matches!(target.expr, Expr::Wildcard) {
1922                // Check wildcard BEFORE alias to catch `SELECT * AS alias` edge case
1923                if target.alias.is_some() {
1924                    return Err(QueryError::Evaluation(
1925                        "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1926                    ));
1927                }
1928                // Expand wildcard using shared constant (must match evaluate_row expansion)
1929                names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1930            } else if let Some(alias) = &target.alias {
1931                names.push(alias.clone());
1932            } else {
1933                names.push(self.expr_to_name(&target.expr, i));
1934            }
1935        }
1936        Ok(names)
1937    }
1938
1939    /// Convert an expression to a column name.
1940    fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1941        match expr {
1942            Expr::Wildcard => "*".to_string(),
1943            Expr::Column(name) => name.clone(),
1944            Expr::Function(func) => func.name.clone(),
1945            Expr::Window(wf) => wf.name.clone(),
1946            _ => format!("col{index}"),
1947        }
1948    }
1949
1950    /// Get a built-in system table by name.
1951    ///
1952    /// Built-in tables are virtual tables that provide access to ledger data:
1953    /// - `#prices` / `prices`: Price directives from the ledger
1954    /// - `#balances` / `balances`: Balance assertion directives from the ledger
1955    /// - `#commodities` / `commodities`: Commodity directives from the ledger
1956    /// - `#events` / `events`: Event directives from the ledger
1957    /// - `#notes` / `notes`: Note directives from the ledger
1958    /// - `#documents` / `documents`: Document directives from the ledger
1959    /// - `#accounts` / `accounts`: Open/Close directives paired by account
1960    /// - `#transactions` / `transactions`: Transaction directives from the ledger
1961    /// - `#entries` / `entries`: All directives with source location info
1962    /// - `#postings` / `postings`: All postings from transactions
1963    ///
1964    /// Both `#`-prefixed and non-prefixed names are supported for Python beancount
1965    /// compatibility (issue #632).
1966    ///
1967    /// Returns `None` if the table name is not a recognized built-in table.
1968    pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1969        // Normalize table name: strip # prefix if present for Python beancount compatibility.
1970        // Both "#transactions" (rustledger) and "transactions" (beancount) work.
1971        // Using strip_prefix avoids allocation in the common case.
1972        let upper = table_name.to_uppercase();
1973        let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1974
1975        match normalized {
1976            "PRICES" => Some(self.build_prices_table()),
1977            "BALANCES" => Some(self.build_balances_table()),
1978            "COMMODITIES" => Some(self.build_commodities_table()),
1979            "EVENTS" => Some(self.build_events_table()),
1980            "NOTES" => Some(self.build_notes_table()),
1981            "DOCUMENTS" => Some(self.build_documents_table()),
1982            "ACCOUNTS" => Some(self.build_accounts_table()),
1983            "TRANSACTIONS" => Some(self.build_transactions_table()),
1984            "ENTRIES" => Some(self.build_entries_table()),
1985            "POSTINGS" => Some(self.build_postings_table()),
1986            _ => None,
1987        }
1988    }
1989}
1990
1991/// Walk an [`Expr`] tree, returning `true` if any [`Expr::Column`]
1992/// references the given column name (case-insensitive).
1993///
1994/// Used to decide whether [`Executor::collect_postings`] needs to
1995/// materialize the per-posting `balance` / `account_balance` snapshots
1996/// — they're expensive (cumulative `Inventory` clones per posting,
1997/// the runaway cost in #1080) so we skip the work when no part of the
1998/// query reads them.
1999fn expr_references_column(expr: &Expr, name: &str) -> bool {
2000    match expr {
2001        Expr::Column(col) => col.eq_ignore_ascii_case(name),
2002        Expr::Function(call) => call.args.iter().any(|a| expr_references_column(a, name)),
2003        Expr::Window(call) => {
2004            // Function args + the OVER clause's PARTITION BY / ORDER BY
2005            // expressions all need to be walked — a window function like
2006            // `SUM(amount) OVER (PARTITION BY balance)` references
2007            // `balance` in the partition-by, not the function args.
2008            // Caught by Copilot review on PR #1085.
2009            call.args.iter().any(|a| expr_references_column(a, name))
2010                || call
2011                    .over
2012                    .partition_by
2013                    .as_ref()
2014                    .is_some_and(|ps| ps.iter().any(|p| expr_references_column(p, name)))
2015                || call
2016                    .over
2017                    .order_by
2018                    .as_ref()
2019                    .is_some_and(|os| os.iter().any(|o| expr_references_column(&o.expr, name)))
2020        }
2021        Expr::BinaryOp(op) => {
2022            expr_references_column(&op.left, name) || expr_references_column(&op.right, name)
2023        }
2024        Expr::UnaryOp(op) => expr_references_column(&op.operand, name),
2025        Expr::Paren(inner) => expr_references_column(inner, name),
2026        Expr::Between { value, low, high } => {
2027            expr_references_column(value, name)
2028                || expr_references_column(low, name)
2029                || expr_references_column(high, name)
2030        }
2031        Expr::Set(items) => items.iter().any(|i| expr_references_column(i, name)),
2032        Expr::Wildcard | Expr::Literal(_) => false,
2033    }
2034}
2035
2036/// Return `true` if any part of a `SelectQuery` references the given
2037/// column. Walks SELECT targets, WHERE, GROUP BY, HAVING, PIVOT BY,
2038/// ORDER BY, and the FROM filter expression. A subquery in FROM is
2039/// treated as opaque — its inner references don't surface to the
2040/// outer query's posting iterator.
2041fn query_references_column(query: &SelectQuery, name: &str) -> bool {
2042    if query
2043        .targets
2044        .iter()
2045        .any(|t| expr_references_column(&t.expr, name))
2046    {
2047        return true;
2048    }
2049    if let Some(w) = &query.where_clause
2050        && expr_references_column(w, name)
2051    {
2052        return true;
2053    }
2054    if let Some(g) = &query.group_by
2055        && g.iter().any(|e| expr_references_column(e, name))
2056    {
2057        return true;
2058    }
2059    if let Some(h) = &query.having
2060        && expr_references_column(h, name)
2061    {
2062        return true;
2063    }
2064    if let Some(p) = &query.pivot_by
2065        && p.iter().any(|e| expr_references_column(e, name))
2066    {
2067        return true;
2068    }
2069    if let Some(o) = &query.order_by
2070        && o.iter().any(|s| expr_references_column(&s.expr, name))
2071    {
2072        return true;
2073    }
2074    if let Some(from) = &query.from
2075        && let Some(f) = &from.filter
2076        && expr_references_column(f, name)
2077    {
2078        return true;
2079    }
2080    false
2081}
2082
2083#[cfg(test)]
2084mod tests;
2085
2086#[cfg(test)]
2087mod dual_eval_parity;