Skip to main content

nodedb_types/columnar/
column_type.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! ColumnType — the atomic value type for typed schemas.
4
5use serde::{Deserialize, Serialize};
6
7use crate::value::Value;
8
9/// Typed column definition for strict document and columnar collections.
10///
11/// `#[non_exhaustive]` — this enum grows with each type system expansion
12/// (e.g. future variants may add `Decimal { precision, scale }` or split
13/// `Timestamp`/`TimestampTz`). External exhaustive `match` arms must handle
14/// future variants via a typed error arm rather than `_ => unreachable!()`.
15#[non_exhaustive]
16#[derive(
17    Debug,
18    Clone,
19    Copy,
20    PartialEq,
21    Eq,
22    Hash,
23    Serialize,
24    Deserialize,
25    zerompk::ToMessagePack,
26    zerompk::FromMessagePack,
27)]
28#[serde(tag = "type", content = "params")]
29pub enum ColumnType {
30    Int64,
31    Float64,
32    String,
33    Bool,
34    Bytes,
35    /// Naive (no-timezone) timestamp with microsecond precision. OID 1114.
36    Timestamp,
37    /// UTC (timezone-aware) timestamp with microsecond precision. OID 1184.
38    Timestamptz,
39    /// System-assigned timestamp (bitemporal `system_from_ms`). Same 8-byte
40    /// layout as `Timestamp`, but tagged distinctly so the planner and DDL
41    /// layer can reject user-supplied values — the column is populated by the
42    /// engine from HLC at commit.
43    SystemTimestamp,
44    /// Arbitrary-precision decimal with explicit precision and scale.
45    ///
46    /// `precision`: total significant digits, 1–38. `scale`: digits after the
47    /// decimal point, 0–precision. Default when unspecified: `{38, 10}`.
48    Decimal {
49        precision: u8,
50        scale: u8,
51    },
52    Geometry,
53    /// Fixed-dimension float32 vector.
54    Vector(u32),
55    /// Dimensionless sparse vector (term-id → weight map). Variable-length:
56    /// a value is a quoted string literal like `'{12: 0.5, 88: 0.3}'`, stored
57    /// as inline UTF-8 bytes and parsed at index-build time. The sparse index
58    /// carries no dimension, so unlike `Vector(u32)` this type takes no `(N)`.
59    SparseVector,
60    Uuid,
61    /// Arbitrary nested data stored as inline MessagePack.
62    /// Variable-length. Accepts any Value type.
63    Json,
64    /// ULID: 16-byte Crockford Base32-encoded sortable ID.
65    Ulid,
66    /// Duration: signed microsecond precision (i64 internally).
67    Duration,
68    /// Ordered array of values. Variable-length, inline MessagePack.
69    Array,
70    /// Ordered set (auto-deduplicated). Variable-length, inline MessagePack.
71    Set,
72    /// Compiled regex pattern. Stored as string internally.
73    Regex,
74    /// Bounded range of values. Variable-length, inline MessagePack.
75    Range,
76    /// Typed reference to another record (`table:id`). Variable-length, inline MessagePack.
77    Record,
78}
79
80impl ColumnType {
81    /// Whether this type has a fixed byte size in Binary Tuple layout.
82    pub fn fixed_size(&self) -> Option<usize> {
83        match self {
84            Self::Int64
85            | Self::Float64
86            | Self::Timestamp
87            | Self::Timestamptz
88            | Self::SystemTimestamp
89            | Self::Duration => Some(8),
90            Self::Bool => Some(1),
91            Self::Decimal { .. } | Self::Uuid | Self::Ulid => Some(16),
92            Self::Vector(dim) => Some(*dim as usize * 4),
93            Self::String
94            | Self::Bytes
95            | Self::Geometry
96            | Self::Json
97            | Self::SparseVector
98            | Self::Array
99            | Self::Set
100            | Self::Regex
101            | Self::Range
102            | Self::Record => None,
103        }
104    }
105
106    /// Whether this type is variable-length (requires offset table entry).
107    pub fn is_variable_length(&self) -> bool {
108        self.fixed_size().is_none()
109    }
110
111    /// Return the canonical PostgreSQL type OID for this column type.
112    ///
113    /// This is the single authoritative mapping between NodeDB `ColumnType`
114    /// variants and PostgreSQL wire-protocol OIDs. All pgwire code must derive
115    /// OIDs from this method — no local string-matching tables.
116    ///
117    /// Choices for non-native types:
118    /// - `Geometry` → `25` (TEXT): no standard pg geometry OID; PostGIS uses
119    ///   its own extension OID which we cannot claim. TEXT lets clients at least
120    ///   see the WKT/WKB string.
121    /// - `Vector(_)` → `1021` (FLOAT4_ARRAY): closest built-in pg type for a
122    ///   fixed-dimension float32 vector; pgvector uses a custom OID, which we
123    ///   avoid to stay dependency-free.
124    /// - `Array`, `Set`, `Range`, `Record`, `Regex` → `114` (JSON): these are
125    ///   variable-length MessagePack-encoded structures; JSON is the safest
126    ///   generic text OID for clients that need to read the value as a string.
127    pub fn to_pg_oid(&self) -> u32 {
128        match self {
129            Self::Bool => 16,
130            Self::Bytes => 17,
131            Self::Int64 => 20,
132            Self::Float64 => 701,
133            Self::String => 25,
134            Self::Timestamp | Self::SystemTimestamp => 1114,
135            Self::Timestamptz => 1184,
136            Self::Decimal { .. } => 1700,
137            Self::Uuid | Self::Ulid => 2950,
138            Self::Json => 3802,
139            Self::Duration => 1186,
140            // No standard built-in OID for geometry; TEXT lets clients read WKT.
141            Self::Geometry => 25,
142            // FLOAT4_ARRAY (1021) is the closest built-in for fixed float32 vectors.
143            Self::Vector(_) => 1021,
144            // Variable-length structured types: expose as JSONB so clients can
145            // parse the serialized representation. `SparseVector` groups here —
146            // its `'{id: weight}'` literal reads naturally as a JSON object.
147            Self::Array
148            | Self::Set
149            | Self::Range
150            | Self::Record
151            | Self::Regex
152            | Self::SparseVector => 3802,
153        }
154    }
155
156    /// Whether a `Value` is compatible with this column type.
157    ///
158    /// Accepts both native Value types (e.g., `Value::DateTime` for Timestamp)
159    /// AND coercion sources from SQL input (e.g., `Value::String` for Timestamp).
160    /// Null is accepted for any type — nullability is enforced at schema level.
161    pub fn accepts(&self, value: &Value) -> bool {
162        matches!(
163            (self, value),
164            (Self::Int64, Value::Integer(_))
165                | (Self::Float64, Value::Float(_) | Value::Integer(_))
166                | (Self::String, Value::String(_))
167                | (Self::Bool, Value::Bool(_))
168                | (Self::Bytes, Value::Bytes(_))
169                | (
170                    Self::Timestamp,
171                    Value::NaiveDateTime(_) | Value::Integer(_) | Value::String(_)
172                )
173                | (
174                    Self::Timestamptz,
175                    Value::DateTime(_) | Value::Integer(_) | Value::String(_)
176                )
177                | (
178                    Self::SystemTimestamp,
179                    Value::DateTime(_) | Value::Integer(_)
180                )
181                | (
182                    Self::Decimal { .. },
183                    Value::Decimal(_) | Value::String(_) | Value::Float(_) | Value::Integer(_)
184                )
185                | (Self::Geometry, Value::Geometry(_) | Value::String(_))
186                | (Self::Vector(_), Value::Array(_) | Value::Bytes(_))
187                | (Self::Uuid, Value::Uuid(_) | Value::String(_))
188                | (Self::Ulid, Value::Ulid(_) | Value::String(_))
189                | (
190                    Self::Duration,
191                    Value::Duration(_) | Value::Integer(_) | Value::String(_)
192                )
193                | (Self::Array, Value::Array(_))
194                | (Self::Set, Value::Set(_) | Value::Array(_))
195                | (Self::Regex, Value::Regex(_) | Value::String(_))
196                | (Self::Range, Value::Range { .. })
197                | (Self::Record, Value::Record { .. } | Value::String(_))
198                | (Self::SparseVector, Value::String(_) | Value::Bytes(_))
199                | (Self::Json, _)
200                | (_, Value::Null)
201        )
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn nodedb_types_datetime_epoch() -> crate::datetime::NdbDateTime {
210        crate::datetime::NdbDateTime::from_micros(0)
211    }
212
213    #[test]
214    fn to_pg_oid_stable() {
215        assert_eq!(ColumnType::Bool.to_pg_oid(), 16);
216        assert_eq!(ColumnType::Bytes.to_pg_oid(), 17);
217        assert_eq!(ColumnType::Int64.to_pg_oid(), 20);
218        assert_eq!(ColumnType::String.to_pg_oid(), 25);
219        assert_eq!(ColumnType::Float64.to_pg_oid(), 701);
220        assert_eq!(ColumnType::Timestamp.to_pg_oid(), 1114);
221        assert_eq!(ColumnType::Timestamptz.to_pg_oid(), 1184);
222        assert_eq!(ColumnType::SystemTimestamp.to_pg_oid(), 1114);
223        assert_eq!(ColumnType::Duration.to_pg_oid(), 1186);
224        assert_eq!(
225            ColumnType::Decimal {
226                precision: 38,
227                scale: 10
228            }
229            .to_pg_oid(),
230            1700
231        );
232        assert_eq!(ColumnType::Uuid.to_pg_oid(), 2950);
233        assert_eq!(ColumnType::Ulid.to_pg_oid(), 2950);
234        assert_eq!(ColumnType::Json.to_pg_oid(), 3802);
235        assert_eq!(ColumnType::Geometry.to_pg_oid(), 25);
236        assert_eq!(ColumnType::Vector(768).to_pg_oid(), 1021);
237        assert_eq!(ColumnType::Array.to_pg_oid(), 3802);
238        assert_eq!(ColumnType::Set.to_pg_oid(), 3802);
239        assert_eq!(ColumnType::Range.to_pg_oid(), 3802);
240        assert_eq!(ColumnType::Record.to_pg_oid(), 3802);
241        assert_eq!(ColumnType::Regex.to_pg_oid(), 3802);
242    }
243
244    #[test]
245    fn parse_system_timestamp() {
246        assert_eq!(
247            "SYSTEM_TIMESTAMP".parse::<ColumnType>().unwrap(),
248            ColumnType::SystemTimestamp
249        );
250        assert_eq!(
251            "SystemTimestamp".parse::<ColumnType>().unwrap(),
252            ColumnType::SystemTimestamp
253        );
254        assert_eq!(ColumnType::SystemTimestamp.fixed_size(), Some(8));
255        assert!(!ColumnType::SystemTimestamp.is_variable_length());
256        assert_eq!(ColumnType::SystemTimestamp.to_string(), "SYSTEM_TIMESTAMP");
257        assert!(!ColumnType::SystemTimestamp.accepts(&Value::String("2024-01-01".into())));
258        assert!(ColumnType::SystemTimestamp.accepts(&Value::Integer(1_700_000_000)));
259    }
260
261    #[test]
262    fn parse_canonical() {
263        assert_eq!("BIGINT".parse::<ColumnType>().unwrap(), ColumnType::Int64);
264        assert_eq!(
265            "FLOAT64".parse::<ColumnType>().unwrap(),
266            ColumnType::Float64
267        );
268        assert_eq!("TEXT".parse::<ColumnType>().unwrap(), ColumnType::String);
269        assert_eq!("BOOL".parse::<ColumnType>().unwrap(), ColumnType::Bool);
270        assert_eq!(
271            "TIMESTAMP".parse::<ColumnType>().unwrap(),
272            ColumnType::Timestamp
273        );
274        assert_eq!(
275            "TIMESTAMPTZ".parse::<ColumnType>().unwrap(),
276            ColumnType::Timestamptz
277        );
278        assert_eq!(
279            "TIMESTAMP WITH TIME ZONE".parse::<ColumnType>().unwrap(),
280            ColumnType::Timestamptz
281        );
282        assert_eq!(
283            "GEOMETRY".parse::<ColumnType>().unwrap(),
284            ColumnType::Geometry
285        );
286        assert_eq!("UUID".parse::<ColumnType>().unwrap(), ColumnType::Uuid);
287    }
288
289    #[test]
290    fn parse_vector() {
291        assert_eq!(
292            "VECTOR(768)".parse::<ColumnType>().unwrap(),
293            ColumnType::Vector(768)
294        );
295        assert!("VECTOR(0)".parse::<ColumnType>().is_err());
296    }
297
298    #[test]
299    fn sparsevector_is_variable_length_string_type() {
300        // Dimensionless: parses from the bare keyword and round-trips via Display.
301        let parsed = "SPARSEVECTOR".parse::<ColumnType>().unwrap();
302        assert_eq!(parsed, ColumnType::SparseVector);
303        assert_eq!(ColumnType::SparseVector.to_string(), "SPARSEVECTOR");
304        assert_eq!(
305            ColumnType::SparseVector
306                .to_string()
307                .parse::<ColumnType>()
308                .unwrap(),
309            ColumnType::SparseVector
310        );
311
312        // Variable-length (no fixed byte size), JSONB pg oid like the other
313        // variable-length structured types.
314        assert_eq!(ColumnType::SparseVector.fixed_size(), None);
315        assert!(ColumnType::SparseVector.is_variable_length());
316        assert_eq!(ColumnType::SparseVector.to_pg_oid(), 3802);
317
318        // Accepts the quoted string literal (and raw bytes); rejects numerics.
319        assert!(ColumnType::SparseVector.accepts(&Value::String("{3:0.5}".into())));
320        assert!(ColumnType::SparseVector.accepts(&Value::Bytes(vec![1, 2, 3])));
321        assert!(ColumnType::SparseVector.accepts(&Value::Null));
322        assert!(!ColumnType::SparseVector.accepts(&Value::Integer(1)));
323    }
324
325    #[test]
326    fn display_roundtrip() {
327        for ct in [
328            ColumnType::Int64,
329            ColumnType::Float64,
330            ColumnType::String,
331            ColumnType::Timestamp,
332            ColumnType::Timestamptz,
333            ColumnType::Vector(768),
334            ColumnType::Decimal {
335                precision: 10,
336                scale: 2,
337            },
338            ColumnType::Decimal {
339                precision: 38,
340                scale: 10,
341            },
342        ] {
343            let s = ct.to_string();
344            let parsed: ColumnType = s.parse().unwrap();
345            assert_eq!(parsed, ct);
346        }
347    }
348
349    #[test]
350    fn decimal_parse_with_params() {
351        assert_eq!(
352            "NUMERIC(10,2)".parse::<ColumnType>().unwrap(),
353            ColumnType::Decimal {
354                precision: 10,
355                scale: 2
356            }
357        );
358        assert_eq!(
359            "DECIMAL(38,10)".parse::<ColumnType>().unwrap(),
360            ColumnType::Decimal {
361                precision: 38,
362                scale: 10
363            }
364        );
365        assert_eq!(
366            "NUMERIC".parse::<ColumnType>().unwrap(),
367            ColumnType::Decimal {
368                precision: 38,
369                scale: 10
370            }
371        );
372        assert_eq!(
373            "DECIMAL".parse::<ColumnType>().unwrap(),
374            ColumnType::Decimal {
375                precision: 38,
376                scale: 10
377            }
378        );
379    }
380
381    #[test]
382    fn decimal_parse_invalid() {
383        assert!("DECIMAL(5,6)".parse::<ColumnType>().is_err());
384        assert!("DECIMAL(0,0)".parse::<ColumnType>().is_err());
385        assert!("DECIMAL(39,0)".parse::<ColumnType>().is_err());
386    }
387
388    #[test]
389    fn decimal_fixed_size() {
390        assert_eq!(
391            ColumnType::Decimal {
392                precision: 10,
393                scale: 2
394            }
395            .fixed_size(),
396            Some(16)
397        );
398    }
399
400    #[test]
401    fn decimal_to_pg_oid_is_1700() {
402        assert_eq!(
403            ColumnType::Decimal {
404                precision: 10,
405                scale: 2
406            }
407            .to_pg_oid(),
408            1700
409        );
410    }
411
412    #[test]
413    fn accepts_native_values() {
414        assert!(ColumnType::Int64.accepts(&Value::Integer(42)));
415        assert!(ColumnType::Float64.accepts(&Value::Float(42.0)));
416        assert!(ColumnType::Float64.accepts(&Value::Integer(42)));
417        assert!(ColumnType::String.accepts(&Value::String("x".into())));
418        assert!(ColumnType::Bool.accepts(&Value::Bool(true)));
419        assert!(ColumnType::Bytes.accepts(&Value::Bytes(vec![1])));
420        assert!(
421            ColumnType::Uuid.accepts(&Value::Uuid("550e8400-e29b-41d4-a716-446655440000".into()))
422        );
423        assert!(
424            ColumnType::Decimal {
425                precision: 38,
426                scale: 10
427            }
428            .accepts(&Value::Decimal(rust_decimal::Decimal::ZERO))
429        );
430
431        let naive = Value::NaiveDateTime(nodedb_types_datetime_epoch());
432        let tz = Value::DateTime(nodedb_types_datetime_epoch());
433        assert!(ColumnType::Timestamp.accepts(&naive));
434        assert!(!ColumnType::Timestamp.accepts(&tz));
435        assert!(ColumnType::Timestamptz.accepts(&tz));
436        assert!(!ColumnType::Timestamptz.accepts(&naive));
437
438        assert!(ColumnType::Int64.accepts(&Value::Null));
439        assert!(!ColumnType::Int64.accepts(&Value::String("x".into())));
440        assert!(!ColumnType::Bool.accepts(&Value::Integer(1)));
441    }
442
443    #[test]
444    fn accepts_coercion_sources() {
445        assert!(ColumnType::Timestamp.accepts(&Value::String("2024-01-01".into())));
446        assert!(ColumnType::Timestamp.accepts(&Value::Integer(1_700_000_000)));
447        assert!(ColumnType::Timestamptz.accepts(&Value::String("2024-01-01T00:00:00Z".into())));
448        assert!(ColumnType::Timestamptz.accepts(&Value::Integer(1_700_000_000)));
449        assert!(ColumnType::Uuid.accepts(&Value::String(
450            "550e8400-e29b-41d4-a716-446655440000".into()
451        )));
452        assert!(
453            ColumnType::Decimal {
454                precision: 10,
455                scale: 2
456            }
457            .accepts(&Value::String("99.95".into()))
458        );
459        assert!(
460            ColumnType::Decimal {
461                precision: 10,
462                scale: 2
463            }
464            .accepts(&Value::Float(99.95))
465        );
466        assert!(ColumnType::Geometry.accepts(&Value::String("POINT(0 0)".into())));
467    }
468
469    #[test]
470    fn column_def_display() {
471        use super::super::column_def::ColumnDef;
472        let col = ColumnDef::required("id", ColumnType::Int64).with_primary_key();
473        assert_eq!(col.to_string(), "id BIGINT NOT NULL PRIMARY KEY");
474    }
475}