diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 4175569..c14bd0a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -88,7 +88,7 @@ jobs: exclude_paths: target/ fail_action_if_review_failed: true - - name: AI Code Review Fallback (gemini-1.5-flash) + - name: AI Code Review Fallback (gemini-2.5-flash-lite) if: steps.review_primary.outcome == 'failure' uses: Wandalen/wretry.action@v3.5.0 with: @@ -100,7 +100,7 @@ jobs: pr_number: ${{ github.event.number }} ai_provider: google google_api_key: ${{ secrets.GEMINI_API_KEY }} - google_model: gemini-1.5-flash + google_model: gemini-2.5-flash-lite include_extensions: .rs,.toml exclude_paths: target/ fail_action_if_review_failed: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 36a17f3..a1e28be 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .vscode/ .claude/ .gemini/ -temp/ \ No newline at end of file +temp/ +AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ac90940 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,199 @@ +# AGENT GUIDELINES + +## Working with an AI Agent on MollyCache + +This document outlines how an AI assistant and the development team collaborate on MollyCache to bring this high-performance, in-memory SQL database to market. + +## Project Context + +**What is MollyCache?** +MollyCache is a from-scratch, in-memory SQL database built in Rust with SQLite compatibility. Unlike traditional query caches that store complete query results, MollyCache implements intelligent row-based caching where individual rows are cached and evicted, enabling superior memory efficiency when queries share overlapping data. + +**Current Status:** Early development (v0.1.0) + +**Target Market:** Applications requiring high-performance in-memory data access with SQL compatibility, particularly those with overlapping query patterns where traditional query caching falls short. + +## Core Development Philosophy + +When working on MollyCache, these principles are non-negotiable: + +1. **Performance First**: Code must be optimized for in-memory performance comparable to Memcached/Redis, not disk-based databases +2. **In-Memory First**: All data lives in RAM; disk I/O only on explicit user request +3. **SQLite Compatibility**: Complete parity with SQLite queries and results +4. **High Test Coverage**: Maintain >75% test coverage at all times +5. **Zero Dependencies**: Use only Rust standard library - no external crates +6. **Clean, Minimal Code**: Straightforward implementations over clever abstractions + +## Development Workflow + +### 1. Understanding Tasks +- Check GitHub Issues for context and related discussions +- Review existing code in relevant modules before making changes +- Ask clarifying questions if requirements are ambiguous +- Consider how changes fit into the broader architecture + +### 2. Code Standards + +**Rust Style:** +- Use Rust Edition 2024 +- Follow standard Rust naming conventions (snake_case for functions/variables, PascalCase for types) +- Prefer explicit error handling with `Result` +- Keep functions focused and modular +- Use descriptive variable and function names +- **No code comments**: Write self-documenting code with clear variable/function names instead of explanatory comments + +**Architecture Patterns:** +- **Stack-based state management** for transactions (rows, columns, table names all maintain stacks) +- **Separation of concerns**: Tokenizer → Parser → AST → Database Executor +- **Helper functions** for shared logic (e.g., row filtering, order-by evaluation) +- **Type casting** for SQLite compatibility across Value types + +**Error Messages:** +- Clear, actionable error messages +- Include context (table names, column names, etc.) +- Parser errors should include line/column information + +### 3. Testing Requirements + +**Every code change must include tests:** +- Unit tests embedded in source files with `#[cfg(test)]` +- Integration tests in `/tests` directory for user-facing features +- Use test utilities in `test_utils.rs` for assertions +- Verify edge cases and error conditions +- Ensure tests pass before committing: `cargo test` + +**Test Coverage:** +- Run `cargo tarpaulin` to verify coverage remains >75% +- Don't merge code that drops coverage below target + +### 5. Commit Messages +- Use clear, descriptive commit messages +- Focus on the "why" not just the "what" +- Follow existing commit style in git log +- One logical change per commit when possible + +### 6. Pull Requests +- Reference related GitHub Issues +- Explain architectural decisions in PR description +- Include test plan or verification steps +- Ensure CI passes before requesting review + +## How an AI Agent Should Approach Tasks + +### Research First +- Read relevant source files before making changes +- Understand existing patterns and architecture +- Check test files to understand expected behavior +- Review recent commits for context + +### Propose Solutions +- Present multiple approaches when applicable +- Explain trade-offs (performance, maintainability, complexity) +- Consider impact on existing functionality +- Think about future extensibility + +### Implement Incrementally +- Break large features into smaller, testable pieces +- Implement core functionality first, then edge cases +- Keep commits focused and atomic +- Test continuously during development + +### Communication Style +- Be concise and technical +- Focus on facts and implementation details +- Avoid unnecessary praise or filler +- Ask specific questions when blocked +- Provide code examples when explaining concepts + +## Key Architectural Components + +### Database Layer (`src/db/`) +- **Database** (`database.rs`): Central executor, routes SQL statements to appropriate handlers +- **Table** (`table/core/table.rs`): Manages rows and columns with stack-based history +- **Row** (`table/core/row.rs`): Vector of Values with transaction stack support +- **Column** (`table/core/column.rs`): Column definitions with types and constraints +- **Value** (`table/core/value.rs`): Typed data (Integer, Real, Text, Blob, Null) with casting +- **Operations** (`table/operations/`): CRUD implementations (CREATE, INSERT, SELECT, UPDATE, DELETE, DROP, ALTER) +- **Transactions** (`transactions/`): Transaction log, COMMIT, ROLLBACK, SAVEPOINT + +### Interpreter Layer (`src/interpreter/`) +- **Tokenizer** (`tokenizer/`): Lexical analysis of SQL strings +- **AST** (`ast/`): Abstract Syntax Tree definitions and parsing +- **Parser** (`ast/parser.rs`): Converts tokens to structured SQL statements + +### CLI Layer (`src/cli/`) +- **CLI** (`cli/mod.rs`): Interactive REPL interface + +## Common Development Scenarios + +### Adding a New SQL Feature +1. Add tokens to `tokenizer/token.rs` if needed +2. Update parser in `interpreter/ast/` to recognize syntax +3. Add statement type to `SqlStatement` enum +4. Implement execution logic in appropriate `operations/` module +5. Add integration tests in `/tests` +6. Update README if user-facing + +### Fixing a Bug +1. Write a failing test that reproduces the bug +2. Identify root cause through code review and debugging +3. Implement minimal fix +4. Verify test passes and no regressions +5. Consider edge cases and add additional tests + +### Optimizing Performance +1. Benchmark current implementation +2. Profile to identify bottleneck +3. Implement optimization +4. Verify correctness with existing tests +5. Benchmark improvement +6. Document trade-offs in commit message + +## Working with Zero Dependencies + +Since MollyCache uses only Rust standard library: +- No external crates for parsing, data structures, or algorithms +- Implement solutions from first principles +- Optimize using std library features (HashMap, Vec, etc.) +- Consider performance implications of standard collections + +## Future Roadmap Awareness + +When making changes, consider these planned features: +- **Row-based caching with eviction policies**: Data structures should support efficient eviction +- **SQLite snapshots**: Architecture should support loading from disk +- **Concurrent reads**: Code should be thread-safe where applicable +- **Multi-table JOINs**: Query execution should be extensible for joins +- **Query optimization**: Consider where indexes or query planning might fit + +## Getting Unstuck + +If you encounter challenges: +1. Review similar implementations in the codebase +2. Check SQLite documentation for expected behavior +3. Look at test files for usage examples +4. Ask the development team specific technical questions +5. Propose multiple solution approaches with trade-offs + +## Goals for Market Launch + +To get MollyCache to market, focus areas include: + +1. **Core SQL Completeness**: Full SQLite compatibility for common queries +2. **Row-Based Caching**: Implement intelligent caching with eviction policies +3. **Performance Benchmarks**: Demonstrate performance parity with Redis/Memcached +4. **Production Readiness**: Error handling, edge cases, memory safety +5. **Documentation**: Clear usage examples and API documentation +6. **Real-World Testing**: Validate with actual use cases and workloads + +## Questions? + +When in doubt: +- Check existing code patterns in the repository +- Review test files for examples +- Ask the development team +- Refer to SQLite documentation for compatibility questions + +--- + +**Remember**: MollyCache aims to revolutionize query caching through intelligent row-based storage. Every line of code should serve that mission while maintaining the principles of performance, simplicity, and SQLite compatibility. diff --git a/README.md b/README.md index 884c36b..a5d6424 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # MollyCache -[![Wakatime](https://wakatime.com/badge/user/9641004b-568b-4c27-99c5-a34ace36b886/project/2668a03d-d729-4e59-8fc8-bafe3d194ee1.svg)](https://wakatime.com/badge/user/9641004b-568b-4c27-99c5-a34ace36b886/project/2668a03d-d729-4e59-8fc8-bafe3d194ee1) +[![Fletcher](https://wakatime.com/badge/user/9641004b-568b-4c27-99c5-a34ace36b886/project/2668a03d-d729-4e59-8fc8-bafe3d194ee1.svg)](https://wakatime.com/badge/user/9641004b-568b-4c27-99c5-a34ace36b886/project/2668a03d-d729-4e59-8fc8-bafe3d194ee1) +[![Fletcher Pt. 2](https://wakatime.com/badge/user/9641004b-568b-4c27-99c5-a34ace36b886/project/b3cd9856-dee7-41a0-a31c-b3b8b68a0e80.svg)](https://wakatime.com/badge/user/9641004b-568b-4c27-99c5-a34ace36b886/project/b3cd9856-dee7-41a0-a31c-b3b8b68a0e80) ![GitHub last commit](https://img.shields.io/github/last-commit/MollyCache/mollycache) ![GitHub stars](https://img.shields.io/github/stars/MollyCache/mollycache?style=social) diff --git a/src/db/table/operations/helpers/common.rs b/src/db/table/operations/helpers/common.rs index 8911920..20d6133 100644 --- a/src/db/table/operations/helpers/common.rs +++ b/src/db/table/operations/helpers/common.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::collections::HashSet; use crate::db::table::core::{row::Row, table::Table, value::DataType, value::Value}; -use crate::db::table::operations::helpers::datetime_functions::date::get_date; +use crate::db::table::operations::helpers::datetime_functions::build_julian_day; use crate::db::table::operations::helpers::order_by_clause::apply_order_by_from_precomputed; use crate::interpreter::ast::{ FunctionName, LimitClause, LogicalOperator, MathOperator, Operator, OrderByClause, @@ -120,7 +120,11 @@ pub fn get_column( SelectableStackElement::Function(func) => { let args = &func.arguments; let res = match func.name { - FunctionName::Date => get_date(args)?, + FunctionName::DateTime => Value::Text(build_julian_day(args)?.as_datetime()), + FunctionName::Date => Value::Text(build_julian_day(args)?.as_date()), + FunctionName::Time => Value::Text(build_julian_day(args)?.as_time()), + FunctionName::JulianDay => Value::Real(build_julian_day(args)?.value()), + FunctionName::UnixEpoch => Value::Real(build_julian_day(args)?.as_unix_epoch()), _ => return Err(format!("Unsupported function: {:?}", func.name)), }; row_values.push(res); diff --git a/src/db/table/operations/helpers/datetime_functions/date.rs b/src/db/table/operations/helpers/datetime_functions/date.rs deleted file mode 100644 index 5ee3c50..0000000 --- a/src/db/table/operations/helpers/datetime_functions/date.rs +++ /dev/null @@ -1,6 +0,0 @@ -use crate::db::table::core::value::Value; -use crate::interpreter::ast::SelectableColumn; - -pub fn get_date(_args: &Vec) -> Result { - todo!() -} diff --git a/src/db/table/operations/helpers/datetime_functions/julian_day.rs b/src/db/table/operations/helpers/datetime_functions/julian_day.rs new file mode 100644 index 0000000..d2d0dd8 --- /dev/null +++ b/src/db/table/operations/helpers/datetime_functions/julian_day.rs @@ -0,0 +1,141 @@ +const JULIAN_DAY_NOON_OFFSET: f64 = 0.5; +const UNIX_EPOCH_JULIAN_DAY: f64 = 2440587.5; +const JULIAN_DAY_EPOCH_OFFSET: i64 = 32045; +const YEAR_OFFSET: i64 = 4800; +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct JulianDay { + jdn: f64, + is_subsecond: bool, +} + +impl JulianDay { + pub fn as_date(&self) -> String { + let jdn_value = self.value(); + let jd_int: i64 = ((jdn_value + JULIAN_DAY_NOON_OFFSET).floor()) as i64; + + let day = ((5 + * (((4 * (jd_int + 1401 + (((4 * jd_int + 274277) / 146097) * 3) / 4 - 38) + 3) + % 1461) + / 4) + + 2) + % 153) + / 5 + + 1; + let month = ((5 + * (((4 * (jd_int + 1401 + (((4 * jd_int + 274277) / 146097) * 3) / 4 - 38) + 3) + % 1461) + / 4) + + 2) + / 153 + + 2) + % 12 + + 1; + let year = (4 * (jd_int + 1401 + (((4 * jd_int + 274277) / 146097) * 3) / 4 - 38) + 3) + / 1461 + - 4716 + + (12 + 2 - month) / 12; + format!("{:04}-{:02}-{:02}", year, month, day) + } + + pub fn as_time(&self) -> String { + let jdn_value = self.value(); + let jd_int = ((jdn_value + JULIAN_DAY_NOON_OFFSET).floor()) as i64; + let jd_fractional = (jdn_value + JULIAN_DAY_NOON_OFFSET) - (jd_int as f64); + let total_seconds = jd_fractional * 86400.0; + let hour = (total_seconds / 3600.0).floor() as i64; + let minute = ((total_seconds % 3600.0) / 60.0).floor() as i64; + let second_with_fraction = (total_seconds % 3600.0) % 60.0; + let second = second_with_fraction.floor() as i64; + + if self.is_subsecond { + let fractional_seconds = second_with_fraction - second as f64; + let milliseconds = (fractional_seconds * 1000.0).round() as i64; + format!( + "{:02}:{:02}:{:02}.{:03}", + hour, minute, second, milliseconds + ) + } else { + format!("{:02}:{:02}:{:02}", hour, minute, second) + } + } + + pub fn as_datetime(&self) -> String { + format!("{} {}", self.as_date(), self.as_time()) + } + + pub fn as_unix_epoch(&self) -> f64 { + let jdn_value = self.value(); + if self.is_subsecond { + (jdn_value - UNIX_EPOCH_JULIAN_DAY) * 86400000.0 + } else { + (jdn_value - UNIX_EPOCH_JULIAN_DAY) * 86400.0 + } + } + + pub fn new(jdn: f64) -> Self { + Self { + jdn, + is_subsecond: false, + } + } + + // https://en.wikipedia.org/wiki/Julian_day + // This function accepts negative and positive float values for all of the params. + // If a None is passed in for the year, it will be treated as julian year 0. + pub fn new_from_datetime_vals( + year: f64, + month: f64, + day: f64, + hour: f64, + minute: f64, + second: f64, + subsecond: f64, + ) -> Self { + let total_seconds = hour * 3600.0 + minute * 60.0 + second + subsecond; + let time_fraction = total_seconds / 86400.0; + + let year_int = year.floor() as i64; + let month_int = month.floor() as i64; + let day_int = day.floor() as i64; + let day_fraction = day - day.floor(); + let a = (14 - month_int) / 12; + let y = year_int + YEAR_OFFSET - a; + let m = month_int + 12 * a - 3; + + let jdn_int = day_int + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 + - JULIAN_DAY_EPOCH_OFFSET; + + let jdn = (jdn_int as f64) + day_fraction + time_fraction - JULIAN_DAY_NOON_OFFSET; + Self { + jdn, + is_subsecond: false, + } + } + + pub fn new_relative_from_datetime_vals( + y: f64, + m: f64, + d: f64, + h: f64, + mi: f64, + s: f64, + fs: f64, + ) -> Self { + let jdn = Self::new_from_datetime_vals(y, m, d, h, mi, s, fs).value(); + let gregorian_year_zero = + Self::new_from_datetime_vals(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0).value(); + let jdn = jdn - gregorian_year_zero; + Self { + jdn, + is_subsecond: false, + } + } + + pub fn value(&self) -> f64 { + self.jdn + } + + pub fn value_mut(&mut self) -> &mut f64 { + &mut self.jdn + } +} diff --git a/src/db/table/operations/helpers/datetime_functions/mod.rs b/src/db/table/operations/helpers/datetime_functions/mod.rs index 7d28cb1..4d4f703 100644 --- a/src/db/table/operations/helpers/datetime_functions/mod.rs +++ b/src/db/table/operations/helpers/datetime_functions/mod.rs @@ -1,2 +1,75 @@ -pub mod date; +pub mod julian_day; +pub mod modifiers; pub mod time_values; + +use crate::db::table::core::value::Value; +use crate::db::table::operations::helpers::datetime_functions::julian_day::JulianDay; +use crate::db::table::operations::helpers::datetime_functions::modifiers::DateTimeModifier; +use crate::db::table::operations::helpers::datetime_functions::modifiers::parse_modifier; +use crate::db::table::operations::helpers::datetime_functions::time_values::parse_timevalue; +use crate::interpreter::ast::SelectableColumn; +use crate::interpreter::ast::SelectableStackElement; + +pub fn build_julian_day(args: &Vec) -> Result { + if args.is_empty() { + return Err("Invalid DateTime function: no arguments".to_string()); + } + let mut init_jdn = { + let arg = &args[0]; + match &arg.selectables[0] { + SelectableStackElement::Value(val) => parse_timevalue(val)?, + _ => { + return Err(format!( + "Invalid argument for datetime function: {:?}", + arg.selectables[0] + )); + } + } + }; + // ONLY SUPPORTS TIME MODIFIERS RN. + for arg in args[1..].iter() { + let arg = match &arg.selectables[0] { + SelectableStackElement::Value(Value::Text(val)) => val.to_string(), + _ => { + return Err(format!( + "Invalid argument for datetime function: {:?}", + arg.selectables[0] + )); + } + }; + let modifier = parse_modifier(&arg)?; + match modifier { + DateTimeModifier::JDNOffset(jd) => { + init_jdn = JulianDay::new(init_jdn.value() + jd.value()); + } + _ => { + return Err(format!("NOT SUPPORTED YET: '{}'", arg)); + } + } + } + Ok(init_jdn) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::table::core::value::Value; + + #[test] + fn test_build_julian_day_as_date_time() { + let args = vec![SelectableColumn { + selectables: vec![SelectableStackElement::Value(Value::Text( + "2025-12-12".to_string(), + ))], + column_name: "text".to_string(), + }]; + let result = build_julian_day(&args).unwrap().as_datetime(); + assert_eq!(result, "2025-12-12 00:00:00"); + let args = vec![SelectableColumn { + selectables: vec![SelectableStackElement::Value(Value::Real(2461022.6789))], + column_name: "real".to_string(), + }]; + let result = build_julian_day(&args).unwrap().as_datetime(); + assert_eq!(result, "2025-12-13 04:17:36"); + } +} diff --git a/src/db/table/operations/helpers/datetime_functions/modifiers.rs b/src/db/table/operations/helpers/datetime_functions/modifiers.rs new file mode 100644 index 0000000..d9c7ef8 --- /dev/null +++ b/src/db/table/operations/helpers/datetime_functions/modifiers.rs @@ -0,0 +1,368 @@ +use crate::db::table::operations::helpers::datetime_functions::julian_day::JulianDay; + +#[derive(Debug, Clone, PartialEq)] +pub enum DateTimeModifier { + JDNOffset(JulianDay), + Ceiling, + Floor, + StartOfMonth, + StartOfYear, + StartOfDay, + Weekday(i64), + UnixEpoch, + JulianDay, + Auto, + Localtime, + Utc, + Subsecond, +} + +// Parsing here is done according to the SQLite documentation for date and time function modifiers. +// https://sqlite.org/lang_datefunc.html see section 3 +pub fn parse_modifier(modifier: &str) -> Result { + // Parse 'weekday N' format. + if let Some(value) = modifier.strip_prefix("weekday ") { + let value = value.trim(); + if value.is_empty() { + return Err("Weekday modifier requires a numeric argument".to_string()); + } + let weekday = value + .parse::() + .map_err(|_| format!("Invalid weekday value: '{}'", value))? + as i64; + if !(0..=6).contains(&weekday) { + return Err("Weekday modifier accepts values between 0 and 6".to_string()); + } + return Ok(DateTimeModifier::Weekday(weekday)); + } + + // Parse other modifiers. + match modifier { + "ceiling" => return Ok(DateTimeModifier::Ceiling), + "floor" => return Ok(DateTimeModifier::Floor), + "start of month" => return Ok(DateTimeModifier::StartOfMonth), + "start of year" => return Ok(DateTimeModifier::StartOfYear), + "start of day" => return Ok(DateTimeModifier::StartOfDay), + "unixepoch" => return Ok(DateTimeModifier::UnixEpoch), + "julianday" => return Ok(DateTimeModifier::JulianDay), + "auto" => return Ok(DateTimeModifier::Auto), + "localtime" => return Ok(DateTimeModifier::Localtime), + "utc" => return Ok(DateTimeModifier::Utc), + "subsec" | "subsecond" => return Ok(DateTimeModifier::Subsecond), + _ => {} + } + + // At this point we should have either a numeric modifier (1-13 in the SQLite documentation) or an error. + let original_modifier = modifier; + let has_sign = modifier.starts_with('+') || modifier.starts_with('-'); + let sign = if modifier.starts_with('-') { -1.0 } else { 1.0 }; + let modifier = modifier.trim_start_matches('+').trim_start_matches('-'); + + // Handle modifiers 1-6 + match modifier.split_once(' ').unwrap_or((modifier, "")) { + (value, "days") => { + let days = value + .parse::() + .map_err(|_| format!("Invalid days value: '{}'", value))?; + return Ok(DateTimeModifier::JDNOffset( + JulianDay::new_relative_from_datetime_vals( + 0.0, + 0.0, + days * sign, + 0.0, + 0.0, + 0.0, + 0.0, + ), + )); + } + (value, "hours") => { + let hours = value + .parse::() + .map_err(|_| format!("Invalid hours value: '{}'", value))?; + return Ok(DateTimeModifier::JDNOffset( + JulianDay::new_relative_from_datetime_vals( + 0.0, + 0.0, + 0.0, + hours * sign, + 0.0, + 0.0, + 0.0, + ), + )); + } + (value, "minutes") => { + let minutes = value + .parse::() + .map_err(|_| format!("Invalid minutes value: '{}'", value))?; + return Ok(DateTimeModifier::JDNOffset( + JulianDay::new_relative_from_datetime_vals( + 0.0, + 0.0, + 0.0, + 0.0, + minutes * sign, + 0.0, + 0.0, + ), + )); + } + (value, "seconds") => { + let seconds = value + .parse::() + .map_err(|_| format!("Invalid seconds value: '{}'", value))?; + return Ok(DateTimeModifier::JDNOffset( + JulianDay::new_relative_from_datetime_vals( + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + seconds * sign, + 0.0, + ), + )); + } + (value, "months") => { + let months = value + .parse::() + .map_err(|_| format!("Invalid months value: '{}'", value))?; + return Ok(DateTimeModifier::JDNOffset( + JulianDay::new_relative_from_datetime_vals( + 0.0, + months * sign, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ), + )); + } + (value, "years") => { + let years = value + .parse::() + .map_err(|_| format!("Invalid years value: '{}'", value))?; + return Ok(DateTimeModifier::JDNOffset( + JulianDay::new_relative_from_datetime_vals( + years * sign, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ), + )); + } + // At this point all of the numeric modifiers have been parsed. The only remaining ones are 7-13 + (value, "") => { + if value.contains('-') { + if !has_sign { + return Err(format!("Invalid modifier: '{}'", original_modifier)); + } + let date = parse_date(value, sign)?; + return Ok(DateTimeModifier::JDNOffset(date)); + } else { + let time = parse_time(value, sign)?; + return Ok(DateTimeModifier::JDNOffset(time)); + } + } + (date, time) => { + if !has_sign { + return Err(format!("Invalid modifier: '{}'", original_modifier)); + } + let date = parse_date(date, sign)?; + let time = parse_time(time, sign)?; + return Ok(DateTimeModifier::JDNOffset(JulianDay::new( + date.value() + time.value(), + ))); + } + } +} + +fn parse_date(date: &str, sign: f64) -> Result { + if date.is_empty() + || date.len() != 10 + || date.chars().nth(4) != Some('-') + || date.chars().nth(7) != Some('-') + { + return Err(format!("Invalid date: '{}'.", date)); + } + let day = date[8..10] + .parse::() + .map_err(|_| format!("Invalid day: '{}'", &date[8..10]))?; + let year = date[0..4] + .parse::() + .map_err(|_| format!("Invalid year: '{}'", &date[0..4]))?; + let month = date[5..7] + .parse::() + .map_err(|_| format!("Invalid month: '{}'", &date[5..7]))?; + + if (month != 0 && (month < 1 || month > 12)) || (month == 0 && day != 0) { + // technically 2025-00-00 is valid + return Err(format!("Invalid date: '{}'.", date)); + } + + if month != 0 && day != 0 { + let max_days = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) { + 29 + } else { + 28 + } + } + _ => 0, + }; + if day < 1 || day > max_days { + return Err(format!("Invalid date: '{}'.", date)); + } + } + + Ok(JulianDay::new_relative_from_datetime_vals( + year as f64 * sign, + month as f64, + day as f64, + 0.0, + 0.0, + 0.0, + 0.0, + )) +} + +fn parse_in_range(s: &str, name: &str, min: i64, max: i64) -> Result { + let value = s + .parse::() + .map_err(|_| format!("Invalid {}: '{}'", name, s))?; + if !(min..=max).contains(&value) { + return Err(format!( + "{} out of range ({}-{}): {}", + name, min, max, value + )); + } + Ok(value) +} + +fn parse_time(time: &str, sign: f64) -> Result { + if time.is_empty() { + return Err(format!("Invalid time: '{}'.", time)); + } + + let mut parts = time.split(':'); + let hour = parse_in_range( + parts + .next() + .ok_or_else(|| format!("Invalid time: '{}'.", time))?, + "hour", + 0, + 23, + )?; + let minute = parse_in_range( + parts + .next() + .ok_or_else(|| format!("Invalid time: '{}'.", time))?, + "minute", + 0, + 59, + )?; + + let (second, subsecond) = if let Some(second_part) = parts.next() { + if parts.next().is_some() { + return Err(format!("Invalid time: '{}'.", time)); + } + if let Some(dot_pos) = second_part.find('.') { + if second_part[dot_pos + 1..].len() > 3 { + return Err(format!("Invalid time: '{}'.", time)); + } + ( + parse_in_range(&second_part[..dot_pos], "second", 0, 59)?, + parse_in_range(&second_part[dot_pos + 1..], "subsecond", 0, 999)? as f64 / 1000.0, + ) + } else { + (parse_in_range(second_part, "second", 0, 59)?, 0.0) + } + } else { + (0, 0.0) + }; + + Ok(JulianDay::new_relative_from_datetime_vals( + 0.0, + 0.0, + 0.0, + hour as f64 * sign, + minute as f64 * sign, + second as f64 * sign, + subsecond * sign, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + impl DateTimeModifier { + fn jdnoffset(&self) -> Option<&JulianDay> { + match self { + DateTimeModifier::JDNOffset(jd) => Some(jd), + _ => None, + } + } + } + trait ModifierValue { + fn value(&self) -> f64; + } + + impl ModifierValue for Result { + fn value(&self) -> f64 { + self.as_ref().unwrap().jdnoffset().unwrap().value() + } + } + + #[test] + fn test_parse_modifier() { + assert!(parse_modifier("5 days").value() == 5.0); + assert!(parse_modifier("12 hours").value() == 0.5); + assert!((parse_modifier("30 minutes").value() - 0.020833333333333332).abs() < 0.000001); + assert!((parse_modifier("45 seconds").value() - 0.0005208333333333333).abs() < 0.000001); + assert!(parse_modifier("6 months").value() == 183.0); + assert!(parse_modifier("2 years").value() == 731.0); + assert!((parse_modifier("12:30").value() - 0.5208333333333333).abs() < 0.000001); + assert!((parse_modifier("+12:30:45").value() - 0.5213541666666666).abs() < 0.000001); + assert!((parse_modifier("-12:30:45.123").value() - (-0.5213555903173983)).abs() < 0.000001); + assert!(parse_modifier("+2025-12-25").value() == 740007.0); + assert!((parse_modifier("+2025-12-25 12:30").value() - 740007.5208333333).abs() < 0.000001); + assert!( + (parse_modifier("+2025-12-25 12:30:45").value() - 740007.5213541666).abs() < 0.000001 + ); + assert!( + (parse_modifier("+2025-12-25 12:30:45.123").value() - 740007.5213555903).abs() + < 0.000001 + ); + } + + #[test] + fn test_parse_modifier_date_requires_sign() { + assert!(parse_modifier("2025-12-25").is_err()); + assert!(parse_modifier("2025-12-25 12:30").is_err()); + assert!(parse_modifier("2025-12-25 12:30:45").is_err()); + assert!(parse_modifier("2025-12-25 12:30:45.123").is_err()); + assert!(parse_modifier("+2025-12-25").is_ok()); + } + + #[test] + fn test_parse_modifier_days_without_sign() { + assert!(parse_modifier("1 days").is_ok()); + assert_eq!( + parse_modifier("1 days") + .unwrap() + .jdnoffset() + .unwrap() + .value(), + 1.0 + ); + } +} diff --git a/src/db/table/operations/helpers/datetime_functions/time_values.rs b/src/db/table/operations/helpers/datetime_functions/time_values.rs index f6993b7..650d8b7 100644 --- a/src/db/table/operations/helpers/datetime_functions/time_values.rs +++ b/src/db/table/operations/helpers/datetime_functions/time_values.rs @@ -1,65 +1,13 @@ use crate::db::table::core::value::Value; +use crate::db::table::operations::helpers::datetime_functions::JulianDay; const UNIX_EPOCH_JULIAN_DAY: f64 = 2440587.5; const MILLISECONDS_PER_DAY: f64 = 86400000.0; -const JULIAN_DAY_EPOCH_OFFSET: i64 = 32045; -const JULIAN_DAY_NOON_OFFSET: f64 = 0.5; -const YEAR_OFFSET: i64 = 4800; - -fn days_in_month(year: i64, month: i64) -> i64 { - match month { - 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, - 4 | 6 | 9 | 11 => 30, - 2 => { - if is_leap_year(year) { - 29 - } else { - 28 - } - } - _ => 0, - } -} - -fn is_leap_year(year: i64) -> bool { - (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) -} - -// Parses a string to an i64 within a given range, i.e. parse hour and validate within 0-23. -fn parse_in_range(s: &str, name: &str, min: i64, max: i64, default: i64) -> Result { - if s.is_empty() { - return Ok(default); - } - let value = s - .parse::() - .map_err(|_| format!("Invalid {}: {:?}", name, s))?; - if !(min..=max).contains(&value) { - return Err(format!( - "{} out of range ({}-{}): {}", - name, min, max, value - )); - } - Ok(value) -} - -// https://en.wikipedia.org/wiki/Julian_day -fn calculate_julian_day(y: i64, m: i64, d: i64, h: i64, mi: i64, s: i64, fs: f64) -> f64 { - let a = (14 - m) / 12; - let y = y + YEAR_OFFSET - a; - let m = m + 12 * a - 3; - - let jdn_int = - d + (153 * m + 2) / 5 + 365 * y + y / 4 - y / 100 + y / 400 - JULIAN_DAY_EPOCH_OFFSET; - - let time_fraction = (h as f64) / 24.0 + (mi as f64) / 1440.0 + (s as f64 + fs) / 86400.0; - - (jdn_int as f64) + time_fraction - JULIAN_DAY_NOON_OFFSET -} // This is parsed according to the SQLite documentation for Time Values // https://sqlite.org/lang_datefunc.html -// This function takes a time value and returns the corresponding f64 julian day number -pub fn parse_timevalue(time_value: &Value) -> Result { +// This function takes a time value and returns the corresponding JDN (Julian Day Number) +pub fn parse_timevalue(time_value: &Value) -> Result { match time_value { Value::Text(text) if text == "now" => { let duration = std::time::SystemTime::now() @@ -68,7 +16,7 @@ pub fn parse_timevalue(time_value: &Value) -> Result { let unix_ms = duration.as_secs() as i64 * 1000 + duration.subsec_millis() as i64; // Convert to Julian Day Number let jdn = (unix_ms as f64 / MILLISECONDS_PER_DAY) + UNIX_EPOCH_JULIAN_DAY; - Ok(jdn) + Ok(JulianDay::new(jdn)) } Value::Text(txt) => { // Look at formats 1-10 in the SQLite documentation for Time Values @@ -152,8 +100,15 @@ pub fn parse_timevalue(time_value: &Value) -> Result { 0.0 }; - let mut jdn = - calculate_julian_day(year, month, day, hour, minute, second, frac_seconds); + let mut jdn = JulianDay::new_from_datetime_vals( + year as f64, + month as f64, + day as f64, + hour as f64, + minute as f64, + second as f64, + frac_seconds, + ); // timezone adjustment let (timezone_hour, timezone_minute) = if timezone_part.len() == 6 { @@ -169,20 +124,56 @@ pub fn parse_timevalue(time_value: &Value) -> Result { let tz_offset = tz_hour / 24.0 + tz_minute / 1440.0; if timezone_part.starts_with('-') { - jdn += tz_offset; + *jdn.value_mut() += tz_offset; } else if timezone_part.starts_with('+') { - jdn -= tz_offset; + *jdn.value_mut() -= tz_offset; } } Ok(jdn) } - Value::Integer(jdn_int) => Ok(*jdn_int as f64), - Value::Real(jdn_float) => Ok(*jdn_float), + Value::Integer(jdn_int) => Ok(JulianDay::new(*jdn_int as f64)), + Value::Real(jdn_float) => Ok(JulianDay::new(*jdn_float)), _ => Err(format!("Invalid time value: {:?}", time_value)), } } +fn days_in_month(year: i64, month: i64) -> i64 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if is_leap_year(year) { + 29 + } else { + 28 + } + } + _ => 0, + } +} + +fn is_leap_year(year: i64) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +// Parses a string to an i64 within a given range, i.e. parse hour and validate within 0-23. +fn parse_in_range(s: &str, name: &str, min: i64, max: i64, default: i64) -> Result { + if s.is_empty() { + return Ok(default); + } + let value = s + .parse::() + .map_err(|_| format!("Invalid {}: {:?}", name, s))?; + if !(min..=max).contains(&value) { + return Err(format!( + "{} out of range ({}-{}): {}", + name, min, max, value + )); + } + Ok(value) +} + #[cfg(test)] mod tests { use super::*; @@ -191,86 +182,98 @@ mod tests { fn test_parse_timevalue() { assert_eq!( parse_timevalue(&Value::Text("2025-12-12 12:00:00".to_string())), - Ok(2461022.0) + Ok(JulianDay::new(2461022.0)) ); - assert_eq!(parse_timevalue(&Value::Real(2461021.5)), Ok(2461021.5)); - assert_eq!(parse_timevalue(&Value::Integer(2461021)), Ok(2461021.0)); + assert_eq!( + parse_timevalue(&Value::Real(2461021.5)), + Ok(JulianDay::new(2461021.5)) + ); + assert_eq!( + parse_timevalue(&Value::Integer(2461021)), + Ok(JulianDay::new(2461021.0)) + ); assert_eq!( parse_timevalue(&Value::Text("2025-12-12".to_string())), - Ok(2461021.5) + Ok(JulianDay::new(2461021.5)) ); let result = parse_timevalue(&Value::Text("2025-12-12 12:30".to_string())).unwrap(); - assert!((result - 2461022.020833333).abs() < 0.000001); + assert!((result.value() - 2461022.020833333).abs() < 0.000001); let result = parse_timevalue(&Value::Text("2025-12-12 12:00:00.123".to_string())).unwrap(); - assert!((result - 2461022.0000014235).abs() < 0.0000001); + assert!((result.value() - 2461022.0000014235).abs() < 0.0000001); let result = parse_timevalue(&Value::Text("2025-12-12T12:30".to_string())).unwrap(); - assert!((result - 2461022.020833333).abs() < 0.000001); + assert!((result.value() - 2461022.020833333).abs() < 0.000001); assert_eq!( parse_timevalue(&Value::Text("2025-12-12T12:00:00".to_string())), - Ok(2461022.0) + Ok(JulianDay::new(2461022.0)) ); assert_eq!( parse_timevalue(&Value::Text("12:00:00".to_string())), - Ok(2451545.0) + Ok(JulianDay::new(2451545.0)) ); let result = parse_timevalue(&Value::Text("12:30".to_string())).unwrap(); - assert!((result - 2451545.020833333).abs() < 0.000001); + assert!((result.value() - 2451545.020833333).abs() < 0.000001); assert_eq!( parse_timevalue(&Value::Text("0000-01-01".to_string())), - Ok(1721059.5) + Ok(JulianDay::new(1721059.5)) ); assert_eq!( parse_timevalue(&Value::Text("2025-12-12 12:00:00.1234567890".to_string())), - Ok(2461022.0000014235) + Ok(JulianDay::new(2461022.0000014235)) ); // Timezone tests assert_eq!( parse_timevalue(&Value::Text("2025-12-12T12:00:00Z".to_string())), - Ok(2461022.0) + Ok(JulianDay::new(2461022.0)) ); assert_eq!( parse_timevalue(&Value::Text("2025-12-12 12:00:00Z".to_string())), - Ok(2461022.0) + Ok(JulianDay::new(2461022.0)) ); let result = parse_timevalue(&Value::Text("2025-12-12T12:00:00-04:00".to_string())).unwrap(); - assert!((result - 2461022.1666666665).abs() < 0.0000001); + assert!((result.value() - 2461022.1666666665).abs() < 0.0000001); let result = parse_timevalue(&Value::Text("2025-12-12T12:00:00+04:00".to_string())).unwrap(); - assert!((result - 2461021.8333333335).abs() < 0.0000001); + assert!((result.value() - 2461021.8333333335).abs() < 0.0000001); assert_eq!( parse_timevalue(&Value::Text("12:00:00Z".to_string())), - Ok(2451545.0) + Ok(JulianDay::new(2451545.0)) ); let result = parse_timevalue(&Value::Text("08:00:00-04:00".to_string())).unwrap(); - assert!((result - 2451545.0).abs() < 0.0000001); + assert!((result.value() - 2451545.0).abs() < 0.0000001); let result = parse_timevalue(&Value::Text("16:00:00+04:00".to_string())).unwrap(); - assert!((result - 2451545.0).abs() < 0.0000001); + assert!((result.value() - 2451545.0).abs() < 0.0000001); let now_result = parse_timevalue(&Value::Text("now".to_string())).unwrap(); - assert!(now_result > 2460000.0 && now_result < 2470000.0); + assert!(now_result.value() > 2460000.0 && now_result.value() < 2470000.0); // Trailing whitespace is ignored assert_eq!( parse_timevalue(&Value::Text("2025-12-12 12:00:00 ".to_string())), - Ok(2461022.0) + Ok(JulianDay::new(2461022.0)) ); // Leading whitespace should fail assert!(parse_timevalue(&Value::Text(" 2025-12-12 12:00:00".to_string())).is_err()); let result = parse_timevalue(&Value::Text("12:00:00.500".to_string())).unwrap(); - assert!((result - 2451545.0000057870).abs() < 0.0000001); + assert!((result.value() - 2451545.0000057870).abs() < 0.0000001); let result = parse_timevalue(&Value::Text("2025-12-12 12:00:00.1".to_string())).unwrap(); - assert!((result - 2461022.0000011574).abs() < 0.0000001); + assert!((result.value() - 2461022.0000011574).abs() < 0.0000001); let result = parse_timevalue(&Value::Text("2025-12-12 12:00:00.12".to_string())).unwrap(); - assert!((result - 2461022.0000013889).abs() < 0.0000001); + assert!((result.value() - 2461022.0000013889).abs() < 0.0000001); // Negative Julian day numbers - assert_eq!(parse_timevalue(&Value::Integer(-1)), Ok(-1.0)); - assert_eq!(parse_timevalue(&Value::Real(-100.5)), Ok(-100.5)); + assert_eq!( + parse_timevalue(&Value::Integer(-1)), + Ok(JulianDay::new(-1.0)) + ); + assert_eq!( + parse_timevalue(&Value::Real(-100.5)), + Ok(JulianDay::new(-100.5)) + ); } #[test] diff --git a/src/db/table/operations/helpers/datetime_functions/todo.md b/src/db/table/operations/helpers/datetime_functions/todo.md new file mode 100644 index 0000000..f6941ef --- /dev/null +++ b/src/db/table/operations/helpers/datetime_functions/todo.md @@ -0,0 +1,11 @@ +Currently the following needs to be completed: + - Fix the implementation of Modifiers, SQLite's implementation is very weird here + - Add support for multiple modifiers to work correctly, dependent on above + - Add support for the other modifiers that don't just directly add or subtract time + - Handle edge cases: Feb 29 in leap years, month-end dates + + - Improve abstraction between modifier.rs and time_value.rs they do sim things. + - strftime and timediff need to be done + + - Wayyyy more comprehesive testing here, maybe against sqlite smhow + \ No newline at end of file diff --git a/src/interpreter/ast/helpers/selectables/get_selectables.rs b/src/interpreter/ast/helpers/selectables/get_selectables.rs index 02d788f..ded5f35 100644 --- a/src/interpreter/ast/helpers/selectables/get_selectables.rs +++ b/src/interpreter/ast/helpers/selectables/get_selectables.rs @@ -20,7 +20,6 @@ fn token_to_function_name(token_type: &TokenTypes) -> Option { TokenTypes::DateTime => Some(FunctionName::DateTime), TokenTypes::JulianDay => Some(FunctionName::JulianDay), TokenTypes::UnixEpoch => Some(FunctionName::UnixEpoch), - TokenTypes::TimeDiff => Some(FunctionName::TimeDiff), _ => None, } } diff --git a/src/interpreter/ast/mod.rs b/src/interpreter/ast/mod.rs index bbad736..ace6829 100644 --- a/src/interpreter/ast/mod.rs +++ b/src/interpreter/ast/mod.rs @@ -246,7 +246,7 @@ pub enum FunctionName { JulianDay, UnixEpoch, // TODO: Support Strftime - TimeDiff, + // TODO: Support TimeDiff } impl FunctionName { @@ -261,8 +261,7 @@ impl FunctionName { | FunctionName::Time | FunctionName::DateTime | FunctionName::JulianDay - | FunctionName::UnixEpoch - | FunctionName::TimeDiff => false, + | FunctionName::UnixEpoch => false, } } } diff --git a/src/interpreter/tokenizer/scanner.rs b/src/interpreter/tokenizer/scanner.rs index 74755f5..cd35047 100644 --- a/src/interpreter/tokenizer/scanner.rs +++ b/src/interpreter/tokenizer/scanner.rs @@ -195,6 +195,11 @@ impl<'a> Scanner<'a> { slice if slice.eq_ignore_ascii_case("AVG") => TokenTypes::Avg, slice if slice.eq_ignore_ascii_case("MIN") => TokenTypes::Min, slice if slice.eq_ignore_ascii_case("MAX") => TokenTypes::Max, + slice if slice.eq_ignore_ascii_case("DATE") => TokenTypes::Date, + slice if slice.eq_ignore_ascii_case("TIME") => TokenTypes::Time, + slice if slice.eq_ignore_ascii_case("DATETIME") => TokenTypes::DateTime, + slice if slice.eq_ignore_ascii_case("JULIANDAY") => TokenTypes::JulianDay, + slice if slice.eq_ignore_ascii_case("UNIXEPOCH") => TokenTypes::UnixEpoch, slice if slice.eq_ignore_ascii_case("TRUE") => TokenTypes::TrueLiteral, slice if slice.eq_ignore_ascii_case("FALSE") => TokenTypes::FalseLiteral, _ => TokenTypes::Identifier, diff --git a/tests/main_tests.rs b/tests/main_tests.rs index 13205e4..715e587 100644 --- a/tests/main_tests.rs +++ b/tests/main_tests.rs @@ -1,6 +1,7 @@ mod common; mod suites { pub mod basic_crud; + pub mod datetime_operations; pub mod set_operators; pub mod transactions; } diff --git a/tests/suites/datetime_operations.rs b/tests/suites/datetime_operations.rs new file mode 100644 index 0000000..7a373a7 --- /dev/null +++ b/tests/suites/datetime_operations.rs @@ -0,0 +1,71 @@ +use mollycache::db::database::Database; +use mollycache::db::table::core::{row::Row, value::Value}; +use mollycache::interpreter::run_sql; + +use crate::common::assert_eq_table_rows; + +#[test] +fn test_datetime_functions() { + let mut database = Database::new(); + let sql = " + CREATE TABLE users (id INTEGER); + INSERT INTO users (id) VALUES (1); + SELECT Date('2025-12-12 12:00:00') FROM users; + SELECT Time('2025-12-12 12:00:00') FROM users; + SELECT DateTime('2025-12-12 12:00:00') FROM users; + SELECT JulianDay('2025-12-12 12:00:00') FROM users; + SELECT UnixEpoch('2025-12-12 12:00:00') FROM users; + "; + let mut result = run_sql(&mut database, sql); + assert!(result.iter().all(|result| result.is_ok())); + + let unix_result = result.pop().unwrap().unwrap().unwrap(); + assert!(matches!(unix_result[0].0[0], Value::Real(epoch) if epoch > 0.0)); + + let jdn = match result.pop().unwrap().unwrap().unwrap()[0].0[0] { + Value::Real(j) => j, + _ => panic!("Expected Real value for JulianDay()"), + }; + assert!((jdn - 2461022.0).abs() < 0.0001); + + let expected_datetime = vec![Row(vec![Value::Text("2025-12-12 12:00:00".to_string())])]; + assert_eq_table_rows(expected_datetime, result.pop().unwrap().unwrap().unwrap()); + + let expected_time = vec![Row(vec![Value::Text("12:00:00".to_string())])]; + assert_eq_table_rows(expected_time, result.pop().unwrap().unwrap().unwrap()); + + let expected_date = vec![Row(vec![Value::Text("2025-12-12".to_string())])]; + assert_eq_table_rows(expected_date, result.pop().unwrap().unwrap().unwrap()); +} + +#[test] +fn test_datetime_functions_with_modifiers() { + let mut database = Database::new(); + let sql = " + CREATE TABLE users (id INTEGER); + INSERT INTO users (id) VALUES (1); + SELECT Date('2025-12-12 12:00:00', '1 days') FROM users; + SELECT DateTime('2025-12-12 12:00:00', '10 years') FROM users; + SELECT DateTime('2025-12-12 12:00:00', '+0000-00-01 00:00:01') FROM users; + "; + let result = run_sql(&mut database, sql); + assert_eq!(result.len(), 5); + assert!(result[0].is_ok()); + assert!(result[1].is_ok()); + assert!(result[2].is_ok()); + assert!(result[3].is_ok()); + assert!( + result[4].is_err(), + "Expected error for invalid modifier '+0000-00-01 00:00:01'" + ); // month is invalid + let expected_date = vec![Row(vec![Value::Text("2025-12-13".to_string())])]; + assert_eq_table_rows( + expected_date, + result[2].as_ref().unwrap().as_ref().unwrap().clone(), + ); + let expected_datetime = vec![Row(vec![Value::Text("2035-12-13 12:00:00".to_string())])]; // TECHNICALLY THIS BEHAVIOUR IS INCORRECT, SQLite does really funny stuff with years... + assert_eq_table_rows( + expected_datetime, + result[3].as_ref().unwrap().as_ref().unwrap().clone(), + ); +}