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}