Skip to main content

rustledger_query/executor/
types.rs

1//! Types used by the BQL query executor.
2
3use std::collections::BTreeMap;
4use std::hash::{Hash, Hasher};
5
6use chrono::Datelike;
7use rust_decimal::Decimal;
8use rustledger_core::{Amount, Inventory, Metadata, NaiveDate, Position, Transaction};
9
10/// Source location information for a directive.
11#[derive(Debug, Clone)]
12pub struct SourceLocation {
13    /// File path.
14    pub filename: String,
15    /// Line number (1-based).
16    pub lineno: usize,
17}
18
19/// An interval unit for date arithmetic.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21pub enum IntervalUnit {
22    /// Days.
23    Day,
24    /// Weeks.
25    Week,
26    /// Months.
27    Month,
28    /// Quarters.
29    Quarter,
30    /// Years.
31    Year,
32}
33
34impl IntervalUnit {
35    /// Parse an interval unit from a string.
36    pub fn parse_unit(s: &str) -> Option<Self> {
37        match s.to_uppercase().as_str() {
38            "DAY" | "DAYS" | "D" => Some(Self::Day),
39            "WEEK" | "WEEKS" | "W" => Some(Self::Week),
40            "MONTH" | "MONTHS" | "M" => Some(Self::Month),
41            "QUARTER" | "QUARTERS" | "Q" => Some(Self::Quarter),
42            "YEAR" | "YEARS" | "Y" => Some(Self::Year),
43            _ => None,
44        }
45    }
46}
47
48/// An interval value for date arithmetic.
49#[derive(Debug, Clone, PartialEq, Eq, Hash)]
50pub struct Interval {
51    /// The count (can be negative).
52    pub count: i64,
53    /// The unit.
54    pub unit: IntervalUnit,
55}
56
57impl Interval {
58    /// Create a new interval.
59    pub const fn new(count: i64, unit: IntervalUnit) -> Self {
60        Self { count, unit }
61    }
62
63    /// Convert interval to an approximate number of days for comparison.
64    /// Uses: Day=1, Week=7, Month=30, Quarter=91, Year=365.
65    pub(crate) const fn to_approx_days(&self) -> i64 {
66        let days_per_unit = match self.unit {
67            IntervalUnit::Day => 1,
68            IntervalUnit::Week => 7,
69            IntervalUnit::Month => 30,
70            IntervalUnit::Quarter => 91,
71            IntervalUnit::Year => 365,
72        };
73        self.count.saturating_mul(days_per_unit)
74    }
75
76    /// Add this interval to a date.
77    #[allow(clippy::missing_const_for_fn)] // chrono methods aren't const
78    pub fn add_to_date(&self, date: NaiveDate) -> Option<NaiveDate> {
79        use chrono::Months;
80
81        match self.unit {
82            IntervalUnit::Day => date.checked_add_signed(chrono::Duration::days(self.count)),
83            IntervalUnit::Week => date.checked_add_signed(chrono::Duration::weeks(self.count)),
84            IntervalUnit::Month => {
85                if self.count >= 0 {
86                    date.checked_add_months(Months::new(self.count as u32))
87                } else {
88                    date.checked_sub_months(Months::new((-self.count) as u32))
89                }
90            }
91            IntervalUnit::Quarter => {
92                let months = self.count * 3;
93                if months >= 0 {
94                    date.checked_add_months(Months::new(months as u32))
95                } else {
96                    date.checked_sub_months(Months::new((-months) as u32))
97                }
98            }
99            IntervalUnit::Year => {
100                let months = self.count * 12;
101                if months >= 0 {
102                    date.checked_add_months(Months::new(months as u32))
103                } else {
104                    date.checked_sub_months(Months::new((-months) as u32))
105                }
106            }
107        }
108    }
109}
110
111/// A value that can result from evaluating a BQL expression.
112///
113/// Heavy variants (Inventory, Position, Metadata, Object) are boxed to reduce
114/// the size of the enum from 120 bytes to 32 bytes, improving cache efficiency
115/// when processing large result sets.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum Value {
118    /// String value.
119    String(String),
120    /// Numeric value.
121    Number(Decimal),
122    /// Integer value.
123    Integer(i64),
124    /// Date value.
125    Date(NaiveDate),
126    /// Boolean value.
127    Boolean(bool),
128    /// Amount (number + currency).
129    Amount(Amount),
130    /// Position (amount + optional cost). Boxed to reduce enum size.
131    Position(Box<Position>),
132    /// Inventory (aggregated positions). Boxed to reduce enum size.
133    Inventory(Box<Inventory>),
134    /// Set of strings (tags, links).
135    StringSet(Vec<String>),
136    /// Metadata dictionary. Boxed to reduce enum size.
137    Metadata(Box<Metadata>),
138    /// Interval for date arithmetic.
139    Interval(Interval),
140    /// Structured object (for entry, meta columns). Boxed to reduce enum size.
141    Object(Box<BTreeMap<String, Self>>),
142    /// NULL value.
143    Null,
144}
145
146impl Value {
147    /// Compute a hash for this value.
148    ///
149    /// Note: This is not the standard Hash trait because some contained types
150    /// (Decimal, Inventory) don't implement Hash. We use byte representations
151    /// for those types.
152    pub(crate) fn hash_value<H: Hasher>(&self, state: &mut H) {
153        std::mem::discriminant(self).hash(state);
154        match self {
155            Self::String(s) => s.hash(state),
156            Self::Number(d) => d.serialize().hash(state),
157            Self::Integer(i) => i.hash(state),
158            Self::Date(d) => {
159                d.year().hash(state);
160                d.month().hash(state);
161                d.day().hash(state);
162            }
163            Self::Boolean(b) => b.hash(state),
164            Self::Amount(a) => {
165                a.number.serialize().hash(state);
166                a.currency.as_str().hash(state);
167            }
168            Self::Position(p) => {
169                // Dereference boxed position
170                p.units.number.serialize().hash(state);
171                p.units.currency.as_str().hash(state);
172                if let Some(cost) = &p.cost {
173                    cost.number.serialize().hash(state);
174                    cost.currency.as_str().hash(state);
175                }
176            }
177            Self::Inventory(inv) => {
178                // Dereference boxed inventory
179                for pos in inv.positions() {
180                    pos.units.number.serialize().hash(state);
181                    pos.units.currency.as_str().hash(state);
182                    if let Some(cost) = &pos.cost {
183                        cost.number.serialize().hash(state);
184                        cost.currency.as_str().hash(state);
185                    }
186                }
187            }
188            Self::StringSet(ss) => {
189                // Hash StringSet in a canonical, order-independent way by sorting first.
190                let mut sorted = ss.clone();
191                sorted.sort();
192                for s in &sorted {
193                    s.hash(state);
194                }
195            }
196            Self::Metadata(meta) => {
197                // Hash metadata in canonical order by sorting keys (boxed)
198                let mut keys: Vec<_> = meta.keys().collect();
199                keys.sort();
200                for key in keys {
201                    key.hash(state);
202                    // Hash the debug representation of the value
203                    format!("{:?}", meta.get(key)).hash(state);
204                }
205            }
206            Self::Interval(interval) => {
207                interval.count.hash(state);
208                interval.unit.hash(state);
209            }
210            Self::Object(obj) => {
211                // BTreeMap is already sorted by key, so iteration order is deterministic (boxed)
212                for (k, v) in obj.as_ref() {
213                    k.hash(state);
214                    v.hash_value(state);
215                }
216            }
217            Self::Null => {}
218        }
219    }
220}
221
222/// A row of query results.
223pub type Row = Vec<Value>;
224
225/// Compute a hash for a row (for DISTINCT deduplication).
226pub fn hash_row(row: &Row) -> u64 {
227    use std::collections::hash_map::DefaultHasher;
228    let mut hasher = DefaultHasher::new();
229    for value in row {
230        value.hash_value(&mut hasher);
231    }
232    hasher.finish()
233}
234
235/// Compute a hash for a single value (for PIVOT lookups).
236pub fn hash_single_value(value: &Value) -> u64 {
237    use std::collections::hash_map::DefaultHasher;
238    let mut hasher = DefaultHasher::new();
239    value.hash_value(&mut hasher);
240    hasher.finish()
241}
242
243/// Query result containing column names and rows.
244#[derive(Debug, Clone)]
245pub struct QueryResult {
246    /// Column names.
247    pub columns: Vec<String>,
248    /// Result rows.
249    pub rows: Vec<Row>,
250}
251
252impl QueryResult {
253    /// Create a new empty result.
254    pub const fn new(columns: Vec<String>) -> Self {
255        Self {
256            columns,
257            rows: Vec::new(),
258        }
259    }
260
261    /// Add a row to the result.
262    pub fn add_row(&mut self, row: Row) {
263        self.rows.push(row);
264    }
265
266    /// Number of rows.
267    pub fn len(&self) -> usize {
268        self.rows.len()
269    }
270
271    /// Whether the result is empty.
272    pub fn is_empty(&self) -> bool {
273        self.rows.is_empty()
274    }
275}
276
277/// Context for a single posting being evaluated.
278#[derive(Debug)]
279pub struct PostingContext<'a> {
280    /// The transaction this posting belongs to.
281    pub transaction: &'a Transaction,
282    /// The posting index within the transaction.
283    pub posting_index: usize,
284    /// Running balance after this posting (optional).
285    pub balance: Option<Inventory>,
286    /// The directive index (for source location lookup).
287    pub directive_index: Option<usize>,
288}
289
290/// Context for window function evaluation.
291#[derive(Debug, Clone)]
292pub struct WindowContext {
293    /// Row number within the partition (1-based).
294    pub row_number: usize,
295    /// Rank within the partition (1-based, ties get same rank).
296    pub rank: usize,
297    /// Dense rank within the partition (1-based, no gaps after ties).
298    pub dense_rank: usize,
299}
300
301/// Account information cached from Open/Close directives.
302#[derive(Debug, Clone)]
303pub struct AccountInfo {
304    /// Date the account was opened.
305    pub open_date: Option<NaiveDate>,
306    /// Date the account was closed (if any).
307    pub close_date: Option<NaiveDate>,
308    /// Metadata from the Open directive.
309    pub open_meta: Metadata,
310}
311
312/// An in-memory table created by CREATE TABLE.
313#[derive(Debug, Clone)]
314pub struct Table {
315    /// Column names.
316    pub columns: Vec<String>,
317    /// Rows of data.
318    pub rows: Vec<Vec<Value>>,
319}
320
321impl Table {
322    /// Create a new empty table with the given column names.
323    #[allow(clippy::missing_const_for_fn)] // Vec::new() isn't const with owned columns
324    pub fn new(columns: Vec<String>) -> Self {
325        Self {
326            columns,
327            rows: Vec::new(),
328        }
329    }
330
331    /// Add a row to the table.
332    pub fn add_row(&mut self, row: Vec<Value>) {
333        self.rows.push(row);
334    }
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    /// Verify Value enum size is reasonable after boxing heavy variants.
342    /// Previously 120 bytes, now 40 bytes (67% reduction).
343    #[test]
344    fn test_value_size() {
345        use std::mem::size_of;
346        // Value should be ~40 bytes with boxed variants (vs 120 unboxed)
347        assert!(
348            size_of::<Value>() <= 48,
349            "Value enum too large: {} bytes",
350            size_of::<Value>()
351        );
352    }
353}