Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ async-graphql = "7.0.7"
async-graphql-axum = "7.0.7"
axum = "0.7.5"
bytes = "1.10.1"
chrono = "0.4.40"
config = "0.14.1"
deadpool = "0.12.1"
deadpool-postgres = { version = "0.14.0", features = ["serde"] }
Expand Down
66 changes: 6 additions & 60 deletions src/schema/climb.rs
Original file line number Diff line number Diff line change
@@ -1,71 +1,17 @@
use std::str::FromStr;

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

use crate::schema::area::Area;
use crate::schema::formation::Formation;
use crate::schema::fontainebleau_grade::FontainebleauGrade;
use crate::schema::vermin_grade::VerminGrade;
use crate::schema::yosemite_decimal_grade::YosemiteDecimalGrade;
use crate::AppData;

use super::grade::Grade;

#[derive(Union)]
enum ClimbParent {
Area(Area),
Formation(Formation),
}

#[derive(SimpleObject)]
struct ClimbFontainebleauGrade {
pub value: FontainebleauGrade,
}

#[derive(SimpleObject)]
struct ClimbYosemiteDecimalGrade {
pub value: YosemiteDecimalGrade,
}

#[derive(SimpleObject)]
struct ClimbVerminGrade {
pub value: VerminGrade,
}

#[derive(Union)]
enum ClimbGrade {
Vermin(ClimbVerminGrade),
Fontainebleau(ClimbFontainebleauGrade),
YosemiteDecimal(ClimbYosemiteDecimalGrade),
}

#[derive(Debug, PartialEq, Eq)]
struct ParseClimbGradeError;

impl FromStr for ClimbGrade {
type Err = ParseClimbGradeError;

fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
VerminGrade::from_str(s)
.map(|v| {
ClimbGrade::Vermin(ClimbVerminGrade { value: v })
})
.or_else(|_| {
FontainebleauGrade::from_str(s)
.map(|f| {
ClimbGrade::Fontainebleau(ClimbFontainebleauGrade { value: f })
})
})
.or_else(|_| {
YosemiteDecimalGrade::from_str(s)
.map(|y| {
ClimbGrade::YosemiteDecimal(ClimbYosemiteDecimalGrade { value: y })
})
})
.map_err(|_| {
ParseClimbGradeError
})
}
}

pub struct Climb(pub i32);

#[Object]
Expand Down Expand Up @@ -108,7 +54,7 @@ impl Climb {
Ok(value.map(|description| description.to_string()))
}

