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