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 Value::Null => Ok(Value::Null),
701 _ => Err(QueryError::Type("YEAR expects a date".to_string())),
702 }
703 }
704 "MONTH" => {
705 Self::require_args_count(&name_upper, args, 1)?;
706 match &args[0] {
707 Value::Date(d) => Ok(Value::Integer(d.month().into())),
708 Value::Null => Ok(Value::Null),
709 _ => Err(QueryError::Type("MONTH expects a date".to_string())),
710 }
711 }
712 "DAY" => {
713 Self::require_args_count(&name_upper, args, 1)?;
714 match &args[0] {
715 Value::Date(d) => Ok(Value::Integer(d.day().into())),
716 Value::Null => Ok(Value::Null),
717 _ => Err(QueryError::Type("DAY expects a date".to_string())),
718 }
719 }
720 // String functions
721 "LENGTH" => {
722 Self::require_args_count(&name_upper, args, 1)?;
723 match &args[0] {
724 // Count Unicode characters, not UTF-8 bytes (matches beanquery).
725 Value::String(s) => Ok(Value::Integer(s.chars().count() as i64)),
726 Value::StringSet(s) => Ok(Value::Integer(s.len() as i64)),
727 Value::Null => Ok(Value::Null),
728 _ => Err(QueryError::Type(
729 "LENGTH expects a string or set".to_string(),
730 )),
731 }
732 }
733 "UPPER" => {
734 Self::require_args_count(&name_upper, args, 1)?;
735 match &args[0] {
736 Value::String(s) => Ok(Value::String(s.to_uppercase())),
737 Value::Null => Ok(Value::Null),
738 _ => Err(QueryError::Type("UPPER expects a string".to_string())),
739 }
740 }
741 "LOWER" => {
742 Self::require_args_count(&name_upper, args, 1)?;
743 match &args[0] {
744 Value::String(s) => Ok(Value::String(s.to_lowercase())),
745 Value::Null => Ok(Value::Null),
746 _ => Err(QueryError::Type("LOWER expects a string".to_string())),
747 }
748 }
749 "TRIM" => {
750 Self::require_args_count(&name_upper, args, 1)?;
751 match &args[0] {
752 Value::String(s) => Ok(Value::String(s.trim().to_string())),
753 Value::Null => Ok(Value::Null),
754 _ => Err(QueryError::Type("TRIM expects a string".to_string())),
755 }
756 }
757 // Math functions
758 "ABS" => {
759 Self::require_args_count(&name_upper, args, 1)?;
760 match &args[0] {
761 Value::Number(n) => Ok(Value::Number(n.abs())),
762 Value::Integer(i) => Ok(Value::Integer(i.abs())),
763 Value::Null => Ok(Value::Null),
764 _ => Err(QueryError::Type("ABS expects a number".to_string())),
765 }
766 }
767 "ROUND" => Self::round_on_values(args),
768 // Utility functions
769 "COALESCE" => {
770 for arg in args {
771 if !matches!(arg, Value::Null) {
772 return Ok(arg.clone());
773 }
774 }
775 Ok(Value::Null)
776 }
777 // Position/Amount functions
778 "NUMBER" => {
779 Self::require_args_count(&name_upper, args, 1)?;
780 match &args[0] {
781 Value::Amount(a) => Ok(Value::Number(a.number)),
782 Value::Position(p) => Ok(Value::Number(p.units.number)),
783 Value::Number(n) => Ok(Value::Number(*n)),
784 Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
785 Value::Inventory(inv) => {
786 // For inventory, only return a number if all positions share the same
787 // currency. Summing across different currencies is not meaningful.
788 // Single pass: track the first currency and running total, bail out
789 // to Null on any currency mismatch.
790 let mut iter = inv.positions();
791 let Some(first) = iter.next() else {
792 return Ok(Value::Number(Decimal::ZERO));
793 };
794 let first_currency = &first.units.currency;
795 let mut total = first.units.number;
796 for pos in iter {
797 if &pos.units.currency != first_currency {
798 return Ok(Value::Null);
799 }
800 total += pos.units.number;
801 }
802 Ok(Value::Number(total))
803 }
804 Value::Null => Ok(Value::Null),
805 _ => Err(QueryError::Type(
806 "NUMBER expects an amount, position, or inventory".to_string(),
807 )),
808 }
809 }
810 "CURRENCY" => {
811 Self::require_args_count(&name_upper, args, 1)?;
812 match &args[0] {
813 Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
814 Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
815 Value::Inventory(inv) => {
816 // Return the currency of the first position, or Null if empty
817 if let Some(pos) = inv.positions().next() {
818 Ok(Value::String(pos.units.currency.to_string()))
819 } else {
820 Ok(Value::Null)
821 }
822 }
823 Value::Null => Ok(Value::Null),
824 _ => Err(QueryError::Type(
825 "CURRENCY expects an amount or position".to_string(),
826 )),
827 }
828 }
829 "UNITS" => {
830 Self::require_args_count(&name_upper, args, 1)?;
831 match &args[0] {
832 Value::Position(p) => Ok(Value::Amount(p.units.clone())),
833 Value::Amount(a) => Ok(Value::Amount(a.clone())),
834 Value::Inventory(inv) => {
835 // Return inventory with just units (no cost info)
836 let mut units_inv = Inventory::new();
837 for pos in inv.positions() {
838 units_inv.add(Position::simple(pos.units.clone()));
839 }
840 Ok(Value::Inventory(Box::new(units_inv)))
841 }
842 Value::Null => Ok(Value::Null),
843 _ => Err(QueryError::Type(
844 "UNITS expects a position or inventory".to_string(),
845 )),
846 }
847 }
848 "COST" => {
849 Self::require_args_count(&name_upper, args, 1)?;
850 match &args[0] {
851 Value::Position(p) => {
852 if let Some(cost) = &p.cost {
853 // Preserve sign: buys give positive cost, sells give negative
854 let total = p.units.number * cost.number;
855 Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
856 } else {
857 Ok(Value::Amount(p.units.clone()))
858 }
859 }
860 Value::Amount(a) => Ok(Value::Amount(a.clone())),
861 Value::Inventory(inv) => {
862 let mut total = Decimal::ZERO;
863 let mut currency: Option<rustledger_core::Currency> = None;
864 for pos in inv.positions() {
865 if let Some(cost) = &pos.cost {
866 total += pos.units.number * cost.number;
867 if currency.is_none() {
868 currency = Some(cost.currency.clone());
869 }
870 } else {
871 total += pos.units.number;
872 if currency.is_none() {
873 currency = Some(pos.units.currency.clone());
874 }
875 }
876 }
877 if let Some(curr) = currency {
878 Ok(Value::Amount(Amount::new(total, curr)))
879 } else {
880 Ok(Value::Null)
881 }
882 }
883 Value::Null => Ok(Value::Null),
884 _ => Err(QueryError::Type(
885 "COST expects a position or inventory".to_string(),
886 )),
887 }
888 }
889 "VALUE" => {
890 // Use shared VALUE implementation for consistent behavior.
891 // See `eval_value` on PositionFunctions for the full signature
892 // contract (DATE vs. currency-string dispatch).
893 if args.is_empty() || args.len() > 2 {
894 return Err(QueryError::InvalidArguments(
895 "VALUE".to_string(),
896 "expected 1-2 arguments".to_string(),
897 ));
898 }
899 let (explicit_currency, at_date) = if args.len() == 2 {
900 match &args[1] {
901 Value::Date(d) => (None, Some(*d)),
902 Value::String(s) => (Some(s.as_str()), None),
903 Value::Null => {
904 return Err(QueryError::Type(
905 concat!(
906 "VALUE: second argument evaluated to NULL; ",
907 "expected a date or currency string ",
908 "(this often means an aggregate expression couldn't ",
909 "evaluate against an empty group — see issue #902)",
910 )
911 .to_string(),
912 ));
913 }
914 _ => {
915 return Err(QueryError::Type(
916 "VALUE second argument must be a date or currency string"
917 .to_string(),
918 ));
919 }
920 }
921 } else {
922 (None, None)
923 };
924 self.convert_to_market_value(&args[0], explicit_currency, at_date)
925 }
926 // Math functions
927 "SAFEDIV" => {
928 Self::require_args_count(&name_upper, args, 2)?;
929 let (dividend, divisor) = (&args[0], &args[1]);
930 match (dividend, divisor) {
931 // NULL propagates.
932 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
933 // Any numeric pair: coerce to Decimal and divide. A zero
934 // divisor yields 0 (the "safe" in SAFEDIV) — matching
935 // beanquery and the per-row `eval_safediv` path, which used to
936 // disagree (this path returned NULL on a zero divisor).
937 _ => {
938 let to_dec = |v: &Value| match v {
939 Value::Number(n) => Some(*n),
940 Value::Integer(i) => Some(Decimal::from(*i)),
941 _ => None,
942 };
943 match (to_dec(dividend), to_dec(divisor)) {
944 (Some(a), Some(b)) => Ok(Value::Number(if b.is_zero() {
945 Decimal::ZERO
946 } else {
947 a / b
948 })),
949 _ => Err(QueryError::Type(
950 "SAFEDIV expects numeric arguments".to_string(),
951 )),
952 }
953 }
954 }
955 }
956 "NEG" => {
957 Self::require_args_count(&name_upper, args, 1)?;
958 match &args[0] {
959 Value::Number(n) => Ok(Value::Number(-n)),
960 Value::Integer(i) => Ok(Value::Integer(-i)),
961 Value::Amount(a) => {
962 Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
963 }
964 Value::Null => Ok(Value::Null),
965 _ => Err(QueryError::Type(
966 "NEG expects a number or amount".to_string(),
967 )),
968 }
969 }
970 // Account functions
971 "ACCOUNT_SORTKEY" => {
972 Self::require_args_count(&name_upper, args, 1)?;
973 match &args[0] {
974 Value::String(s) => {
975 let type_index = Self::account_type_index(s);
976 Ok(Value::String(format!("{type_index}-{s}")))
977 }
978 Value::Null => Ok(Value::Null),
979 _ => Err(QueryError::Type(
980 "ACCOUNT_SORTKEY expects an account string".to_string(),
981 )),
982 }
983 }
984 "PARENT" => {
985 Self::require_args_count(&name_upper, args, 1)?;
986 match &args[0] {
987 Value::String(s) => {
988 if let Some(idx) = s.rfind(':') {
989 Ok(Value::String(s[..idx].to_string()))
990 } else {
991 Ok(Value::Null)
992 }
993 }
994 Value::Null => Ok(Value::Null),
995 _ => Err(QueryError::Type(
996 "PARENT expects an account string".to_string(),
997 )),
998 }
999 }
1000 "LEAF" => {
1001 Self::require_args_count(&name_upper, args, 1)?;
1002 match &args[0] {
1003 Value::String(s) => {
1004 if let Some(idx) = s.rfind(':') {
1005 Ok(Value::String(s[idx + 1..].to_string()))
1006 } else {
1007 Ok(Value::String(s.clone()))
1008 }
1009 }
1010 Value::Null => Ok(Value::Null),
1011 _ => Err(QueryError::Type(
1012 "LEAF expects an account string".to_string(),
1013 )),
1014 }
1015 }
1016 "ROOT" => {
1017 if args.is_empty() || args.len() > 2 {
1018 return Err(QueryError::InvalidArguments(
1019 "ROOT".to_string(),
1020 "expected 1 or 2 arguments".to_string(),
1021 ));
1022 }
1023 let n = if args.len() == 2 {
1024 let raw = match &args[1] {
1025 Value::Integer(i) => *i,
1026 _ => {
1027 return Err(QueryError::Type(
1028 "ROOT second arg must be integer".to_string(),
1029 ));
1030 }
1031 };
1032 // Reject negatives explicitly — `i as usize` would silently
1033 // turn -1 into `usize::MAX` and return the whole account.
1034 // Mirrors the lazy `eval_root` guard so both paths agree.
1035 usize::try_from(raw).map_err(|_| {
1036 QueryError::Type(format!(
1037 "ROOT second arg must be a non-negative integer, got {raw}"
1038 ))
1039 })?
1040 } else {
1041 1
1042 };
1043 match &args[0] {
1044 Value::String(s) => {
1045 let parts: Vec<&str> = s.split(':').collect();
1046 if n >= parts.len() {
1047 Ok(Value::String(s.clone()))
1048 } else {
1049 Ok(Value::String(parts[..n].join(":")))
1050 }
1051 }
1052 Value::Null => Ok(Value::Null),
1053 _ => Err(QueryError::Type(
1054 "ROOT expects an account string".to_string(),
1055 )),
1056 }
1057 }
1058 // ONLY function: extract single-currency amount from inventory
1059 "ONLY" => {
1060 Self::require_args_count(&name_upper, args, 2)?;
1061 let currency = match &args[0] {
1062 Value::String(s) => s.clone(),
1063 // NULL propagates (beanquery parity): `first(cost_currency)`
1064 // is NULL for groups without costs, and fava's Holdings
1065 // by_currency query feeds exactly that into only() — see
1066 // #1699. The second-argument match below already
1067 // propagates; the asymmetry was the bug.
1068 Value::Null => return Ok(Value::Null),
1069 _ => {
1070 return Err(QueryError::Type(
1071 "ONLY: first argument must be a currency string".to_string(),
1072 ));
1073 }
1074 };
1075 match &args[1] {
1076 Value::Inventory(inv) => {
1077 let total = inv.units(¤cy);
1078 if total.is_zero() {
1079 Ok(Value::Null)
1080 } else {
1081 Ok(Value::Amount(Amount::new(total, ¤cy)))
1082 }
1083 }
1084 Value::Position(p) => {
1085 if p.units.currency.as_str() == currency {
1086 Ok(Value::Amount(p.units.clone()))
1087 } else {
1088 Ok(Value::Null)
1089 }
1090 }
1091 Value::Amount(a) => {
1092 if a.currency.as_str() == currency {
1093 Ok(Value::Amount(a.clone()))
1094 } else {
1095 Ok(Value::Null)
1096 }
1097 }
1098 Value::Null => Ok(Value::Null),
1099 _ => Err(QueryError::Type(
1100 "ONLY: second argument must be an inventory, position, or amount"
1101 .to_string(),
1102 )),
1103 }
1104 }
1105 // GETPRICE function - needs price database
1106 "GETPRICE" => {
1107 if args.len() < 2 || args.len() > 3 {
1108 return Err(QueryError::InvalidArguments(
1109 "GETPRICE".to_string(),
1110 "expected 2 or 3 arguments".to_string(),
1111 ));
1112 }
1113 // Handle NULL arguments gracefully
1114 let base = match &args[0] {
1115 Value::String(s) => s.clone(),
1116 Value::Null => return Ok(Value::Null),
1117 _ => {
1118 return Err(QueryError::Type(
1119 "GETPRICE: first argument must be a currency string".to_string(),
1120 ));
1121 }
1122 };
1123 let quote = match &args[1] {
1124 Value::String(s) => s.clone(),
1125 Value::Null => return Ok(Value::Null),
1126 _ => {
1127 return Err(QueryError::Type(
1128 "GETPRICE: second argument must be a currency string".to_string(),
1129 ));
1130 }
1131 };
1132 let date = if args.len() == 3 {
1133 match &args[2] {
1134 Value::Date(d) => *d,
1135 Value::Null => self.query_date,
1136 _ => self.query_date,
1137 }
1138 } else {
1139 self.query_date
1140 };
1141 match self.price_db.get_price(&base, "e, date) {
1142 Some(price) => Ok(Value::Number(price)),
1143 None => Ok(Value::Null),
1144 }
1145 }
1146 // Inventory functions
1147 "EMPTY" => {
1148 Self::require_args_count(&name_upper, args, 1)?;
1149 match &args[0] {
1150 Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
1151 Value::Null => Ok(Value::Boolean(true)),
1152 _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
1153 }
1154 }
1155 "FILTER_CURRENCY" => {
1156 Self::require_args_count(&name_upper, args, 2)?;
1157 let currency = match &args[1] {
1158 Value::String(s) => s.clone(),
1159 Value::Null => return Ok(Value::Null),
1160 _ => {
1161 return Err(QueryError::Type(
1162 "FILTER_CURRENCY expects (inventory, string)".to_string(),
1163 ));
1164 }
1165 };
1166 match &args[0] {
1167 Value::Inventory(inv) => {
1168 let filtered: Vec<Position> = inv
1169 .positions()
1170 .filter(|p| p.units.currency.as_str() == currency)
1171 .cloned()
1172 .collect();
1173 let mut new_inv = Inventory::new();
1174 for pos in filtered {
1175 new_inv.add(pos);
1176 }
1177 Ok(Value::Inventory(Box::new(new_inv)))
1178 }
1179 Value::Null => Ok(Value::Null),
1180 _ => Err(QueryError::Type(
1181 "FILTER_CURRENCY expects (inventory, string)".to_string(),
1182 )),
1183 }
1184 }
1185 "POSSIGN" => {
1186 Self::require_args_count(&name_upper, args, 2)?;
1187 let account_str = match &args[1] {
1188 Value::String(s) => s.clone(),
1189 Value::Null => return Ok(Value::Null),
1190 _ => {
1191 return Err(QueryError::Type(
1192 "POSSIGN expects (amount, account_string)".to_string(),
1193 ));
1194 }
1195 };
1196 let first_component = account_str.split(':').next().unwrap_or("");
1197 let is_credit_normal =
1198 matches!(first_component, "Liabilities" | "Equity" | "Income");
1199 match &args[0] {
1200 Value::Amount(a) => {
1201 let mut amt = a.clone();
1202 if is_credit_normal {
1203 amt.number = -amt.number;
1204 }
1205 Ok(Value::Amount(amt))
1206 }
1207 Value::Number(n) => {
1208 let adjusted = if is_credit_normal { -n } else { *n };
1209 Ok(Value::Number(adjusted))
1210 }
1211 // Mirror the lazy `POSSIGN`: an integer amount is treated as
1212 // a number and sign-adjusted.
1213 Value::Integer(i) => {
1214 let n = Decimal::from(*i);
1215 let adjusted = if is_credit_normal { -n } else { n };
1216 Ok(Value::Number(adjusted))
1217 }
1218 Value::Null => Ok(Value::Null),
1219 _ => Err(QueryError::Type(
1220 "POSSIGN expects (amount, account_string)".to_string(),
1221 )),
1222 }
1223 }
1224 // CONVERT function - convert amounts/positions/inventories to target currency
1225 "CONVERT" => {
1226 if args.len() < 2 || args.len() > 3 {
1227 return Err(QueryError::InvalidArguments(
1228 "CONVERT".to_string(),
1229 "expected 2 or 3 arguments: (value, currency[, date])".to_string(),
1230 ));
1231 }
1232
1233 let target_currency = match &args[1] {
1234 Value::String(s) => s.clone(),
1235 Value::Null => {
1236 return Err(QueryError::Type(
1237 concat!(
1238 "CONVERT: second argument evaluated to NULL; ",
1239 "expected a currency string ",
1240 "(this often means an aggregate expression couldn't ",
1241 "evaluate against an empty group — see issue #902)",
1242 )
1243 .to_string(),
1244 ));
1245 }
1246 _ => {
1247 return Err(QueryError::Type(
1248 "CONVERT: second argument must be a currency string".to_string(),
1249 ));
1250 }
1251 };
1252
1253 // Optional date argument
1254 let date: Option<rustledger_core::NaiveDate> = if args.len() == 3 {
1255 match &args[2] {
1256 Value::Date(d) => Some(*d),
1257 Value::Null => None, // NULL date uses latest price
1258 _ => {
1259 return Err(QueryError::Type(
1260 "CONVERT: third argument must be a date".to_string(),
1261 ));
1262 }
1263 }
1264 } else {
1265 None
1266 };
1267
1268 // Helper closure to convert an amount
1269 let convert_amount = |amt: &Amount| -> Option<Amount> {
1270 if let Some(d) = date {
1271 self.price_db.convert(amt, &target_currency, d)
1272 } else {
1273 self.price_db.convert_latest(amt, &target_currency)
1274 }
1275 };
1276
1277 match &args[0] {
1278 Value::Position(p) => {
1279 if p.units.currency == target_currency {
1280 Ok(Value::Amount(p.units.clone()))
1281 } else if let Some(converted) = convert_amount(&p.units) {
1282 Ok(Value::Amount(converted))
1283 } else {
1284 Ok(Value::Amount(p.units.clone()))
1285 }
1286 }
1287 Value::Amount(a) => {
1288 if a.currency == target_currency {
1289 Ok(Value::Amount(a.clone()))
1290 } else if let Some(converted) = convert_amount(a) {
1291 Ok(Value::Amount(converted))
1292 } else {
1293 Ok(Value::Amount(a.clone()))
1294 }
1295 }
1296 Value::Inventory(inv) => {
1297 // Convert each position, keeping originals when no conversion available
1298 // (matches Python beancount behavior)
1299 let mut result = Inventory::default();
1300 for pos in inv.positions() {
1301 if pos.units.currency == target_currency {
1302 result.add(Position::simple(pos.units.clone()));
1303 } else if let Some(converted) = convert_amount(&pos.units) {
1304 result.add(Position::simple(converted));
1305 } else {
1306 // No conversion available - keep original (Python beancount behavior)
1307 result.add(Position::simple(pos.units.clone()));
1308 }
1309 }
1310 // If result has single currency matching target, return as Amount
1311 // If result is empty, return zero in target currency (issue #586)
1312 let positions: Vec<&Position> = result.positions().collect();
1313 if positions.is_empty() {
1314 Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1315 } else if positions.len() == 1
1316 && positions[0].units.currency == target_currency
1317 {
1318 Ok(Value::Amount(positions[0].units.clone()))
1319 } else {
1320 Ok(Value::Inventory(Box::new(result)))
1321 }
1322 }
1323 Value::Number(n) => Ok(Value::Amount(Amount::new(*n, &target_currency))),
1324 Value::String(s) => {
1325 // String input is a rustledger extension (issue #1179),
1326 // not present in Python beancount. Lets users write
1327 // ad-hoc currency conversions like
1328 // `SELECT CONVERT('100 USD', 'EUR')` without anchoring
1329 // them to a posting. Strict parser (see
1330 // `Amount::from_str`): malformed input surfaces as a
1331 // typed `QueryError` rather than a silent zero or a
1332 // panic.
1333 let amt: Amount = s.parse().map_err(|e| {
1334 QueryError::Type(format!(
1335 "CONVERT: first argument {e} (e.g. \"100 USD\")"
1336 ))
1337 })?;
1338 if amt.currency == target_currency {
1339 Ok(Value::Amount(amt))
1340 } else if let Some(converted) = convert_amount(&amt) {
1341 Ok(Value::Amount(converted))
1342 } else {
1343 // Match the `Value::Amount` arm: no price available
1344 // → return original unchanged.
1345 Ok(Value::Amount(amt))
1346 }
1347 }
1348 Value::Null => {
1349 // For null values (e.g., empty sum), return zero in target currency
1350 // This matches Python beancount behavior for empty balances (issue #586)
1351 Ok(Value::Amount(Amount::new(Decimal::ZERO, &target_currency)))
1352 }
1353 _ => Err(QueryError::Type(
1354 "CONVERT expects a position, amount, inventory, number, or amount-string"
1355 .to_string(),
1356 )),
1357 }
1358 }
1359 // Type casting functions - use shared helpers
1360 "STR" => {
1361 Self::require_args_count(&name_upper, args, 1)?;
1362 Self::value_to_str(&args[0])
1363 }
1364 "INT" => {
1365 Self::require_args_count(&name_upper, args, 1)?;
1366 Self::value_to_int(&args[0])
1367 }
1368 "DECIMAL" => {
1369 Self::require_args_count(&name_upper, args, 1)?;
1370 Self::value_to_decimal(&args[0])
1371 }
1372 "BOOL" => {
1373 Self::require_args_count(&name_upper, args, 1)?;
1374 Self::value_to_bool(&args[0])
1375 }
1376 // Date functions for wrapping aggregates: QUARTER(MAX(date))
1377 "QUARTER" => {
1378 Self::require_args_count(&name_upper, args, 1)?;
1379 match &args[0] {
1380 // beanquery returns a `YYYY-Qn` string, not an integer.
1381 Value::Date(d) => Ok(Value::String(format!(
1382 "{:04}-Q{}",
1383 d.year(),
1384 (d.month() - 1) / 3 + 1
1385 ))),
1386 Value::Null => Ok(Value::Null),
1387 _ => Err(QueryError::Type("QUARTER expects a date".to_string())),
1388 }
1389 }
1390 "WEEKDAY" => {
1391 Self::require_args_count(&name_upper, args, 1)?;
1392 match &args[0] {
1393 Value::Date(d) => Ok(Value::String(
1394 functions::weekday_abbrev(d.weekday().to_monday_zero_offset() as u32)
1395 .to_string(),
1396 )),
1397 _ => Err(QueryError::Type("WEEKDAY expects a date".to_string())),
1398 }
1399 }
1400 "YMONTH" => {
1401 Self::require_args_count(&name_upper, args, 1)?;
1402 match &args[0] {
1403 Value::Date(d) => {
1404 Ok(Value::String(format!("{:04}-{:02}", d.year(), d.month())))
1405 }
1406 _ => Err(QueryError::Type("YMONTH expects a date".to_string())),
1407 }
1408 }
1409 // String functions for wrapping aggregates
1410 "SUBSTR" | "SUBSTRING" => {
1411 if args.len() < 2 || args.len() > 3 {
1412 return Err(QueryError::InvalidArguments(
1413 name_upper,
1414 "expected 2 or 3 arguments".to_string(),
1415 ));
1416 }
1417 // Python slice semantics s[start:end] — see `py_slice` /
1418 // `eval_substr`. arg3 is the END index, not a length.
1419 match (&args[0], &args[1], args.get(2)) {
1420 (Value::String(s), Value::Integer(start), None) => Ok(Value::String(
1421 functions::string::py_slice(&s.chars().collect::<Vec<_>>(), *start, None),
1422 )),
1423 (Value::String(s), Value::Integer(start), Some(Value::Integer(end))) => {
1424 Ok(Value::String(functions::string::py_slice(
1425 &s.chars().collect::<Vec<_>>(),
1426 *start,
1427 Some(*end),
1428 )))
1429 }
1430 _ => Err(QueryError::Type(
1431 "SUBSTR expects (string, int, [int])".to_string(),
1432 )),
1433 }
1434 }
1435 "STARTSWITH" => {
1436 Self::require_args_count(&name_upper, args, 2)?;
1437 match (&args[0], &args[1]) {
1438 (Value::String(s), Value::String(prefix)) => {
1439 Ok(Value::Boolean(s.starts_with(prefix.as_str())))
1440 }
1441 _ => Err(QueryError::Type(
1442 "STARTSWITH expects two strings".to_string(),
1443 )),
1444 }
1445 }
1446 "ENDSWITH" => {
1447 Self::require_args_count(&name_upper, args, 2)?;
1448 match (&args[0], &args[1]) {
1449 (Value::String(s), Value::String(suffix)) => {
1450 Ok(Value::Boolean(s.ends_with(suffix.as_str())))
1451 }
1452 _ => Err(QueryError::Type("ENDSWITH expects two strings".to_string())),
1453 }
1454 }
1455 "MAXWIDTH" => Self::maxwidth_on_values(args),
1456 // Account function used in GROUP BY
1457 "ACCOUNT_DEPTH" => {
1458 Self::require_args_count(&name_upper, args, 1)?;
1459 match &args[0] {
1460 Value::String(s) => Ok(Value::Integer(s.matches(':').count() as i64 + 1)),
1461 _ => Err(QueryError::Type(
1462 "ACCOUNT_DEPTH expects an account string".to_string(),
1463 )),
1464 }
1465 }
1466 // Position/amount getters
1467 "GETITEM" | "GET" => {
1468 Self::require_args_count(&name_upper, args, 2)?;
1469 match (&args[0], &args[1]) {
1470 (Value::Inventory(inv), Value::String(currency)) => {
1471 let amount = inv.units(currency);
1472 if amount.is_zero() {
1473 Ok(Value::Null)
1474 } else {
1475 Ok(Value::Amount(Amount::new(amount, currency.as_str())))
1476 }
1477 }
1478 // Metadata / object lookup — mirror the per-row path
1479 // (`eval_getitem`). Previously only the lazy path handled
1480 // these, so `getitem(meta, key)` errored in the eager /
1481 // `#postings` evaluation path.
1482 (Value::Metadata(meta), Value::String(key)) => {
1483 Ok(Self::meta_value_to_value(meta.get(key)))
1484 }
1485 (Value::Object(obj), Value::String(key)) => {
1486 Ok(obj.get(key).cloned().unwrap_or(Value::Null))
1487 }
1488 (Value::Null, _) => Ok(Value::Null),
1489 _ => Err(QueryError::Type(
1490 "GETITEM expects (inventory, string), (metadata, string), or (object, string)"
1491 .to_string(),
1492 )),
1493 }
1494 }
1495 "WEIGHT" => {
1496 Self::require_args_count(&name_upper, args, 1)?;
1497 match &args[0] {
1498 Value::Position(p) => {
1499 if let Some(cost) = &p.cost {
1500 let total = p.units.number * cost.number;
1501 Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
1502 } else {
1503 Ok(Value::Amount(p.units.clone()))
1504 }
1505 }
1506 Value::Amount(a) => Ok(Value::Amount(a.clone())),
1507 Value::Inventory(inv) => {
1508 let mut result = Inventory::new();
1509 for pos in inv.positions() {
1510 if let Some(cost) = &pos.cost {
1511 let total = pos.units.number * cost.number;
1512 result.add(Position::simple(Amount::new(
1513 total,
1514 cost.currency.clone(),
1515 )));
1516 } else {
1517 result.add(Position::simple(pos.units.clone()));
1518 }
1519 }
1520 Ok(Value::Inventory(Box::new(result)))
1521 }
1522 Value::Null => Ok(Value::Null),
1523 _ => Err(QueryError::Type(
1524 "WEIGHT expects a position, amount, or inventory".to_string(),
1525 )),
1526 }
1527 }
1528 "DATE" => Self::date_construct_on_values(args),
1529 "DATE_ADD" => Self::date_add_on_values(args),
1530 "DATE_TRUNC" => Self::date_trunc_on_values(args),
1531 "DATE_PART" => Self::date_part_on_values(args),
1532 "PARSE_DATE" => Self::parse_date_on_values(args),
1533 "DATE_BIN" => Self::date_bin_on_values(args),
1534 "INTERVAL" => Self::interval_on_values(args),
1535 // Date: DATE_DIFF for wrapping aggregates like DATE_DIFF(MAX(date), MIN(date))
1536 "DATE_DIFF" => {
1537 Self::require_args_count(&name_upper, args, 2)?;
1538 match (&args[0], &args[1]) {
1539 (Value::Date(d1), Value::Date(d2)) => Ok(Value::Integer(i64::from(
1540 d1.since(*d2).unwrap_or_default().get_days(),
1541 ))),
1542 _ => Err(QueryError::Type("DATE_DIFF expects two dates".to_string())),
1543 }
1544 }
1545 // String: regex functions for wrapping aggregates
1546 "GREP" => {
1547 Self::require_args_count(&name_upper, args, 2)?;
1548 match (&args[0], &args[1]) {
1549 (Value::String(pattern), Value::String(s)) => {
1550 let re = regex::Regex::new(pattern).map_err(|e| {
1551 QueryError::Type(format!("GREP: invalid regex '{pattern}': {e}"))
1552 })?;
1553 match re.find(s) {
1554 Some(m) => Ok(Value::String(m.as_str().to_string())),
1555 None => Ok(Value::Null),
1556 }
1557 }
1558 // Null args → Null (e.g., narration is Null for non-transaction entries)
1559 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1560 _ => Err(QueryError::Type("GREP expects two strings".to_string())),
1561 }
1562 }
1563 "GREPN" => {
1564 Self::require_args_count(&name_upper, args, 3)?;
1565 let n = match &args[2] {
1566 Value::Integer(i) => (*i).max(0) as usize,
1567 Value::Number(n) => {
1568 use rust_decimal::prelude::ToPrimitive;
1569 n.to_usize().unwrap_or(0)
1570 }
1571 _ => {
1572 return Err(QueryError::Type(
1573 "GREPN: third argument must be an integer".to_string(),
1574 ));
1575 }
1576 };
1577 match (&args[0], &args[1]) {
1578 (Value::String(pattern), Value::String(s)) => {
1579 let re = regex::Regex::new(pattern).map_err(|e| {
1580 QueryError::Type(format!("GREPN: invalid regex '{pattern}': {e}"))
1581 })?;
1582 match re.captures(s) {
1583 Some(caps) => match caps.get(n) {
1584 Some(m) => Ok(Value::String(m.as_str().to_string())),
1585 None => Ok(Value::Null),
1586 },
1587 None => Ok(Value::Null),
1588 }
1589 }
1590 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1591 _ => Err(QueryError::Type(
1592 "GREPN expects (pattern, string, int)".to_string(),
1593 )),
1594 }
1595 }
1596 "SUBST" => {
1597 Self::require_args_count(&name_upper, args, 3)?;
1598 match (&args[0], &args[1], &args[2]) {
1599 (Value::String(pattern), Value::String(replacement), Value::String(s)) => {
1600 let re = regex::Regex::new(pattern).map_err(|e| {
1601 QueryError::Type(format!("SUBST: invalid regex '{pattern}': {e}"))
1602 })?;
1603 Ok(Value::String(
1604 re.replace_all(s, replacement.as_str()).to_string(),
1605 ))
1606 }
1607 _ => Err(QueryError::Type(
1608 "SUBST expects (pattern, replacement, string)".to_string(),
1609 )),
1610 }
1611 }
1612 "SPLITCOMP" => {
1613 Self::require_args_count(&name_upper, args, 3)?;
1614 let n = match &args[2] {
1615 Value::Integer(i) => (*i).max(0) as usize,
1616 Value::Number(n) => {
1617 use rust_decimal::prelude::ToPrimitive;
1618 n.to_usize().unwrap_or(0)
1619 }
1620 _ => {
1621 return Err(QueryError::Type(
1622 "SPLITCOMP: third argument must be an integer".to_string(),
1623 ));
1624 }
1625 };
1626 match (&args[0], &args[1]) {
1627 (Value::String(s), Value::String(delim)) => {
1628 let parts: Vec<&str> = s.split(delim.as_str()).collect();
1629 match parts.get(n) {
1630 Some(part) => Ok(Value::String((*part).to_string())),
1631 None => Ok(Value::Null),
1632 }
1633 }
1634 _ => Err(QueryError::Type(
1635 "SPLITCOMP expects (string, delimiter, int)".to_string(),
1636 )),
1637 }
1638 }
1639 "JOINSTR" => {
1640 // Mirror the former lazy `eval_joinstr`: require >=1 argument,
1641 // SKIP nulls, and stringify every other non-String/Set arg via
1642 // `value_to_string`, joining with ", " (a comma+space).
1643 if args.is_empty() {
1644 return Err(QueryError::InvalidArguments(
1645 "JOINSTR".to_string(),
1646 "expected at least 1 argument".to_string(),
1647 ));
1648 }
1649 let mut parts = Vec::new();
1650 for v in args {
1651 match v {
1652 Value::String(s) => parts.push(s.clone()),
1653 Value::StringSet(ss) => parts.extend(ss.iter().cloned()),
1654 Value::Null => {}
1655 other => parts.push(Self::value_to_string(other)),
1656 }
1657 }
1658 Ok(Value::String(parts.join(", ")))
1659 }
1660 // Account metadata functions — look up open/close info
1661 "OPEN_DATE" => {
1662 Self::require_args_count(&name_upper, args, 1)?;
1663 match &args[0] {
1664 Value::String(account) => Ok(self
1665 .account_info
1666 .get(account.as_str())
1667 .and_then(|info| info.open_date)
1668 .map_or(Value::Null, Value::Date)),
1669 Value::Null => Ok(Value::Null),
1670 _ => Err(QueryError::Type(
1671 "OPEN_DATE expects an account string".to_string(),
1672 )),
1673 }
1674 }
1675 "CLOSE_DATE" => {
1676 Self::require_args_count(&name_upper, args, 1)?;
1677 match &args[0] {
1678 Value::String(account) => Ok(self
1679 .account_info
1680 .get(account.as_str())
1681 .and_then(|info| info.close_date)
1682 .map_or(Value::Null, Value::Date)),
1683 Value::Null => Ok(Value::Null),
1684 _ => Err(QueryError::Type(
1685 "CLOSE_DATE expects an account string".to_string(),
1686 )),
1687 }
1688 }
1689 "OPEN_META" => {
1690 Self::require_args_count(&name_upper, args, 2)?;
1691 match (&args[0], &args[1]) {
1692 (Value::String(account), Value::String(key)) => Ok(self
1693 .account_info
1694 .get(account.as_str())
1695 .and_then(|info| info.open_meta.get(key))
1696 .map_or(Value::Null, |mv| Self::meta_value_to_value(Some(mv)))),
1697 (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
1698 _ => Err(QueryError::Type(
1699 "OPEN_META expects (account_string, key_string)".to_string(),
1700 )),
1701 }
1702 }
1703 // Metadata access — returns Null in evaluate_function_on_values
1704 // because metadata is accessed via row context in eval_meta_on_table_row.
1705 // This branch handles edge cases where META is called outside table context.
1706 "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
1707 Self::require_args_count(&name_upper, args, 1)?;
1708 match &args[0] {
1709 Value::String(_) | Value::Null => Ok(Value::Null),
1710 _ => Err(QueryError::Type(format!(
1711 "{name_upper}: argument must be a string key"
1712 ))),
1713 }
1714 }
1715 // Aggregate functions return Null when evaluated on a single row
1716 "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
1717 _ => Err(QueryError::UnknownFunction(name.to_string())),
1718 }
1719 }
1720
1721 /// Convert a `Metadata` map to a `Value::Object` for table storage.
1722 fn metadata_to_value(meta: &rustledger_core::Metadata) -> Value {
1723 if meta.is_empty() {
1724 return Value::Null;
1725 }
1726 let map: std::collections::BTreeMap<String, Value> = meta
1727 .iter()
1728 .map(|(k, v)| (k.clone(), Self::meta_value_to_value(Some(v))))
1729 .collect();
1730 Value::Object(Box::new(map))
1731 }
1732
1733 /// Helper to require a specific number of arguments (for pre-evaluated args).
1734 fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
1735 if args.len() != expected {
1736 return Err(QueryError::InvalidArguments(
1737 name.to_string(),
1738 format!("expected {} argument(s), got {}", expected, args.len()),
1739 ));
1740 }
1741 Ok(())
1742 }
1743
1744 /// Helper to require a specific number of arguments.
1745 fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
1746 if func.args.len() != expected {
1747 return Err(QueryError::InvalidArguments(
1748 name.to_string(),
1749 format!("expected {expected} argument(s)"),
1750 ));
1751 }
1752 Ok(())
1753 }
1754
1755 /// Convert a value to its market value.
1756 ///
1757 /// Shared `VALUE()` implementation used by both expression evaluation and
1758 /// the aggregate/subquery path in `evaluate_function_on_values`.
1759 ///
1760 /// # Arguments
1761 /// * `val` - The value to convert (`Position`, `Amount`, `Inventory`, or `Null`).
1762 /// * `explicit_currency` - Optional explicit target currency. When `None`,
1763 /// the currency is inferred from the position's cost basis (Python
1764 /// beancount compatibility) or falls back to the executor's
1765 /// `target_currency` setting.
1766 /// * `at_date` - Optional valuation date. When `Some`, prices are looked up
1767 /// with "on or before" semantics via [`price::PriceDatabase::convert`];
1768 /// when `None`, the latest available price is used via
1769 /// [`price::PriceDatabase::convert_latest`] (matches Python's
1770 /// `value(position)` with `date=None`, which may use a future-dated price).
1771 ///
1772 /// # Returns
1773 /// - `Value::Amount` when conversion succeeds, or when the input is a
1774 /// single `Position`/`Amount` that can't be priced (raw units returned).
1775 /// - `Value::Inventory` when no target currency can be determined and the
1776 /// input is an `Inventory`.
1777 /// - `Value::Null` when the input is null.
1778 ///
1779 /// # Inventory caveat
1780 ///
1781 /// For `Value::Inventory` inputs with a determined target currency, this
1782 /// function returns a single `Value::Amount` summed in the target currency.
1783 /// Positions within the inventory that cannot be priced at `at_date` (or
1784 /// have no latest price) are silently dropped from the sum. This differs
1785 /// from Python beancount's `inventory.reduce(get_value, ...)`, which
1786 /// preserves unpriced positions as raw units in the resulting inventory.
1787 /// Reconciling this is tracked as a separate follow-up and is out of scope
1788 /// for #892.
1789 pub(crate) fn convert_to_market_value(
1790 &self,
1791 val: &Value,
1792 explicit_currency: Option<&str>,
1793 at_date: Option<NaiveDate>,
1794 ) -> Result<Value, QueryError> {
1795 // Column-type stability (#1701): the one-argument form infers the
1796 // target currency PER ROW (cost currency, else executor default), so
1797 // an Amount-vs-Inventory return that depends on the row's data makes
1798 // the column type unstable — the FFI layer declares the type from one
1799 // row and other rows then contradict it. The rule:
1800 // - explicit currency (two-arg form): target is constant across the
1801 // query -> Amount for every row (existing behavior, stable);
1802 // - one-arg form over an Inventory: ALWAYS return an Inventory
1803 // (beanquery parity: value(inventory) is inventory-typed), whether
1804 // or not a target currency could be inferred for this row.
1805 let inventory_stays_inventory = explicit_currency.is_none();
1806 // Determine target currency:
1807 // 1. Explicit argument takes precedence
1808 // 2. Infer from position's cost currency (beancount compatibility)
1809 // 3. Fall back to executor's target_currency setting
1810 let target_currency = if let Some(currency) = explicit_currency {
1811 currency.to_string()
1812 } else {
1813 // Try to infer from cost currency
1814 let inferred = match val {
1815 Value::Position(p) => p.cost.as_ref().map(|c| c.currency.to_string()),
1816 Value::Inventory(inv) => inv
1817 .positions()
1818 .find_map(|p| p.cost.as_ref().map(|c| c.currency.to_string())),
1819 _ => None,
1820 };
1821
1822 match inferred.or_else(|| self.target_currency.clone()) {
1823 Some(c) => c,
1824 None => {
1825 // No currency can be determined — return value as-is
1826 // (matches Python beancount behavior for positions without cost).
1827 // Note: `at_date` is ignored here because there is nothing to
1828 // convert without a target currency.
1829 return match val {
1830 Value::Position(p) => Ok(Value::Amount(p.units.clone())),
1831 Value::Amount(a) => Ok(Value::Amount(a.clone())),
1832 Value::Inventory(inv) => Ok(Value::Inventory(inv.clone())),
1833 Value::Null => Ok(Value::Null),
1834 _ => Err(QueryError::Type(
1835 "VALUE expects a position, amount, or inventory".to_string(),
1836 )),
1837 };
1838 }
1839 }
1840 };
1841
1842 // Price lookup matches Python beancount's semantics:
1843 // - When `at_date` is None, use the latest price (which may be future-dated).
1844 // - When `at_date` is Some, use the most recent price on or before that date;
1845 // if no such price exists, the conversion silently returns the raw units.
1846 let convert_one = |amount: &Amount| -> Option<Amount> {
1847 match at_date {
1848 Some(d) => self.price_db.convert(amount, &target_currency, d),
1849 None => self.price_db.convert_latest(amount, &target_currency),
1850 }
1851 };
1852
1853 match val {
1854 Value::Position(p) => {
1855 if p.units.currency == target_currency {
1856 Ok(Value::Amount(p.units.clone()))
1857 } else if let Some(converted) = convert_one(&p.units) {
1858 Ok(Value::Amount(converted))
1859 } else {
1860 Ok(Value::Amount(p.units.clone()))
1861 }
1862 }
1863 Value::Amount(a) => {
1864 if a.currency == target_currency {
1865 Ok(Value::Amount(a.clone()))
1866 } else if let Some(converted) = convert_one(a) {
1867 Ok(Value::Amount(converted))
1868 } else {
1869 Ok(Value::Amount(a.clone()))
1870 }
1871 }
1872 Value::Inventory(inv) => {
1873 if inventory_stays_inventory {
1874 // Convert per position; a position with no available price
1875 // keeps its raw units (matching the Position/Amount arms
1876 // above and beanquery, which never drops positions).
1877 let mut out = rustledger_core::Inventory::new();
1878 for pos in inv.positions() {
1879 let units = if pos.units.currency == target_currency {
1880 pos.units.clone()
1881 } else if let Some(converted) = convert_one(&pos.units) {
1882 converted
1883 } else {
1884 pos.units.clone()
1885 };
1886 out.add(rustledger_core::Position::simple(units));
1887 }
1888 return Ok(Value::Inventory(Box::new(out)));
1889 }
1890 // Two-arg form: collapse to a single Amount in the explicit
1891 // target currency. NOTE (pre-existing beanquery divergence,
1892 // out of #1701's scope): positions with no available price are
1893 // dropped from the total here; beanquery would keep them as
1894 // their original units in an Inventory result.
1895 let mut total = Decimal::ZERO;
1896 for pos in inv.positions() {
1897 if pos.units.currency == target_currency {
1898 total += pos.units.number;
1899 } else if let Some(converted) = convert_one(&pos.units) {
1900 total += converted.number;
1901 }
1902 }
1903 Ok(Value::Amount(Amount::new(total, &target_currency)))
1904 }
1905 Value::Null => Ok(Value::Null),
1906 _ => Err(QueryError::Type(
1907 "VALUE expects a position, amount, or inventory".to_string(),
1908 )),
1909 }
1910 }
1911
1912 /// Check if an expression is a window function.
1913 pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1914 matches!(expr, Expr::Window(_))
1915 }
1916
1917 /// Resolve column names from targets.
1918 fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1919 let mut names = Vec::new();
1920 for (i, target) in targets.iter().enumerate() {
1921 if matches!(target.expr, Expr::Wildcard) {
1922 // Check wildcard BEFORE alias to catch `SELECT * AS alias` edge case
1923 if target.alias.is_some() {
1924 return Err(QueryError::Evaluation(
1925 "Cannot alias wildcard (*) - it expands to multiple columns".to_string(),
1926 ));
1927 }
1928 // Expand wildcard using shared constant (must match evaluate_row expansion)
1929 names.extend(WILDCARD_COLUMNS.iter().map(|s| (*s).to_string()));
1930 } else if let Some(alias) = &target.alias {
1931 names.push(alias.clone());
1932 } else {
1933 names.push(self.expr_to_name(&target.expr, i));
1934 }
1935 }
1936 Ok(names)
1937 }
1938
1939 /// Convert an expression to a column name.
1940 fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1941 match expr {
1942 Expr::Wildcard => "*".to_string(),
1943 Expr::Column(name) => name.clone(),
1944 Expr::Function(func) => func.name.clone(),
1945 Expr::Window(wf) => wf.name.clone(),
1946 _ => format!("col{index}"),
1947 }
1948 }
1949
1950 /// Get a built-in system table by name.
1951 ///
1952 /// Built-in tables are virtual tables that provide access to ledger data:
1953 /// - `#prices` / `prices`: Price directives from the ledger
1954 /// - `#balances` / `balances`: Balance assertion directives from the ledger
1955 /// - `#commodities` / `commodities`: Commodity directives from the ledger
1956 /// - `#events` / `events`: Event directives from the ledger
1957 /// - `#notes` / `notes`: Note directives from the ledger
1958 /// - `#documents` / `documents`: Document directives from the ledger
1959 /// - `#accounts` / `accounts`: Open/Close directives paired by account
1960 /// - `#transactions` / `transactions`: Transaction directives from the ledger
1961 /// - `#entries` / `entries`: All directives with source location info
1962 /// - `#postings` / `postings`: All postings from transactions
1963 ///
1964 /// Both `#`-prefixed and non-prefixed names are supported for Python beancount
1965 /// compatibility (issue #632).
1966 ///
1967 /// Returns `None` if the table name is not a recognized built-in table.
1968 pub(super) fn get_builtin_table(&self, table_name: &str) -> Option<Table> {
1969 // Normalize table name: strip # prefix if present for Python beancount compatibility.
1970 // Both "#transactions" (rustledger) and "transactions" (beancount) work.
1971 // Using strip_prefix avoids allocation in the common case.
1972 let upper = table_name.to_uppercase();
1973 let normalized = upper.strip_prefix('#').unwrap_or(&upper);
1974
1975 match normalized {
1976 "PRICES" => Some(self.build_prices_table()),
1977 "BALANCES" => Some(self.build_balances_table()),
1978 "COMMODITIES" => Some(self.build_commodities_table()),
1979 "EVENTS" => Some(self.build_events_table()),
1980 "NOTES" => Some(self.build_notes_table()),
1981 "DOCUMENTS" => Some(self.build_documents_table()),
1982 "ACCOUNTS" => Some(self.build_accounts_table()),
1983 "TRANSACTIONS" => Some(self.build_transactions_table()),
1984 "ENTRIES" => Some(self.build_entries_table()),
1985 "POSTINGS" => Some(self.build_postings_table()),
1986 _ => None,
1987 }
1988 }
1989}
1990
1991/// Walk an [`Expr`] tree, returning `true` if any [`Expr::Column`]
1992/// references the given column name (case-insensitive).
1993///
1994/// Used to decide whether [`Executor::collect_postings`] needs to
1995/// materialize the per-posting `balance` / `account_balance` snapshots
1996/// — they're expensive (cumulative `Inventory` clones per posting,
1997/// the runaway cost in #1080) so we skip the work when no part of the
1998/// query reads them.
1999fn expr_references_column(expr: &Expr, name: &str) -> bool {
2000 match expr {
2001 Expr::Column(col) => col.eq_ignore_ascii_case(name),
2002 Expr::Function(call) => call.args.iter().any(|a| expr_references_column(a, name)),
2003 Expr::Window(call) => {
2004 // Function args + the OVER clause's PARTITION BY / ORDER BY
2005 // expressions all need to be walked — a window function like
2006 // `SUM(amount) OVER (PARTITION BY balance)` references
2007 // `balance` in the partition-by, not the function args.
2008 // Caught by Copilot review on PR #1085.
2009 call.args.iter().any(|a| expr_references_column(a, name))
2010 || call
2011 .over
2012 .partition_by
2013 .as_ref()
2014 .is_some_and(|ps| ps.iter().any(|p| expr_references_column(p, name)))
2015 || call
2016 .over
2017 .order_by
2018 .as_ref()
2019 .is_some_and(|os| os.iter().any(|o| expr_references_column(&o.expr, name)))
2020 }
2021 Expr::BinaryOp(op) => {
2022 expr_references_column(&op.left, name) || expr_references_column(&op.right, name)
2023 }
2024 Expr::UnaryOp(op) => expr_references_column(&op.operand, name),
2025 Expr::Paren(inner) => expr_references_column(inner, name),
2026 Expr::Between { value, low, high } => {
2027 expr_references_column(value, name)
2028 || expr_references_column(low, name)
2029 || expr_references_column(high, name)
2030 }
2031 Expr::Set(items) => items.iter().any(|i| expr_references_column(i, name)),
2032 Expr::Wildcard | Expr::Literal(_) => false,
2033 }
2034}
2035
2036/// Return `true` if any part of a `SelectQuery` references the given
2037/// column. Walks SELECT targets, WHERE, GROUP BY, HAVING, PIVOT BY,
2038/// ORDER BY, and the FROM filter expression. A subquery in FROM is
2039/// treated as opaque — its inner references don't surface to the
2040/// outer query's posting iterator.
2041fn query_references_column(query: &SelectQuery, name: &str) -> bool {
2042 if query
2043 .targets
2044 .iter()
2045 .any(|t| expr_references_column(&t.expr, name))
2046 {
2047 return true;
2048 }
2049 if let Some(w) = &query.where_clause
2050 && expr_references_column(w, name)
2051 {
2052 return true;
2053 }
2054 if let Some(g) = &query.group_by
2055 && g.iter().any(|e| expr_references_column(e, name))
2056 {
2057 return true;
2058 }
2059 if let Some(h) = &query.having
2060 && expr_references_column(h, name)
2061 {
2062 return true;
2063 }
2064 if let Some(p) = &query.pivot_by
2065 && p.iter().any(|e| expr_references_column(e, name))
2066 {
2067 return true;
2068 }
2069 if let Some(o) = &query.order_by
2070 && o.iter().any(|s| expr_references_column(&s.expr, name))
2071 {
2072 return true;
2073 }
2074 if let Some(from) = &query.from
2075 && let Some(f) = &from.filter
2076 && expr_references_column(f, name)
2077 {
2078 return true;
2079 }
2080 false
2081}
2082
2083#[cfg(test)]
2084mod tests;
2085
2086#[cfg(test)]
2087mod dual_eval_parity;