Skip to main content

ubiquisync_sql/db/
value.rs

1use crate::dialect::{PlaceholderGen, SqlDialect};
2
3use super::DbError;
4
5/// A Db value — used for both parameters and query results.
6#[derive(Debug, Clone, PartialEq)]
7pub enum DbValue {
8    /// SQL NULL.
9    Null,
10    /// Signed 64-bit integer.
11    Integer(i64),
12    /// UTF-8 text.
13    Text(String),
14    /// Raw byte string.
15    Blob(Vec<u8>),
16    /// 16-byte UUID.
17    Uuid([u8; 16]),
18}
19
20impl DbValue {
21    /// Build an [`Integer`](DbValue::Integer) from a `u64`, erroring when it
22    /// exceeds `i64::MAX`. SQL backends store signed 64-bit integers and the
23    /// HLC persist guard merges them with a signed `MAX`/`GREATEST`, so a value
24    /// past `i64::MAX` could neither round-trip nor order correctly — reject it
25    /// rather than silently wrap to a negative. Use this everywhere a `u64`
26    /// (packed HLC timestamp, counter, …) is bound into SQL instead of a raw
27    /// `as i64` cast. The real clock stays far below the bound (millis below
28    /// 2^47, ~year 6400).
29    pub fn from_u64(value: u64) -> Result<Self, DbError> {
30        i64::try_from(value)
31            .map(DbValue::Integer)
32            .map_err(|_| DbError::IntegerOutOfRange(value as i128))
33    }
34}
35
36/// One result row: a positional list of column values.
37#[derive(Debug)]
38pub struct DbRow {
39    /// The row's column values, in `SELECT`/column order.
40    pub values: Vec<DbValue>,
41}
42
43impl DbRow {
44    /// Read column `idx` as an `i64`. Errors if it is NULL, not an integer, or
45    /// out of bounds.
46    pub fn get_i64(&self, idx: usize) -> Result<i64, DbError> {
47        match self.values.get(idx) {
48            Some(DbValue::Integer(v)) => Ok(*v),
49            Some(DbValue::Null) => Err(DbError::UnexpectedNull(idx)),
50            Some(_) => Err(DbError::TypeMismatch {
51                col: idx,
52                expected: "integer",
53            }),
54            None => Err(DbError::ColumnOutOfBounds(idx)),
55        }
56    }
57
58    /// Read column `idx` as text. Errors if it is NULL, not text, or out of
59    /// bounds.
60    pub fn get_text(&self, idx: usize) -> Result<&str, DbError> {
61        match self.values.get(idx) {
62            Some(DbValue::Text(v)) => Ok(v),
63            Some(DbValue::Null) => Err(DbError::UnexpectedNull(idx)),
64            Some(_) => Err(DbError::TypeMismatch {
65                col: idx,
66                expected: "text",
67            }),
68            None => Err(DbError::ColumnOutOfBounds(idx)),
69        }
70    }
71
72    /// Read column `idx` as a byte blob. Errors if it is NULL, not a blob, or
73    /// out of bounds.
74    pub fn get_blob(&self, idx: usize) -> Result<&[u8], DbError> {
75        match self.values.get(idx) {
76            Some(DbValue::Blob(v)) => Ok(v),
77            Some(DbValue::Null) => Err(DbError::UnexpectedNull(idx)),
78            Some(_) => Err(DbError::TypeMismatch {
79                col: idx,
80                expected: "blob",
81            }),
82            None => Err(DbError::ColumnOutOfBounds(idx)),
83        }
84    }
85
86    /// Read a column written via [`DbValue::from_u64`]: a stored integer that
87    /// must be non-negative. Mirrors the checked write so a `u64` round-trips
88    /// through a signed column without a lossy `as` cast; a negative stored
89    /// value (corruption or a hand-edit) is rejected rather than wrapped to a
90    /// huge `u64` that would jump the clock to the end of time.
91    pub fn get_u64(&self, idx: usize) -> Result<u64, DbError> {
92        let v = self.get_i64(idx)?;
93        u64::try_from(v).map_err(|_| DbError::IntegerOutOfRange(v as i128))
94    }
95
96    /// Read an optional column written via [`DbValue::from_u64`]: a stored integer that
97    /// must be non-negative. Mirrors the checked write so a `u64` round-trips
98    /// through a signed column without a lossy `as` cast; a negative stored
99    /// value (corruption or a hand-edit) is rejected rather than wrapped to a
100    /// huge `u64` that would jump the clock to the end of time.
101    pub fn get_optional_u64(&self, idx: usize) -> Result<Option<u64>, DbError> {
102        if let Some(v) = self.get_optional_i64(idx)? {
103            Ok(Some(
104                u64::try_from(v).map_err(|_| DbError::IntegerOutOfRange(v as i128))?,
105            ))
106        } else {
107            Ok(None)
108        }
109    }
110
111    /// Read column `idx` as a bool — a stored integer, `true` iff nonzero.
112    /// Errors if it is NULL, not an integer, or out of bounds.
113    pub fn get_bool(&self, idx: usize) -> Result<bool, DbError> {
114        match self.values.get(idx) {
115            Some(DbValue::Integer(v)) => Ok(*v != 0),
116            Some(DbValue::Null) => Err(DbError::UnexpectedNull(idx)),
117            Some(_) => Err(DbError::TypeMismatch {
118                col: idx,
119                expected: "bool/integer",
120            }),
121            None => Err(DbError::ColumnOutOfBounds(idx)),
122        }
123    }
124
125    /// Read column `idx` as an optional `i64`: `None` when NULL. Errors if it
126    /// is not an integer or out of bounds.
127    pub fn get_optional_i64(&self, idx: usize) -> Result<Option<i64>, DbError> {
128        match self.values.get(idx) {
129            Some(DbValue::Integer(v)) => Ok(Some(*v)),
130            Some(DbValue::Null) => Ok(None),
131            Some(_) => Err(DbError::TypeMismatch {
132                col: idx,
133                expected: "integer",
134            }),
135            None => Err(DbError::ColumnOutOfBounds(idx)),
136        }
137    }
138
139    /// Read column `idx` as optional text: `None` when NULL. Errors if it is
140    /// not text or out of bounds.
141    pub fn get_optional_text(&self, idx: usize) -> Result<Option<&str>, DbError> {
142        match self.values.get(idx) {
143            Some(DbValue::Text(v)) => Ok(Some(v)),
144            Some(DbValue::Null) => Ok(None),
145            Some(_) => Err(DbError::TypeMismatch {
146                col: idx,
147                expected: "text",
148            }),
149            None => Err(DbError::ColumnOutOfBounds(idx)),
150        }
151    }
152
153    /// Read column `idx` as a 16-byte UUID, accepting either a native UUID
154    /// value or a 16-byte blob. Errors if it is NULL, a wrong-length blob,
155    /// another type, or out of bounds.
156    pub fn get_uuid(&self, idx: usize) -> Result<[u8; 16], DbError> {
157        match self.values.get(idx) {
158            Some(DbValue::Blob(v)) => v.as_slice().try_into().map_err(|_| DbError::TypeMismatch {
159                col: idx,
160                expected: "16-byte UUID blob",
161            }),
162            Some(DbValue::Uuid(v)) => Ok(*v),
163            Some(DbValue::Null) => Err(DbError::UnexpectedNull(idx)),
164            Some(_) => Err(DbError::TypeMismatch {
165                col: idx,
166                expected: "uuid or 16-byte blob",
167            }),
168            None => Err(DbError::ColumnOutOfBounds(idx)),
169        }
170    }
171
172    /// Read column `idx` as an optional 16-byte UUID (native UUID or 16-byte
173    /// blob): `None` when NULL. Errors on a wrong-length blob, another type, or
174    /// out of bounds.
175    pub fn get_optional_uuid(&self, idx: usize) -> Result<Option<[u8; 16]>, DbError> {
176        match self.values.get(idx) {
177            Some(DbValue::Blob(v)) => {
178                let arr = v.as_slice().try_into().map_err(|_| DbError::TypeMismatch {
179                    col: idx,
180                    expected: "16-byte UUID blob",
181                })?;
182                Ok(Some(arr))
183            }
184            Some(DbValue::Uuid(v)) => Ok(Some(*v)),
185            Some(DbValue::Null) => Ok(None),
186            Some(_) => Err(DbError::TypeMismatch {
187                col: idx,
188                expected: "uuid or 16-byte blob",
189            }),
190            None => Err(DbError::ColumnOutOfBounds(idx)),
191        }
192    }
193
194    /// Read column `idx` as an optional byte blob: `None` when NULL. Errors if
195    /// it is not a blob or out of bounds.
196    pub fn get_optional_blob(&self, idx: usize) -> Result<Option<&[u8]>, DbError> {
197        match self.values.get(idx) {
198            Some(DbValue::Blob(v)) => Ok(Some(v)),
199            Some(DbValue::Null) => Ok(None),
200            Some(_) => Err(DbError::TypeMismatch {
201                col: idx,
202                expected: "blob",
203            }),
204            None => Err(DbError::ColumnOutOfBounds(idx)),
205        }
206    }
207}
208
209/// Builds a parameterized statement: each [`bind_next`](Self::bind_next)
210/// records a value and returns its placeholder string, so a query builder can
211/// splice placeholders into SQL text and hand the collected values to the
212/// driver in bind order.
213pub struct ValueBinder {
214    placeholder_gen: PlaceholderGen,
215    values: Vec<DbValue>,
216}
217
218impl ValueBinder {
219    /// Start an empty binder for `dialect`.
220    pub fn new(dialect: SqlDialect) -> Self {
221        Self {
222            placeholder_gen: PlaceholderGen::new(dialect),
223            values: vec![],
224        }
225    }
226
227    /// Record `value` and return its placeholder (`?n` / `$n`) for the SQL text.
228    pub fn bind_next(&mut self, value: DbValue) -> String {
229        self.values.push(value);
230        self.placeholder_gen.next_placeholder()
231    }
232
233    /// Consume the binder, returning the bound values in bind order.
234    pub fn values(self) -> Vec<DbValue> {
235        self.values
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    #[test]
244    fn from_u64_round_trips_through_get_u64() {
245        // Values up to i64::MAX bind and read back unchanged — the common case
246        // (every realistic HLC timestamp) must be lossless.
247        for v in [0u64, 1, 1_700_000_000_000 << 16, i64::MAX as u64] {
248            let DbValue::Integer(stored) = DbValue::from_u64(v).unwrap() else {
249                panic!("from_u64 must produce an Integer");
250            };
251            let row = DbRow {
252                values: vec![DbValue::Integer(stored)],
253            };
254            assert_eq!(row.get_u64(0).unwrap(), v);
255        }
256    }
257
258    #[test]
259    fn from_u64_rejects_values_past_i64_max() {
260        // A u64 past i64::MAX has no signed representation and would break the
261        // signed MAX-guard merge, so it must error rather than wrap negative.
262        assert!(matches!(
263            DbValue::from_u64(i64::MAX as u64 + 1),
264            Err(DbError::IntegerOutOfRange(_))
265        ));
266        assert!(matches!(
267            DbValue::from_u64(u64::MAX),
268            Err(DbError::IntegerOutOfRange(_))
269        ));
270    }
271
272    #[test]
273    fn get_u64_rejects_negative_stored_value() {
274        // A negative column value (corruption / hand-edit) must not wrap to a
275        // huge u64 that jumps the clock forever.
276        let row = DbRow {
277            values: vec![DbValue::Integer(-1)],
278        };
279        assert!(matches!(row.get_u64(0), Err(DbError::IntegerOutOfRange(_))));
280    }
281
282    #[test]
283    fn get_optional_u64_passes_null_through_and_still_guards_range() {
284        // NULL is a clean None; a present-but-negative value is still rejected.
285        let null_row = DbRow {
286            values: vec![DbValue::Null],
287        };
288        assert_eq!(null_row.get_optional_u64(0).unwrap(), None);
289
290        let value_row = DbRow {
291            values: vec![DbValue::Integer(42)],
292        };
293        assert_eq!(value_row.get_optional_u64(0).unwrap(), Some(42));
294
295        let negative_row = DbRow {
296            values: vec![DbValue::Integer(-1)],
297        };
298        assert!(matches!(
299            negative_row.get_optional_u64(0),
300            Err(DbError::IntegerOutOfRange(_))
301        ));
302    }
303}