Skip to content

Commit b23b519

Browse files
committed
feat: implement date window for ascents
For now, the date window is a string displayed like ISO 8601 with intervals. It could make sense to make this a scalar of some kind to enforce the implementation. Some notes on implementation.. - the PostgreSQL `DATERANGE` type is queries as `::TEXT` since rust-postgres doesn't appear (or that I could find) to allow the user to specify the [format per returned column](https://www.postgresql.org/docs/9.3/protocol-overview.html#PROTOCOL-FORMAT-CODES).. and so the `FromSql` implementation must accept a `TEXT` type as well. - the `DATERANGE` is [always of the format `[)`](https://www.postgresql.org/docs/current/rangetypes.html#RANGETYPES-DISCRETE) (or `()` when the bottom is unbounded), so this makes parsing quite simple.
1 parent 44a9f41 commit b23b519

File tree

2 files changed

+137
-2
lines changed

2 files changed

+137
-2
lines changed

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ async-graphql = "7.0.7"
88
async-graphql-axum = "7.0.7"
99
axum = "0.7.5"
1010
bytes = "1.10.1"
11+
chrono = "0.4.40"
1112
config = "0.14.1"
1213
deadpool = "0.12.1"
1314
deadpool-postgres = { version = "0.14.0", features = ["serde"] }

src/schema/mod.rs

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
use std::fmt::Display;
1+
use std::{fmt::Display, ops::Bound, error::Error};
22

33
use async_graphql::{Context, Object, OneofObject, Result, SimpleObject, Union, ID};
44

55
use area::Area;
6+
use chrono::{Duration, NaiveDate};
67
use climb::Climb;
78
use formation::{Coordinate, Formation};
89
use grade::{Grade, GradeInput};
9-
use postgres_types::ToSql;
10+
use postgres_types::{accepts, FromSql, ToSql};
1011

1112
use crate::AppData;
1213

@@ -18,6 +19,107 @@ pub mod fontainebleau_grade;
1819
pub mod vermin_grade;
1920
pub mod yosemite_decimal_grade;
2021

22+
struct BoundedNaiveDateRange(pub Bound<NaiveDate>, pub Bound<NaiveDate>);
23+
24+
#[derive(Debug)]
25+
struct PgDateRangeParseError;
26+
27+
impl Display for PgDateRangeParseError {
28+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29+
write!(f, "Failed to parse PostgreSQL DATERANGE")
30+
}
31+
}
32+
33+
impl Error for PgDateRangeParseError { }
34+
35+
impl BoundedNaiveDateRange {
36+
fn from_pg_range_text(s: &str) -> std::result::Result<Self, PgDateRangeParseError> {
37+
let trimmed = s.trim();
38+
39+
if trimmed.len() < 3 || (!trimmed.starts_with(['[', '('])) || (!trimmed.ends_with([']', ')'])) {
40+
return Err(PgDateRangeParseError);
41+
}
42+
43+
let start_closed = trimmed.starts_with('[');
44+
let end_closed = trimmed.ends_with(']');
45+
46+
let inner = &trimmed[1..trimmed.len() - 1];
47+
let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();
48+
49+
let start_bound = match parts.first() {
50+
Some(date_str) if !date_str.is_empty() => {
51+
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
52+
.map_err(|_| PgDateRangeParseError)?;
53+
if start_closed {
54+
Bound::Included(date)
55+
} else {
56+
Bound::Excluded(date)
57+
}
58+
}
59+
_ => Bound::Unbounded,
60+
};
61+
62+
let end_bound = match parts.get(1).copied() {
63+
Some(date_str) if !date_str.is_empty() => {
64+
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
65+
.map_err(|_| PgDateRangeParseError)?;
66+
if end_closed {
67+
Bound::Included(date)
68+
} else {
69+
Bound::Excluded(date)
70+
}
71+
}
72+
_ => Bound::Unbounded,
73+
};
74+
75+
Ok(BoundedNaiveDateRange(start_bound, end_bound))
76+
}
77+
}
78+
79+
impl Display for BoundedNaiveDateRange {
80+
// format the string in a somewhat universal format (ISO 8601 with only needed extensions)
81+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82+
// TODO This implementation is naive, I can think of several simplifications off the top of
83+
// my head such as..
84+
// - [2024-03-01, 2024-03-02) --> 2024-03-02
85+
// - [2024-03-01, 2024-04-01) --> 2024-03
86+
// - [2024-01-01, 2025-01-01) --> 2024
87+
88+
// Formatting the start bound
89+
let start = match &self.0 {
90+
Bound::Included(date) => date.to_string(),
91+
Bound::Excluded(date) => (*date + Duration::days(1)).to_string(),
92+
Bound::Unbounded => String::from(".."),
93+
};
94+
95+
// Formatting the end bound
96+
let end = match &self.1 {
97+
Bound::Included(date) => date.to_string(),
98+
Bound::Excluded(date) => (*date - Duration::days(1)).to_string(),
99+
Bound::Unbounded => String::from(".."),
100+
};
101+
102+
// Combine the formatted start and end bounds, separating with a "/"
103+
write!(f, "{}/{}", start, end)
104+
}
105+
}
106+
107+
impl<'a> FromSql<'a> for BoundedNaiveDateRange {
108+
fn from_sql(ty: &postgres_types::Type, raw: &'a [u8]) -> std::result::Result<Self, Box<dyn std::error::Error + Sync + Send>> {
109+
match *ty {
110+
postgres_types::Type::DATE_RANGE => todo!(),
111+
postgres_types::Type::TEXT => {
112+
let text = std::str::from_utf8(raw)?;
113+
let parsed = BoundedNaiveDateRange::from_pg_range_text(text)?;
114+
Ok(parsed)
115+
},
116+
_ => Err(format!("Unsupported type: {:?}", ty).into()),
117+
}
118+
}
119+
120+
accepts!(DATE_RANGE, TEXT);
121+
}
122+
21123
pub struct QueryRoot;
22124

23125
impl Display for GradeInput {
@@ -251,6 +353,38 @@ impl Ascent {
251353
Ok(Climber(climber_id))
252354
}
253355

356+
async fn date_window<'a>(
357+
&self,
358+
ctx: &Context<'a>,
359+
) -> Result<Option<String>> { // ISO 8601 interval
360+
let data = ctx.data::<AppData>()?;
361+
let client = match &data.pg_pool {
362+
Some(pool) => pool.get().await?,
363+
None => {
364+
return Err("Database connection is not available".into());
365+
}
366+
};
367+
368+
let result = client
369+
// TODO sfackler/rust-postgres-range#18
370+
.query_one(
371+
"
372+
SELECT date_window::TEXT
373+
FROM ascents
374+
WHERE id = $1
375+
",
376+
&[&self.0],
377+
)
378+
.await?;
379+
380+
let value: Option<BoundedNaiveDateRange> = result.try_get(0)?;
381+
382+
match value {
383+
Some(range) => Ok(Some(format!("{}", range).to_string())),
384+
None => Ok(None),
385+
}
386+
}
387+
254388
async fn grades<'a>(
255389
&self,
256390
ctx: &Context<'a>,

0 commit comments

Comments
 (0)