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}