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, FETCH, fusion weights, …). When a parameter was sent
7//! over 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//!
22//! Row-bound extraction (LIMIT / OFFSET / FETCH) returns
23//! `Result<RowBound, _>`. `RowBound` has two states: `Rows(n)` for a
24//! literal in `[0, usize::MAX]`, and `Unbounded` for a `NULL` argument
25//! — PostgreSQL treats `NULL` as no bound at all, the same as an absent
26//! clause. `LIMIT ALL` is PostgreSQL's other unbounded spelling, but
27//! `sqlparser` parses it as an absent LIMIT expression, so it never
28//! reaches `checked_row_bound` at all — callers see it as `None` before
29//! this module runs. A malformed literal (negative, fractional,
30//! non-numeric, overflowing `usize`) is a third case: the statement
31//! fails. `checked_row_bound` is the chokepoint every
32//! LIMIT/OFFSET/FETCH site calls.
33
34use sqlparser::ast;
35
36/// A row bound the planner resolved.
37#[derive(Debug, PartialEq)]
38pub enum RowBound {
39 /// A literal count the planner applies.
40 Rows(usize),
41 /// `NULL`. PostgreSQL reads it as no bound at all, the same as an
42 /// absent clause or `LIMIT ALL` (which never reaches this type —
43 /// see the module doc).
44 Unbounded,
45}
46
47impl RowBound {
48 /// The LIMIT this bound names. `Rows(n)` maps to `Some(n)`.
49 /// `Unbounded` maps to `None` — no limit applies.
50 pub fn limit(self) -> Option<usize> {
51 match self {
52 RowBound::Rows(n) => Some(n),
53 RowBound::Unbounded => None,
54 }
55 }
56
57 /// The OFFSET this bound names. `Rows(n)` maps to `n`.
58 /// `Unbounded` maps to `0` — no rows skip.
59 pub fn offset(self) -> usize {
60 match self {
61 RowBound::Rows(n) => n,
62 RowBound::Unbounded => 0,
63 }
64 }
65}
66
67/// A row-bound literal that did not resolve to `[0, usize::MAX]`.
68///
69/// Carries the value as written so the caller names it without
70/// re-deriving text from the AST.
71#[derive(Debug, PartialEq)]
72pub struct InvalidRowBoundLiteral {
73 pub raw: String,
74}
75
76/// Resolve a `Value` into a `usize` row bound.
77///
78/// Accepts:
79/// - `Value::Number(n, _)` — the typed-parameter and explicit-literal path.
80/// - `Value::SingleQuotedString(s)` where `s` parses as `usize` — the
81/// UNKNOWN-param bind path (pgwire drivers that send `Type::UNKNOWN`).
82///
83/// `Value::DoubleQuotedString` is NOT accepted: with the PostgreSQL dialect
84/// double-quoted tokens parse as `Expr::Identifier`, never as
85/// `Expr::Value(Value::DoubleQuotedString)`, so that variant is unreachable
86/// in practice and routing it here would silently accept non-numeric text.
87///
88/// # Bounds
89///
90/// Valid outputs are `[0, usize::MAX]` (64-bit on typical targets).
91/// A negative number, a fractional value, a value exceeding `usize::MAX`,
92/// or non-numeric text returns `Err(InvalidRowBoundLiteral)` carrying the
93/// value as written.
94///
95/// Does not perform saturating or wrapping coercion — values that
96/// overflow `usize` are rejected, not silently truncated.
97pub fn as_usize_literal(value: &ast::Value) -> Result<usize, InvalidRowBoundLiteral> {
98 match value {
99 ast::Value::Number(n, _) => n
100 .parse::<usize>()
101 .map_err(|_| InvalidRowBoundLiteral { raw: n.clone() }),
102 ast::Value::SingleQuotedString(s) => s
103 .parse::<usize>()
104 .map_err(|_| InvalidRowBoundLiteral { raw: s.clone() }),
105 other => Err(InvalidRowBoundLiteral {
106 raw: other.to_string(),
107 }),
108 }
109}
110
111/// Resolve an `Expr::Value` into a `usize` row bound. Thin wrapper that
112/// unpacks the `Expr` → `Value` layer so callers reading LIMIT/OFFSET/FETCH
113/// clauses don't each re-write the unpack. A non-`Expr::Value` expression
114/// (a column reference, a function call, an arithmetic expression) is not
115/// a literal the planner can read — it fails with the expression's
116/// `Display` text as `raw`.
117pub fn expr_as_usize_literal(expr: &ast::Expr) -> Result<usize, InvalidRowBoundLiteral> {
118 match expr {
119 ast::Expr::Value(v) => as_usize_literal(&v.value),
120 other => Err(InvalidRowBoundLiteral {
121 raw: other.to_string(),
122 }),
123 }
124}
125
126/// Resolve a LIMIT/OFFSET/FETCH bound. Names `clause` and the value as
127/// written on failure. Every row-bound site routes through here.
128///
129/// `Expr::Value(Value::Null)` resolves to `RowBound::Unbounded` —
130/// PostgreSQL treats a `NULL` LIMIT/OFFSET/FETCH argument as no bound
131/// at all. A prepared `LIMIT $1` reaches this shape at Parse-time
132/// schema inference, before the real bound is bound at Execute time.
133pub fn checked_row_bound(clause: &'static str, expr: &ast::Expr) -> crate::error::Result<RowBound> {
134 if matches!(expr, ast::Expr::Value(v) if matches!(v.value, ast::Value::Null)) {
135 return Ok(RowBound::Unbounded);
136 }
137 expr_as_usize_literal(expr)
138 .map(RowBound::Rows)
139 .map_err(|e| crate::error::SqlError::InvalidLimitValue {
140 clause,
141 value: e.raw,
142 })
143}
144
145/// Resolve a `Value` into an `f64` if numeric-shaped.
146///
147/// Same UNKNOWN-coercion behavior as `as_usize_literal` but for
148/// floating-point contexts (fusion weights, scoring thresholds,
149/// confidence intervals).
150///
151/// # Bounds
152///
153/// `f64::from_str` accepts `NaN`, `inf`, subnormals, and values that
154/// overflow to `±inf` via the IEEE-754 rules. Callers that need to
155/// reject those (e.g. fusion weights outside `[0, 1]`) must validate
156/// the returned value themselves — this helper is purely a literal
157/// extractor, not a domain validator.
158pub fn as_f64_literal(value: &ast::Value) -> Option<f64> {
159 match value {
160 ast::Value::Number(n, _) => n.parse::<f64>().ok(),
161 ast::Value::SingleQuotedString(s) => s.parse::<f64>().ok(),
162 _ => None,
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 #[test]
171 fn usize_from_number() {
172 assert_eq!(
173 as_usize_literal(&ast::Value::Number("42".into(), false)),
174 Ok(42)
175 );
176 }
177
178 /// Untyped pgwire param: `Type::UNKNOWN` → `ParamValue::Text` →
179 /// `Value::SingleQuotedString`. LIMIT still has to work.
180 #[test]
181 fn usize_from_unknown_param_text() {
182 assert_eq!(
183 as_usize_literal(&ast::Value::SingleQuotedString("42".into())),
184 Ok(42)
185 );
186 }
187
188 #[test]
189 fn usize_rejects_non_numeric_text() {
190 assert_eq!(
191 as_usize_literal(&ast::Value::SingleQuotedString("abc".into())),
192 Err(InvalidRowBoundLiteral { raw: "abc".into() })
193 );
194 }
195
196 #[test]
197 fn usize_rejects_negative() {
198 assert_eq!(
199 as_usize_literal(&ast::Value::SingleQuotedString("-1".into())),
200 Err(InvalidRowBoundLiteral { raw: "-1".into() })
201 );
202 }
203
204 #[test]
205 fn f64_from_unknown_param_text() {
206 assert_eq!(
207 as_f64_literal(&ast::Value::SingleQuotedString("1.5".into())),
208 Some(1.5)
209 );
210 }
211
212 // ── bounds / overflow ──────────────────────────────────────────
213
214 /// Values larger than `usize::MAX` are rejected, not wrapped or
215 /// truncated. Silent truncation would reproduce the pattern of
216 /// "untyped param drops silently" — the exact bug class this
217 /// module exists to close.
218 #[test]
219 fn usize_rejects_overflow_number() {
220 let huge = format!("{}0", usize::MAX);
221 assert_eq!(
222 as_usize_literal(&ast::Value::Number(huge.clone(), false)),
223 Err(InvalidRowBoundLiteral { raw: huge })
224 );
225 }
226
227 #[test]
228 fn usize_rejects_overflow_text() {
229 let huge = format!("{}0", usize::MAX);
230 assert_eq!(
231 as_usize_literal(&ast::Value::SingleQuotedString(huge.clone())),
232 Err(InvalidRowBoundLiteral { raw: huge })
233 );
234 }
235
236 #[test]
237 fn usize_rejects_fractional_number() {
238 assert_eq!(
239 as_usize_literal(&ast::Value::Number("1.5".into(), false)),
240 Err(InvalidRowBoundLiteral { raw: "1.5".into() })
241 );
242 }
243
244 #[test]
245 fn usize_rejects_fractional_text() {
246 assert_eq!(
247 as_usize_literal(&ast::Value::SingleQuotedString("1.5".into())),
248 Err(InvalidRowBoundLiteral { raw: "1.5".into() })
249 );
250 }
251
252 #[test]
253 fn usize_rejects_scientific_notation() {
254 // `1e3` is not a usize literal — Postgres treats it as a float.
255 assert_eq!(
256 as_usize_literal(&ast::Value::Number("1e3".into(), false)),
257 Err(InvalidRowBoundLiteral { raw: "1e3".into() })
258 );
259 }
260
261 #[test]
262 fn usize_accepts_zero() {
263 assert_eq!(
264 as_usize_literal(&ast::Value::Number("0".into(), false)),
265 Ok(0)
266 );
267 }
268
269 #[test]
270 fn usize_accepts_max() {
271 let max_str = usize::MAX.to_string();
272 assert_eq!(
273 as_usize_literal(&ast::Value::Number(max_str, false)),
274 Ok(usize::MAX)
275 );
276 }
277
278 #[test]
279 fn f64_accepts_negative() {
280 assert_eq!(
281 as_f64_literal(&ast::Value::SingleQuotedString("-1.5".into())),
282 Some(-1.5)
283 );
284 }
285
286 #[test]
287 fn f64_overflow_produces_infinity() {
288 // IEEE-754 semantics — documented contract. Callers that
289 // can't tolerate `inf` must validate.
290 let out = as_f64_literal(&ast::Value::Number("1e400".into(), false));
291 assert!(matches!(out, Some(f) if f.is_infinite()));
292 }
293
294 #[test]
295 fn f64_rejects_non_numeric_text() {
296 assert_eq!(
297 as_f64_literal(&ast::Value::SingleQuotedString("foo".into())),
298 None
299 );
300 }
301}