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
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

93 changes: 92 additions & 1 deletion src/binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@

use rust_decimal::Decimal;
use serde_json::Value;
use tokio_postgres::types::{ToSql, Type};
use std::collections::HashMap;
use tokio_postgres::types::{Kind, ToSql, Type};
use uuid::Uuid;

pub type PgParam = Box<dyn ToSql + Sync + Send>;
Expand Down Expand Up @@ -44,6 +45,11 @@ pub struct BindOptions<'a> {
/// the `CAST($N AS <enum>)` coercion in [`bind_pg_enum_string`].
pub enum_type: Option<&'a str>,
pub allow_default: bool,
/// Real OID of the `hstore` type for this database, when `column_type`
/// is `"hstore"` — required because hstore has no well-known Postgres
/// OID and varies per installation. `None` when the column isn't
/// hstore, or resolution failed (surfaced as an error at bind time).
pub hstore_oid: Option<u32>,
}

const USE_DEFAULT_SENTINEL: &str = "__USE_DEFAULT__";
Expand All @@ -63,6 +69,12 @@ pub fn bind_pg_value(
) -> Result<BoundValue, String> {
let base_type = options.column_type.map(extract_base_type);

// hstore column — bind before the JSON/JSONB check below, since a JSON
// object destined for hstore must NOT go through the JSON ToSql path.
if options.column_type == Some("hstore") {
return bind_pg_hstore(value, placeholder_idx, options.hstore_oid);
}

// JSON/JSONB columns receiving a native JSON value (object/array/number/bool)
// must bind the value's own ToSql JSON encoding — a text CAST trips an OID
// mismatch for json/jsonb columns.
Expand Down Expand Up @@ -102,6 +114,85 @@ pub fn bind_pg_value(
}
}

/// Binds a JSON object to an hstore column as `HashMap<String, Option<String>>`,
/// which `tokio-postgres` encodes natively via its built-in hstore `ToSql` impl.
/// Requires the real OID of the `hstore` type in this database (extension-defined,
/// not a well-known Postgres OID) so the placeholder's `Type` pins it correctly.
/// Ported from `tabularis#427`'s `bind_pg_hstore`.
fn bind_pg_hstore(
value: Value,
placeholder_idx: usize,
hstore_oid: Option<u32>,
) -> Result<BoundValue, String> {
let map = match value {
Value::Null => {
return Ok(BoundValue {
sql: "NULL".to_string(),
param: None,
});
}
Value::Object(map) => map,

// The grid's plain-text cell editor doesn't yet know about hstore, so it
// may round-trip the value as a JSON-encoded string rather than an
// object. Accept that shape here so editing still works.
Value::String(s) => match serde_json::from_str::<Value>(&s) {
Ok(Value::Object(map)) => map,
_ => {
return Err(format!(
"hstore column requires a JSON object value, got a string that is not valid JSON: {:?}",
s
));
}
},
other => {
return Err(format!(
"hstore column requires a JSON object value, got {:?}",
other
));
}
};

let oid = hstore_oid.ok_or_else(|| {
"Could not resolve the hstore type OID; is the hstore extension installed?".to_string()
})?;
let hmap = hstore_map_from_json_object(map)?;
let pg_type = Type::new(
"hstore".to_string(),
oid,
Kind::Simple,
"public".to_string(),
);
Ok(BoundValue {
sql: format!("${}", placeholder_idx),
param: Some((Box::new(hmap), pg_type)),
})
}

/// Converts a JSON object into the `HashMap<String, Option<String>>` shape that
/// `tokio-postgres` encodes natively as hstore. Every value must be a string or
/// null — hstore itself only stores text, so numbers/bools/nested objects have no
/// unambiguous representation and are rejected with a message naming the offending key.
fn hstore_map_from_json_object(
map: serde_json::Map<String, Value>,
) -> Result<HashMap<String, Option<String>>, String> {
let mut hmap = HashMap::with_capacity(map.len());
for (k, v) in map {
let val = match v {
Value::String(s) => Some(s),
Value::Null => None,
other => {
return Err(format!(
"hstore value for key '{}' must be a string or null, got {:?}",
k, other
));
}
};
hmap.insert(k, val);
}
Ok(hmap)
}

