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