Skip to main content

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}