From 9093c437356fc45d1f01533be667fe49a15cde7c Mon Sep 17 00:00:00 2001 From: Sebastian Thiebaud Date: Sat, 25 Jul 2026 13:24:27 -0700 Subject: [PATCH 1/2] feat(api): carry type size and modifier in RowDescription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FieldInfo` hardcoded `type_size` and `type_modifier` to `0` when converting into `FieldDescription`, with a TODO to fill them in. For the modifier this is not merely imprecise. `0` is not "unset": PostgreSQL sends `-1` when a type has no modifier, and clients decode a `0` as a real one. For `numeric` that yields a nonsense display scale, so JDBC-based clients render every value with a long tail of spurious zeros — an unconstrained `numeric` holding `1` displays as `1.000...`. Add `type_size` and `type_modifier` to `FieldInfo` as defaulted fields, so `FieldInfo::new` keeps its current signature, with `with_type_size` / `with_type_modifier` builders mirroring the existing `with_format_options`. The modifier defaults to `-1`. Both values now reach `FieldDescription`, and the reverse `FieldDescription -> FieldInfo` conversion carries them back. --- src/api/results.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 67 insertions(+), 3 deletions(-) diff --git a/src/api/results.rs b/src/api/results.rs index b06e0dd5..fb8b0b4c 100644 --- a/src/api/results.rs +++ b/src/api/results.rs @@ -153,6 +153,21 @@ pub struct FieldInfo { format: FieldFormat, #[new(value = "DEFAULT_FORMAT_OPTIONS.with(|opts| Arc::clone(&*opts))")] format_options: Arc, + /// `pg_type.typlen`: the type's fixed byte width, or a negative value for a + /// variable-width type. Defaults to 0 ("unspecified"); set it with + /// [`FieldInfo::with_type_size`] to describe the value bytes precisely. + #[new(value = "0")] + type_size: i16, + /// `pg_attribute.atttypmod`: the type-specific modifier, such as the + /// precision and scale packed into a `numeric(p, s)` or the declared length + /// of a `varchar(n)`. + /// + /// Defaults to `-1`, PostgreSQL's encoding for "no modifier". This matters: + /// clients derive display scale from it, and a `0` here is not "unset" but a + /// valid-looking modifier that decodes to a nonsense scale — a `numeric` + /// then renders with a long tail of spurious zeros in JDBC-based clients. + #[new(value = "-1")] + type_modifier: i32, } impl FieldInfo { @@ -191,6 +206,28 @@ impl FieldInfo { self.format_options = format_options; self } + + /// Get the type size (`pg_type.typlen`). + pub fn type_size(&self) -> i16 { + self.type_size + } + + /// Set the type size (`pg_type.typlen`). + pub fn with_type_size(mut self, type_size: i16) -> Self { + self.type_size = type_size; + self + } + + /// Get the type modifier (`pg_attribute.atttypmod`). + pub fn type_modifier(&self) -> i32 { + self.type_modifier + } + + /// Set the type modifier (`pg_attribute.atttypmod`); `-1` means none. + pub fn with_type_modifier(mut self, type_modifier: i32) -> Self { + self.type_modifier = type_modifier; + self + } } impl From<&FieldInfo> for FieldDescription { @@ -200,9 +237,8 @@ impl From<&FieldInfo> for FieldDescription { fi.table_id.unwrap_or(0), // table_id fi.column_id.unwrap_or(0), // column_id fi.datatype.oid(), // type_id - // TODO: type size and modifier - 0, - 0, + fi.type_size, // type_size + fi.type_modifier, // type_modifier fi.format.value(), ) } @@ -217,6 +253,8 @@ impl From for FieldInfo { Type::from_oid(value.type_id).unwrap_or(Type::UNKNOWN), FieldFormat::from(value.format_code), ) + .with_type_size(value.type_size) + .with_type_modifier(value.type_modifier) } } @@ -824,6 +862,32 @@ mod test { use super::*; + #[test] + fn field_info_defaults_to_no_type_modifier() { + // `0` is not "unset" on the wire — it decodes to a nonsense scale and + // makes JDBC clients render numerics with spurious trailing zeros. + let fi = FieldInfo::new("c".into(), None, None, Type::NUMERIC, FieldFormat::Text); + assert_eq!(fi.type_modifier(), -1); + assert_eq!(FieldDescription::from(&fi).type_modifier, -1); + } + + #[test] + fn field_info_type_size_and_modifier_reach_the_field_description() { + // numeric(18, 4) packs as ((18 << 16) | 4) + 4. + let typmod = ((18 << 16) | 4) + 4; + let fi = FieldInfo::new("amount".into(), None, None, Type::NUMERIC, FieldFormat::Text) + .with_type_size(-1) + .with_type_modifier(typmod); + let fd = FieldDescription::from(&fi); + assert_eq!(fd.type_size, -1); + assert_eq!(fd.type_modifier, typmod); + + // And they survive the round trip back into a `FieldInfo`. + let back = FieldInfo::from(fd); + assert_eq!(back.type_size(), -1); + assert_eq!(back.type_modifier(), typmod); + } + #[test] fn test_command_complete() { let tag = Tag::new("INSERT").with_rows(100); From d294b12ccf04b6182f6926d17eef1e6fa7a64575 Mon Sep 17 00:00:00 2001 From: Sebastian Thiebaud Date: Sat, 25 Jul 2026 19:51:19 -0700 Subject: [PATCH 2/2] style: apply rustfmt --- src/api/results.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/api/results.rs b/src/api/results.rs index fb8b0b4c..b9ecab77 100644 --- a/src/api/results.rs +++ b/src/api/results.rs @@ -875,9 +875,15 @@ mod test { fn field_info_type_size_and_modifier_reach_the_field_description() { // numeric(18, 4) packs as ((18 << 16) | 4) + 4. let typmod = ((18 << 16) | 4) + 4; - let fi = FieldInfo::new("amount".into(), None, None, Type::NUMERIC, FieldFormat::Text) - .with_type_size(-1) - .with_type_modifier(typmod); + let fi = FieldInfo::new( + "amount".into(), + None, + None, + Type::NUMERIC, + FieldFormat::Text, + ) + .with_type_size(-1) + .with_type_modifier(typmod); let fd = FieldDescription::from(&fi); assert_eq!(fd.type_size, -1); assert_eq!(fd.type_modifier, typmod);