Skip to main content

gwk_kernel/
numeric.rs

1//! Checked conversion between wire `u64` counters and PostgreSQL `numeric(20,0)`.
2//!
3//! The contract's 64-bit counters — `seq`, `fence_token`, `byte_size` — span
4//! the full `u64` range, and PostgreSQL has no unsigned 64-bit type: `bigint`
5//! tops out at 2^63-1, exactly half of it. `numeric(20,0)` holds the range, so
6//! the schema uses it.
7//!
8//! Getting the value across is where precision goes to die. A driver that maps
9//! `numeric` through `f64` silently rounds above 2^53, and one that maps it
10//! through `i64` overflows above 2^63. This module refuses to go through either:
11//! values cross as DECIMAL TEXT, which is exact for every `u64` by
12//! construction, and parse back under a bounds check. That is also why the
13//! queries in this crate say `seq::text` and `$1::numeric` rather than binding
14//! a numeric type — the cast is the conversion.
15
16/// A `numeric(20,0)` value that is not a `u64`.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct NumericError {
19    pub raw: String,
20    pub reason: &'static str,
21}
22
23impl std::fmt::Display for NumericError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "numeric {:?} is not a u64: {}", self.raw, self.reason)
26    }
27}
28
29impl std::error::Error for NumericError {}
30
31/// Render a counter for a `$n::numeric` parameter.
32pub fn to_numeric_text(value: u64) -> String {
33    value.to_string()
34}
35
36/// Parse a `numeric(20,0)::text` result back into a counter.
37///
38/// Strict on purpose. A scale-0 column renders without a fractional part, so
39/// anything else — `4.0`, `+4`, ` 4`, `4e2`, `NaN` — means the value did not
40/// come from where this function assumes, and guessing at it would be how a
41/// silently wrong sequence enters the log.
42pub fn from_numeric_text(raw: &str) -> Result<u64, NumericError> {
43    let bad = |reason: &'static str| NumericError {
44        raw: raw.to_owned(),
45        reason,
46    };
47    if raw.is_empty() {
48        return Err(bad("empty"));
49    }
50    if !raw.bytes().all(|b| b.is_ascii_digit()) {
51        return Err(bad(
52            "expected only ASCII digits (scale-0 numeric renders bare)",
53        ));
54    }
55    raw.parse::<u64>().map_err(|_| bad("out of u64 range"))
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn every_boundary_survives_the_round_trip() {
64        // 2^53 is where f64 stops being exact; 2^63 is where i64 overflows.
65        // Both are inside the range this column must carry.
66        for value in [
67            0,
68            1,
69            9_007_199_254_740_992,     // 2^53
70            9_007_199_254_740_993,     // 2^53 + 1, unrepresentable in f64
71            9_223_372_036_854_775_807, // i64::MAX
72            9_223_372_036_854_775_808, // i64::MAX + 1
73            u64::MAX,
74        ] {
75            let text = to_numeric_text(value);
76            assert_eq!(from_numeric_text(&text), Ok(value), "round trip of {value}");
77        }
78        assert_eq!(to_numeric_text(u64::MAX), "18446744073709551615");
79    }
80
81    proptest::proptest! {
82        /// The boundary case above proves the five values that were REASONED
83        /// about. This one covers the rest of the range, which is the part no
84        /// list of examples can reach: the claim in this module's own header is
85        /// that decimal text is exact for every `u64`, and "every" is a
86        /// quantifier a generator can actually attack.
87        #[test]
88        fn a_counter_survives_the_round_trip_whatever_it_is(value: u64) {
89            proptest::prop_assert_eq!(from_numeric_text(&to_numeric_text(value)), Ok(value));
90        }
91
92        /// And nothing outside that shape gets in.
93        ///
94        /// The generator is weighted, not uniform: an arbitrary Unicode string
95        /// almost never looks like `+4` or `4e2`, which are exactly the strings a
96        /// lax parser accepts as a sequence number. Two thirds of the cases are
97        /// therefore drawn from the confusable alphabet — digits, signs, a
98        /// decimal point, an exponent, a space — and the last third from
99        /// anywhere, to catch a parser that trusts `char::is_numeric` (which is
100        /// true of `٤` and `4`) instead of ASCII.
101        #[test]
102        fn only_bare_ascii_digits_parse(
103            raw in proptest::prop_oneof![
104                r"[0-9]{0,24}",
105                r"[-+0-9.eE ]{0,12}",
106                r"\PC*",
107            ],
108        ) {
109            let bare = !raw.is_empty() && raw.bytes().all(|b| b.is_ascii_digit());
110            let parsed = from_numeric_text(&raw);
111            if bare {
112                // Still may be refused, but only for being out of range.
113                proptest::prop_assert!(parsed.is_ok() || raw.parse::<u64>().is_err());
114            } else {
115                proptest::prop_assert!(parsed.is_err(), "{raw:?} parsed as a counter");
116            }
117        }
118    }
119
120    #[test]
121    fn anything_that_is_not_a_bare_integer_is_refused() {
122        for raw in [
123            "",
124            " 4",
125            "4 ",
126            "+4",
127            "-1",
128            "4.0",
129            "4e2",
130            "NaN",
131            "0x10",
132            "18446744073709551616", // u64::MAX + 1
133            "99999999999999999999", // the numeric(20,0) ceiling, above u64
134        ] {
135            assert!(
136                from_numeric_text(raw).is_err(),
137                "{raw:?} must not parse as a counter"
138            );
139        }
140    }
141}