Skip to main content

nodedb_sql/
coerce.rs

1//! Postgres-semantic value coercion for planner use-sites.
2//!
3//! The planner matches `sqlparser::ast::Value` in numeric contexts
4//! (LIMIT, OFFSET, fusion weights, …). When a parameter was sent over
5//! the pgwire Parse message with `Type::UNKNOWN` — the default for
6//! drivers that don't pre-fetch OIDs, e.g. `postgres-js` with
7//! `fetch_types: false` — our bind layer emits it as
8//! `Value::SingleQuotedString` (we have no type information to do
9//! otherwise at bind time, and a guess-and-coerce approach would
10//! silently corrupt string parameters bound into string columns).
11//!
12//! Postgres' model: UNKNOWN literals stay uncoerced until the planner
13//! has context, and the planner then resolves them by the surrounding
14//! operator / column type. These helpers are the single chokepoint
15//! implementing that resolution for numeric contexts. Any future
16//! numeric use-site must route through here — a raw
17//! `match Value::Number` ignores UNKNOWN-coerced literals and
18//! re-introduces the silent match-failure bug class.
19
20use sqlparser::ast;
21
22/// Resolve a `Value` into a `usize` if numeric-shaped.
23///
24/// Accepts:
25/// - `Value::Number(n, _)` — the typed-parameter and explicit-literal path.
26/// - `Value::SingleQuotedString(s)` / `DoubleQuotedString(s)` where
27///   `s` parses as `usize` — the UNKNOWN-param bind path.
28///
29/// # Bounds
30///
31/// Valid outputs are `[0, usize::MAX]` (64-bit on typical targets).
32/// Inputs that don't fit — negative numbers, fractional values,
33/// values exceeding `usize::MAX`, non-numeric text — return `None`.
34/// The caller decides the semantic: a LIMIT site treats `None` as
35/// "no limit applied" (pre-existing behavior); stricter sites should
36/// surface a planner error.
37///
38/// Does not perform saturating or wrapping coercion — values that
39/// overflow `usize` are rejected, not silently truncated.
40pub fn as_usize_literal(value: &ast::Value) -> Option<usize> {
41    match value {
42        ast::Value::Number(n, _) => n.parse::<usize>().ok(),
43        ast::Value::SingleQuotedString(s) | ast::Value::DoubleQuotedString(s) => {
44            s.parse::<usize>().ok()
45        }
46        _ => None,
47    }
48}
49
50/// Resolve an `Expr::Value` into a `usize` if numeric-shaped. Thin
51/// wrapper that unpacks the `Expr` → `Value` layer so callers reading
52/// LIMIT/OFFSET clauses don't each re-write the unpack.
53pub fn expr_as_usize_literal(expr: &ast::Expr) -> Option<usize> {
54    if let ast::Expr::Value(v) = expr {
55        as_usize_literal(&v.value)
56    } else {
57        None
58    }
59}
60
61/// Resolve a `Value` into an `f64` if numeric-shaped.
62///
63/// Same UNKNOWN-coercion behavior as `as_usize_literal` but for
64/// floating-point contexts (fusion weights, scoring thresholds,
65/// confidence intervals).
66///
67/// # Bounds
68///
69/// `f64::from_str` accepts `NaN`, `inf`, subnormals, and values that
70/// overflow to `±inf` via the IEEE-754 rules. Callers that need to
71/// reject those (e.g. fusion weights outside `[0, 1]`) must validate
72/// the returned value themselves — this helper is purely a literal
73/// extractor, not a domain validator.
74pub fn as_f64_literal(value: &ast::Value) -> Option<f64> {
75    match value {
76        ast::Value::Number(n, _) => n.parse::<f64>().ok(),
77        ast::Value::SingleQuotedString(s) | ast::Value::DoubleQuotedString(s) => {
78            s.parse::<f64>().ok()
79        }
80        _ => None,
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[test]
89    fn usize_from_number() {
90        assert_eq!(
91            as_usize_literal(&ast::Value::Number("42".into(), false)),
92            Some(42)
93        );
94    }
95
96    /// Untyped pgwire param: `Type::UNKNOWN` → `ParamValue::Text` →
97    /// `Value::SingleQuotedString`. LIMIT still has to work.
98    #[test]
99    fn usize_from_unknown_param_text() {
100        assert_eq!(
101            as_usize_literal(&ast::Value::SingleQuotedString("42".into())),
102            Some(42)
103        );
104    }
105
106    #[test]
107    fn usize_rejects_non_numeric_text() {
108        assert_eq!(
109            as_usize_literal(&ast::Value::SingleQuotedString("abc".into())),
110            None
111        );
112    }
113
114    #[test]
115    fn usize_rejects_negative() {
116        assert_eq!(
117            as_usize_literal(&ast::Value::SingleQuotedString("-1".into())),
118            None
119        );
120    }
121
122    #[test]
123    fn f64_from_unknown_param_text() {
124        assert_eq!(
125            as_f64_literal(&ast::Value::SingleQuotedString("1.5".into())),
126            Some(1.5)
127        );
128    }
129
130    // ── bounds / overflow ──────────────────────────────────────────
131
132    /// Values larger than `usize::MAX` are rejected, not wrapped or
133    /// truncated. Silent truncation would reproduce the pattern of
134    /// "untyped param drops silently" — the exact bug class this
135    /// module exists to close.
136    #[test]
137    fn usize_rejects_overflow_number() {
138        let huge = format!("{}0", usize::MAX);
139        assert_eq!(as_usize_literal(&ast::Value::Number(huge, false)), None);
140    }
141
142    #[test]
143    fn usize_rejects_overflow_text() {
144        let huge = format!("{}0", usize::MAX);
145        assert_eq!(
146            as_usize_literal(&ast::Value::SingleQuotedString(huge)),
147            None
148        );
149    }
150
151    #[test]
152    fn usize_rejects_fractional_number() {
153        assert_eq!(
154            as_usize_literal(&ast::Value::Number("1.5".into(), false)),
155            None
156        );
157    }
158
159    #[test]
160    fn usize_rejects_fractional_text() {
161        assert_eq!(
162            as_usize_literal(&ast::Value::SingleQuotedString("1.5".into())),
163            None
164        );
165    }
166
167    #[test]
168    fn usize_rejects_scientific_notation() {
169        // `1e3` is not a usize literal — Postgres treats it as a float.
170        assert_eq!(
171            as_usize_literal(&ast::Value::Number("1e3".into(), false)),
172            None
173        );
174    }
175
176    #[test]
177    fn usize_accepts_zero() {
178        assert_eq!(
179            as_usize_literal(&ast::Value::Number("0".into(), false)),
180            Some(0)
181        );
182    }
183
184    #[test]
185    fn usize_accepts_max() {
186        let max_str = usize::MAX.to_string();
187        assert_eq!(
188            as_usize_literal(&ast::Value::Number(max_str, false)),
189            Some(usize::MAX)
190        );
191    }
192
193    #[test]
194    fn f64_accepts_negative() {
195        assert_eq!(
196            as_f64_literal(&ast::Value::SingleQuotedString("-1.5".into())),
197            Some(-1.5)
198        );
199    }
200
201    #[test]
202    fn f64_overflow_produces_infinity() {
203        // IEEE-754 semantics — documented contract. Callers that
204        // can't tolerate `inf` must validate.
205        let out = as_f64_literal(&ast::Value::Number("1e400".into(), false));
206        assert!(matches!(out, Some(f) if f.is_infinite()));
207    }
208
209    #[test]
210    fn f64_rejects_non_numeric_text() {
211        assert_eq!(
212            as_f64_literal(&ast::Value::SingleQuotedString("foo".into())),
213            None
214        );
215    }
216}