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}