Skip to content

Commit 7766f21

Browse files
committed
Implement support for Postgresql interval syntax
1 parent 6f42706 commit 7766f21

File tree

5 files changed

+219
-25
lines changed

5 files changed

+219
-25
lines changed

.github/workflows/ci.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,11 @@ jobs:
2727
- uses: actions/checkout@v4
2828
- uses: dtolnay/rust-toolchain@stable
2929
- run: cargo clippy -- -D warnings
30+
31+
tests:
32+
name: Tests
33+
runs-on: ubuntu-latest
34+
steps:
35+
- uses: actions/checkout@v4
36+
- uses: dtolnay/rust-toolchain@stable
37+
- run: cargo test --all --locked

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ SELECT LEN("Git Query Language")
4141
SELECT "One" IN ("One", "Two", "Three")
4242
SELECT "Git Query Language" LIKE "%Query%"
4343
SELECT INTERVAL '1 year 2 mons 3 days 04:05:06.789'
44+
SELECT INTERVAL '1 millennium 2 centuries 1 decade ago'
4445

4546
SET @arr = [1, 2, 3];
4647
SELECT [[1, 2, 3], [4, 5, 6], [7, 8, 9]];

crates/gitql-ast/src/interval.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::ops::Mul;
77
const INTERVAL_MAX_VALUE_I: i64 = 170_000_000;
88
const INTERVAL_MAX_VALUE_F: f64 = 170_000_000.0;
99

10-
#[derive(Default, PartialEq, Clone)]
10+
#[derive(Default, Debug, PartialEq, Clone)]
1111
pub struct Interval {
1212
pub years: i64,
1313
pub months: i64,
@@ -109,9 +109,18 @@ impl Display for Interval {
109109
));
110110
}
111111

112-
let (hours, minutes, seconds) = (self.hours, self.minutes, self.seconds);
112+
let (mut hours, mut minutes, mut seconds) = (self.hours, self.minutes, self.seconds);
113113
if hours != 0 || minutes != 0 || seconds != 0f64 {
114-
parts.push(format!("{hours:02}:{minutes:02}:{seconds:02}"));
114+
let has_minus_sign =
115+
hours.is_negative() || minutes.is_negative() || seconds.is_sign_negative();
116+
if has_minus_sign {
117+
hours = hours.abs();
118+
minutes = minutes.abs();
119+
seconds = seconds.abs();
120+
parts.push(format!("-{hours:02}:{minutes:02}:{seconds:02}"));
121+
} else {
122+
parts.push(format!("{hours:02}:{minutes:02}:{seconds:02}"));
123+
}
115124
}
116125

