Skip to content

Commit 5deb417

Browse files
committed
feat: implement Array and ArrayWithNullableItems wrapper types
1 parent 82b4dcd commit 5deb417

File tree

4 files changed

+291
-0
lines changed

4 files changed

+291
-0
lines changed

benzina/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ utoipa = ["dep:utoipa"]
5151
example-generated = ["typed-uuid"]
5252
dangerous-construction = ["typed-uuid"]
5353

54+
array = ["postgres"]
5455
json = ["postgres", "dep:serde_core", "dep:serde_json", "diesel/serde_json"]
5556
ctid = ["postgres", "diesel/i-implement-a-third-party-backend-and-opt-into-breaking-changes"]
5657

benzina/src/array.rs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
use std::fmt::Debug;
2+
3+
use diesel::{
4+
deserialize::{FromSql, FromSqlRow},
5+
expression::{AppearsOnTable, Expression, SelectableExpression},
6+
pg::{Pg, PgValue},
7+
query_builder::{AstPass, QueryFragment, QueryId},
8+
result::QueryResult,
9+
serialize::ToSql,
10+
sql_types::{self, BigInt, Bool, Double, Float, Integer, Nullable, SmallInt, Text},
11+
};
12+
13+
use crate::{U15, U31, U63, error::InvalidArray};
14+
15+
/// A diesel [`Array`] serialization and deserialization wrapper __without__ NULL items
16+
///
17+
/// Since postgres ignores the array dimension (if specified), diesel implements
18+
/// [`FromSql`] for `Vec<T>` and [`ToSql`] for `Vec<T>`/`&[T]`.
19+
/// In addition postgres also considers array items as always nullable.
20+
/// This makes it hard to deal with real arrays that have a predetermined length
21+
/// and an homogeneous nullability.
22+
/// This type checks at runtime the array length and the __non__ nullability of its items,
23+
/// therefore to be completely safe, you should also add the following `CHECK` constraints:
24+
/// ```sql
25+
/// array_ndims(array_field) = 1 AND
26+
/// array_length(array_field, 1) = N AND
27+
/// array_position(array_field, NULL) IS NULL
28+
/// ```
29+
///
30+
/// This type is not intended to be used directly in the model but rather to be
31+
/// used with diesel [`serialize_as`] and [`deserialize_as`].
32+
///
33+
/// ```
34+
/// use benzina::{Array, U31};
35+
/// use diesel::{Insertable, Queryable};
36+
///
37+
/// #[derive(Debug, Queryable)]
38+
/// #[diesel(table_name = users, check_for_backend(diesel::pg::Pg))]
39+
/// struct User {
40+
/// id: U31,
41+
/// first_name: String,
42+
/// last_name: String,
43+
/// #[diesel(deserialize_as = Array<bool, 5>)]
44+
/// flags: UserFlags,
45+
/// }
46+
///
47+
/// #[derive(Debug, Insertable)]
48+
/// #[diesel(table_name = users)]
49+
/// struct NewUser {
50+
/// first_name: String,
51+
/// last_name: String,
52+
/// #[diesel(serialize_as = Array<bool, 5>)]
53+
/// flags: UserFlags,
54+
/// }
55+
///
56+
/// #[derive(Debug)]
57+
/// struct UserFlags([bool; 5]);
58+
///
59+
/// // needed by deserialize_as
60+
/// impl From<benzina::Array<bool, 5>> for UserFlags {
61+
/// fn from(value: benzina::Array<bool, 5>) -> Self {
62+
/// Self(value.into_inner())
63+
/// }
64+
/// }
65+
///
66+
/// // needed by serialize_as
67+
/// impl From<UserFlags> for benzina::Array<bool, 5> {
68+
/// fn from(value: UserFlags) -> Self {
69+
/// Self::new(value.0)
70+
/// }
71+
/// }
72+
///
73+
/// diesel::table! {
74+
/// users (id) {
75+
/// id -> Int4,
76+
/// first_name -> Text,
77+
/// last_name -> Text,
78+
/// flags -> Array<Nullable<Bool>>,
79+
/// }
80+
/// }
81+
/// ```
82+
///
83+
/// [`Array`]: diesel::sql_types::Array
84+
/// [`serialize_as`]: diesel::prelude::Insertable#optional-field-attributes
85+
/// [`deserialize_as`]: diesel::prelude::Queryable#deserialize_as-attribute
86+
#[derive(Debug, FromSqlRow)]
87+
pub struct Array<T, const N: usize>([T; N]);
88+
impl<T, const N: usize> Array<T, N> {
89+
#[must_use]
90+
pub fn new(values: [T; N]) -> Self {
91+
Self(values)
92+
}
93+
94+
#[must_use]
95+
pub fn into_inner(self) -> [T; N] {
96+
self.0
97+
}
98+
}
99+
100+
/// A diesel [`Array`](diesel::sql_types::Array) serialization and deserialization wrapper __with__ NULL items
101+
///
102+
/// This type works exactly as benzina [`Array`](crate::Array), with the following execeptions:
103+
/// - it does not require __non__ nullable items
104+
/// - it shouldn't be used with the following nullability `CHECK` constraint:
105+
/// ```sql
106+
/// array_position(array_field, NULL) IS NULL
107+
/// ```
108+
#[derive(Debug, FromSqlRow)]
109+
pub struct ArrayWithNullableItems<T, const N: usize>([Option<T>; N]);
110+
impl<T, const N: usize> ArrayWithNullableItems<T, N> {
111+
#[must_use]
112+
pub fn new(values: [Option<T>; N]) -> Self {
113+
Self(values)
114+
}
115+
116+
#[must_use]
117+
pub fn into_inner(self) -> [Option<T>; N] {
118+
self.0
119+
}
120+
}
121+
122+
macro_rules! impl_array {
123+
(
124+
$(
125+
$rust_type:tt $(< $generic:ident >)? => $diesel_type:ident
126+
),*
127+
) => {
128+
$(
129+
impl<$($generic,)? const N: usize> Expression for Array<$rust_type$(<$generic>)?, N> {
130+
type SqlType = sql_types::Array<Nullable<$diesel_type>>;
131+
}
132+
133+
impl<$($generic,)? const N: usize> QueryId for Array<$rust_type$(<$generic>)?, N> {
134+
type QueryId = <sql_types::Array<Nullable<$diesel_type>> as QueryId>::QueryId;
135+
136+
const HAS_STATIC_QUERY_ID: bool = <sql_types::Array<Nullable<$diesel_type>> as QueryId>::HAS_STATIC_QUERY_ID;
137+
}
138+
139+
impl<$($generic: Debug + std::clone::Clone,)? const N: usize> QueryFragment<Pg> for Array<$rust_type$(<$generic>)?, N>
140+
{
141+
fn walk_ast<'b>(&'b self, mut pass: AstPass<'_, 'b, Pg>) -> QueryResult<()> {
142+
pass.push_bind_param(self)?;
143+
Ok(())
144+
}
145+
}
146+
147+
impl<__QS, $($generic,)? const N: usize> AppearsOnTable<__QS> for Array<$rust_type$(<$generic>)?, N> {}
148+
149+
impl<__QS, $($generic,)? const N: usize> SelectableExpression<__QS> for Array<$rust_type$(<$generic>)?, N> {}
150+
151+
impl<$($generic: Debug + std::clone::Clone,)? const N: usize> ToSql<sql_types::Array<Nullable<$diesel_type>>, Pg> for Array<$rust_type$(<$generic>)?, N>
152+
{
153+
fn to_sql<'b>(
154+
&'b self,
155+
out: &mut diesel::serialize::Output<'b, '_, Pg>,
156+
) -> diesel::serialize::Result {
157+
<[$rust_type $(< $generic >)?] as ToSql<sql_types::Array<$diesel_type>, Pg>>::to_sql(&self.0.as_slice(), out)
158+
}
159+
}
160+
161+
impl<$($generic: Debug,)? const N: usize> FromSql<sql_types::Array<Nullable<$diesel_type>>, Pg> for Array<$rust_type$(< $generic >)?, N>
162+
{
163+
fn from_sql(bytes: PgValue<'_>) -> diesel::deserialize::Result<Self> {
164+
let raw = <Vec<Option<$rust_type $(< $generic >)?>> as FromSql<sql_types::Array<Nullable<$diesel_type>>, Pg>>::from_sql(bytes)?;
165+
166+
let res: [$rust_type $(< $generic >)?; N] = raw
167+
.into_iter()
168+
.collect::<Option<Vec<$rust_type $(< $generic >)?>>>()
169+
.ok_or(diesel::result::Error::DeserializationError(Box::new(
170+
InvalidArray::UnexpectedNullValue,
171+
)))?
172+
.try_into()
173+
.map_err(|_| {
174+
diesel::result::Error::DeserializationError(Box::new(
175+
InvalidArray::UnexpectedLength,
176+
))
177+
})?;
178+
179+
Ok(Self(res))
180+
}
181+
}
182+
)*
183+
}
184+
}
185+
186+
macro_rules! impl_array_with_nullable_items {
187+
(
188+
$(
189+
$rust_type:tt $(< $generic:ident >)? => $diesel_type:ident
190+
),*
191+
) => {
192+
$(
193+
impl<$($generic,)? const N: usize> Expression for ArrayWithNullableItems<$rust_type$(<$generic>)?, N> {
194+
type SqlType = sql_types::Array<Nullable<$diesel_type>>;
195+
}
196+
197+
impl<$($generic,)? const N: usize> QueryId for ArrayWithNullableItems<$rust_type$(<$generic>)?, N> {
198+
type QueryId = <sql_types::Array<Nullable<$diesel_type>> as QueryId>::QueryId;
199+
200+
const HAS_STATIC_QUERY_ID: bool = <sql_types::Array<Nullable<$diesel_type>> as QueryId>::HAS_STATIC_QUERY_ID;
201+
}
202+
203+
impl<$($generic: Debug + std::clone::Clone,)? const N: usize> QueryFragment<Pg> for ArrayWithNullableItems<$rust_type$(<$generic>)?, N>
204+
{
205+
fn walk_ast<'b>(&'b self, mut pass: AstPass<'_, 'b, Pg>) -> QueryResult<()> {
206+
pass.push_bind_param(self)?;
207+
Ok(())
208+
}
209+
}
210+
211+
impl<__QS, $($generic,)? const N: usize> AppearsOnTable<__QS> for ArrayWithNullableItems<$rust_type$(<$generic>)?, N> {}
212+
impl<__QS, $($generic,)? const N: usize> SelectableExpression<__QS> for ArrayWithNullableItems<$rust_type$(<$generic>)?, N> {}
213+
214+
215+
impl<$($generic: Debug + std::clone::Clone,)? const N: usize> ToSql<sql_types::Array<Nullable<$diesel_type>>, Pg> for ArrayWithNullableItems<$rust_type$(<$generic>)?, N>
216+
{
217+
fn to_sql<'b>(
218+
&'b self,
219+
out: &mut diesel::serialize::Output<'b, '_, Pg>,
220+
) -> diesel::serialize::Result {
221+
<[Option<$rust_type$(< $generic >)?>] as ToSql<sql_types::Array<Nullable<$diesel_type>>, Pg>>::to_sql(self.0.as_slice(), out)
222+
}
223+
}
224+
225+
impl<$($generic: Debug,)? const N: usize> FromSql<sql_types::Array<Nullable<$diesel_type>>, Pg> for ArrayWithNullableItems<$rust_type$(< $generic >)?, N>
226+
{
227+
fn from_sql(bytes: PgValue<'_>) -> diesel::deserialize::Result<Self> {
228+
let raw = <Vec<Option<$rust_type$(< $generic >)?>> as FromSql<sql_types::Array<Nullable<$diesel_type>>, Pg>>::from_sql(bytes)?;
229+
230+
let res: [Option<$rust_type $(< $generic >)?>; N] = raw
231+
.try_into()
232+
.map_err(|_| {
233+
diesel::result::Error::DeserializationError(Box::new(
234+
InvalidArray::UnexpectedLength,
235+
))
236+
})?;
237+
238+
Ok(Self(res))
239+
}
240+
}
241+
)*
242+
};
243+
}
244+
245+
impl_array! {
246+
U15 => SmallInt,
247+
U31 => Integer,
248+
U63 => BigInt,
249+
i16 => SmallInt,
250+
i32 => Integer,
251+
i64 => BigInt,
252+
f32 => Float,
253+
f64 => Double,
254+
bool => Bool,
255+
String => Text
256+
}
257+
258+
impl_array_with_nullable_items! {
259+
U15 => SmallInt,
260+
U31 => Integer,
261+
U63 => BigInt,
262+
i16 => SmallInt,
263+
i32 => Integer,
264+
i64 => BigInt,
265+
f32 => Float,
266+
f64 => Double,
267+
bool => Bool,
268+
String => Text
269+
}

benzina/src/error.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,20 @@ impl Error for ParseIntError {
3838
}
3939
}
4040
}
41+
42+
#[derive(Debug, Clone)]
43+
pub enum InvalidArray {
44+
UnexpectedLength,
45+
UnexpectedNullValue,
46+
}
47+
48+
impl Display for InvalidArray {
49+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50+
f.write_str(match self {
51+
Self::UnexpectedLength => "mismatched array length",
52+
Self::UnexpectedNullValue => "the array contains an unexpected null value",
53+
})
54+
}
55+
}
56+
57+
impl Error for InvalidArray {}

benzina/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
22

3+
#[cfg(feature = "array")]
4+
pub use self::array::{Array, ArrayWithNullableItems};
35
#[cfg(feature = "ctid")]
46
pub use self::ctid::{Ctid, ctid};
57
#[cfg(feature = "postgres")]
@@ -15,6 +17,8 @@ pub use benzina_derive::{Enum, join};
1517

1618
#[doc(hidden)]
1719
pub mod __private;
20+
#[cfg(feature = "array")]
21+
mod array;
1822
#[cfg(feature = "ctid")]
1923
mod ctid;
2024
#[cfg(feature = "postgres")]

0 commit comments

Comments
 (0)