nodedb_types/columnar/float_width.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! [`FloatWidth`] — the declared width of a floating-point column.
4//!
5//! nodedb stores every float as a full `f64` regardless of the width the DDL
6//! author declared: `ColumnType::Float64` is the one storage variant for
7//! `REAL`, `FLOAT4`, `DOUBLE`, and `FLOAT8` alike. The declared width is
8//! therefore not a storage property — it is a *wire contract*: the column's
9//! `RowDescription` OID is 700 or 701, and under the binary result format the
10//! value is transmitted as 4 or 8 bytes. A client that declared `REAL` decodes
11//! exactly four bytes; handing it eight is a decode failure, not a rounding
12//! error.
13//!
14//! # How this differs from [`IntWidth`](super::IntWidth)
15//!
16//! The integer analogue bounds writes by *full range*: narrowing an
17//! out-of-range `i64` wraps, so any value that does not fit a declared
18//! `INTEGER` is rejected at write time exactly as PostgreSQL rejects it.
19//! Floats are bounded differently. Narrowing an `f64` to an `f32` **rounds** —
20//! `1.1` becoming `1.10000002` is correct PostgreSQL `real` behaviour, not an
21//! error — so `check_declared_float_ranges` (the float counterpart of
22//! `check_declared_int_ranges`) never rejects on rounding. The single failure
23//! mode it does reject is *overflow to infinity*: a finite `f64` beyond
24//! `f32`'s range becomes `inf`, which PostgreSQL raises `value out of range:
25//! overflow` for. That check runs at write time in the planner, mirroring the
26//! read-side guard the wire encoder applies at narrowing time — the two are
27//! deliberately layered, not redundant: the write-time check is bypassed by
28//! rows written before the column's width was declared or via non-SQL
29//! ingest, so the read-side guard remains the backstop.
30//!
31//! Because the keyword set is shared by catalog introspection OIDs and
32//! `RowDescription` OIDs alike, [`FloatWidth::from_declared_type`] is the
33//! **single** place that maps a declared type string to a float width, so the
34//! two can never drift apart.
35
36use serde::{Deserialize, Serialize};
37
38/// The precision at or below which PostgreSQL's `FLOAT(p)` is single-precision.
39///
40/// `FLOAT(1)`..`FLOAT(24)` is `real`; `FLOAT(25)`..`FLOAT(53)` is
41/// `double precision`. See PostgreSQL's numeric-type documentation.
42const MAX_SINGLE_PRECISION_P: u32 = 24;
43
44/// The declared width of a floating-point column.
45///
46/// Ordered narrow → wide; `F64` is the storage width and the default for a
47/// float column whose declared type carries no width information.
48#[derive(
49 Debug,
50 Clone,
51 Copy,
52 PartialEq,
53 Eq,
54 PartialOrd,
55 Ord,
56 Hash,
57 Serialize,
58 Deserialize,
59 zerompk::ToMessagePack,
60 zerompk::FromMessagePack,
61)]
62#[msgpack(c_enum)]
63#[repr(u8)]
64pub enum FloatWidth {
65 /// `REAL` / `FLOAT4` / `FLOAT32` / `FLOAT(p<=24)` — 4 bytes on the wire,
66 /// PostgreSQL OID 700.
67 F32 = 0,
68 /// `DOUBLE PRECISION` / `FLOAT8` / `FLOAT64` / `FLOAT` / `FLOAT(p>=25)` —
69 /// 8 bytes on the wire, PostgreSQL OID 701.
70 F64 = 1,
71}
72
73impl FloatWidth {
74 /// Resolve a declared SQL type string to a float width, or `None` when the
75 /// string does not name a floating-point type.
76 ///
77 /// Matching is case-insensitive and boundary-aware, using the same idiom as
78 /// [`IntWidth::from_declared_type`](super::IntWidth::from_declared_type): a
79 /// keyword matches when the (trimmed, lowercased) input either equals it
80 /// exactly or is followed by `(` or whitespace, so trailing modifiers
81 /// (`"REAL NOT NULL"`) and parameterised spellings still resolve. The
82 /// boundary check is also what keeps bare `float` from swallowing
83 /// `float4`/`float8`/`float32`/`float64` as prefixes — the next character
84 /// is a digit, which is neither `(` nor whitespace — so the arm order below
85 /// is not load-bearing.
86 ///
87 /// # Keyword mapping
88 ///
89 /// Bare `FLOAT` is **double precision**, matching PostgreSQL (and the SQL
90 /// standard), not single. `REAL` is always single. `FLOAT(p)` follows
91 /// PostgreSQL's precision rule: `p <= 24` is `real`, `p >= 25` is
92 /// `double precision`. A `(p)` that is absent, unterminated, non-numeric,
93 /// or outside `1..=53` falls back to the wider `F64` rather than guessing —
94 /// widening is the only lossless direction.
95 pub fn from_declared_type(declared: &str) -> Option<Self> {
96 let normalized = declared.trim().to_ascii_lowercase();
97 let is = |name: &str| super::declared_type_matches(&normalized, name);
98
99 if is("real") || is("float4") || is("float32") {
100 Some(Self::F32)
101 } else if is("double") || is("float8") || is("float64") {
102 // `is("double")` already covers `double precision`: the boundary
103 // character after `double` is whitespace.
104 Some(Self::F64)
105 } else if is("float") {
106 // Bare `FLOAT`, or the parameterised `FLOAT(p)` form.
107 Some(Self::from_float_precision(&normalized))
108 } else {
109 None
110 }
111 }
112
113 /// The width PostgreSQL gives a `FLOAT(p)` spelling, given the already
114 /// lowercased, trimmed declared type.
115 ///
116 /// Every malformed shape — no `(`, no closing `)`, non-numeric or
117 /// out-of-range `p` — resolves to `F64`, the wider type. This is a total
118 /// function by construction: it never indexes, never unwraps, and has no
119 /// failure path to propagate, because a bare `FLOAT` legitimately has no
120 /// precision and must land on the same answer as a broken one.
121 fn from_float_precision(normalized: &str) -> Self {
122 let Some(rest) = normalized.strip_prefix("float") else {
123 return Self::F64;
124 };
125 let Some(inner) = rest.trim_start().strip_prefix('(') else {
126 return Self::F64;
127 };
128 let Some(end) = inner.find(')') else {
129 return Self::F64;
130 };
131 let Some(digits) = inner.get(..end) else {
132 return Self::F64;
133 };
134 match digits.trim().parse::<u32>() {
135 Ok(p) if (1..=MAX_SINGLE_PRECISION_P).contains(&p) => Self::F32,
136 Ok(_) | Err(_) => Self::F64,
137 }
138 }
139
140 /// The PostgreSQL type OID a column of this width advertises in
141 /// `RowDescription` and in catalog introspection.
142 pub const fn pg_oid(self) -> u32 {
143 match self {
144 Self::F32 => 700,
145 Self::F64 => 701,
146 }
147 }
148
149 /// The canonical PostgreSQL type name, for error messages that must read
150 /// like PostgreSQL's own (`value out of range for type real`).
151 pub const fn pg_type_name(self) -> &'static str {
152 match self {
153 Self::F32 => "real",
154 Self::F64 => "double precision",
155 }
156 }
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn resolves_every_declared_float_spelling() {
165 let cases: &[(&str, FloatWidth)] = &[
166 ("REAL", FloatWidth::F32),
167 ("FLOAT4", FloatWidth::F32),
168 ("FLOAT32", FloatWidth::F32),
169 ("FLOAT", FloatWidth::F64),
170 ("FLOAT8", FloatWidth::F64),
171 ("FLOAT64", FloatWidth::F64),
172 ("DOUBLE", FloatWidth::F64),
173 ("DOUBLE PRECISION", FloatWidth::F64),
174 ];
175 for (declared, expected) in cases {
176 assert_eq!(
177 FloatWidth::from_declared_type(declared),
178 Some(*expected),
179 "{declared} must resolve to {expected:?}"
180 );
181 assert_eq!(
182 FloatWidth::from_declared_type(&declared.to_lowercase()),
183 Some(*expected),
184 "{declared} must resolve case-insensitively"
185 );
186 }
187 }
188
189 /// Bare `FLOAT` is double precision in PostgreSQL, not single. Getting
190 /// this backwards is the whole reason catalog introspection and the SELECT
191 /// path disagreed for `FLOAT` columns before this type existed.
192 #[test]
193 fn bare_float_is_double_precision() {
194 assert_eq!(
195 FloatWidth::from_declared_type("FLOAT"),
196 Some(FloatWidth::F64)
197 );
198 assert_eq!(FloatWidth::F64.pg_oid(), 701);
199 }
200
201 /// The boundary check — not the arm order — is what stops `float` from
202 /// prefix-matching the numbered spellings. Locking it directly means a
203 /// future reordering of the arms cannot silently regress the mapping.
204 #[test]
205 fn float_does_not_prefix_match_numbered_spellings() {
206 for (declared, expected) in [
207 ("float4", FloatWidth::F32),
208 ("float32", FloatWidth::F32),
209 ("float8", FloatWidth::F64),
210 ("float64", FloatWidth::F64),
211 ] {
212 assert_eq!(FloatWidth::from_declared_type(declared), Some(expected));
213 }
214 }
215
216 /// PostgreSQL's `FLOAT(p)` rule: `p <= 24` is `real`, `p >= 25` is
217 /// `double precision`.
218 #[test]
219 fn float_precision_follows_the_postgres_rule() {
220 for p in [1_u32, 10, 24] {
221 assert_eq!(
222 FloatWidth::from_declared_type(&format!("FLOAT({p})")),
223 Some(FloatWidth::F32),
224 "FLOAT({p}) must be single precision"
225 );
226 }
227 for p in [25_u32, 40, 53] {
228 assert_eq!(
229 FloatWidth::from_declared_type(&format!("FLOAT({p})")),
230 Some(FloatWidth::F64),
231 "FLOAT({p}) must be double precision"
232 );
233 }
234 }
235
236 /// A malformed or out-of-range precision must never panic and must never
237 /// narrow — it falls back to the wider `F64`, the only lossless default.
238 #[test]
239 fn malformed_float_precision_falls_back_to_f64() {
240 for declared in [
241 "FLOAT(x)",
242 "FLOAT(",
243 "FLOAT()",
244 "FLOAT(24",
245 "FLOAT(0)",
246 "FLOAT(54)",
247 "FLOAT(-3)",
248 "FLOAT(99999999999999999999)",
249 "FLOAT (24",
250 ] {
251 assert_eq!(
252 FloatWidth::from_declared_type(declared),
253 Some(FloatWidth::F64),
254 "{declared} must fall back to double precision"
255 );
256 }
257 }
258
259 #[test]
260 fn tolerates_trailing_modifiers_and_whitespace() {
261 assert_eq!(
262 FloatWidth::from_declared_type("REAL NOT NULL"),
263 Some(FloatWidth::F32)
264 );
265 assert_eq!(
266 FloatWidth::from_declared_type(" double precision "),
267 Some(FloatWidth::F64)
268 );
269 assert_eq!(
270 FloatWidth::from_declared_type("FLOAT8 NOT NULL"),
271 Some(FloatWidth::F64)
272 );
273 }
274
275 #[test]
276 fn rejects_non_float_types() {
277 for declared in [
278 "TEXT",
279 "VARCHAR(20)",
280 "INT4",
281 "BIGINT",
282 "BOOL",
283 "TIMESTAMP",
284 "GEOMETRY",
285 "DECIMAL(10,2)",
286 "",
287 "floaty",
288 "realistic",
289 ] {
290 assert_eq!(
291 FloatWidth::from_declared_type(declared),
292 None,
293 "{declared} must not resolve to a float width"
294 );
295 }
296 }
297
298 #[test]
299 fn pg_oids_match_the_postgres_catalog() {
300 assert_eq!(FloatWidth::F32.pg_oid(), 700);
301 assert_eq!(FloatWidth::F64.pg_oid(), 701);
302 }
303
304 #[test]
305 fn pg_type_names_match_the_postgres_catalog() {
306 assert_eq!(FloatWidth::F32.pg_type_name(), "real");
307 assert_eq!(FloatWidth::F64.pg_type_name(), "double precision");
308 }
309
310 /// Narrow → wide ordering, mirroring `IntWidth`, so a future "widest of"
311 /// resolution can rely on `Ord` rather than a bespoke comparison.
312 #[test]
313 fn orders_narrow_to_wide() {
314 assert!(FloatWidth::F32 < FloatWidth::F64);
315 }
316}