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}