Skip to content
Open
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
22 changes: 17 additions & 5 deletions src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,19 +586,31 @@ pub fn bind_pk_value(
column_type: Option<&str>,
) -> Result<BoundValue, String> {
let base_type = column_type.map(extract_base_type);
let is_real = matches!(base_type.as_deref(), Some("REAL" | "FLOAT4"));
// Numeric and numeric-string float4 values must compare at column precision.
let row_value = |bound: BoundValue| {
if is_real {
BoundValue {
sql: format!("CAST({} AS real)", bound.sql),
param: bound.param,
}
} else {
bound
}
};

match value {
Value::Number(n) => {
if let Some(i) = n.as_i64() {
Ok(BoundValue {
Ok(row_value(BoundValue {
sql: format!("CAST(${} AS bigint)", placeholder_idx),
param: Some((Box::new(i), Type::INT8)),
})
}))
} else if let Some(f) = n.as_f64() {
Ok(BoundValue {
Ok(row_value(BoundValue {
sql: format!("CAST(${} AS double precision)", placeholder_idx),
param: Some((Box::new(f), Type::FLOAT8)),
})
}))
} else {
Err("Unsupported numeric PK value".to_string())
}
Expand Down Expand Up @@ -626,7 +638,7 @@ pub fn bind_pk_value(
if let Some(bound) = bind_pg_numeric_string(s, bt, placeholder_idx)
.or_else(|| bind_pg_temporal_string(s, bt, placeholder_idx))
{
return bound;
return bound.map(row_value);
}
}

Expand Down
16 changes: 16 additions & 0 deletions src/binding_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,22 @@ mod bind_pg_value_tests {
mod bind_pk_value_tests {
use super::*;

#[test]
fn real_aliases_cast_integer_and_fractional_row_identities_to_real() {
for column_type in ["real", "FLOAT4", " REAL "] {
for (value, cast) in [
(json!(1), "bigint"),
(json!(89.9), "double precision"),
(json!("89.9"), "double precision"),
] {
let bound = bind_pk_value(&value, 2, Some(column_type)).unwrap();
assert_eq!(bound.sql, format!("CAST(CAST($2 AS {cast}) AS real)"));
}
}
let bound = bind_pk_value(&json!("89.9"), 2, Some("double precision")).unwrap();
assert_eq!(bound.sql, "CAST($2 AS double precision)");
}

#[test]
fn integer_pk_binds_as_bigint_cast() {
let bound = bind_pk_value(&json!(42), 1, None).unwrap();
Expand Down
28 changes: 8 additions & 20 deletions src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use tokio_postgres::types::{FromSql, Kind, Type};
use tokio_postgres::Row;
use uuid::Uuid;

use crate::utils::values::float4_to_json;

/// JavaScript's Number.MAX_SAFE_INTEGER (2^53 - 1).
pub(crate) const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;

Expand Down Expand Up @@ -52,19 +54,14 @@ pub fn extract_value(row: &Row, index: usize) -> JsonValue {
}

/// `Kind::Simple` bucket: scalar types with no element/subtype (everything
/// except enum/array/range/composite/domain/multirange). Every arm here is
/// unchanged from the pre-restructure flat match.
/// except enum/array/range/composite/domain/multirange).
fn extract_simple_kind(col_type: &Type, row: &Row, index: usize) -> JsonValue {
match *col_type {
ref t if *t == Type::BOOL => try_extract::<bool>(row, index, JsonValue::Bool),
ref t if *t == Type::INT2 => try_extract::<i16>(row, index, JsonValue::from),
ref t if *t == Type::INT4 => try_extract::<i32>(row, index, JsonValue::from),
ref t if *t == Type::INT8 => try_extract::<i64>(row, index, i64_to_json),
ref t if *t == Type::FLOAT4 => try_extract::<f32>(row, index, |v| {
serde_json::Number::from_f64(v as f64)
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null)
}),
ref t if *t == Type::FLOAT4 => try_extract::<f32>(row, index, float4_to_json),
ref t if *t == Type::FLOAT8 => try_extract::<f64>(row, index, |v| {
serde_json::Number::from_f64(v)
.map(JsonValue::Number)
Expand Down Expand Up @@ -191,9 +188,8 @@ fn extract_simple_kind(col_type: &Type, row: &Row, index: usize) -> JsonValue {
}

/// `Kind::Array(_)` bucket: the eight hardcoded fast-paths (checked against
/// the outer array `Type::` constant, unchanged from the pre-restructure
/// flat match), falling back to the generic per-element decoder for any
/// other array element type (e.g. `enum[]`, `hstore[]`).
/// the outer array `Type::` constant), falling back to the generic per-element
/// decoder for any other array element type (e.g. `enum[]`, `hstore[]`).
fn extract_array_kind(col_type: &Type, row: &Row, index: usize) -> JsonValue {
match *col_type {
ref t if *t == Type::INT2_ARRAY => try_extract::<Vec<Option<i16>>>(row, index, |v| {
Expand Down Expand Up @@ -229,11 +225,7 @@ fn extract_array_kind(col_type: &Type, row: &Row, index: usize) -> JsonValue {
ref t if *t == Type::FLOAT4_ARRAY => try_extract::<Vec<Option<f32>>>(row, index, |v| {
JsonValue::Array(
v.into_iter()
.map(|e| {
e.and_then(|f| serde_json::Number::from_f64(f as f64))
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null)
})
.map(|e| e.map(float4_to_json).unwrap_or(JsonValue::Null))
.collect(),
)
}),
Expand Down Expand Up @@ -842,11 +834,7 @@ fn extract_simple_kind_from_bytes(ty: &Type, buf: &[u8]) -> JsonValue {
.map(JsonValue::from)
.unwrap_or(JsonValue::Null),
_ if *ty == Type::FLOAT4 => f32::from_sql(ty, buf)
.map(|v| {
serde_json::Number::from_f64(v as f64)
.map(JsonValue::Number)
.unwrap_or(JsonValue::Null)
})
.map(float4_to_json)
.unwrap_or(JsonValue::Null),
_ if *ty == Type::FLOAT8 => f64::from_sql(ty, buf)
.map(|v| {
Expand Down
5 changes: 4 additions & 1 deletion src/handlers/blob.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,10 @@ async fn fetch_blob_bytes(
);
let column_types = client::get_column_types_map(conn_params, table, schema)
.await
.unwrap_or_default();
.unwrap_or_else(|error| {
log::warn!("Failed to load PostgreSQL column types for {schema}.{table}: {error}; row matching may fail");
Default::default()
});

let (predicate, owned_params) = build_pk_map_predicate(pk_map, &column_types, 1)?;
let query = format!(
Expand Down
10 changes: 8 additions & 2 deletions src/handlers/crud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,10 @@ async fn exec_update(

let column_types = client::get_column_types_map(conn_params, table, schema)
.await
.unwrap_or_default();
.unwrap_or_else(|error| {
log::warn!("Failed to load PostgreSQL column types for {schema}.{table}: {error}; row matching may fail");
Default::default()
});
let enum_types = client::get_enum_column_types(conn_params, schema, table)
.await
.unwrap_or_default();
Expand Down Expand Up @@ -223,7 +226,10 @@ async fn exec_delete(

let column_types = client::get_column_types_map(conn_params, table, schema)
.await
.unwrap_or_default();
.unwrap_or_else(|error| {
log::warn!("Failed to load PostgreSQL column types for {schema}.{table}: {error}; row matching may fail");
Default::default()
});

let (predicate, owned_params) = build_pk_map_predicate(pk_map, &column_types, 1)?;

Expand Down
3 changes: 3 additions & 0 deletions src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@ pub mod identifiers;
pub mod pagination;
#[cfg(test)]
mod pagination_tests;
pub(crate) mod values;
#[cfg(test)]
mod values_tests;
13 changes: 13 additions & 0 deletions src/utils/values.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
use serde_json::Value;

/// Preserve the shortest float4 decimal before storing it in JSON's f64 number.
pub(crate) fn float4_to_json(value: f32) -> Value {
Value::from(value.to_string().parse::<f64>().ok().map(|decimal| {
// Avoid double rounding when the decimal lands on an f32 midpoint.
if decimal as f32 == value {
decimal
} else {
f64::from(value)
}
}))
}
29 changes: 29 additions & 0 deletions src/utils/values_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
use super::values::float4_to_json;

#[test]
fn float4_preserves_short_decimal_values() {
for (value, expected) in [(89.9f32, 89.9f64), (59.99, 59.99), (-89.9, -89.9)] {
assert_eq!(
float4_to_json(value),
serde_json::json!(expected),
"input: {value}"
);
}
}

#[test]
fn finite_float4_extremes_round_trip_without_losing_precision() {
for value in [
f32::MAX,
f32::MIN,
f32::MIN_POSITIVE,
f32::from_bits(1),
f32::from_bits(0x15ae43fd),
-f32::from_bits(0x15ae43fd),
-0.0,
] {
let json = float4_to_json(value);
let decoded = json.as_f64().expect("finite float4 is a JSON number") as f32;
assert_eq!(decoded.to_bits(), value.to_bits(), "input: {value}");
}
}
147 changes: 147 additions & 0 deletions tests/live_db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,153 @@ fn execute_query_returns_rows_from_live_database() {
assert_eq!(rows[0][0], json!(1));
}

#[test]
fn float4_values_use_short_decimals_in_scalars_arrays_and_composites() {
let mut plugin = Plugin::spawn();
let params = conn_params();
plugin.call_ok(
"execute_query",
json!({
"params": params,
"query": "CREATE TABLE IF NOT EXISTS live_float4_values (price real, prices real[])",
}),
);
let result = plugin.call_ok(
"execute_query",
json!({
"params": params,
"query": "SELECT 89.9::real, 59.99::real, 29.5::real, -89.9::real, \
1.2345678::real, NULL::real, 'NaN'::real, 'Infinity'::real, \
ARRAY[89.9::real, NULL, 59.99::real], \
ROW(89.9::real, ARRAY[59.99::real, NULL])::live_float4_values, \
89.9000015258789::double precision",
}),
);
assert_eq!(
result["rows"],
json!([[89.9, 59.99, 29.5, -89.9, 1.2345678, null, null, null,
[89.9, null, 59.99], {"price": 89.9, "prices": [59.99, null]},
89.9000015258789]]),
);
plugin.call_ok(
"execute_query",
json!({"params": params, "query": "DROP TABLE live_float4_values"}),
);
}

#[test]
fn real_row_identity_allows_update_and_delete_with_short_decimals() {
let mut plugin = Plugin::spawn();
let params = conn_params();
for primary_key in ["", ", PRIMARY KEY (price)", ", PRIMARY KEY (price, id)"] {
plugin.call_ok(
"execute_query",
json!({"params": params, "query": "DROP TABLE IF EXISTS live_float4_identity"}),
);
plugin.call_ok(
"execute_query",
json!({
"params": params,
"query": format!("CREATE TABLE live_float4_identity \
(price real, id integer, label text{primary_key})"),
}),
);
for value in [
89.9f32,
f32::from_bits(0x15ae43fd),
-f32::from_bits(0x15ae43fd),
] {
plugin.call_ok(
"insert_record",
json!({
"params": params, "table": "live_float4_identity", "schema": "public",
"data": {"price": value, "id": 1, "label": "before"},
}),
);
let row = plugin.call_ok(
"execute_query",
json!({"params": params, "query": "SELECT price FROM live_float4_identity"}),
);
let price = &row["rows"][0][0];
assert_eq!((price.as_f64().unwrap() as f32).to_bits(), value.to_bits());
let mut pk_map = match primary_key {
"" => json!({"price": price, "id": 1, "label": "before"}),
", PRIMARY KEY (price)" => json!({"price": price}),
_ => json!({"price": price, "id": 1}),
};
let updated = plugin.call_ok(
"update_record",
json!({
"params": params, "table": "live_float4_identity", "schema": "public",
"pk_map": pk_map, "col_name": "label", "new_val": "after",
}),
);
assert_eq!(
updated,
json!(1),
"REAL value must identify the row: {value}"
);
if primary_key.is_empty() {
pk_map["label"] = json!("after");
}
let deleted = plugin.call_ok(
"delete_record",
json!({
"params": params, "table": "live_float4_identity", "schema": "public",
"pk_map": pk_map,
}),
);
assert_eq!(deleted, json!(1));
}
plugin.call_ok(
"execute_query",
json!({"params": params, "query": "DROP TABLE live_float4_identity"}),
);
}
}

#[test]
fn keyless_real_two_column_update_accepts_string_identity() {
let mut plugin = Plugin::spawn();
let params = conn_params();
for query in [
"DROP TABLE IF EXISTS live_float4_keyless_edit",
"CREATE TABLE live_float4_keyless_edit (price real, label text)",
"INSERT INTO live_float4_keyless_edit VALUES (89.9, 'a')",
] {
plugin.call_ok("execute_query", json!({"params": params, "query": query}));
}
let mut identity = json!({"price": 89.9, "label": "a"});
let edited_price = json!("59.99");
let first = plugin.call_ok(
"update_record",
json!({
"params": params, "table": "live_float4_keyless_edit", "schema": "public",
"pk_map": identity, "col_name": "price", "new_val": edited_price,
}),
);
assert_eq!(first, json!(1), "price update");
// The next keyless step carries the editor's string without re-reading the row.
identity["price"] = edited_price;
let second = plugin.call_ok(
"update_record",
json!({
"params": params, "table": "live_float4_keyless_edit", "schema": "public",
"pk_map": identity, "col_name": "label", "new_val": "b",
}),
);
assert_eq!(second, json!(1), "label update with string REAL identity");
let result = plugin.call_ok(
"execute_query",
json!({"params": params, "query": "SELECT price, label FROM live_float4_keyless_edit"}),
);
assert_eq!(result["rows"], json!([[59.99, "b"]]));
plugin.call_ok(
"execute_query",
json!({"params": params, "query": "DROP TABLE live_float4_keyless_edit"}),
);
}

// Coverage for #66: `tokio_postgres::Error`'s own `Display` impl prints the
// generic "db error" string for any server-side error (its `Kind::Db` arm),
// throwing away the real message in the wrapped `DbError`. `exec_query_on_client`
Expand Down
Loading