async fn grades<'a>(&self, ctx: &Context<'a>) -> Result<Vec<ClimbGrade>> {
async fn grades<'a>(&self, ctx: &Context<'a>) -> Result<Vec<Grade>> {
let data = ctx.data::<AppData>()?;
let client = match &data.pg_pool {
Some(pool) => pool.get().await?,
Expand All @@ -117,7 +63,7 @@ impl Climb {
}
};

let value: Vec<ClimbGrade> = client
let value: Vec<Grade> = client
// TODO since grade doesn't implement binary functions, we must request the text
// format, when grade _does_ implement these, the ::TEXT is not needed
.query(
Expand All @@ -132,7 +78,7 @@ impl Climb {
.into_iter()
.filter_map(|row| {
let grade_str: String = row.get(0);
match grade_str.parse::<ClimbGrade>() {
match grade_str.parse::<Grade>() {
Ok(grade) => Some(grade),
Err(_) => None,
}
Expand Down
266 changes: 266 additions & 0 deletions src/schema/date_interval.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
use std::{error::Error, fmt::Display, ops::Bound, str::FromStr};

use async_graphql::{InputValueError, InputValueResult, Scalar, ScalarType, Value};
use chrono::{Duration, NaiveDate};
use postgres_types::{accepts, FromSql, ToSql};

#[derive(Debug, PartialEq)]
pub struct DateInterval(pub Bound<NaiveDate>, pub Bound<NaiveDate>);

impl Display for DateInterval {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// TODO This implementation is naive, I can think of several simplifications off the top of
// my head such as..
// - [2024-03-01, 2024-03-02) --> 2024-03-02
// - [2024-03-01, 2024-04-01) --> 2024-03
// - [2024-01-01, 2025-01-01) --> 2024

let start = match &self.0 {
Bound::Included(date) => date.to_string(),
Bound::Excluded(date) => (*date + Duration::days(1)).to_string(),
Bound::Unbounded => String::from(".."),
};

let end = match &self.1 {
Bound::Included(date) => date.to_string(),
Bound::Excluded(date) => (*date - Duration::days(1)).to_string(),
Bound::Unbounded => String::from(".."),
};

write!(f, "{}/{}", start, end)
}
}

#[derive(Debug)]
pub struct ParseDateIntervalError;

impl Display for ParseDateIntervalError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Failed to parse PostgreSQL DATERANGE")
}
}

impl Error for ParseDateIntervalError { }


impl FromStr for DateInterval {
type Err = ParseDateIntervalError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('/').collect();
if parts.len() != 2 {
return Err(ParseDateIntervalError);
}

let parse_bound = |s: &str| -> Result<Bound<NaiveDate>, ParseDateIntervalError> {
if s == ".." {
Ok(Bound::Unbounded)
} else {
NaiveDate::from_str(s)
.map(Bound::Included)
.map_err(|_| ParseDateIntervalError)
}
};

let start = parse_bound(parts[0])?;
let end = parse_bound(parts[1])?;

Ok(DateInterval(start, end))
}
}

impl<'a> FromSql<'a> for DateInterval {
fn from_sql(ty: &postgres_types::Type, raw: &'a [u8]) -> std::result::Result<Self, Box<dyn std::error::Error + Sync + Send>> {
match *ty {
postgres_types::Type::DATE_RANGE => todo!(),
postgres_types::Type::TEXT => {
let text = std::str::from_utf8(raw)?;
let parsed = DateInterval::from_pg_range_text(text)?;
Ok(parsed)
},
_ => Err(format!("Unsupported type: {:?}", ty).into()),
}
}

accepts!(DATE_RANGE, TEXT);
}

impl ToSql for DateInterval {
fn to_sql(&self, _: &postgres_types::Type, out: &mut bytes::BytesMut) -> Result<postgres_types::IsNull, Box<dyn Error + Sync + Send>>
where
Self: Sized {
let encoded = self.to_pg_range_text();
out.extend_from_slice(encoded.as_bytes());
Ok(postgres_types::IsNull::No)
}

accepts!(DATE_RANGE);

fn to_sql_checked(
&self,
ty: &postgres_types::Type,
out: &mut bytes::BytesMut,
) -> Result<postgres_types::IsNull, Box<dyn Error + Sync + Send>> {
if !<Self as ToSql>::accepts(ty) {
return Err("Unsupported PostgreSQL type".into());
}
self.to_sql(ty, out)
}

fn encode_format(&self, _ty: &postgres_types::Type) -> postgres_types::Format {
postgres_types::Format::Text
}
}

impl DateInterval {
fn from_pg_range_text(s: &str) -> std::result::Result<Self, ParseDateIntervalError> {
let trimmed = s.trim();

if trimmed.len() < 3 || (!trimmed.starts_with(['[', '('])) || (!trimmed.ends_with([']', ')'])) {
return Err(ParseDateIntervalError);
}

let start_closed = trimmed.starts_with('[');
let end_closed = trimmed.ends_with(']');

let inner = &trimmed[1..trimmed.len() - 1];
let parts: Vec<&str> = inner.split(',').map(|s| s.trim()).collect();

let start_bound = match parts.first() {
Some(date_str) if !date_str.is_empty() => {
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|_| ParseDateIntervalError)?;
if start_closed {
Bound::Included(date)
} else {
Bound::Excluded(date)
}
}
_ => Bound::Unbounded,
};

let end_bound = match parts.get(1).copied() {
Some(date_str) if !date_str.is_empty() => {
let date = NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|_| ParseDateIntervalError)?;
if end_closed {
Bound::Included(date)
} else {
Bound::Excluded(date)
}
}
_ => Bound::Unbounded,
};

Ok(DateInterval(start_bound, end_bound))
}

fn to_pg_range_text(&self) -> String {
let start_bound = match &self.0 {
Bound::Included(date) => format!("[{}", date.format("%Y-%m-%d")),
Bound::Excluded(date) => format!("({}", date.format("%Y-%m-%d")),
Bound::Unbounded => "(".to_string(),
};

let end_bound = match &self.1 {
Bound::Included(date) => format!(",{:}]", date.format("%Y-%m-%d")),
Bound::Excluded(date) => format!(",{:})", date.format("%Y-%m-%d")),
Bound::Unbounded => ",)".to_string(),
};

format!("{}{}", start_bound, end_bound)
}
}

#[Scalar]
impl ScalarType for DateInterval {
fn parse(value: Value) -> InputValueResult<Self> {
if let Value::String(s) = &value {
s.parse::<Self>().map_err(|_| InputValueError::custom("Invalid format"))
} else {
Err(InputValueError::custom("Expected a string"))
}
}

fn to_value(&self) -> Value {
Value::String(self.to_string())
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn fmt() {
assert_eq!(
format!("{}", DateInterval(Bound::Unbounded, Bound::Unbounded)),
"../.."
);

assert_eq!(
format!(
"{}",
DateInterval(
Bound::Included(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
Bound::Unbounded
)
),
"2024-03-01/.."
);

assert_eq!(
format!(
"{}",
DateInterval(
Bound::Unbounded,
Bound::Included(NaiveDate::from_ymd_opt(2024, 3, 31).unwrap())
)
),
"../2024-03-31"
);

assert_eq!(
format!(
"{}",
DateInterval(
Bound::Included(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
Bound::Included(NaiveDate::from_ymd_opt(2024, 3, 31).unwrap())
)
),
"2024-03-01/2024-03-31"
);
}

#[test]
fn from_str() {
assert_eq!(
DateInterval(Bound::Unbounded, Bound::Unbounded),
"../..".to_string().parse::<DateInterval>().expect("Is valid")
);

assert_eq!(
DateInterval(
Bound::Included(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
Bound::Unbounded
),
"2024-03-01/..".to_string().parse::<DateInterval>().expect("Is valid")
);

assert_eq!(
DateInterval(
Bound::Unbounded,
Bound::Included(NaiveDate::from_ymd_opt(2024, 3, 31).unwrap())
),
"../2024-03-31".to_string().parse::<DateInterval>().expect("Is valid")
);

assert_eq!(
DateInterval(
Bound::Included(NaiveDate::from_ymd_opt(2024, 3, 1).unwrap()),
Bound::Included(NaiveDate::from_ymd_opt(2024, 3, 31).unwrap())
),
"2024-03-01/2024-03-31".to_string().parse::<DateInterval>().expect("Is valid")
);
}
}
Loading