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                    _ => {
1051                        return Err(QueryError::Type(
1052                            "ONLY: first argument must be a currency string".to_string(),
1053                        ));
1054                    }
1055                };
1056                match &args[1] {
1057                    Value::Inventory(inv) => {
1058                        let total = inv.units(&currency);
1059                        if total.is_zero() {
1060                            Ok(Value::Null)
1061                        } else {
1062                            Ok(Value::Amount(Amount::new(total, &currency)))
1063                        }
1064                    }
1065                    Value::Position(p) => {
1066                        if p.units.currency.as_str() == currency {
1067                            Ok(Value::Amount(p.units.clone()))
1068                        } else {
1069                            Ok(Value::Null)
1070                        }
1071                    }
1072                    Value::Amount(a) => {
1073                        if a.currency.as_str() == currency {
1074                            Ok(Value::Amount(a.clone()))
1075                        } else {
1076                            Ok(Value::Null)
1077                        }
1078                    }
1079                    Value::Null => Ok(Value::Null),
1080                    _ => Err(QueryError::Type(
1081                        "ONLY: second argument must be an inventory, position, or amount"
1082                            .to_string(),
1083                    )),
1084                }
1085            }
1086            // GETPRICE function - needs price database
1087            "GETPRICE" => {
1088                if args.len() < 2 || args.len() > 3 {
1089                    return Err(QueryError::InvalidArguments(
1090                        "GETPRICE".to_string(),
1091                        "expected 2 or 3 arguments".to_string(),
1092                    ));
1093                }
1094                // Handle NULL arguments gracefully
1095                let base = match &args[0] {
1096                    Value::String(s) => s.clone(),
1097                    Value::Null => return Ok(Value::Null),
1098                    _ => {
1099                        return Err(QueryError::Type(
1100                            "GETPRICE: first argument must be a currency string".to_string(),
1101                        ));
1102                    }
1103                };
1104                let quote = match &args[1] {
1105                    Value::String(s) => s.clone(),
1106                    Value::Null => return Ok(Value::Null),
1107                    _ => {
1108                        return Err(QueryError::Type(
1109                            "GETPRICE: second argument must be a currency string".to_string(),
1110                        ));
1111                    }
1112                };
1113                let date = if args.len() == 3 {
1114                    match &args[2] {
1115                        Value::Date(d) => *d,
1116                        Value::Null => self.query_date,
1117                        _ => self.query_date,
1118                    }
1119                } else {
1120                    self.query_date
1121                };
1122                match self.price_db.get_price(&base, &quote, date) {
1123                    Some(price) => Ok(Value::Number(price)),
1124                    None => Ok(Value::Null),
1125                }
1126            }
1127            // Inventory functions
1128            "EMPTY" => {
1129                Self::require_args_count(&name_upper, args, 1)?;
1130                match &args[0] {
1131                    Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
1132                    Value::Null => Ok(Value::Boolean(true)),
1133                    _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
1134                }
1135            }
1136            "FILTER_CURRENCY" => {
1137                Self::require_args_count(&name_upper, args, 2)?;
1138                let currency = match &args[1] {
1139                    Value::String(s) => s.clone(),
1140                    _ => {
1141                        return Err(QueryError::Type(
1142                            "FILTER_CURRENCY expects (inventory, string)".to_string(),
1143                        ));
1144                    }
1145                };
1146                match &args[0] {
1147                    Value::Inventory(inv) => {
1148                        let filtered: Vec<Position> = inv
1149                            .positions()
1150                            .filter(|p| p.units.currency.as_str() == currency)
1151                            .cloned()
1152                            .collect();
1153                        let mut new_inv = Inventory::new();
1154                        for pos in filtered {
1155                            new_inv.add(pos);
1156                        }
1157                        Ok(Value::Inventory(Box::new(new_inv)))
1158                    }
1159                    Value::Null => Ok(Value::Null),
1160                    _ => Err(QueryError::Type(
1161                        "FILTER_CURRENCY expects (inventory, string)".to_string(),
1162                    )),
1163                }
1164            }
1165            "POSSIGN" => {
1166                Self::require_args_count(&name_upper, args, 2)?;
1167                let account_str = match &args[1] {
1168                    Value::String(s) => s.clone(),
1169                    _ => {
1170                        return Err(QueryError::Type(
1171                            "POSSIGN expects (amount, account_string)".to_string(),
1172                        ));
1173                    }
1174                };
1175                let first_component = account_str.split(':').next().unwrap_or("");
1176                let is_credit_normal =
1177                    matches!(first_component, "Liabilities" | "Equity" | "Income");
1178                match &args[0] {
1179                    Value::Amount(a) => {
1180                        let mut amt = a.clone();
1181                        if is_credit_normal {
1182                            amt.number = -amt.number;
1183                        }
1184                        Ok(Value::Amount(amt))
1185                    }
1186                    Value::Number(n) => {
1187                        let adjusted = if is_credit_normal { -n } else { *n };
1188                        Ok(Value::Number(adjusted))
1189                    }
1190                    // Mirror the lazy `POSSIGN`: an integer amount is treated as
1191                    // a number and sign-adjusted.
1192                    Value::Integer(i) => {
1193                        let n = Decimal::from(*i);
1194                        let adjusted = if is_credit_normal { -n } else { n };
1195                        Ok(Value::Number(adjusted))
1196                    }
1197                    Value::Null => Ok(Value::Null),
1198                    _ => Err(QueryError::Type(
1199                        "POSSIGN expects (amount, account_string)".to_string(),
1200                    )),
1201                }
1202            }
1203            // CONVERT function - convert amounts/positions/inventories to target currency
1204            "CONVERT" => {
1205                if args.len() < 2 || args.len() > 3 {
1206                    return Err(QueryError::InvalidArguments(
1207                        "CONVERT".to_string(),
1208                        "expected 2 or 3 arguments: (value, currency[, date])".to_string(),
1209                    ));
1210                }
1211
1212                let target_currency = match &args[1] {
1213                    Value::String(s) => s.clone(),
1214                    Value::Null => {
1215                        return Err(QueryError::Type(
1216                            concat!(
1217                                "CONVERT: second argument evaluated to NULL; ",
1218                                "expected a currency string ",
1219                                "(this often means an aggregate expression couldn't ",
1220                                "evaluate against an empty group — see issue #902)",
1221                            )
1222                            .to_string(),
1223                        ));
1224                    }
1225                    _ => {
1226                        return Err(QueryError::Type(
1227                            "CONVERT: second argument must be a currency string".to_string(),
1228                        ));
1229                    }
1230                };
1231
1232                // Optional date argument
1233                let date: Option<rustledger_core::NaiveDate> = if args.len() == 3 {
1234                    match &args[2] {
1235                        Value::Date(d) => Some(*d),
1236                        Value::Null => None, // NULL date uses latest price
1237                        _ => {
1238                            return Err(QueryError::Type(
1239                                "CONVERT: third argument must be a date".to_string(),
1240                            ));
1241                        }
1242                    }
1243                } else {
1244                    None
1245                };
1246
1247                // Helper closure to convert an amount
1248                let convert_amount = |amt: &Amount| -> Option<Amount> {
1249                    if let Some(d) = date {
1250                        self.price_db.convert(amt, &target_currency, d)
1251                    } else {
1252                        self.price_db.convert_latest(amt, &target_currency)
1253                    }
1254                };
1255
1256                match &args[0] {
1257                    Value::Position(p) => {
1258                        if p.units.currency == target_currency {
1259                            Ok(Value::Amount(p.units.clone()))
1260                        } else if let Some(converted) = convert_amount(&p.units) {
1261                            Ok(Value::Amount(converted))
1262                        } else {
1263                            Ok(Value::Amount(p.units.clone()))
1264                        }
1265                    }
1266                    Value::Amount(a) => {
1267                        if a.currency == target_currency {
1268                            Ok(Value::Amount(a.clone()))
1269                        } else if let Some(converted) = convert_amount(a) {
1270                            Ok(Value::Amount(converted))
1271                        } else {
1272                            Ok(Value::Amount(a.clone()))
1273                        }
1274                    }
1275                    Value::Inventory(inv) => {
1276                        // Convert each position, keeping originals when no conversion available
1277                        // (matches Python beancount behavior)
1278                        let mut result = Inventory::default();
1279                        for pos in inv.positions() {
1280                            if pos.units.currency == target_currency {
1281                                result.add(Position::simple(pos.units.clone()));
1282                            } else if let Some(converted) = convert_amount(&pos.units) {
1283                                result.add(Position::simple(converted));
1284                            } else {
1285                                // No conversion available - keep original (Python beancount behavior)
1286                                result.add(Position::simple(pos.units.clone()));
1287                            }
1288                        }
1289                        // If result has single currency matching target, return as Amount
1290                        // If result is empty, return zero in target currency (issue #586)
1291                        let positions: Vec<&Position> = result.positions().collect();
1292                        if positions.is_empty() {
1293                            Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1294                        } else if positions.len() == 1
1295                            && positions[0].units.currency == target_currency
1296                        {
1297                            Ok(Value::Amount(positions[0].units.clone()))
1298                        } else {
1299                            Ok(Value::Inventory(Box::new(result)))
1300                        }
1301                    }
1302                    Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
1303                    Value::String(s) => {
1304                        // String input is a rustledger extension (issue #1179),
1305                        // not present in Python beancount. Lets users write
1306                        // ad-hoc currency conversions like
1307                        // `SELECT CONVERT('100 USD', 'EUR')` without anchoring
1308                        // them to a posting. Strict parser (see
1309                        // `Amount::from_str`): malformed input surfaces as a
1310                        // typed `QueryError` rather than a silent zero or a
1311                        // panic.
1312                        let amt: Amount = s.parse().map_err(|e| {
1313                            QueryError::Type(format!(
1314                                "CONVERT: first argument {e} (e.g. \"100 USD\")"
1315                            ))
1316                        })?;
1317                        if amt.currency == target_currency {
1318                            Ok(Value::Amount(amt))
1319                        } else if let Some(converted) = convert_amount(&amt) {
1320                            Ok(Value::Amount(converted))
1321                        } else {
1322                            // Match the `Value::Amount` arm: no price available
1323                            // → return original unchanged.
1324                            Ok(Value::Amount(amt))
1325                        }
1326                    }
1327                    Value::Null => {
1328                        // For null values (e.g., empty sum), return zero in target currency
1329                        // This matches Python beancount behavior for empty balances (issue #586)
1330                        Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1331                    }
1332                    _ => Err(QueryError::Type(
1333                        "CONVERT expects a position, amount, inventory, number, or amount-string"
1334                            .to_string(),
1335                    )),
1336                }
1337            }
1338            // Type casting functions - use shared helpers
1339            "STR" => {
1340                Self::require_args_count(&name_upper, args, 1)?;
1341                Self::value_to_str(&args[0])
1342            }
1343            "INT" => {
1344                Self::require_args_count(&name_upper, args, 1)?;
1345                Self::value_to_int(&args[0])
1346            }
1347            "DECIMAL" => {
1348                Self::require_args_count(&name_upper, args, 1)?;
1349                Self::value_to_decimal(&args[0])
1350            }
1351            "BOOL" => {
1352                Self::require_args_count(&name_upper, args, 1)?;
1353                Self::value_to_bool(&args[0])
1354            }
1355            // Date functions for wrapping aggregates: QUARTER(MAX(date))
1356            "QUARTER" => {
1357                Self::require_args_count(&name_upper, args, 1)?;
1358                match &args[0] {
1359                    // beanquery returns a `YYYY-Qn` string, not an integer.
1360                    Value::Date(d) => Ok(Value::String(format!(
1361                        "{:04}-Q{}",
1362                        d.year(),
1363                        (d.month() - 1) / 3 + 1
1364                    ))),
1365                    _ => Err(QueryError::Type("QUARTER expects a date".to_string())),
1366                }
1367            }
1368            "WEEKDAY" => {
1369                Self::require_args_count(&name_upper, args, 1)?;
1370                match &args[0] {
1371                    Value::Date(d) => Ok(Value::String(
1372                        functions::weekday_abbrev(d.weekday().to_monday_zero_offset() as u32)
1373                            .to_string(),
1374                    )),
1375                    _ => Err(QueryError::Type("WEEKDAY expects a date".to_string())),
1376                }
1377            }
1378            "YMONTH" => {
1379                Self::require_args_count(&name_upper, args, 1)?;
1380                match &args[0] {
1381                    Value::Date(d) => {
1382                        Ok(Value::String(format!("{:04}-{:02}", d.year(), d.month())))
1383                    }
1384                    _ => Err(QueryError::Type("YMONTH expects a date".to_string())),
1385                }
1386            }
1387            // String functions for wrapping aggregates
1388            "SUBSTR" | "SUBSTRING" => {
1389                if args.len() < 2 || args.len() > 3 {
1390                    return Err(QueryError::InvalidArguments(
1391                        name_upper,
1392                        "expected 2 or 3 arguments".to_string(),
1393                    ));
1394                }
1395                // Python slice semantics s[start:end] — see `py_slice` /
1396                // `eval_substr`. arg3 is the END index, not a length.
1397                match (&args[0], &args[1], args.get(2)) {
1398                    (Value::String(s), Value::Integer(start), None) => Ok(Value::String(
1399                        functions::string::py_slice(&s.chars().collect::<Vec<_>>(), *start, None),
1400                    )),
1401                    (Value::String(s), Value::Integer(start), Some(Value::Integer(end))) => {
1402                        Ok(Value::String(functions::string::py_slice(
1403                            &s.chars().collect::<Vec<_>>(),
1404                            *start,
1405                            Some(*end),
1406                        )))
1407                    }
1408                    _ => Err(QueryError::Type(
1409                        "SUBSTR expects (string, int, [int])".to_string(),
1410                    )),
1411                }
1412            }
1413            "STARTSWITH" => {
1414                Self::require_args_count(&name_upper, args, 2)?;
1415                match (&args[0], &args[1]) {
1416                    (Value::String(s), Value::String(prefix)) => {
1417                        Ok(Value::Boolean(s.starts_with(prefix.as_str())))
1418                    }
1419                    _ => Err(QueryError::Type(
1420                        "STARTSWITH expects two strings".to_string(),
1421                    )),
1422                }
1423            }
1424            "ENDSWITH" => {
1425                Self::require_args_count(&name_upper, args, 2)?;
1426                match (&args[0], &args[1]) {
1427                    (Value::String(s), Value::String(suffix)) => {
1428                        Ok(Value::Boolean(s.ends_with(suffix.as_str())))
1429                    }
1430                    _ => Err(QueryError::Type("ENDSWITH expects two strings".to_string())),
1431                }
1432            }
1433            "MAXWIDTH" => Self::maxwidth_on_values(args),
1434            // Account function used in GROUP BY
1435            "ACCOUNT_DEPTH" => {
1436                Self::require_args_count(&name_upper, args, 1)?;
1437                match &args[0] {
1438                    Value::String(s) => Ok(Value::Integer(s.matches(':').count() as i64 + 1)),
1439                    _ => Err(QueryError::Type(
1440                        "ACCOUNT_DEPTH expects an account string".to_string(),
1441                    )),
1442                }
1443            }
1444            // Position/amount getters
1445            "GETITEM" | "GET" => {
1446                Self::require_args_count(&name_upper, args, 2)?;
1447                match (&args[0], &args[1]) {
1448                    (Value::Inventory(inv), Value::String(currency)) => {
1449                        let amount = inv.units(currency);
1450                        if amount.is_zero() {
1451                            Ok(Value::Null)
1452                        } else {
1453                            Ok(Value::Amount(Amount::new(amount, currency.as_str())))
1454                        }
1455                    }
1456                    // Metadata / object lookup — mirror the per-row path
1457                    // (`eval_getitem`). Previously only the lazy path handled
1458                    // these, so `getitem(meta, key)` errored in the eager /
1459                    // `#postings` evaluation path.
1460                    (Value::Metadata(meta), Value::String(key)) => {
1461                        Ok(Self::meta_value_to_value(meta.get(key)))
1462                    }
1463                    (Value::Object(obj), Value::String(key)) => {
1464                        Ok(obj.get(key).cloned().unwrap_or(Value::Null))
1465                    }
1466                    (Value::Null, _) => Ok(Value::Null),
1467                    _ => Err(QueryError::Type(
1468                        "GETITEM expects (inventory, string), (metadata, string), or (object, string)"
1469                            .to_string(),
1470                    )),
1471                }
1472            }
1473            "WEIGHT" => {
1474                Self::require_args_count(&name_upper, args, 1)?;
1475                match &args[0] {
1476                    Value::Position(p) => {
1477                        if let Some(cost) = &p.cost {
1478                            let total = p.units.number * cost.number;
1479                            Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
1480                        } else {
1481                            Ok(Value::Amount(p.units.clone()))
1482                        }
1483                    }
1484                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
1485                    Value::Inventory(inv) => {
1486                        let mut result = Inventory::new();
1487                        for pos in inv.positions() {
1488                            if let Some(cost) = &pos.cost {
1489                                let total = pos.units.number * cost.number;
1490                                result.add(Position::simple(Amount::new(
1491                                    total,
1492                                    cost.currency.clone(),
1493                                )));
1494                            } else {
1495                                result.add(Position::simple(pos.units.clone()));
1496                            }
1497                        }
1498                        Ok(Value::Inventory(Box::new(result)))
1499                    }
1500                    Value::Null => Ok(Value::Null),
1501                    _ => Err(QueryError::Type(
1502                        "WEIGHT expects a position, amount, or inventory".to_string(),
1503                    )),
1504                }
1505            }
1506            "DATE" => Self::date_construct_on_values(args),
1507            "DATE_ADD" => Self::date_add_on_values(args),
1508            "DATE_TRUNC" => Self::date_trunc_on_values(args),
1509            "DATE_PART" => Self::date_part_on_values(args),
1510            "PARSE_DATE" => Self::parse_date_on_values(args),
1511            "DATE_BIN" => Self::date_bin_on_values(args),
1512            "INTERVAL" => Self::interval_on_values(args),
1513            // Date: DATE_DIFF for wrapping aggregates like DATE_DIFF(MAX(date), MIN(date))
1514            "DATE_DIFF" => {
1515                Self::require_args_count(&name_upper, args, 2)?;
1516                match (&args[0], &args[1]) {
1517                    (Value::Date(d1), Value::Date(d2)) => Ok(Value::Integer(i64::from(
1518                        d1.since(*d2).unwrap_or_default().get_days(),
1519                    ))),
1520                    _ => Err(QueryError::Type("DATE_DIFF expects two dates".to_string())),
1521                }
1522            }
1523            // String: regex functions for wrapping aggregates
1524            "GREP" => {
1525                Self::require_args_count(&name_upper, args, 2)?;
1526                match (&args[0], &args[1]) {
1527                    (Value::String(pattern), Value::String(s)) => {
1528                        let re = regex::Regex::new(pattern).map_err(|e| {
1529                            QueryError::Type(format!("GREP: invalid regex '{pattern}': {e}"))
1530                        })?;
1531                        match re.find(s) {
1532                            Some(m) => Ok(Value::String(m.as_str().to_string())),
1533                            None => Ok(Value::Null),
1534                        }
1535                    }
1536                    // Null args → Null (e.g., narration is Null for non-transaction entries)
1537                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1538                    _ => Err(QueryError::Type("GREP expects two strings".to_string())),
1539                }
1540            }
1541            "GREPN" => {
1542                Self::require_args_count(&name_upper, args, 3)?;
1543                let n = match &args[2] {
1544                    Value::Integer(i) => (*i).max(0) as usize,
1545                    Value::Number(n) => {
1546                        use rust_decimal::prelude::ToPrimitive;
1547                        n.to_usize().unwrap_or(0)
1548                    }
1549                    _ => {
1550                        return Err(QueryError::Type(
1551                            "GREPN: third argument must be an integer".to_string(),
1552                        ));
1553                    }
1554                };
1555                match (&args[0], &args[1]) {
1556                    (Value::String(pattern), Value::String(s)) => {
1557                        let re = regex::Regex::new(pattern).map_err(|e| {
1558                            QueryError::Type(format!("GREPN: invalid regex '{pattern}': {e}"))
1559                        })?;
1560                        match re.captures(s) {
1561                            Some(caps) => match caps.get(n) {
1562                                Some(m) => Ok(Value::String(m.as_str().to_string())),
1563                                None => Ok(Value::Null),
1564                            },
1565                            None => Ok(Value::Null),
1566                        }
1567                    }
1568                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1569                    _ => Err(QueryError::Type(
1570                        "GREPN expects (pattern, string, int)".to_string(),
1571                    )),
1572                }
1573            }
1574            "SUBST" => {
1575                Self::require_args_count(&name_upper, args, 3)?;
1576                match (&args[0], &args[1], &args[2]) {
1577                    (Value::String(pattern), Value::String(replacement), Value::String(s)) => {
1578                        let re = regex::Regex::new(pattern).map_err(|e| {
1579                            QueryError::Type(format!("SUBST: invalid regex '{pattern}': {e}"))
1580                        })?;
1581                        Ok(Value::String(
1582                            re.replace_all(s, replacement.as_str()).to_string(),
1583                        ))
1584                    }
1585                    _ => Err(QueryError::Type(
1586                        "SUBST expects (pattern, replacement, string)".to_string(),
1587                    )),
1588                }
1589            }
1590            "SPLITCOMP" => {
1591                Self::require_args_count(&name_upper, args, 3)?;
1592                let n = match &args[2] {
1593                    Value::Integer(i) => (*i).max(0) as usize,
1594                    Value::Number(n) => {
1595                        use rust_decimal::prelude::ToPrimitive;
1596                        n.to_usize().unwrap_or(0)
1597                    }
1598                    _ => {
1599                        return Err(QueryError::Type(
1600                            "SPLITCOMP: third argument must be an integer".to_string(),
1601                        ));
1602                    }
1603                };
1604                match (&args[0], &args[1]) {
1605                    (Value::String(s), Value::String(delim)) => {
1606                        let parts: Vec<&str> = s.split(delim.as_str()).collect();
1607                        match parts.get(n) {
1608                            Some(part) => Ok(Value::String((*part).to_string())),
1609                            None => Ok(Value::Null),
1610                        }
1611                    }
1612                    _ => Err(QueryError::Type(
1613                        "SPLITCOMP expects (string, delimiter, int)".to_string(),
1614                    )),
1615                }
1616            }
1617            "JOINSTR" => {
1618                // Mirror the former lazy `eval_joinstr`: require >=1 argument,
1619                // SKIP nulls, and stringify every other non-String/Set arg via
1620                // `value_to_string`, joining with ", " (a comma+space).
1621                if args.is_empty() {
1622                    return Err(QueryError::InvalidArguments(
1623                        "JOINSTR".to_string(),
1624                        "expected at least 1 argument".to_string(),
1625                    ));
1626                }
1627                let mut parts = Vec::new();
1628                for v in args {
1629                    match v {
1630                        Value::String(s) => parts.push(s.clone()),
1631                        Value::StringSet(ss) => parts.extend(ss.iter().cloned()),
1632                        Value::Null => {}
1633                        other => parts.push(Self::value_to_string(other)),
1634                    }
1635                }
1636                Ok(Value::String(parts.join(", ")))
1637            }
1638            // Account metadata functions — look up open/close info
1639            "OPEN_DATE" => {
1640                Self::require_args_count(&name_upper, args, 1)?;
1641                match &args[0] {
1642                    Value::String(account) => Ok(self
1643                        .account_info
1644                        .get(account.as_str())
1645                        .and_then(|info| info.open_date)
1646                        .map_or(Value::Null, Value::Date)),
1647                    Value::Null => Ok(Value::Null),
1648                    _ => Err(QueryError::Type(
1649                        "OPEN_DATE expects an account string".to_string(),
1650                    )),
1651                }
1652            }
1653            "CLOSE_DATE" => {
1654                Self::require_args_count(&name_upper, args, 1)?;
1655                match &args[0] {
1656                    Value::String(account) => Ok(self
1657                        .account_info
1658                        .get(account.as_str())
1659                        .and_then(|info| info.close_date)
1660                        .map_or(Value::Null, Value::Date)),
1661                    Value::Null => Ok(Value::Null),
1662                    _ => Err(QueryError::Type(
1663                        "CLOSE_DATE expects an account string".to_string(),
1664                    )),
1665                }
1666            }
1667            "OPEN_META" => {
1668                Self::require_args_count(&name_upper, args, 2)?;
1669                match (&args[0], &args[1]) {
1670                    (Value::String(account), Value::String(key)) => Ok(self
1671                        .account_info
1672                        .get(account.as_str())
1673                        .and_then(|info| info.open_meta.get(key))
1674                        .map_or(Value::Null, |mv| Self::meta_value_to_value(Some(mv)))),
1675                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1676                    _ => Err(QueryError::Type(
1677                        "OPEN_META expects (account_string, key_string)".to_string(),
1678                    )),
1679                }
1680            }
1681            // Metadata access — returns Null in evaluate_function_on_values
1682            // because metadata is accessed via row context in eval_meta_on_table_row.
1683            // This branch handles edge cases where META is called outside table context.
1684            "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
1685                Self::require_args_count(&name_upper, args, 1)?;
1686                match &args[0] {
1687                    Value::String(_) | Value::Null => Ok(Value::Null),
1688                    _ => Err(QueryError::Type(format!(
1689                        "{name_upper}: argument must be a string key"
1690                    ))),
1691                }
1692            }
1693            // Aggregate functions return Null when evaluated on a single row
1694            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1695            _ => Err(QueryError::UnknownFunction(name.to_string())),
1696        }
1697    }
1698
1699    /// Convert a `Metadata` map to a `Value::Object` for table storage.
1700    fn metadata_to_value(meta: &rustledger_core::Metadata) -> Value {
1701        if meta.is_empty() {
1702            return Value::Null;
1703        }
1704        let map: std::collections::BTreeMap<String, Value> = meta
1705            .iter()
1706            .map(|(k, v)| (k.clone(), Self::meta_value_to_value(Some(v))))
1707            .collect();
1708        Value::Object(Box::new(map))
1709    }
1710
1711    /// Helper to require a specific number of arguments (for pre-evaluated args).
1712    fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1713        if args.len() != expected {
1714            return Err(QueryError::InvalidArguments(
1715                name.to_string(),
1716                format!("expected {} argument(s), got {}", expected, args.len()),
1717            ));
1718        }
1719        Ok(())
1720    }
1721
1722    /// Helper to require a specific number of arguments.
1723    fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1724        if func.args.len() != expected {
1725            return Err(QueryError::InvalidArguments(
1726                name.to_string(),
1727                format!("expected {expected} argument(s)"),
1728            ));
1729        }
1730        Ok(())
1731    }
1732
1733    /// Convert a value to its market value.
1734    ///
1735    /// Shared `VALUE()` implementation used by both expression evaluation and
1736    /// the aggregate/subquery path in `evaluate_function_on_values`.
1737    ///
1738    /// # Arguments
1739    /// * `val` - The value to convert (`Position`, `Amount`, `Inventory`, or `Null`).
1740    /// * `explicit_currency` - Optional explicit target currency. When `None`,
1741    ///   the currency is inferred from the position's cost basis (Python
1742    ///   beancount compatibility) or falls back to the executor's
1743    ///   `target_currency` setting.
1744    /// * `at_date` - Optional valuation date. When `Some`, prices are looked up
1745    ///   with "on or before" semantics via [`price::PriceDatabase::convert`];
1746    ///   when `None`, the latest available price is used via
1747    ///   [`price::PriceDatabase::convert_latest`] (matches Python's
1748    ///   `value(position)` with `date=None`, which may use a future-dated price).
1749    ///
1750    /// # Returns
1751    /// - `Value::Amount` when conversion succeeds, or when the input is a
1752    ///   single `Position`/`Amount` that can't be priced (raw units returned).
1753    /// - `Value::Inventory` when no target currency can be determined and the
1754    ///   input is an `Inventory`.
1755    /// - `Value::Null` when the input is null.
1756    ///
1757    /// # Inventory caveat
1758    ///
1759    /// For `Value::Inventory` inputs with a determined target currency, this
1760    /// function returns a single `Value::Amount` summed in the target currency.
1761    /// Positions within the inventory that cannot be priced at `at_date` (or
1762    /// have no latest price) are silently dropped from the sum. This differs
1763    /// from Python beancount's `inventory.reduce(get_value, ...)`, which
1764    /// preserves unpriced positions as raw units in the resulting inventory.
1765    /// Reconciling this is tracked as a separate follow-up and is out of scope
1766    /// for #892.
1767    pub(crate) fn convert_to_market_value(
1768        &self,
1769        val: &Value,
1770        explicit_currency: Option<&str>,
1771        at_date: Option<NaiveDate>,
1772    ) -> Result<Value, QueryError> {
1773        // Determine target currency:
1774        // 1. Explicit argument takes precedence
1775        // 2. Infer from position's cost currency (beancount compatibility)
1776        // 3. Fall back to executor's target_currency setting
1777        let target_currency = if let Some(currency) = explicit_currency {
1778            currency.to_string()
1779        } else {
1780            // Try to infer from cost currency
1781            let inferred = match val {
1782                Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1783                Value::Inventory(inv) => inv
1784                    .positions()
1785                    .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1786                _ => None,
1787            };
1788
1789            match inferred.or_else(|| self.target_currency.clone()) {
1790                Some(c) => c,
1791                None => {
1792                    // No currency can be determined — return value as-is
1793                    // (matches Python beancount behavior for positions without cost).
1794                    // Note: `at_date` is ignored here because there is nothing to
1795                    // convert without a target currency.
1796                    return match val {
1797                        Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1798                        Value::Amount(a) => Ok(Value::Amount(a.clone())),
1799                        Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1800                        Value::Null => Ok(Value::Null),
1801                        _ => Err(QueryError::Type(
1802                            "VALUE expects a position, amount, or inventory".to_string(),
1803                        )),
1804                    };
1805                }
1806            }
1807        };
1808
1809        // Price lookup matches Python beancount's semantics:
1810        // - When `at_date` is None, use the latest price (which may be future-dated).
1811        // - When `at_date` is Some, use the most recent price on or before that date;
1812        //   if no such price exists, the conversion silently returns the raw units.
1813        let convert_one = |amount: &Amount| -> Option<Amount> {
1814            match at_date {
1815                Some(d) => self.price_db.convert(amount, &target_currency, d),
1816                None => self.price_db.convert_latest(amount, &target_currency),
1817            }
1818        };
1819
1820        match val {
1821            Value::Position(p) => {
1822                if p.units.currency == target_currency {
1823                    Ok(Value::Amount(p.units.clone()))
1824                } else if let Some(converted) = convert_one(&p.units) {
1825                    Ok(Value::Amount(converted))
1826                } else {
1827                    Ok(Value::Amount(p.units.clone()))
1828                }
1829            }
1830            Value::Amount(a) => {
1831                if a.currency == target_currency {
1832                    Ok(Value::Amount(a.clone()))
1833                } else if let Some(converted) = convert_one(a) {
1834                    Ok(Value::Amount(converted))
1835                } else {
1836                    Ok(Value::Amount(a.clone()))
1837                }
1838            }
1839            Value::Inventory(inv) => {
1840                let mut total = Decimal::ZERO;
1841                for pos in inv.positions() {
1842                    if pos.units.currency == target_currency {
1843                        total += pos.units.number;
1844                    } else if let Some(converted) = convert_one(&pos.units) {
1845                        total += converted.number;
1846                    }
1847                }
1848                Ok(Value::Amount(Amount::new(total, &target_currency)))
1849            }
1850            Value::Null => Ok(Value::Null),
1851            _ => Err(QueryError::Type(
1852                "VALUE expects a position, amount, or inventory".to_string(),
1853            )),
1854        }
1855    }
1856
1857    /// Check if an expression is a window function.
1858    pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1859        matches!(expr, Expr::Window(_))
1860    }
1861
1862    /// Resolve column names from targets.
1863    fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1864        let mut names = Vec::new();
1865        for (i, target) in targets.iter().enumerate() {
1866            if matches!(target.expr, Expr::Wildcard) {
1867                // Check wildcard BEFORE alias to catch `SELECT * AS alias` edge case
1868                if target.alias.is_some() {
1869                    return Err(QueryError::Evaluation(
1870                        "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1871                    ));
1872                }
1873                // Expand wildcard using shared constant (must match evaluate_row expansion)
1874                names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1875            } else if let Some(alias) = &target.alias {
1876                names.push(alias.clone());
1877            } else {
1878                names.push(self.expr_to_name(&target.expr, i));
1879            }
1880        }
1881        Ok(names)
1882    }
1883
1884    /// Convert an expression to a column name.
1885    fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1886        match expr {
1887            Expr::Wildcard => "*".to_string(),
1888            Expr::Column(name) => name.clone(),
1889            Expr::Function(func) => func.name.clone(),
1890            Expr::Window(wf) => wf.name.clone(),
1891            _ => format!("col{index}"),
1892        }
1893    }
1894
1895    /// Get a built-in system table by name.
1896    ///
1897    /// Built-in tables are virtual tables that provide access to ledger data:
1898    /// - `#prices` / `prices`: Price directives from the ledger
1899    /// - `#balances` / `balances`: Balance assertion directives from the ledger
1900    /// - `#commodities` / `commodities`: Commodity directives from the ledger
1901    /// - `#events` / `events`: Event directives from the ledger
1902    /// - `#notes` / `notes`: Note directives from the ledger
1903    /// - `#documents` / `documents`: Document directives from the ledger
1904    /// - `#accounts` / `accounts`: Open/Close directives paired by account
1905    /// - `#transactions` / `transactions`: Transaction directives from the ledger
1906    /// - `#entries` / `entries`: All directives with source location info
1907    /// - `#postings` / `postings`: All postings from transactions
1908    ///
1909    /// Both `#`-prefixed and non-prefixed names are supported for Python beancount
1910    /// compatibility (issue #632).
1911    ///
1912    /// Returns `None` if the table name is not a recognized built-in table.
1913    pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1914        // Normalize table name: strip # prefix if present for Python beancount compatibility.
1915        // Both "#transactions" (rustledger) and "transactions" (beancount) work.
1916        // Using strip_prefix avoids allocation in the common case.
1917        let upper = table_name.to_uppercase();
1918        let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1919
1920        match normalized {
1921            "PRICES" => Some(self.build_prices_table()),
1922            "BALANCES" => Some(self.build_balances_table()),
1923            "COMMODITIES" => Some(self.build_commodities_table()),
1924            "EVENTS" => Some(self.build_events_table()),
1925            "NOTES" => Some(self.build_notes_table()),
1926            "DOCUMENTS" => Some(self.build_documents_table()),
1927            "ACCOUNTS" => Some(self.build_accounts_table()),
1928            "TRANSACTIONS" => Some(self.build_transactions_table()),
1929            "ENTRIES" => Some(self.build_entries_table()),
1930            "POSTINGS" => Some(self.build_postings_table()),
1931            _ => None,
1932        }
1933    }
1934}
1935
1936/// Walk an [`Expr`] tree, returning `true` if any [`Expr::Column`]
1937/// references the given column name (case-insensitive).
1938///
1939/// Used to decide whether [`Executor::collect_postings`] needs to
1940/// materialize the per-posting `balance` / `account_balance` snapshots
1941/// — they're expensive (cumulative `Inventory` clones per posting,
1942/// the runaway cost in #1080) so we skip the work when no part of the
1943/// query reads them.
1944fn expr_references_column(expr: &Expr, name: &str) -> bool {
1945    match expr {
1946        Expr::Column(col) => col.eq_ignore_ascii_case(name),
1947        Expr::Function(call) => call.args.iter().any(|a| expr_references_column(a, name)),
1948        Expr::Window(call) => {
1949            // Function args + the OVER clause's PARTITION BY / ORDER BY
1950            // expressions all need to be walked — a window function like
1951            // `SUM(amount) OVER (PARTITION BY balance)` references
1952            // `balance` in the partition-by, not the function args.
1953            // Caught by Copilot review on PR #1085.
1954            call.args.iter().any(|a| expr_references_column(a, name))
1955                || call
1956                    .over
1957                    .partition_by
1958                    .as_ref()
1959                    .is_some_and(|ps| ps.iter().any(|p| expr_references_column(p, name)))
1960                || call
1961                    .over
1962                    .order_by
1963                    .as_ref()
1964                    .is_some_and(|os| os.iter().any(|o| expr_references_column(&o.expr, name)))
1965        }
1966        Expr::BinaryOp(op) => {
1967            expr_references_column(&op.left, name) || expr_references_column(&op.right, name)
1968        }
1969        Expr::UnaryOp(op) => expr_references_column(&op.operand, name),
1970        Expr::Paren(inner) => expr_references_column(inner, name),
1971        Expr::Between { value, low, high } => {
1972            expr_references_column(value, name)
1973                || expr_references_column(low, name)
1974                || expr_references_column(high, name)
1975        }
1976        Expr::Set(items) => items.iter().any(|i| expr_references_column(i, name)),
1977        Expr::Wildcard | Expr::Literal(_) => false,
1978    }
1979}
1980
1981/// Return `true` if any part of a `SelectQuery` references the given
1982/// column. Walks SELECT targets, WHERE, GROUP BY, HAVING, PIVOT BY,
1983/// ORDER BY, and the FROM filter expression. A subquery in FROM is
1984/// treated as opaque — its inner references don't surface to the
1985/// outer query's posting iterator.
1986fn query_references_column(query: &SelectQuery, name: &str) -> bool {
1987    if query
1988        .targets
1989        .iter()
1990        .any(|t| expr_references_column(&t.expr, name))
1991    {
1992        return true;
1993    }
1994    if let Some(w) = &query.where_clause
1995        && expr_references_column(w, name)
1996    {
1997        return true;
1998    }
1999    if let Some(g) = &query.group_by
2000        && g.iter().any(|e| expr_references_column(e, name))
2001    {
2002        return true;
2003    }
2004    if let Some(h) = &query.having
2005        && expr_references_column(h, name)
2006    {
2007        return true;
2008    }
2009    if let Some(p) = &query.pivot_by
2010        && p.iter().any(|e| expr_references_column(e, name))
2011    {
2012        return true;
2013    }
2014    if let Some(o) = &query.order_by
2015        && o.iter().any(|s| expr_references_column(&s.expr, name))
2016    {
2017        return true;
2018    }
2019    if let Some(from) = &query.from
2020        && let Some(f) = &from.filter
2021        && expr_references_column(f, name)
2022    {
2023        return true;
2024    }
2025    false
2026}
2027
2028#[cfg(test)]
2029mod tests;
2030
2031#[cfg(test)]
2032mod dual_eval_parity;