Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 28 additions & 1 deletion src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -77,6 +77,7 @@ pub fn extract_value(row: &Row, index: usize) -> JsonValue {
}
ref t if *t == Type::MACADDR => try_extract::<MacAddr>(row, index, JsonValue::from),
ref t if *t == Type::OID => try_extract::<u32>(row, index, JsonValue::from),
ref t if *t == Type::MONEY => try_extract::<Money>(row, index, JsonValue::from),
ref t
if *t == Type::INT4_RANGE
|| *t == Type::INT8_RANGE
Expand Down Expand Up @@ -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<Self, Box<dyn std::error::Error + Sync + Send>> {
Ok(Self(<i64 as FromSql>::from_sql(&Type::INT8, raw)?))
}

fn accepts(ty: &Type) -> bool {
*ty == Type::MONEY
}
}

impl From<Money> 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
Expand Down
29 changes: 28 additions & 1 deletion src/extract_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
);
}
Loading