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