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