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, 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}