Skip to main content

alibi_sqlx/
number.rs

1use crate::value::{ColumnKind, SqlValue, SqlxValue, ValueTypeError};
2use sqlx::error::BoxDynError;
3#[cfg(feature = "postgres")]
4use sqlx::postgres::{PgTypeInfo, PgValueRef, Postgres};
5#[cfg(feature = "sqlite")]
6use sqlx::sqlite::{Sqlite, SqliteTypeInfo, SqliteValueRef};
7use sqlx::{Decode, Type, ValueRef};
8
9/// Better Auth's number columns use SQLite INTEGER affinity.
10///
11/// SQLite may return either an integer or a real for the same column, including large JS chain
12/// IDs. PostgreSQL returns its INTEGER as i32. Retain the value as a JS Number without requiring a
13/// different persisted column type.
14#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize)]
15#[serde(transparent)]
16pub struct Number(pub f64);
17
18impl From<f64> for Number {
19    fn from(value: f64) -> Self {
20        Self(value)
21    }
22}
23
24impl SqlxValue for Number {
25    const KIND: ColumnKind = ColumnKind::Number;
26    fn null() -> SqlValue {
27        SqlValue::Double(None)
28    }
29    #[expect(
30        clippy::as_conversions,
31        clippy::cast_possible_truncation,
32        clippy::cast_precision_loss,
33        reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
34    )]
35    fn into_sql_value(self) -> SqlValue {
36        if self.0.fract() == 0.0 && self.0 >= i64::MIN as f64 && self.0 < i64::MAX as f64 {
37            SqlValue::BigInt(Some(self.0 as i64))
38        } else {
39            SqlValue::Double(Some(self.0))
40        }
41    }
42    #[expect(
43        clippy::as_conversions,
44        clippy::cast_precision_loss,
45        reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
46    )]
47    fn from_sql_value(value: SqlValue) -> Result<Self, ValueTypeError> {
48        match value {
49            SqlValue::Double(Some(value)) => Ok(Self(value)),
50            SqlValue::BigInt(Some(value)) => Ok(Self(value as f64)),
51            SqlValue::Int(Some(value)) => Ok(Self(f64::from(value))),
52            _ => Err(ValueTypeError),
53        }
54    }
55}
56
57#[cfg(feature = "sqlite")]
58impl Type<Sqlite> for Number {
59    fn type_info() -> SqliteTypeInfo {
60        <f64 as Type<Sqlite>>::type_info()
61    }
62    fn compatible(ty: &SqliteTypeInfo) -> bool {
63        <f64 as Type<Sqlite>>::compatible(ty)
64            || <i64 as Type<Sqlite>>::compatible(ty)
65            || <i32 as Type<Sqlite>>::compatible(ty)
66    }
67}
68
69#[cfg(feature = "sqlite")]
70impl<'r> Decode<'r, Sqlite> for Number {
71    #[expect(
72        clippy::as_conversions,
73        clippy::cast_precision_loss,
74        reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
75    )]
76    fn decode(value: SqliteValueRef<'r>) -> Result<Self, BoxDynError> {
77        // Try f64, then i64, then i32, as the typed column decoders would.
78        let ty = value.type_info().into_owned();
79        if <f64 as Type<Sqlite>>::compatible(&ty) {
80            <f64 as Decode<'r, Sqlite>>::decode(value).map(Self)
81        } else if <i64 as Type<Sqlite>>::compatible(&ty) {
82            <i64 as Decode<'r, Sqlite>>::decode(value).map(|value| Self(value as f64))
83        } else {
84            <i32 as Decode<'r, Sqlite>>::decode(value).map(|value| Self(f64::from(value)))
85        }
86    }
87}
88
89#[cfg(feature = "postgres")]
90impl Type<Postgres> for Number {
91    fn type_info() -> PgTypeInfo {
92        <i32 as Type<Postgres>>::type_info()
93    }
94    fn compatible(ty: &PgTypeInfo) -> bool {
95        <f64 as Type<Postgres>>::compatible(ty)
96            || <i64 as Type<Postgres>>::compatible(ty)
97            || <i32 as Type<Postgres>>::compatible(ty)
98    }
99}
100
101#[cfg(feature = "postgres")]
102impl<'r> Decode<'r, Postgres> for Number {
103    #[expect(
104        clippy::as_conversions,
105        clippy::cast_precision_loss,
106        reason = "JavaScript-compatible numbers deliberately retain IEEE754 rounding and guarded integer coercion at the wire or adapter boundary"
107    )]
108    fn decode(value: PgValueRef<'r>) -> Result<Self, BoxDynError> {
109        let ty = value.type_info().into_owned();
110        if <f64 as Type<Postgres>>::compatible(&ty) {
111            <f64 as Decode<'r, Postgres>>::decode(value).map(Self)
112        } else if <i64 as Type<Postgres>>::compatible(&ty) {
113            <i64 as Decode<'r, Postgres>>::decode(value).map(|value| Self(value as f64))
114        } else {
115            <i32 as Decode<'r, Postgres>>::decode(value).map(|value| Self(f64::from(value)))
116        }
117    }
118}
119
120#[cfg(feature = "sqlite")]
121impl sqlx::Encode<'_, Sqlite> for Number {
122    fn encode_by_ref(
123        &self,
124        buffer: &mut sqlx::sqlite::SqliteArgumentsBuffer,
125    ) -> Result<sqlx::encode::IsNull, BoxDynError> {
126        <f64 as sqlx::Encode<'_, Sqlite>>::encode_by_ref(&self.0, buffer)
127    }
128}
129
130#[cfg(feature = "postgres")]
131impl sqlx::Encode<'_, Postgres> for Number {
132    #[expect(
133        clippy::cast_possible_truncation,
134        clippy::as_conversions,
135        reason = "the finite, integral value is range-checked before encoding PostgreSQL int4"
136    )]
137    fn encode_by_ref(
138        &self,
139        buffer: &mut sqlx::postgres::PgArgumentBuffer,
140    ) -> Result<sqlx::encode::IsNull, BoxDynError> {
141        if !self.0.is_finite()
142            || self.0.fract() != 0.0
143            || self.0 < f64::from(i32::MIN)
144            || self.0 > f64::from(i32::MAX)
145        {
146            return Err("Better Auth PostgreSQL numbers must fit INTEGER".into());
147        }
148        <i32 as sqlx::Encode<'_, Postgres>>::encode(self.0 as i32, buffer)
149    }
150}