Skip to main content

larust_db/sql/
codec.rs

1//! Generic row <-> value conversion - the one place this crate reads a
2//! database row without knowing its shape ahead of time, and the one
3//! place it binds a value back without knowing its type ahead of time.
4//! Every other part of the SQL admin dashboard goes through these two
5//! functions rather than reimplementing either.
6//!
7//! **Why this needs a direct `sqlx-core` dependency.** The `sqlx` facade
8//! crate re-exports `AnyRow`/`AnyTypeInfoKind` but not `AnyColumn` or
9//! `AnyValueKind` - the two types a generic decoder actually needs to
10//! name (confirmed by grepping the whole `sqlx-0.8.6` source tree: zero
11//! hits for either). `sqlx-core` is added as a direct dependency,
12//! version-pinned to the exact same range as `sqlx` itself, so it
13//! resolves to the identical already-locked instance - not a second copy
14//! of the type. Both `AnyColumn`/`AnyValueKind` are `#[doc(hidden)]` in
15//! `sqlx-core`'s own re-export (semver-exempt, not part of sqlx's stable
16//! public API) - a future sqlx point release could restructure this
17//! without a semver bump; worth knowing if a version bump ever breaks
18//! this file specifically.
19
20use serde_json::Value as Json;
21use sqlx::any::AnyTypeInfoKind;
22use sqlx::{Column, Row, ValueRef};
23use sqlx_core::any::AnyValueKind;
24
25/// Decodes an entire row into a `{column: value}` JSON object. `sqlx::Any`
26/// has already normalized every backend's native types into one of 9
27/// kinds by the time a row reaches here, so this needs no per-backend
28/// branching at all.
29pub fn row_to_json(row: &sqlx::any::AnyRow) -> Json {
30    let mut map = serde_json::Map::with_capacity(row.columns().len());
31    for (i, col) in row.columns().iter().enumerate() {
32        let value = match row.try_get_raw(i) {
33            Ok(raw) => any_value_kind_to_json(ValueRef::to_owned(&raw).kind),
34            Err(_) => Json::Null,
35        };
36        map.insert(col.name().to_string(), value);
37    }
38    Json::Object(map)
39}
40
41fn any_value_kind_to_json(kind: AnyValueKind<'static>) -> Json {
42    match kind {
43        AnyValueKind::Null(_) => Json::Null,
44        AnyValueKind::Bool(b) => Json::Bool(b),
45        AnyValueKind::SmallInt(n) => Json::from(n),
46        AnyValueKind::Integer(n) => Json::from(n),
47        AnyValueKind::BigInt(n) => Json::from(n),
48        // `serde_json::Number::from_f64` returns `None` (encoded here as
49        // JSON `null`) for non-finite floats (NaN/Infinity), which JSON
50        // has no representation for - an edge case no demo/framework
51        // table's data can actually produce today, but a silent `null`
52        // is the correct, safe degrade if some future column ever did.
53        AnyValueKind::Real(f) => Json::from(f),
54        AnyValueKind::Double(f) => Json::from(f),
55        AnyValueKind::Text(s) => Json::String(s.into_owned()),
56        AnyValueKind::Blob(b) => Json::String(format!("<blob, {} bytes>", b.len())),
57        // `AnyValueKind` is `#[non_exhaustive]` (a future sqlx release
58        // could add a variant) - degrade to null rather than panic on a
59        // kind this crate doesn't know about yet.
60        other => {
61            tracing::warn!(
62                ?other,
63                "unrecognized AnyValueKind variant; rendering as null"
64            );
65            Json::Null
66        }
67    }
68}
69
70/// Converts a form-submitted value (already parsed as loose JSON, the
71/// same "try JSON, fall back to a plain string" idea
72/// `larust_db::parse_cli_value` uses for the KV store) into the shape
73/// [`bind_any`] needs - driven by the column's *declared* type, not the
74/// JSON value's own shape. This matters concretely: this schema (like
75/// most SQLite-first apps) stores booleans and timestamps as plain
76/// `INTEGER`, never a native `BOOLEAN`/`TIMESTAMP` column, so a checkbox
77/// posting `true` for such a column must become `AnyValueKind::BigInt(1)`,
78/// not `AnyValueKind::Bool(true)` - binding the wrong kind against an
79/// `INTEGER` column is exactly the mistake this function exists to avoid.
80pub fn json_to_any_value(value: &Json, declared: AnyTypeInfoKind) -> AnyValueKind<'static> {
81    if value.is_null() {
82        return AnyValueKind::Null(declared);
83    }
84    match declared {
85        AnyTypeInfoKind::Bool => AnyValueKind::Bool(json_as_bool(value)),
86        AnyTypeInfoKind::SmallInt => AnyValueKind::SmallInt(json_as_i64(value) as i16),
87        AnyTypeInfoKind::Integer => AnyValueKind::Integer(json_as_i64(value) as i32),
88        AnyTypeInfoKind::BigInt => AnyValueKind::BigInt(json_as_i64(value)),
89        AnyTypeInfoKind::Real => AnyValueKind::Real(json_as_f64(value) as f32),
90        AnyTypeInfoKind::Double => AnyValueKind::Double(json_as_f64(value)),
91        // Blob columns are read-only in this crate (see `introspect.rs`'s
92        // own doc comment) - a submitted value for one is stored as text
93        // rather than silently dropped, honest about not being real blob
94        // support rather than pretending to write binary data correctly.
95        AnyTypeInfoKind::Text | AnyTypeInfoKind::Blob | AnyTypeInfoKind::Null => {
96            AnyValueKind::Text(json_as_string(value).into())
97        }
98    }
99}
100
101fn json_as_bool(value: &Json) -> bool {
102    match value {
103        Json::Bool(b) => *b,
104        Json::Number(n) => n.as_i64().is_some_and(|n| n != 0),
105        Json::String(s) => s == "true" || s == "1",
106        _ => false,
107    }
108}
109
110fn json_as_i64(value: &Json) -> i64 {
111    match value {
112        Json::Number(n) => n.as_i64().unwrap_or(0),
113        Json::Bool(b) => i64::from(*b),
114        Json::String(s) => s.parse().unwrap_or(0),
115        _ => 0,
116    }
117}
118
119fn json_as_f64(value: &Json) -> f64 {
120    match value {
121        Json::Number(n) => n.as_f64().unwrap_or(0.0),
122        Json::String(s) => s.parse().unwrap_or(0.0),
123        _ => 0.0,
124    }
125}
126
127fn json_as_string(value: &Json) -> String {
128    match value {
129        Json::String(s) => s.clone(),
130        other => other.to_string(),
131    }
132}
133
134/// Binds one already-converted value onto a query. `AnyValueKind` itself
135/// doesn't implement `Encode`/`Type` (confirmed absent from `sqlx-core`'s
136/// own source - the only impl found was `Value for AnyValue`, the
137/// read-side trait, not the write-side one) - so this can't be one
138/// generic `.bind()` call. Each arm unwraps to a concrete primitive and
139/// is its own monomorphized bind, mirroring `sqlx-core`'s own internal
140/// `AnyArguments::convert_to`, which does the identical thing.
141pub fn bind_any<'q>(
142    query: sqlx::query::Query<'q, sqlx::any::Any, sqlx::any::AnyArguments<'q>>,
143    value: AnyValueKind<'q>,
144) -> sqlx::query::Query<'q, sqlx::any::Any, sqlx::any::AnyArguments<'q>> {
145    match value {
146        AnyValueKind::Null(AnyTypeInfoKind::Bool) => query.bind(Option::<bool>::None),
147        AnyValueKind::Null(AnyTypeInfoKind::SmallInt) => query.bind(Option::<i16>::None),
148        AnyValueKind::Null(AnyTypeInfoKind::Integer) => query.bind(Option::<i32>::None),
149        AnyValueKind::Null(AnyTypeInfoKind::BigInt) => query.bind(Option::<i64>::None),
150        AnyValueKind::Null(AnyTypeInfoKind::Real) => query.bind(Option::<f32>::None),
151        AnyValueKind::Null(AnyTypeInfoKind::Double) => query.bind(Option::<f64>::None),
152        AnyValueKind::Null(AnyTypeInfoKind::Blob) => query.bind(Option::<Vec<u8>>::None),
153        AnyValueKind::Null(AnyTypeInfoKind::Text | AnyTypeInfoKind::Null) => {
154            query.bind(Option::<String>::None)
155        }
156        AnyValueKind::Bool(b) => query.bind(b),
157        AnyValueKind::SmallInt(n) => query.bind(n),
158        AnyValueKind::Integer(n) => query.bind(n),
159        AnyValueKind::BigInt(n) => query.bind(n),
160        AnyValueKind::Real(f) => query.bind(f),
161        AnyValueKind::Double(f) => query.bind(f),
162        AnyValueKind::Text(s) => query.bind(s.into_owned()),
163        AnyValueKind::Blob(b) => query.bind(b.into_owned()),
164        // Same `#[non_exhaustive]` reasoning as `any_value_kind_to_json`
165        // above - bind SQL NULL rather than panic on an unknown variant.
166        other => {
167            tracing::warn!(?other, "unrecognized AnyValueKind variant; binding null");
168            query.bind(Option::<String>::None)
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn json_to_any_value_maps_null_to_the_declared_kind() {
179        assert!(matches!(
180            json_to_any_value(&Json::Null, AnyTypeInfoKind::BigInt),
181            AnyValueKind::Null(AnyTypeInfoKind::BigInt)
182        ));
183    }
184
185    #[test]
186    fn json_to_any_value_coerces_a_bool_into_an_integer_column() {
187        // The concrete scenario this function exists for: no table in
188        // this schema uses a real BOOLEAN column, so a checkbox-shaped
189        // `true`/`false` must become an integer, not `AnyValueKind::Bool`.
190        assert!(matches!(
191            json_to_any_value(&Json::Bool(true), AnyTypeInfoKind::BigInt),
192            AnyValueKind::BigInt(1)
193        ));
194        assert!(matches!(
195            json_to_any_value(&Json::Bool(false), AnyTypeInfoKind::Integer),
196            AnyValueKind::Integer(0)
197        ));
198    }
199
200    #[test]
201    fn json_to_any_value_parses_a_numeric_string_for_a_numeric_column() {
202        assert!(matches!(
203            json_to_any_value(&Json::String("42".to_string()), AnyTypeInfoKind::BigInt),
204            AnyValueKind::BigInt(42)
205        ));
206    }
207
208    #[test]
209    fn json_to_any_value_stringifies_anything_for_a_text_column() {
210        match json_to_any_value(&Json::from(42), AnyTypeInfoKind::Text) {
211            AnyValueKind::Text(s) => assert_eq!(s, "42"),
212            other => panic!("expected Text, got {other:?}"),
213        }
214    }
215
216    #[test]
217    fn any_value_kind_to_json_round_trips_every_variant() {
218        assert_eq!(
219            any_value_kind_to_json(AnyValueKind::Null(AnyTypeInfoKind::Text)),
220            Json::Null
221        );
222        assert_eq!(
223            any_value_kind_to_json(AnyValueKind::Bool(true)),
224            Json::Bool(true)
225        );
226        assert_eq!(
227            any_value_kind_to_json(AnyValueKind::BigInt(7)),
228            Json::from(7)
229        );
230        assert_eq!(
231            any_value_kind_to_json(AnyValueKind::Text("hi".into())),
232            Json::String("hi".to_string())
233        );
234        assert_eq!(
235            any_value_kind_to_json(AnyValueKind::Blob(vec![1, 2, 3].into())),
236            Json::String("<blob, 3 bytes>".to_string())
237        );
238    }
239}