Skip to main content

powdb_storage/
types.rs

1use std::cmp::Ordering;
2use std::hash::{Hash, Hasher};
3
4/// Type identifier for schema definitions and wire protocol.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
6#[repr(u8)]
7pub enum TypeId {
8    Empty = 0,
9    Int = 1,
10    Float = 2,
11    Bool = 3,
12    Str = 4,
13    DateTime = 5,
14    Uuid = 6,
15    Bytes = 7,
16    /// Canonical binary JSON (PJ1). Variable-length like `Str`/`Bytes`, so it
17    /// participates in row v2 overflow spill. See [`crate::pj1`].
18    Json = 8,
19}
20
21impl TypeId {
22    /// Decode a `u8` discriminant into a `TypeId`, returning `None` for unknown values.
23    pub fn from_u8(v: u8) -> Option<Self> {
24        match v {
25            0 => Some(TypeId::Empty),
26            1 => Some(TypeId::Int),
27            2 => Some(TypeId::Float),
28            3 => Some(TypeId::Bool),
29            4 => Some(TypeId::Str),
30            5 => Some(TypeId::DateTime),
31            6 => Some(TypeId::Uuid),
32            7 => Some(TypeId::Bytes),
33            8 => Some(TypeId::Json),
34            _ => None,
35        }
36    }
37}
38
39/// A single scalar value. Optional fields use `Empty` (set-based nullability).
40#[derive(Debug, Clone)]
41pub enum Value {
42    Int(i64),
43    Float(f64),
44    Bool(bool),
45    Str(String),
46    DateTime(i64), // microseconds since Unix epoch
47    Uuid([u8; 16]),
48    Bytes(Vec<u8>),
49    /// Canonical PJ1 (binary JSON) bytes. Rendered as canonical JSON text on
50    /// the wire; ordered by the PJ1 total order. See [`crate::pj1`].
51    Json(Box<[u8]>),
52    Empty, // {} — the empty set, not NULL
53}
54
55impl Value {
56    pub fn type_id(&self) -> TypeId {
57        match self {
58            Value::Int(_) => TypeId::Int,
59            Value::Float(_) => TypeId::Float,
60            Value::Bool(_) => TypeId::Bool,
61            Value::Str(_) => TypeId::Str,
62            Value::DateTime(_) => TypeId::DateTime,
63            Value::Uuid(_) => TypeId::Uuid,
64            Value::Bytes(_) => TypeId::Bytes,
65            Value::Json(_) => TypeId::Json,
66            Value::Empty => TypeId::Empty,
67        }
68    }
69
70    /// Number of bytes this value occupies when encoded in a row.
71    pub fn encoded_size(&self) -> usize {
72        match self {
73            Value::Int(_) => 8,
74            Value::Float(_) => 8,
75            Value::Bool(_) => 1,
76            Value::Str(s) => 4 + s.len(), // u32 length prefix + UTF-8 bytes
77            Value::DateTime(_) => 8,
78            Value::Uuid(_) => 16,
79            Value::Bytes(b) => 4 + b.len(), // u32 length prefix + raw bytes
80            Value::Json(b) => 4 + b.len(),  // canonical PJ1 bytes, like Bytes
81            Value::Empty => 0,
82        }
83    }
84
85    pub fn is_empty(&self) -> bool {
86        matches!(self, Value::Empty)
87    }
88
89    /// Canonical wire/text rendering of a value, shared by the server protocol,
90    /// the CLI, and the embedded bindings so a result is identical however it is
91    /// read. `Empty` (NULL) renders as the bareword `null` — the sentinel the
92    /// typed-row decoders recognize; a UUID renders as the canonical hyphenated
93    /// form; bytes render as a `<N bytes>` placeholder (the binary wire path
94    /// does not stringify raw bytes).
95    pub fn to_wire_string(&self) -> String {
96        match self {
97            Value::Int(n) => n.to_string(),
98            Value::Float(n) => format!("{n}"),
99            Value::Bool(b) => b.to_string(),
100            Value::Str(s) => s.clone(),
101            Value::DateTime(t) => format!("{t}"),
102            Value::Uuid(u) => format!(
103                "{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
104                u[0], u[1], u[2], u[3], u[4], u[5], u[6], u[7],
105                u[8], u[9], u[10], u[11], u[12], u[13], u[14], u[15]
106            ),
107            Value::Bytes(b) => format!("<{} bytes>", b.len()),
108            // Render canonical JSON text by decoding the PJ1 bytes. Stored
109            // bytes are always canonical, so the error path is unreachable; a
110            // malformed blob falls back to the `null` sentinel rather than
111            // panicking on the wire.
112            Value::Json(b) => crate::pj1::pj1_to_text(b).unwrap_or_else(|_| "null".into()),
113            Value::Empty => "null".into(),
114        }
115    }
116}
117
118// NOTE on cross-numeric equality: `PartialEq` (and `Hash`) deliberately do
119// NOT treat `Int(100)` and `Float(100.0)` as equal. Making them equal would
120// require a consistent `Hash` — if `a == b` then `hash(a) == hash(b)` — and
121// the canonical fix (normalise ints that fit exactly to f64 bits) is subtle
122// enough that we intentionally keep equality/hashing strictly typed. The
123// cross-type fix lives in `Ord::cmp` (below), which is what BETWEEN, ORDER
124// BY, and range predicates actually call. If you need numeric equality
125// across Int/Float, use `cmp(...) == Ordering::Equal` explicitly.
126impl PartialEq for Value {
127    fn eq(&self, other: &Self) -> bool {
128        match (self, other) {
129            (Value::Int(a), Value::Int(b)) => a == b,
130            (Value::Float(a), Value::Float(b)) => a.total_cmp(b) == Ordering::Equal,
131            (Value::Bool(a), Value::Bool(b)) => a == b,
132            (Value::Str(a), Value::Str(b)) => a == b,
133            (Value::DateTime(a), Value::DateTime(b)) => a == b,
134            (Value::Uuid(a), Value::Uuid(b)) => a == b,
135            (Value::Bytes(a), Value::Bytes(b)) => a == b,
136            // Canonical PJ1: equal documents have equal bytes, so byte equality
137            // IS document equality.
138            (Value::Json(a), Value::Json(b)) => a == b,
139            (Value::Empty, Value::Empty) => true,
140            _ => false,
141        }
142    }
143}
144
145impl Eq for Value {}
146
147impl Hash for Value {
148    fn hash<H: Hasher>(&self, state: &mut H) {
149        // Tag first so distinct variants with coincidentally equal byte
150        // representations (e.g. Int(0) vs Bool(false)) can't collide.
151        std::mem::discriminant(self).hash(state);
152        match self {
153            Value::Int(v) => v.hash(state),
154            // f64 has no Hash impl. Use the IEEE bit pattern, but canonicalise
155            // via total_cmp so NaN hashes stably (and matches our PartialEq,
156            // which also uses total_cmp for equality).
157            Value::Float(v) => v.to_bits().hash(state),
158            Value::Bool(v) => v.hash(state),
159            Value::Str(v) => v.hash(state),
160            Value::DateTime(v) => v.hash(state),
161            Value::Uuid(v) => v.hash(state),
162            Value::Bytes(v) => v.hash(state),
163            // Canonical bytes => hashing the bytes is consistent with the
164            // byte-equality above.
165            Value::Json(v) => v.hash(state),
166            Value::Empty => {} // discriminant already hashed
167        }
168    }
169}
170
171impl PartialOrd for Value {
172    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
173        Some(self.cmp(other))
174    }
175}
176
177impl Ord for Value {
178    fn cmp(&self, other: &Self) -> Ordering {
179        match (self, other) {
180            (Value::Int(a), Value::Int(b)) => a.cmp(b),
181            (Value::Float(a), Value::Float(b)) => a.total_cmp(b),
182            // Cross-type numeric comparison: promote Int -> f64 and use
183            // total_cmp so BETWEEN / ORDER BY / range predicates work on
184            // mixed Int literals vs Float columns (and vice versa).
185            // `i64 as f64` can lose precision above 2^53, but the result is
186            // still monotonic, which is what comparison needs.
187            (Value::Int(a), Value::Float(b)) => (*a as f64).total_cmp(b),
188            (Value::Float(a), Value::Int(b)) => a.total_cmp(&(*b as f64)),
189            (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
190            (Value::Str(a), Value::Str(b)) => a.cmp(b),
191            (Value::DateTime(a), Value::DateTime(b)) => a.cmp(b),
192            // A timestamp literal has no distinct spelling in PowQL: it is the
193            // raw micros integer, so a datetime predicate or index bound is an
194            // Int against a stored DateTime. Without these two arms the pair
195            // falls to the type-discriminant fallback below, making every
196            // DateTime compare greater than every Int whatever the timestamps
197            // are: `filter .created_at > <ts>` then matched every non-null row
198            // on a scan, and a datetime index range scan returned every entry.
199            // Same reasoning as the Int/Float arms above, and the same
200            // deliberate asymmetry with `PartialEq` documented there.
201            (Value::DateTime(a), Value::Int(b)) => a.cmp(b),
202            (Value::Int(a), Value::DateTime(b)) => a.cmp(b),
203            (Value::Uuid(a), Value::Uuid(b)) => a.cmp(b),
204            (Value::Bytes(a), Value::Bytes(b)) => a.cmp(b),
205            // Json uses the PJ1 total order (null < false < true < numbers <
206            // strings < arrays < objects), not a raw byte compare.
207            (Value::Json(a), Value::Json(b)) => crate::pj1::pj1_cmp(a, b),
208            (Value::Empty, Value::Empty) => Ordering::Equal,
209            (Value::Empty, _) => Ordering::Less,
210            (_, Value::Empty) => Ordering::Greater,
211            _ => (self.type_id() as u8).cmp(&(other.type_id() as u8)),
212        }
213    }
214}
215
216/// Column definition in a table schema.
217#[derive(Debug, Clone)]
218pub struct ColumnDef {
219    pub name: String,
220    pub type_id: TypeId,
221    pub required: bool,
222    pub position: u16,
223}
224
225/// Schema for a table — ordered list of columns.
226#[derive(Debug, Clone)]
227pub struct Schema {
228    pub table_name: String,
229    pub columns: Vec<ColumnDef>,
230}
231
232impl Schema {
233    pub fn column_count(&self) -> usize {
234        self.columns.len()
235    }
236
237    pub fn find_column(&self, name: &str) -> Option<&ColumnDef> {
238        self.columns.iter().find(|c| c.name == name)
239    }
240
241    pub fn column_index(&self, name: &str) -> Option<usize> {
242        self.columns.iter().position(|c| c.name == name)
243    }
244
245    /// Size of the null bitmap in bytes for this schema.
246    pub fn null_bitmap_size(&self) -> usize {
247        self.columns.len().div_ceil(8)
248    }
249}
250
251/// Whether a type has a fixed encoded size.
252pub fn is_fixed_size(type_id: TypeId) -> bool {
253    matches!(
254        type_id,
255        TypeId::Int | TypeId::Float | TypeId::Bool | TypeId::DateTime | TypeId::Uuid
256    )
257}
258
259/// Fixed encoded size for fixed-size types.
260pub fn fixed_size(type_id: TypeId) -> Option<usize> {
261    match type_id {
262        TypeId::Int => Some(8),
263        TypeId::Float => Some(8),
264        TypeId::Bool => Some(1),
265        TypeId::DateTime => Some(8),
266        TypeId::Uuid => Some(16),
267        _ => None,
268    }
269}
270
271/// A row is an ordered list of values matching a schema.
272pub type Row = Vec<Value>;
273
274/// RowId uniquely identifies a row's physical location.
275#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
276pub struct RowId {
277    pub page_id: u32,
278    pub slot_index: u16,
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn test_value_to_wire_string() {
287        assert_eq!(Value::Int(42).to_wire_string(), "42");
288        assert_eq!(Value::Bool(true).to_wire_string(), "true");
289        assert_eq!(Value::Str("hi".into()).to_wire_string(), "hi");
290        // NULL renders as the bareword the typed-row decoders recognize.
291        assert_eq!(Value::Empty.to_wire_string(), "null");
292        // UUID renders in canonical hyphenated form.
293        assert_eq!(
294            Value::Uuid([
295                0x55, 0x0e, 0x84, 0x00, 0xe2, 0x9b, 0x41, 0xd4, 0xa7, 0x16, 0x44, 0x66, 0x55, 0x44,
296                0x00, 0x00
297            ])
298            .to_wire_string(),
299            "550e8400-e29b-41d4-a716-446655440000"
300        );
301        assert_eq!(Value::Bytes(vec![1, 2, 3]).to_wire_string(), "<3 bytes>");
302    }
303
304    #[test]
305    fn test_value_type_id() {
306        assert_eq!(Value::Int(42).type_id(), TypeId::Int);
307        assert_eq!(Value::Str("hello".into()).type_id(), TypeId::Str);
308        assert_eq!(Value::Float(2.78).type_id(), TypeId::Float);
309        assert_eq!(Value::Bool(true).type_id(), TypeId::Bool);
310        assert_eq!(Value::Empty.type_id(), TypeId::Empty);
311    }
312
313    #[test]
314    fn test_value_encoded_size() {
315        assert_eq!(Value::Int(42).encoded_size(), 8);
316        assert_eq!(Value::Float(1.0).encoded_size(), 8);
317        assert_eq!(Value::Bool(true).encoded_size(), 1);
318        assert_eq!(Value::Str("hello".into()).encoded_size(), 4 + 5);
319        assert_eq!(Value::Empty.encoded_size(), 0);
320    }
321
322    #[test]
323    fn test_value_ordering() {
324        assert!(Value::Int(1) < Value::Int(2));
325        assert!(Value::Str("a".into()) < Value::Str("b".into()));
326        assert!(Value::Float(1.0) < Value::Float(2.0));
327    }
328
329    #[test]
330    fn test_datetime_value() {
331        let ts = Value::DateTime(1_700_000_000_000_000);
332        assert_eq!(ts.type_id(), TypeId::DateTime);
333        assert_eq!(ts.encoded_size(), 8);
334    }
335
336    #[test]
337    fn test_uuid_value() {
338        let uuid = Value::Uuid([0u8; 16]);
339        assert_eq!(uuid.type_id(), TypeId::Uuid);
340        assert_eq!(uuid.encoded_size(), 16);
341    }
342
343    #[test]
344    fn test_empty_is_less_than_values() {
345        assert!(Value::Empty < Value::Int(0));
346        assert!(Value::Empty < Value::Str("".into()));
347    }
348
349    #[test]
350    fn test_ord_int_vs_float() {
351        // Regression: prior to the cross-type fix, `Int(100) < Float(175.5)`
352        // fell through to comparing TypeId discriminants (Int=1 vs Float=2),
353        // which happened to return Less for this case but Greater for others,
354        // breaking BETWEEN on Float columns with Int literals.
355        assert!(Value::Int(100) < Value::Float(175.5));
356        assert!(Value::Int(500) > Value::Float(450.0));
357        assert!(Value::Int(100) < Value::Float(100.5));
358        assert!(Value::Int(100) > Value::Float(99.9));
359        // Equal magnitudes compare equal across types.
360        assert_eq!(Value::Int(100).cmp(&Value::Float(100.0)), Ordering::Equal);
361        assert_eq!(Value::Int(0).cmp(&Value::Float(0.0)), Ordering::Equal);
362        // Negative numbers.
363        assert!(Value::Int(-10) < Value::Float(-5.5));
364        assert!(Value::Int(-1) > Value::Float(-1.5));
365    }
366
367    #[test]
368    fn test_ord_float_vs_int() {
369        assert!(Value::Float(175.5) > Value::Int(100));
370        assert!(Value::Float(450.0) < Value::Int(500));
371        assert!(Value::Float(100.5) > Value::Int(100));
372        assert!(Value::Float(99.9) < Value::Int(100));
373        assert_eq!(Value::Float(100.0).cmp(&Value::Int(100)), Ordering::Equal);
374        assert!(Value::Float(-5.5) > Value::Int(-10));
375        assert!(Value::Float(-1.5) < Value::Int(-1));
376    }
377
378    #[test]
379    fn test_ord_between_simulation() {
380        // Simulates the Product.price BETWEEN 100 AND 500 case: Int literals
381        // bounding a Float column. All of 175.5 and 450.0 must be in range.
382        let lo = Value::Int(100);
383        let hi = Value::Int(500);
384        let prices = [29.0_f64, 175.5, 450.0, 1299.0];
385        let in_range: Vec<f64> = prices
386            .iter()
387            .copied()
388            .filter(|p| {
389                let v = Value::Float(*p);
390                v >= lo && v <= hi
391            })
392            .collect();
393        assert_eq!(in_range, vec![175.5, 450.0]);
394    }
395
396    #[test]
397    fn test_schema_column_lookup() {
398        let schema = Schema {
399            table_name: "test".into(),
400            columns: vec![
401                ColumnDef {
402                    name: "a".into(),
403                    type_id: TypeId::Int,
404                    required: true,
405                    position: 0,
406                },
407                ColumnDef {
408                    name: "b".into(),
409                    type_id: TypeId::Str,
410                    required: false,
411                    position: 1,
412                },
413            ],
414        };
415        assert_eq!(schema.column_index("a"), Some(0));
416        assert_eq!(schema.column_index("b"), Some(1));
417        assert_eq!(schema.column_index("c"), None);
418        assert_eq!(schema.null_bitmap_size(), 1);
419    }
420
421    /// A timestamp literal is written as a plain integer, so a DateTime can be
422    /// compared against an Int. Without an explicit arm this pair falls to the
423    /// type-discriminant fallback, which orders by tag rather than by value and
424    /// makes every DateTime sort above every Int. That produced wrong query
425    /// results before it was fixed, so the ordering is pinned here.
426    ///
427    /// Note the deliberate asymmetry with `PartialEq`, which stays strictly
428    /// typed for `Hash` consistency: this mirrors the Int/Float arms documented
429    /// above and is the same trade-off.
430    #[test]
431    fn datetime_orders_against_int_by_microseconds_not_by_type_tag() {
432        use std::cmp::Ordering;
433        assert_eq!(Value::DateTime(100).cmp(&Value::Int(200)), Ordering::Less);
434        assert_eq!(
435            Value::DateTime(300).cmp(&Value::Int(200)),
436            Ordering::Greater
437        );
438        assert_eq!(Value::DateTime(200).cmp(&Value::Int(200)), Ordering::Equal);
439        assert_eq!(Value::Int(100).cmp(&Value::DateTime(200)), Ordering::Less);
440        assert_eq!(
441            Value::Int(300).cmp(&Value::DateTime(200)),
442            Ordering::Greater
443        );
444        assert_eq!(Value::Int(200).cmp(&Value::DateTime(200)), Ordering::Equal);
445
446        // Negative timestamps (pre-epoch) must order correctly too, which a
447        // tag comparison would also get wrong.
448        assert_eq!(Value::DateTime(-5).cmp(&Value::Int(5)), Ordering::Less);
449
450        // Equality stays strictly typed on purpose, as for Int vs Float.
451        assert_ne!(Value::DateTime(200), Value::Int(200));
452    }
453}