nodedb_sql/placeholder_types/slots.rs
1// SPDX-License-Identifier: Apache-2.0
2
3//! Per-position inference state: the slot table, its conflict rule, and the
4//! parsing of a `$N` placeholder body into a position.
5
6use nodedb_types::columnar::{FloatWidth, IntWidth};
7use sqlparser::ast::{Expr, Value, ValueWithSpan};
8
9use crate::types::ColumnInfo;
10use crate::types_expr::SqlDataType;
11
12/// A parameter type the statement pins down.
13///
14/// Carries the declared numeric width alongside the logical type because the
15/// two are not interchangeable on the wire: a column declared `INT` must be
16/// advertised as `int4` (OID 23), not `int8` (OID 20), and one declared `REAL`
17/// as `float4` (OID 700), not `float8` (OID 701). The client encodes its bind
18/// value at exactly the width the `ParameterDescription` names, so collapsing
19/// every integer to `int8` or every float to `float8` here would put a 4-byte
20/// column behind an 8-byte promise — the precise failure the catalog's
21/// resolved [`IntWidth`] / [`FloatWidth`] exist to prevent.
22#[derive(Debug, Clone, PartialEq)]
23pub struct InferredParamType {
24 /// The logical type this position resolves to.
25 pub data_type: SqlDataType,
26 /// Declared width for an integer position, or `None` when the form that
27 /// resolved it had no catalog column behind it.
28 pub int_width: Option<IntWidth>,
29 /// Declared width for a floating-point position, or `None` when the form
30 /// that resolved it had no catalog column behind it.
31 pub float_width: Option<FloatWidth>,
32}
33
34impl InferredParamType {
35 /// A type the SQL text names outright — a cast target, or a row count.
36 ///
37 /// No catalog column stands behind such a position, so there is no
38 /// declared width to carry. That is not a loss for the forms that use it:
39 /// `LIMIT` / `OFFSET` are genuinely `bigint`, and a cast target names the
40 /// type it names.
41 pub(super) fn from_sql_type(data_type: SqlDataType) -> Self {
42 Self {
43 data_type,
44 int_width: None,
45 float_width: None,
46 }
47 }
48
49 /// The type of the catalog column a placeholder is compared or assigned
50 /// against, declared width included.
51 pub(super) fn from_column(column: &ColumnInfo) -> Self {
52 Self {
53 data_type: column.data_type.clone(),
54 int_width: column.int_width,
55 float_width: column.float_width,
56 }
57 }
58}
59
60/// One placeholder position's inference state.
61#[derive(Clone, PartialEq)]
62enum Slot {
63 /// Seen, but in no position this pass can type.
64 Unresolved,
65 /// Typed by exactly one form (or by several that agree).
66 Resolved(InferredParamType),
67 /// Typed by two forms that disagree — e.g. `LIMIT $1` and `$1::TEXT`
68 /// in the same statement. Reported as unknown, never as a guess.
69 Conflicted,
70}
71
72/// Accumulated inference state for one SQL string.
73///
74/// Both passes — the catalog-free visitor and the catalog-backed walk — route
75/// every conclusion through [`Self::record`], so the conflict rule applies
76/// uniformly no matter which form typed a position.
77#[derive(Default)]
78pub(super) struct InferenceContext {
79 slots: Vec<Slot>,
80}
81
82/// The highest `$N` index this pass will size the slot table for.
83///
84/// Matches the pgwire wire format's own ceiling: `Parse` and `Bind` both
85/// carry the parameter count as an `Int16`, so no real client statement
86/// names a placeholder above this. A hostile SQL string can still spell
87/// `$99999999999999` — parsed as a `usize` with nothing to stop it — and
88/// without this cap `Vec::resize` would attempt an allocation far beyond
89/// any real statement's needs. Rejecting it here degrades to the same
90/// "unresolved" outcome as any other position this pass doesn't type.
91const MAX_PLACEHOLDER_INDEX: usize = u16::MAX as usize;
92
93impl InferenceContext {
94 /// Grow the slot table so a 1-based placeholder index is addressable.
95 ///
96 /// `None` for an index of `0` or one beyond [`MAX_PLACEHOLDER_INDEX`] —
97 /// neither is a position this pass will size the slot table for.
98 fn slot_mut(&mut self, index_1based: usize) -> Option<&mut Slot> {
99 let zero_based = index_1based.checked_sub(1)?;
100 if zero_based >= MAX_PLACEHOLDER_INDEX {
101 return None;
102 }
103 if self.slots.len() <= zero_based {
104 self.slots.resize(zero_based + 1, Slot::Unresolved);
105 }
106 self.slots.get_mut(zero_based)
107 }
108
109 /// Note that `$N` exists without typing it.
110 pub(super) fn observe(&mut self, index_1based: usize) {
111 let _ = self.slot_mut(index_1based);
112 }
113
114 /// Record a resolved type for `$N`, demoting to `Conflicted` when a
115 /// different type was already recorded for the same position.
116 pub(super) fn record(&mut self, index_1based: usize, ty: InferredParamType) {
117 let Some(slot) = self.slot_mut(index_1based) else {
118 return;
119 };
120 let next = match slot {
121 Slot::Unresolved => Slot::Resolved(ty),
122 Slot::Resolved(existing) if *existing == ty => Slot::Resolved(ty),
123 Slot::Resolved(_) | Slot::Conflicted => Slot::Conflicted,
124 };
125 *slot = next;
126 }
127
128 /// Record `ty` for `expr` when `expr` is a bare placeholder, and do
129 /// nothing otherwise.
130 pub(super) fn record_if_placeholder(&mut self, expr: &Expr, ty: InferredParamType) {
131 if let Some(index) = placeholder_index(expr) {
132 self.record(index, ty);
133 }
134 }
135
136 pub(super) fn finish(self) -> Vec<Option<InferredParamType>> {
137 self.slots
138 .into_iter()
139 .map(|slot| match slot {
140 Slot::Resolved(ty) => Some(ty),
141 Slot::Unresolved | Slot::Conflicted => None,
142 })
143 .collect()
144 }
145}
146
147/// The 1-based index of `expr` when it is (a parenthesised) `$N`.
148pub(super) fn placeholder_index(expr: &Expr) -> Option<usize> {
149 match expr {
150 Expr::Value(ValueWithSpan {
151 value: Value::Placeholder(body),
152 ..
153 }) => parse_placeholder_body(body),
154 Expr::Nested(inner) => placeholder_index(inner),
155 // Not a bare placeholder — nothing to attribute a type to.
156 _ => None,
157 }
158}
159
160/// Parse the `1` out of a `$1` placeholder body.
161///
162/// Returns `None` for any other placeholder spelling (`?`, `:name`, `$`,
163/// `$0`, `$abc`) rather than assuming a position.
164pub(super) fn parse_placeholder_body(body: &str) -> Option<usize> {
165 let digits = body.strip_prefix('$')?;
166 let index: usize = digits.parse().ok()?;
167 (index > 0).then_some(index)
168}