@@ -33,8 +33,7 @@ pub(crate) fn coerce_and_check_rows(
3333) -> Result < ( ) > {
3434 coerce_rows_to_declared_types ( & info. columns , rows, info. primary_key . as_deref ( ) ) ?;
3535 check_declared_int_ranges ( & info. columns , rows) ?;
36- coerce_rows_to_declared_types ( & info. columns , rows, info. primary_key . as_deref ( ) ) ?;
37- check_declared_int_ranges ( & info. columns , rows)
36+ check_declared_float_ranges ( & info. columns , rows)
3837}
3938
4039/// Reject any integer value that does not fit its column's declared width.
@@ -92,6 +91,69 @@ pub(crate) fn check_declared_int_ranges(
9291 Ok ( ( ) )
9392}
9493
94+ /// Reject a finite float value that overflows to infinity when narrowed to
95+ /// its column's declared `REAL` width.
96+ ///
97+ /// This is deliberately *not* the float mirror of
98+ /// [`check_declared_int_ranges`]'s full-range check: narrowing an `f64` to
99+ /// `f32` normally **rounds**, and `1.1` becoming `1.10000002` is correct
100+ /// PostgreSQL `real` behaviour — accepted, never rejected. The one narrowing
101+ /// that is not value-preserving is a finite value beyond `f32`'s range, which
102+ /// would otherwise silently become `Infinity` on read; that is refused here at
103+ /// write time, exactly as PostgreSQL refuses it (`value out of range: real`).
104+ /// A value that is *already* infinite or `NaN` in the source is legitimately
105+ /// representable in both widths and passes through unchanged — it is not an
106+ /// overflow.
107+ ///
108+ /// This runs in the planner, on the same coerce-then-check path as
109+ /// [`check_declared_int_ranges`], for the same reason: the declared width is
110+ /// engine-independent, and parameters are bound into the AST before planning,
111+ /// so one check here covers literal `VALUES` and `$1` placeholders alike, for
112+ /// every engine, on every DML path.
113+ ///
114+ /// See `nodedb::control::server::pgwire::numeric_narrow::checked_narrow_f32`
115+ /// for the read-side backstop this check is layered with: rows written before
116+ /// a column's width was declared, or written via non-SQL ingest, are not
117+ /// covered by this planner-time check, so the read-side guard stays in place
118+ /// as the last line of defence. Neither is redundant with the other.
119+ pub ( crate ) fn check_declared_float_ranges (
120+ columns : & [ ColumnInfo ] ,
121+ rows : & [ Vec < ( String , SqlValue ) > ] ,
122+ ) -> Result < ( ) > {
123+ // Overwhelmingly the common case — skip the per-cell name lookup entirely
124+ // when the collection declares no `REAL`-width column.
125+ if !columns
126+ . iter ( )
127+ . any ( |c| c. float_width == Some ( nodedb_types:: columnar:: FloatWidth :: F32 ) )
128+ {
129+ return Ok ( ( ) ) ;
130+ }
131+
132+ for row in rows {
133+ for ( name, value) in row {
134+ let SqlValue :: Float ( v) = value else { continue } ;
135+ let Some ( width) = columns
136+ . iter ( )
137+ . find ( |c| c. name . eq_ignore_ascii_case ( name) )
138+ . and_then ( |c| c. float_width )
139+ else {
140+ continue ;
141+ } ;
142+ if width != nodedb_types:: columnar:: FloatWidth :: F32 {
143+ continue ;
144+ }
145+ if v. is_finite ( ) && !( * v as f32 ) . is_finite ( ) {
146+ return Err ( SqlError :: FloatOutOfRange {
147+ column : name. clone ( ) ,
148+ value : * v,
149+ declared_type : width. pg_type_name ( ) ,
150+ } ) ;
151+ }
152+ }
153+ }
154+ Ok ( ( ) )
155+ }
156+
95157/// [`check_declared_int_ranges`] for `UPDATE ... SET col = <literal>`.
96158///
97159/// Only literal assignments are checkable at plan time; a computed assignment
@@ -116,3 +178,94 @@ pub(crate) fn check_declared_int_ranges_in_assignments(
116178 check_declared_int_ranges ( columns, std:: slice:: from_ref ( & literals) )
117179}
118180
181+ /// [`check_declared_float_ranges`] for `UPDATE ... SET col = <literal>`.
182+ ///
183+ /// Same literal-only scope and same computed-assignment carve-out as
184+ /// [`check_declared_int_ranges_in_assignments`] — see its docs.
185+ pub ( crate ) fn check_declared_float_ranges_in_assignments (
186+ columns : & [ ColumnInfo ] ,
187+ assignments : & [ ( String , SqlExpr ) ] ,
188+ ) -> Result < ( ) > {
189+ let literals: Vec < ( String , SqlValue ) > = assignments
190+ . iter ( )
191+ . filter_map ( |( col, expr) | match expr {
192+ SqlExpr :: Literal ( v @ SqlValue :: Float ( _) ) => Some ( ( col. clone ( ) , v. clone ( ) ) ) ,
193+ _ => None ,
194+ } )
195+ . collect ( ) ;
196+ if literals. is_empty ( ) {
197+ return Ok ( ( ) ) ;
198+ }
199+ check_declared_float_ranges ( columns, std:: slice:: from_ref ( & literals) )
200+ }
201+
202+ #[ cfg( test) ]
203+ mod float_range_tests {
204+ use super :: * ;
205+ use nodedb_types:: columnar:: FloatWidth ;
206+
207+ fn float_column ( name : & str , width : Option < FloatWidth > ) -> ColumnInfo {
208+ ColumnInfo {
209+ name : name. to_string ( ) ,
210+ data_type : SqlDataType :: Float64 ,
211+ nullable : true ,
212+ is_primary_key : false ,
213+ default : None ,
214+ raw_type : None ,
215+ int_width : None ,
216+ float_width : width,
217+ }
218+ }
219+
220+ fn row ( name : & str , value : f64 ) -> Vec < Vec < ( String , SqlValue ) > > {
221+ vec ! [ vec![ ( name. to_string( ) , SqlValue :: Float ( value) ) ] ]
222+ }
223+
224+ #[ test]
225+ fn out_of_f32_range_literal_into_real_column_is_rejected ( ) {
226+ let columns = [ float_column ( "r" , Some ( FloatWidth :: F32 ) ) ] ;
227+ let rows = row ( "r" , 1e300 ) ;
228+ let err = check_declared_float_ranges ( & columns, & rows) . expect_err ( "must overflow f32" ) ;
229+ assert ! ( matches!( err, SqlError :: FloatOutOfRange { .. } ) ) ;
230+ }
231+
232+ #[ test]
233+ fn same_literal_into_double_column_is_accepted ( ) {
234+ let columns = [ float_column ( "r" , Some ( FloatWidth :: F64 ) ) ] ;
235+ let rows = row ( "r" , 1e300 ) ;
236+ check_declared_float_ranges ( & columns, & rows) . expect ( "f64 has no narrower width to check" ) ;
237+ }
238+
239+ #[ test]
240+ fn merely_rounding_value_into_real_column_is_accepted ( ) {
241+ // The load-bearing case: `1.1` narrows to `1.10000002` under `f32`,
242+ // which is correct rounding, never an error.
243+ let columns = [ float_column ( "r" , Some ( FloatWidth :: F32 ) ) ] ;
244+ let rows = row ( "r" , 1.1 ) ;
245+ check_declared_float_ranges ( & columns, & rows) . expect ( "rounding must not be rejected" ) ;
246+ }
247+
248+ #[ test]
249+ fn f32_max_itself_is_accepted ( ) {
250+ let columns = [ float_column ( "r" , Some ( FloatWidth :: F32 ) ) ] ;
251+ let rows = row ( "r" , f32:: MAX as f64 ) ;
252+ check_declared_float_ranges ( & columns, & rows) . expect ( "f32::MAX fits f32 exactly" ) ;
253+ }
254+
255+ #[ test]
256+ fn already_infinite_or_nan_literal_is_accepted ( ) {
257+ let columns = [ float_column ( "r" , Some ( FloatWidth :: F32 ) ) ] ;
258+ for v in [ f64:: INFINITY , f64:: NEG_INFINITY , f64:: NAN ] {
259+ let rows = row ( "r" , v) ;
260+ check_declared_float_ranges ( & columns, & rows)
261+ . expect ( "already-infinite/NaN source values are not an overflow" ) ;
262+ }
263+ }
264+
265+ #[ test]
266+ fn no_declared_float_width_skips_check ( ) {
267+ let columns = [ float_column ( "r" , None ) ] ;
268+ let rows = row ( "r" , 1e300 ) ;
269+ check_declared_float_ranges ( & columns, & rows) . expect ( "untyped float column is unchecked" ) ;
270+ }
271+ }
0 commit comments