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(¤cy);
1084 if total.is_zero() {
1085 Ok(Value::Null)
1086 } else {
1087 Ok(Value::Amount(Amount::new(total, ¤cy)))
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, "e, 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;