Skip to main content

nodedb_sql/
coerce.rs

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