diff --git a/benzina/src/at_time_zone.rs b/benzina/src/at_time_zone.rs new file mode 100644 index 0000000..6901690 --- /dev/null +++ b/benzina/src/at_time_zone.rs @@ -0,0 +1,372 @@ +//! PostgreSQL `AT TIME ZONE` conversions with user-declared time zones, +//! bridging `Timestamptz` columns to the `extract_*` expressions. + +use std::marker::PhantomData; + +use diesel::expression::Expression; +use diesel::sql_types::{Nullable, SingleValue, SqlType, Timestamp, Timestamptz}; + +/// A PostgreSQL time zone, as a compile-time marker type. +/// +/// Usually declared with the [`timezone!`](crate::timezone) macro and +/// applied with [`at`](Self::at): +/// +/// ``` +/// use benzina::{TimeZone, extract_date}; +/// use diesel::{ +/// ExpressionMethods, QueryDsl, debug_query, +/// dsl::{date, now}, +/// pg::Pg, +/// }; +/// +/// benzina::timezone!(pub Rome = "Europe/Rome"); +/// +/// diesel::table! { +/// events (id) { +/// id -> Integer, +/// created_at -> Timestamptz, +/// } +/// } +/// +/// // Rows created today on the Rome calendar +/// let q = events::table.filter(extract_date(Rome::at(events::created_at)).eq(date(now))); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"(("events"."created_at") AT TIME ZONE 'Europe/Rome') >= date(CURRENT_TIMESTAMP)"# +/// )); +/// ``` +/// +/// # SQL literal +/// +/// [`NAME`](Self::NAME) is spliced into the SQL as a literal, not a bind: a +/// bind would break `SELECT`/`GROUP BY` matching (PostgreSQL compares the +/// expressions structurally, and `$1` != `$4`), and a runtime name would +/// break the type-keyed prepared-statement cache. +pub trait TimeZone { + /// The PostgreSQL time zone name, e.g. `Europe/Rome`. + /// + /// Spliced verbatim between the quotes of a SQL literal, so — like + /// [`diesel::dsl::sql`] — it must be valid SQL there. Whether it names + /// an *existing* zone is only checked by PostgreSQL at query time + /// (`time zone "..." not recognized`). Prefer IANA names + /// (`Europe/Rome`): POSIX-style offsets (`UTC+2`) are also accepted by + /// PostgreSQL but with the sign meaning inverted from ISO 8601. + const NAME: &'static str; + + /// Converts a timestamp expression to this zone's wall-clock time, see + /// [`at_time_zone`]. + /// + /// # Examples + /// + /// ``` + /// use benzina::{TimeZone, extract_year}; + /// use diesel::{QueryDsl, debug_query, pg::Pg}; + /// + /// benzina::timezone!(pub Rome = "Europe/Rome"); + /// + /// diesel::table! { + /// events (id) { + /// id -> Integer, + /// created_at -> Timestamptz, + /// } + /// } + /// + /// let q = events::table.filter(extract_year(Rome::at(events::created_at)).eq(2024)); + /// assert!(debug_query::(&q).to_string().contains( + /// r#"(CAST(date_part('year', (("events"."created_at") AT TIME ZONE 'Europe/Rome')) AS integer) = $1)"# + /// )); + /// ``` + fn at(expr: Expr) -> AtTimeZone::Conversion> + where + Self: Sized, + Expr: Expression, + Expr::SqlType: InstantLike, + { + at_time_zone::(expr) + } +} + +/// Timestamp SQL types accepted by [`at_time_zone`]: `Timestamptz` and +/// `Timestamp`, plus their `Nullable` variants. +/// +/// Nullability propagates: converting a `Nullable` expression +/// yields a `Nullable` one. +pub trait InstantLike: SqlType { + /// Marker picking the SQL emitted by [`AtTimeZone`]: [`FromInstant`] + /// for `Timestamptz`, [`FromUtc`] for `Timestamp`. + type Conversion; + + /// The SQL type of the converted expression — the zone's wall-clock + /// `Timestamp`, `Nullable` when the input is. + type Output: SqlType + SingleValue; +} +impl InstantLike for Timestamptz { + type Conversion = FromInstant; + type Output = Timestamp; +} +impl InstantLike for Timestamp { + type Conversion = FromUtc; + type Output = Timestamp; +} +impl InstantLike for Nullable { + type Conversion = T::Conversion; + type Output = Nullable; +} + +/// Marker for `Timestamptz` columns — the value already is an instant. +/// +/// The expression is emitted as `((expr) AT TIME ZONE 'NAME')`. +#[derive(Debug, Clone, Copy)] +pub struct FromInstant; + +/// Marker for `Timestamp` columns **assumed to store UTC wall-clock time** +/// (the common convention for naive timestamps). +/// +/// The expression is emitted as +/// `(((expr) AT TIME ZONE 'UTC') AT TIME ZONE 'NAME')`: the first +/// conversion reinterprets the naive value as a UTC instant, the second +/// renders it in the target zone. +#[derive(Debug, Clone, Copy)] +pub struct FromUtc; + +/// Converts a timestamp expression to `Tz`'s wall-clock time. +/// +/// The result is a plain (`Nullable<`)`Timestamp`(`>`) expression, so the +/// whole `extract_*` family composes with it — +/// [`extract_date`](crate::extract_date), +/// [`extract_time`](crate::extract_time), +/// [`extract_year`](crate::extract_year)/month/day — which is how those +/// are used with `Timestamptz` columns. +/// +/// `timezone(zone, timestamp)` is `IMMUTABLE` in PostgreSQL (unlike the +/// session-dependent casts that keep `Timestamptz` out of +/// [`DateLike`](crate::DateLike) and [`TimeLike`](crate::TimeLike)), so +/// comparisons can use an expression index built on the emitted +/// expression: +/// +/// ```sql +/// CREATE INDEX ... ON tbl ((col AT TIME ZONE 'Europe/Rome')); +/// ``` +/// +/// (PostgreSQL matches indexes on the parse tree, so parenthesization +/// doesn't matter.) Note the `IMMUTABLE` marking is a pragmatic lie: a +/// political change to the zone's rules can make already-indexed values +/// stale until the index is rebuilt. +/// +/// # Examples +/// +/// ``` +/// use benzina::{at_time_zone, extract_date, extract_time}; +/// use diesel::{ +/// ExpressionMethods, QueryDsl, debug_query, +/// dsl::{date, now}, +/// pg::Pg, +/// }; +/// +/// benzina::timezone!(pub Rome = "Europe/Rome"); +/// +/// diesel::table! { +/// events (id) { +/// id -> Integer, +/// created_at -> Timestamptz, +/// recorded_at -> Timestamp, +/// starts_at -> Time, +/// } +/// } +/// +/// // `Timestamptz` column: single conversion +/// let q = events::table.select(at_time_zone::(events::created_at)); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"(("events"."created_at") AT TIME ZONE 'Europe/Rome')"# +/// )); +/// +/// // `Timestamp` column storing UTC: reinterpreted as UTC first +/// let q = events::table.select(at_time_zone::(events::recorded_at)); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"((("events"."recorded_at") AT TIME ZONE 'UTC') AT TIME ZONE 'Europe/Rome')"# +/// )); +/// +/// // Rows on today's Rome calendar day: `extract_date` compares the +/// // converted expression as a half-open range +/// let q = events::table +/// .filter(extract_date(at_time_zone::(events::created_at)).eq(date(now))); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"((("events"."created_at") AT TIME ZONE 'Europe/Rome') >= date(CURRENT_TIMESTAMP) AND (("events"."created_at") AT TIME ZONE 'Europe/Rome') < date(CURRENT_TIMESTAMP) + 1)"# +/// )); +/// +/// // Rows after a Rome time-of-day: `extract_time` casts the converted +/// // expression to `time` +/// let q = events::table +/// .filter(extract_time(at_time_zone::(events::created_at)).gt(events::starts_at)); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"CAST((("events"."created_at") AT TIME ZONE 'Europe/Rome') AS time) > "events"."starts_at""# +/// )); +/// ``` +/// +/// Only timestamp types are accepted: +/// +/// ```compile_fail +/// use benzina::TimeZone; +/// +/// benzina::timezone!(pub Rome = "Europe/Rome"); +/// +/// diesel::table! { +/// events (id) { +/// id -> Integer, +/// day -> Date, +/// } +/// } +/// +/// let expr = Rome::at(events::day); // `Date` has no time zone to convert +/// ``` +pub fn at_time_zone( + expr: Expr, +) -> AtTimeZone::Conversion> +where + Tz: TimeZone, + Expr: Expression, + Expr::SqlType: InstantLike, +{ + AtTimeZone { + expr, + marker: PhantomData, + } +} + +/// The return type of [`at_time_zone`] and [`TimeZone::at`]. +#[derive(Debug, Clone, Copy, diesel::sql_types::DieselNumericOps)] +pub struct AtTimeZone { + expr: Expr, + marker: PhantomData<(Tz, Conv)>, +} + +impl AtTimeZone { + /// Returns the inner timestamp expression. + pub fn into_inner(self) -> Expr { + self.expr + } +} + +impl diesel::query_builder::QueryId for AtTimeZone +where + Expr: diesel::query_builder::QueryId, + Tz: 'static, + Conv: 'static, +{ + type QueryId = AtTimeZone; + + const HAS_STATIC_QUERY_ID: bool = Expr::HAS_STATIC_QUERY_ID; +} + +impl Expression for AtTimeZone +where + Expr: Expression, + Expr::SqlType: InstantLike, +{ + type SqlType = ::Output; +} + +// The walked expression needs its own parens: Diesel emits arithmetic +// unparenthesized and `AT TIME ZONE` binds tighter than `+`. +impl diesel::query_builder::QueryFragment for AtTimeZone +where + Expr: diesel::query_builder::QueryFragment, + Tz: TimeZone, + DB: diesel::backend::Backend, +{ + fn walk_ast<'b>( + &'b self, + mut out: diesel::query_builder::AstPass<'_, 'b, DB>, + ) -> diesel::result::QueryResult<()> { + out.push_sql("(("); + self.expr.walk_ast(out.reborrow())?; + out.push_sql(") AT TIME ZONE '"); + out.push_sql(Tz::NAME); + out.push_sql("')"); + Ok(()) + } +} + +impl diesel::query_builder::QueryFragment for AtTimeZone +where + Expr: diesel::query_builder::QueryFragment, + Tz: TimeZone, + DB: diesel::backend::Backend, +{ + fn walk_ast<'b>( + &'b self, + mut out: diesel::query_builder::AstPass<'_, 'b, DB>, + ) -> diesel::result::QueryResult<()> { + out.push_sql("((("); + self.expr.walk_ast(out.reborrow())?; + out.push_sql(") AT TIME ZONE 'UTC') AT TIME ZONE '"); + out.push_sql(Tz::NAME); + out.push_sql("')"); + Ok(()) + } +} + +// `ValidGrouping` is `Never` on purpose: Diesel can't verify computed +// `GROUP BY` expressions, so opt out of its aggregate checking (like +// `diesel::dsl::sql` does). +impl diesel::expression::ValidGrouping for AtTimeZone { + type IsAggregate = diesel::expression::is_aggregate::Never; +} + +impl diesel::expression::SelectableExpression for AtTimeZone +where + Self: Expression, + Expr: diesel::expression::SelectableExpression, +{ +} + +impl diesel::expression::AppearsOnTable for AtTimeZone +where + Self: Expression, + Expr: diesel::expression::AppearsOnTable, +{ +} + +/// Declares one or more time zones for use with +/// [`at_time_zone`](crate::at_time_zone). +/// +/// Each declaration expands to a unit struct implementing +/// [`TimeZone`](crate::TimeZone). The zone name is spliced verbatim into +/// the SQL literal, so it must be valid SQL there; whether the zone +/// *exists* is only checked by PostgreSQL at query time. +/// +/// # Examples +/// +/// ``` +/// use benzina::TimeZone; +/// +/// benzina::timezone!( +/// /// You can add documentation. +/// pub Rome = "Europe/Rome", +/// NewYork = "America/New_York", +/// ); +/// +/// assert_eq!(Rome::NAME, "Europe/Rome"); +/// ``` +#[macro_export] +macro_rules! timezone { + ( + $( + $(#[$attr:meta])* + $vis:vis $name:ident = $tz:expr + ),+ $(,)? + ) => { + $( + $(#[$attr])* + #[derive( + $crate::__private::std::fmt::Debug, + $crate::__private::std::clone::Clone, + $crate::__private::std::marker::Copy, + )] + $vis struct $name; + + impl $crate::TimeZone for $name { + const NAME: &'static str = $tz; + } + )+ + }; +} diff --git a/benzina/src/date_eq.rs b/benzina/src/date_eq.rs new file mode 100644 index 0000000..ac98178 --- /dev/null +++ b/benzina/src/date_eq.rs @@ -0,0 +1,237 @@ +//! PostgreSQL date equality via index-friendly ranges. + +use std::marker::PhantomData; + +use diesel::expression::{AsExpression, Expression}; +use diesel::sql_types::{Bool, Date, Nullable, SqlType, Timestamp}; + +/// SQL types holding a plain calendar date — `Date` and `Nullable`. +/// +/// Gates rewrites that compare a column against a *closed* date range: on +/// a timestamp column the range end would be promoted to midnight and cut +/// off the rest of the last day. +pub trait DateOnly: SqlType {} +impl DateOnly for Date {} +impl DateOnly for Nullable {} + +/// SQL types accepted by [`extract_date`]: `Date` and `Timestamp`, plus +/// their `Nullable` variants. +/// +/// `Timestamptz` is excluded on purpose: which instants fall on a calendar +/// date depends on the session time zone, so no fixed range is correct. +/// Pick the calendar explicitly by converting first: +/// `extract_date(Rome::at(col))` — see [`TimeZone`](crate::TimeZone). +pub trait DateLike: SqlType { + /// Marker picking the SQL emitted by [`DateEq`]: [`ClosedRange`] for + /// dates, [`HalfOpenRange`] for timestamps. + type Range; +} +impl DateLike for Date { + type Range = ClosedRange; +} +impl DateLike for Timestamp { + type Range = HalfOpenRange; +} +impl DateLike for Nullable { + type Range = T::Range; +} + +/// Marker for `Date` columns, compared with a closed range. +/// +/// A `Date` value *is* the whole day, so [`DateEq`] emits +/// `(expr BETWEEN rhs AND rhs)` — equivalent to `= rhs`. +#[derive(Debug, Clone, Copy)] +pub struct ClosedRange; + +/// Marker for `Timestamp` columns, compared with a half-open range. +/// +/// A day of timestamps has no last instant, so [`DateEq`] emits +/// `(expr >= rhs AND expr < rhs + 1)` — covering the whole day without +/// touching midnight of the next. +#[derive(Debug, Clone, Copy)] +pub struct HalfOpenRange; + +/// Compares a date/timestamp expression against a `Date` as a range. +/// +/// The column stays bare on one side, so PostgreSQL can drive a B-tree +/// index scan on it: +/// +/// - `Date` columns: `(expr BETWEEN date AND date)` — equivalent to +/// `= date` +/// - `Timestamp` columns: `(expr >= date AND expr < date + 1)` — every +/// instant of that day, half-open so nothing past midnight is lost +/// +/// `Timestamptz` columns are rejected: which instants fall on a calendar +/// date depends on the session time zone. Pick the calendar explicitly by +/// converting first: `extract_date(Rome::at(col))` — see +/// [`TimeZone`](crate::TimeZone). +/// +/// # Examples +/// +/// ``` +/// use benzina::extract_date; +/// use diesel::{ +/// QueryDsl, debug_query, +/// dsl::{date, now}, +/// pg::Pg, +/// }; +/// +/// diesel::table! { +/// events (id) { +/// id -> Integer, +/// day -> Date, +/// created_at -> Timestamp, +/// } +/// } +/// +/// // `Date` column: closed range, equivalent to `= today` +/// let q = events::table.filter(extract_date(events::day).eq(date(now))); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"("events"."day" BETWEEN date(CURRENT_TIMESTAMP) AND date(CURRENT_TIMESTAMP))"# +/// )); +/// +/// // `Timestamp` column: half-open range covering the whole day +/// let q = events::table.filter(extract_date(events::created_at).eq(date(now))); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"("events"."created_at" >= date(CURRENT_TIMESTAMP) AND "events"."created_at" < date(CURRENT_TIMESTAMP) + 1)"# +/// )); +/// ``` +pub fn extract_date(expr: Expr) -> ExtractedDate +where + Expr: Expression, + Expr::SqlType: DateLike, +{ + ExtractedDate { expr } +} + +/// The return type of [`extract_date`]. +/// +/// Compare it with [`eq`](ExtractedDate::eq). It is not an expression on +/// its own: the date is never computed in SQL, only rewritten into a +/// range comparison on the column. +#[derive(Debug, Clone, Copy, diesel::query_builder::QueryId)] +pub struct ExtractedDate { + expr: Expr, +} + +impl ExtractedDate { + /// Builds the range comparison against `rhs`, see [`extract_date`] for + /// the SQL emitted per column type. + /// + /// Both sides can be emitted twice, so keep them to columns and binds — + /// a volatile expression would be evaluated twice. + pub fn eq( + self, + rhs: Rhs, + ) -> DateEq::Range> + where + Expr: Expression, + Expr::SqlType: DateLike, + Rhs: AsExpression, + { + DateEq { + expr: self.expr, + rhs: rhs.as_expression(), + range: PhantomData, + } + } + + /// Returns the inner date/timestamp expression. + pub fn into_inner(self) -> Expr { + self.expr + } +} + +/// The return type of [`ExtractedDate::eq`]. +/// +/// Emits `(expr BETWEEN rhs AND rhs)` or `(expr >= rhs AND expr < rhs + 1)`, +/// picked by the `Range` marker — see [`extract_date`]. +#[derive(Debug, Clone, Copy)] +pub struct DateEq { + expr: Expr, + rhs: Rhs, + range: PhantomData, +} + +impl diesel::query_builder::QueryId for DateEq +where + Expr: diesel::query_builder::QueryId, + Rhs: diesel::query_builder::QueryId, + Range: 'static, +{ + type QueryId = DateEq; + + const HAS_STATIC_QUERY_ID: bool = Expr::HAS_STATIC_QUERY_ID && Rhs::HAS_STATIC_QUERY_ID; +} + +impl Expression for DateEq +where + Expr: Expression, + Rhs: Expression, +{ + type SqlType = Bool; +} + +impl diesel::query_builder::QueryFragment for DateEq +where + Expr: diesel::query_builder::QueryFragment, + Rhs: diesel::query_builder::QueryFragment, + DB: diesel::backend::Backend, +{ + fn walk_ast<'b>( + &'b self, + mut out: diesel::query_builder::AstPass<'_, 'b, DB>, + ) -> diesel::result::QueryResult<()> { + out.push_sql("("); + self.expr.walk_ast(out.reborrow())?; + out.push_sql(" BETWEEN "); + self.rhs.walk_ast(out.reborrow())?; + out.push_sql(" AND "); + self.rhs.walk_ast(out.reborrow())?; + out.push_sql(")"); + Ok(()) + } +} + +impl diesel::query_builder::QueryFragment for DateEq +where + Expr: diesel::query_builder::QueryFragment, + Rhs: diesel::query_builder::QueryFragment, + DB: diesel::backend::Backend, +{ + fn walk_ast<'b>( + &'b self, + mut out: diesel::query_builder::AstPass<'_, 'b, DB>, + ) -> diesel::result::QueryResult<()> { + out.push_sql("("); + self.expr.walk_ast(out.reborrow())?; + out.push_sql(" >= "); + self.rhs.walk_ast(out.reborrow())?; + out.push_sql(" AND "); + self.expr.walk_ast(out.reborrow())?; + out.push_sql(" < "); + self.rhs.walk_ast(out.reborrow())?; + out.push_sql(" + 1)"); + Ok(()) + } +} + +impl diesel::expression::ValidGrouping for DateEq { + type IsAggregate = diesel::expression::is_aggregate::Never; +} + +impl diesel::expression::SelectableExpression for DateEq +where + Self: Expression, + Expr: diesel::expression::SelectableExpression, + Rhs: diesel::expression::SelectableExpression, +{ +} + +impl diesel::expression::AppearsOnTable for DateEq +where + Self: Expression, + Expr: diesel::expression::AppearsOnTable, + Rhs: diesel::expression::AppearsOnTable, +{ +} diff --git a/benzina/src/date_part.rs b/benzina/src/date_part.rs new file mode 100644 index 0000000..eddfd3d --- /dev/null +++ b/benzina/src/date_part.rs @@ -0,0 +1,366 @@ +//! PostgreSQL `date_part()` expressions for extracting and comparing the +//! year, month and day of date/timestamp columns. + +use diesel::{ + ExpressionMethods, dsl, + expression::Expression, + sql_types::{Date, Nullable, SqlType, Timestamp}, +}; + +use crate::U15; +use crate::date_eq::DateOnly; + +/// Date/timestamp SQL types accepted by [`extract_year`], [`extract_month`] +/// and [`extract_day`]. +/// +/// `Timestamptz` is excluded: its date parts depend on the time zone. Pick +/// one explicitly by converting first: `extract_year(Rome::at(col))` — see +/// [`TimeZone`](crate::TimeZone). +/// +/// Nullable inputs are accepted for filtering (a `NULL` comparison excludes +/// the row), but the extract expressions still claim a non-nullable +/// `Integer`, so don't `SELECT` them off a nullable column. +pub trait DateOrTimestamp: SqlType {} +impl DateOrTimestamp for Date {} +impl DateOrTimestamp for Timestamp {} +impl DateOrTimestamp for Nullable {} + +/// Right-hand side of the `eq` on [`YearPart`], [`MonthPart`] and +/// [`DayPart`]. +/// +/// Each impl decides the SQL emitted for its value type. +pub trait DatePartEq { + /// The diesel expression type produced by the comparison. + type Output; + + /// Builds the comparison expression with `lhs` on the left. + fn eq_date_part(self, lhs: Lhs) -> Self::Output; +} + +/// Bounds of the `between` on [`YearPart`], [`MonthPart`] and [`DayPart`]. +/// +/// Each impl decides the SQL emitted for its value type. +pub trait DatePartBetween: Sized { + /// The diesel expression type produced by the comparison. + type Output; + + /// Builds the inclusive range expression with `lhs` on the left. + fn between_date_part(lhs: Lhs, lower: Self, upper: Self) -> Self::Output; +} + +// `ValidGrouping` is `Never` on purpose: Diesel can't verify computed +// `GROUP BY` expressions, so opt out of its aggregate checking (like +// `diesel::dsl::sql` does). +macro_rules! impl_expression_boilerplate { + ($ty:ident) => { + impl diesel::expression::ValidGrouping for $ty { + type IsAggregate = diesel::expression::is_aggregate::Never; + } + + impl diesel::expression::SelectableExpression for $ty + where + Self: Expression, + Expr: diesel::expression::SelectableExpression, + { + } + + impl diesel::expression::AppearsOnTable for $ty + where + Self: Expression, + Expr: diesel::expression::AppearsOnTable, + { + } + }; +} + +// `CAST(date_part('', expr) AS integer)` with the field baked into the +// type: a bind would break `SELECT`/`GROUP BY` matching (PostgreSQL compares +// the expressions structurally, and `$1` != `$4`), and a runtime field would +// break the type-keyed prepared-statement cache. +macro_rules! date_part_expr { + ($ty:ident, $constructor:ident, $field:literal $(, $(#[$extra_doc:meta])+)? $(,)?) => { + #[doc = concat!("The return type of [`", stringify!($constructor), "`].")] + /// + #[doc = concat!("Emits `CAST(date_part('", $field, "', expr) AS integer)`.")] + #[derive( + Debug, Clone, Copy, diesel::query_builder::QueryId, diesel::sql_types::DieselNumericOps, + )] + pub struct $ty { + expr: Expr, + } + + #[doc = concat!("Extracts the ", $field, " of a date/timestamp expression as an integer.")] + /// + #[doc = concat!("Emits `CAST(date_part('", $field, "', expr) AS integer)`. Comparing")] + /// this expression can only use an expression index built on exactly + /// the emitted expression: + /// + /// ```sql + #[doc = concat!("CREATE INDEX ... ON tbl ((CAST(date_part('", $field, "', col) AS integer)));")] + /// ``` + /// + /// An index built with `EXTRACT(..)` instead will *not* match: + /// `EXTRACT` maps to a different catalog function than `date_part()` + /// (they diverged in PostgreSQL 14; before that, `EXTRACT` was + /// rewritten to `date_part()`). + /// + /// # Examples + /// + /// ``` + #[doc = concat!("use benzina::", stringify!($constructor), ";")] + /// use diesel::{QueryDsl, debug_query, pg::Pg}; + /// + /// diesel::table! { + /// events (id) { + /// id -> Integer, + /// created_at -> Timestamp, + /// } + /// } + /// + #[doc = concat!("let q = events::table.filter(", stringify!($constructor), "(events::created_at).eq(7));")] + /// assert!(debug_query::(&q).to_string().contains( + #[doc = concat!(" r#\"(CAST(date_part('", $field, "', \"events\".\"created_at\") AS integer) = $1)\"#")] + /// )); + /// ``` + $($(#[$extra_doc])+)? + pub fn $constructor(expr: Expr) -> $ty + where + Expr: Expression, + Expr::SqlType: DateOrTimestamp, + { + $ty { expr } + } + + impl Expression for $ty + where + Expr: Expression, + { + type SqlType = diesel::sql_types::Integer; + } + + impl diesel::query_builder::QueryFragment for $ty + where + Expr: diesel::query_builder::QueryFragment, + DB: diesel::backend::Backend, + { + fn walk_ast<'b>( + &'b self, + mut out: diesel::query_builder::AstPass<'_, 'b, DB>, + ) -> diesel::result::QueryResult<()> { + out.push_sql(concat!("CAST(date_part('", $field, "', ")); + self.expr.walk_ast(out.reborrow())?; + out.push_sql(") AS integer)"); + Ok(()) + } + } + + impl_expression_boilerplate!($ty); + + impl $ty { + #[doc = concat!("Compares `date_part('", $field, "', expr)`; the generated SQL")] + /// depends on the right-hand side, see [`DatePartEq`]. + /// + /// Shadows [`ExpressionMethods::eq`] so that the right-hand side + /// can pick a smarter SQL form than `date_part(..) = $1` where + /// one exists. + pub fn eq(self, rhs: Rhs) -> Rhs::Output + where + Rhs: DatePartEq, + { + rhs.eq_date_part(self) + } + + #[doc = concat!("Range-compares `date_part('", $field, "', expr)`; the generated SQL")] + /// depends on the bound type, see [`DatePartBetween`]. + /// + /// Shadows [`ExpressionMethods::between`] so that the bounds can + /// pick a smarter SQL form than `date_part(..) BETWEEN $1 AND $2` + /// where one exists. + pub fn between(self, lower: Rhs, upper: Rhs) -> Rhs::Output + where + Rhs: DatePartBetween, + { + Rhs::between_date_part(self, lower, upper) + } + + /// Returns the inner date/timestamp expression. + /// + /// Useful for [`DatePartEq`] impls in downstream crates that + /// rewrite the comparison into a range filter on the column + /// itself. + pub fn into_inner(self) -> Expr { + self.expr + } + } + + #[doc = concat!("`date_part('", $field, "', a) = date_part('", $field, "', b)`")] + impl DatePartEq<$ty> for $ty + where + Expr: Expression, + Expr2: Expression, + { + type Output = dsl::Eq<$ty, $ty>; + + fn eq_date_part(self, lhs: $ty) -> Self::Output { + ExpressionMethods::eq(lhs, self) + } + } + + impl DatePartEq<$ty> for i32 + where + Expr: Expression, + { + type Output = dsl::Eq<$ty, i32>; + + fn eq_date_part(self, lhs: $ty) -> Self::Output { + ExpressionMethods::eq(lhs, self) + } + } + + impl DatePartBetween<$ty> for i32 + where + Expr: Expression, + { + type Output = dsl::Between<$ty, i32, i32>; + + fn between_date_part(lhs: $ty, lower: Self, upper: Self) -> Self::Output { + ExpressionMethods::between(lhs, lower, upper) + } + } + }; +} + +date_part_expr!( + YearPart, + extract_year, + "year", + /// On date columns, bounded year types (`u8`, `u16`, [`U15`]) rewrite + /// the comparison to a plain date range that can use a B-tree index on + /// the column — see [`YearRange`]: + /// + /// ``` + /// use benzina::extract_year; + /// use diesel::{QueryDsl, debug_query, pg::Pg}; + /// + /// diesel::table! { + /// events (id) { + /// id -> Integer, + /// day -> Date, + /// } + /// } + /// + /// let q = events::table.filter(extract_year(events::day).eq(2024u16)); + /// assert!(debug_query::(&q).to_string().contains( + /// r#"("events"."day" BETWEEN make_date($1, 1, 1) AND make_date($2, 12, 31))"# + /// )); + /// ``` +); +date_part_expr!(MonthPart, extract_month, "month"); +date_part_expr!(DayPart, extract_day, "day"); + +/// The return type of [`YearPart::eq`] and [`YearPart::between`] on date +/// columns, for bounded year types. +/// +/// "The year of `expr` is between `$1` and `$2`" is the same condition as +/// "`expr` is between Jan 1 of `$1` and Dec 31 of `$2`", so the comparison +/// is rewritten to +/// `(expr BETWEEN make_date($1, 1, 1) AND make_date($2, 12, 31))`: the +/// column stays bare, letting PostgreSQL use a plain B-tree index on it +/// instead of computing `date_part('year', expr)` for every row. +/// +/// The rewrite only happens for year values that `make_date()` always +/// accepts — `u8`, `u16` and [`U15`]; see their [`DatePartEq`] and +/// [`DatePartBetween`] impls for the year-0 runtime caveat. +#[derive(Debug, Clone, Copy, diesel::query_builder::QueryId)] +pub struct YearRange { + expr: Expr, + from_year: i32, + to_year: i32, +} + +impl Expression for YearRange +where + Expr: Expression, +{ + type SqlType = diesel::sql_types::Bool; +} + +impl diesel::query_builder::QueryFragment for YearRange +where + Expr: diesel::query_builder::QueryFragment, + DB: diesel::backend::Backend, + i32: diesel::serialize::ToSql, +{ + fn walk_ast<'b>( + &'b self, + mut out: diesel::query_builder::AstPass<'_, 'b, DB>, + ) -> diesel::result::QueryResult<()> { + out.push_sql("("); + self.expr.walk_ast(out.reborrow())?; + out.push_sql(" BETWEEN make_date("); + out.push_bind_param::(&self.from_year)?; + out.push_sql(", 1, 1) AND make_date("); + out.push_bind_param::(&self.to_year)?; + out.push_sql(", 12, 31))"); + Ok(()) + } +} + +impl_expression_boilerplate!(YearRange); + +// Year types that can't name BC years, whose values are all valid +// `make_date()` years except 0 (documented on the impls). `i32` +// deliberately gets only the plain `date_part(..)` comparison instead: part +// of its range are years that PostgreSQL rejects at runtime. +macro_rules! year_range_bounds { + ($($ty:ty),*) => {$( + /// `(expr BETWEEN make_date(year, 1, 1) AND make_date(year, 12, 31))` + /// + /// Equivalent to comparing `date_part('year', expr)` on a date column, + /// but the plain range comparison lets PostgreSQL use a B-tree index + /// on the column. Only the `i32` year is bound, so this works no + /// matter which Rust date library the application uses. + /// + /// PostgreSQL has no year 0: passing 0 fails at runtime with + /// `date field value out of range`. + impl DatePartEq> for $ty + where + Expr: Expression, + Expr::SqlType: DateOnly, + { + type Output = YearRange; + + fn eq_date_part(self, lhs: YearPart) -> Self::Output { + DatePartBetween::between_date_part(lhs, self, self) + } + } + + /// `(expr BETWEEN make_date(lower, 1, 1) AND make_date(upper, 12, 31))` + /// + /// Equivalent to range-comparing `date_part('year', expr)` on a date + /// column (a span of whole years is still a single date range), but + /// lets PostgreSQL use a B-tree index on the column. Only the `i32` + /// years are bound, so this works no matter which Rust date library + /// the application uses. + /// + /// PostgreSQL has no year 0: passing 0 as either bound fails at + /// runtime with `date field value out of range`. + impl DatePartBetween> for $ty + where + Expr: Expression, + Expr::SqlType: DateOnly, + { + type Output = YearRange; + + fn between_date_part(lhs: YearPart, lower: Self, upper: Self) -> Self::Output { + YearRange { + expr: lhs.expr, + from_year: i32::from(lower), + to_year: i32::from(upper), + } + } + } + )*}; +} + +year_range_bounds!(u8, u16, U15); diff --git a/benzina/src/int.rs b/benzina/src/int.rs index 6364cae..93284a3 100644 --- a/benzina/src/int.rs +++ b/benzina/src/int.rs @@ -239,6 +239,18 @@ macro_rules! from_primitive_numbers { } } +macro_rules! into_signed_primitive_numbers { + ($($from:ident => $to:ident),*) => { + $( + impl From<$from> for $to { + fn from(value: $from) -> Self { + value.get_signed().into() + } + } + )* + } +} + impl_numbers! { U15 => u16, i16, SmallInt, U31 => u32, i32, Integer, @@ -260,6 +272,12 @@ from_primitive_numbers! { u32 => U63 } +into_signed_primitive_numbers! { + U15 => i32, + U15 => i64, + U31 => i64 +} + #[cfg(test)] mod tests { use super::{U15, U31, U63}; diff --git a/benzina/src/lib.rs b/benzina/src/lib.rs index 0fd844d..b6a8203 100644 --- a/benzina/src/lib.rs +++ b/benzina/src/lib.rs @@ -6,9 +6,22 @@ pub use benzina_derive::{Enum, join}; #[cfg(feature = "array")] pub use self::array::{Array, ArrayWithNullableItems}; #[cfg(feature = "postgres")] +pub use self::at_time_zone::{ + AtTimeZone, FromInstant, FromUtc, InstantLike, TimeZone, at_time_zone, +}; +#[cfg(feature = "postgres")] pub use self::binary::Binary; #[cfg(feature = "ctid")] pub use self::ctid::{Ctid, ctid}; +#[cfg(feature = "postgres")] +pub use self::date_eq::{ + ClosedRange, DateEq, DateLike, DateOnly, ExtractedDate, HalfOpenRange, extract_date, +}; +#[cfg(feature = "postgres")] +pub use self::date_part::{ + DateOrTimestamp, DatePartBetween, DatePartEq, DayPart, MonthPart, YearPart, YearRange, + extract_day, extract_month, extract_year, +}; pub use self::either::Either; #[cfg(feature = "postgres")] pub use self::int::{U15, U31, U63}; @@ -18,15 +31,23 @@ pub use self::json::{ binary::Jsonb, nullable::{NullableJson, NullableJsonb}, }; +#[cfg(feature = "postgres")] +pub use self::time_eq::{BareTime, CastToTime, ExtractedTime, TimeEq, TimeLike, extract_time}; #[doc(hidden)] pub mod __private; #[cfg(feature = "array")] mod array; #[cfg(feature = "postgres")] +mod at_time_zone; +#[cfg(feature = "postgres")] mod binary; #[cfg(feature = "ctid")] mod ctid; +#[cfg(feature = "postgres")] +mod date_eq; +#[cfg(feature = "postgres")] +mod date_part; mod either; #[cfg(feature = "postgres")] pub mod error; @@ -44,6 +65,8 @@ mod schemars; mod serde; #[cfg(feature = "postgres")] pub mod sql_types; +#[cfg(feature = "postgres")] +mod time_eq; #[cfg(feature = "typed-uuid")] mod typed_uuid; #[cfg(all(feature = "utoipa", feature = "postgres"))] diff --git a/benzina/src/time_eq.rs b/benzina/src/time_eq.rs new file mode 100644 index 0000000..62d63a6 --- /dev/null +++ b/benzina/src/time_eq.rs @@ -0,0 +1,274 @@ +//! PostgreSQL time-of-day expressions for comparing the time part of +//! time/timestamp columns. + +use std::marker::PhantomData; + +use diesel::expression::{AsExpression, Expression}; +use diesel::sql_types::{Bool, Nullable, SqlType, Time, Timestamp}; + +/// SQL types accepted by [`extract_time`]: `Time` and `Timestamp`, plus +/// their `Nullable` variants. +/// +/// `Timestamptz` is excluded on purpose: its cast to `time` depends on the +/// session time zone (the cast is only `STABLE`), so PostgreSQL also +/// rejects an expression index built on it. Pick the zone explicitly by +/// converting first: `extract_time(Rome::at(col))` — see +/// [`TimeZone`](crate::TimeZone). +/// +/// Nullable inputs are accepted for filtering (a `NULL` comparison +/// excludes the row), but [`ExtractedTime`] still claims a non-nullable +/// `Time`, so don't `SELECT` it off a nullable column. +pub trait TimeLike: SqlType { + /// Marker picking the SQL emitted by [`ExtractedTime`]: [`BareTime`] + /// for time columns, [`CastToTime`] for timestamps. + type Cast; +} +impl TimeLike for Time { + type Cast = BareTime; +} +impl TimeLike for Timestamp { + type Cast = CastToTime; +} +impl TimeLike for Nullable { + type Cast = T::Cast; +} + +/// Marker for `Time` columns — the value already is the time-of-day. +/// +/// The column is emitted bare, so comparisons are driven by a plain +/// B-tree index on it. +#[derive(Debug, Clone, Copy)] +pub struct BareTime; + +/// Marker for `Timestamp` columns — the time-of-day must be computed. +/// +/// The column is emitted as `CAST(expr AS time)`, so comparisons can only +/// use an expression index built on exactly that expression, see +/// [`extract_time`]. +#[derive(Debug, Clone, Copy)] +pub struct CastToTime; + +/// Extracts the time-of-day of a time/timestamp expression. +/// +/// The result is compared with the regular diesel operators (`.eq()`, +/// `.between()`, ...), which only accept `Time` values on the other side: +/// +/// - `Time` columns are emitted bare, so a plain B-tree index on the +/// column drives the comparison; their [`eq`](ExtractedTime::eq) emits +/// the `(expr BETWEEN rhs AND rhs)` range form, like +/// [`extract_date`](crate::extract_date); +/// - `Timestamp` columns are emitted as `CAST(expr AS time)` and compared +/// with the plain operators, which can only use an expression index +/// built on exactly that expression: +/// +/// ```sql +/// CREATE INDEX ... ON tbl ((CAST(col AS time))); +/// ``` +/// +/// (`(col)::time` parses to the same expression and matches too.) +/// +/// Beware of `between` bounds crossing midnight: `BETWEEN '23:00' AND +/// '01:00'` is the empty range. Split it into a `.ge(..)` OR `.lt(..)` +/// pair instead. +/// +/// # Examples +/// +/// ``` +/// use benzina::extract_time; +/// use diesel::{ExpressionMethods, QueryDsl, debug_query, pg::Pg}; +/// +/// diesel::table! { +/// shifts (id) { +/// id -> Integer, +/// starts_at -> Time, +/// created_at -> Timestamp, +/// } +/// } +/// +/// // `Time` column: stays bare, `eq` uses the range form +/// let q = shifts::table +/// .filter(extract_time(shifts::starts_at).eq(extract_time(shifts::created_at))); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"("shifts"."starts_at" BETWEEN CAST("shifts"."created_at" AS time) AND CAST("shifts"."created_at" AS time))"# +/// )); +/// +/// // `Timestamp` column: cast to `time`, compared with the regular operators +/// let q = shifts::table +/// .filter(extract_time(shifts::created_at).gt(extract_time(shifts::starts_at))); +/// assert!(debug_query::(&q).to_string().contains( +/// r#"CAST("shifts"."created_at" AS time) > "shifts"."starts_at""# +/// )); +/// ``` +pub fn extract_time(expr: Expr) -> ExtractedTime::Cast> +where + Expr: Expression, + Expr::SqlType: TimeLike, +{ + ExtractedTime { + expr, + cast: PhantomData, + } +} + +/// The return type of [`extract_time`]. +/// +/// Compare it with the regular diesel operators (`.eq()`, `.between()`, +/// ...), or with its own [`eq`](ExtractedTime::eq) on `Time` columns. +#[derive(Debug, Clone, Copy)] +pub struct ExtractedTime { + expr: Expr, + cast: PhantomData, +} + +impl ExtractedTime { + /// Returns the inner time/timestamp expression. + pub fn into_inner(self) -> Expr { + self.expr + } +} + +impl ExtractedTime { + /// `(expr BETWEEN rhs AND rhs)` — the same `Time` value on both sides, + /// equivalent to `= rhs` and driven by a B-tree index on the column. + /// + /// Shadows [`ExpressionMethods::eq`](diesel::ExpressionMethods::eq) so + /// that time columns get the same range form as + /// [`extract_date`](crate::extract_date). + /// + /// `rhs` is emitted twice, so keep it to columns and binds — a + /// volatile expression would be evaluated twice. + pub fn eq(self, rhs: Rhs) -> TimeEq + where + Rhs: AsExpression