Skip to main content

ogc_cql2/
bound.rs

1// SPDX-License-Identifier: Apache-2.0
2
3#![warn(missing_docs)]
4
5//! In OGC CQL2 a temporal _Bound_ is a value that is either bounded or not used
6//! either on its own as an _Instant_ or a _limit_ in an _Interval_.
7//!
8
9use crate::{MyError, Q};
10use core::fmt;
11use jiff::{Timestamp, Zoned, civil::Date, tz::TimeZone};
12use std::{cmp::Ordering, mem};
13
14/// Possible variants of a CQL2 _Instant_ and _Interval_ limit.
15#[derive(Debug, Clone)]
16pub enum Bound {
17    /// Unbounded temporal value used as lower, upper, or both limit(s);
18    /// represented by the string `'..'` .
19    None,
20    /// Instant with a 1-day granularity, in UTC time-zone.
21    Date(Zoned),
22    /// Instant with a 1-second or less granularity in UTC time-zone.
23    Timestamp(Zoned),
24}
25
26impl PartialEq for Bound {
27    fn eq(&self, other: &Self) -> bool {
28        match (self, other) {
29            (Bound::Date(x), Bound::Date(y))
30            | (Bound::Date(x), Bound::Timestamp(y))
31            | (Bound::Timestamp(x), Bound::Date(y))
32            | (Bound::Timestamp(x), Bound::Timestamp(y)) => x == y,
33            _ => mem::discriminant(self) == mem::discriminant(other),
34        }
35    }
36}
37
38impl Eq for Bound {}
39
40impl PartialOrd for Bound {
41    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
42        match (self, other) {
43            // if both are unbounded, the result is always equal.
44            (Bound::None, Bound::None) => Some(Ordering::Equal),
45            // if the LHS is unbounded and the RHS is not then the result is...
46            (Bound::None, _) => Some(Ordering::Less),
47            // and the opposite is true if it's the other way around...
48            (_, Bound::None) => Some(Ordering::Greater),
49            // if they're both bounded instants of the same type...
50            (Bound::Date(z1), Bound::Date(z2)) | (Bound::Timestamp(z1), Bound::Timestamp(z2)) => {
51                z1.partial_cmp(z2)
52            }
53            // IMPORTANT (rsn) 202511-19 - just make sure they're date/time based;
54            // otherwise we may run into stack overflow
55            (Bound::Date(z1), Bound::Timestamp(z2)) | (Bound::Timestamp(z1), Bound::Date(z2)) => {
56                z1.partial_cmp(z2)
57            }
58        }
59    }
60}
61
62impl fmt::Display for Bound {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Bound::None => write!(f, ".."),
66            Bound::Date(x) => write!(f, "{x}/d"),
67            Bound::Timestamp(x) => write!(f, "{x}/t"),
68        }
69    }
70}
71
72impl TryFrom<Q> for Bound {
73    type Error = MyError;
74
75    fn try_from(value: Q) -> Result<Self, Self::Error> {
76        Self::try_from(&value)
77    }
78}
79
80impl TryFrom<&Q> for Bound {
81    type Error = MyError;
82
83    fn try_from(value: &Q) -> Result<Self, Self::Error> {
84        match value {
85            Q::Str(x) => {
86                let s = x.as_str();
87                match s {
88                    "'..'" => Ok(Bound::None),
89                    _ => Err(MyError::Runtime(
90                        "Only '..' string is allowed for interval bounds".into(),
91                    )),
92                }
93            }
94            Q::Instant(x) => Ok(x.to_owned()),
95            _ => Err(MyError::Runtime("Expected a zoned timestamp | '..'".into())),
96        }
97    }
98}
99
100impl Bound {
101    /// Try creating a new Bound::Date variant from a well-formed RFC-3339
102    /// date string. Return [MyError] if an error occurs.
103    pub fn try_new_date(s: &str) -> Result<Self, MyError> {
104        let d = s.parse::<Date>()?;
105        let z = d.to_zoned(TimeZone::UTC)?;
106        Ok(Bound::Date(z))
107    }
108
109    /// Try creating a new Bound::Timestamp variant from a well-formed RFC-3339
110    /// timestamp string. Return [MyError] if an error occurs.
111    pub fn try_new_timestamp(s: &str) -> Result<Self, MyError> {
112        let d = s.parse::<Timestamp>()?;
113        let z = d.to_zoned(TimeZone::UTC);
114        Ok(Bound::Timestamp(z))
115    }
116
117    /// Return the inner value in `Some` if this is not the unbound variant.
118    /// Return `None` otherwise.
119    pub fn as_zoned(&self) -> Option<Zoned> {
120        match self {
121            Bound::Date(x) => Some(x.to_owned()),
122            Bound::Timestamp(x) => Some(x.to_owned()),
123            Bound::None => None,
124        }
125    }
126
127    /// Return inner value if it was a bounded instant.
128    pub(crate) fn to_zoned(&self) -> Result<Zoned, MyError> {
129        match self {
130            Bound::Date(z) => Ok(z.to_owned()),
131            Bound::Timestamp(z) => Ok(z.to_owned()),
132            _ => Err(MyError::Runtime(
133                format!("{self} is not a bounded instant").into(),
134            )),
135        }
136    }
137
138    // Return TRUE if this is an unbound variant, FALSE otherwise.
139    #[cfg(test)]
140    pub(crate) fn is_unbound(&self) -> bool {
141        matches!(self, Bound::None)
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn test_bound() {
151        const D: &str = "2015-01-01";
152        const T: &str = "2015-01-01T00:00:00Z";
153
154        let d = Bound::try_new_date(D);
155        assert!(d.is_ok());
156        let b1 = d.unwrap();
157        assert!(!b1.is_unbound());
158        let b1_ = b1.as_zoned();
159        assert!(b1_.is_some());
160        let z1 = b1_.unwrap();
161
162        let t = Bound::try_new_timestamp(T);
163        assert!(t.is_ok());
164        let b2 = t.unwrap();
165        assert!(!b2.is_unbound());
166        let b2_ = b2.as_zoned();
167        assert!(b2_.is_some());
168        let z2 = b2_.unwrap();
169
170        assert_eq!(z1, z2);
171        assert!(z1 == z2);
172    }
173}