Skip to main content

vld_sqlx/
lib.rs

1//! # vld-sqlx — SQLx integration for `vld`
2//!
3//! Validate values against a [`vld`] schema before writing them with SQLx
4//! ([`validate_insert`], [`Validated`]) or after loading them
5//! ([`validate_row`], [`validate_rows`]), and bind validated column wrappers
6//! ([`VldText`], [`VldInt`], [`VldFloat`], [`VldBool`]) that implement
7//! `sqlx::Type`, `Encode` and `Decode`.
8//!
9//! Features: `sqlx-0_8` (default) or `sqlx-0_9` — exactly one; `sqlite`
10//! (default), `postgres`, `mysql`.
11//!
12//! ```
13//! use vld_sqlx::prelude::*;
14//!
15//! vld::schema! {
16//!     #[derive(Debug)]
17//!     pub struct NewUserSchema {
18//!         pub name: String  => vld::string().min(1).max(100),
19//!         pub email: String => vld::string().email(),
20//!     }
21//! }
22//!
23//! // Column schemas validate `{"value": ...}`.
24//! vld::schema! {
25//!     #[derive(Debug)]
26//!     pub struct EmailField {
27//!         pub value: String => vld::string().email(),
28//!     }
29//! }
30//!
31//! #[derive(serde::Serialize)]
32//! struct NewUser { name: String, email: String }
33//!
34//! let user = NewUser { name: "Alice".into(), email: "alice@example.com".into() };
35//! validate_insert::<NewUserSchema, _>(&user).unwrap();
36//! // sqlx::query("INSERT ...").bind(&user.name).bind(&user.email).execute(&pool).await?;
37//!
38//! let email = VldText::<EmailField>::new("carol@example.com").unwrap(); // bind(&email)
39//! assert!(VldText::<EmailField>::new("bad").is_err());
40//! # let _ = email;
41//! ```
42//!
43//! Decoding does not re-validate (data from the database is trusted); call
44//! [`validate_row`] to re-check loaded rows.
45//!
46//! Guide: <https://s00d.github.io/vld/integrations/database/sqlx>
47#![warn(missing_docs)]
48
49#[cfg(all(feature = "sqlx-0_8", feature = "sqlx-0_9"))]
50compile_error!("Enable exactly one SQLx version feature: `sqlx-0_8` or `sqlx-0_9`.");
51#[cfg(not(any(feature = "sqlx-0_8", feature = "sqlx-0_9")))]
52compile_error!("Enable one SQLx version feature: `sqlx-0_8` or `sqlx-0_9`.");
53
54#[cfg(feature = "sqlx-0_8")]
55use sqlx08 as sqlx;
56#[cfg(feature = "sqlx-0_9")]
57use sqlx09 as sqlx;
58
59pub use vld;
60
61#[cfg(feature = "sqlx-0_8")]
62type SqlxArgBuf<'q, DB> = <DB as sqlx::Database>::ArgumentBuffer<'q>;
63#[cfg(feature = "sqlx-0_9")]
64type SqlxArgBuf<'q, DB> = <DB as sqlx::Database>::ArgumentBuffer;
65
66// ========================= Error / Validated / wrappers ======================
67
68vld_integration_common::__vld_db_error!(
69    /// Error returned by `vld-sqlx` operations.
70    VldSqlxError
71);
72
73/// A validation failure happens before a value is written: it becomes
74/// [`sqlx::Error::Encode`] (not `Protocol`, which means a broken connection).
75impl From<VldSqlxError> for sqlx::Error {
76    fn from(e: VldSqlxError) -> Self {
77        sqlx::Error::Encode(Box::new(e))
78    }
79}
80
81vld_integration_common::__vld_db_validated!(VldSqlxError);
82
83vld_integration_common::__vld_db_wrappers! {
84    VldSqlxError;
85    text: []
86    int: []
87    float: []
88    bool: []
89}
90
91// ========================= Standalone helpers ================================
92
93/// Validate a value against schema `S` before inserting (or updating).
94///
95/// ```
96/// use vld::prelude::*;
97///
98/// vld::schema! {
99///     #[derive(Debug)]
100///     pub struct ItemSchema {
101///         pub name: String => vld::string().min(1),
102///         pub qty: i64 => vld::number().int().min(0),
103///     }
104/// }
105///
106/// #[derive(serde::Serialize)]
107/// struct NewItem { name: String, qty: i64 }
108///
109/// let item = NewItem { name: "Widget".into(), qty: 5 };
110/// vld_sqlx::validate_insert::<ItemSchema, _>(&item).unwrap();
111///
112/// let v = vld_sqlx::Validated::<ItemSchema, _>::new(item).unwrap();
113/// assert_eq!(v.inner().qty, 5);
114/// ```
115pub fn validate_insert<S, T>(value: &T) -> Result<(), VldSqlxError>
116where
117    S: vld::schema::VldParse,
118    T: serde::Serialize,
119{
120    __vld_validate::<S, T>(value).map(|_| ())
121}
122
123/// Validate a row loaded from the database against schema `S`
124/// (same check as [`validate_insert`]).
125pub fn validate_row<S, T>(value: &T) -> Result<(), VldSqlxError>
126where
127    S: vld::schema::VldParse,
128    T: serde::Serialize,
129{
130    validate_insert::<S, T>(value)
131}
132
133/// Validate a batch of rows against schema `S`.
134///
135/// Returns the index and error of the first invalid row.
136pub fn validate_rows<S, T>(rows: &[T]) -> Result<(), (usize, VldSqlxError)>
137where
138    S: vld::schema::VldParse,
139    T: serde::Serialize,
140{
141    for (i, row) in rows.iter().enumerate() {
142        validate_row::<S, T>(row).map_err(|e| (i, e))?;
143    }
144    Ok(())
145}
146
147// ========================= SQLx trait impls ==================================
148//
149// Encode/Decode delegate to the inner Rust type. Decoding does not re-validate
150// (data from the DB is trusted; use `validate_row` to re-check).
151
152macro_rules! sqlx_delegate {
153    ($Name:ident, $inner:ty) => {
154        impl<S, DB: sqlx::Database> sqlx::Type<DB> for $Name<S>
155        where
156            $inner: sqlx::Type<DB>,
157        {
158            fn type_info() -> DB::TypeInfo {
159                <$inner as sqlx::Type<DB>>::type_info()
160            }
161            fn compatible(ty: &DB::TypeInfo) -> bool {
162                <$inner as sqlx::Type<DB>>::compatible(ty)
163            }
164        }
165
166        impl<'q, S, DB: sqlx::Database> sqlx::Encode<'q, DB> for $Name<S>
167        where
168            $inner: sqlx::Encode<'q, DB>,
169        {
170            fn encode_by_ref(
171                &self,
172                buf: &mut SqlxArgBuf<'q, DB>,
173            ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
174                self.value.encode_by_ref(buf)
175            }
176        }
177
178        impl<'r, S, DB: sqlx::Database> sqlx::Decode<'r, DB> for $Name<S>
179        where
180            $inner: sqlx::Decode<'r, DB>,
181        {
182            fn decode(
183                value: <DB as sqlx::Database>::ValueRef<'r>,
184            ) -> Result<Self, sqlx::error::BoxDynError> {
185                Ok(Self::new_unchecked(
186                    <$inner as sqlx::Decode<'r, DB>>::decode(value)?,
187                ))
188            }
189        }
190    };
191}
192
193sqlx_delegate!(VldText, String);
194sqlx_delegate!(VldBool, bool);
195
196// VldFloat: accepts DOUBLE and REAL / FLOAT4 columns, encodes as f64. A REAL
197// is widened through its decimal form, so `0.1f32` reads back as `0.1`.
198impl<S, DB: sqlx::Database> sqlx::Type<DB> for VldFloat<S>
199where
200    f64: sqlx::Type<DB>,
201    f32: sqlx::Type<DB>,
202{
203    fn type_info() -> DB::TypeInfo {
204        <f64 as sqlx::Type<DB>>::type_info()
205    }
206    fn compatible(ty: &DB::TypeInfo) -> bool {
207        <f64 as sqlx::Type<DB>>::compatible(ty) || <f32 as sqlx::Type<DB>>::compatible(ty)
208    }
209}
210
211impl<'q, S, DB: sqlx::Database> sqlx::Encode<'q, DB> for VldFloat<S>
212where
213    f64: sqlx::Encode<'q, DB>,
214{
215    fn encode_by_ref(
216        &self,
217        buf: &mut SqlxArgBuf<'q, DB>,
218    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
219        self.value.encode_by_ref(buf)
220    }
221}
222
223impl<'r, S, DB: sqlx::Database> sqlx::Decode<'r, DB> for VldFloat<S>
224where
225    f64: sqlx::Decode<'r, DB> + sqlx::Type<DB>,
226    f32: sqlx::Decode<'r, DB> + sqlx::Type<DB>,
227{
228    fn decode(
229        value: <DB as sqlx::Database>::ValueRef<'r>,
230    ) -> Result<Self, sqlx::error::BoxDynError> {
231        use sqlx::ValueRef as _;
232        let ty = value.type_info().into_owned();
233        let v = if !<f64 as sqlx::Type<DB>>::compatible(&ty)
234            && <f32 as sqlx::Type<DB>>::compatible(&ty)
235        {
236            let f = <f32 as sqlx::Decode<'r, DB>>::decode(value)?;
237            f.to_string().parse().unwrap_or(f64::from(f))
238        } else {
239            <f64 as sqlx::Decode<'r, DB>>::decode(value)?
240        };
241        Ok(Self::new_unchecked(v))
242    }
243}
244
245// VldInt: accepts BIGINT, INTEGER and SMALLINT columns (Postgres INT8/INT4/INT2
246// are distinct types), encodes as i64.
247impl<S, DB: sqlx::Database> sqlx::Type<DB> for VldInt<S>
248where
249    i64: sqlx::Type<DB>,
250    i32: sqlx::Type<DB>,
251    i16: sqlx::Type<DB>,
252{
253    fn type_info() -> DB::TypeInfo {
254        <i64 as sqlx::Type<DB>>::type_info()
255    }
256    fn compatible(ty: &DB::TypeInfo) -> bool {
257        <i64 as sqlx::Type<DB>>::compatible(ty)
258            || <i32 as sqlx::Type<DB>>::compatible(ty)
259            || <i16 as sqlx::Type<DB>>::compatible(ty)
260    }
261}
262
263impl<'q, S, DB: sqlx::Database> sqlx::Encode<'q, DB> for VldInt<S>
264where
265    i64: sqlx::Encode<'q, DB>,
266{
267    fn encode_by_ref(
268        &self,
269        buf: &mut SqlxArgBuf<'q, DB>,
270    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
271        self.value.encode_by_ref(buf)
272    }
273}
274
275impl<'r, S, DB: sqlx::Database> sqlx::Decode<'r, DB> for VldInt<S>
276where
277    i64: sqlx::Decode<'r, DB> + sqlx::Type<DB>,
278    i32: sqlx::Decode<'r, DB> + sqlx::Type<DB>,
279    i16: sqlx::Decode<'r, DB> + sqlx::Type<DB>,
280{
281    fn decode(
282        value: <DB as sqlx::Database>::ValueRef<'r>,
283    ) -> Result<Self, sqlx::error::BoxDynError> {
284        use sqlx::ValueRef as _;
285        let ty = value.type_info().into_owned();
286        let v = if <i64 as sqlx::Type<DB>>::compatible(&ty) {
287            <i64 as sqlx::Decode<'r, DB>>::decode(value)?
288        } else if <i32 as sqlx::Type<DB>>::compatible(&ty) {
289            <i32 as sqlx::Decode<'r, DB>>::decode(value)?.into()
290        } else if <i16 as sqlx::Type<DB>>::compatible(&ty) {
291            <i16 as sqlx::Decode<'r, DB>>::decode(value)?.into()
292        } else {
293            <i64 as sqlx::Decode<'r, DB>>::decode(value)?
294        };
295        Ok(Self::new_unchecked(v))
296    }
297}
298
299#[cfg(all(test, feature = "postgres"))]
300mod pg_tests {
301    use super::*;
302    use sqlx::Type;
303    use sqlx::postgres::Postgres;
304
305    struct AnySchema;
306
307    #[test]
308    fn vld_float_compatible_with_float4() {
309        for ty in [
310            <f32 as Type<Postgres>>::type_info(),
311            <f64 as Type<Postgres>>::type_info(),
312        ] {
313            assert!(<VldFloat<AnySchema> as Type<Postgres>>::compatible(&ty));
314        }
315    }
316
317    #[test]
318    fn vld_int_compatible_with_int4_and_int2() {
319        for ty in [
320            <i16 as Type<Postgres>>::type_info(),
321            <i32 as Type<Postgres>>::type_info(),
322            <i64 as Type<Postgres>>::type_info(),
323        ] {
324            assert!(<VldInt<AnySchema> as Type<Postgres>>::compatible(&ty));
325        }
326    }
327}
328
329// ========================= Prelude ===========================================
330
331/// Common imports: the sqlx wrappers plus `vld::prelude`.
332pub mod prelude {
333    pub use crate::{
334        Validated, VldBool, VldFloat, VldInt, VldSqlxError, VldText, validate_insert, validate_row,
335        validate_rows,
336    };
337    pub use vld::prelude::*;
338}