Skip to main content

nodedb_types/columnar/
int_width.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! [`IntWidth`] — the declared width of an integer column.
4//!
5//! nodedb stores every integer as a full `i64` regardless of the width the
6//! DDL author declared: `ColumnType::Int64` is the one storage variant for
7//! `SMALLINT`, `INTEGER`, and `BIGINT` alike. The declared width is therefore
8//! not a storage property — it is a *constraint plus a wire contract*:
9//!
10//! * **Constraint** — a value written to a column declared `INTEGER` must fit
11//!   in an `i32`, exactly as PostgreSQL enforces (`integer out of range`).
12//!   Without this, the wire contract below cannot be honoured.
13//! * **Wire contract** — the column's `RowDescription` OID is 21/23/20, and
14//!   under the binary result format the value is transmitted as 2/4/8 bytes.
15//!   A client that declared `SMALLINT` decodes exactly two bytes; handing it
16//!   a wider value is silent corruption, not a rounding error.
17//!
18//! Because both of those depend on recognising the same set of SQL keywords,
19//! [`IntWidth::from_declared_type`] is the **single** place that maps a
20//! declared type string to a width. Every consumer — catalog introspection
21//! OIDs, `RowDescription` OIDs, and write-time range validation — resolves
22//! through it, so the three can never drift apart.
23
24use serde::{Deserialize, Serialize};
25
26/// The declared width of an integer column.
27///
28/// Ordered narrow → wide; `I64` is the storage width and the default for an
29/// integer column whose declared type carries no width information.
30#[derive(
31    Debug,
32    Clone,
33    Copy,
34    PartialEq,
35    Eq,
36    PartialOrd,
37    Ord,
38    Hash,
39    Serialize,
40    Deserialize,
41    zerompk::ToMessagePack,
42    zerompk::FromMessagePack,
43)]
44#[msgpack(c_enum)]
45#[repr(u8)]
46pub enum IntWidth {
47    /// `SMALLINT` / `INT2` — 2 bytes on the wire, PostgreSQL OID 21.
48    I16 = 0,
49    /// `INTEGER` / `INT` / `INT4` — 4 bytes on the wire, PostgreSQL OID 23.
50    I32 = 1,
51    /// `BIGINT` / `INT8` / `INT64` — 8 bytes on the wire, PostgreSQL OID 20.
52    I64 = 2,
53}
54
55impl IntWidth {
56    /// Resolve a declared SQL type string to an integer width, or `None` when
57    /// the string does not name an integer type.
58    ///
59    /// Matching is case-insensitive and boundary-aware: a keyword matches when
60    /// the (trimmed, lowercased) input either equals it exactly or is followed
61    /// by `(` or whitespace, so trailing modifiers (`"BIGINT NOT NULL"`) and
62    /// parameterised spellings still resolve. The boundary check is also what
63    /// keeps `int` from swallowing `int2`/`int4`/`int8`/`int64` as prefixes —
64    /// the next character is a digit, which is neither `(` nor whitespace —
65    /// so the arm order below is not load-bearing.
66    ///
67    /// This is the single source of truth for keyword → width. Callers that
68    /// need an OID or a range check derive it from the returned width rather
69    /// than re-parsing the string.
70    pub fn from_declared_type(declared: &str) -> Option<Self> {
71        let normalized = declared.trim().to_ascii_lowercase();
72        let is = |name: &str| super::declared_type_matches(&normalized, name);
73
74        if is("smallint") || is("int2") {
75            Some(Self::I16)
76        } else if is("integer") || is("int4") || is("int") {
77            Some(Self::I32)
78        } else if is("bigint") || is("int8") || is("int64") {
79            Some(Self::I64)
80        } else {
81            None
82        }
83    }
84
85    /// The inclusive value range a column of this width accepts.
86    pub const fn range(self) -> (i64, i64) {
87        match self {
88            Self::I16 => (i16::MIN as i64, i16::MAX as i64),
89            Self::I32 => (i32::MIN as i64, i32::MAX as i64),
90            Self::I64 => (i64::MIN, i64::MAX),
91        }
92    }
93
94    /// Whether `value` fits in a column of this declared width.
95    pub const fn contains(self, value: i64) -> bool {
96        let (min, max) = self.range();
97        value >= min && value <= max
98    }
99
100    /// The PostgreSQL type OID a column of this width advertises in
101    /// `RowDescription` and in catalog introspection.
102    pub const fn pg_oid(self) -> u32 {
103        match self {
104            Self::I16 => 21,
105            Self::I32 => 23,
106            Self::I64 => 20,
107        }
108    }
109
110    /// The canonical PostgreSQL type name, for error messages that must read
111    /// like PostgreSQL's own (`integer out of range`).
112    pub const fn pg_type_name(self) -> &'static str {
113        match self {
114            Self::I16 => "smallint",
115            Self::I32 => "integer",
116            Self::I64 => "bigint",
117        }
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn resolves_every_declared_integer_spelling() {
127        let cases: &[(&str, IntWidth)] = &[
128            ("SMALLINT", IntWidth::I16),
129            ("INT2", IntWidth::I16),
130            ("INTEGER", IntWidth::I32),
131            ("INT", IntWidth::I32),
132            ("INT4", IntWidth::I32),
133            ("BIGINT", IntWidth::I64),
134            ("INT8", IntWidth::I64),
135            ("INT64", IntWidth::I64),
136        ];
137        for (declared, expected) in cases {
138            assert_eq!(
139                IntWidth::from_declared_type(declared),
140                Some(*expected),
141                "{declared} must resolve to {expected:?}"
142            );
143            assert_eq!(
144                IntWidth::from_declared_type(&declared.to_lowercase()),
145                Some(*expected),
146                "{declared} must resolve case-insensitively"
147            );
148        }
149    }
150
151    /// The boundary check — not the arm order — is what stops `int` from
152    /// prefix-matching the numbered spellings. Locking it directly means a
153    /// future reordering of the arms cannot silently regress the mapping.
154    #[test]
155    fn int_does_not_prefix_match_numbered_spellings() {
156        for (declared, expected) in [
157            ("int2", IntWidth::I16),
158            ("int4", IntWidth::I32),
159            ("int8", IntWidth::I64),
160            ("int64", IntWidth::I64),
161        ] {
162            assert_eq!(IntWidth::from_declared_type(declared), Some(expected));
163        }
164    }
165
166    #[test]
167    fn tolerates_trailing_modifiers_and_parameters() {
168        assert_eq!(
169            IntWidth::from_declared_type("BIGINT NOT NULL"),
170            Some(IntWidth::I64)
171        );
172        assert_eq!(
173            IntWidth::from_declared_type("  smallint  "),
174            Some(IntWidth::I16)
175        );
176        assert_eq!(IntWidth::from_declared_type("int(11)"), Some(IntWidth::I32));
177    }
178
179    #[test]
180    fn rejects_non_integer_types() {
181        for declared in [
182            "TEXT",
183            "VARCHAR(20)",
184            "FLOAT8",
185            "BOOL",
186            "TIMESTAMP",
187            "GEOMETRY",
188            "",
189            "intergalactic",
190        ] {
191            assert_eq!(
192                IntWidth::from_declared_type(declared),
193                None,
194                "{declared} must not resolve to an integer width"
195            );
196        }
197    }
198
199    #[test]
200    fn range_matches_the_rust_primitive_it_names() {
201        assert_eq!(IntWidth::I16.range(), (i16::MIN as i64, i16::MAX as i64));
202        assert_eq!(IntWidth::I32.range(), (i32::MIN as i64, i32::MAX as i64));
203        assert_eq!(IntWidth::I64.range(), (i64::MIN, i64::MAX));
204    }
205
206    #[test]
207    fn contains_rejects_values_one_past_the_boundary() {
208        assert!(IntWidth::I16.contains(i16::MAX as i64));
209        assert!(!IntWidth::I16.contains(i16::MAX as i64 + 1));
210        assert!(IntWidth::I16.contains(i16::MIN as i64));
211        assert!(!IntWidth::I16.contains(i16::MIN as i64 - 1));
212
213        assert!(IntWidth::I32.contains(i32::MAX as i64));
214        assert!(!IntWidth::I32.contains(i32::MAX as i64 + 1));
215        assert!(IntWidth::I32.contains(i32::MIN as i64));
216        assert!(!IntWidth::I32.contains(i32::MIN as i64 - 1));
217
218        assert!(IntWidth::I64.contains(i64::MAX));
219        assert!(IntWidth::I64.contains(i64::MIN));
220    }
221
222    #[test]
223    fn pg_oids_match_the_postgres_catalog() {
224        assert_eq!(IntWidth::I16.pg_oid(), 21);
225        assert_eq!(IntWidth::I32.pg_oid(), 23);
226        assert_eq!(IntWidth::I64.pg_oid(), 20);
227    }
228}