117126
if parts.is_empty() {

crates/gitql-parser/src/parse_interval.rs

Lines changed: 182 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,38 +37,74 @@ pub(crate) fn parse_interval_expression(
3737
Ok(Box::new(IntervalExpr::new(interval)))
3838
}
3939

40-
/// Parse string intl Interval expression.
40+
/// Parse string into Interval expression.
41+
///
42+
/// [@] <quantity> <unit> [quantity unit...] <direction>
43+
///
44+
/// quantity: is a number (possibly signed)
45+
/// unit: is
46+
/// microsecond, millisecond, second,
47+
/// minute, hour, day, week, month, year,
48+
/// decade, century, millennium, or abbreviations or plurals of these units
49+
/// direction: can be ago or empty
50+
/// The at sign (@) is optional noise
51+
///
4152
/// Ref: https://www.postgresql.org/docs/current/datatype-datetime.html#DATATYPE-INTERVAL-INPUT
4253
fn parse_interval_literal(
4354
interval_str: &str,
4455
location: SourceLocation,
4556
) -> Result<Interval, Box<Diagnostic>> {
46-
if interval_str.is_empty() {
47-
return Err(Diagnostic::error("Invalid input syntax for type interval")
57+
let tokens = interval_str.split_whitespace().collect::<Vec<&str>>();
58+
if tokens.is_empty() {
59+
return Err(Diagnostic::error("Interval value can't be empty")
60+
.add_help("Please check the documentation for help")
4861
.with_location(location)
4962
.as_boxed());
5063
}
5164

5265
let mut position = 0;
5366
let mut interval = Interval::default();
54-
let tokens = interval_str.split_whitespace().collect::<Vec<&str>>();
5567

68+
// Date part
69+
let mut has_millenniums = false;
70+
let mut has_centuries = false;
71+
let mut has_decades = false;
5672
let mut has_years = false;
5773
let mut has_months = false;
74+
let mut has_week = false;
5875
let mut has_days = false;
76+
77+
// Time part
5978
let mut has_any_time_part = false;
6079
let mut has_hours: bool = false;
6180
let mut has_minutes = false;
6281
let mut has_seconds = false;
6382

83+
let mut has_direction_ago = false;
84+
6485
while position < tokens.len() {
6586
let token = tokens[position].trim();
6687
if token.is_empty() {
6788
position += 1;
6889
continue;
6990
}
7091

71-
// Parse Days, Months or Years
92+
if token == "ago" {
93+
if has_direction_ago {
94+
return Err(
95+
Diagnostic::error("Interval can't contains more than one `ago`")
96+
.add_help("Please keep at most only one `ago` direction")
97+
.with_location(location)
98+
.as_boxed(),
99+
);
100+
}
101+
102+
has_direction_ago = true;
103+
position += 1;
104+
continue;
105+
}
106+
107+
// Parse Millienniums, Centuries, Decades, Years, Weeks, Months and Days
72108
if let Ok(value) = token.parse::<i64>() {
73109
// Consume value
74110
position += 1;
@@ -84,32 +120,101 @@ fn parse_interval_literal(
84120
// Parse the unit
85121
let mut maybe_unit = tokens[position];
86122
let unit_lower = &maybe_unit.to_lowercase();
87-
maybe_unit = &unit_lower.as_str();
123+
maybe_unit = unit_lower.as_str();
124+
125+
if matches!(maybe_unit, "millennium" | "millenniums") {
126+
check_interval_value_and_unit(&mut has_millenniums, value, maybe_unit, &location)?;
127+
interval.years += value * 1000;
128+
position += 1;
129+
continue;
130+
}
131+
132+
if matches!(maybe_unit, "century" | "centuries") {
133+
check_interval_value_and_unit(&mut has_centuries, value, maybe_unit, &location)?;
134+
interval.years += value * 100;
135+
position += 1;
136+
continue;
137+
}
138+
139+
if matches!(maybe_unit, "decade" | "decades") {
140+
check_interval_value_and_unit(&mut has_decades, value, maybe_unit, &location)?;
141+
interval.years += value * 10;
142+
position += 1;
143+
continue;
144+
}
88145

89146
if matches!(maybe_unit, "y" | "year" | "years") {
90-
check_interval_value_and_unit(&mut has_years, value, maybe_unit, location)?;
91-
interval.years = value;
147+
check_interval_value_and_unit(&mut has_years, value, maybe_unit, &location)?;
148+
interval.years += value;
92149
position += 1;
93150
continue;
94151
}
95152

96153
if matches!(maybe_unit, "m" | "mon" | "mons" | "months") {
97-
check_interval_value_and_unit(&mut has_months, value, maybe_unit, location)?;
98-
interval.months = value;
154+
check_interval_value_and_unit(&mut has_months, value, maybe_unit, &location)?;
155+
interval.months += value;
156+
position += 1;
157+
continue;
158+
}
159+
160+
if matches!(maybe_unit, "w" | "week" | "weeks") {
161+
check_interval_value_and_unit(&mut has_week, value, maybe_unit, &location)?;
162+
interval.days += value * 7;
99163
position += 1;
100164
continue;
101165
}
102166

103167
if matches!(maybe_unit, "d" | "day" | "days") {
104-
check_interval_value_and_unit(&mut has_days, value, maybe_unit, location)?;
105-
interval.days = value;
168+
check_interval_value_and_unit(&mut has_days, value, maybe_unit, &location)?;
169+
interval.days += value;
106170
position += 1;
107171
continue;
108172
}
109173

110174
if matches!(maybe_unit, "h" | "hour" | "hours") {
111-
check_interval_value_and_unit(&mut has_any_time_part, value, maybe_unit, location)?;
112-
interval.hours = value;
175+
check_interval_value_and_unit(&mut has_hours, value, maybe_unit, &location)?;
176+
has_any_time_part = true;
177+
interval.hours += value;
178+
position += 1;
179+
continue;
180+
}
181+
182+
if matches!(maybe_unit, "minute" | "minutes") {
183+
check_interval_value_and_unit(&mut has_minutes, value, maybe_unit, &location)?;
184+
has_any_time_part = true;
185+
interval.minutes += value;
186+
position += 1;
187+
continue;
188+
}
189+
}
190+
191+
// Parse Seconds
192+
if let Ok(value) = token.parse::<f64>() {
193+
// Consume value
194+
position += 1;
195+
196+
if position >= tokens.len() {
197+
return Err(Diagnostic::error(&format!(
198+
"Missing interval unit after value {value}",
199+
))
200+
.with_location(location)
201+
.as_boxed());
202+
}
203+
204+
// Parse the unit
205+
let mut maybe_unit = tokens[position];
206+
let unit_lower = &maybe_unit.to_lowercase();
207+
maybe_unit = unit_lower.as_str();
208+
209+
if matches!(maybe_unit, "second" | "seconds") {
210+
check_interval_value_and_unit(
211+
&mut has_seconds,
212+
value as i64,
213+
maybe_unit,
214+
&location,
215+
)?;
216+
has_any_time_part = true;
217+
interval.seconds += value;
113218
position += 1;
114219
continue;
115220
}
@@ -124,7 +229,7 @@ fn parse_interval_literal(
124229
.as_boxed());
125230
}
126231

127-
// Parse Seconds, Minutes or Hours
232+
// Parse the optional time part without explicit unit markings (Seconds, Minutes or Hours)
128233
if token.contains(':') {
129234
if has_any_time_part {
130235
return Err(
@@ -135,15 +240,15 @@ fn parse_interval_literal(
135240
}
136241

137242
let time_parts: Vec<&str> = token.split(':').collect();
138-
if time_parts.len() != 3 && time_parts.len() != 2 {
243+
if !matches!(time_parts.len(), 2 | 3) {
139244
return Err(Diagnostic::error("Invalid input syntax for type interval")
140245
.with_location(location)
141246
.as_boxed());
142247
}
143248

144249
match time_parts[0].parse::<i64>() {
145250
Ok(hours) => {
146-
check_interval_value_and_unit(&mut has_hours, hours, time_parts[0], location)?;
251+
check_interval_value_and_unit(&mut has_hours, hours, time_parts[0], &location)?;
147252
interval.hours = hours;
148253
}
149254
Err(_) => {
@@ -159,7 +264,7 @@ fn parse_interval_literal(
159264
&mut has_minutes,
160265
minutes,
161266
time_parts[1],
162-
location,
267+
&location,
163268
)?;
164269
interval.minutes = minutes;
165270
}
@@ -177,7 +282,7 @@ fn parse_interval_literal(
177282
&mut has_seconds,
178283
seconds as i64,
179284
time_parts[2],
180-
location,
285+
&location,
181286
)?;
182287
interval.seconds = seconds;
183288
}
@@ -200,14 +305,19 @@ fn parse_interval_literal(
200305
.as_boxed());
201306
}
202307

308+
// ago directon negates all the fields
309+
if has_direction_ago {
310+
interval = interval.mul(-1).unwrap_or(interval);
311+
}
312+
203313
Ok(interval)
204314
}
205315

206316
fn check_interval_value_and_unit(
207317
is_used_twice: &mut bool,
208318
interval_value: i64,
209319
unit_name: &str,
210-
location: SourceLocation,
320+
location: &SourceLocation,
211321
) -> Result<(), Box<Diagnostic>> {
212322
if !*is_used_twice {
213323
*is_used_twice = true;
@@ -219,14 +329,14 @@ fn check_interval_value_and_unit(
219329
"Interval value for unit `{unit_name}` is out of the range",
220330
))
221331
.add_help("Interval value must be in range from -170_000_000 to 170_000_000")
222-
.with_location(location)
332+
.with_location(*location)
223333
.as_boxed());
224334
}
225335

226336
Err(Diagnostic::error(&format!(
227337
"Can't use the same interval unit `{unit_name}` twice",
228338
))
229-
.with_location(location)
339+
.with_location(*location)
230340
.as_boxed())
231341
}
232342

@@ -247,6 +357,56 @@ mod tests {
247357
}
248358
}
249359

360+
#[test]
361+
fn valid_weeks() {
362+
let inputs = [
363+
"2 w",
364+
"2 week",
365+
"2 weeks",
366+
"1 week 7 day",
367+
"1 w 7 d",
368+
"1 weeks 7 days",
369+
];
370+
371+
for input in inputs {
372+
let parse_result = parse_interval_literal(input, SourceLocation::default());
373+
assert!(parse_result.is_ok());
374+
375+
if let Ok(interval) = parse_result {
376+
assert_eq!(interval.days, 14);
377+
}
378+
}
379+
}
380+
381+
#[test]
382+
fn valid_seconds() {
383+
let inputs = ["10.1 second"];
384+
385+
for input in inputs {
386+
let parse_result = parse_interval_literal(input, SourceLocation::default());
387+
assert!(parse_result.is_ok());
388+
389+
if let Ok(interval) = parse_result {
390+
assert_eq!(interval.seconds, 10.1);
391+
}
392+
}
393+
}
394+
395+
#[test]
396+
fn ago_direction() {
397+
let parse_result = parse_interval_literal("1 y 1 m 1 w 1 d", SourceLocation::default());
398+
assert!(parse_result.is_ok());
399+
400+
let parse_result_with_ago =
401+
parse_interval_literal("1 y 1 m 1 w 1 d ago", SourceLocation::default());
402+
assert!(parse_result_with_ago.is_ok());
403+
404+
assert_eq!(
405+
parse_result.ok().unwrap().mul(-1).unwrap(),
406+
parse_result_with_ago.ok().unwrap()
407+
);
408+
}
409+
250410
#[test]
251411
fn invalid_time() {
252412
let inputs = ["1 h 1:00:00", "1 h 1 h"];

0 commit comments

Comments
 (0)