Skip to main content

faucet_core/
json_numbers.rs

1//! Exact handling of numbers inside JSON text read from a database column.
2//!
3//! A `serde_json::Value` holds every non-integer (and every integer beyond
4//! `u64`/`i64`) as an `f64`, so a JSON document with a number of more than
5//! about 17 significant digits would silently lose digits on the way through.
6//! [`parse_json_exact`] checks every number token of the *source text* and
7//! either refuses the document or keeps such a number as an exact string,
8//! per [`JsonBigNumbers`].
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// What to do with a number inside a JSON / JSONB column that a 64-bit float
14/// cannot hold exactly.
15#[derive(
16    Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
17)]
18#[serde(rename_all = "snake_case")]
19pub enum JsonBigNumbers {
20    /// Fail the read with an error naming the column (default): no silent
21    /// rounding.
22    #[default]
23    Fail,
24    /// Emit that number as a JSON string holding its exact digits.
25    String,
26}
27
28/// An inexact number found in a JSON document.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct InexactNumber {
31    /// The number's text as it appears in the document.
32    pub text: String,
33}
34
35impl InexactNumber {
36    /// The leading digits, for an error message.
37    pub fn preview(&self) -> String {
38        let mut p: String = self.text.chars().take(24).collect();
39        if self.text.chars().count() > 24 {
40            p.push('…');
41        }
42        p
43    }
44}
45
46/// `(negative, significant digits, exponent)` with `value = digits × 10^exp`,
47/// digits stripped of leading and trailing zeros (`("", 0)` for zero).
48fn normalize(text: &str) -> Option<(bool, String, i64)> {
49    let (neg, rest) = match text.strip_prefix('-') {
50        Some(r) => (true, r),
51        None => (false, text),
52    };
53    let (mantissa, exp) = match rest.find(['e', 'E']) {
54        Some(i) => (&rest[..i], rest[i + 1..].parse::<i64>().ok()?),
55        None => (rest, 0),
56    };
57    let (int, frac) = mantissa.split_once('.').unwrap_or((mantissa, ""));
58    if int.is_empty() || !int.bytes().chain(frac.bytes()).all(|b| b.is_ascii_digit()) {
59        return None;
60    }
61    let mut digits = format!("{int}{frac}");
62    let mut exp = exp.checked_sub(frac.len() as i64)?;
63    let trimmed = digits.trim_start_matches('0').len();
64    digits.drain(..digits.len() - trimmed);
65    while digits.ends_with('0') {
66        digits.pop();
67        exp += 1;
68    }
69    if digits.is_empty() {
70        return Some((false, String::new(), 0));
71    }
72    Some((neg, digits, exp))
73}
74
75/// Whether the number `text` survives conversion to a JSON `Value` exactly:
76/// an `i64` / `u64`, or an `f64` whose shortest form has the same value.
77pub fn is_exact(text: &str) -> bool {
78    if text.parse::<i64>().is_ok() || text.parse::<u64>().is_ok() {
79        return true;
80    }
81    let Ok(f) = text.parse::<f64>() else {
82        return false;
83    };
84    let Some(n) = serde_json::Number::from_f64(f) else {
85        return false;
86    };
87    normalize(text).is_some() && normalize(text) == normalize(&n.to_string())
88}
89
90/// Byte ranges of the number tokens in JSON `text` (outside strings).
91fn number_tokens(text: &str) -> Vec<std::ops::Range<usize>> {
92    let bytes = text.as_bytes();
93    let mut out = Vec::new();
94    let mut i = 0;
95    while i < bytes.len() {
96        match bytes[i] {
97            b'"' => {
98                i += 1;
99                while i < bytes.len() && bytes[i] != b'"' {
100                    i += if bytes[i] == b'\\' { 2 } else { 1 };
101                }
102                i += 1;
103            }
104            b'-' | b'0'..=b'9' => {
105                let start = i;
106                while i < bytes.len()
107                    && matches!(bytes[i], b'-' | b'+' | b'.' | b'e' | b'E' | b'0'..=b'9')
108                {
109                    i += 1;
110                }
111                out.push(start..i);
112            }
113            _ => i += 1,
114        }
115    }
116    out
117}
118
119/// Parse JSON `text`, applying `mode` to every number a `Value` cannot hold
120/// exactly. Returns the value and the inexact numbers found; under
121/// [`JsonBigNumbers::Fail`] a document with any is an `Err` carrying them.
122pub fn parse_json_exact(
123    text: &str,
124    mode: JsonBigNumbers,
125) -> Result<(Value, Vec<InexactNumber>), JsonNumberError> {
126    let inexact: Vec<_> = number_tokens(text)
127        .into_iter()
128        .filter(|r| !is_exact(&text[r.clone()]))
129        .collect();
130    if inexact.is_empty() {
131        return serde_json::from_str(text)
132            .map(|v| (v, Vec::new()))
133            .map_err(|e| JsonNumberError::Invalid(e.to_string()));
134    }
135    let found: Vec<InexactNumber> = inexact
136        .iter()
137        .map(|r| InexactNumber {
138            text: text[r.clone()].to_string(),
139        })
140        .collect();
141    if mode == JsonBigNumbers::Fail {
142        return Err(JsonNumberError::Inexact(found));
143    }
144    let mut rewritten = String::with_capacity(text.len() + 2 * inexact.len());
145    let mut last = 0;
146    for r in &inexact {
147        rewritten.push_str(&text[last..r.start]);
148        rewritten.push('"');
149        rewritten.push_str(&text[r.clone()]);
150        rewritten.push('"');
151        last = r.end;
152    }
153    rewritten.push_str(&text[last..]);
154    serde_json::from_str(&rewritten)
155        .map(|v| (v, found))
156        .map_err(|e| JsonNumberError::Invalid(e.to_string()))
157}
158
159/// Why [`parse_json_exact`] refused a document.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub enum JsonNumberError {
162    /// Numbers a `Value` cannot hold exactly, under [`JsonBigNumbers::Fail`].
163    Inexact(Vec<InexactNumber>),
164    /// The text is not valid JSON.
165    Invalid(String),
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use serde_json::json;
172
173    #[test]
174    fn exactness_compares_decimal_values() {
175        for exact in [
176            "0",
177            "-0",
178            "1",
179            "1.50",
180            "0.1",
181            "1e2",
182            "1E-2",
183            "9007199254740993",
184            "18446744073709551615",
185            "-9223372036854775808",
186            "123.456",
187            "1e300",
188        ] {
189            assert!(is_exact(exact), "{exact}");
190        }
191        for inexact in [
192            "12345678901234567890.123",
193            "18446744073709551616",
194            "0.12345678901234567891",
195            "1e400",
196            "-",
197        ] {
198            assert!(!is_exact(inexact), "{inexact}");
199        }
200        assert_eq!(normalize("1.2e"), None);
201        assert_eq!(normalize("."), None);
202        assert_eq!(normalize("0.000"), Some((false, String::new(), 0)));
203        assert_eq!(normalize("-1.50"), Some((true, "15".to_string(), -1)));
204    }
205
206    #[test]
207    fn documents_fail_or_keep_big_numbers_as_strings() {
208        let doc = r#"{"a": 12345678901234567890.123, "s": "9.99999999999999999999 in a string", "b": [1, 2.5], "e": "\"q\\" }"#;
209        let failed = parse_json_exact(doc, JsonBigNumbers::Fail);
210        assert!(matches!(
211            &failed,
212            Err(JsonNumberError::Inexact(found))
213                if found.len() == 1 && found[0].preview() == "12345678901234567890.123"
214        ));
215        let (v, found) = parse_json_exact(doc, JsonBigNumbers::String).unwrap();
216        assert_eq!(found.len(), 1);
217        assert_eq!(v["a"], json!("12345678901234567890.123"));
218        assert_eq!(v["b"], json!([1, 2.5]));
219        let (v, found) = parse_json_exact("[1.5]", JsonBigNumbers::Fail).unwrap();
220        assert!(found.is_empty());
221        assert_eq!(v, json!([1.5]));
222        assert!(matches!(
223            parse_json_exact("{", JsonBigNumbers::Fail),
224            Err(JsonNumberError::Invalid(_))
225        ));
226        assert!(matches!(
227            parse_json_exact("[1e400,", JsonBigNumbers::String),
228            Err(JsonNumberError::Invalid(_))
229        ));
230        let long = InexactNumber {
231            text: "1".repeat(30),
232        };
233        assert!(long.preview().ends_with('…'));
234    }
235}