fn bind_pg_number(n: serde_json::Number, placeholder_idx: usize) -> Result<BoundValue, String> {
if let Some(i) = n.as_i64() {
Ok(BoundValue {
Expand Down
115 changes: 114 additions & 1 deletion src/binding_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//! (`.rules/rust.md` #4/#5) — loaded via `#[cfg(test)] mod binding_tests;`.

use crate::binding::{bind_pg_value, bind_pk_value, BindOptions};
use serde_json::json;
use serde_json::{json, Value};

mod bind_pg_value_tests {
use super::*;
Expand Down Expand Up @@ -65,6 +65,7 @@ mod bind_pg_value_tests {
column_type: Some("jsonb"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let bound = bind_pg_value(json!({"a": 1}), 1, &options).unwrap();
assert_eq!(bound.sql, "$1");
Expand All @@ -80,6 +81,7 @@ mod bind_pg_value_tests {
column_type: Some("jsonb"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let bound = bind_pg_value(json!("{\"a\":1}"), 1, &options).unwrap();
assert_eq!(bound.sql, "$1");
Expand All @@ -91,6 +93,7 @@ mod bind_pg_value_tests {
column_type: None,
enum_type: None,
allow_default: true,
hstore_oid: None,
};
let bound = bind_pg_value(json!("__USE_DEFAULT__"), 1, &options).unwrap();
assert_eq!(bound.sql, "DEFAULT");
Expand All @@ -103,6 +106,7 @@ mod bind_pg_value_tests {
column_type: None,
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let bound = bind_pg_value(json!("__USE_DEFAULT__"), 1, &options).unwrap();
// Falls through to the plain TEXT fallback, not treated as DEFAULT.
Expand All @@ -128,6 +132,7 @@ mod bind_pg_value_tests {
column_type: None,
enum_type: Some("\"test_schema\".\"mood\""),
allow_default: false,
hstore_oid: None,
};
let bound = bind_pg_value(json!("sad"), 1, &options).unwrap();
assert_eq!(bound.sql, "CAST($1 AS \"test_schema\".\"mood\")");
Expand All @@ -143,6 +148,7 @@ mod bind_pg_value_tests {
column_type: None,
enum_type: Some("\"public\".\"status\""),
allow_default: false,
hstore_oid: None,
};
let bound =
bind_pg_value(json!("a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11"), 1, &options).unwrap();
Expand All @@ -155,6 +161,7 @@ mod bind_pg_value_tests {
column_type: Some("boolean"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
for truthy in ["true", "t", "yes", "y", "on", "1", "TRUE"] {
let bound = bind_pg_value(json!(truthy), 1, &options).unwrap();
Expand All @@ -168,6 +175,7 @@ mod bind_pg_value_tests {
column_type: Some("boolean"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let err = bind_pg_value(json!("maybe"), 1, &options).unwrap_err();
assert!(err.contains("boolean"));
Expand All @@ -179,6 +187,7 @@ mod bind_pg_value_tests {
column_type: Some("integer"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let bound = bind_pg_value(json!("42"), 1, &options).unwrap();
assert_eq!(bound.sql, "CAST($1 AS bigint)");
Expand All @@ -190,6 +199,7 @@ mod bind_pg_value_tests {
column_type: Some("integer"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let err = bind_pg_value(json!("not-a-number"), 1, &options).unwrap_err();
assert!(err.contains("integer"));
Expand All @@ -201,6 +211,7 @@ mod bind_pg_value_tests {
column_type: Some("numeric"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let bound = bind_pg_value(json!("12345.67"), 1, &options).unwrap();
assert_eq!(bound.sql, "CAST($1 AS numeric)");
Expand All @@ -212,6 +223,7 @@ mod bind_pg_value_tests {
column_type: Some("timestamp"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let bound = bind_pg_value(json!("2026-01-15 14:30:00"), 1, &options).unwrap();
assert_eq!(bound.sql, "CAST($1 AS timestamp)");
Expand All @@ -223,6 +235,7 @@ mod bind_pg_value_tests {
column_type: Some("timestamptz"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let bound = bind_pg_value(json!("2026-01-15 14:30:00+00"), 1, &options).unwrap();
assert_eq!(bound.sql, "CAST($1 AS timestamptz)");
Expand Down Expand Up @@ -252,6 +265,106 @@ mod bind_pg_value_tests {
assert_eq!(bound.sql, "$1");
assert!(bound.param.is_some());
}

#[test]
fn hstore_object_bound_as_value_with_correct_type_name() {
let options = BindOptions {
column_type: Some("hstore"),
enum_type: None,
allow_default: false,
hstore_oid: Some(16_500),
};
let bound = bind_pg_value(json!({"key": "value", "other": "thing"}), 1, &options).unwrap();

assert_eq!(bound.sql, "$1");
let (_, pg_type) = bound.param.unwrap();
assert_eq!(pg_type.name(), "hstore");
assert_eq!(pg_type.oid(), 16_500);
}

#[test]
fn hstore_object_with_null_value_bound_correctly() {
let options = BindOptions {
column_type: Some("hstore"),
enum_type: None,
allow_default: false,
hstore_oid: Some(16_500),
};
let bound = bind_pg_value(json!({"key": null}), 1, &options).unwrap();

assert_eq!(bound.sql, "$1");
assert!(bound.param.is_some());
}

#[test]
fn hstore_null_value_stays_sql_null() {
let options = BindOptions {
column_type: Some("hstore"),
enum_type: None,
allow_default: false,
hstore_oid: Some(16_500),
};
let bound = bind_pg_value(Value::Null, 1, &options).unwrap();

assert_eq!(bound.sql, "NULL");
assert!(bound.param.is_none());
}

#[test]
fn hstore_json_encoded_string_is_accepted_as_a_fallback() {
// The plain-text cell editor doesn't distinguish hstore from other
// types, so it may round-trip a value as a JSON-encoded string.
let options = BindOptions {
column_type: Some("hstore"),
enum_type: None,
allow_default: false,
hstore_oid: Some(16_500),
};
let bound = bind_pg_value(json!("{\"key\": \"value\"}"), 1, &options).unwrap();

assert_eq!(bound.sql, "$1");
assert!(bound.param.is_some());
}

#[test]
fn hstore_non_string_value_in_object_returns_clear_error() {
let options = BindOptions {
column_type: Some("hstore"),
enum_type: None,
allow_default: false,
hstore_oid: Some(16_500),
};
let err = bind_pg_value(json!({"key": 42}), 1, &options).unwrap_err();

assert!(err.contains("key"));
assert!(err.contains("string or null"));
}

#[test]
fn hstore_non_object_value_returns_clear_error() {
let options = BindOptions {
column_type: Some("hstore"),
enum_type: None,
allow_default: false,
hstore_oid: Some(16_500),
};
let err = bind_pg_value(json!(42), 1, &options).unwrap_err();

assert!(err.contains("JSON object"));
}

#[test]
fn hstore_object_without_resolved_oid_returns_clear_error() {
let options = BindOptions {
column_type: Some("hstore"),
enum_type: None,
allow_default: false,
hstore_oid: None,
};
let err = bind_pg_value(json!({"key": "value"}), 1, &options).unwrap_err();

assert!(err.contains("hstore"));
}
}

mod bind_pk_value_tests {
Expand Down
26 changes: 26 additions & 0 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,32 @@ pub async fn get_enum_column_types(
.collect())
}

/// Resolves the real OID of the `hstore` type for a specific column,
/// confirming in the same query that the column actually is hstore. hstore
/// is an extension type (not a well-known Postgres OID), so its OID varies
/// per installation and — unlike `information_schema.columns.data_type`,
/// which reports it only as the generic "USER-DEFINED" — the concrete type
/// name has to come from the catalog directly. `None` means either the
/// extension isn't installed or the column isn't hstore. Ported from
/// `tabularis#427`'s `get_hstore_oid_for_column`.
pub async fn get_hstore_oid_for_column(
params: &ConnectionParams,
schema: &str,
table: &str,
col_name: &str,
) -> Result<Option<u32>, String> {
let query = "SELECT a.atttypid::oid \
FROM pg_attribute a \
JOIN pg_class c ON c.oid = a.attrelid \
JOIN pg_namespace n ON n.oid = c.relnamespace \
JOIN pg_type t ON t.oid = a.atttypid \
WHERE n.nspname = $1 AND c.relname = $2 AND a.attname = $3 AND t.typname = 'hstore' \
LIMIT 1";

let rows = query_rows(params, query, &[&schema, &table, &col_name]).await?;
Ok(rows.first().and_then(|row| row.try_get::<_, u32>(0).ok()))
}

/// Quote a schema-qualified type name (e.g. `"public"."mood"`) so it can be
/// spliced into a `CAST($N AS ...)` without becoming an injection vector.
fn quote_qualified_type(type_schema: &str, type_name: &str) -> String {
Expand Down
Loading
Loading