@@ -25,6 +25,85 @@ pub(super) fn convert_value_rows(
2525 . collect ( )
2626}
2727
28+ /// Reject any integer value that does not fit its column's declared width.
29+ ///
30+ /// nodedb stores every integer as an `i64`, so this is not a storage limit.
31+ /// It is the constraint that makes the column's advertised wire type honest:
32+ /// a column declared `INTEGER` reports OID 23 in `RowDescription`, and a
33+ /// pgwire client reading it in binary format decodes exactly four bytes.
34+ /// Accepting a wider value would force a later choice between truncating it on
35+ /// read and lying about the column's type — so the value is refused at the
36+ /// point it enters, exactly as PostgreSQL refuses it.
37+ ///
38+ /// This runs in the planner rather than in each engine because the declared
39+ /// width is engine-independent (the same `IntWidth` drives the wire type for
40+ /// schemaless, columnar, strict, and kv alike), and because parameters are
41+ /// bound into the AST before planning — so one check here covers both literal
42+ /// `VALUES` and `$1` placeholders, for every engine, on every DML path.
43+ ///
44+ /// Non-integer values and columns with no declared width pass through: this
45+ /// checks range only, never type.
46+ pub ( super ) fn check_declared_int_ranges (
47+ columns : & [ ColumnInfo ] ,
48+ rows : & [ Vec < ( String , SqlValue ) > ] ,
49+ ) -> Result < ( ) > {
50+ // Overwhelmingly the common case — skip the per-cell name lookup entirely
51+ // when the collection declares no narrowed integer column.
52+ if !columns. iter ( ) . any ( |c| {
53+ matches ! (
54+ c. int_width,
55+ Some ( nodedb_types:: columnar:: IntWidth :: I16 | nodedb_types:: columnar:: IntWidth :: I32 )
56+ )
57+ } ) {
58+ return Ok ( ( ) ) ;
59+ }
60+
61+ for row in rows {
62+ for ( name, value) in row {
63+ let SqlValue :: Int ( v) = value else { continue } ;
64+ let Some ( width) = columns
65+ . iter ( )
66+ . find ( |c| c. name . eq_ignore_ascii_case ( name) )
67+ . and_then ( |c| c. int_width )
68+ else {
69+ continue ;
70+ } ;
71+ if !width. contains ( * v) {
72+ return Err ( SqlError :: IntegerOutOfRange {
73+ column : name. clone ( ) ,
74+ value : * v,
75+ declared_type : width. pg_type_name ( ) ,
76+ } ) ;
77+ }
78+ }
79+ }
80+ Ok ( ( ) )
81+ }
82+
83+ /// [`check_declared_int_ranges`] for `UPDATE ... SET col = <literal>`.
84+ ///
85+ /// Only literal assignments are checkable at plan time; a computed assignment
86+ /// (`SET n = n + 1`) has no value until the Data Plane evaluates it. Those are
87+ /// caught on the read path instead, where the encoder refuses to transmit a
88+ /// value that does not fit the column's advertised width — so an out-of-range
89+ /// value can never reach a client silently by either route.
90+ pub ( super ) fn check_declared_int_ranges_in_assignments (
91+ columns : & [ ColumnInfo ] ,
92+ assignments : & [ ( String , SqlExpr ) ] ,
93+ ) -> Result < ( ) > {
94+ let literals: Vec < ( String , SqlValue ) > = assignments
95+ . iter ( )
96+ . filter_map ( |( col, expr) | match expr {
97+ SqlExpr :: Literal ( v @ SqlValue :: Int ( _) ) => Some ( ( col. clone ( ) , v. clone ( ) ) ) ,
98+ _ => None ,
99+ } )
100+ . collect ( ) ;
101+ if literals. is_empty ( ) {
102+ return Ok ( ( ) ) ;
103+ }
104+ check_declared_int_ranges ( columns, std:: slice:: from_ref ( & literals) )
105+ }
106+
28107/// Resolve the effective column list for a `VALUES`-clause INSERT/UPSERT.
29108///
30109/// A *positional* insert — `INSERT INTO t VALUES (...)` with no explicit
@@ -277,6 +356,7 @@ pub(super) fn build_kv_insert_plan(
277356 intent : KvInsertIntent ,
278357 on_conflict_updates : Vec < ( String , SqlExpr ) > ,
279358 pk_col : Option < & str > ,
359+ declared_columns : & [ ColumnInfo ] ,
280360) -> Result < Vec < SqlPlan > > {
281361 // Positional KV insert (no column list): the key/value split below is
282362 // driven entirely by matching column *names* against `key_col_name`/
@@ -288,6 +368,10 @@ pub(super) fn build_kv_insert_plan(
288368 collection : table_name,
289369 } ) ;
290370 }
371+ // KV returns early from every INSERT/UPSERT entry point, so the declared
372+ // width check lives here rather than at the call sites — otherwise a
373+ // fourth KV entry point could be added without it.
374+ check_declared_int_ranges ( declared_columns, & convert_value_rows ( columns, rows_ast) ?) ?;
291375 let key_col_name = pk_col. unwrap_or ( "key" ) ;
292376 let key_idx = columns. iter ( ) . position ( |c| c == key_col_name) ;
293377 let ttl_idx = columns. iter ( ) . position ( |c| c == "ttl" ) ;
0 commit comments