diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf7f2f5..413c1b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,57 +14,42 @@ env: jobs: test: - name: Build, Lint, and Test - runs-on: ubuntu-latest - container: - image: rust:alpine - steps: - - name: Install Alpine dependencies - run: apk add --no-cache git musl-dev - - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust components - run: rustup component add clippy rustfmt + name: test - ${{ matrix.target }} + strategy: + fail-fast: true + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + os: ubuntu + - target: aarch64-unknown-linux-gnu + runner: ubuntu-24.04-arm + os: ubuntu + - target: aarch64-apple-darwin + runner: macos-latest + os: macos + runs-on: ${{ matrix.runner }} - - name: Cache Cargo dependencies - uses: actions/cache@v3 + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - target/ - key: ${{ runner.os }}-alpine-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Check formatting - run: cargo fmt -- --check - - - name: Lint with Clippy - run: cargo clippy -- -D warnings - - - name: Run Tests - run: cargo test --verbose - - publish: - name: Publish to Crates.io + target: ${{ matrix.target }} + rustflags: "" + - name: run test + run: | + cargo test --verbose + + # Check formatting with rustfmt + formatting: + name: cargo fmt runs-on: ubuntu-latest - needs: test - environment: master - if: github.ref == 'refs/heads/master' steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - - name: Build Release - run: cargo build --release - - - name: Publish to Crates.io - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - run: cargo publish --token $CARGO_REGISTRY_TOKEN + - uses: actions/checkout@v4 + # Ensure rustfmt is installed and setup problem matcher + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + components: rustfmt + rustflags: "" + - name: Rustfmt Check + uses: actions-rust-lang/rustfmt@v1 diff --git a/Cargo.toml b/Cargo.toml index 440472c..5b1b082 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "pg_interval" version = "0.4.3" edition = "2018" -authors = ["Ryan Piper "] +authors = ["Ryan Piper ", "Ning Sun "] license = "MIT" description = "A native PostgreSQL interval type" repository = "https://github.com/piperRyan/rust-postgres-interval" diff --git a/README.md b/README.md index f4b44c5..5d6770b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -[![Build Status](https://travis-ci.org/piperRyan/rust-postgres-interval.svg?branch=master)](https://travis-ci.org/piperRyan/rust-postgres-interval) [![codecov](https://codecov.io/gh/piperRyan/rust-postgres-interval/branch/master/graph/badge.svg)](https://codecov.io/gh/piperRyan/rust-postgres-interval) - # Rust-Postgres-Interval A interval type for the postgres driver. @@ -23,18 +21,3 @@ fn main() { assert_eq!(String::from("P1Y1M1DT1H"), output); } ``` - -## Requirements -- rust 1.22 - -## Roadmap to 1.0.0 - -- [x] Convert Interval Into Formated String - - [x] Iso 8601 - - [x] Postgres - - [x] Sql -- [ ] Parse Formated Strings Into The Interval Type - - [x] Iso 8601 - - [x] Postgres - - [ ] Sql -- [x] Chrono Integrations diff --git a/src/integrations/duration.rs b/src/integrations/duration.rs index 4962607..2a2af2e 100644 --- a/src/integrations/duration.rs +++ b/src/integrations/duration.rs @@ -12,9 +12,9 @@ impl Interval { let mut days = duration.num_days(); let mut new_dur = duration - Duration::days(days); let mut hours = duration.num_hours(); - new_dur -= Duration::hours(hours); + new_dur = new_dur - Duration::hours(hours); let minutes = new_dur.num_minutes(); - new_dur -= Duration::minutes(minutes); + new_dur = new_dur - Duration::minutes(minutes); let nano_secs = new_dur.num_nanoseconds()?; if days > (i32::MAX as i64) { let overflow_days = days - (i32::MAX as i64); diff --git a/src/interval_fmt/iso_8601.rs b/src/interval_fmt/iso_8601.rs index e261386..66ea5d8 100644 --- a/src/interval_fmt/iso_8601.rs +++ b/src/interval_fmt/iso_8601.rs @@ -17,12 +17,19 @@ impl IntervalNorm { if self.minutes != 0 { time_interval.push_str(&format!("{}M", self.minutes)); } - if self.seconds != 0 { - time_interval.push_str(&format!("{}S", self.seconds)); - } - if self.microseconds != 0 { - let ms = super::safe_abs_u64(self.microseconds); - time_interval.push_str(&format!(".{:06}", ms)); + if self.seconds != 0 || self.microseconds != 0 { + let secs_with_micros = if self.seconds != 0 && self.microseconds != 0 { + format!( + "{}.{:06}", + self.seconds, + super::safe_abs_u64(self.microseconds) + ) + } else if self.microseconds != 0 { + format!(".{:06}", super::safe_abs_u64(self.microseconds)) + } else { + format!("{}", self.seconds) + }; + time_interval.push_str(&format!("{}S", secs_with_micros)); } } else { time_interval = "".to_owned(); diff --git a/src/interval_fmt/postgres.rs b/src/interval_fmt/postgres.rs index 66d8127..75c6594 100644 --- a/src/interval_fmt/postgres.rs +++ b/src/interval_fmt/postgres.rs @@ -1,5 +1,53 @@ use crate::interval_norm::IntervalNorm; +fn get_year_suffix(value: i32) -> &'static str { + if value == 1 { + "year" + } else { + "years" + } +} + +fn get_mon_suffix(value: i32) -> &'static str { + if value == 1 { + "mon" + } else { + "mons" + } +} + +fn get_day_suffix(value: i32) -> &'static str { + if value == 1 { + "day" + } else { + "days" + } +} + +fn get_hour_suffix(value: i64) -> &'static str { + if value == 1 { + "hour" + } else { + "hours" + } +} + +fn get_min_suffix(value: i64) -> &'static str { + if value == 1 { + "min" + } else { + "mins" + } +} + +fn get_sec_suffix(seconds: i64, microseconds: i64) -> &'static str { + if seconds == 1 && microseconds == 0 { + "sec" + } else { + "secs" + } +} + impl IntervalNorm { /// Produces a postgres compliant interval string. pub fn into_postgres(self) -> String { @@ -10,14 +58,18 @@ impl IntervalNorm { let mut day_interval = "".to_owned(); let time_interval = self.get_postgres_time_interval(); if self.is_day_present() { - day_interval = format!("{:#?} days ", self.days) + day_interval = format!("{} {} ", self.days, get_day_suffix(self.days)) } if self.is_year_month_present() { if self.years != 0 { - year_interval.push_str(&format!("{:#?} year ", self.years)) + year_interval.push_str(&*format!("{} {} ", self.years, get_year_suffix(self.years))) } if self.months != 0 { - year_interval.push_str(&format!("{:#?} mons ", self.months)); + year_interval.push_str(&*format!( + "{} {} ", + self.months, + get_mon_suffix(self.months) + )); } } year_interval.push_str(&day_interval); @@ -48,4 +100,97 @@ impl IntervalNorm { } time_interval } + + /// Produces a postgres_verbose compliant interval string. + pub fn into_postgres_verbose(self) -> String { + let is_negative = !self.is_time_interval_pos() + && (self.years < 0 + || self.months < 0 + || self.days < 0 + || self.hours < 0 + || self.minutes < 0 + || self.seconds < 0 + || self.microseconds < 0); + + let mut parts = Vec::new(); + + if self.years != 0 { + let abs_years = if self.years < 0 { + -self.years + } else { + self.years + }; + parts.push(format!("{} {}", abs_years, get_year_suffix(abs_years))); + } + + if self.months != 0 { + let abs_months = if self.months < 0 { + -self.months + } else { + self.months + }; + parts.push(format!("{} {}", abs_months, get_mon_suffix(abs_months))); + } + + if self.days != 0 { + let abs_days = if self.days < 0 { -self.days } else { self.days }; + parts.push(format!("{} {}", abs_days, get_day_suffix(abs_days))); + } + + if self.hours != 0 { + let abs_hours = if self.hours < 0 { + -self.hours + } else { + self.hours + }; + parts.push(format!("{} {}", abs_hours, get_hour_suffix(abs_hours))); + } + + if self.minutes != 0 { + let abs_minutes = if self.minutes < 0 { + -self.minutes + } else { + self.minutes + }; + parts.push(format!("{} {}", abs_minutes, get_min_suffix(abs_minutes))); + } + + if self.seconds != 0 || self.microseconds != 0 { + let abs_seconds = if self.seconds < 0 { + -self.seconds + } else { + self.seconds + }; + let abs_micros = if self.microseconds < 0 { + -self.microseconds + } else { + self.microseconds + }; + if abs_micros != 0 { + let secs_with_micros = abs_seconds as f64 + abs_micros as f64 / 1_000_000.0; + parts.push(format!( + "{} {}", + secs_with_micros, + get_sec_suffix(abs_seconds, abs_micros) + )); + } else { + parts.push(format!( + "{} {}", + abs_seconds, + get_sec_suffix(abs_seconds, abs_micros) + )); + } + } + + if parts.is_empty() { + return "@ 0".to_owned(); + } + + let result = format!("@ {}", parts.join(" ")); + if is_negative { + format!("{} ago", result) + } else { + result + } + } } diff --git a/src/interval_fmt/sql.rs b/src/interval_fmt/sql.rs index e6a7b51..f2c2db0 100644 --- a/src/interval_fmt/sql.rs +++ b/src/interval_fmt/sql.rs @@ -2,64 +2,146 @@ use crate::interval_norm::IntervalNorm; impl IntervalNorm { pub fn into_sql(self) -> String { - if self.is_zeroed() { - "0".to_owned() - } else if !self.is_time_present() && !self.is_day_present() { - get_year_month(self.months, self.years, true) - } else if !self.is_time_present() && !self.is_year_month_present() { - format!("{} 0:00:00", self.days) - } else if !self.is_year_month_present() && !self.is_day_present() { - get_time_interval( - self.hours, - self.minutes, - self.seconds, - self.microseconds, - self.is_time_interval_pos(), - true, - ) - } else { - let year_month = get_year_month(self.months, self.years, false); - let time_interval = get_time_interval( - self.hours, - self.minutes, - self.seconds, - self.microseconds, - self.is_time_interval_pos(), - false, - ); - format!("{} {:+} {}", year_month, self.days, time_interval) + let has_negative = self.years < 0 + || self.months < 0 + || self.days < 0 + || self.hours < 0 + || self.minutes < 0 + || self.seconds < 0 + || self.microseconds < 0; + let has_positive = self.years > 0 + || self.months > 0 + || self.days > 0 + || self.hours > 0 + || self.minutes > 0 + || self.seconds > 0 + || self.microseconds > 0; + let has_year_month = self.years != 0 || self.months != 0; + let has_day_time = self.days != 0 + || self.hours != 0 + || self.minutes != 0 + || self.seconds != 0 + || self.microseconds != 0; + let has_day = self.days != 0; + let is_year_month_only = has_year_month && !has_day_time; + let is_day_time_only = has_day_time && !has_year_month; + let sql_standard_value = + !(has_negative && has_positive) && !(has_year_month && has_day_time); + + if !has_negative && !has_positive { + return "0".to_owned(); } - } -} -fn get_year_month(mons: i32, years: i32, is_only_year_month: bool) -> String { - let months = super::safe_abs_u32(mons); - if years == 0 || is_only_year_month { - format!("{}-{}", years, months) - } else { - format!("{:+}-{}", years, months) - } -} + if !sql_standard_value { + // Mixed signs or year-month + day-time: force signs on each component + let year_sign = if self.years < 0 || self.months < 0 { + '-' + } else { + '+' + }; + let day_sign = if self.days < 0 { '-' } else { '+' }; + let sec_sign = if self.hours < 0 + || self.minutes < 0 + || self.seconds < 0 + || self.microseconds < 0 + { + '-' + } else { + '+' + }; -fn get_time_interval( - hours: i64, - mins: i64, - secs: i64, - micros: i64, - is_time_interval_pos: bool, - is_only_time: bool, -) -> String { - let mut interval = "".to_owned(); - if is_time_interval_pos && is_only_time { - interval.push_str(&format!("{}:{:02}:{:02}", hours, mins, secs)); - } else { - let minutes = super::safe_abs_u64(mins); - let seconds = super::safe_abs_u64(secs); - interval.push_str(&format!("{:+}:{:02}:{:02}", hours, minutes, seconds)); - } - if micros != 0 { - let microseconds = format!(".{:06}", super::safe_abs_u64(micros)); - interval.push_str(µseconds); + let (years, months, days, hours, minutes, seconds, microseconds) = if year_sign == '-' { + ( + -self.years, + -self.months, + -self.days, + -self.hours, + -self.minutes, + -self.seconds, + -self.microseconds, + ) + } else { + ( + self.years, + self.months, + self.days, + self.hours, + self.minutes, + self.seconds, + self.microseconds, + ) + }; + + let hours_abs = super::safe_abs_u64(hours); + let minutes_abs = super::safe_abs_u64(minutes); + let seconds_abs = super::safe_abs_u64(seconds); + let mut time_str = format!( + "{}{}:{:02}:{:02}", + sec_sign, hours_abs, minutes_abs, seconds_abs + ); + + if microseconds != 0 { + let microseconds_fmt = format!(".{:06}", super::safe_abs_u64(microseconds)); + time_str.push_str(µseconds_fmt); + } + + format!( + "{}{}-{} {}{} {}", + year_sign, + years.abs(), + months.abs(), + day_sign, + days.abs(), + time_str + ) + } else if is_year_month_only { + // Only year-month + if has_negative { + format!("-{}-{}", self.years.abs(), super::safe_abs_u32(self.months)) + } else { + format!("{}-{}", self.years, super::safe_abs_u32(self.months)) + } + } else if is_day_time_only && has_day { + // Day-time with day + let hours_abs = super::safe_abs_u64(self.hours); + let minutes = super::safe_abs_u64(self.minutes); + let seconds = super::safe_abs_u64(self.seconds); + let mut time_str = if self.hours == 0 + && self.minutes == 0 + && self.seconds == 0 + && self.microseconds == 0 + { + "0:00:00".to_owned() + } else { + format!("{}:{:02}:{:02}", hours_abs, minutes, seconds) + }; + + if self.microseconds != 0 { + let microseconds_fmt = format!(".{:06}", super::safe_abs_u64(self.microseconds)); + time_str.push_str(µseconds_fmt); + } + + if has_negative { + format!("-{} {}", self.days.abs(), time_str) + } else { + format!("{} {}", self.days, time_str) + } + } else if is_day_time_only { + // Time only + let hours_abs = super::safe_abs_u64(self.hours); + let minutes = super::safe_abs_u64(self.minutes); + let seconds = super::safe_abs_u64(self.seconds); + let sign = if self.hours < 0 { "-" } else { "" }; + let mut result = format!("{}{}:{:02}:{:02}", sign, hours_abs, minutes, seconds); + + if self.microseconds != 0 { + let microseconds_fmt = format!(".{:06}", super::safe_abs_u64(self.microseconds)); + result.push_str(µseconds_fmt); + } + + result + } else { + "0".to_owned() + } } - interval } diff --git a/src/interval_parse/mod.rs b/src/interval_parse/mod.rs index 2cd3eb7..b4f5012 100644 --- a/src/interval_parse/mod.rs +++ b/src/interval_parse/mod.rs @@ -1,6 +1,7 @@ mod iso_8601; pub mod parse_error; mod postgres; +mod sql; static DAYS_PER_MONTH: i32 = 30; static MONTHS_PER_YEAR: i32 = 12; diff --git a/src/interval_parse/postgres.rs b/src/interval_parse/postgres.rs index ae41c83..9eae2db 100644 --- a/src/interval_parse/postgres.rs +++ b/src/interval_parse/postgres.rs @@ -7,9 +7,92 @@ use super::{ }; impl Interval { + pub fn from_postgres_verbose(verbose_str: &str) -> Result { + let mut is_negative = false; + let mut input = verbose_str.trim(); + + if input.starts_with('@') { + input = input[1..].trim_start(); + } else { + return Err(ParseError::from_invalid_interval( + "Verbose interval must start with '@'", + )); + } + + if input.ends_with("ago") { + is_negative = true; + input = input[..input.len() - 3].trim_end(); + } + + if input == "0" { + return Ok(Interval::new(0, 0, 0)); + } + + let mut delim = vec![ + "years", "year", "months", "mons", "mon", "days", "day", "hours", "hour", "minutes", + "minute", "seconds", "second", "mins", "min", "secs", "sec", + ]; + let mut time_tokens = input.split(' ').collect::>(); + time_tokens.retain(|&token| !token.is_empty()); + + let mut final_tokens = Vec::with_capacity(time_tokens.len()); + for token in time_tokens { + if is_token_time_format(token)? { + let (hours, minutes, seconds, microseconds) = parse_time_format(token)?; + if hours != 0 { + final_tokens.push(hours.to_string()); + final_tokens.push("hours".to_owned()); + } + if minutes != 0 { + final_tokens.push(minutes.to_string()); + final_tokens.push("minutes".to_owned()); + } + if seconds != 0 || microseconds != 0 { + let total_seconds = + seconds as f64 + microseconds as f64 / MICROS_PER_SECOND as f64; + final_tokens.push(total_seconds.to_string()); + final_tokens.push("seconds".to_owned()); + } + } else if is_token_alphanumeric(token)? { + let (val, unit) = split_token(token)?; + final_tokens.push(val); + final_tokens.push(unit); + } else { + final_tokens.push(token.to_owned()); + } + } + if final_tokens.len() % 2 != 0 { + return Err(ParseError::from_invalid_interval( + "Invalid amount tokens were found.", + )); + } + + let mut val = 0.0; + let mut is_numeric = true; + let mut interval = IntervalNorm::default(); + for token in final_tokens { + if is_numeric { + val = token.parse::()?; + is_numeric = false; + } else { + consume_token(&mut interval, val, token, &mut delim)?; + is_numeric = true; + } + } + let mut result = interval.try_into_interval()?; + + if is_negative { + result.months = -result.months; + result.days = -result.days; + result.microseconds = -result.microseconds; + } + Ok(result) + } + pub fn from_postgres(iso_str: &str) -> Result { let mut delim = vec![ - "years", "months", "mons", "days", "hours", "minutes", "seconds", + "years", "year", "months", "mons", "mon", "days", "day", "hours", "hour", "minutes", + "minute", "seconds", "second", ]; let mut time_tokens = iso_str.split(' ').collect::>(); // clean up empty values caused by n spaces between values. time_tokens.retain(|&token| !token.is_empty()); @@ -17,7 +100,23 @@ impl Interval { // value we need to scan each token. let mut final_tokens = Vec::with_capacity(time_tokens.len()); for token in time_tokens { - if is_token_alphanumeric(token)? { + if is_token_time_format(token)? { + let (hours, minutes, seconds, microseconds) = parse_time_format(token)?; + if hours != 0 { + final_tokens.push(hours.to_string()); + final_tokens.push("hours".to_owned()); + } + if minutes != 0 { + final_tokens.push(minutes.to_string()); + final_tokens.push("minutes".to_owned()); + } + if seconds != 0 || microseconds != 0 { + let total_seconds = + seconds as f64 + microseconds as f64 / MICROS_PER_SECOND as f64; + final_tokens.push(total_seconds.to_string()); + final_tokens.push("seconds".to_owned()); + } + } else if is_token_alphanumeric(token)? { let (val, unit) = split_token(token)?; final_tokens.push(val); final_tokens.push(unit); @@ -87,6 +186,86 @@ fn split_token(val: &str) -> Result<(String, String), ParseError> { Ok((value, delim)) } +/// Check if the token is a time format (e.g., "01:02:03" or "-01:02:03.123456") +fn is_token_time_format(val: &str) -> Result { + if !val.contains(':') { + return Ok(false); + } + + let parts: Vec<&str> = val.split(':').collect(); + if parts.len() != 3 { + return Err(ParseError::from_invalid_interval("Invalid time format.")); + } + + for (i, part) in parts.iter().enumerate() { + let is_first = i == 0; + for character in part.chars() { + if character.is_numeric() || (is_first && character == '-') { + // OK + } else if character == '.' && i == 2 { + // Fractional seconds only in the last part + continue; + } else { + return Err(ParseError::from_invalid_interval( + "Invalid character in time format.", + )); + } + } + } + + Ok(true) +} + +/// Parse a time format token (e.g., "01:02:03" or "-01:02:03.123456") +/// Returns (hours, minutes, seconds, microseconds) +fn parse_time_format(val: &str) -> Result<(i64, i64, i64, i64), ParseError> { + let is_negative = val.starts_with('-'); + let time_str = if is_negative { &val[1..] } else { val }; + + let parts: Vec<&str> = time_str.split(':').collect(); + if parts.len() != 3 { + return Err(ParseError::from_invalid_interval("Invalid time format.")); + } + + let hours: i64 = parts[0] + .parse() + .map_err(|_| ParseError::from_invalid_interval("Invalid hours value."))?; + + let minutes: i64 = parts[1] + .parse() + .map_err(|_| ParseError::from_invalid_interval("Invalid minutes value."))?; + + let seconds_str = parts[2]; + let (seconds, microseconds) = if let Some(dot_pos) = seconds_str.find('.') { + let whole_part = &seconds_str[..dot_pos]; + let fraction_part = &seconds_str[dot_pos + 1..]; + + let seconds: i64 = whole_part + .parse() + .map_err(|_| ParseError::from_invalid_interval("Invalid seconds value."))?; + + let microseconds: i64 = if !fraction_part.is_empty() { + let padded = format!("{:0<6}", fraction_part); + padded[..6].parse().unwrap_or(0) + } else { + 0 + }; + + (seconds, microseconds) + } else { + let seconds: i64 = seconds_str + .parse() + .map_err(|_| ParseError::from_invalid_interval("Invalid seconds value."))?; + (seconds, 0) + }; + + if is_negative { + Ok((-hours, -minutes, -seconds, -microseconds)) + } else { + Ok((hours, minutes, seconds, microseconds)) + } +} + /// Consume the token parts and add to the normalized interval. fn consume_token( interval: &mut IntervalNorm, @@ -99,46 +278,48 @@ fn consume_token( // the deliminator list. if delim_list.contains(&&*delim) { match &*delim { - "years" => { + "years" | "year" => { let (year, month) = scale_date(val, MONTHS_PER_YEAR); interval.years += year; interval.months += month; - delim_list.retain(|x| *x != "years"); + delim_list.retain(|x| *x != "years" && *x != "year"); Ok(()) } - "months" | "mons" => { + "months" | "mons" | "mon" => { let (month, day) = scale_date(val, DAYS_PER_MONTH); interval.months += month; interval.days += day; - delim_list.retain(|x| *x != "months" && *x != "mons"); + delim_list.retain(|x| *x != "months" && *x != "mons" && *x != "mon"); Ok(()) } - "days" => { + "days" | "day" => { let (days, hours) = scale_date(val, HOURS_PER_DAY); interval.days += days; interval.hours += hours as i64; - delim_list.retain(|x| *x != "days"); + delim_list.retain(|x| *x != "days" && *x != "day"); Ok(()) } - "hours" => { + "hours" | "hour" => { let (hours, minutes) = scale_time(val, MINUTES_PER_HOUR); interval.hours += hours; interval.minutes += minutes; - delim_list.retain(|x| *x != "hours"); + delim_list.retain(|x| *x != "hours" && *x != "hour"); Ok(()) } - "minutes" => { + "minutes" | "minute" | "mins" | "min" => { let (minutes, seconds) = scale_time(val, SECONDS_PER_MIN); interval.minutes += minutes; interval.seconds += seconds; - delim_list.retain(|x| *x != "minutes"); + delim_list + .retain(|x| *x != "minutes" && *x != "minute" && *x != "mins" && *x != "min"); Ok(()) } - "seconds" => { + "seconds" | "second" | "secs" | "sec" => { let (seconds, microseconds) = scale_time(val, MICROS_PER_SECOND); interval.seconds += seconds; interval.microseconds += microseconds; - delim_list.retain(|x| *x != "seconds"); + delim_list + .retain(|x| *x != "seconds" && *x != "second" && *x != "secs" && *x != "sec"); Ok(()) } _ => unreachable!(), @@ -366,4 +547,645 @@ mod tests { let interval = Interval::from_postgres("!"); assert!(interval.is_err()); } + + #[test] + fn test_roundtrip_issue_time_with_colons() { + let original = Interval::new(0, 0, 3661000000); + let postgres_str = original.to_postgres(); + let result = Interval::from_postgres(&postgres_str); + assert_eq!( + result.unwrap(), + original, + "Time format with colons should roundtrip successfully: {}", + postgres_str + ); + } + + #[test] + fn test_roundtrip_issue_12_plus_months() { + let original = Interval::new(12, 0, 0); + let postgres_str = original.to_postgres(); + let result = Interval::from_postgres(&postgres_str); + assert_eq!( + result.unwrap(), + original, + "Singular 'year' format should roundtrip successfully: {}", + postgres_str + ); + } + + #[test] + fn test_roundtrip_issue_single_day() { + let original = Interval::new(0, 1, 0); + let postgres_str = original.to_postgres(); + let result = Interval::from_postgres(&postgres_str); + assert_eq!( + result.unwrap(), + original, + "Singular 'day' format should roundtrip successfully: {}", + postgres_str + ); + } + + #[test] + fn test_roundtrip_issue_zero_interval() { + let original = Interval::new(0, 0, 0); + let postgres_str = original.to_postgres(); + let result = Interval::from_postgres(&postgres_str); + assert_eq!( + result.unwrap(), + original, + "Zero interval '00:00:00' should roundtrip successfully: {}", + postgres_str + ); + } + + #[test] + fn test_roundtrip_issue_microseconds() { + let original = Interval::new(0, 0, 1000000); + let postgres_str = original.to_postgres(); + let result = Interval::from_postgres(&postgres_str); + assert_eq!( + result.unwrap(), + original, + "Microseconds format with colons should roundtrip successfully: {}", + postgres_str + ); + } + + #[test] + fn test_to_postgres_matches_postgresql_12_months() { + let interval = Interval::new(12, 0, 0); + let lib_output = interval.to_postgres(); + assert_eq!(lib_output, "1 year"); + assert_eq!(lib_output, "1 year"); + } + + #[test] + fn test_to_postgres_matches_postgresql_13_months() { + let interval = Interval::new(13, 0, 0); + let lib_output = interval.to_postgres(); + assert_eq!(lib_output, "1 year 1 mon"); + } + + #[test] + fn test_to_postgres_matches_postgresql_1_day() { + let interval = Interval::new(0, 1, 0); + let lib_output = interval.to_postgres(); + assert_eq!(lib_output, "1 day"); + } + + #[test] + fn test_to_postgres_matches_postgresql_zero() { + let interval = Interval::new(0, 0, 0); + let lib_output = interval.to_postgres(); + assert_eq!(lib_output, "00:00:00"); + } + + #[test] + fn test_to_postgres_matches_postgresql_01_01_01() { + let interval = Interval::new(0, 0, 3661000000); + let lib_output = interval.to_postgres(); + assert_eq!(lib_output, "01:01:01"); + } + + #[test] + fn test_to_postgres_matches_postgresql_1_second() { + let interval = Interval::new(0, 0, 1000000); + let lib_output = interval.to_postgres(); + assert_eq!(lib_output, "00:00:01"); + } + + #[test] + fn test_postgresql_can_parse_singular_year() { + assert!( + Interval::from_postgres("1 year").is_ok(), + "Library can now parse '1 year' like PostgreSQL can" + ); + } + + #[test] + fn test_postgresql_can_parse_singular_mon() { + assert!( + Interval::from_postgres("1 mon").is_ok(), + "Library can now parse '1 mon' like PostgreSQL can" + ); + } + + #[test] + fn test_postgresql_can_parse_singular_day() { + assert!( + Interval::from_postgres("1 day").is_ok(), + "Library can now parse '1 day' like PostgreSQL can" + ); + } + + #[test] + fn test_postgresql_can_parse_time_format_00_00_00() { + assert!( + Interval::from_postgres("00:00:00").is_ok(), + "Library can now parse '00:00:00' time format like PostgreSQL can" + ); + } + + #[test] + fn test_postgresql_can_parse_time_format_01_01_01() { + assert!( + Interval::from_postgres("01:01:01").is_ok(), + "Library can now parse '01:01:01' time format like PostgreSQL can" + ); + } + + #[test] + fn test_parse_time_format_with_microseconds() { + let interval = Interval::from_postgres("01:01:01.123456").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 3661123456)); + } + + #[test] + fn test_parse_time_format_negative() { + let interval = Interval::from_postgres("-01:01:01").unwrap(); + assert_eq!(interval, Interval::new(0, 0, -3661000000)); + } + + #[test] + fn test_parse_singular_hour() { + let interval = Interval::from_postgres("1 hour").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 3600000000)); + } + + #[test] + fn test_parse_singular_minute() { + let interval = Interval::from_postgres("1 minute").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 60000000)); + } + + #[test] + fn test_parse_singular_second() { + let interval = Interval::from_postgres("1 second").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 1000000)); + } + + #[test] + fn test_roundtrip_complex_interval() { + let original = Interval::new(12, 1, 3661123456); + let postgres_str = original.to_postgres(); + let result = Interval::from_postgres(&postgres_str); + assert_eq!( + result.unwrap(), + original, + "Complex interval should roundtrip successfully: {}", + postgres_str + ); + } + + #[test] + fn test_roundtrip_all_formats() { + let test_cases = vec![ + ("1 year", Interval::new(12, 0, 0)), + ("1 mon", Interval::new(1, 0, 0)), + ("1 day", Interval::new(0, 1, 0)), + ("00:00:00", Interval::new(0, 0, 0)), + ("01:01:01", Interval::new(0, 0, 3661000000)), + ("01:01:01.123456", Interval::new(0, 0, 3661123456)), + ("1 hour", Interval::new(0, 0, 3600000000)), + ("1 minute", Interval::new(0, 0, 60000000)), + ("1 second", Interval::new(0, 0, 1000000)), + ]; + + for (input, expected) in test_cases { + let result = Interval::from_postgres(input).unwrap(); + assert_eq!(result, expected, "Failed to parse '{}'", input); + } + } + + #[test] + fn test_to_postgres_from_postgres_roundtrip() { + let intervals = vec![ + Interval::new(12, 0, 0), + Interval::new(1, 0, 0), + Interval::new(0, 1, 0), + Interval::new(0, 0, 0), + Interval::new(0, 0, 3661000000), + Interval::new(0, 0, 3661123456), + Interval::new(0, 0, 3600000000), + Interval::new(0, 0, 60000000), + Interval::new(0, 0, 1000000), + Interval::new(12, 1, 3661123456), + ]; + + for original in intervals { + let postgres_str = original.to_postgres(); + let result = Interval::from_postgres(&postgres_str).unwrap(); + assert_eq!( + result, original, + "Roundtrip failed for {:?} -> {} -> {:?}", + original, postgres_str, result + ); + } + } + + #[test] + fn test_parse_edge_case_mixed_singular_plural() { + let interval = Interval::from_postgres("1 year 2 mons").unwrap(); + assert_eq!(interval, Interval::new(14, 0, 0)); + } + + #[test] + fn test_parse_zero_values() { + assert_eq!( + Interval::from_postgres("0 year").unwrap(), + Interval::new(0, 0, 0) + ); + assert_eq!( + Interval::from_postgres("0 mon").unwrap(), + Interval::new(0, 0, 0) + ); + assert_eq!( + Interval::from_postgres("0 day").unwrap(), + Interval::new(0, 0, 0) + ); + } + + #[test] + fn test_parse_single_digit_time() { + let interval = Interval::from_postgres("1:00:00").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 3600000000)); + } + + #[test] + fn test_parse_fractional_values() { + assert_eq!( + Interval::from_postgres("1.5 years").unwrap(), + Interval::new(18, 0, 0) + ); + assert_eq!( + Interval::from_postgres("1.5 hours").unwrap(), + Interval::new(0, 0, 5400000000) + ); + assert_eq!( + Interval::from_postgres("1.5 seconds").unwrap(), + Interval::new(0, 0, 1500000) + ); + } + + #[test] + fn test_parse_all_units() { + let interval = Interval::from_postgres("1 days 2 hours 3 minutes 4.567 seconds").unwrap(); + assert_eq!(interval, Interval::new(0, 1, 7384567000)); + } + + #[test] + fn test_roundtrip_comprehensive() { + let test_cases = vec![ + Interval::new(18, 0, 0), + Interval::new(0, 0, 5400000000), + Interval::new(0, 0, 1500000), + Interval::new(0, 1, 7384567000), + Interval::new(13, -1, 0), + Interval::new(-13, 1, 0), + Interval::new(-12, 0, 0), + Interval::new(0, -1, 0), + Interval::new(0, 0, -3600000000), + ]; + + for original in test_cases { + let postgres_str = original.to_postgres(); + let result = Interval::from_postgres(&postgres_str).unwrap(); + assert_eq!( + result, original, + "Roundtrip failed for {:?} -> {} -> {:?}", + original, postgres_str, result + ); + } + } + + #[test] + fn test_negative_values_postgresql_format() { + assert_eq!(Interval::new(-12, 0, 0).to_postgres(), "-1 years"); + assert_eq!(Interval::new(0, -1, 0).to_postgres(), "-1 days"); + assert_eq!(Interval::new(0, 0, -3600000000).to_postgres(), "-01:00:00"); + + assert_eq!( + Interval::from_postgres("-1 years").unwrap(), + Interval::new(-12, 0, 0) + ); + assert_eq!( + Interval::from_postgres("-1 days").unwrap(), + Interval::new(0, -1, 0) + ); + assert_eq!( + Interval::from_postgres("-01:00:00").unwrap(), + Interval::new(0, 0, -3600000000) + ); + } + + #[test] + fn test_large_values() { + assert_eq!( + Interval::from_postgres("9999 years").unwrap(), + Interval::new(9999 * 12, 0, 0) + ); + assert_eq!( + Interval::from_postgres("10000 years").unwrap(), + Interval::new(10000 * 12, 0, 0) + ); + } + + #[test] + fn test_time_format_with_leading_zeros() { + assert_eq!( + Interval::from_postgres("01:00:00").unwrap(), + Interval::new(0, 0, 3600000000) + ); + assert_eq!( + Interval::from_postgres("00:01:00").unwrap(), + Interval::new(0, 0, 60000000) + ); + assert_eq!( + Interval::from_postgres("00:00:01").unwrap(), + Interval::new(0, 0, 1000000) + ); + } + + #[test] + fn test_from_postgres_verbose_zero() { + let interval = Interval::from_postgres_verbose("@ 0").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 0)); + } + + #[test] + fn test_from_postgres_verbose_1_year() { + let interval = Interval::from_postgres_verbose("@ 1 year").unwrap(); + assert_eq!(interval, Interval::new(12, 0, 0)); + } + + #[test] + fn test_from_postgres_verbose_1_year_2_mons() { + let interval = Interval::from_postgres_verbose("@ 1 year 2 mons").unwrap(); + assert_eq!(interval, Interval::new(14, 0, 0)); + } + + #[test] + fn test_from_postgres_verbose_1_day() { + let interval = Interval::from_postgres_verbose("@ 1 day").unwrap(); + assert_eq!(interval, Interval::new(0, 1, 0)); + } + + #[test] + fn test_from_postgres_verbose_1_hour() { + let interval = Interval::from_postgres_verbose("@ 1 hour").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 3600000000)); + } + + #[test] + fn test_from_postgres_verbose_1_min() { + let interval = Interval::from_postgres_verbose("@ 1 min").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 60000000)); + } + + #[test] + fn test_from_postgres_verbose_1_sec() { + let interval = Interval::from_postgres_verbose("@ 1 sec").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 1000000)); + } + + #[test] + fn test_from_postgres_verbose_complex() { + let interval = + Interval::from_postgres_verbose("@ 1 year 2 mons 1 day 2 hours 3 mins 4.567 secs") + .unwrap(); + assert_eq!(interval, Interval::new(14, 1, 7384567000)); + } + + #[test] + fn test_from_postgres_verbose_negative_year() { + let interval = Interval::from_postgres_verbose("@ 1 year ago").unwrap(); + assert_eq!(interval, Interval::new(-12, 0, 0)); + } + + #[test] + fn test_from_postgres_verbose_negative_complex() { + let interval = + Interval::from_postgres_verbose("@ 1 year 2 mons 1 day 2 hours 3 mins 4.567 secs ago") + .unwrap(); + assert_eq!(interval, Interval::new(-14, -1, -7384567000)); + } + + #[test] + fn test_from_postgres_verbose_2_years() { + let interval = Interval::from_postgres_verbose("@ 2 years").unwrap(); + assert_eq!(interval, Interval::new(24, 0, 0)); + } + + #[test] + fn test_from_postgres_verbose_2_mons() { + let interval = Interval::from_postgres_verbose("@ 2 mons").unwrap(); + assert_eq!(interval, Interval::new(2, 0, 0)); + } + + #[test] + fn test_from_postgres_verbose_2_days() { + let interval = Interval::from_postgres_verbose("@ 2 days").unwrap(); + assert_eq!(interval, Interval::new(0, 2, 0)); + } + + #[test] + fn test_from_postgres_verbose_2_hours() { + let interval = Interval::from_postgres_verbose("@ 2 hours").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 7200000000)); + } + + #[test] + fn test_from_postgres_verbose_2_mins() { + let interval = Interval::from_postgres_verbose("@ 2 mins").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 120000000)); + } + + #[test] + fn test_from_postgres_verbose_2_secs() { + let interval = Interval::from_postgres_verbose("@ 2 secs").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 2000000)); + } + + #[test] + fn test_from_postgres_verbose_microseconds() { + let interval = Interval::from_postgres_verbose("@ 5.678901 secs").unwrap(); + assert_eq!(interval, Interval::new(0, 0, 5678901)); + } + + #[test] + fn test_from_postgres_verbose_invalid_no_at() { + let interval = Interval::from_postgres_verbose("1 year"); + assert_eq!(interval.is_err(), true); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_zero() { + let original = Interval::new(0, 0, 0); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_1_year() { + let original = Interval::new(12, 0, 0); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_1_year_2_mons() { + let original = Interval::new(14, 0, 0); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_1_day() { + let original = Interval::new(0, 1, 0); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_1_hour() { + let original = Interval::new(0, 0, 3600000000); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_1_min() { + let original = Interval::new(0, 0, 60000000); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_1_sec() { + let original = Interval::new(0, 0, 1000000); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_complex() { + let original = Interval::new(14, 1, 7384567000); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_negative_year() { + let original = Interval::new(-12, 0, 0); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_negative_complex() { + let original = Interval::new(-14, -1, -7384567000); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_2_years() { + let original = Interval::new(24, 0, 0); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_2_mons() { + let original = Interval::new(2, 0, 0); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_2_days() { + let original = Interval::new(0, 2, 0); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_2_hours() { + let original = Interval::new(0, 0, 7200000000); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_2_mins() { + let original = Interval::new(0, 0, 120000000); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_2_secs() { + let original = Interval::new(0, 0, 2000000); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_microseconds() { + let original = Interval::new(0, 0, 5678901); + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str); + assert_eq!(result.unwrap(), original); + } + + #[test] + fn test_from_postgres_verbose_roundtrip_comprehensive() { + let test_cases = vec![ + Interval::new(0, 0, 0), + Interval::new(12, 0, 0), + Interval::new(14, 0, 0), + Interval::new(0, 1, 0), + Interval::new(0, 0, 3600000000), + Interval::new(0, 0, 60000000), + Interval::new(0, 0, 1000000), + Interval::new(14, 1, 7384567000), + Interval::new(-12, 0, 0), + Interval::new(-14, -1, -7384567000), + Interval::new(24, 0, 0), + Interval::new(2, 0, 0), + Interval::new(0, 2, 0), + Interval::new(0, 0, 7200000000), + Interval::new(0, 0, 120000000), + Interval::new(0, 0, 2000000), + Interval::new(0, 0, 5678901), + ]; + + for original in test_cases { + let verbose_str = original.to_postgres_verbose(); + let result = Interval::from_postgres_verbose(&verbose_str).unwrap(); + assert_eq!( + result, original, + "Roundtrip failed for {:?} -> {} -> {:?}", + original, verbose_str, result + ); + } + } } diff --git a/src/interval_parse/sql.rs b/src/interval_parse/sql.rs new file mode 100644 index 0000000..6e067d1 --- /dev/null +++ b/src/interval_parse/sql.rs @@ -0,0 +1,535 @@ +use super::parse_error::ParseError; +use crate::interval_norm::IntervalNorm; +use crate::Interval; + +impl Interval { + pub fn from_sql(sql_str: &str) -> Result { + if sql_str == "0" { + return Ok(Interval::new(0, 0, 0)); + } + + let tokens: Vec<&str> = sql_str.split_whitespace().collect(); + let mut interval_norm = IntervalNorm::default(); + + match tokens.len() { + 1 => { + let token = tokens[0]; + if token.contains(':') { + parse_time_part(token, &mut interval_norm, true)?; + } else if token.contains('-') { + parse_year_month_part(token, &mut interval_norm)?; + } else { + return Err(ParseError::from_invalid_interval( + "Invalid format: expected year-month or time format", + )); + } + } + 2 => { + parse_day_part(tokens[0], &mut interval_norm)?; + parse_time_part(tokens[1], &mut interval_norm, true)?; + } + 3 => { + // Mixed format: year-month + day + time + parse_year_month_part(tokens[0], &mut interval_norm)?; + parse_day_part(tokens[1], &mut interval_norm)?; + parse_time_part(tokens[2], &mut interval_norm, false)?; + } + _ => { + return Err(ParseError::from_invalid_interval( + "Invalid format: expected 1-3 tokens", + )); + } + } + + interval_norm.try_into_interval() + } +} + +fn parse_year_month_part(token: &str, interval: &mut IntervalNorm) -> Result<(), ParseError> { + let (sign, rest) = if let Some(stripped) = token.strip_prefix('-') { + (-1, stripped) + } else { + (1, token.trim_start_matches('+')) + }; + + let (years_str, months_str) = if let Some(pos) = rest.find('-') { + (&rest[..pos], &rest[pos + 1..]) + } else { + return Err(ParseError::from_invalid_interval( + "Invalid year-month format", + )); + }; + + let years = if years_str.is_empty() { + 0 + } else { + years_str.parse::()? * sign + }; + + let months: i32 = months_str.parse::()? * sign; + + interval.years = years; + interval.months = months; + Ok(()) +} + +fn parse_day_part(token: &str, interval: &mut IntervalNorm) -> Result<(), ParseError> { + // Handle optional leading sign + let (sign, days_str) = if let Some(rest) = token.strip_prefix('-') { + (-1, rest) + } else if let Some(rest) = token.strip_prefix('+') { + (1, rest) + } else { + (1, token) + }; + let days: i32 = days_str.parse()?; + interval.days = days * sign; + Ok(()) +} + +fn parse_time_part( + token: &str, + interval: &mut IntervalNorm, + is_only_time: bool, +) -> Result<(), ParseError> { + let (time_token, sign) = if is_only_time { + let is_negative = token.starts_with('-'); + let token_str = if is_negative { &token[1..] } else { token }; + (token_str, if is_negative { -1 } else { 1 }) + } else { + let is_negative = token.starts_with('-'); + let token_str = token.trim_start_matches('+').trim_start_matches('-'); + (token_str, if is_negative { -1 } else { 1 }) + }; + + let time_parts: Vec<&str> = time_token.split(':').collect(); + + if time_parts.len() < 2 || time_parts.len() > 3 { + return Err(ParseError::from_invalid_interval("Invalid time format")); + } + + let hours: i64 = time_parts[0].parse()?; + let minutes: i64 = time_parts[1].parse()?; + + let (seconds, microseconds) = if time_parts.len() == 3 { + parse_seconds_part(time_parts[2])? + } else { + (0, 0) + }; + + interval.hours = hours * sign; + interval.minutes = minutes * sign; + interval.seconds = seconds * sign; + interval.microseconds = microseconds * sign; + Ok(()) +} + +fn parse_seconds_part(token: &str) -> Result<(i64, i64), ParseError> { + if token.contains('.') { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 2 { + return Err(ParseError::from_invalid_interval("Invalid seconds format")); + } + let seconds: i64 = parts[0].parse()?; + let mut micros_str = parts[1].to_string(); + + if micros_str.len() > 6 { + return Err(ParseError::from_invalid_interval( + "Microseconds precision too high", + )); + } + + while micros_str.len() < 6 { + micros_str.push('0'); + } + + let microseconds: i64 = micros_str.parse()?; + Ok((seconds, microseconds)) + } else { + let seconds: i64 = token.parse()?; + Ok((seconds, 0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_sql_1() { + let interval = Interval::from_sql("1-0").unwrap(); + let interval_exp = Interval::new(12, 0, 0); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_2() { + let interval = Interval::from_sql("1-1").unwrap(); + let interval_exp = Interval::new(13, 0, 0); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_3() { + let interval = Interval::from_sql("+1-1 +1 +0:00:00").unwrap(); + let interval_exp = Interval::new(13, 1, 0); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_4() { + let interval = Interval::from_sql("+1-1 +1 +1:00:00").unwrap(); + let interval_exp = Interval::new(13, 1, 3600000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_5() { + let interval = Interval::from_sql("+1-1 +1 +1:10:00").unwrap(); + let interval_exp = Interval::new(13, 1, 4200000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_6() { + let interval = Interval::from_sql("+1-1 +1 +1:10:15").unwrap(); + let interval_exp = Interval::new(13, 1, 4215000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_7() { + let interval = Interval::from_sql("1:00:00").unwrap(); + let interval_exp = Interval::new(0, 0, 3600000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_8() { + let interval = Interval::from_sql("1:10:00").unwrap(); + let interval_exp = Interval::new(0, 0, 4200000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_9() { + let interval = Interval::from_sql("1:10:15").unwrap(); + let interval_exp = Interval::new(0, 0, 4215000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_10() { + let interval = Interval::from_sql("-1-0").unwrap(); + let interval_exp = Interval::new(-12, 0, 0); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_11() { + let interval = Interval::from_sql("-1-1").unwrap(); + let interval_exp = Interval::new(-13, 0, 0); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_12() { + let interval = Interval::from_sql("-1-1 -1 +0:00:00").unwrap(); + let interval_exp = Interval::new(-13, -1, 0); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_13() { + let interval = Interval::from_sql("-1-1 -1 -1:00:00").unwrap(); + let interval_exp = Interval::new(-13, -1, -3600000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_14() { + let interval = Interval::from_sql("-1-1 -1 -1:10:00").unwrap(); + let interval_exp = Interval::new(-13, -1, -4200000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_15() { + let interval = Interval::from_sql("-1-1 -1 -1:10:15").unwrap(); + let interval_exp = Interval::new(-13, -1, -4215000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_16() { + let interval = Interval::from_sql("-1:00:00").unwrap(); + let interval_exp = Interval::new(0, 0, -3600000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_17() { + let interval = Interval::from_sql("-1:10:00").unwrap(); + let interval_exp = Interval::new(0, 0, -4200000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_18() { + let interval = Interval::from_sql("-1:10:15").unwrap(); + let interval_exp = Interval::new(0, 0, -4215000000); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_19() { + let interval = Interval::from_sql("0").unwrap(); + let interval_exp = Interval::new(0, 0, 0); + assert_eq!(interval, interval_exp); + } + + #[test] + fn test_from_sql_20() { + let interval = Interval::from_sql("invalid"); + assert!(interval.is_err()); + } + + #[test] + fn test_roundtrip_sql_1() { + let original = Interval::new(12, 0, 0); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_2() { + let original = Interval::new(13, 0, 0); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_3() { + let original = Interval::new(13, 1, 0); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_4() { + let original = Interval::new(13, 1, 3600000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_5() { + let original = Interval::new(13, 1, 4200000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_6() { + let original = Interval::new(13, 1, 4215000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_7() { + let original = Interval::new(0, 0, 3600000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_8() { + let original = Interval::new(0, 0, 4200000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_9() { + let original = Interval::new(0, 0, 4215000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_10() { + let original = Interval::new(-12, 0, 0); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_11() { + let original = Interval::new(-13, 0, 0); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_12() { + let original = Interval::new(-13, -1, 0); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_13() { + let original = Interval::new(-13, -1, -3600000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_14() { + let original = Interval::new(-13, -1, -4200000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_15() { + let original = Interval::new(-13, -1, -4215000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_16() { + let original = Interval::new(0, 0, -3600000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_17() { + let original = Interval::new(0, 0, -4200000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_18() { + let original = Interval::new(0, 0, -4215000000); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_19() { + let original = Interval::new(0, 0, 0); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_sql_with_microseconds() { + let original = Interval::new(1, 2, 123456); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_postgres_compatibility_year_month_only_positive() { + let sql = "1-0"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(12, 0, 0)); + } + + #[test] + fn test_postgres_compatibility_year_month_only_negative() { + let sql = "-1-1"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(-13, 0, 0)); + } + + #[test] + fn test_postgres_compatibility_time_only_positive() { + let sql = "1:00:00"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(0, 0, 3600000000)); + } + + #[test] + fn test_postgres_compatibility_time_only_negative() { + let sql = "-1:10:15"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(0, 0, -4215000000)); + } + + #[test] + fn test_postgres_compatibility_full_interval_positive() { + let sql = "+1-1 +1 +1:10:15"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(13, 1, 4215000000)); + } + + #[test] + fn test_postgres_compatibility_full_interval_negative() { + let sql = "-1-1 -1 -1:10:15"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(-13, -1, -4215000000)); + } + + #[test] + fn test_postgres_compatibility_fractional_seconds() { + let sql = "1:10:15.123456"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(0, 0, 4215123456)); + } + + #[test] + fn test_postgres_compatibility_half_second() { + let sql = "1:00:00.5"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(0, 0, 3600500000)); + } + + #[test] + fn test_postgres_compatibility_full_fractional_interval() { + let sql = "+1-1 +1 +1:10:15.123456"; + let interval = Interval::from_sql(sql).unwrap(); + assert_eq!(interval, Interval::new(13, 1, 4215123456)); + } + + #[test] + fn test_roundtrip_fractional_seconds() { + let original = Interval::new(0, 0, 4215123456); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } + + #[test] + fn test_roundtrip_fractional_with_months_days() { + let original = Interval::new(13, 1, 4215123456); + let sql = original.to_sql(); + let parsed = Interval::from_sql(&sql).unwrap(); + assert_eq!(original, parsed); + } +} diff --git a/src/pg_interval.rs b/src/pg_interval.rs index 34b8a08..e92156b 100644 --- a/src/pg_interval.rs +++ b/src/pg_interval.rs @@ -27,6 +27,11 @@ impl Interval { IntervalNorm::from(self).into_postgres() } + /// Output the interval as a postgres_verbose interval string. + pub fn to_postgres_verbose(&self) -> String { + IntervalNorm::from(self).into_postgres_verbose() + } + ///Output the interval as a sql compliant interval string. pub fn to_sql(&self) -> String { IntervalNorm::from(self).into_sql() @@ -193,6 +198,34 @@ mod tests { assert_eq!(String::from("PT-1H-10M-15S"), output); } + #[test] + fn test_8601_19() { + let interval = Interval::new(0, 0, 1234567); + let output = interval.to_iso_8601(); + assert_eq!(String::from("PT1.234567S"), output); + } + + #[test] + fn test_8601_20() { + let interval = Interval::new(13, 1, 4215001000); + let output = interval.to_iso_8601(); + assert_eq!(String::from("P1Y1M1DT1H10M15.001000S"), output); + } + + #[test] + fn test_8601_21() { + let interval = Interval::new(0, 0, -1234567); + let output = interval.to_iso_8601(); + assert_eq!(String::from("PT-1.234567S"), output); + } + + #[test] + fn test_8601_22() { + let interval = Interval::new(0, 0, 500000); + let output = interval.to_iso_8601(); + assert_eq!(String::from("PT.500000S"), output); + } + #[test] fn test_postgres_1() { let interval = Interval::new(12, 0, 0); @@ -204,35 +237,35 @@ mod tests { fn test_postgres_2() { let interval = Interval::new(13, 0, 0); let output = interval.to_postgres(); - assert_eq!(String::from("1 year 1 mons"), output); + assert_eq!(String::from("1 year 1 mon"), output); } #[test] fn test_postgres_3() { let interval = Interval::new(13, 1, 0); let output = interval.to_postgres(); - assert_eq!(String::from("1 year 1 mons 1 days"), output); + assert_eq!(String::from("1 year 1 mon 1 day"), output); } #[test] fn test_postgres_4() { let interval = Interval::new(13, 1, 3600000000); let output = interval.to_postgres(); - assert_eq!(String::from("1 year 1 mons 1 days 01:00:00"), output); + assert_eq!(String::from("1 year 1 mon 1 day 01:00:00"), output); } #[test] fn test_postgres_5() { let interval = Interval::new(13, 1, 4200000000); let output = interval.to_postgres(); - assert_eq!(String::from("1 year 1 mons 1 days 01:10:00"), output); + assert_eq!(String::from("1 year 1 mon 1 day 01:10:00"), output); } #[test] fn test_postgres_6() { let interval = Interval::new(13, 1, 4215000000); let output = interval.to_postgres(); - assert_eq!(String::from("1 year 1 mons 1 days 01:10:15"), output); + assert_eq!(String::from("1 year 1 mon 1 day 01:10:15"), output); } #[test] @@ -260,35 +293,42 @@ mod tests { fn test_postgres_10() { let interval = Interval::new(-12, 0, 0); let output = interval.to_postgres(); - assert_eq!(String::from("-1 year"), output); + assert_eq!(String::from("-1 years"), output); } #[test] fn test_postgres_11() { let interval = Interval::new(-13, 0, 0); let output = interval.to_postgres(); - assert_eq!(String::from("-1 year -1 mons"), output); + assert_eq!(String::from("-1 years -1 mons"), output); } #[test] fn test_postgres_12() { let interval = Interval::new(-13, -1, 0); let output = interval.to_postgres(); - assert_eq!(String::from("-1 year -1 mons -1 days"), output); + assert_eq!(String::from("-1 years -1 mons -1 days"), output); } #[test] fn test_postgres_13() { let interval = Interval::new(-13, -1, -3600000000); let output = interval.to_postgres(); - assert_eq!(String::from("-1 year -1 mons -1 days -01:00:00"), output); + assert_eq!(String::from("-1 years -1 mons -1 days -01:00:00"), output); } #[test] fn test_postgres_14() { let interval = Interval::new(-13, -1, -4200000000); let output = interval.to_postgres(); - assert_eq!(String::from("-1 year -1 mons -1 days -01:10:00"), output); + assert_eq!(String::from("-1 years -1 mons -1 days -01:10:00"), output); + } + + #[test] + fn test_postgres_15() { + let interval = Interval::new(-13, -1, -4215000000); + let output = interval.to_postgres(); + assert_eq!(String::from("-1 years -1 mons -1 days -01:10:15"), output); } #[test] @@ -382,6 +422,34 @@ mod tests { assert_eq!(String::from("1:10:15"), output); } + #[test] + fn test_sql_leading_zero_hour_1() { + let interval = Interval::new(0, 0, 3661000000); + let output = interval.to_sql(); + assert_eq!(String::from("1:01:01"), output); + } + + #[test] + fn test_sql_leading_zero_hour_2() { + let interval = Interval::new(0, 0, 3600000000); + let output = interval.to_sql(); + assert_eq!(String::from("1:00:00"), output); + } + + #[test] + fn test_sql_leading_zero_hour_3() { + let interval = Interval::new(13, 1, 3661000000); + let output = interval.to_sql(); + assert_eq!(String::from("+1-1 +1 +1:01:01"), output); + } + + #[test] + fn test_sql_zero_interval() { + let interval = Interval::new(0, 0, 0); + let output = interval.to_sql(); + assert_eq!(String::from("0"), output); + } + #[test] fn test_sql_10() { let interval = Interval::new(-12, 0, 0); @@ -444,4 +512,129 @@ mod tests { let output = interval.to_sql(); assert_eq!(String::from("-1:10:15"), output); } + + #[test] + fn test_postgres_verbose_zero() { + let interval = Interval::new(0, 0, 0); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 0"), output); + } + + #[test] + fn test_postgres_verbose_1_year() { + let interval = Interval::new(12, 0, 0); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 1 year"), output); + } + + #[test] + fn test_postgres_verbose_1_year_2_mons() { + let interval = Interval::new(14, 0, 0); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 1 year 2 mons"), output); + } + + #[test] + fn test_postgres_verbose_1_day() { + let interval = Interval::new(0, 1, 0); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 1 day"), output); + } + + #[test] + fn test_postgres_verbose_1_hour() { + let interval = Interval::new(0, 0, 3600000000); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 1 hour"), output); + } + + #[test] + fn test_postgres_verbose_1_min() { + let interval = Interval::new(0, 0, 60000000); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 1 min"), output); + } + + #[test] + fn test_postgres_verbose_1_sec() { + let interval = Interval::new(0, 0, 1000000); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 1 sec"), output); + } + + #[test] + fn test_postgres_verbose_complex() { + let interval = Interval::new(14, 1, 7384567000); + let output = interval.to_postgres_verbose(); + assert_eq!( + String::from("@ 1 year 2 mons 1 day 2 hours 3 mins 4.567 secs"), + output + ); + } + + #[test] + fn test_postgres_verbose_negative_year() { + let interval = Interval::new(-12, 0, 0); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 1 year ago"), output); + } + + #[test] + fn test_postgres_verbose_negative_complex() { + let interval = Interval::new(-14, -1, -7384567000); + let output = interval.to_postgres_verbose(); + assert_eq!( + String::from("@ 1 year 2 mons 1 day 2 hours 3 mins 4.567 secs ago"), + output + ); + } + + #[test] + fn test_postgres_verbose_2_years() { + let interval = Interval::new(24, 0, 0); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 2 years"), output); + } + + #[test] + fn test_postgres_verbose_2_mons() { + let interval = Interval::new(2, 0, 0); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 2 mons"), output); + } + + #[test] + fn test_postgres_verbose_2_days() { + let interval = Interval::new(0, 2, 0); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 2 days"), output); + } + + #[test] + fn test_postgres_verbose_2_hours() { + let interval = Interval::new(0, 0, 7200000000); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 2 hours"), output); + } + + #[test] + fn test_postgres_verbose_2_mins() { + let interval = Interval::new(0, 0, 120000000); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 2 mins"), output); + } + + #[test] + fn test_postgres_verbose_2_secs() { + let interval = Interval::new(0, 0, 2000000); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 2 secs"), output); + } + + #[test] + fn test_postgres_verbose_microseconds() { + let interval = Interval::new(0, 0, 5678901); + let output = interval.to_postgres_verbose(); + assert_eq!(String::from("@ 5.678901 secs"), output); + } }