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                    _ => Err(QueryError::Type("YEAR expects a date".to_string())),
701                }
702            }
703            "MONTH" => {
704                Self::require_args_count(&name_upper, args, 1)?;
705                match &args[0] {
706                    Value::Date(d) => Ok(Value::Integer(d.month().into())),
707                    _ => Err(QueryError::Type("MONTH expects a date".to_string())),
708                }
709            }
710            "DAY" => {
711                Self::require_args_count(&name_upper, args, 1)?;
712                match &args[0] {
713                    Value::Date(d) => Ok(Value::Integer(d.day().into())),
714                    _ => Err(QueryError::Type("DAY expects a date".to_string())),
715                }
716            }
717            // String functions
718            "LENGTH" => {
719                Self::require_args_count(&name_upper, args, 1)?;
720                match &args[0] {
721                    // Count Unicode characters, not UTF-8 bytes (matches beanquery).
722                    Value::String(s) => Ok(Value::Integer(s.chars().count() as i64)),
723                    Value::StringSet(s) => Ok(Value::Integer(s.len() as i64)),
724                    _ => Err(QueryError::Type(
725                        "LENGTH expects a string or set".to_string(),
726                    )),
727                }
728            }
729            "UPPER" => {
730                Self::require_args_count(&name_upper, args, 1)?;
731                match &args[0] {
732                    Value::String(s) => Ok(Value::String(s.to_uppercase())),
733                    _ => Err(QueryError::Type("UPPER expects a string".to_string())),
734                }
735            }
736            "LOWER" => {
737                Self::require_args_count(&name_upper, args, 1)?;
738                match &args[0] {
739                    Value::String(s) => Ok(Value::String(s.to_lowercase())),
740                    _ => Err(QueryError::Type("LOWER expects a string".to_string())),
741                }
742            }
743            "TRIM" => {
744                Self::require_args_count(&name_upper, args, 1)?;
745                match &args[0] {
746                    Value::String(s) => Ok(Value::String(s.trim().to_string())),
747                    _ => Err(QueryError::Type("TRIM expects a string".to_string())),
748                }
749            }
750            // Math functions
751            "ABS" => {
752                Self::require_args_count(&name_upper, args, 1)?;
753                match &args[0] {
754                    Value::Number(n) => Ok(Value::Number(n.abs())),
755                    Value::Integer(i) => Ok(Value::Integer(i.abs())),
756                    _ => Err(QueryError::Type("ABS expects a number".to_string())),
757                }
758            }
759            "ROUND" => Self::round_on_values(args),
760            // Utility functions
761            "COALESCE" => {
762                for arg in args {
763                    if !matches!(arg, Value::Null) {
764                        return Ok(arg.clone());
765                    }
766                }
767                Ok(Value::Null)
768            }
769            // Position/Amount functions
770            "NUMBER" => {
771                Self::require_args_count(&name_upper, args, 1)?;
772                match &args[0] {
773                    Value::Amount(a) => Ok(Value::Number(a.number)),
774                    Value::Position(p) => Ok(Value::Number(p.units.number)),
775                    Value::Number(n) => Ok(Value::Number(*n)),
776                    Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
777                    Value::Inventory(inv) => {
778                        // For inventory, only return a number if all positions share the same
779                        // currency. Summing across different currencies is not meaningful.
780                        // Single pass: track the first currency and running total, bail out
781                        // to Null on any currency mismatch.
782                        let mut iter = inv.positions();
783                        let Some(first) = iter.next() else {
784                            return Ok(Value::Number(Decimal::ZERO));
785                        };
786                        let first_currency = &first.units.currency;
787                        let mut total = first.units.number;
788                        for pos in iter {
789                            if &pos.units.currency != first_currency {
790                                return Ok(Value::Null);
791                            }
792                            total += pos.units.number;
793                        }
794                        Ok(Value::Number(total))
795                    }
796                    Value::Null => Ok(Value::Null),
797                    _ => Err(QueryError::Type(
798                        "NUMBER expects an amount, position, or inventory".to_string(),
799                    )),
800                }
801            }
802            "CURRENCY" => {
803                Self::require_args_count(&name_upper, args, 1)?;
804                match &args[0] {
805                    Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
806                    Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
807                    Value::Inventory(inv) => {
808                        // Return the currency of the first position, or Null if empty
809                        if let Some(pos) = inv.positions().next() {
810                            Ok(Value::String(pos.units.currency.to_string()))
811                        } else {
812                            Ok(Value::Null)
813                        }
814                    }
815                    Value::Null => Ok(Value::Null),
816                    _ => Err(QueryError::Type(
817                        "CURRENCY expects an amount or position".to_string(),
818                    )),
819                }
820            }
821            "UNITS" => {
822                Self::require_args_count(&name_upper, args, 1)?;
823                match &args[0] {
824                    Value::Position(p) => Ok(Value::Amount(p.units.clone())),
825                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
826                    Value::Inventory(inv) => {
827                        // Return inventory with just units (no cost info)
828                        let mut units_inv = Inventory::new();
829                        for pos in inv.positions() {
830                            units_inv.add(Position::simple(pos.units.clone()));
831                        }
832                        Ok(Value::Inventory(Box::new(units_inv)))
833                    }
834                    Value::Null => Ok(Value::Null),
835                    _ => Err(QueryError::Type(
836                        "UNITS expects a position or inventory".to_string(),
837                    )),
838                }
839            }
840            "COST" => {
841                Self::require_args_count(&name_upper, args, 1)?;
842                match &args[0] {
843                    Value::Position(p) => {
844                        if let Some(cost) = &p.cost {
845                            // Preserve sign: buys give positive cost, sells give negative
846                            let total = p.units.number * cost.number;
847                            Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
848                        } else {
849                            Ok(Value::Amount(p.units.clone()))
850                        }
851                    }
852                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
853                    Value::Inventory(inv) => {
854                        let mut total = Decimal::ZERO;
855                        let mut currency: Option<rustledger_core::Currency> = None;
856                        for pos in inv.positions() {
857                            if let Some(cost) = &pos.cost {
858                                total += pos.units.number * cost.number;
859                                if currency.is_none() {
860                                    currency = Some(cost.currency.clone());
861                                }
862                            } else {
863                                total += pos.units.number;
864                                if currency.is_none() {
865                                    currency = Some(pos.units.currency.clone());
866                                }
867                            }
868                        }
869                        if let Some(curr) = currency {
870                            Ok(Value::Amount(Amount::new(total, curr)))
871                        } else {
872                            Ok(Value::Null)
873                        }
874                    }
875                    Value::Null => Ok(Value::Null),
876                    _ => Err(QueryError::Type(
877                        "COST expects a position or inventory".to_string(),
878                    )),
879                }
880            }
881            "VALUE" => {
882                // Use shared VALUE implementation for consistent behavior.
883                // See `eval_value` on PositionFunctions for the full signature
884                // contract (DATE vs. currency-string dispatch).
885                if args.is_empty() || args.len() > 2 {
886                    return Err(QueryError::InvalidArguments(
887                        "VALUE".to_string(),
888                        "expected 1-2 arguments".to_string(),
889                    ));
890                }
891                let (explicit_currency, at_date) = if args.len() == 2 {
892                    match &args[1] {
893                        Value::Date(d) => (None, Some(*d)),
894                        Value::String(s) => (Some(s.as_str()), None),
895                        Value::Null => {
896                            return Err(QueryError::Type(
897                                concat!(
898                                    "VALUE: second argument evaluated to NULL; ",
899                                    "expected a date or currency string ",
900                                    "(this often means an aggregate expression couldn't ",
901                                    "evaluate against an empty group — see issue #902)",
902                                )
903                                .to_string(),
904                            ));
905                        }
906                        _ => {
907                            return Err(QueryError::Type(
908                                "VALUE second argument must be a date or currency string"
909                                    .to_string(),
910                            ));
911                        }
912                    }
913                } else {
914                    (None, None)
915                };
916                self.convert_to_market_value(&args[0], explicit_currency, at_date)
917            }
918            // Math functions
919            "SAFEDIV" => {
920                Self::require_args_count(&name_upper, args, 2)?;
921                let (dividend, divisor) = (&args[0], &args[1]);
922                match (dividend, divisor) {
923                    // NULL propagates.
924                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
925                    // Any numeric pair: coerce to Decimal and divide. A zero
926                    // divisor yields 0 (the "safe" in SAFEDIV) — matching
927                    // beanquery and the per-row `eval_safediv` path, which used to
928                    // disagree (this path returned NULL on a zero divisor).
929                    _ => {
930                        let to_dec = |v: &Value| match v {
931                            Value::Number(n) => Some(*n),
932                            Value::Integer(i) => Some(Decimal::from(*i)),
933                            _ => None,
934                        };
935                        match (to_dec(dividend), to_dec(divisor)) {
936                            (Some(a), Some(b)) => Ok(Value::Number(if b.is_zero() {
937                                Decimal::ZERO
938                            } else {
939                                a / b
940                            })),
941                            _ => Err(QueryError::Type(
942                                "SAFEDIV expects numeric arguments".to_string(),
943                            )),
944                        }
945                    }
946                }
947            }
948            "NEG" => {
949                Self::require_args_count(&name_upper, args, 1)?;
950                match &args[0] {
951                    Value::Number(n) => Ok(Value::Number(-n)),
952                    Value::Integer(i) => Ok(Value::Integer(-i)),
953                    Value::Amount(a) => {
954                        Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
955                    }
956                    _ => Err(QueryError::Type(
957                        "NEG expects a number or amount".to_string(),
958                    )),
959                }
960            }
961            // Account functions
962            "ACCOUNT_SORTKEY" => {
963                Self::require_args_count(&name_upper, args, 1)?;
964                match &args[0] {
965                    Value::String(s) => {
966                        let type_index = Self::account_type_index(s);
967                        Ok(Value::String(format!("{type_index}-{s}")))
968                    }
969                    _ => Err(QueryError::Type(
970                        "ACCOUNT_SORTKEY expects an account string".to_string(),
971                    )),
972                }
973            }
974            "PARENT" => {
975                Self::require_args_count(&name_upper, args, 1)?;
976                match &args[0] {
977                    Value::String(s) => {
978                        if let Some(idx) = s.rfind(':') {
979                            Ok(Value::String(s[..idx].to_string()))
980                        } else {
981                            Ok(Value::Null)
982                        }
983                    }
984                    _ => Err(QueryError::Type(
985                        "PARENT expects an account string".to_string(),
986                    )),
987                }
988            }
989            "LEAF" => {
990                Self::require_args_count(&name_upper, args, 1)?;
991                match &args[0] {
992                    Value::String(s) => {
993                        if let Some(idx) = s.rfind(':') {
994                            Ok(Value::String(s[idx + 1..].to_string()))
995                        } else {
996                            Ok(Value::String(s.clone()))
997                        }
998                    }
999                    _ => Err(QueryError::Type(
1000                        "LEAF expects an account string".to_string(),
1001                    )),
1002                }
1003            }
1004            "ROOT" => {
1005                if args.is_empty() || args.len() > 2 {
1006                    return Err(QueryError::InvalidArguments(
1007                        "ROOT".to_string(),
1008                        "expected 1 or 2 arguments".to_string(),
1009                    ));
1010                }
1011                let n = if args.len() == 2 {
1012                    let raw = match &args[1] {
1013                        Value::Integer(i) => *i,
1014                        _ => {
1015                            return Err(QueryError::Type(
1016                                "ROOT second arg must be integer".to_string(),
1017                            ));
1018                        }
1019                    };
1020                    // Reject negatives explicitly — `i as usize` would silently
1021                    // turn -1 into `usize::MAX` and return the whole account.
1022                    // Mirrors the lazy `eval_root` guard so both paths agree.
1023                    usize::try_from(raw).map_err(|_| {
1024                        QueryError::Type(format!(
1025                            "ROOT second arg must be a non-negative integer, got {raw}"
1026                        ))
1027                    })?
1028                } else {
1029                    1
1030                };
1031                match &args[0] {
1032                    Value::String(s) => {
1033                        let parts: Vec<&str> = s.split(':').collect();
1034                        if n >= parts.len() {
1035                            Ok(Value::String(s.clone()))
1036                        } else {
1037                            Ok(Value::String(parts[..n].join(":")))
1038                        }
1039                    }
1040                    _ => Err(QueryError::Type(
1041                        "ROOT expects an account string".to_string(),
1042                    )),
1043                }
1044            }
1045            // ONLY function: extract single-currency amount from inventory
1046            "ONLY" => {
1047                Self::require_args_count(&name_upper, args, 2)?;
1048                let currency = match &args[0] {
1049                    Value::String(s) => s.clone(),
1050                    // NULL propagates (beanquery parity): `first(cost_currency)`
1051                    // is NULL for groups without costs, and fava's Holdings
1052                    // by_currency query feeds exactly that into only() — see
1053                    // #1699. The second-argument match below already
1054                    // propagates; the asymmetry was the bug.
1055                    Value::Null => return Ok(Value::Null),
1056                    _ => {
1057                        return Err(QueryError::Type(
1058                            "ONLY: first argument must be a currency string".to_string(),
1059                        ));
1060                    }
1061                };
1062                match &args[1] {
1063                    Value::Inventory(inv) => {
1064                        let total = inv.units(&currency);
1065                        if total.is_zero() {
1066                            Ok(Value::Null)
1067                        } else {
1068                            Ok(Value::Amount(Amount::new(total, &currency)))
1069                        }
1070                    }
1071                    Value::Position(p) => {
1072                        if p.units.currency.as_str() == currency {
1073                            Ok(Value::Amount(p.units.clone()))
1074                        } else {
1075                            Ok(Value::Null)
1076                        }
1077                    }
1078                    Value::Amount(a) => {
1079                        if a.currency.as_str() == currency {
1080                            Ok(Value::Amount(a.clone()))
1081                        } else {
1082                            Ok(Value::Null)
1083                        }
1084                    }
1085                    Value::Null => Ok(Value::Null),
1086                    _ => Err(QueryError::Type(
1087                        "ONLY: second argument must be an inventory, position, or amount"
1088                            .to_string(),
1089                    )),
1090                }
1091            }
1092            // GETPRICE function - needs price database
1093            "GETPRICE" => {
1094                if args.len() < 2 || args.len() > 3 {
1095                    return Err(QueryError::InvalidArguments(
1096                        "GETPRICE".to_string(),
1097                        "expected 2 or 3 arguments".to_string(),
1098                    ));
1099                }
1100                // Handle NULL arguments gracefully
1101                let base = match &args[0] {
1102                    Value::String(s) => s.clone(),
1103                    Value::Null => return Ok(Value::Null),
1104                    _ => {
1105                        return Err(QueryError::Type(
1106                            "GETPRICE: first argument must be a currency string".to_string(),
1107                        ));
1108                    }
1109                };
1110                let quote = match &args[1] {
1111                    Value::String(s) => s.clone(),
1112                    Value::Null => return Ok(Value::Null),
1113                    _ => {
1114                        return Err(QueryError::Type(
1115                            "GETPRICE: second argument must be a currency string".to_string(),
1116                        ));
1117                    }
1118                };
1119                let date = if args.len() == 3 {
1120                    match &args[2] {
1121                        Value::Date(d) => *d,
1122                        Value::Null => self.query_date,
1123                        _ => self.query_date,
1124                    }
1125                } else {
1126                    self.query_date
1127                };
1128                match self.price_db.get_price(&base, &quote, date) {
1129                    Some(price) => Ok(Value::Number(price)),
1130                    None => Ok(Value::Null),
1131                }
1132            }
1133            // Inventory functions
1134            "EMPTY" => {
1135                Self::require_args_count(&name_upper, args, 1)?;
1136                match &args[0] {
1137                    Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
1138                    Value::Null => Ok(Value::Boolean(true)),
1139                    _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
1140                }
1141            }
1142            "FILTER_CURRENCY" => {
1143                Self::require_args_count(&name_upper, args, 2)?;
1144                let currency = match &args[1] {
1145                    Value::String(s) => s.clone(),
1146                    _ => {
1147                        return Err(QueryError::Type(
1148                            "FILTER_CURRENCY expects (inventory, string)".to_string(),
1149                        ));
1150                    }
1151                };
1152                match &args[0] {
1153                    Value::Inventory(inv) => {
1154                        let filtered: Vec<Position> = inv
1155                            .positions()
1156                            .filter(|p| p.units.currency.as_str() == currency)
1157                            .cloned()
1158                            .collect();
1159                        let mut new_inv = Inventory::new();
1160                        for pos in filtered {
1161                            new_inv.add(pos);
1162                        }
1163                        Ok(Value::Inventory(Box::new(new_inv)))
1164                    }
1165                    Value::Null => Ok(Value::Null),
1166                    _ => Err(QueryError::Type(
1167                        "FILTER_CURRENCY expects (inventory, string)".to_string(),
1168                    )),
1169                }
1170            }
1171            "POSSIGN" => {
1172                Self::require_args_count(&name_upper, args, 2)?;
1173                let account_str = match &args[1] {
1174                    Value::String(s) => s.clone(),
1175                    _ => {
1176                        return Err(QueryError::Type(
1177                            "POSSIGN expects (amount, account_string)".to_string(),
1178                        ));
1179                    }
1180                };
1181                let first_component = account_str.split(':').next().unwrap_or("");
1182                let is_credit_normal =
1183                    matches!(first_component, "Liabilities" | "Equity" | "Income");
1184                match &args[0] {
1185                    Value::Amount(a) => {
1186                        let mut amt = a.clone();
1187                        if is_credit_normal {
1188                            amt.number = -amt.number;
1189                        }
1190                        Ok(Value::Amount(amt))
1191                    }
1192                    Value::Number(n) => {
1193                        let adjusted = if is_credit_normal { -n } else { *n };
1194                        Ok(Value::Number(adjusted))
1195                    }
1196                    // Mirror the lazy `POSSIGN`: an integer amount is treated as
1197                    // a number and sign-adjusted.
1198                    Value::Integer(i) => {
1199                        let n = Decimal::from(*i);
1200                        let adjusted = if is_credit_normal { -n } else { n };
1201                        Ok(Value::Number(adjusted))
1202                    }
1203                    Value::Null => Ok(Value::Null),
1204                    _ => Err(QueryError::Type(
1205                        "POSSIGN expects (amount, account_string)".to_string(),
1206                    )),
1207                }
1208            }
1209            // CONVERT function - convert amounts/positions/inventories to target currency
1210            "CONVERT" => {
1211                if args.len() < 2 || args.len() > 3 {
1212                    return Err(QueryError::InvalidArguments(
1213                        "CONVERT".to_string(),
1214                        "expected 2 or 3 arguments: (value, currency[, date])".to_string(),
1215                    ));
1216                }
1217
1218                let target_currency = match &args[1] {
1219                    Value::String(s) => s.clone(),
1220                    Value::Null => {
1221                        return Err(QueryError::Type(
1222                            concat!(
1223                                "CONVERT: second argument evaluated to NULL; ",
1224                                "expected a currency string ",
1225                                "(this often means an aggregate expression couldn't ",
1226                                "evaluate against an empty group — see issue #902)",
1227                            )
1228                            .to_string(),
1229                        ));
1230                    }
1231                    _ => {
1232                        return Err(QueryError::Type(
1233                            "CONVERT: second argument must be a currency string".to_string(),
1234                        ));
1235                    }
1236                };
1237
1238                // Optional date argument
1239                let date: Option<rustledger_core::NaiveDate> = if args.len() == 3 {
1240                    match &args[2] {
1241                        Value::Date(d) => Some(*d),
1242                        Value::Null => None, // NULL date uses latest price
1243                        _ => {
1244                            return Err(QueryError::Type(
1245                                "CONVERT: third argument must be a date".to_string(),
1246                            ));
1247                        }
1248                    }
1249                } else {
1250                    None
1251                };
1252
1253                // Helper closure to convert an amount
1254                let convert_amount = |amt: &Amount| -> Option<Amount> {
1255                    if let Some(d) = date {
1256                        self.price_db.convert(amt, &target_currency, d)
1257                    } else {
1258                        self.price_db.convert_latest(amt, &target_currency)
1259                    }
1260                };
1261
1262                match &args[0] {
1263                    Value::Position(p) => {
1264                        if p.units.currency == target_currency {
1265                            Ok(Value::Amount(p.units.clone()))
1266                        } else if let Some(converted) = convert_amount(&p.units) {
1267                            Ok(Value::Amount(converted))
1268                        } else {
1269                            Ok(Value::Amount(p.units.clone()))
1270                        }
1271                    }
1272                    Value::Amount(a) => {
1273                        if a.currency == target_currency {
1274                            Ok(Value::Amount(a.clone()))
1275                        } else if let Some(converted) = convert_amount(a) {
1276                            Ok(Value::Amount(converted))
1277                        } else {
1278                            Ok(Value::Amount(a.clone()))
1279                        }
1280                    }
1281                    Value::Inventory(inv) => {
1282                        // Convert each position, keeping originals when no conversion available
1283                        // (matches Python beancount behavior)
1284                        let mut result = Inventory::default();
1285                        for pos in inv.positions() {
1286                            if pos.units.currency == target_currency {
1287                                result.add(Position::simple(pos.units.clone()));
1288                            } else if let Some(converted) = convert_amount(&pos.units) {
1289                                result.add(Position::simple(converted));
1290                            } else {
1291                                // No conversion available - keep original (Python beancount behavior)
1292                                result.add(Position::simple(pos.units.clone()));
1293                            }
1294                        }
1295                        // If result has single currency matching target, return as Amount
1296                        // If result is empty, return zero in target currency (issue #586)
1297                        let positions: Vec<&Position> = result.positions().collect();
1298                        if positions.is_empty() {
1299                            Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1300                        } else if positions.len() == 1
1301                            && positions[0].units.currency == target_currency
1302                        {
1303                            Ok(Value::Amount(positions[0].units.clone()))
1304                        } else {
1305                            Ok(Value::Inventory(Box::new(result)))
1306                        }
1307                    }
1308                    Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
1309                    Value::String(s) => {
1310                        // String input is a rustledger extension (issue #1179),
1311                        // not present in Python beancount. Lets users write
1312                        // ad-hoc currency conversions like
1313                        // `SELECT CONVERT('100 USD', 'EUR')` without anchoring
1314                        // them to a posting. Strict parser (see
1315                        // `Amount::from_str`): malformed input surfaces as a
1316                        // typed `QueryError` rather than a silent zero or a
1317                        // panic.
1318                        let amt: Amount = s.parse().map_err(|e| {
1319                            QueryError::Type(format!(
1320                                "CONVERT: first argument {e} (e.g. \"100 USD\")"
1321                            ))
1322                        })?;
1323                        if amt.currency == target_currency {
1324                            Ok(Value::Amount(amt))
1325                        } else if let Some(converted) = convert_amount(&amt) {
1326                            Ok(Value::Amount(converted))
1327                        } else {
1328                            // Match the `Value::Amount` arm: no price available
1329                            // → return original unchanged.
1330                            Ok(Value::Amount(amt))
1331                        }
1332                    }
1333                    Value::Null => {
1334                        // For null values (e.g., empty sum), return zero in target currency
1335                        // This matches Python beancount behavior for empty balances (issue #586)
1336                        Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1337                    }
1338                    _ => Err(QueryError::Type(
1339                        "CONVERT expects a position, amount, inventory, number, or amount-string"
1340                            .to_string(),
1341                    )),
1342                }
1343            }
1344            // Type casting functions - use shared helpers
1345            "STR" => {
1346                Self::require_args_count(&name_upper, args, 1)?;
1347                Self::value_to_str(&args[0])
1348            }
1349            "INT" => {
1350                Self::require_args_count(&name_upper, args, 1)?;
1351                Self::value_to_int(&args[0])
1352            }
1353            "DECIMAL" => {
1354                Self::require_args_count(&name_upper, args, 1)?;
1355                Self::value_to_decimal(&args[0])
1356            }
1357            "BOOL" => {
1358                Self::require_args_count(&name_upper, args, 1)?;
1359                Self::value_to_bool(&args[0])
1360            }
1361            // Date functions for wrapping aggregates: QUARTER(MAX(date))
1362            "QUARTER" => {
1363                Self::require_args_count(&name_upper, args, 1)?;
1364                match &args[0] {
1365                    // beanquery returns a `YYYY-Qn` string, not an integer.
1366                    Value::Date(d) => Ok(Value::String(format!(
1367                        "{:04}-Q{}",
1368                        d.year(),
1369                        (d.month() - 1) / 3 + 1
1370                    ))),
1371                    _ => Err(QueryError::Type("QUARTER expects a date".to_string())),
1372                }
1373            }
1374            "WEEKDAY" => {
1375                Self::require_args_count(&name_upper, args, 1)?;
1376                match &args[0] {
1377                    Value::Date(d) => Ok(Value::String(
1378                        functions::weekday_abbrev(d.weekday().to_monday_zero_offset() as u32)
1379                            .to_string(),
1380                    )),
1381                    _ => Err(QueryError::Type("WEEKDAY expects a date".to_string())),
1382                }
1383            }
1384            "YMONTH" => {
1385                Self::require_args_count(&name_upper, args, 1)?;
1386                match &args[0] {
1387                    Value::Date(d) => {
1388                        Ok(Value::String(format!("{:04}-{:02}", d.year(), d.month())))
1389                    }
1390                    _ => Err(QueryError::Type("YMONTH expects a date".to_string())),
1391                }
1392            }
1393            // String functions for wrapping aggregates
1394            "SUBSTR" | "SUBSTRING" => {
1395                if args.len() < 2 || args.len() > 3 {
1396                    return Err(QueryError::InvalidArguments(
1397                        name_upper,
1398                        "expected 2 or 3 arguments".to_string(),
1399                    ));
1400                }
1401                // Python slice semantics s[start:end] — see `py_slice` /
1402                // `eval_substr`. arg3 is the END index, not a length.
1403                match (&args[0], &args[1], args.get(2)) {
1404                    (Value::String(s), Value::Integer(start), None) => Ok(Value::String(
1405                        functions::string::py_slice(&s.chars().collect::<Vec<_>>(), *start, None),
1406                    )),
1407                    (Value::String(s), Value::Integer(start), Some(Value::Integer(end))) => {
1408                        Ok(Value::String(functions::string::py_slice(
1409                            &s.chars().collect::<Vec<_>>(),
1410                            *start,
1411                            Some(*end),
1412                        )))
1413                    }
1414                    _ => Err(QueryError::Type(
1415                        "SUBSTR expects (string, int, [int])".to_string(),
1416                    )),
1417                }
1418            }
1419            "STARTSWITH" => {
1420                Self::require_args_count(&name_upper, args, 2)?;
1421                match (&args[0], &args[1]) {
1422                    (Value::String(s), Value::String(prefix)) => {
1423                        Ok(Value::Boolean(s.starts_with(prefix.as_str())))
1424                    }
1425                    _ => Err(QueryError::Type(
1426                        "STARTSWITH expects two strings".to_string(),
1427                    )),
1428                }
1429            }
1430            "ENDSWITH" => {
1431                Self::require_args_count(&name_upper, args, 2)?;
1432                match (&args[0], &args[1]) {
1433                    (Value::String(s), Value::String(suffix)) => {
1434                        Ok(Value::Boolean(s.ends_with(suffix.as_str())))
1435                    }
1436                    _ => Err(QueryError::Type("ENDSWITH expects two strings".to_string())),
1437                }
1438            }
1439            "MAXWIDTH" => Self::maxwidth_on_values(args),
1440            // Account function used in GROUP BY
1441            "ACCOUNT_DEPTH" => {
1442                Self::require_args_count(&name_upper, args, 1)?;
1443                match &args[0] {
1444                    Value::String(s) => Ok(Value::Integer(s.matches(':').count() as i64 + 1)),
1445                    _ => Err(QueryError::Type(
1446                        "ACCOUNT_DEPTH expects an account string".to_string(),
1447                    )),
1448                }
1449            }
1450            // Position/amount getters
1451            "GETITEM" | "GET" => {
1452                Self::require_args_count(&name_upper, args, 2)?;
1453                match (&args[0], &args[1]) {
1454                    (Value::Inventory(inv), Value::String(currency)) => {
1455                        let amount = inv.units(currency);
1456                        if amount.is_zero() {
1457                            Ok(Value::Null)
1458                        } else {
1459                            Ok(Value::Amount(Amount::new(amount, currency.as_str())))
1460                        }
1461                    }
1462                    // Metadata / object lookup — mirror the per-row path
1463                    // (`eval_getitem`). Previously only the lazy path handled
1464                    // these, so `getitem(meta, key)` errored in the eager /
1465                    // `#postings` evaluation path.
1466                    (Value::Metadata(meta), Value::String(key)) => {
1467                        Ok(Self::meta_value_to_value(meta.get(key)))
1468                    }
1469                    (Value::Object(obj), Value::String(key)) => {
1470                        Ok(obj.get(key).cloned().unwrap_or(Value::Null))
1471                    }
1472                    (Value::Null, _) => Ok(Value::Null),
1473                    _ => Err(QueryError::Type(
1474                        "GETITEM expects (inventory, string), (metadata, string), or (object, string)"
1475                            .to_string(),
1476                    )),
1477                }
1478            }
1479            "WEIGHT" => {
1480                Self::require_args_count(&name_upper, args, 1)?;
1481                match &args[0] {
1482                    Value::Position(p) => {
1483                        if let Some(cost) = &p.cost {
1484                            let total = p.units.number * cost.number;
1485                            Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
1486                        } else {
1487                            Ok(Value::Amount(p.units.clone()))
1488                        }
1489                    }
1490                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
1491                    Value::Inventory(inv) => {
1492                        let mut result = Inventory::new();
1493                        for pos in inv.positions() {
1494                            if let Some(cost) = &pos.cost {
1495                                let total = pos.units.number * cost.number;
1496                                result.add(Position::simple(Amount::new(
1497                                    total,
1498                                    cost.currency.clone(),
1499                                )));
1500                            } else {
1501                                result.add(Position::simple(pos.units.clone()));
1502                            }
1503                        }
1504                        Ok(Value::Inventory(Box::new(result)))
1505                    }
1506                    Value::Null => Ok(Value::Null),
1507                    _ => Err(QueryError::Type(
1508                        "WEIGHT expects a position, amount, or inventory".to_string(),
1509                    )),
1510                }
1511            }
1512            "DATE" => Self::date_construct_on_values(args),
1513            "DATE_ADD" => Self::date_add_on_values(args),
1514            "DATE_TRUNC" => Self::date_trunc_on_values(args),
1515            "DATE_PART" => Self::date_part_on_values(args),
1516            "PARSE_DATE" => Self::parse_date_on_values(args),
1517            "DATE_BIN" => Self::date_bin_on_values(args),
1518            "INTERVAL" => Self::interval_on_values(args),
1519            // Date: DATE_DIFF for wrapping aggregates like DATE_DIFF(MAX(date), MIN(date))
1520            "DATE_DIFF" => {
1521                Self::require_args_count(&name_upper, args, 2)?;
1522                match (&args[0], &args[1]) {
1523                    (Value::Date(d1), Value::Date(d2)) => Ok(Value::Integer(i64::from(
1524                        d1.since(*d2).unwrap_or_default().get_days(),
1525                    ))),
1526                    _ => Err(QueryError::Type("DATE_DIFF expects two dates".to_string())),
1527                }
1528            }
1529            // String: regex functions for wrapping aggregates
1530            "GREP" => {
1531                Self::require_args_count(&name_upper, args, 2)?;
1532                match (&args[0], &args[1]) {
1533                    (Value::String(pattern), Value::String(s)) => {
1534                        let re = regex::Regex::new(pattern).map_err(|e| {
1535                            QueryError::Type(format!("GREP: invalid regex '{pattern}': {e}"))
1536                        })?;
1537                        match re.find(s) {
1538                            Some(m) => Ok(Value::String(m.as_str().to_string())),
1539                            None => Ok(Value::Null),
1540                        }
1541                    }
1542                    // Null args → Null (e.g., narration is Null for non-transaction entries)
1543                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1544                    _ => Err(QueryError::Type("GREP expects two strings".to_string())),
1545                }
1546            }
1547            "GREPN" => {
1548                Self::require_args_count(&name_upper, args, 3)?;
1549                let n = match &args[2] {
1550                    Value::Integer(i) => (*i).max(0) as usize,
1551                    Value::Number(n) => {
1552                        use rust_decimal::prelude::ToPrimitive;
1553                        n.to_usize().unwrap_or(0)
1554                    }
1555                    _ => {
1556                        return Err(QueryError::Type(
1557                            "GREPN: third argument must be an integer".to_string(),
1558                        ));
1559                    }
1560                };
1561                match (&args[0], &args[1]) {
1562                    (Value::String(pattern), Value::String(s)) => {
1563                        let re = regex::Regex::new(pattern).map_err(|e| {
1564                            QueryError::Type(format!("GREPN: invalid regex '{pattern}': {e}"))
1565                        })?;
1566                        match re.captures(s) {
1567                            Some(caps) => match caps.get(n) {
1568                                Some(m) => Ok(Value::String(m.as_str().to_string())),
1569                                None => Ok(Value::Null),
1570                            },
1571                            None => Ok(Value::Null),
1572                        }
1573                    }
1574                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1575                    _ => Err(QueryError::Type(
1576                        "GREPN expects (pattern, string, int)".to_string(),
1577                    )),
1578                }
1579            }
1580            "SUBST" => {
1581                Self::require_args_count(&name_upper, args, 3)?;
1582                match (&args[0], &args[1], &args[2]) {
1583                    (Value::String(pattern), Value::String(replacement), Value::String(s)) => {
1584                        let re = regex::Regex::new(pattern).map_err(|e| {
1585                            QueryError::Type(format!("SUBST: invalid regex '{pattern}': {e}"))
1586                        })?;
1587                        Ok(Value::String(
1588                            re.replace_all(s, replacement.as_str()).to_string(),
1589                        ))
1590                    }
1591                    _ => Err(QueryError::Type(
1592                        "SUBST expects (pattern, replacement, string)".to_string(),
1593                    )),
1594                }
1595            }
1596            "SPLITCOMP" => {
1597                Self::require_args_count(&name_upper, args, 3)?;
1598                let n = match &args[2] {
1599                    Value::Integer(i) => (*i).max(0) as usize,
1600                    Value::Number(n) => {
1601                        use rust_decimal::prelude::ToPrimitive;
1602                        n.to_usize().unwrap_or(0)
1603                    }
1604                    _ => {
1605                        return Err(QueryError::Type(
1606                            "SPLITCOMP: third argument must be an integer".to_string(),
1607                        ));
1608                    }
1609                };
1610                match (&args[0], &args[1]) {
1611                    (Value::String(s), Value::String(delim)) => {
1612                        let parts: Vec<&str> = s.split(delim.as_str()).collect();
1613                        match parts.get(n) {
1614                            Some(part) => Ok(Value::String((*part).to_string())),
1615                            None => Ok(Value::Null),
1616                        }
1617                    }
1618                    _ => Err(QueryError::Type(
1619                        "SPLITCOMP expects (string, delimiter, int)".to_string(),
1620                    )),
1621                }
1622            }
1623            "JOINSTR" => {
1624                // Mirror the former lazy `eval_joinstr`: require >=1 argument,
1625                // SKIP nulls, and stringify every other non-String/Set arg via
1626                // `value_to_string`, joining with ", " (a comma+space).
1627                if args.is_empty() {
1628                    return Err(QueryError::InvalidArguments(
1629                        "JOINSTR".to_string(),
1630                        "expected at least 1 argument".to_string(),
1631                    ));
1632                }
1633                let mut parts = Vec::new();
1634                for v in args {
1635                    match v {
1636                        Value::String(s) => parts.push(s.clone()),
1637                        Value::StringSet(ss) => parts.extend(ss.iter().cloned()),
1638                        Value::Null => {}
1639                        other => parts.push(Self::value_to_string(other)),
1640                    }
1641                }
1642                Ok(Value::String(parts.join(", ")))
1643            }
1644            // Account metadata functions — look up open/close info
1645            "OPEN_DATE" => {
1646                Self::require_args_count(&name_upper, args, 1)?;
1647                match &args[0] {
1648                    Value::String(account) => Ok(self
1649                        .account_info
1650                        .get(account.as_str())
1651                        .and_then(|info| info.open_date)
1652                        .map_or(Value::Null, Value::Date)),
1653                    Value::Null => Ok(Value::Null),
1654                    _ => Err(QueryError::Type(
1655                        "OPEN_DATE expects an account string".to_string(),
1656                    )),
1657                }
1658            }
1659            "CLOSE_DATE" => {
1660                Self::require_args_count(&name_upper, args, 1)?;
1661                match &args[0] {
1662                    Value::String(account) => Ok(self
1663                        .account_info
1664                        .get(account.as_str())
1665                        .and_then(|info| info.close_date)
1666                        .map_or(Value::Null, Value::Date)),
1667                    Value::Null => Ok(Value::Null),
1668                    _ => Err(QueryError::Type(
1669                        "CLOSE_DATE expects an account string".to_string(),
1670                    )),
1671                }
1672            }
1673            "OPEN_META" => {
1674                Self::require_args_count(&name_upper, args, 2)?;
1675                match (&args[0], &args[1]) {
1676                    (Value::String(account), Value::String(key)) => Ok(self
1677                        .account_info
1678                        .get(account.as_str())
1679                        .and_then(|info| info.open_meta.get(key))
1680                        .map_or(Value::Null, |mv| Self::meta_value_to_value(Some(mv)))),
1681                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1682                    _ => Err(QueryError::Type(
1683                        "OPEN_META expects (account_string, key_string)".to_string(),
1684                    )),
1685                }
1686            }
1687            // Metadata access — returns Null in evaluate_function_on_values
1688            // because metadata is accessed via row context in eval_meta_on_table_row.
1689            // This branch handles edge cases where META is called outside table context.
1690            "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
1691                Self::require_args_count(&name_upper, args, 1)?;
1692                match &args[0] {
1693                    Value::String(_) | Value::Null => Ok(Value::Null),
1694                    _ => Err(QueryError::Type(format!(
1695                        "{name_upper}: argument must be a string key"
1696                    ))),
1697                }
1698            }
1699            // Aggregate functions return Null when evaluated on a single row
1700            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1701            _ => Err(QueryError::UnknownFunction(name.to_string())),
1702        }
1703    }
1704
1705    /// Convert a `Metadata` map to a `Value::Object` for table storage.
1706    fn metadata_to_value(meta: &rustledger_core::Metadata) -> Value {
1707        if meta.is_empty() {
1708            return Value::Null;
1709        }
1710        let map: std::collections::BTreeMap<String, Value> = meta
1711            .iter()
1712            .map(|(k, v)| (k.clone(), Self::meta_value_to_value(Some(v))))
1713            .collect();
1714        Value::Object(Box::new(map))
1715    }
1716
1717    /// Helper to require a specific number of arguments (for pre-evaluated args).
1718    fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1719        if args.len() != expected {
1720            return Err(QueryError::InvalidArguments(
1721                name.to_string(),
1722                format!("expected {} argument(s), got {}", expected, args.len()),
1723            ));
1724        }
1725        Ok(())
1726    }
1727
1728    /// Helper to require a specific number of arguments.
1729    fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1730        if func.args.len() != expected {
1731            return Err(QueryError::InvalidArguments(
1732                name.to_string(),
1733                format!("expected {expected} argument(s)"),
1734            ));
1735        }
1736        Ok(())
1737    }
1738
1739    /// Convert a value to its market value.
1740    ///
1741    /// Shared `VALUE()` implementation used by both expression evaluation and
1742    /// the aggregate/subquery path in `evaluate_function_on_values`.
1743    ///
1744    /// # Arguments
1745    /// * `val` - The value to convert (`Position`, `Amount`, `Inventory`, or `Null`).
1746    /// * `explicit_currency` - Optional explicit target currency. When `None`,
1747    ///   the currency is inferred from the position's cost basis (Python
1748    ///   beancount compatibility) or falls back to the executor's
1749    ///   `target_currency` setting.
1750    /// * `at_date` - Optional valuation date. When `Some`, prices are looked up
1751    ///   with "on or before" semantics via [`price::PriceDatabase::convert`];
1752    ///   when `None`, the latest available price is used via
1753    ///   [`price::PriceDatabase::convert_latest`] (matches Python's
1754    ///   `value(position)` with `date=None`, which may use a future-dated price).
1755    ///
1756    /// # Returns
1757    /// - `Value::Amount` when conversion succeeds, or when the input is a
1758    ///   single `Position`/`Amount` that can't be priced (raw units returned).
1759    /// - `Value::Inventory` when no target currency can be determined and the
1760    ///   input is an `Inventory`.
1761    /// - `Value::Null` when the input is null.
1762    ///
1763    /// # Inventory caveat
1764    ///
1765    /// For `Value::Inventory` inputs with a determined target currency, this
1766    /// function returns a single `Value::Amount` summed in the target currency.
1767    /// Positions within the inventory that cannot be priced at `at_date` (or
1768    /// have no latest price) are silently dropped from the sum. This differs
1769    /// from Python beancount's `inventory.reduce(get_value, ...)`, which
1770    /// preserves unpriced positions as raw units in the resulting inventory.
1771    /// Reconciling this is tracked as a separate follow-up and is out of scope
1772    /// for #892.
1773    pub(crate) fn convert_to_market_value(
1774        &self,
1775        val: &Value,
1776        explicit_currency: Option<&str>,
1777        at_date: Option<NaiveDate>,
1778    ) -> Result<Value, QueryError> {
1779        // Column-type stability (#1701): the one-argument form infers the
1780        // target currency PER ROW (cost currency, else executor default), so
1781        // an Amount-vs-Inventory return that depends on the row's data makes
1782        // the column type unstable — the FFI layer declares the type from one
1783        // row and other rows then contradict it. The rule:
1784        //   - explicit currency (two-arg form): target is constant across the
1785        //     query -> Amount for every row (existing behavior, stable);
1786        //   - one-arg form over an Inventory: ALWAYS return an Inventory
1787        //     (beanquery parity: value(inventory) is inventory-typed), whether
1788        //     or not a target currency could be inferred for this row.
1789        let inventory_stays_inventory = explicit_currency.is_none();
1790        // Determine target currency:
1791        // 1. Explicit argument takes precedence
1792        // 2. Infer from position's cost currency (beancount compatibility)
1793        // 3. Fall back to executor's target_currency setting
1794        let target_currency = if let Some(currency) = explicit_currency {
1795            currency.to_string()
1796        } else {
1797            // Try to infer from cost currency
1798            let inferred = match val {
1799                Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1800                Value::Inventory(inv) => inv
1801                    .positions()
1802                    .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1803                _ => None,
1804            };
1805
1806            match inferred.or_else(|| self.target_currency.clone()) {
1807                Some(c) => c,
1808                None => {
1809                    // No currency can be determined — return value as-is
1810                    // (matches Python beancount behavior for positions without cost).
1811                    // Note: `at_date` is ignored here because there is nothing to
1812                    // convert without a target currency.
1813                    return match val {
1814                        Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1815                        Value::Amount(a) => Ok(Value::Amount(a.clone())),
1816                        Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1817                        Value::Null => Ok(Value::Null),
1818                        _ => Err(QueryError::Type(
1819                            "VALUE expects a position, amount, or inventory".to_string(),
1820                        )),
1821                    };
1822                }
1823            }
1824        };
1825
1826        // Price lookup matches Python beancount's semantics:
1827        // - When `at_date` is None, use the latest price (which may be future-dated).
1828        // - When `at_date` is Some, use the most recent price on or before that date;
1829        //   if no such price exists, the conversion silently returns the raw units.
1830        let convert_one = |amount: &Amount| -> Option<Amount> {
1831            match at_date {
1832                Some(d) => self.price_db.convert(amount, &target_currency, d),
1833                None => self.price_db.convert_latest(amount, &target_currency),
1834            }
1835        };
1836
1837        match val {
1838            Value::Position(p) => {
1839                if p.units.currency == target_currency {
1840                    Ok(Value::Amount(p.units.clone()))
1841                } else if let Some(converted) = convert_one(&p.units) {
1842                    Ok(Value::Amount(converted))
1843                } else {
1844                    Ok(Value::Amount(p.units.clone()))
1845                }
1846            }
1847            Value::Amount(a) => {
1848                if a.currency == target_currency {
1849                    Ok(Value::Amount(a.clone()))
1850                } else if let Some(converted) = convert_one(a) {
1851                    Ok(Value::Amount(converted))
1852                } else {
1853                    Ok(Value::Amount(a.clone()))
1854                }
1855            }
1856            Value::Inventory(inv) => {
1857                if inventory_stays_inventory {
1858                    // Convert per position; a position with no available price
1859                    // keeps its raw units (matching the Position/Amount arms
1860                    // above and beanquery, which never drops positions).
1861                    let mut out = rustledger_core::Inventory::new();
1862                    for pos in inv.positions() {
1863                        let units = if pos.units.currency == target_currency {
1864                            pos.units.clone()
1865                        } else if let Some(converted) = convert_one(&pos.units) {
1866                            converted
1867                        } else {
1868                            pos.units.clone()
1869                        };
1870                        out.add(rustledger_core::Position::simple(units));
1871                    }
1872                    return Ok(Value::Inventory(Box::new(out)));
1873                }
1874                // Two-arg form: collapse to a single Amount in the explicit
1875                // target currency. NOTE (pre-existing beanquery divergence,
1876                // out of #1701's scope): positions with no available price are
1877                // dropped from the total here; beanquery would keep them as
1878                // their original units in an Inventory result.
1879                let mut total = Decimal::ZERO;
1880                for pos in inv.positions() {
1881                    if pos.units.currency == target_currency {
1882                        total += pos.units.number;
1883                    } else if let Some(converted) = convert_one(&pos.units) {
1884                        total += converted.number;
1885                    }
1886                }
1887                Ok(Value::Amount(Amount::new(total, &target_currency)))
1888            }
1889            Value::Null => Ok(Value::Null),
1890            _ => Err(QueryError::Type(
1891                "VALUE expects a position, amount, or inventory".to_string(),
1892            )),
1893        }
1894    }
1895
1896    /// Check if an expression is a window function.
1897    pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1898        matches!(expr, Expr::Window(_))
1899    }
1900
1901    /// Resolve column names from targets.
1902    fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1903        let mut names = Vec::new();
1904        for (i, target) in targets.iter().enumerate() {
1905            if matches!(target.expr, Expr::Wildcard) {
1906                // Check wildcard BEFORE alias to catch `SELECT * AS alias` edge case
1907                if target.alias.is_some() {
1908                    return Err(QueryError::Evaluation(
1909                        "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1910                    ));
1911                }
1912                // Expand wildcard using shared constant (must match evaluate_row expansion)
1913                names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1914            } else if let Some(alias) = &target.alias {
1915                names.push(alias.clone());
1916            } else {
1917                names.push(self.expr_to_name(&target.expr, i));
1918            }
1919        }
1920        Ok(names)
1921    }
1922
1923    /// Convert an expression to a column name.
1924    fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1925        match expr {
1926            Expr::Wildcard => "*".to_string(),
1927            Expr::Column(name) => name.clone(),
1928            Expr::Function(func) => func.name.clone(),
1929            Expr::Window(wf) => wf.name.clone(),
1930            _ => format!("col{index}"),
1931        }
1932    }
1933
1934    /// Get a built-in system table by name.
1935    ///
1936    /// Built-in tables are virtual tables that provide access to ledger data:
1937    /// - `#prices` / `prices`: Price directives from the ledger
1938    /// - `#balances` / `balances`: Balance assertion directives from the ledger
1939    /// - `#commodities` / `commodities`: Commodity directives from the ledger
1940    /// - `#events` / `events`: Event directives from the ledger
1941    /// - `#notes` / `notes`: Note directives from the ledger
1942    /// - `#documents` / `documents`: Document directives from the ledger
1943    /// - `#accounts` / `accounts`: Open/Close directives paired by account
1944    /// - `#transactions` / `transactions`: Transaction directives from the ledger
1945    /// - `#entries` / `entries`: All directives with source location info
1946    /// - `#postings` / `postings`: All postings from transactions
1947    ///
1948    /// Both `#`-prefixed and non-prefixed names are supported for Python beancount
1949    /// compatibility (issue #632).
1950    ///
1951    /// Returns `None` if the table name is not a recognized built-in table.
1952    pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1953        // Normalize table name: strip # prefix if present for Python beancount compatibility.
1954        // Both "#transactions" (rustledger) and "transactions" (beancount) work.
1955        // Using strip_prefix avoids allocation in the common case.
1956        let upper = table_name.to_uppercase();
1957        let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1958
1959        match normalized {
1960            "PRICES" => Some(self.build_prices_table()),
1961            "BALANCES" => Some(self.build_balances_table()),
1962            "COMMODITIES" => Some(self.build_commodities_table()),
1963            "EVENTS" => Some(self.build_events_table()),
1964            "NOTES" => Some(self.build_notes_table()),
1965            "DOCUMENTS" => Some(self.build_documents_table()),
1966            "ACCOUNTS" => Some(self.build_accounts_table()),
1967            "TRANSACTIONS" => Some(self.build_transactions_table()),
1968            "ENTRIES" => Some(self.build_entries_table()),
1969            "POSTINGS" => Some(self.build_postings_table()),
1970            _ => None,
1971        }
1972    }
1973}
1974
1975/// Walk an [`Expr`] tree, returning `true` if any [`Expr::Column`]
1976/// references the given column name (case-insensitive).
1977///
1978/// Used to decide whether [`Executor::collect_postings`] needs to
1979/// materialize the per-posting `balance` / `account_balance` snapshots
1980/// — they're expensive (cumulative `Inventory` clones per posting,
1981/// the runaway cost in #1080) so we skip the work when no part of the
1982/// query reads them.
1983fn expr_references_column(expr: &Expr, name: &str) -> bool {
1984    match expr {
1985        Expr::Column(col) => col.eq_ignore_ascii_case(name),
1986        Expr::Function(call) => call.args.iter().any(|a| expr_references_column(a, name)),
1987        Expr::Window(call) => {
1988            // Function args + the OVER clause's PARTITION BY / ORDER BY
1989            // expressions all need to be walked — a window function like
1990            // `SUM(amount) OVER (PARTITION BY balance)` references
1991            // `balance` in the partition-by, not the function args.
1992            // Caught by Copilot review on PR #1085.
1993            call.args.iter().any(|a| expr_references_column(a, name))
1994                || call
1995                    .over
1996                    .partition_by
1997                    .as_ref()
1998                    .is_some_and(|ps| ps.iter().any(|p| expr_references_column(p, name)))
1999                || call
2000                    .over
2001                    .order_by
2002                    .as_ref()
2003                    .is_some_and(|os| os.iter().any(|o| expr_references_column(&o.expr, name)))
2004        }
2005        Expr::BinaryOp(op) => {
2006            expr_references_column(&op.left, name) || expr_references_column(&op.right, name)
2007        }
2008        Expr::UnaryOp(op) => expr_references_column(&op.operand, name),
2009        Expr::Paren(inner) => expr_references_column(inner, name),
2010        Expr::Between { value, low, high } => {
2011            expr_references_column(value, name)
2012                || expr_references_column(low, name)
2013                || expr_references_column(high, name)
2014        }
2015        Expr::Set(items) => items.iter().any(|i| expr_references_column(i, name)),
2016        Expr::Wildcard | Expr::Literal(_) => false,
2017    }
2018}
2019
2020/// Return `true` if any part of a `SelectQuery` references the given
2021/// column. Walks SELECT targets, WHERE, GROUP BY, HAVING, PIVOT BY,
2022/// ORDER BY, and the FROM filter expression. A subquery in FROM is
2023/// treated as opaque — its inner references don't surface to the
2024/// outer query's posting iterator.
2025fn query_references_column(query: &SelectQuery, name: &str) -> bool {
2026    if query
2027        .targets
2028        .iter()
2029        .any(|t| expr_references_column(&t.expr, name))
2030    {
2031        return true;
2032    }
2033    if let Some(w) = &query.where_clause
2034        && expr_references_column(w, name)
2035    {
2036        return true;
2037    }
2038    if let Some(g) = &query.group_by
2039        && g.iter().any(|e| expr_references_column(e, name))
2040    {
2041        return true;
2042    }
2043    if let Some(h) = &query.having
2044        && expr_references_column(h, name)
2045    {
2046        return true;
2047    }
2048    if let Some(p) = &query.pivot_by
2049        && p.iter().any(|e| expr_references_column(e, name))
2050    {
2051        return true;
2052    }
2053    if let Some(o) = &query.order_by
2054        && o.iter().any(|s| expr_references_column(&s.expr, name))
2055    {
2056        return true;
2057    }
2058    if let Some(from) = &query.from
2059        && let Some(f) = &from.filter
2060        && expr_references_column(f, name)
2061    {
2062        return true;
2063    }
2064    false
2065}
2066
2067#[cfg(test)]
2068mod tests;
2069
2070#[cfg(test)]
2071mod dual_eval_parity;