Skip to main content

rustledger_query/executor/
mod.rs

1//! BQL Query Executor.
2//!
3//! Executes parsed BQL queries against a set of Beancount directives.
4
5mod functions;
6mod types;
7
8use types::AccountInfo;
9pub use types::{
10    Interval, IntervalUnit, PostingContext, QueryResult, Row, SourceLocation, Table, Value,
11    WindowContext,
12};
13
14use std::sync::RwLock;
15
16use rustc_hash::FxHashMap;
17
18use chrono::Datelike;
19use regex::Regex;
20use rust_decimal::Decimal;
21use rustledger_core::{Amount, Directive, InternedStr, Inventory, Metadata, Position};
22#[cfg(test)]
23use rustledger_core::{MetaValue, NaiveDate, Transaction};
24use rustledger_loader::SourceMap;
25use rustledger_parser::Spanned;
26
27use crate::ast::{Expr, FromClause, FunctionCall, Query, Target};
28use crate::error::QueryError;
29
30/// Query executor.
31pub struct Executor<'a> {
32    /// All directives to query over.
33    directives: &'a [Directive],
34    /// Spanned directives (optional, for source location support).
35    spanned_directives: Option<&'a [Spanned<Directive>]>,
36    /// Account balances (built up during query).
37    balances: FxHashMap<InternedStr, Inventory>,
38    /// Price database for `VALUE()` conversions.
39    price_db: crate::price::PriceDatabase,
40    /// Target currency for `VALUE()` conversions.
41    target_currency: Option<String>,
42    /// Query date for price lookups (defaults to today).
43    query_date: chrono::NaiveDate,
44    /// Cache for compiled regex patterns (`RwLock` for thread-safe parallel execution).
45    regex_cache: RwLock<FxHashMap<String, Option<Regex>>>,
46    /// Account info cache from Open/Close directives.
47    account_info: FxHashMap<String, AccountInfo>,
48    /// Source locations for directives (indexed by directive index).
49    source_locations: Option<Vec<SourceLocation>>,
50    /// In-memory tables created by CREATE TABLE.
51    tables: FxHashMap<String, Table>,
52}
53
54// Sub-modules for focused functionality
55mod aggregation;
56mod evaluation;
57mod execution;
58mod operators;
59mod sort;
60mod window;
61
62impl<'a> Executor<'a> {
63    /// Create a new executor with the given directives.
64    pub fn new(directives: &'a [Directive]) -> Self {
65        let price_db = crate::price::PriceDatabase::from_directives(directives);
66
67        // Build account info cache from Open/Close directives
68        let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
69        for directive in directives {
70            match directive {
71                Directive::Open(open) => {
72                    let account = open.account.to_string();
73                    let info = account_info.entry(account).or_insert_with(|| AccountInfo {
74                        open_date: None,
75                        close_date: None,
76                        open_meta: Metadata::default(),
77                    });
78                    info.open_date = Some(open.date);
79                    info.open_meta.clone_from(&open.meta);
80                }
81                Directive::Close(close) => {
82                    let account = close.account.to_string();
83                    let info = account_info.entry(account).or_insert_with(|| AccountInfo {
84                        open_date: None,
85                        close_date: None,
86                        open_meta: Metadata::default(),
87                    });
88                    info.close_date = Some(close.date);
89                }
90                _ => {}
91            }
92        }
93
94        Self {
95            directives,
96            spanned_directives: None,
97            balances: FxHashMap::default(),
98            price_db,
99            target_currency: None,
100            query_date: chrono::Local::now().date_naive(),
101            regex_cache: RwLock::new(FxHashMap::default()),
102            account_info,
103            source_locations: None,
104            tables: FxHashMap::default(),
105        }
106    }
107
108    /// Create a new executor with source location support.
109    ///
110    /// This constructor accepts spanned directives and a source map, enabling
111    /// the `filename`, `lineno`, and `location` columns in queries.
112    pub fn new_with_sources(
113        spanned_directives: &'a [Spanned<Directive>],
114        source_map: &SourceMap,
115    ) -> Self {
116        // Build price database from spanned directives
117        let mut price_db = crate::price::PriceDatabase::new();
118        for spanned in spanned_directives {
119            if let Directive::Price(p) = &spanned.value {
120                price_db.add_price(p);
121            }
122        }
123
124        // Build source locations
125        let source_locations: Vec<SourceLocation> = spanned_directives
126            .iter()
127            .map(|spanned| {
128                let file = source_map.get(spanned.file_id as usize);
129                let (line, _col) = file.map_or((0, 0), |f| f.line_col(spanned.span.start));
130                SourceLocation {
131                    filename: file.map_or_else(String::new, |f| f.path.display().to_string()),
132                    lineno: line,
133                }
134            })
135            .collect();
136
137        // Build account info cache from Open/Close directives
138        let mut account_info: FxHashMap<String, AccountInfo> = FxHashMap::default();
139        for spanned in spanned_directives {
140            match &spanned.value {
141                Directive::Open(open) => {
142                    let account = open.account.to_string();
143                    let info = account_info.entry(account).or_insert_with(|| AccountInfo {
144                        open_date: None,
145                        close_date: None,
146                        open_meta: Metadata::default(),
147                    });
148                    info.open_date = Some(open.date);
149                    info.open_meta.clone_from(&open.meta);
150                }
151                Directive::Close(close) => {
152                    let account = close.account.to_string();
153                    let info = account_info.entry(account).or_insert_with(|| AccountInfo {
154                        open_date: None,
155                        close_date: None,
156                        open_meta: Metadata::default(),
157                    });
158                    info.close_date = Some(close.date);
159                }
160                _ => {}
161            }
162        }
163
164        Self {
165            directives: &[], // Empty - we use spanned_directives instead
166            spanned_directives: Some(spanned_directives),
167            balances: FxHashMap::default(),
168            price_db,
169            target_currency: None,
170            query_date: chrono::Local::now().date_naive(),
171            regex_cache: RwLock::new(FxHashMap::default()),
172            account_info,
173            source_locations: Some(source_locations),
174            tables: FxHashMap::default(),
175        }
176    }
177
178    /// Get the source location for a directive by index.
179    fn get_source_location(&self, directive_index: usize) -> Option<&SourceLocation> {
180        self.source_locations
181            .as_ref()
182            .and_then(|locs| locs.get(directive_index))
183    }
184
185    /// Get or compile a regex pattern from the cache.
186    ///
187    /// Returns `Some(Regex)` if the pattern is valid, `None` if it's invalid.
188    /// Invalid patterns are cached as `None` to avoid repeated compilation attempts.
189    fn get_or_compile_regex(&self, pattern: &str) -> Option<Regex> {
190        // Fast path: check read lock first
191        {
192            // Handle lock poisoning gracefully - if another thread panicked while holding
193            // the lock, we can still recover the cached data via into_inner()
194            let cache = match self.regex_cache.read() {
195                Ok(guard) => guard,
196                Err(poisoned) => poisoned.into_inner(),
197            };
198            if let Some(cached) = cache.get(pattern) {
199                return cached.clone();
200            }
201        }
202        // Slow path: compile and insert with write lock
203        let compiled = Regex::new(pattern).ok();
204        let mut cache = match self.regex_cache.write() {
205            Ok(guard) => guard,
206            Err(poisoned) => poisoned.into_inner(),
207        };
208        // Double-check in case another thread inserted while we waited
209        if let Some(cached) = cache.get(pattern) {
210            return cached.clone();
211        }
212        cache.insert(pattern.to_string(), compiled.clone());
213        compiled
214    }
215
216    /// Get or compile a regex pattern, returning an error if invalid.
217    fn require_regex(&self, pattern: &str) -> Result<Regex, QueryError> {
218        self.get_or_compile_regex(pattern)
219            .ok_or_else(|| QueryError::Type(format!("invalid regex: {pattern}")))
220    }
221
222    /// Set the target currency for `VALUE()` conversions.
223    pub fn set_target_currency(&mut self, currency: impl Into<String>) {
224        self.target_currency = Some(currency.into());
225    }
226
227    /// Execute a query and return the results.
228    ///
229    /// # Errors
230    ///
231    /// Returns [`QueryError`] in the following cases:
232    ///
233    /// - [`QueryError::UnknownColumn`] - A referenced column name doesn't exist
234    /// - [`QueryError::UnknownFunction`] - An unknown function is called
235    /// - [`QueryError::InvalidArguments`] - Function called with wrong arguments
236    /// - [`QueryError::Type`] - Type mismatch in expression (e.g., comparing string to number)
237    /// - [`QueryError::Aggregation`] - Error in aggregate function (SUM, COUNT, etc.)
238    /// - [`QueryError::Evaluation`] - General expression evaluation error
239    pub fn execute(&mut self, query: &Query) -> Result<QueryResult, QueryError> {
240        match query {
241            Query::Select(select) => self.execute_select(select),
242            Query::Journal(journal) => self.execute_journal(journal),
243            Query::Balances(balances) => self.execute_balances(balances),
244            Query::Print(print) => self.execute_print(print),
245            Query::CreateTable(create) => self.execute_create_table(create),
246            Query::Insert(insert) => self.execute_insert(insert),
247        }
248    }
249
250    /// Execute a SELECT query.
251    fn build_balances_with_filter(&mut self, from: Option<&FromClause>) -> Result<(), QueryError> {
252        for directive in self.directives {
253            if let Directive::Transaction(txn) = directive {
254                // Apply FROM filter if present
255                if let Some(from_clause) = from
256                    && let Some(filter) = &from_clause.filter
257                    && !self.evaluate_from_filter(filter, txn)?
258                {
259                    continue;
260                }
261
262                for posting in &txn.postings {
263                    if let Some(units) = posting.amount() {
264                        let balance = self.balances.entry(posting.account.clone()).or_default();
265
266                        let pos = if let Some(cost_spec) = &posting.cost {
267                            if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
268                                Position::with_cost(units.clone(), cost)
269                            } else {
270                                Position::simple(units.clone())
271                            }
272                        } else {
273                            Position::simple(units.clone())
274                        };
275                        balance.add(pos);
276                    }
277                }
278            }
279        }
280        Ok(())
281    }
282
283    /// Collect postings matching the FROM and WHERE clauses.
284    fn collect_postings(
285        &self,
286        from: Option<&FromClause>,
287        where_clause: Option<&Expr>,
288    ) -> Result<Vec<PostingContext<'a>>, QueryError> {
289        let mut postings = Vec::new();
290        // Track running balance per account
291        let mut running_balances: FxHashMap<InternedStr, Inventory> = FxHashMap::default();
292
293        // Create an iterator over (directive_index, directive) pairs
294        // Handle both spanned and unspanned directives
295        let directive_iter: Vec<(usize, &Directive)> =
296            if let Some(spanned) = self.spanned_directives {
297                spanned
298                    .iter()
299                    .enumerate()
300                    .map(|(i, s)| (i, &s.value))
301                    .collect()
302            } else {
303                self.directives.iter().enumerate().collect()
304            };
305
306        for (directive_index, directive) in directive_iter {
307            if let Directive::Transaction(txn) = directive {
308                // Check FROM clause (transaction-level filter)
309                if let Some(from) = from {
310                    // Apply date filters
311                    if let Some(open_date) = from.open_on
312                        && txn.date < open_date
313                    {
314                        // Update balances but don't include in results
315                        for posting in &txn.postings {
316                            if let Some(units) = posting.amount() {
317                                let balance =
318                                    running_balances.entry(posting.account.clone()).or_default();
319                                balance.add(Position::simple(units.clone()));
320                            }
321                        }
322                        continue;
323                    }
324                    if let Some(close_date) = from.close_on
325                        && txn.date > close_date
326                    {
327                        continue;
328                    }
329                    // Apply filter expression
330                    if let Some(filter) = &from.filter
331                        && !self.evaluate_from_filter(filter, txn)?
332                    {
333                        continue;
334                    }
335                }
336
337                // Add postings with running balance
338                for (i, posting) in txn.postings.iter().enumerate() {
339                    // Update running balance for this account
340                    if let Some(units) = posting.amount() {
341                        let balance = running_balances.entry(posting.account.clone()).or_default();
342                        balance.add(Position::simple(units.clone()));
343                    }
344
345                    let ctx = PostingContext {
346                        transaction: txn,
347                        posting_index: i,
348                        balance: running_balances.get(&posting.account).cloned(),
349                        directive_index: Some(directive_index),
350                    };
351
352                    // Check WHERE clause (posting-level filter)
353                    if let Some(where_expr) = where_clause {
354                        if self.evaluate_predicate(where_expr, &ctx)? {
355                            postings.push(ctx);
356                        }
357                    } else {
358                        postings.push(ctx);
359                    }
360                }
361            }
362        }
363
364        Ok(postings)
365    }
366    fn evaluate_function(
367        &self,
368        func: &FunctionCall,
369        ctx: &PostingContext,
370    ) -> Result<Value, QueryError> {
371        let name = func.name.to_uppercase();
372        match name.as_str() {
373            // Date functions
374            "YEAR" | "MONTH" | "DAY" | "WEEKDAY" | "QUARTER" | "YMONTH" | "TODAY" => {
375                self.eval_date_function(&name, func, ctx)
376            }
377            // Extended date functions
378            "DATE" | "DATE_DIFF" | "DATE_ADD" | "DATE_TRUNC" | "DATE_PART" | "PARSE_DATE"
379            | "DATE_BIN" | "INTERVAL" => self.eval_extended_date_function(&name, func, ctx),
380            // String functions
381            "LENGTH" | "UPPER" | "LOWER" | "SUBSTR" | "SUBSTRING" | "TRIM" | "STARTSWITH"
382            | "ENDSWITH" | "GREP" | "GREPN" | "SUBST" | "SPLITCOMP" | "JOINSTR" | "MAXWIDTH" => {
383                self.eval_string_function(&name, func, ctx)
384            }
385            // Account functions
386            "PARENT" | "LEAF" | "ROOT" | "ACCOUNT_DEPTH" | "ACCOUNT_SORTKEY" => {
387                self.eval_account_function(&name, func, ctx)
388            }
389            // Account metadata functions
390            "OPEN_DATE" | "CLOSE_DATE" | "OPEN_META" => {
391                self.eval_account_meta_function(&name, func, ctx)
392            }
393            // Math functions
394            "ABS" | "NEG" | "ROUND" | "SAFEDIV" => self.eval_math_function(&name, func, ctx),
395            // Amount/Position functions
396            "NUMBER" | "CURRENCY" | "GETITEM" | "GET" | "UNITS" | "COST" | "WEIGHT" | "VALUE" => {
397                self.eval_position_function(&name, func, ctx)
398            }
399            // Inventory functions
400            "EMPTY" | "FILTER_CURRENCY" | "POSSIGN" => {
401                self.eval_inventory_function(&name, func, ctx)
402            }
403            // Price functions
404            "GETPRICE" => self.eval_getprice(func, ctx),
405            // Utility functions
406            "COALESCE" => self.eval_coalesce(func, ctx),
407            "ONLY" => self.eval_only(func, ctx),
408            // Metadata functions
409            "META" | "ENTRY_META" | "ANY_META" | "POSTING_META" => {
410                self.eval_meta_function(&name, func, ctx)
411            }
412            // Currency conversion
413            "CONVERT" => self.eval_convert(func, ctx),
414            // Type casting functions
415            "INT" => self.eval_int(func, ctx),
416            "DECIMAL" => self.eval_decimal(func, ctx),
417            "STR" => self.eval_str(func, ctx),
418            "BOOL" => self.eval_bool(func, ctx),
419            // Aggregate functions return Null when evaluated on a single row
420            // They're handled specially in aggregate evaluation
421            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
422            _ => Err(QueryError::UnknownFunction(func.name.clone())),
423        }
424    }
425
426    /// Evaluate a function with pre-evaluated arguments (for subquery context).
427    fn evaluate_function_on_values(&self, name: &str, args: &[Value]) -> Result<Value, QueryError> {
428        let name_upper = name.to_uppercase();
429        match name_upper.as_str() {
430            // Date functions
431            "TODAY" => Ok(Value::Date(chrono::Local::now().date_naive())),
432            "YEAR" => {
433                Self::require_args_count(&name_upper, args, 1)?;
434                match &args[0] {
435                    Value::Date(d) => Ok(Value::Integer(d.year().into())),
436                    _ => Err(QueryError::Type("YEAR expects a date".to_string())),
437                }
438            }
439            "MONTH" => {
440                Self::require_args_count(&name_upper, args, 1)?;
441                match &args[0] {
442                    Value::Date(d) => Ok(Value::Integer(d.month().into())),
443                    _ => Err(QueryError::Type("MONTH expects a date".to_string())),
444                }
445            }
446            "DAY" => {
447                Self::require_args_count(&name_upper, args, 1)?;
448                match &args[0] {
449                    Value::Date(d) => Ok(Value::Integer(d.day().into())),
450                    _ => Err(QueryError::Type("DAY expects a date".to_string())),
451                }
452            }
453            // String functions
454            "LENGTH" => {
455                Self::require_args_count(&name_upper, args, 1)?;
456                match &args[0] {
457                    Value::String(s) => Ok(Value::Integer(s.len() as i64)),
458                    _ => Err(QueryError::Type("LENGTH expects a string".to_string())),
459                }
460            }
461            "UPPER" => {
462                Self::require_args_count(&name_upper, args, 1)?;
463                match &args[0] {
464                    Value::String(s) => Ok(Value::String(s.to_uppercase())),
465                    _ => Err(QueryError::Type("UPPER expects a string".to_string())),
466                }
467            }
468            "LOWER" => {
469                Self::require_args_count(&name_upper, args, 1)?;
470                match &args[0] {
471                    Value::String(s) => Ok(Value::String(s.to_lowercase())),
472                    _ => Err(QueryError::Type("LOWER expects a string".to_string())),
473                }
474            }
475            "TRIM" => {
476                Self::require_args_count(&name_upper, args, 1)?;
477                match &args[0] {
478                    Value::String(s) => Ok(Value::String(s.trim().to_string())),
479                    _ => Err(QueryError::Type("TRIM expects a string".to_string())),
480                }
481            }
482            // Math functions
483            "ABS" => {
484                Self::require_args_count(&name_upper, args, 1)?;
485                match &args[0] {
486                    Value::Number(n) => Ok(Value::Number(n.abs())),
487                    Value::Integer(i) => Ok(Value::Integer(i.abs())),
488                    _ => Err(QueryError::Type("ABS expects a number".to_string())),
489                }
490            }
491            "ROUND" => {
492                if args.is_empty() || args.len() > 2 {
493                    return Err(QueryError::InvalidArguments(
494                        "ROUND".to_string(),
495                        "expected 1 or 2 arguments".to_string(),
496                    ));
497                }
498                match &args[0] {
499                    Value::Number(n) => {
500                        let scale = if args.len() == 2 {
501                            match &args[1] {
502                                Value::Integer(i) => *i as u32,
503                                _ => 0,
504                            }
505                        } else {
506                            0
507                        };
508                        Ok(Value::Number(n.round_dp(scale)))
509                    }
510                    Value::Integer(i) => Ok(Value::Integer(*i)),
511                    _ => Err(QueryError::Type("ROUND expects a number".to_string())),
512                }
513            }
514            // Utility functions
515            "COALESCE" => {
516                for arg in args {
517                    if !matches!(arg, Value::Null) {
518                        return Ok(arg.clone());
519                    }
520                }
521                Ok(Value::Null)
522            }
523            // Position/Amount functions
524            "NUMBER" => {
525                Self::require_args_count(&name_upper, args, 1)?;
526                match &args[0] {
527                    Value::Amount(a) => Ok(Value::Number(a.number)),
528                    Value::Position(p) => Ok(Value::Number(p.units.number)),
529                    Value::Number(n) => Ok(Value::Number(*n)),
530                    Value::Integer(i) => Ok(Value::Number(Decimal::from(*i))),
531                    Value::Inventory(inv) => {
532                        // For inventory, only return a number if all positions share the same
533                        // currency. Summing across different currencies is not meaningful.
534                        let positions = inv.positions();
535                        if positions.is_empty() {
536                            return Ok(Value::Number(Decimal::ZERO));
537                        }
538                        let first_currency = &positions[0].units.currency;
539                        let all_same_currency = positions
540                            .iter()
541                            .all(|p| &p.units.currency == first_currency);
542                        if all_same_currency {
543                            let total: Decimal = positions.iter().map(|p| p.units.number).sum();
544                            Ok(Value::Number(total))
545                        } else {
546                            // Multiple currencies - return NULL rather than a meaningless sum
547                            Ok(Value::Null)
548                        }
549                    }
550                    Value::Null => Ok(Value::Null),
551                    _ => Err(QueryError::Type(
552                        "NUMBER expects an amount, position, or inventory".to_string(),
553                    )),
554                }
555            }
556            "CURRENCY" => {
557                Self::require_args_count(&name_upper, args, 1)?;
558                match &args[0] {
559                    Value::Amount(a) => Ok(Value::String(a.currency.to_string())),
560                    Value::Position(p) => Ok(Value::String(p.units.currency.to_string())),
561                    Value::Inventory(inv) => {
562                        // Return the currency of the first position, or Null if empty
563                        if let Some(pos) = inv.positions().first() {
564                            Ok(Value::String(pos.units.currency.to_string()))
565                        } else {
566                            Ok(Value::Null)
567                        }
568                    }
569                    Value::Null => Ok(Value::Null),
570                    _ => Err(QueryError::Type(
571                        "CURRENCY expects an amount or position".to_string(),
572                    )),
573                }
574            }
575            "UNITS" => {
576                Self::require_args_count(&name_upper, args, 1)?;
577                match &args[0] {
578                    Value::Position(p) => Ok(Value::Amount(p.units.clone())),
579                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
580                    Value::Inventory(inv) => {
581                        // Return inventory with just units (no cost info)
582                        let mut units_inv = Inventory::new();
583                        for pos in inv.positions() {
584                            units_inv.add(Position::simple(pos.units.clone()));
585                        }
586                        Ok(Value::Inventory(Box::new(units_inv)))
587                    }
588                    Value::Null => Ok(Value::Null),
589                    _ => Err(QueryError::Type(
590                        "UNITS expects a position or inventory".to_string(),
591                    )),
592                }
593            }
594            "COST" => {
595                Self::require_args_count(&name_upper, args, 1)?;
596                match &args[0] {
597                    Value::Position(p) => {
598                        if let Some(cost) = &p.cost {
599                            let total = p.units.number.abs() * cost.number;
600                            Ok(Value::Amount(Amount::new(total, cost.currency.clone())))
601                        } else {
602                            Ok(Value::Null)
603                        }
604                    }
605                    Value::Amount(a) => Ok(Value::Amount(a.clone())),
606                    Value::Inventory(inv) => {
607                        let mut total = Decimal::ZERO;
608                        let mut currency: Option<InternedStr> = None;
609                        for pos in inv.positions() {
610                            if let Some(cost) = &pos.cost {
611                                total += pos.units.number.abs() * cost.number;
612                                if currency.is_none() {
613                                    currency = Some(cost.currency.clone());
614                                }
615                            }
616                        }
617                        if let Some(curr) = currency {
618                            Ok(Value::Amount(Amount::new(total, curr)))
619                        } else {
620                            Ok(Value::Null)
621                        }
622                    }
623                    Value::Null => Ok(Value::Null),
624                    _ => Err(QueryError::Type(
625                        "COST expects a position or inventory".to_string(),
626                    )),
627                }
628            }
629            "VALUE" => {
630                // VALUE requires price database context - for now, just return the amount/position as-is
631                // Full implementation would convert using target_currency
632                if args.is_empty() || args.len() > 2 {
633                    return Err(QueryError::InvalidArguments(
634                        "VALUE".to_string(),
635                        "expected 1-2 arguments".to_string(),
636                    ));
637                }
638                let target_currency = if args.len() == 2 {
639                    match &args[1] {
640                        Value::String(s) => Some(s.clone()),
641                        _ => None,
642                    }
643                } else {
644                    self.target_currency.clone()
645                };
646                match &args[0] {
647                    Value::Position(p) => {
648                        if let Some(ref target) = target_currency {
649                            if p.units.currency.as_str() == target {
650                                return Ok(Value::Amount(p.units.clone()));
651                            }
652                            // Try price conversion
653                            if let Some(converted) =
654                                self.price_db.convert(&p.units, target, self.query_date)
655                            {
656                                return Ok(Value::Amount(converted));
657                            }
658                        }
659                        Ok(Value::Amount(p.units.clone()))
660                    }
661                    Value::Amount(a) => {
662                        if let Some(ref target) = target_currency {
663                            if a.currency.as_str() == target {
664                                return Ok(Value::Amount(a.clone()));
665                            }
666                            if let Some(converted) =
667                                self.price_db.convert(a, target, self.query_date)
668                            {
669                                return Ok(Value::Amount(converted));
670                            }
671                        }
672                        Ok(Value::Amount(a.clone()))
673                    }
674                    Value::Inventory(inv) => {
675                        if let Some(ref target) = target_currency {
676                            let mut total = Decimal::ZERO;
677                            for pos in inv.positions() {
678                                if pos.units.currency.as_str() == target {
679                                    total += pos.units.number;
680                                } else if let Some(converted) =
681                                    self.price_db.convert(&pos.units, target, self.query_date)
682                                {
683                                    total += converted.number;
684                                }
685                            }
686                            return Ok(Value::Amount(Amount::new(total, target)));
687                        }
688                        // No target currency - can't convert inventory to single amount
689                        Ok(Value::Inventory(inv.clone()))
690                    }
691                    Value::Null => Ok(Value::Null),
692                    _ => Err(QueryError::Type(
693                        "VALUE expects a position or inventory".to_string(),
694                    )),
695                }
696            }
697            // Math functions
698            "SAFEDIV" => {
699                Self::require_args_count(&name_upper, args, 2)?;
700                let (dividend, divisor) = (&args[0], &args[1]);
701                match (dividend, divisor) {
702                    (Value::Number(a), Value::Number(b)) => {
703                        if b.is_zero() {
704                            Ok(Value::Null)
705                        } else {
706                            Ok(Value::Number(a / b))
707                        }
708                    }
709                    (Value::Integer(a), Value::Integer(b)) => {
710                        if *b == 0 {
711                            Ok(Value::Null)
712                        } else {
713                            Ok(Value::Number(Decimal::from(*a) / Decimal::from(*b)))
714                        }
715                    }
716                    (Value::Number(a), Value::Integer(b)) => {
717                        if *b == 0 {
718                            Ok(Value::Null)
719                        } else {
720                            Ok(Value::Number(a / Decimal::from(*b)))
721                        }
722                    }
723                    (Value::Integer(a), Value::Number(b)) => {
724                        if b.is_zero() {
725                            Ok(Value::Null)
726                        } else {
727                            Ok(Value::Number(Decimal::from(*a) / b))
728                        }
729                    }
730                    (Value::Null, _) | (_, Value::Null) => Ok(Value::Null),
731                    _ => Err(QueryError::Type(
732                        "SAFEDIV expects numeric arguments".to_string(),
733                    )),
734                }
735            }
736            "NEG" => {
737                Self::require_args_count(&name_upper, args, 1)?;
738                match &args[0] {
739                    Value::Number(n) => Ok(Value::Number(-n)),
740                    Value::Integer(i) => Ok(Value::Integer(-i)),
741                    Value::Amount(a) => {
742                        Ok(Value::Amount(Amount::new(-a.number, a.currency.clone())))
743                    }
744                    _ => Err(QueryError::Type(
745                        "NEG expects a number or amount".to_string(),
746                    )),
747                }
748            }
749            // Account functions
750            "ACCOUNT_SORTKEY" => {
751                Self::require_args_count(&name_upper, args, 1)?;
752                match &args[0] {
753                    Value::String(s) => {
754                        let type_index = Self::account_type_index(s);
755                        Ok(Value::String(format!("{type_index}-{s}")))
756                    }
757                    _ => Err(QueryError::Type(
758                        "ACCOUNT_SORTKEY expects an account string".to_string(),
759                    )),
760                }
761            }
762            "PARENT" => {
763                Self::require_args_count(&name_upper, args, 1)?;
764                match &args[0] {
765                    Value::String(s) => {
766                        if let Some(idx) = s.rfind(':') {
767                            Ok(Value::String(s[..idx].to_string()))
768                        } else {
769                            Ok(Value::Null)
770                        }
771                    }
772                    _ => Err(QueryError::Type(
773                        "PARENT expects an account string".to_string(),
774                    )),
775                }
776            }
777            "LEAF" => {
778                Self::require_args_count(&name_upper, args, 1)?;
779                match &args[0] {
780                    Value::String(s) => {
781                        if let Some(idx) = s.rfind(':') {
782                            Ok(Value::String(s[idx + 1..].to_string()))
783                        } else {
784                            Ok(Value::String(s.clone()))
785                        }
786                    }
787                    _ => Err(QueryError::Type(
788                        "LEAF expects an account string".to_string(),
789                    )),
790                }
791            }
792            "ROOT" => {
793                if args.is_empty() || args.len() > 2 {
794                    return Err(QueryError::InvalidArguments(
795                        "ROOT".to_string(),
796                        "expected 1 or 2 arguments".to_string(),
797                    ));
798                }
799                let n = if args.len() == 2 {
800                    match &args[1] {
801                        Value::Integer(i) => *i as usize,
802                        _ => 1,
803                    }
804                } else {
805                    1
806                };
807                match &args[0] {
808                    Value::String(s) => {
809                        let parts: Vec<&str> = s.split(':').collect();
810                        if n >= parts.len() {
811                            Ok(Value::String(s.clone()))
812                        } else {
813                            Ok(Value::String(parts[..n].join(":")))
814                        }
815                    }
816                    _ => Err(QueryError::Type(
817                        "ROOT expects an account string".to_string(),
818                    )),
819                }
820            }
821            // ONLY function: extract single-currency amount from inventory
822            "ONLY" => {
823                Self::require_args_count(&name_upper, args, 2)?;
824                let currency = match &args[0] {
825                    Value::String(s) => s.clone(),
826                    _ => {
827                        return Err(QueryError::Type(
828                            "ONLY: first argument must be a currency string".to_string(),
829                        ));
830                    }
831                };
832                match &args[1] {
833                    Value::Inventory(inv) => {
834                        let total = inv.units(&currency);
835                        if total.is_zero() {
836                            Ok(Value::Null)
837                        } else {
838                            Ok(Value::Amount(Amount::new(total, &currency)))
839                        }
840                    }
841                    Value::Position(p) => {
842                        if p.units.currency.as_str() == currency {
843                            Ok(Value::Amount(p.units.clone()))
844                        } else {
845                            Ok(Value::Null)
846                        }
847                    }
848                    Value::Amount(a) => {
849                        if a.currency.as_str() == currency {
850                            Ok(Value::Amount(a.clone()))
851                        } else {
852                            Ok(Value::Null)
853                        }
854                    }
855                    Value::Null => Ok(Value::Null),
856                    _ => Err(QueryError::Type(
857                        "ONLY: second argument must be an inventory, position, or amount"
858                            .to_string(),
859                    )),
860                }
861            }
862            // GETPRICE function - needs price database
863            "GETPRICE" => {
864                if args.len() < 2 || args.len() > 3 {
865                    return Err(QueryError::InvalidArguments(
866                        "GETPRICE".to_string(),
867                        "expected 2 or 3 arguments".to_string(),
868                    ));
869                }
870                // Handle NULL arguments gracefully
871                let base = match &args[0] {
872                    Value::String(s) => s.clone(),
873                    Value::Null => return Ok(Value::Null),
874                    _ => {
875                        return Err(QueryError::Type(
876                            "GETPRICE: first argument must be a currency string".to_string(),
877                        ));
878                    }
879                };
880                let quote = match &args[1] {
881                    Value::String(s) => s.clone(),
882                    Value::Null => return Ok(Value::Null),
883                    _ => {
884                        return Err(QueryError::Type(
885                            "GETPRICE: second argument must be a currency string".to_string(),
886                        ));
887                    }
888                };
889                let date = if args.len() == 3 {
890                    match &args[2] {
891                        Value::Date(d) => *d,
892                        Value::Null => self.query_date,
893                        _ => self.query_date,
894                    }
895                } else {
896                    self.query_date
897                };
898                match self.price_db.get_price(&base, &quote, date) {
899                    Some(price) => Ok(Value::Number(price)),
900                    None => Ok(Value::Null),
901                }
902            }
903            // Inventory functions
904            "EMPTY" => {
905                Self::require_args_count(&name_upper, args, 1)?;
906                match &args[0] {
907                    Value::Inventory(inv) => Ok(Value::Boolean(inv.is_empty())),
908                    Value::Null => Ok(Value::Boolean(true)),
909                    _ => Err(QueryError::Type("EMPTY expects an inventory".to_string())),
910                }
911            }
912            "FILTER_CURRENCY" => {
913                Self::require_args_count(&name_upper, args, 2)?;
914                let currency = match &args[1] {
915                    Value::String(s) => s.clone(),
916                    _ => {
917                        return Err(QueryError::Type(
918                            "FILTER_CURRENCY expects (inventory, string)".to_string(),
919                        ));
920                    }
921                };
922                match &args[0] {
923                    Value::Inventory(inv) => {
924                        let filtered: Vec<Position> = inv
925                            .positions()
926                            .iter()
927                            .filter(|p| p.units.currency.as_str() == currency)
928                            .cloned()
929                            .collect();
930                        let mut new_inv = Inventory::new();
931                        for pos in filtered {
932                            new_inv.add(pos);
933                        }
934                        Ok(Value::Inventory(Box::new(new_inv)))
935                    }
936                    Value::Null => Ok(Value::Null),
937                    _ => Err(QueryError::Type(
938                        "FILTER_CURRENCY expects (inventory, string)".to_string(),
939                    )),
940                }
941            }
942            "POSSIGN" => {
943                Self::require_args_count(&name_upper, args, 2)?;
944                let account_str = match &args[1] {
945                    Value::String(s) => s.clone(),
946                    _ => {
947                        return Err(QueryError::Type(
948                            "POSSIGN expects (amount, account_string)".to_string(),
949                        ));
950                    }
951                };
952                let first_component = account_str.split(':').next().unwrap_or("");
953                let is_credit_normal =
954                    matches!(first_component, "Liabilities" | "Equity" | "Income");
955                match &args[0] {
956                    Value::Amount(a) => {
957                        let mut amt = a.clone();
958                        if is_credit_normal {
959                            amt.number = -amt.number;
960                        }
961                        Ok(Value::Amount(amt))
962                    }
963                    Value::Number(n) => {
964                        let adjusted = if is_credit_normal { -n } else { *n };
965                        Ok(Value::Number(adjusted))
966                    }
967                    Value::Null => Ok(Value::Null),
968                    _ => Err(QueryError::Type(
969                        "POSSIGN expects (amount, account_string)".to_string(),
970                    )),
971                }
972            }
973            // Aggregate functions return Null when evaluated on a single row
974            "SUM" | "COUNT" | "MIN" | "MAX" | "FIRST" | "LAST" | "AVG" => Ok(Value::Null),
975            _ => Err(QueryError::UnknownFunction(name.to_string())),
976        }
977    }
978
979    /// Helper to require a specific number of arguments (for pre-evaluated args).
980    fn require_args_count(name: &str, args: &[Value], expected: usize) -> Result<(), QueryError> {
981        if args.len() != expected {
982            return Err(QueryError::InvalidArguments(
983                name.to_string(),
984                format!("expected {} argument(s), got {}", expected, args.len()),
985            ));
986        }
987        Ok(())
988    }
989
990    /// Helper to require a specific number of arguments.
991    fn require_args(name: &str, func: &FunctionCall, expected: usize) -> Result<(), QueryError> {
992        if func.args.len() != expected {
993            return Err(QueryError::InvalidArguments(
994                name.to_string(),
995                format!("expected {expected} argument(s)"),
996            ));
997        }
998        Ok(())
999    }
1000    /// Check if an expression is a window function.
1001    pub(super) const fn is_window_expr(expr: &Expr) -> bool {
1002        matches!(expr, Expr::Window(_))
1003    }
1004
1005    /// Resolve column names from targets.
1006    fn resolve_column_names(&self, targets: &[Target]) -> Result<Vec<String>, QueryError> {
1007        let mut names = Vec::new();
1008        for (i, target) in targets.iter().enumerate() {
1009            if let Some(alias) = &target.alias {
1010                names.push(alias.clone());
1011            } else {
1012                names.push(self.expr_to_name(&target.expr, i));
1013            }
1014        }
1015        Ok(names)
1016    }
1017
1018    /// Convert an expression to a column name.
1019    fn expr_to_name(&self, expr: &Expr, index: usize) -> String {
1020        match expr {
1021            Expr::Wildcard => "*".to_string(),
1022            Expr::Column(name) => name.clone(),
1023            Expr::Function(func) => func.name.clone(),
1024            Expr::Window(wf) => wf.name.clone(),
1025            _ => format!("col{index}"),
1026        }
1027    }
1028}
1029#[cfg(test)]
1030mod tests {
1031    use super::types::{hash_row, hash_single_value};
1032    use super::*;
1033    use crate::parse;
1034    use rust_decimal_macros::dec;
1035    use rustledger_core::Posting;
1036
1037    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1038        NaiveDate::from_ymd_opt(year, month, day).unwrap()
1039    }
1040
1041    fn sample_directives() -> Vec<Directive> {
1042        vec![
1043            Directive::Transaction(
1044                Transaction::new(date(2024, 1, 15), "Coffee")
1045                    .with_flag('*')
1046                    .with_payee("Coffee Shop")
1047                    .with_posting(Posting::new(
1048                        "Expenses:Food:Coffee",
1049                        Amount::new(dec!(5.00), "USD"),
1050                    ))
1051                    .with_posting(Posting::new(
1052                        "Assets:Bank:Checking",
1053                        Amount::new(dec!(-5.00), "USD"),
1054                    )),
1055            ),
1056            Directive::Transaction(
1057                Transaction::new(date(2024, 1, 16), "Groceries")
1058                    .with_flag('*')
1059                    .with_payee("Supermarket")
1060                    .with_posting(Posting::new(
1061                        "Expenses:Food:Groceries",
1062                        Amount::new(dec!(50.00), "USD"),
1063                    ))
1064                    .with_posting(Posting::new(
1065                        "Assets:Bank:Checking",
1066                        Amount::new(dec!(-50.00), "USD"),
1067                    )),
1068            ),
1069        ]
1070    }
1071
1072    #[test]
1073    fn test_simple_select() {
1074        let directives = sample_directives();
1075        let mut executor = Executor::new(&directives);
1076
1077        let query = parse("SELECT date, account").unwrap();
1078        let result = executor.execute(&query).unwrap();
1079
1080        assert_eq!(result.columns, vec!["date", "account"]);
1081        assert_eq!(result.len(), 4); // 2 transactions × 2 postings
1082    }
1083
1084    #[test]
1085    fn test_where_clause() {
1086        let directives = sample_directives();
1087        let mut executor = Executor::new(&directives);
1088
1089        let query = parse("SELECT account WHERE account ~ \"Expenses:\"").unwrap();
1090        let result = executor.execute(&query).unwrap();
1091
1092        assert_eq!(result.len(), 2); // Only expense postings
1093    }
1094
1095    #[test]
1096    fn test_balances() {
1097        let directives = sample_directives();
1098        let mut executor = Executor::new(&directives);
1099
1100        let query = parse("BALANCES").unwrap();
1101        let result = executor.execute(&query).unwrap();
1102
1103        assert_eq!(result.columns, vec!["account", "balance"]);
1104        assert!(result.len() >= 3); // At least 3 accounts
1105    }
1106
1107    #[test]
1108    fn test_account_functions() {
1109        let directives = sample_directives();
1110        let mut executor = Executor::new(&directives);
1111
1112        // Test LEAF function
1113        let query = parse("SELECT DISTINCT LEAF(account) WHERE account ~ \"Expenses:\"").unwrap();
1114        let result = executor.execute(&query).unwrap();
1115        assert_eq!(result.len(), 2); // Coffee, Groceries
1116
1117        // Test ROOT function
1118        let query = parse("SELECT DISTINCT ROOT(account)").unwrap();
1119        let result = executor.execute(&query).unwrap();
1120        assert_eq!(result.len(), 2); // Expenses, Assets
1121
1122        // Test PARENT function
1123        let query = parse("SELECT DISTINCT PARENT(account) WHERE account ~ \"Expenses:\"").unwrap();
1124        let result = executor.execute(&query).unwrap();
1125        assert!(!result.is_empty()); // At least "Expenses:Food"
1126    }
1127
1128    #[test]
1129    fn test_min_max_aggregate() {
1130        let directives = sample_directives();
1131        let mut executor = Executor::new(&directives);
1132
1133        // Test MIN(date)
1134        let query = parse("SELECT MIN(date)").unwrap();
1135        let result = executor.execute(&query).unwrap();
1136        assert_eq!(result.len(), 1);
1137        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
1138
1139        // Test MAX(date)
1140        let query = parse("SELECT MAX(date)").unwrap();
1141        let result = executor.execute(&query).unwrap();
1142        assert_eq!(result.len(), 1);
1143        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
1144    }
1145
1146    #[test]
1147    fn test_order_by() {
1148        let directives = sample_directives();
1149        let mut executor = Executor::new(&directives);
1150
1151        let query = parse("SELECT date, account ORDER BY date DESC").unwrap();
1152        let result = executor.execute(&query).unwrap();
1153
1154        // Should have all postings, ordered by date descending
1155        assert_eq!(result.len(), 4);
1156        // First row should be from 2024-01-16 (later date)
1157        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
1158    }
1159
1160    #[test]
1161    fn test_hash_value_all_variants() {
1162        use rustledger_core::{Cost, Inventory, Position};
1163
1164        // Test that all Value variants can be hashed without panic
1165        let values = vec![
1166            Value::String("test".to_string()),
1167            Value::Number(dec!(123.45)),
1168            Value::Integer(42),
1169            Value::Date(date(2024, 1, 15)),
1170            Value::Boolean(true),
1171            Value::Boolean(false),
1172            Value::Amount(Amount::new(dec!(100), "USD")),
1173            Value::Position(Box::new(Position::simple(Amount::new(dec!(10), "AAPL")))),
1174            Value::Position(Box::new(Position::with_cost(
1175                Amount::new(dec!(10), "AAPL"),
1176                Cost::new(dec!(150), "USD"),
1177            ))),
1178            Value::Inventory(Box::new(Inventory::new())),
1179            Value::StringSet(vec!["tag1".to_string(), "tag2".to_string()]),
1180            Value::Null,
1181        ];
1182
1183        // Hash each value and verify no panic
1184        for value in &values {
1185            let hash = hash_single_value(value);
1186            assert!(hash != 0 || matches!(value, Value::Null));
1187        }
1188
1189        // Test that different values produce different hashes (usually)
1190        let hash1 = hash_single_value(&Value::String("a".to_string()));
1191        let hash2 = hash_single_value(&Value::String("b".to_string()));
1192        assert_ne!(hash1, hash2);
1193
1194        // Test that same values produce same hashes
1195        let hash3 = hash_single_value(&Value::Integer(42));
1196        let hash4 = hash_single_value(&Value::Integer(42));
1197        assert_eq!(hash3, hash4);
1198    }
1199
1200    #[test]
1201    fn test_hash_row_distinct() {
1202        // Test hash_row for DISTINCT deduplication
1203        let row1 = vec![Value::String("a".to_string()), Value::Integer(1)];
1204        let row2 = vec![Value::String("a".to_string()), Value::Integer(1)];
1205        let row3 = vec![Value::String("b".to_string()), Value::Integer(1)];
1206
1207        assert_eq!(hash_row(&row1), hash_row(&row2));
1208        assert_ne!(hash_row(&row1), hash_row(&row3));
1209    }
1210
1211    #[test]
1212    fn test_string_set_hash_order_independent() {
1213        // StringSet hash should be order-independent
1214        let set1 = Value::StringSet(vec!["a".to_string(), "b".to_string(), "c".to_string()]);
1215        let set2 = Value::StringSet(vec!["c".to_string(), "a".to_string(), "b".to_string()]);
1216        let set3 = Value::StringSet(vec!["b".to_string(), "c".to_string(), "a".to_string()]);
1217
1218        let hash1 = hash_single_value(&set1);
1219        let hash2 = hash_single_value(&set2);
1220        let hash3 = hash_single_value(&set3);
1221
1222        assert_eq!(hash1, hash2);
1223        assert_eq!(hash2, hash3);
1224    }
1225
1226    #[test]
1227    fn test_inventory_hash_includes_cost() {
1228        use rustledger_core::{Cost, Inventory, Position};
1229
1230        // Two inventories with same units but different costs should hash differently
1231        let mut inv1 = Inventory::new();
1232        inv1.add(Position::with_cost(
1233            Amount::new(dec!(10), "AAPL"),
1234            Cost::new(dec!(100), "USD"),
1235        ));
1236
1237        let mut inv2 = Inventory::new();
1238        inv2.add(Position::with_cost(
1239            Amount::new(dec!(10), "AAPL"),
1240            Cost::new(dec!(200), "USD"),
1241        ));
1242
1243        let hash1 = hash_single_value(&Value::Inventory(Box::new(inv1)));
1244        let hash2 = hash_single_value(&Value::Inventory(Box::new(inv2)));
1245
1246        assert_ne!(hash1, hash2);
1247    }
1248
1249    #[test]
1250    fn test_distinct_deduplication() {
1251        let directives = sample_directives();
1252        let mut executor = Executor::new(&directives);
1253
1254        // Without DISTINCT - should have duplicates (same flag '*' for all)
1255        let query = parse("SELECT flag").unwrap();
1256        let result = executor.execute(&query).unwrap();
1257        assert_eq!(result.len(), 4); // One per posting, all have flag '*'
1258
1259        // With DISTINCT - should deduplicate
1260        let query = parse("SELECT DISTINCT flag").unwrap();
1261        let result = executor.execute(&query).unwrap();
1262        assert_eq!(result.len(), 1); // Deduplicated to 1 (all '*')
1263    }
1264
1265    #[test]
1266    fn test_limit_clause() {
1267        let directives = sample_directives();
1268        let mut executor = Executor::new(&directives);
1269
1270        // Test LIMIT restricts result count
1271        let query = parse("SELECT date, account LIMIT 2").unwrap();
1272        let result = executor.execute(&query).unwrap();
1273        assert_eq!(result.len(), 2);
1274
1275        // Test LIMIT 0 returns empty
1276        let query = parse("SELECT date LIMIT 0").unwrap();
1277        let result = executor.execute(&query).unwrap();
1278        assert_eq!(result.len(), 0);
1279
1280        // Test LIMIT larger than result set returns all
1281        let query = parse("SELECT date LIMIT 100").unwrap();
1282        let result = executor.execute(&query).unwrap();
1283        assert_eq!(result.len(), 4);
1284    }
1285
1286    #[test]
1287    fn test_group_by_with_count() {
1288        let directives = sample_directives();
1289        let mut executor = Executor::new(&directives);
1290
1291        // Group by account root and count postings
1292        let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
1293        let result = executor.execute(&query).unwrap();
1294
1295        assert_eq!(result.columns.len(), 2);
1296        // Should have 2 groups: Assets and Expenses
1297        assert_eq!(result.len(), 2);
1298    }
1299
1300    #[test]
1301    fn test_count_aggregate() {
1302        let directives = sample_directives();
1303        let mut executor = Executor::new(&directives);
1304
1305        // Count all postings
1306        let query = parse("SELECT COUNT(account)").unwrap();
1307        let result = executor.execute(&query).unwrap();
1308
1309        assert_eq!(result.len(), 1);
1310        assert_eq!(result.rows[0][0], Value::Integer(4));
1311
1312        // Count with GROUP BY
1313        let query = parse("SELECT ROOT(account), COUNT(account) GROUP BY ROOT(account)").unwrap();
1314        let result = executor.execute(&query).unwrap();
1315        assert_eq!(result.len(), 2); // Assets, Expenses
1316    }
1317
1318    #[test]
1319    fn test_journal_query() {
1320        let directives = sample_directives();
1321        let mut executor = Executor::new(&directives);
1322
1323        // JOURNAL for Expenses account
1324        let query = parse("JOURNAL \"Expenses\"").unwrap();
1325        let result = executor.execute(&query).unwrap();
1326
1327        // Should have columns for journal output
1328        assert!(result.columns.contains(&"account".to_string()));
1329        // Should only show expense account entries
1330        assert_eq!(result.len(), 2);
1331    }
1332
1333    #[test]
1334    fn test_print_query() {
1335        let directives = sample_directives();
1336        let mut executor = Executor::new(&directives);
1337
1338        // PRINT outputs formatted directives
1339        let query = parse("PRINT").unwrap();
1340        let result = executor.execute(&query).unwrap();
1341
1342        // PRINT returns single column "directive" with formatted output
1343        assert_eq!(result.columns.len(), 1);
1344        assert_eq!(result.columns[0], "directive");
1345        // Should have one row per directive (2 transactions)
1346        assert_eq!(result.len(), 2);
1347    }
1348
1349    #[test]
1350    fn test_empty_directives() {
1351        let directives: Vec<Directive> = vec![];
1352        let mut executor = Executor::new(&directives);
1353
1354        // SELECT on empty directives
1355        let query = parse("SELECT date, account").unwrap();
1356        let result = executor.execute(&query).unwrap();
1357        assert!(result.is_empty());
1358
1359        // BALANCES on empty directives
1360        let query = parse("BALANCES").unwrap();
1361        let result = executor.execute(&query).unwrap();
1362        assert!(result.is_empty());
1363    }
1364
1365    #[test]
1366    fn test_comparison_operators() {
1367        let directives = sample_directives();
1368        let mut executor = Executor::new(&directives);
1369
1370        // Less than comparison on dates
1371        let query = parse("SELECT date WHERE date < 2024-01-16").unwrap();
1372        let result = executor.execute(&query).unwrap();
1373        assert_eq!(result.len(), 2); // First transaction postings
1374
1375        // Greater than comparison on year
1376        let query = parse("SELECT date WHERE year > 2023").unwrap();
1377        let result = executor.execute(&query).unwrap();
1378        assert_eq!(result.len(), 4); // All 2024 postings
1379
1380        // Equality comparison on day
1381        let query = parse("SELECT account WHERE day = 15").unwrap();
1382        let result = executor.execute(&query).unwrap();
1383        assert_eq!(result.len(), 2); // First transaction postings (Jan 15)
1384    }
1385
1386    #[test]
1387    fn test_logical_operators() {
1388        let directives = sample_directives();
1389        let mut executor = Executor::new(&directives);
1390
1391        // AND operator
1392        let query = parse("SELECT account WHERE account ~ \"Expenses\" AND day > 14").unwrap();
1393        let result = executor.execute(&query).unwrap();
1394        assert_eq!(result.len(), 2); // Expense postings on Jan 15 and 16
1395
1396        // OR operator
1397        let query = parse("SELECT account WHERE day = 15 OR day = 16").unwrap();
1398        let result = executor.execute(&query).unwrap();
1399        assert_eq!(result.len(), 4); // All postings (both days)
1400    }
1401
1402    #[test]
1403    fn test_arithmetic_expressions() {
1404        let directives = sample_directives();
1405        let mut executor = Executor::new(&directives);
1406
1407        // Negation on integer
1408        let query = parse("SELECT -day WHERE day = 15").unwrap();
1409        let result = executor.execute(&query).unwrap();
1410        assert_eq!(result.len(), 2);
1411        // Day 15 negated should be -15
1412        for row in &result.rows {
1413            if let Value::Integer(n) = &row[0] {
1414                assert_eq!(*n, -15);
1415            }
1416        }
1417    }
1418
1419    #[test]
1420    fn test_first_last_aggregates() {
1421        let directives = sample_directives();
1422        let mut executor = Executor::new(&directives);
1423
1424        // FIRST aggregate
1425        let query = parse("SELECT FIRST(date)").unwrap();
1426        let result = executor.execute(&query).unwrap();
1427        assert_eq!(result.len(), 1);
1428        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15)));
1429
1430        // LAST aggregate
1431        let query = parse("SELECT LAST(date)").unwrap();
1432        let result = executor.execute(&query).unwrap();
1433        assert_eq!(result.len(), 1);
1434        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 16)));
1435    }
1436
1437    #[test]
1438    fn test_wildcard_select() {
1439        let directives = sample_directives();
1440        let mut executor = Executor::new(&directives);
1441
1442        // SELECT * returns all postings with wildcard column name
1443        let query = parse("SELECT *").unwrap();
1444        let result = executor.execute(&query).unwrap();
1445
1446        // Wildcard produces column name "*"
1447        assert_eq!(result.columns, vec!["*"]);
1448        // But each row has expanded values (date, flag, payee, narration, account, position)
1449        assert_eq!(result.len(), 4);
1450        assert_eq!(result.rows[0].len(), 6); // 6 expanded values
1451    }
1452
1453    #[test]
1454    fn test_query_result_methods() {
1455        let mut result = QueryResult::new(vec!["col1".to_string(), "col2".to_string()]);
1456
1457        // Initially empty
1458        assert!(result.is_empty());
1459        assert_eq!(result.len(), 0);
1460
1461        // Add rows
1462        result.add_row(vec![Value::Integer(1), Value::String("a".to_string())]);
1463        assert!(!result.is_empty());
1464        assert_eq!(result.len(), 1);
1465
1466        result.add_row(vec![Value::Integer(2), Value::String("b".to_string())]);
1467        assert_eq!(result.len(), 2);
1468    }
1469
1470    #[test]
1471    fn test_type_cast_functions() {
1472        let directives = sample_directives();
1473        let mut executor = Executor::new(&directives);
1474
1475        // Test INT function
1476        let query = parse("SELECT int(5.7)").unwrap();
1477        let result = executor.execute(&query).unwrap();
1478        assert_eq!(result.rows[0][0], Value::Integer(5));
1479
1480        // Test DECIMAL function
1481        let query = parse("SELECT decimal(42)").unwrap();
1482        let result = executor.execute(&query).unwrap();
1483        assert_eq!(result.rows[0][0], Value::Number(dec!(42)));
1484
1485        // Test STR function
1486        let query = parse("SELECT str(123)").unwrap();
1487        let result = executor.execute(&query).unwrap();
1488        assert_eq!(result.rows[0][0], Value::String("123".to_string()));
1489
1490        // Test BOOL function
1491        let query = parse("SELECT bool(1)").unwrap();
1492        let result = executor.execute(&query).unwrap();
1493        assert_eq!(result.rows[0][0], Value::Boolean(true));
1494
1495        let query = parse("SELECT bool(0)").unwrap();
1496        let result = executor.execute(&query).unwrap();
1497        assert_eq!(result.rows[0][0], Value::Boolean(false));
1498    }
1499
1500    #[test]
1501    fn test_meta_functions() {
1502        // Create directives with metadata
1503        let mut txn_meta: Metadata = Metadata::default();
1504        txn_meta.insert(
1505            "source".to_string(),
1506            MetaValue::String("bank_import".to_string()),
1507        );
1508
1509        let mut posting_meta: Metadata = Metadata::default();
1510        posting_meta.insert(
1511            "category".to_string(),
1512            MetaValue::String("food".to_string()),
1513        );
1514
1515        let txn = Transaction {
1516            date: date(2024, 1, 15),
1517            flag: '*',
1518            payee: Some("Coffee Shop".into()),
1519            narration: "Coffee".into(),
1520            tags: vec![],
1521            links: vec![],
1522            meta: txn_meta,
1523            postings: vec![
1524                Posting {
1525                    account: "Expenses:Food".into(),
1526                    units: Some(rustledger_core::IncompleteAmount::Complete(Amount::new(
1527                        dec!(5),
1528                        "USD",
1529                    ))),
1530                    cost: None,
1531                    price: None,
1532                    flag: None,
1533                    meta: posting_meta,
1534                },
1535                Posting::new("Assets:Cash", Amount::new(dec!(-5), "USD")),
1536            ],
1537        };
1538
1539        let directives = vec![Directive::Transaction(txn)];
1540        let mut executor = Executor::new(&directives);
1541
1542        // Test META (posting metadata)
1543        let query = parse("SELECT meta('category') WHERE account ~ 'Expenses'").unwrap();
1544        let result = executor.execute(&query).unwrap();
1545        assert_eq!(result.rows[0][0], Value::String("food".to_string()));
1546
1547        // Test ENTRY_META (transaction metadata)
1548        let query = parse("SELECT entry_meta('source') WHERE account ~ 'Expenses'").unwrap();
1549        let result = executor.execute(&query).unwrap();
1550        assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
1551
1552        // Test ANY_META (falls back to txn meta when posting meta missing)
1553        let query = parse("SELECT any_meta('source') WHERE account ~ 'Expenses'").unwrap();
1554        let result = executor.execute(&query).unwrap();
1555        assert_eq!(result.rows[0][0], Value::String("bank_import".to_string()));
1556
1557        // Test ANY_META (uses posting meta when available)
1558        let query = parse("SELECT any_meta('category') WHERE account ~ 'Expenses'").unwrap();
1559        let result = executor.execute(&query).unwrap();
1560        assert_eq!(result.rows[0][0], Value::String("food".to_string()));
1561
1562        // Test missing meta returns NULL
1563        let query = parse("SELECT meta('nonexistent') WHERE account ~ 'Expenses'").unwrap();
1564        let result = executor.execute(&query).unwrap();
1565        assert_eq!(result.rows[0][0], Value::Null);
1566    }
1567
1568    #[test]
1569    fn test_convert_function() {
1570        // Create directives with price information
1571        let price = rustledger_core::Price {
1572            date: date(2024, 1, 1),
1573            currency: "EUR".into(),
1574            amount: Amount::new(dec!(1.10), "USD"),
1575            meta: Metadata::default(),
1576        };
1577
1578        let txn = Transaction::new(date(2024, 1, 15), "Test")
1579            .with_flag('*')
1580            .with_posting(Posting::new("Assets:Euro", Amount::new(dec!(100), "EUR")))
1581            .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-110), "USD")));
1582
1583        let directives = vec![Directive::Price(price), Directive::Transaction(txn)];
1584        let mut executor = Executor::new(&directives);
1585
1586        // Test CONVERT with amount
1587        let query = parse("SELECT convert(position, 'USD') WHERE account ~ 'Euro'").unwrap();
1588        let result = executor.execute(&query).unwrap();
1589        // 100 EUR × 1.10 = 110 USD
1590        match &result.rows[0][0] {
1591            Value::Amount(a) => {
1592                assert_eq!(a.number, dec!(110));
1593                assert_eq!(a.currency.as_ref(), "USD");
1594            }
1595            _ => panic!("Expected Amount, got {:?}", result.rows[0][0]),
1596        }
1597    }
1598
1599    #[test]
1600    fn test_date_functions() {
1601        let directives = sample_directives();
1602        let mut executor = Executor::new(&directives);
1603
1604        // Test DATE construction from string
1605        let query = parse("SELECT date('2024-06-15')").unwrap();
1606        let result = executor.execute(&query).unwrap();
1607        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
1608
1609        // Test DATE construction from components
1610        let query = parse("SELECT date(2024, 6, 15)").unwrap();
1611        let result = executor.execute(&query).unwrap();
1612        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
1613
1614        // Test DATE_DIFF
1615        let query = parse("SELECT date_diff(date('2024-01-20'), date('2024-01-15'))").unwrap();
1616        let result = executor.execute(&query).unwrap();
1617        assert_eq!(result.rows[0][0], Value::Integer(5));
1618
1619        // Test DATE_ADD
1620        let query = parse("SELECT date_add(date('2024-01-15'), 10)").unwrap();
1621        let result = executor.execute(&query).unwrap();
1622        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 25)));
1623
1624        // Test DATE_TRUNC year
1625        let query = parse("SELECT date_trunc('year', date('2024-06-15'))").unwrap();
1626        let result = executor.execute(&query).unwrap();
1627        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1)));
1628
1629        // Test DATE_TRUNC month
1630        let query = parse("SELECT date_trunc('month', date('2024-06-15'))").unwrap();
1631        let result = executor.execute(&query).unwrap();
1632        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1)));
1633
1634        // Test DATE_PART
1635        let query = parse("SELECT date_part('month', date('2024-06-15'))").unwrap();
1636        let result = executor.execute(&query).unwrap();
1637        assert_eq!(result.rows[0][0], Value::Integer(6));
1638
1639        // Test PARSE_DATE with custom format
1640        let query = parse("SELECT parse_date('15/06/2024', '%d/%m/%Y')").unwrap();
1641        let result = executor.execute(&query).unwrap();
1642        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 15)));
1643
1644        // Test DATE_BIN with day stride
1645        let query =
1646            parse("SELECT date_bin('7 days', date('2024-01-15'), date('2024-01-01'))").unwrap();
1647        let result = executor.execute(&query).unwrap();
1648        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); // 15 is 14 days from 1, so bucket starts at 15
1649
1650        // Test DATE_BIN with week stride
1651        let query =
1652            parse("SELECT date_bin('1 week', date('2024-01-20'), date('2024-01-01'))").unwrap();
1653        let result = executor.execute(&query).unwrap();
1654        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 15))); // Week 3 starts at day 15
1655
1656        // Test DATE_BIN with month stride
1657        let query =
1658            parse("SELECT date_bin('1 month', date('2024-06-15'), date('2024-01-01'))").unwrap();
1659        let result = executor.execute(&query).unwrap();
1660        assert_eq!(result.rows[0][0], Value::Date(date(2024, 6, 1))); // June bucket
1661
1662        // Test DATE_BIN with year stride
1663        let query =
1664            parse("SELECT date_bin('1 year', date('2024-06-15'), date('2020-01-01'))").unwrap();
1665        let result = executor.execute(&query).unwrap();
1666        assert_eq!(result.rows[0][0], Value::Date(date(2024, 1, 1))); // 2024 bucket
1667    }
1668
1669    #[test]
1670    fn test_string_functions_extended() {
1671        let directives = sample_directives();
1672        let mut executor = Executor::new(&directives);
1673
1674        // Test GREP - returns matched portion
1675        let query = parse("SELECT grep('Ex[a-z]+', 'Hello Expenses World')").unwrap();
1676        let result = executor.execute(&query).unwrap();
1677        assert_eq!(result.rows[0][0], Value::String("Expenses".to_string()));
1678
1679        // Test GREP - no match returns NULL
1680        let query = parse("SELECT grep('xyz', 'Hello World')").unwrap();
1681        let result = executor.execute(&query).unwrap();
1682        assert_eq!(result.rows[0][0], Value::Null);
1683
1684        // Test GREPN - capture group (using [0-9] since \d is not escaped in BQL strings)
1685        let query = parse("SELECT grepn('([0-9]+)-([0-9]+)', '2024-01', 1)").unwrap();
1686        let result = executor.execute(&query).unwrap();
1687        assert_eq!(result.rows[0][0], Value::String("2024".to_string()));
1688
1689        // Test SUBST - substitution
1690        let query = parse("SELECT subst('-', '/', '2024-01-15')").unwrap();
1691        let result = executor.execute(&query).unwrap();
1692        assert_eq!(result.rows[0][0], Value::String("2024/01/15".to_string()));
1693
1694        // Test SPLITCOMP
1695        let query = parse("SELECT splitcomp('a:b:c', ':', 1)").unwrap();
1696        let result = executor.execute(&query).unwrap();
1697        assert_eq!(result.rows[0][0], Value::String("b".to_string()));
1698
1699        // Test JOINSTR
1700        let query = parse("SELECT joinstr('hello', 'world')").unwrap();
1701        let result = executor.execute(&query).unwrap();
1702        assert_eq!(result.rows[0][0], Value::String("hello, world".to_string()));
1703
1704        // Test MAXWIDTH - no truncation needed
1705        let query = parse("SELECT maxwidth('hello', 10)").unwrap();
1706        let result = executor.execute(&query).unwrap();
1707        assert_eq!(result.rows[0][0], Value::String("hello".to_string()));
1708
1709        // Test MAXWIDTH - truncation with ellipsis
1710        let query = parse("SELECT maxwidth('hello world', 8)").unwrap();
1711        let result = executor.execute(&query).unwrap();
1712        assert_eq!(result.rows[0][0], Value::String("hello...".to_string()));
1713    }
1714
1715    #[test]
1716    fn test_inventory_functions() {
1717        let directives = sample_directives();
1718        let mut executor = Executor::new(&directives);
1719
1720        // Test EMPTY on sum of position (sum across all postings may cancel out)
1721        // Use a filter to get non-canceling positions
1722        let query = parse("SELECT empty(sum(position)) WHERE account ~ 'Assets'").unwrap();
1723        let result = executor.execute(&query).unwrap();
1724        // Should be a boolean (the actual value depends on sample data)
1725        assert!(matches!(result.rows[0][0], Value::Boolean(_)));
1726
1727        // Test EMPTY with null returns true
1728        // (null handling is already tested in the function)
1729
1730        // Test POSSIGN with debit account (Assets) - no sign change
1731        let query = parse("SELECT possign(100, 'Assets:Bank')").unwrap();
1732        let result = executor.execute(&query).unwrap();
1733        assert_eq!(
1734            result.rows[0][0],
1735            Value::Number(rust_decimal::Decimal::from(100))
1736        );
1737
1738        // Test POSSIGN with credit account (Income) - sign is negated
1739        let query = parse("SELECT possign(100, 'Income:Salary')").unwrap();
1740        let result = executor.execute(&query).unwrap();
1741        assert_eq!(
1742            result.rows[0][0],
1743            Value::Number(rust_decimal::Decimal::from(-100))
1744        );
1745
1746        // Test POSSIGN with Expenses (debit normal) - no sign change
1747        let query = parse("SELECT possign(50, 'Expenses:Food')").unwrap();
1748        let result = executor.execute(&query).unwrap();
1749        assert_eq!(
1750            result.rows[0][0],
1751            Value::Number(rust_decimal::Decimal::from(50))
1752        );
1753
1754        // Test POSSIGN with Liabilities (credit normal) - sign is negated
1755        let query = parse("SELECT possign(200, 'Liabilities:CreditCard')").unwrap();
1756        let result = executor.execute(&query).unwrap();
1757        assert_eq!(
1758            result.rows[0][0],
1759            Value::Number(rust_decimal::Decimal::from(-200))
1760        );
1761
1762        // Test POSSIGN with Equity (credit normal) - sign is negated
1763        let query = parse("SELECT possign(300, 'Equity:OpeningBalances')").unwrap();
1764        let result = executor.execute(&query).unwrap();
1765        assert_eq!(
1766            result.rows[0][0],
1767            Value::Number(rust_decimal::Decimal::from(-300))
1768        );
1769    }
1770
1771    #[test]
1772    fn test_account_meta_functions() {
1773        use rustledger_core::{Close, Metadata, Open};
1774
1775        // Create directives with Open/Close
1776        let mut open_meta = Metadata::default();
1777        open_meta.insert(
1778            "category".to_string(),
1779            MetaValue::String("checking".to_string()),
1780        );
1781
1782        let directives = vec![
1783            Directive::Open(Open {
1784                date: date(2020, 1, 1),
1785                account: "Assets:Bank:Checking".into(),
1786                currencies: vec![],
1787                booking: None,
1788                meta: open_meta,
1789            }),
1790            Directive::Open(Open::new(date(2020, 2, 15), "Expenses:Food")),
1791            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank:Checking")),
1792            // A transaction to have postings for the query context
1793            Directive::Transaction(
1794                Transaction::new(date(2024, 1, 15), "Coffee")
1795                    .with_posting(Posting::new(
1796                        "Expenses:Food",
1797                        Amount::new(dec!(5.00), "USD"),
1798                    ))
1799                    .with_posting(Posting::new(
1800                        "Assets:Bank:Checking",
1801                        Amount::new(dec!(-5.00), "USD"),
1802                    )),
1803            ),
1804        ];
1805
1806        let mut executor = Executor::new(&directives);
1807
1808        // Test OPEN_DATE - account with open directive
1809        let query = parse("SELECT open_date('Assets:Bank:Checking')").unwrap();
1810        let result = executor.execute(&query).unwrap();
1811        assert_eq!(result.rows[0][0], Value::Date(date(2020, 1, 1)));
1812
1813        // Test CLOSE_DATE - account with close directive
1814        let query = parse("SELECT close_date('Assets:Bank:Checking')").unwrap();
1815        let result = executor.execute(&query).unwrap();
1816        assert_eq!(result.rows[0][0], Value::Date(date(2024, 12, 31)));
1817
1818        // Test OPEN_DATE - account without close directive
1819        let query = parse("SELECT close_date('Expenses:Food')").unwrap();
1820        let result = executor.execute(&query).unwrap();
1821        assert_eq!(result.rows[0][0], Value::Null);
1822
1823        // Test OPEN_META - get metadata from open directive
1824        let query = parse("SELECT open_meta('Assets:Bank:Checking', 'category')").unwrap();
1825        let result = executor.execute(&query).unwrap();
1826        assert_eq!(result.rows[0][0], Value::String("checking".to_string()));
1827
1828        // Test OPEN_META - non-existent key
1829        let query = parse("SELECT open_meta('Assets:Bank:Checking', 'nonexistent')").unwrap();
1830        let result = executor.execute(&query).unwrap();
1831        assert_eq!(result.rows[0][0], Value::Null);
1832
1833        // Test with non-existent account
1834        let query = parse("SELECT open_date('NonExistent:Account')").unwrap();
1835        let result = executor.execute(&query).unwrap();
1836        assert_eq!(result.rows[0][0], Value::Null);
1837    }
1838
1839    #[test]
1840    fn test_source_location_columns_return_null_without_sources() {
1841        // When using the regular constructor (without source location support),
1842        // the filename, lineno, and location columns should return Null
1843        let directives = vec![Directive::Transaction(Transaction {
1844            date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1845            flag: '*',
1846            payee: Some("Test".into()),
1847            narration: "Test transaction".into(),
1848            tags: vec![],
1849            links: vec![],
1850            meta: Metadata::default(),
1851            postings: vec![
1852                Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
1853                Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
1854            ],
1855        })];
1856
1857        let mut executor = Executor::new(&directives);
1858
1859        // Test filename column returns Null
1860        let query = parse("SELECT filename").unwrap();
1861        let result = executor.execute(&query).unwrap();
1862        assert_eq!(result.rows[0][0], Value::Null);
1863
1864        // Test lineno column returns Null
1865        let query = parse("SELECT lineno").unwrap();
1866        let result = executor.execute(&query).unwrap();
1867        assert_eq!(result.rows[0][0], Value::Null);
1868
1869        // Test location column returns Null
1870        let query = parse("SELECT location").unwrap();
1871        let result = executor.execute(&query).unwrap();
1872        assert_eq!(result.rows[0][0], Value::Null);
1873    }
1874
1875    #[test]
1876    fn test_source_location_columns_with_sources() {
1877        use rustledger_loader::SourceMap;
1878        use rustledger_parser::Spanned;
1879        use std::sync::Arc;
1880
1881        // Create a source map with a test file
1882        let mut source_map = SourceMap::new();
1883        let source: Arc<str> =
1884            "2024-01-15 * \"Test\"\n  Assets:Bank  100 USD\n  Expenses:Food".into();
1885        let file_id = source_map.add_file("test.beancount".into(), source);
1886
1887        // Create a spanned directive
1888        let txn = Transaction {
1889            date: NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
1890            flag: '*',
1891            payee: Some("Test".into()),
1892            narration: "Test transaction".into(),
1893            tags: vec![],
1894            links: vec![],
1895            meta: Metadata::default(),
1896            postings: vec![
1897                Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")),
1898                Posting::new("Expenses:Food", Amount::new(dec!(-100), "USD")),
1899            ],
1900        };
1901
1902        let spanned_directives = vec![Spanned {
1903            value: Directive::Transaction(txn),
1904            span: rustledger_parser::Span { start: 0, end: 50 },
1905            file_id: file_id as u16,
1906        }];
1907
1908        let mut executor = Executor::new_with_sources(&spanned_directives, &source_map);
1909
1910        // Test filename column returns the file path
1911        let query = parse("SELECT filename").unwrap();
1912        let result = executor.execute(&query).unwrap();
1913        assert_eq!(
1914            result.rows[0][0],
1915            Value::String("test.beancount".to_string())
1916        );
1917
1918        // Test lineno column returns line number
1919        let query = parse("SELECT lineno").unwrap();
1920        let result = executor.execute(&query).unwrap();
1921        assert_eq!(result.rows[0][0], Value::Integer(1));
1922
1923        // Test location column returns formatted location
1924        let query = parse("SELECT location").unwrap();
1925        let result = executor.execute(&query).unwrap();
1926        assert_eq!(
1927            result.rows[0][0],
1928            Value::String("test.beancount:1".to_string())
1929        );
1930    }
1931
1932    #[test]
1933    fn test_interval_function() {
1934        let directives = sample_directives();
1935        let mut executor = Executor::new(&directives);
1936
1937        // Test interval with single argument (unit only, count=1)
1938        let query = parse("SELECT interval('month')").unwrap();
1939        let result = executor.execute(&query).unwrap();
1940        assert_eq!(
1941            result.rows[0][0],
1942            Value::Interval(Interval::new(1, IntervalUnit::Month))
1943        );
1944
1945        // Test interval with two arguments (count, unit)
1946        let query = parse("SELECT interval(3, 'day')").unwrap();
1947        let result = executor.execute(&query).unwrap();
1948        assert_eq!(
1949            result.rows[0][0],
1950            Value::Interval(Interval::new(3, IntervalUnit::Day))
1951        );
1952
1953        // Test interval with negative count
1954        let query = parse("SELECT interval(-2, 'week')").unwrap();
1955        let result = executor.execute(&query).unwrap();
1956        assert_eq!(
1957            result.rows[0][0],
1958            Value::Interval(Interval::new(-2, IntervalUnit::Week))
1959        );
1960    }
1961
1962    #[test]
1963    fn test_date_add_with_interval() {
1964        let directives = sample_directives();
1965        let mut executor = Executor::new(&directives);
1966
1967        // Test date_add with interval
1968        let query = parse("SELECT date_add(date(2024, 1, 15), interval(1, 'month'))").unwrap();
1969        let result = executor.execute(&query).unwrap();
1970        assert_eq!(
1971            result.rows[0][0],
1972            Value::Date(NaiveDate::from_ymd_opt(2024, 2, 15).unwrap())
1973        );
1974
1975        // Test date + interval using binary operator
1976        let query = parse("SELECT date(2024, 1, 15) + interval(1, 'year')").unwrap();
1977        let result = executor.execute(&query).unwrap();
1978        assert_eq!(
1979            result.rows[0][0],
1980            Value::Date(NaiveDate::from_ymd_opt(2025, 1, 15).unwrap())
1981        );
1982
1983        // Test date - interval
1984        let query = parse("SELECT date(2024, 3, 15) - interval(2, 'month')").unwrap();
1985        let result = executor.execute(&query).unwrap();
1986        assert_eq!(
1987            result.rows[0][0],
1988            Value::Date(NaiveDate::from_ymd_opt(2024, 1, 15).unwrap())
1989        );
1990    }
1991}