-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Support jiff as a date time lib backend #3487
Comments
I have no strong opinions on See also this comment: BurntSushi/jiff#50 (comment) |
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
For reference, here is a wrapper that can be used to bridge jiff's Timestamp to Postgres' TIMESTAMPTZ: use std::str::FromStr;
use jiff::SignedDuration;
use serde::Deserialize;
use serde::Serialize;
use sqlx::encode::IsNull;
use sqlx::error::BoxDynError;
use sqlx::postgres::types::Oid;
use sqlx::postgres::PgArgumentBuffer;
use sqlx::postgres::PgHasArrayType;
use sqlx::postgres::PgTypeInfo;
use sqlx::postgres::PgValueFormat;
use sqlx::Database;
use sqlx::Decode;
use sqlx::Encode;
use sqlx::Postgres;
use sqlx::Type;
/// A module for Jiff support of SQLx.
// TODO(tisonkun): either switch to the upstream [1] or spawn a dedicate open-source crate.
// [1] https://github.com/launchbadge/sqlx/pull/3511
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Timestamp(pub jiff::Timestamp);
impl Type<Postgres> for Timestamp {
fn type_info() -> PgTypeInfo {
// 1184 => PgType::Timestamptz
PgTypeInfo::with_oid(Oid(1184))
}
}
impl PgHasArrayType for Timestamp {
fn array_type_info() -> PgTypeInfo {
// 1185 => PgType::TimestamptzArray
PgTypeInfo::with_oid(Oid(1185))
}
}
impl Encode<'_, Postgres> for Timestamp {
fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
// TIMESTAMP is encoded as the microseconds since the epoch
let micros = self
.0
.duration_since(postgres_epoch_timestamp())
.as_micros();
let micros = i64::try_from(micros)
.map_err(|_| format!("Timestamp {} out of range for Postgres: {micros}", self.0))?;
Encode::<Postgres>::encode(micros, buf)
}
fn size_hint(&self) -> usize {
size_of::<i64>()
}
}
impl<'r> Decode<'r, Postgres> for Timestamp {
fn decode(value: <Postgres as Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
Ok(match value.format() {
PgValueFormat::Binary => {
// TIMESTAMP is encoded as the microseconds since the epoch
let us = Decode::<Postgres>::decode(value)?;
let ts = postgres_epoch_timestamp().checked_add(SignedDuration::from_micros(us))?;
Timestamp(ts)
}
PgValueFormat::Text => {
let s = value.as_str()?;
let ts = jiff::Timestamp::from_str(s)?;
Timestamp(ts)
}
})
}
}
fn postgres_epoch_timestamp() -> jiff::Timestamp {
jiff::Timestamp::from_str("2000-01-01T00:00:00Z")
.expect("2000-01-01T00:00:00Z is a valid timestamp")
} For full integration if we are back to here and continue, sfackler/rust-postgres#1164 is a good reference for data type mapping and edge cases (error handling). |
bump, it would be truly awesome if jiff zoned datetimes / timestamps could "just work" with postgres using sqlx ... i'm trying the sqlx chrono feature and somehow my 2025 timestamptz teleported all the way back to 1970! |
Yeah I'm working on it now. Note that it will require going through wrapper types, but that's the best we can do I think. |
I'm tossing around the idea of adding a couple new traits, roughly like so: pub trait DecodeAs<DB: Database, T> {
type Error: Error + Send + Sync + 'static;
fn decode_as(row: DB::Row) -> Result<T, Self::Error>;
}
pub trait EncodeAs<DB: Database, T> {
type Error: Error + Send + Sync + 'static;
fn encode_as<'a>(value: &T, args: DB::Arguments<'a>) -> Result<T, Self::Error>
}
impl<DB: Database, T> DecodeAs<DB, T> for T { /* ... */ }
impl<DB: Database, T> EncodeAs<DB, T> for T { /* ... */ }
// jiff-sqlx
pub struct ZonedAsTimestampTz;
impl DecodeAs<Postgres, Zoned> for ZonedAsTimestampTz { /* ... */ }
impl EncodeAs<Postgres, Zoned> for ZonedAsTimestampTz { /* ... */ } For the macros, the user would specify With #3383, the user could just add global overrides (copy-pastable from For non-macro usage, the However, that may not add much over what's currently supported.
And we can add a |
Yeah the wrapper types will have bi-directional Also, one thing to clarify here is that I'm not sure there will be a |
|
@abonander Hmmm. That seems quite fraught! How does the choice between binary and plain text get made? It seems like it would be very easy to shoot yourself in the foot if you just used I'll probably have to add something like this:
to the |
The sqlx chrono integration here might be buggy. See: sqlx/sqlx-postgres/src/types/chrono/datetime.rs Lines 109 to 122 in 65229f7
But fn main() -> anyhow::Result<()> {
let dt = parse("2025-02-20 17:00:00-05:00")?;
assert_eq!(dt.to_string(), "2025-02-20 17:00:00 -05:00");
// If you try to pass in something without an offset,
// then parsing will fail.
assert!(parse("2025-02-20 17:00:00").is_err());
Ok(())
}
fn parse(s: &str) -> anyhow::Result<chrono::DateTime<chrono::FixedOffset>> {
Ok(chrono::DateTime::parse_from_str(
s,
if s.contains('+') || s.contains('-') {
// Contains a time-zone specifier
// This is given for timestamptz for some reason
// Postgres already guarantees this to always be UTC
"%Y-%m-%d %H:%M:%S%.f%#z"
} else {
"%Y-%m-%d %H:%M:%S%.f"
},
)?)
} I'm not sure how exactly to test this with PostgreSQL. (I've had a helluva time finding authoritative documentation on the actual format of types transmitted over the wire.) |
Why is the use of The big problem is that timestamp encoding is very different between databases. PostgresI was mainly talking about Postgres before, which has both a binary protocol and a text protocol for every type. The text protocol is mainly used when executing an non-prepared statement, i.e. the Simple Query flow. Values in rows returned by a simple query are always text-encoded, so we need to support parsing timestamps in a text format, but because simple queries don't support bind parameters, we don't need to worry about encoding them. Text-encoded timestamps are returned with a timezone offset specified as a connection-level setting. SQLx sets this to UTC by default for consistency, but many users set this to a different timezone so date/time handling in SQL is in local time instead (certain functions rely on this, such as When preparing a statement for execution (Extended Query flow), you can specify for bind parameters and result columns whether you prefer text or binary encoding. For simplicity and efficiency, SQLx always specifies binary encoding. Binary-encoded timestamps are always transmitted, stored, and returned in UTC, but they don't use the Unix epoch. They're specified as the number of microseconds (signed) from January 1, 2000, which you have to actually read the source code to find out (or learn this from reading someone else's implementation, like Diesel's): https://github.com/postgres/postgres/blob/306dc520b9dfd6014613961962a89940a431a069/src/include/datatype/timestamp.h#L235 For MySQLMySQL timestamps always use a binary protocol, but are encoded as date and time: https://dev.mysql.com/doc/dev/mysql-server/8.4.3/page_protocol_binary_resultset.html The timezone in this case is like Postgres, a connection-level setting. It's not included in the encoding at all, so we default to UTC and assume this everywhere. Users also like to override this setting, so this is very much a "proceed at your own risk" situation. We just trust them to choose a date/time type that doesn't assume UTC. SQLiteSQLite has no special date/time encoding, but it can handle ISO-8601. Timestamps are encoded and returned as text, with or without a timezone offset and are not modified in-flight ( |
Thank you for all this information! I really appreciate it. This will increase the chances I get this right the first time. :-)
Oh it absolutely could. It's just a major footgun. Let's say you have use jiff::{ToSpan, Zoned};
fn main() -> anyhow::Result<()> {
let zdt: Zoned = "2024-03-09T17:30:00-05:00[-05:00]".parse()?;
let next_day = zdt.checked_add(1.day())?;
assert_eq!(next_day.to_string(), "2024-03-10T17:30:00-05:00[-05:00]");
let actual_next_day: Zoned =
"2024-03-10T17:30:00[America/New_York]".parse()?;
assert_eq!(
actual_next_day.to_string(),
"2024-03-10T17:30:00-04:00[America/New_York]",
);
Ok(())
} Notice how the offset is wrong. So you're an hour off because your arithmetic didn't take DST into account. And that happened because your use jiff::{ToSpan, Zoned};
fn main() -> anyhow::Result<()> {
let zdt: Zoned = "2024-03-09T17:30:00-05:00[America/New_York]".parse()?;
assert_eq!(
zdt.checked_add(1.day())?.to_string(),
"2024-03-10T17:30:00-04:00[America/New_York]",
);
Ok(())
} But if you stored a (I haven't read the rest of your comment. Have to go make dinner.) |
Well, to be fair the actual bug seems to be that assuming adding 1 day is exactly equivalent to adding 24 hours in all circumstances. Or conversely, assuming that a timestamp with a UTC-5 offset should be equivalent to the same time in New York on March 10th. I'm not really sure how that's Postgres's fault, to be honest... But yeah, this is the reason why I personally prefer to just handle everything as UTC. IMHO, time zones are the frontend's problem (converting pure data to something meaningful to the user). All this messy human stuff I just don't want to have to think about. My gut feeling is that users probably won't want to deviate from the native types, because sometimes you have to do time-aware stuff in SQL and having to convert to/from a specific text encoding is going to be annoying. |
RE PostgreSQL format: Thank you, that is incredibly useful information. I think I can use that to test the text encoding paths. I figured out most of the format details by reading source code (including in
I think we're missing each other here. The problem is not any sort of assumption around how fixed offset datetimes behave, but rather, the implicit and silent dropping of data. A
Let's try to reframe this discussion. I'm not trying to blame anyone here. I mean, I have a pretty negative opinion about how PostgreSQL (and I guess more generally, SQL itself) handles datetimes. Particularly the names. But the point in question here is whether a
Right! If you can use UTC, and you can for many things, then you absolutely should. And PostgreSQL's types are generally okay for that use case. And if you can use UTC, then you just use
Yup! I agree. Here's my order of preference: 1: Provide integration with I think it's reasonable to pick 1 or 2, but I don't think the last option is reasonable. It might be reasonable in the context of a specific application that is knowingly dropping data, but it's wildly inappropriate to provide as a general purpose wrapper as a paved path for A major point in the design of I do prefer to take the conservative approach here, so perhaps I'll start with option (2) and wait for real user feedback. Once I have an initial version of |
Yeah. I report it at #703 (comment) but it seems missed. |
I'd support this option and this is how we're using it so far: store the timestamp and externally manage the timezone. As you noticed above, at least the Postgres data model doesn't match |
Yeah. That really only works for times in the past though. |
A PR is always welcome. |
@abonander How does one implement I see, for example, the sqlx/sqlx-sqlite/src/types/chrono.rs Lines 44 to 52 in 65229f7
But it's using internal APIs to build a But there aren't any existing impls for the date types, other than for the |
And like, doing this sort of parsing based on the type outside of SQLx also seems tricky? sqlx/sqlx-sqlite/src/types/chrono.rs Lines 109 to 123 in 65229f7
I think I could use |
And I guess I have a similar problem with MySQL: sqlx/sqlx-mysql/src/types/chrono.rs Lines 164 to 168 in 65229f7
It kinda looks like PostgreSQL is somewhat of the odd one out, as it but not SQLite or MySQL has constructors for building type info: https://docs.rs/sqlx/latest/sqlx/postgres/struct.PgTypeInfo.html |
This PR adds a new `jiff-sqlx` crate. It defines wrapper types for `Timestamp`, `DateTime`, `Date`, `Time` and `Span`. For each wrapper type, the SQLx encoding traits are implemented. (Except, with `Span`, only the decoding trait is implemented.) This is similar to #141, but organizes things a bit differently. This also comes with SQLite support. MySQL support is missing since it seems, at present, to require exposing APIs in SQLx for a correct implementation. This initial implementation also omits `Zoned` entirely. I've left a comment in the source code explaining why. The quick summary is that, at least for PostgreSQL, I don't see a way to provide support for it without either silently losing data (the time zone) or just storing it as an RFC 9557 timestamp in a `TEXT` field. The downside of the latter is that it doesn't use PostgreSQL native datetime types. (Becuase we can't. Because PostgreSQL doesn't support storing anything other than civil time and timestamps with respect to its datetime types.) I do personally lean toward just using RFC 9557 as a `TEXT` type, but I'd like to collect real use cases first to make sure that's the right way to go. Ref #50, Closes #141 Ref launchbadge/sqlx#3487
I have a PR up here to add I'd love for feedback if anyone feels up to it. But otherwise I'm happy to iterate on it after a release too. I'm sure I won't get it right the first time. |
This PR adds a new `jiff-sqlx` crate. It defines wrapper types for `Timestamp`, `DateTime`, `Date`, `Time` and `Span`. For each wrapper type, the SQLx encoding traits are implemented. (Except, with `Span`, only the decoding trait is implemented.) This is similar to #141, but organizes things a bit differently. This also comes with SQLite support. MySQL support is missing since it seems, at present, to require exposing APIs in SQLx for a correct implementation. This initial implementation also omits `Zoned` entirely. I've left a comment in the source code explaining why. The quick summary is that, at least for PostgreSQL, I don't see a way to provide support for it without either silently losing data (the time zone) or just storing it as an RFC 9557 timestamp in a `TEXT` field. The downside of the latter is that it doesn't use PostgreSQL native datetime types. (Becuase we can't. Because PostgreSQL doesn't support storing anything other than civil time and timestamps with respect to its datetime types.) I do personally lean toward just using RFC 9557 as a `TEXT` type, but I'd like to collect real use cases first to make sure that's the right way to go. Ref #50, Closes #141 Ref launchbadge/sqlx#3487
This PR adds a new `jiff-sqlx` crate. It defines wrapper types for `Timestamp`, `DateTime`, `Date`, `Time` and `Span`. For each wrapper type, the SQLx encoding traits are implemented. (Except, with `Span`, only the decoding trait is implemented.) This is similar to #141, but organizes things a bit differently. This also comes with SQLite support. MySQL support is missing since it seems, at present, to require exposing APIs in SQLx for a correct implementation. This initial implementation also omits `Zoned` entirely. I've left a comment in the source code explaining why. The quick summary is that, at least for PostgreSQL, I don't see a way to provide support for it without either silently losing data (the time zone) or just storing it as an RFC 9557 timestamp in a `TEXT` field. The downside of the latter is that it doesn't use PostgreSQL native datetime types. (Becuase we can't. Because PostgreSQL doesn't support storing anything other than civil time and timestamps with respect to its datetime types.) I do personally lean toward just using RFC 9557 as a `TEXT` type, but I'd like to collect real use cases first to make sure that's the right way to go. Ref #50, Closes #141 Ref launchbadge/sqlx#3487
As in https://docs.rs/sqlx/0.8.2/sqlx/postgres/types/index.html for
chrono
andtime
, being integrated withjiff
.Since the abstractions are similar, it should be viable.
cc @BurntSushi
I may take a closer look later but I can't commit it :P
The text was updated successfully, but these errors were encountered: