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