diff --git a/CHANGELOG.md b/CHANGELOG.md index fa78d2a..81bbd78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,16 @@ before this fix, accepted after (while a CA-untrusted cert is still correctly rejected, and `verify-full` still correctly rejects the hostname mismatch). +- MONEY columns always read as `null` — `extract_value` had no case for + `Type::MONEY`, so it fell through to the generic string fallback, but + `String`'s `FromSql::accepts` returns `false` for `Type::MONEY` (confirmed + directly), making that fallback fail every time regardless of the actual + value. MONEY is listed as a supported numeric type in the README and + `ddl.rs`'s implicit-cast-compatible group, but reading it back was + silently broken. Added a `Money` wrapper (`src/extract.rs`) that decodes + the same 8-byte big-endian i64 wire format `INT8` uses and reuses the + existing `i64_to_json` JS-safe-integer stringification, matching the + builtin driver's `extract/advanced_types.rs::Money`. ## [1.0.0-beta.7] - 2026-08-17 diff --git a/src/extract.rs b/src/extract.rs index ff454a1..827acf1 100644 --- a/src/extract.rs +++ b/src/extract.rs @@ -12,7 +12,7 @@ use tokio_postgres::Row; use uuid::Uuid; /// JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1). -const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; +pub(crate) const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; /// Extract a single column value from a row as a JSON value. /// Matches the builtin driver's extraction behavior exactly. @@ -77,6 +77,7 @@ pub fn extract_value(row: &Row, index: usize) -> JsonValue { } ref t if *t == Type::MACADDR => try_extract::(row, index, JsonValue::from), ref t if *t == Type::OID => try_extract::(row, index, JsonValue::from), + ref t if *t == Type::MONEY => try_extract::(row, index, JsonValue::from), ref t if *t == Type::INT4_RANGE || *t == Type::INT8_RANGE @@ -327,6 +328,32 @@ impl<'a> FromSql<'a> for EnumLabel { } } +/// MONEY: total value in cents (or the smallest fractional unit of the +/// database's locale). Wire format is identical to INT8 — a big-endian +/// i64 — so decoding just reinterprets those bytes and reuses `i64_to_json` +/// for the same JS-safe-integer stringification `INT8` gets. Matches +/// `src-tauri/src/drivers/postgres/extract/advanced_types.rs::Money`. +pub(crate) struct Money(i64); + +impl<'a> FromSql<'a> for Money { + fn from_sql( + _ty: &Type, + raw: &'a [u8], + ) -> Result> { + Ok(Self(::from_sql(&Type::INT8, raw)?)) + } + + fn accepts(ty: &Type) -> bool { + *ty == Type::MONEY + } +} + +impl From for JsonValue { + fn from(value: Money) -> Self { + i64_to_json(value.0) + } +} + /// TIMETZ: time-of-day + UTC offset. Wire format: 8-byte microseconds since /// midnight (i64, always non-negative), then a 4-byte signed offset in /// seconds (positive = west of UTC, hence the sign flip below). Matches diff --git a/src/extract_tests.rs b/src/extract_tests.rs index 8dbc027..0acbf84 100644 --- a/src/extract_tests.rs +++ b/src/extract_tests.rs @@ -9,7 +9,7 @@ //! nulls out), the same way the builtin driver unit-tests //! `extract/enum.rs::extract_or_null`. -use crate::extract::EnumLabel; +use crate::extract::{EnumLabel, Money}; use tokio_postgres::types::{FromSql, Kind, Type}; fn enum_type() -> Type { @@ -40,3 +40,30 @@ fn accepts_only_enum_kinds() { assert!(!EnumLabel::accepts(&Type::TEXT)); assert!(!EnumLabel::accepts(&Type::INT4)); } + +#[test] +fn money_accepts_only_the_money_type() { + assert!(Money::accepts(&Type::MONEY)); + assert!(!Money::accepts(&Type::INT8)); + assert!(!Money::accepts(&Type::NUMERIC)); +} + +#[test] +fn money_decodes_the_same_8_byte_wire_format_as_int8() { + // MONEY is wire-encoded identically to INT8 (big-endian i64, smallest + // fractional unit e.g. cents) — 12345 = $123.45. + let bytes = 12_345_i64.to_be_bytes(); + let money = Money::from_sql(&Type::MONEY, &bytes).unwrap(); + assert_eq!(serde_json::Value::from(money), serde_json::json!(12_345)); +} + +#[test] +fn money_above_js_safe_integer_becomes_a_string() { + let above_safe = crate::extract::JS_MAX_SAFE_INTEGER + 1; + let bytes = above_safe.to_be_bytes(); + let money = Money::from_sql(&Type::MONEY, &bytes).unwrap(); + assert_eq!( + serde_json::Value::from(money), + serde_json::json!(above_safe.to_string()) + ); +}