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