From 0452211de4aa552b3347ba3971d744d6bacc505e Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 16:03:59 +0800 Subject: [PATCH 01/22] feat: implement Error for ParseError --- src/interval_parse/parse_error.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/interval_parse/parse_error.rs b/src/interval_parse/parse_error.rs index 0e46441..332ab16 100644 --- a/src/interval_parse/parse_error.rs +++ b/src/interval_parse/parse_error.rs @@ -1,3 +1,4 @@ +use std::fmt; use std::num::{ParseFloatError, ParseIntError}; #[derive(Debug, PartialEq, Eq)] @@ -35,6 +36,30 @@ impl From for ParseError { } } +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ParseError::ParseIntErr(e) => write!(f, "Failed to parse integer: {}", e), + ParseError::ParseFloatErr(e) => write!(f, "Failed to parse float: {}", e), + ParseError::InvalidYearMonth(s) => write!(f, "Invalid year/month interval: {}", s), + ParseError::InvalidTime(s) => write!(f, "Invalid time interval: {}", s), + ParseError::InvalidInterval(s) => write!(f, "Invalid interval: {}", s), + } + } +} + +impl std::error::Error for ParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ParseError::ParseIntErr(e) => Some(e), + ParseError::ParseFloatErr(e) => Some(e), + ParseError::InvalidYearMonth(_) + | ParseError::InvalidTime(_) + | ParseError::InvalidInterval(_) => None, + } + } +} + #[cfg(test)] mod tests { use super::ParseError; From 4f20ffbde01f56052f7cdcd259cd3afc0acb75fc Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 17:14:41 +0800 Subject: [PATCH 02/22] style: format code --- src/integrations/duration.rs | 33 +++++++++++++++---------------- src/integrations/mod.rs | 2 +- src/integrations/rust_postgres.rs | 2 +- src/interval_norm.rs | 2 +- src/interval_parse/iso_8601.rs | 5 ++--- src/interval_parse/postgres.rs | 5 ++--- src/pg_interval_add.rs | 1 - src/pg_interval_sub.rs | 1 - 8 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/integrations/duration.rs b/src/integrations/duration.rs index 2b03b13..1c6f6c8 100644 --- a/src/integrations/duration.rs +++ b/src/integrations/duration.rs @@ -5,22 +5,22 @@ const NANOS_PER_SEC: i64 = 1_000_000_000; const NANOS_PER_MICRO: i64 = 1000; impl Interval { - /// Tries to convert from the `Duration` type to a `Interval`. Will - /// return `None` on a overflow. This is a lossy conversion in that + /// Tries to convert from the `Duration` type to a `Interval`. Will + /// return `None` on a overflow. This is a lossy conversion in that /// any units smaller than a microsecond will be lost. pub fn from_duration(duration: Duration) -> Option { - let mut days = duration.num_days(); - let mut new_dur = duration - Duration::days(days); - let mut hours = duration.num_hours(); - new_dur = new_dur - Duration::hours(hours); - let minutes = new_dur.num_minutes(); - new_dur = new_dur - Duration::minutes(minutes); + let mut days = duration.num_days(); + let mut new_dur = duration - Duration::days(days); + let mut hours = duration.num_hours(); + new_dur = new_dur - Duration::hours(hours); + let minutes = new_dur.num_minutes(); + new_dur = new_dur - Duration::minutes(minutes); let nano_secs = new_dur.num_nanoseconds()?; if days > (i32::max_value() as i64) { let overflow_days = days - (i32::max_value() as i64); let added_hours = overflow_days.checked_mul(24)?; - hours = hours.checked_add(added_hours)?; - days -= overflow_days; + hours = hours.checked_add(added_hours)?; + days -= overflow_days; } let (seconds, remaining_nano) = reduce_by_units(nano_secs, NANOS_PER_SEC); // We have to discard any remaining nanoseconds @@ -39,9 +39,9 @@ impl Interval { } fn reduce_by_units(nano_secs: i64, unit: i64) -> (i64, i64) { - let new_time_unit = (nano_secs - (nano_secs % unit)) / unit; + let new_time_unit = (nano_secs - (nano_secs % unit)) / unit; let remaining_nano = nano_secs - (new_time_unit * unit); - (new_time_unit, remaining_nano) + (new_time_unit, remaining_nano) } #[cfg(test)] @@ -49,12 +49,11 @@ mod tests { use super::*; use chrono::Duration; - #[test] fn can_convert_small_amount_of_days() { let dur = Duration::days(5); let interval = Interval::from_duration(dur); - assert_eq!(interval, Some(Interval::new(0,5,0))) + assert_eq!(interval, Some(Interval::new(0, 5, 0))) } #[test] @@ -68,13 +67,13 @@ mod tests { fn can_convert_small_amount_of_secs() { let dur = Duration::seconds(1); let interval = Interval::from_duration(dur); - assert_eq!(interval, Some(Interval::new(0,0,1_000_000))) + assert_eq!(interval, Some(Interval::new(0, 0, 1_000_000))) } #[test] fn can_convert_one_micro() { let dur = Duration::nanoseconds(1000); let interval = Interval::from_duration(dur); - assert_eq!(interval, Some(Interval::new(0,0,1))) + assert_eq!(interval, Some(Interval::new(0, 0, 1))) } -} \ No newline at end of file +} diff --git a/src/integrations/mod.rs b/src/integrations/mod.rs index 9cebf41..1cdf2e3 100644 --- a/src/integrations/mod.rs +++ b/src/integrations/mod.rs @@ -1,2 +1,2 @@ +mod duration; mod rust_postgres; -mod duration; \ No newline at end of file diff --git a/src/integrations/rust_postgres.rs b/src/integrations/rust_postgres.rs index 9ad0bc5..a841a7c 100644 --- a/src/integrations/rust_postgres.rs +++ b/src/integrations/rust_postgres.rs @@ -1,7 +1,7 @@ use crate::Interval; use bytes::{Buf, BufMut, BytesMut}; -use std::error::Error; use postgres_types::{to_sql_checked, FromSql, IsNull, ToSql, Type}; +use std::error::Error; impl<'a> FromSql<'a> for Interval { fn from_sql(_: &Type, mut raw: &'a [u8]) -> Result> { diff --git a/src/interval_norm.rs b/src/interval_norm.rs index 0482c98..da9ba64 100644 --- a/src/interval_norm.rs +++ b/src/interval_norm.rs @@ -62,7 +62,7 @@ impl IntervalNorm { })?, days: self.days, microseconds: microseconds - .ok_or_else(|| ParseError::from_time("Invalid time interval overflow detected."))?, + .ok_or_else(|| ParseError::from_time("Invalid time interval overflow detected."))?, }) } diff --git a/src/interval_parse/iso_8601.rs b/src/interval_parse/iso_8601.rs index 76f15f6..74f3a0a 100644 --- a/src/interval_parse/iso_8601.rs +++ b/src/interval_parse/iso_8601.rs @@ -1,8 +1,8 @@ +use super::parse_error::ParseError; use super::{ scale_date, scale_time, DAYS_PER_MONTH, HOURS_PER_DAY, MICROS_PER_SECOND, MINUTES_PER_HOUR, MONTHS_PER_YEAR, SECONDS_PER_MIN, }; -use super::parse_error::ParseError; use crate::{interval_norm::IntervalNorm, Interval}; enum ParserCode { @@ -102,7 +102,7 @@ impl Interval { } fn consume_number<'a>(val: &'a char, number: &'a mut String, delim: &[char]) -> ParserCode { - let is_first_char = number.is_empty() && *val == '-'; + let is_first_char = number.is_empty() && *val == '-'; let is_period_char = !number.is_empty() && *val == '.'; if val.is_digit(10) || is_first_char || is_period_char { number.push(*val); @@ -299,5 +299,4 @@ mod tests { let interval_exp = Interval::new(0, 0, 10000000); assert_eq!(interval, interval_exp); } - } diff --git a/src/interval_parse/postgres.rs b/src/interval_parse/postgres.rs index ed25a33..6d4780c 100644 --- a/src/interval_parse/postgres.rs +++ b/src/interval_parse/postgres.rs @@ -7,11 +7,11 @@ use super::{ }; impl Interval { - pub fn from_postgres(iso_str: &str) -> Result { + pub fn from_postgres(iso_str: &str) -> Result { let mut delim = vec![ "years", "months", "mons", "days", "hours", "minutes", "seconds", ]; - let mut time_tokens = iso_str.split(' ').collect::>(); // clean up empty values caused by n spaces between values. + 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()); // since there might not be space between the delim and the // value we need to scan each token. @@ -366,5 +366,4 @@ mod tests { let interval = Interval::from_postgres("!"); assert_eq!(interval.is_err(), true); } - } diff --git a/src/pg_interval_add.rs b/src/pg_interval_add.rs index 2f1604f..dd301e2 100644 --- a/src/pg_interval_add.rs +++ b/src/pg_interval_add.rs @@ -150,5 +150,4 @@ mod tests { let result = interval.add_day_time(2, 0, 0, 2.123456789); assert_eq!(result, Interval::new(13, 2, 2123456)); } - } diff --git a/src/pg_interval_sub.rs b/src/pg_interval_sub.rs index 21ea052..516b04a 100644 --- a/src/pg_interval_sub.rs +++ b/src/pg_interval_sub.rs @@ -155,5 +155,4 @@ mod tests { let result = interval.checked_sub(interval_sub); assert_eq!(result, None); } - } From 6ea2eba3f28164c45c26eb478ec879286753a718 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 17:15:19 +0800 Subject: [PATCH 03/22] style: format --- src/pg_interval.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pg_interval.rs b/src/pg_interval.rs index 1bafae4..fc471ae 100644 --- a/src/pg_interval.rs +++ b/src/pg_interval.rs @@ -313,7 +313,7 @@ mod tests { } #[test] - fn test_postgres_19(){ + fn test_postgres_19() { let interval = Interval::new(0, 3, 0); let output = interval.to_postgres(); assert_eq!(String::from("3 days"), output); @@ -444,5 +444,4 @@ mod tests { let output = interval.to_sql(); assert_eq!(String::from("-1:10:15"), output); } - } From 3bfab3e7785eaeae1188482e03c4005acb13cd70 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 17:14:20 +0800 Subject: [PATCH 04/22] fix: pluralization for postgres style --- src/interval_fmt/postgres.rs | 40 +++++++++++++++++++++++++++++++++--- src/pg_interval.rs | 27 +++++++++++++++--------- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/interval_fmt/postgres.rs b/src/interval_fmt/postgres.rs index 7899a8c..ac92528 100644 --- a/src/interval_fmt/postgres.rs +++ b/src/interval_fmt/postgres.rs @@ -1,5 +1,35 @@ use crate::interval_norm::IntervalNorm; +fn get_year_suffix(value: i32) -> &'static str { + if value < 0 { + "years" + } else if value.abs() == 1 { + "year" + } else { + "years" + } +} + +fn get_mon_suffix(value: i32) -> &'static str { + if value < 0 { + "mons" + } else if value.abs() == 1 { + "mon" + } else { + "mons" + } +} + +fn get_day_suffix(value: i32) -> &'static str { + if value < 0 { + "days" + } else if value.abs() == 1 { + "day" + } else { + "days" + } +} + impl IntervalNorm { /// Produces a postgres compliant interval string. pub fn into_postgres(self) -> String { @@ -10,14 +40,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); diff --git a/src/pg_interval.rs b/src/pg_interval.rs index fc471ae..65b949c 100644 --- a/src/pg_interval.rs +++ b/src/pg_interval.rs @@ -204,35 +204,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 +260,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] From db10de024e460124309d5447e2e21bc592dda9e7 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 17:53:08 +0800 Subject: [PATCH 05/22] fix: several roundtrip cases --- src/interval_parse/postgres.rs | 485 ++++++++++++++++++++++++++++++++- 1 file changed, 471 insertions(+), 14 deletions(-) diff --git a/src/interval_parse/postgres.rs b/src/interval_parse/postgres.rs index 6d4780c..e772be9 100644 --- a/src/interval_parse/postgres.rs +++ b/src/interval_parse/postgres.rs @@ -9,7 +9,8 @@ use super::{ impl Interval { 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 +18,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 +104,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<'a>( interval: &mut IntervalNorm, @@ -99,46 +196,46 @@ fn consume_token<'a>( // 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" => { 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"); Ok(()) } - "seconds" => { + "seconds" | "second" => { 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"); Ok(()) } _ => unreachable!(), @@ -366,4 +463,364 @@ mod tests { let interval = Interval::from_postgres("!"); assert_eq!(interval.is_err(), true); } + + #[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) + ); + } } From 81359a98ff3d625c66c88f1d87fe51f19ff2f83d Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 19:24:51 +0800 Subject: [PATCH 06/22] feat: implement postgres_verbose --- src/interval_fmt/postgres.rs | 137 +++++++++++++++++++++++++++++++++++ src/pg_interval.rs | 130 +++++++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) diff --git a/src/interval_fmt/postgres.rs b/src/interval_fmt/postgres.rs index 7899a8c..dd8b4cf 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.abs() == 1 { + "year" + } else { + "years" + } +} + +fn get_mon_suffix(value: i32) -> &'static str { + if value.abs() == 1 { + "mon" + } else { + "mons" + } +} + +fn get_day_suffix(value: i32) -> &'static str { + if value.abs() == 1 { + "day" + } else { + "days" + } +} + +fn get_hour_suffix(value: i64) -> &'static str { + if value.abs() == 1 { + "hour" + } else { + "hours" + } +} + +fn get_min_suffix(value: i64) -> &'static str { + if value.abs() == 1 { + "min" + } else { + "mins" + } +} + +fn get_sec_suffix(value: i64) -> &'static str { + if value.abs() == 1 { + "sec" + } else { + "secs" + } +} + impl IntervalNorm { /// Produces a postgres compliant interval string. pub fn into_postgres(self) -> String { @@ -48,4 +96,93 @@ 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(self.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(self.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(self.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(self.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(self.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(self.seconds) + )); + } else { + parts.push(format!("{} {}", abs_seconds, get_sec_suffix(self.seconds))); + } + } + + 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/pg_interval.rs b/src/pg_interval.rs index fc471ae..6d7f12c 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() @@ -444,4 +449,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); + } } From c89c5337db3b93701f0ddec966b97092ccfba899 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 19:28:10 +0800 Subject: [PATCH 07/22] ci: add github actions --- .github/workflows/ci.yml | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3abe7e1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + 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-latest-arm + os: ubuntu + - target: aarch64-apple-darwin + runner: macos-latest + os: macos + runs-on: ${{ matrix.runner }} + + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + target: ${{ matrix.target }} + rustflags: "" + - name: run test + run: | + $CARGO_BIN test --target ${{ matrix.target }} --verbose + + # Check formatting with rustfmt + formatting: + name: cargo fmt + runs-on: ubuntu-latest + steps: + - 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 From 4843bb13b5423356369e79b26725ab2676134d39 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 19:29:51 +0800 Subject: [PATCH 08/22] ci: update ci --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3abe7e1..5f5eb96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: rustflags: "" - name: run test run: | - $CARGO_BIN test --target ${{ matrix.target }} --verbose + cargo test --verbose # Check formatting with rustfmt formatting: From eec5c4d36fef9d936d3405c088e067bb7b12059e Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 19:31:26 +0800 Subject: [PATCH 09/22] ci: update arm runner --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f5eb96..43d0c02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: runner: ubuntu-latest os: ubuntu - target: aarch64-unknown-linux-gnu - runner: ubuntu-latest-arm + runner: ubuntu-24.04-arm os: ubuntu - target: aarch64-apple-darwin runner: macos-latest From d3cba018e20c19df214d048d948279d288418661 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 19:46:48 +0800 Subject: [PATCH 10/22] feat: add parse_sql for interval --- src/interval_parse/mod.rs | 1 + src/interval_parse/sql.rs | 528 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 src/interval_parse/sql.rs 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/sql.rs b/src/interval_parse/sql.rs new file mode 100644 index 0000000..abf0510 --- /dev/null +++ b/src/interval_parse/sql.rs @@ -0,0 +1,528 @@ +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 => { + 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 token = token.trim_start_matches('+'); + + let (sign, rest) = if let Some(stripped) = token.strip_prefix('-') { + (-1, stripped) + } else { + (1, token) + }; + + 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> { + let days: i32 = token.parse()?; + interval.days = days; + 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); + } +} From 17ca77411c32115c39b772ae75dd192f45cc5001 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 21:55:37 +0800 Subject: [PATCH 11/22] fix: format --- src/interval_fmt/postgres.rs | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/interval_fmt/postgres.rs b/src/interval_fmt/postgres.rs index d2ac887..b6766c1 100644 --- a/src/interval_fmt/postgres.rs +++ b/src/interval_fmt/postgres.rs @@ -1,7 +1,7 @@ use crate::interval_norm::IntervalNorm; fn get_year_suffix(value: i32) -> &'static str { - if value.abs() == 1 { + if value == 1 { "year" } else { "years" @@ -9,7 +9,7 @@ fn get_year_suffix(value: i32) -> &'static str { } fn get_mon_suffix(value: i32) -> &'static str { - if value.abs() == 1 { + if value == 1 { "mon" } else { "mons" @@ -17,7 +17,7 @@ fn get_mon_suffix(value: i32) -> &'static str { } fn get_day_suffix(value: i32) -> &'static str { - if value.abs() == 1 { + if value == 1 { "day" } else { "days" @@ -25,7 +25,7 @@ fn get_day_suffix(value: i32) -> &'static str { } fn get_hour_suffix(value: i64) -> &'static str { - if value.abs() == 1 { + if value == 1 { "hour" } else { "hours" @@ -33,7 +33,7 @@ fn get_hour_suffix(value: i64) -> &'static str { } fn get_min_suffix(value: i64) -> &'static str { - if value.abs() == 1 { + if value == 1 { "min" } else { "mins" @@ -41,7 +41,7 @@ fn get_min_suffix(value: i64) -> &'static str { } fn get_sec_suffix(value: i64) -> &'static str { - if value.abs() == 1 { + if value == 1 { "sec" } else { "secs" @@ -120,7 +120,7 @@ impl IntervalNorm { } else { self.years }; - parts.push(format!("{} {}", abs_years, get_year_suffix(self.years))); + parts.push(format!("{} {}", abs_years, get_year_suffix(abs_years))); } if self.months != 0 { @@ -129,12 +129,12 @@ impl IntervalNorm { } else { self.months }; - parts.push(format!("{} {}", abs_months, get_mon_suffix(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(self.days))); + parts.push(format!("{} {}", abs_days, get_day_suffix(abs_days))); } if self.hours != 0 { @@ -143,7 +143,7 @@ impl IntervalNorm { } else { self.hours }; - parts.push(format!("{} {}", abs_hours, get_hour_suffix(self.hours))); + parts.push(format!("{} {}", abs_hours, get_hour_suffix(abs_hours))); } if self.minutes != 0 { @@ -152,7 +152,7 @@ impl IntervalNorm { } else { self.minutes }; - parts.push(format!("{} {}", abs_minutes, get_min_suffix(self.minutes))); + parts.push(format!("{} {}", abs_minutes, get_min_suffix(abs_minutes))); } if self.seconds != 0 || self.microseconds != 0 { @@ -171,10 +171,10 @@ impl IntervalNorm { parts.push(format!( "{} {}", secs_with_micros, - get_sec_suffix(self.seconds) + get_sec_suffix(abs_seconds) )); } else { - parts.push(format!("{} {}", abs_seconds, get_sec_suffix(self.seconds))); + parts.push(format!("{} {}", abs_seconds, get_sec_suffix(abs_seconds))); } } From 29f64365e656288c1233ee6995b151f8741fb05e Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Wed, 21 Jan 2026 22:23:07 +0800 Subject: [PATCH 12/22] feat: from_postgres_verbose --- src/interval_parse/postgres.rs | 373 ++++++++++++++++++++++++++++++++- 1 file changed, 369 insertions(+), 4 deletions(-) diff --git a/src/interval_parse/postgres.rs b/src/interval_parse/postgres.rs index e772be9..c11d6d1 100644 --- a/src/interval_parse/postgres.rs +++ b/src/interval_parse/postgres.rs @@ -7,6 +7,88 @@ 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", "year", "months", "mons", "mon", "days", "day", "hours", "hour", "minutes", @@ -224,18 +306,20 @@ fn consume_token<'a>( delim_list.retain(|x| *x != "hours" && *x != "hour"); Ok(()) } - "minutes" | "minute" => { + "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" && *x != "minute"); + delim_list + .retain(|x| *x != "minutes" && *x != "minute" && *x != "mins" && *x != "min"); Ok(()) } - "seconds" | "second" => { + "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" && *x != "second"); + delim_list + .retain(|x| *x != "seconds" && *x != "second" && *x != "secs" && *x != "sec"); Ok(()) } _ => unreachable!(), @@ -823,4 +907,285 @@ mod tests { 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 + ); + } + } } From bb2de2ac5e2ee0d663354310bb3ecb899baa760a Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Thu, 22 Jan 2026 10:29:49 +0800 Subject: [PATCH 13/22] fix: iso format --- src/interval_fmt/iso_8601.rs | 19 +++++++++++++------ src/pg_interval.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/interval_fmt/iso_8601.rs b/src/interval_fmt/iso_8601.rs index cfcb888..6c728a2 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/pg_interval.rs b/src/pg_interval.rs index c9f4faf..13231cf 100644 --- a/src/pg_interval.rs +++ b/src/pg_interval.rs @@ -198,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); From 0f8d14cf426dbaa609ec84a2de146ae0bfa6d2cb Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Thu, 22 Jan 2026 10:47:15 +0800 Subject: [PATCH 14/22] fix: sql standard format for leading zero --- src/interval_fmt/sql.rs | 9 ++++++-- src/pg_interval.rs | 49 +++++++++++++++++++++++++++++------------ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/interval_fmt/sql.rs b/src/interval_fmt/sql.rs index e6a7b51..4762a41 100644 --- a/src/interval_fmt/sql.rs +++ b/src/interval_fmt/sql.rs @@ -51,11 +51,16 @@ fn get_time_interval( ) -> String { let mut interval = "".to_owned(); if is_time_interval_pos && is_only_time { - interval.push_str(&format!("{}:{:02}:{:02}", hours, mins, secs)); + interval.push_str(&format!("{:02}:{:02}:{:02}", hours, mins, secs)); } else { + let sign = if hours < 0 { "-" } else { "+" }; + let hours_abs = super::safe_abs_u64(hours); let minutes = super::safe_abs_u64(mins); let seconds = super::safe_abs_u64(secs); - interval.push_str(&format!("{:+}:{:02}:{:02}", hours, minutes, seconds)); + interval.push_str(&format!( + "{}{:02}:{:02}:{:02}", + sign, hours_abs, minutes, seconds + )); } if micros != 0 { let microseconds = format!(".{:06}", super::safe_abs_u64(micros)); diff --git a/src/pg_interval.rs b/src/pg_interval.rs index 13231cf..9893a96 100644 --- a/src/pg_interval.rs +++ b/src/pg_interval.rs @@ -377,49 +377,70 @@ mod tests { fn test_sql_3() { let interval = Interval::new(13, 1, 0); let output = interval.to_sql(); - assert_eq!(String::from("+1-1 +1 +0:00:00"), output); + assert_eq!(String::from("+1-1 +1 +00:00:00"), output); } #[test] fn test_sql_4() { let interval = Interval::new(13, 1, 3600000000); let output = interval.to_sql(); - assert_eq!(String::from("+1-1 +1 +1:00:00"), output); + assert_eq!(String::from("+1-1 +1 +01:00:00"), output); } #[test] fn test_sql_5() { let interval = Interval::new(13, 1, 4200000000); let output = interval.to_sql(); - assert_eq!(String::from("+1-1 +1 +1:10:00"), output); + assert_eq!(String::from("+1-1 +1 +01:10:00"), output); } #[test] fn test_sql_6() { let interval = Interval::new(13, 1, 4215000000); let output = interval.to_sql(); - assert_eq!(String::from("+1-1 +1 +1:10:15"), output); + assert_eq!(String::from("+1-1 +1 +01:10:15"), output); } #[test] fn test_sql_7() { let interval = Interval::new(0, 0, 3600000000); let output = interval.to_sql(); - assert_eq!(String::from("1:00:00"), output); + assert_eq!(String::from("01:00:00"), output); } #[test] fn test_sql_8() { let interval = Interval::new(0, 0, 4200000000); let output = interval.to_sql(); - assert_eq!(String::from("1:10:00"), output); + assert_eq!(String::from("01:10:00"), output); } #[test] fn test_sql_9() { let interval = Interval::new(0, 0, 4215000000); let output = interval.to_sql(); - assert_eq!(String::from("1:10:15"), output); + assert_eq!(String::from("01: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("01: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("01: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 +01:01:01"), output); } #[test] @@ -440,49 +461,49 @@ mod tests { fn test_sql_12() { let interval = Interval::new(-13, -1, 0); let output = interval.to_sql(); - assert_eq!(String::from("-1-1 -1 +0:00:00"), output); + assert_eq!(String::from("-1-1 -1 +00:00:00"), output); } #[test] fn test_sql_13() { let interval = Interval::new(-13, -1, -3600000000); let output = interval.to_sql(); - assert_eq!(String::from("-1-1 -1 -1:00:00"), output); + assert_eq!(String::from("-1-1 -1 -01:00:00"), output); } #[test] fn test_sql_14() { let interval = Interval::new(-13, -1, -4200000000); let output = interval.to_sql(); - assert_eq!(String::from("-1-1 -1 -1:10:00"), output); + assert_eq!(String::from("-1-1 -1 -01:10:00"), output); } #[test] fn test_sql_15() { let interval = Interval::new(-13, -1, -4215000000); let output = interval.to_sql(); - assert_eq!(String::from("-1-1 -1 -1:10:15"), output); + assert_eq!(String::from("-1-1 -1 -01:10:15"), output); } #[test] fn test_sql_16() { let interval = Interval::new(0, 0, -3600000000); let output = interval.to_sql(); - assert_eq!(String::from("-1:00:00"), output); + assert_eq!(String::from("-01:00:00"), output); } #[test] fn test_sql_17() { let interval = Interval::new(0, 0, -4200000000); let output = interval.to_sql(); - assert_eq!(String::from("-1:10:00"), output); + assert_eq!(String::from("-01:10:00"), output); } #[test] fn test_sql_18() { let interval = Interval::new(0, 0, -4215000000); let output = interval.to_sql(); - assert_eq!(String::from("-1:10:15"), output); + assert_eq!(String::from("-01:10:15"), output); } #[test] From 2192fad251aca4a289f359f20aff51b6490c7049 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Thu, 22 Jan 2026 11:01:44 +0800 Subject: [PATCH 15/22] fix: sql 0 output --- src/interval_fmt/sql.rs | 2 +- src/pg_interval.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/interval_fmt/sql.rs b/src/interval_fmt/sql.rs index 4762a41..2e6cf9d 100644 --- a/src/interval_fmt/sql.rs +++ b/src/interval_fmt/sql.rs @@ -3,7 +3,7 @@ use crate::interval_norm::IntervalNorm; impl IntervalNorm { pub fn into_sql(self) -> String { if self.is_zeroed() { - "0".to_owned() + "00:00:00".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() { diff --git a/src/pg_interval.rs b/src/pg_interval.rs index 9893a96..719526b 100644 --- a/src/pg_interval.rs +++ b/src/pg_interval.rs @@ -443,6 +443,13 @@ mod tests { assert_eq!(String::from("+1-1 +1 +01: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("00:00:00"), output); + } + #[test] fn test_sql_10() { let interval = Interval::new(-12, 0, 0); From bd20f1cd4d80cb1a0122f499da0ecdb5eebd93c9 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Thu, 22 Jan 2026 11:14:24 +0800 Subject: [PATCH 16/22] fix: update pluar form of fractional sections --- src/interval_fmt/postgres.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/interval_fmt/postgres.rs b/src/interval_fmt/postgres.rs index b6766c1..62a6842 100644 --- a/src/interval_fmt/postgres.rs +++ b/src/interval_fmt/postgres.rs @@ -40,8 +40,8 @@ fn get_min_suffix(value: i64) -> &'static str { } } -fn get_sec_suffix(value: i64) -> &'static str { - if value == 1 { +fn get_sec_suffix(seconds: i64, microseconds: i64) -> &'static str { + if seconds == 1 && microseconds == 0 { "sec" } else { "secs" @@ -171,10 +171,14 @@ impl IntervalNorm { parts.push(format!( "{} {}", secs_with_micros, - get_sec_suffix(abs_seconds) + get_sec_suffix(abs_seconds, abs_micros) )); } else { - parts.push(format!("{} {}", abs_seconds, get_sec_suffix(abs_seconds))); + parts.push(format!( + "{} {}", + abs_seconds, + get_sec_suffix(abs_seconds, abs_micros) + )); } } From 6f582fbce5470adcdfe9024e1d1fd7020f40776a Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Thu, 22 Jan 2026 11:33:07 +0800 Subject: [PATCH 17/22] chore: fork --- Cargo.toml | 10 +++++----- README.md | 21 +++------------------ 2 files changed, 8 insertions(+), 23 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4900ee9..0b5e993 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "pg_interval" -version = "0.4.2" +name = "pg_interval_2" +version = "0.5.0" 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" +description = "A native PostgreSQL interval type, forked version" +repository = "https://github.com/sunng87/rust-postgres-interval" readme = "README.md" keywords = ["database", "postgres", "postgresql", "sql", "interval"] include = ["src/*", "Cargo.toml", "LICENSE", "README.md"] diff --git a/README.md b/README.md index f4b44c5..9606914 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ -[![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. +A interval type for the postgres driver. Forked version. + +Crate name on crates.io: `pg_interval_2`. # Contributing @@ -23,18 +23,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 From b8499f78bb831381a655ec91688f629a31dee256 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Thu, 22 Jan 2026 11:41:43 +0800 Subject: [PATCH 18/22] chore: Release pg_interval_2 version 0.5.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 0b5e993..4c4d8f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pg_interval_2" -version = "0.5.0" +version = "0.5.1" edition = "2018" authors = ["Ryan Piper ", "Ning Sun "] license = "MIT" From 016e12a3befc2bb67945089f55969e2fbc304dcf Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Fri, 23 Jan 2026 17:45:38 +0800 Subject: [PATCH 19/22] fix: correct to_sql format --- src/interval_fmt/sql.rs | 199 ++++++++++++++++++++++++++------------ src/interval_parse/sql.rs | 17 +++- src/pg_interval.rs | 36 +++---- 3 files changed, 168 insertions(+), 84 deletions(-) diff --git a/src/interval_fmt/sql.rs b/src/interval_fmt/sql.rs index 2e6cf9d..f2c2db0 100644 --- a/src/interval_fmt/sql.rs +++ b/src/interval_fmt/sql.rs @@ -2,69 +2,146 @@ use crate::interval_norm::IntervalNorm; impl IntervalNorm { pub fn into_sql(self) -> String { - if self.is_zeroed() { - "00:00:00".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}:{:02}", hours, mins, secs)); - } else { - let sign = if hours < 0 { "-" } else { "+" }; - let hours_abs = super::safe_abs_u64(hours); - let minutes = super::safe_abs_u64(mins); - let seconds = super::safe_abs_u64(secs); - interval.push_str(&format!( - "{}{:02}:{:02}:{:02}", - sign, hours_abs, 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/sql.rs b/src/interval_parse/sql.rs index abf0510..6e067d1 100644 --- a/src/interval_parse/sql.rs +++ b/src/interval_parse/sql.rs @@ -29,6 +29,7 @@ impl Interval { 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)?; @@ -45,12 +46,10 @@ impl Interval { } fn parse_year_month_part(token: &str, interval: &mut IntervalNorm) -> Result<(), ParseError> { - let token = token.trim_start_matches('+'); - let (sign, rest) = if let Some(stripped) = token.strip_prefix('-') { (-1, stripped) } else { - (1, token) + (1, token.trim_start_matches('+')) }; let (years_str, months_str) = if let Some(pos) = rest.find('-') { @@ -75,8 +74,16 @@ fn parse_year_month_part(token: &str, interval: &mut IntervalNorm) -> Result<(), } fn parse_day_part(token: &str, interval: &mut IntervalNorm) -> Result<(), ParseError> { - let days: i32 = token.parse()?; - interval.days = days; + // 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(()) } diff --git a/src/pg_interval.rs b/src/pg_interval.rs index 719526b..9071d1f 100644 --- a/src/pg_interval.rs +++ b/src/pg_interval.rs @@ -377,77 +377,77 @@ mod tests { fn test_sql_3() { let interval = Interval::new(13, 1, 0); let output = interval.to_sql(); - assert_eq!(String::from("+1-1 +1 +00:00:00"), output); + assert_eq!(String::from("+1-1 +1 +0:00:00"), output); } #[test] fn test_sql_4() { let interval = Interval::new(13, 1, 3600000000); let output = interval.to_sql(); - assert_eq!(String::from("+1-1 +1 +01:00:00"), output); + assert_eq!(String::from("+1-1 +1 +1:00:00"), output); } #[test] fn test_sql_5() { let interval = Interval::new(13, 1, 4200000000); let output = interval.to_sql(); - assert_eq!(String::from("+1-1 +1 +01:10:00"), output); + assert_eq!(String::from("+1-1 +1 +1:10:00"), output); } #[test] fn test_sql_6() { let interval = Interval::new(13, 1, 4215000000); let output = interval.to_sql(); - assert_eq!(String::from("+1-1 +1 +01:10:15"), output); + assert_eq!(String::from("+1-1 +1 +1:10:15"), output); } #[test] fn test_sql_7() { let interval = Interval::new(0, 0, 3600000000); let output = interval.to_sql(); - assert_eq!(String::from("01:00:00"), output); + assert_eq!(String::from("1:00:00"), output); } #[test] fn test_sql_8() { let interval = Interval::new(0, 0, 4200000000); let output = interval.to_sql(); - assert_eq!(String::from("01:10:00"), output); + assert_eq!(String::from("1:10:00"), output); } #[test] fn test_sql_9() { let interval = Interval::new(0, 0, 4215000000); let output = interval.to_sql(); - assert_eq!(String::from("01:10:15"), output); + 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("01:01:01"), output); + 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("01:00:00"), output); + 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 +01:01:01"), output); + 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("00:00:00"), output); + assert_eq!(String::from("0"), output); } #[test] @@ -468,49 +468,49 @@ mod tests { fn test_sql_12() { let interval = Interval::new(-13, -1, 0); let output = interval.to_sql(); - assert_eq!(String::from("-1-1 -1 +00:00:00"), output); + assert_eq!(String::from("-1-1 -1 +0:00:00"), output); } #[test] fn test_sql_13() { let interval = Interval::new(-13, -1, -3600000000); let output = interval.to_sql(); - assert_eq!(String::from("-1-1 -1 -01:00:00"), output); + assert_eq!(String::from("-1-1 -1 -1:00:00"), output); } #[test] fn test_sql_14() { let interval = Interval::new(-13, -1, -4200000000); let output = interval.to_sql(); - assert_eq!(String::from("-1-1 -1 -01:10:00"), output); + assert_eq!(String::from("-1-1 -1 -1:10:00"), output); } #[test] fn test_sql_15() { let interval = Interval::new(-13, -1, -4215000000); let output = interval.to_sql(); - assert_eq!(String::from("-1-1 -1 -01:10:15"), output); + assert_eq!(String::from("-1-1 -1 -1:10:15"), output); } #[test] fn test_sql_16() { let interval = Interval::new(0, 0, -3600000000); let output = interval.to_sql(); - assert_eq!(String::from("-01:00:00"), output); + assert_eq!(String::from("-1:00:00"), output); } #[test] fn test_sql_17() { let interval = Interval::new(0, 0, -4200000000); let output = interval.to_sql(); - assert_eq!(String::from("-01:10:00"), output); + assert_eq!(String::from("-1:10:00"), output); } #[test] fn test_sql_18() { let interval = Interval::new(0, 0, -4215000000); let output = interval.to_sql(); - assert_eq!(String::from("-01:10:15"), output); + assert_eq!(String::from("-1:10:15"), output); } #[test] From ee6917c77fa1e0a04c9013d13f1e16da74a63c92 Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Fri, 23 Jan 2026 20:06:38 +0800 Subject: [PATCH 20/22] chore: Release pg_interval_2 version 0.5.2 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 4c4d8f8..cbf7ce2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pg_interval_2" -version = "0.5.1" +version = "0.5.2" edition = "2018" authors = ["Ryan Piper ", "Ning Sun "] license = "MIT" From a1fb2775e54322c03e1d9844282b45511c5d809a Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Thu, 5 Feb 2026 12:05:23 +0800 Subject: [PATCH 21/22] Update README.md --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 9606914..5d6770b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,5 @@ # Rust-Postgres-Interval -A interval type for the postgres driver. Forked version. - -Crate name on crates.io: `pg_interval_2`. +A interval type for the postgres driver. # Contributing From 2f9996d24772faffb0e7214d23d6dee0fbbda96e Mon Sep 17 00:00:00 2001 From: Ning Sun Date: Thu, 5 Feb 2026 12:06:42 +0800 Subject: [PATCH 22/22] Update Cargo.toml --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fe10d32..5b1b082 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,8 @@ version = "0.4.3" edition = "2018" authors = ["Ryan Piper ", "Ning Sun "] license = "MIT" -description = "A native PostgreSQL interval type, forked version" -repository = "https://github.com/sunng87/rust-postgres-interval" +description = "A native PostgreSQL interval type" +repository = "https://github.com/piperRyan/rust-postgres-interval" readme = "README.md" keywords = ["database", "postgres", "postgresql", "sql", "interval"] include = ["src/*", "Cargo.toml", "LICENSE", "README.md"]