From 8c6275a47052b356f7fea4c707160712fb16f57b Mon Sep 17 00:00:00 2001 From: Manuel Pelloni Date: Tue, 21 Jul 2026 09:49:48 +0000 Subject: [PATCH 1/5] feat: add U* into signed int conversions --- benzina/src/int.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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}; From c8be86c03932a4ff3af7d202911cbbd1fc3537e5 Mon Sep 17 00:00:00 2001 From: Manuel Pelloni Date: Tue, 21 Jul 2026 09:50:16 +0000 Subject: [PATCH 2/5] feat(postgres): add extract_date for index-friendly date equality Rewrites date equality as a range that keeps the column bare, so a plain B-tree index can drive it: a closed BETWEEN on Date columns (ClosedRange), a half-open >= / < + 1 pair on Timestamp columns (HalfOpenRange), picked at compile time via DateLike::Range. Timestamptz is rejected: which instants fall on a calendar date depends on the session time zone. --- benzina/src/date_eq.rs | 233 +++++++++++++++++++++++++++++++++++++++ benzina/src/lib.rs | 6 + benzina/tests/date_eq.rs | 38 +++++++ 3 files changed, 277 insertions(+) create mode 100644 benzina/src/date_eq.rs create mode 100644 benzina/tests/date_eq.rs diff --git a/benzina/src/date_eq.rs b/benzina/src/date_eq.rs new file mode 100644 index 0000000..c50d1b7 --- /dev/null +++ b/benzina/src/date_eq.rs @@ -0,0 +1,233 @@ +//! 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. +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. +/// +/// # 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/lib.rs b/benzina/src/lib.rs index 0fd844d..ced1706 100644 --- a/benzina/src/lib.rs +++ b/benzina/src/lib.rs @@ -9,6 +9,10 @@ pub use self::array::{Array, ArrayWithNullableItems}; 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, +}; pub use self::either::Either; #[cfg(feature = "postgres")] pub use self::int::{U15, U31, U63}; @@ -27,6 +31,8 @@ mod array; mod binary; #[cfg(feature = "ctid")] mod ctid; +#[cfg(feature = "postgres")] +mod date_eq; mod either; #[cfg(feature = "postgres")] pub mod error; diff --git a/benzina/tests/date_eq.rs b/benzina/tests/date_eq.rs new file mode 100644 index 0000000..02e3802 --- /dev/null +++ b/benzina/tests/date_eq.rs @@ -0,0 +1,38 @@ +#![cfg(feature = "postgres")] + +use benzina::extract_date; +use diesel::{QueryDsl, debug_query, pg::Pg}; + +diesel::table! { + events (id) { + id -> Integer, + d -> Date, + ts -> Timestamp, + nd -> Nullable, + } +} + +const SELECT: &str = + r#"SELECT "events"."id", "events"."d", "events"."ts", "events"."nd" FROM "events" WHERE "#; + +#[test] +fn date_eq_emits_a_between_range() { + let q = events::table.filter(extract_date(events::nd).eq(events::d)); + assert_eq!( + debug_query::(&q).to_string(), + format!( + "{SELECT}(\"events\".\"nd\" BETWEEN \"events\".\"d\" AND \"events\".\"d\") -- binds: []" + ) + ); +} + +#[test] +fn date_eq_on_timestamp_covers_the_whole_day() { + let q = events::table.filter(extract_date(events::ts).eq(events::d)); + assert_eq!( + debug_query::(&q).to_string(), + format!( + "{SELECT}(\"events\".\"ts\" >= \"events\".\"d\" AND \"events\".\"ts\" < \"events\".\"d\" + 1) -- binds: []" + ) + ); +} From 8fae62bc9a5edcb3251a7fd1a720e9fbb7f4dcd8 Mon Sep 17 00:00:00 2001 From: Manuel Pelloni Date: Tue, 21 Jul 2026 09:51:03 +0000 Subject: [PATCH 3/5] feat(postgres): add date_part expressions with year range rewriting extract_year/extract_month/extract_day emit CAST(date_part('', expr) AS integer), the exact form an expression index must be built on (EXTRACT maps to a different catalog function; the two diverged in PostgreSQL 14). On date columns, bounded year types (u8, u16, U15) rewrite eq/between into a plain BETWEEN make_date(..) AND make_date(..) range (YearRange) so a B-tree index on the column works; i32 years keep the date_part comparison because part of their range is rejected by make_date() at runtime. --- benzina/src/date_part.rs | 362 +++++++++++++++++++++++++++++++++++++ benzina/src/lib.rs | 7 + benzina/tests/date_part.rs | 92 ++++++++++ 3 files changed, 461 insertions(+) create mode 100644 benzina/src/date_part.rs create mode 100644 benzina/tests/date_part.rs diff --git a/benzina/src/date_part.rs b/benzina/src/date_part.rs new file mode 100644 index 0000000..fa31e06 --- /dev/null +++ b/benzina/src/date_part.rs @@ -0,0 +1,362 @@ +//! 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`]. +/// +/// 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/lib.rs b/benzina/src/lib.rs index ced1706..d2dbb8e 100644 --- a/benzina/src/lib.rs +++ b/benzina/src/lib.rs @@ -13,6 +13,11 @@ pub use self::ctid::{Ctid, ctid}; 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}; @@ -33,6 +38,8 @@ mod binary; mod ctid; #[cfg(feature = "postgres")] mod date_eq; +#[cfg(feature = "postgres")] +mod date_part; mod either; #[cfg(feature = "postgres")] pub mod error; diff --git a/benzina/tests/date_part.rs b/benzina/tests/date_part.rs new file mode 100644 index 0000000..30236d6 --- /dev/null +++ b/benzina/tests/date_part.rs @@ -0,0 +1,92 @@ +#![cfg(feature = "postgres")] + +use benzina::{U15, extract_month, extract_year}; +use diesel::{QueryDsl, debug_query, pg::Pg}; + +diesel::table! { + events (id) { + id -> Integer, + d -> Date, + ts -> Timestamp, + nd -> Nullable, + } +} + +const SELECT: &str = + r#"SELECT "events"."id", "events"."d", "events"."ts", "events"."nd" FROM "events" WHERE "#; + +#[test] +fn year_eq_rewrites_to_make_date_range() { + let q = events::table.filter(extract_year(events::d).eq(2024u16)); + assert_eq!( + debug_query::(&q).to_string(), + format!( + "{SELECT}(\"events\".\"d\" BETWEEN make_date($1, 1, 1) AND make_date($2, 12, 31)) -- binds: [2024, 2024]" + ) + ); +} + +#[test] +fn year_between_binds_both_years() { + let q = events::table + .filter(extract_year(events::d).between(U15::new(2020).unwrap(), U15::new(2024).unwrap())); + assert!( + debug_query::(&q) + .to_string() + .ends_with("-- binds: [2020, 2024]") + ); +} + +#[test] +fn year_range_works_on_nullable_date_columns() { + let q = events::table.filter(extract_year(events::nd).eq(2024u16)); + assert!( + debug_query::(&q) + .to_string() + .ends_with("-- binds: [2024, 2024]") + ); +} + +#[test] +fn i32_year_falls_back_to_date_part() { + let q = events::table.filter(extract_year(events::d).eq(-5)); + assert_eq!( + debug_query::(&q).to_string(), + format!( + "{SELECT}(CAST(date_part('year', \"events\".\"d\") AS integer) = $1) -- binds: [-5]" + ) + ); +} + +#[test] +fn i32_year_works_on_timestamp_columns() { + let q = events::table.filter(extract_year(events::ts).eq(2024)); + assert_eq!( + debug_query::(&q).to_string(), + format!( + "{SELECT}(CAST(date_part('year', \"events\".\"ts\") AS integer) = $1) -- binds: [2024]" + ) + ); +} + +#[test] +fn month_compares_the_indexable_expression() { + let q = events::table.filter(extract_month(events::ts).between(3, 5)); + assert_eq!( + debug_query::(&q).to_string(), + format!( + "{SELECT}(CAST(date_part('month', \"events\".\"ts\") AS integer) BETWEEN $1 AND $2) -- binds: [3, 5]" + ) + ); +} + +#[test] +fn month_eq_month_compares_both_expressions() { + let q = events::table + .filter(extract_month(events::d).eq(extract_month(events::ts))) + .select(events::id); + assert_eq!( + debug_query::(&q).to_string(), + "SELECT \"events\".\"id\" FROM \"events\" WHERE (CAST(date_part('month', \"events\".\"d\") AS integer) = CAST(date_part('month', \"events\".\"ts\") AS integer)) -- binds: []" + ); +} From 455d13233f253eea434226f315f910b2f352ffd8 Mon Sep 17 00:00:00 2001 From: Manuel Pelloni Date: Tue, 21 Jul 2026 13:28:43 +0000 Subject: [PATCH 4/5] feat(postgres): add extract_time for index-friendly time-of-day comparisons Time columns stay bare (BareTime) so a plain B-tree index drives the comparison, and their eq uses the same range form as extract_date. Timestamp columns are emitted as CAST(expr AS time) (CastToTime), which matches an expression index built on exactly that cast. Timestamptz is rejected: its cast to time depends on the session time zone (it is only STABLE), so PostgreSQL rejects the expression index too. --- benzina/src/lib.rs | 4 + benzina/src/time_eq.rs | 272 +++++++++++++++++++++++++++++++++++++++ benzina/tests/time_eq.rs | 58 +++++++++ 3 files changed, 334 insertions(+) create mode 100644 benzina/src/time_eq.rs create mode 100644 benzina/tests/time_eq.rs diff --git a/benzina/src/lib.rs b/benzina/src/lib.rs index d2dbb8e..a178b84 100644 --- a/benzina/src/lib.rs +++ b/benzina/src/lib.rs @@ -27,6 +27,8 @@ 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; @@ -57,6 +59,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..53f1060 --- /dev/null +++ b/benzina/src/time_eq.rs @@ -0,0 +1,272 @@ +//! 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. +/// +/// 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