diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c94e8642..fb68918d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6137,7 +6137,7 @@ dependencies = [ [[package]] name = "tabularis" -version = "0.9.21" +version = "0.10.0" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/src-tauri/src/drivers/postgres/binding.rs b/src-tauri/src/drivers/postgres/binding.rs new file mode 100644 index 00000000..765c7a88 --- /dev/null +++ b/src-tauri/src/drivers/postgres/binding.rs @@ -0,0 +1,225 @@ +use super::helpers::{ + escape_identifier, extract_base_type, is_raw_sql_function, is_wkt_geometry, + json_array_to_pg_literal, try_parse_pg_array, +}; +use tokio_postgres::types::ToSql; + +pub(super) type PgParam = Box; + +pub(super) struct BoundValue { + pub sql: String, + pub param: Option, +} + +pub(super) struct PgValueOptions<'a> { + pub column_type: Option<&'a str>, + pub max_blob_size: u64, + pub allow_default: bool, +} + +/// Build a parameterized " = $N" predicate plus the boxed parameter for the +/// given JSON pk_val. Numeric values are cast through bigint/double precision so the +/// bind succeeds against int2/int4/int8/real columns; UUID strings are bound as the +/// `Uuid` type so PostgreSQL receives the matching OID. +pub(super) fn build_pk_predicate( + pk_col: &str, + pk_val: serde_json::Value, + placeholder_idx: usize, +) -> Result<(String, PgParam), String> { + let col = format!("\"{}\"", escape_identifier(pk_col)); + match pk_val { + serde_json::Value::Number(n) => { + let bound = bind_pg_number(&n, placeholder_idx)?; + let param = bound + .param + .ok_or_else(|| "Internal PostgreSQL numeric binding error".to_string())?; + Ok((format!("{} = {}", col, bound.sql), param)) + } + serde_json::Value::String(s) => { + if let Ok(uuid) = s.parse::() { + Ok((format!("{} = ${}", col, placeholder_idx), Box::new(uuid))) + } else { + Ok((format!("{} = ${}", col, placeholder_idx), Box::new(s))) + } + } + _ => Err("Unsupported PK type".into()), + } +} + +pub(super) fn bind_pg_value( + value: serde_json::Value, + placeholder_idx: usize, + options: PgValueOptions<'_>, +) -> Result { + match value { + serde_json::Value::Number(n) => bind_pg_number(&n, placeholder_idx), + serde_json::Value::String(s) => bind_pg_string(&s, placeholder_idx, options), + serde_json::Value::Bool(b) => Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some(Box::new(b)), + }), + serde_json::Value::Null => Ok(BoundValue { + sql: "NULL".to_string(), + param: None, + }), + serde_json::Value::Array(arr) => Ok(BoundValue { + sql: json_array_to_pg_literal(&arr)?, + param: None, + }), + _ => Err("Unsupported Value type".into()), + } +} + +/// SQL fragment + boxed parameter for a JSON Number bound to PostgreSQL. +/// +/// tokio-postgres binds Rust `i64` as INT8 and `f64` as FLOAT8, and rejects the +/// bind when the column is INT2/INT4/REAL with "error serializing parameter X". +/// Wrapping the placeholder in `CAST($N AS bigint)` / `CAST($N AS double precision)` +/// lets PostgreSQL convert to the actual column width via its assignment / implicit +/// comparison casts. +pub(super) fn bind_pg_number( + n: &serde_json::Number, + placeholder_idx: usize, +) -> Result { + if let Some(v) = n.as_i64() { + Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some(Box::new(v)), + }) + } else if let Some(v) = n.as_f64() { + Ok(BoundValue { + sql: format!("CAST(${} AS double precision)", placeholder_idx), + param: Some(Box::new(v)), + }) + } else { + Err(format!("Unsupported numeric value: {}", n)) + } +} + +pub(super) fn bind_pg_numeric_string( + s: &str, + column_type: &str, + placeholder_idx: usize, +) -> Option> { + let trimmed = s.trim(); + let normalized = extract_base_type(column_type).to_lowercase(); + + if matches!( + normalized.as_str(), + "smallint" | "integer" | "bigint" | "int2" | "int4" | "int8" | "serial" | "bigserial" + ) { + return Some(trimmed.parse::().map_or_else( + |e| { + Err(format!( + "Cannot convert value {:?} to PostgreSQL numeric column type {}: {}", + s, column_type, e + )) + }, + |v| { + Ok(BoundValue { + sql: format!("CAST(${} AS bigint)", placeholder_idx), + param: Some(Box::new(v) as PgParam), + }) + }, + )); + } + + if matches!(normalized.as_str(), "numeric" | "decimal") { + return Some(trimmed.parse::().map_or_else( + |e| { + Err(format!( + "Cannot convert value {:?} to PostgreSQL numeric column type {}: {}", + s, column_type, e + )) + }, + |v| { + Ok(BoundValue { + sql: format!("CAST(${} AS numeric)", placeholder_idx), + param: Some(Box::new(v) as PgParam), + }) + }, + )); + } + + if matches!( + normalized.as_str(), + "real" | "double precision" | "float4" | "float8" + ) { + return Some(trimmed.parse::().map_or_else( + |e| { + Err(format!( + "Cannot convert value {:?} to PostgreSQL numeric column type {}: {}", + s, column_type, e + )) + }, + |v| { + Ok(BoundValue { + sql: format!("CAST(${} AS double precision)", placeholder_idx), + param: Some(Box::new(v) as PgParam), + }) + }, + )); + } + + None +} + +fn bind_pg_string( + s: &str, + placeholder_idx: usize, + options: PgValueOptions<'_>, +) -> Result { + if options.allow_default && s == "__USE_DEFAULT__" { + return Ok(BoundValue { + sql: "DEFAULT".to_string(), + param: None, + }); + } + + if let Some(bytes) = crate::drivers::common::decode_blob_wire_format(s, options.max_blob_size) { + return Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some(Box::new(bytes)), + }); + } + + if let Some(binding) = options + .column_type + .and_then(|data_type| bind_pg_numeric_string(s, data_type, placeholder_idx)) + { + return binding; + } + + if is_raw_sql_function(s) { + return Ok(BoundValue { + sql: s.to_string(), + param: None, + }); + } + + if is_wkt_geometry(s) { + return Ok(BoundValue { + sql: format!("ST_GeomFromText(${})", placeholder_idx), + param: Some(Box::new(s.to_string())), + }); + } + + if s.parse::().is_ok() { + return Ok(BoundValue { + sql: format!("CAST(${} AS uuid)", placeholder_idx), + param: Some(Box::new(s.to_string())), + }); + } + + if let Some(pg_arr) = try_parse_pg_array(s) { + return Ok(BoundValue { + sql: pg_arr?, + param: None, + }); + } + + Ok(BoundValue { + sql: format!("${}", placeholder_idx), + param: Some(Box::new(s.to_string())), + }) +} diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index da086d68..04eb87ad 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -2,6 +2,7 @@ pub mod types; pub mod extract; +mod binding; mod client; mod explain; mod helpers; @@ -14,15 +15,12 @@ use crate::models::{ TableColumn, TableInfo, ViewInfo, }; use crate::pool_manager::get_postgres_pool; +use binding::{PgValueOptions, bind_pg_value, build_pk_predicate}; use client::{execute, format_pg_error, query_all, query_one}; pub use explain::explain_query; use extract::extract_value; -use helpers::{ - escape_identifier, extract_base_type, is_implicit_cast_compatible, is_raw_sql_function, - is_wkt_geometry, json_array_to_pg_literal, try_parse_pg_array, -}; +use helpers::{escape_identifier, extract_base_type, is_implicit_cast_compatible}; use tokio_postgres::types::ToSql; -use uuid::Uuid; pub async fn get_schemas(params: &ConnectionParams) -> Result, String> { let pool = get_postgres_pool(params).await?; @@ -387,32 +385,16 @@ pub async fn save_blob_column_to_file( ) -> Result<(), String> { let pool = get_postgres_pool(params).await?; + let (predicate, param) = build_pk_predicate(pk_col, pk_val, 1)?; let query = format!( - "SELECT \"{}\" FROM \"{}\".\"{}\" WHERE \"{}\" = $1", + "SELECT \"{}\" FROM \"{}\".\"{}\" WHERE {}", escape_identifier(col_name), escape_identifier(schema), escape_identifier(table), - escape_identifier(pk_col) + predicate, ); - let row = match pk_val { - serde_json::Value::Number(n) => { - if n.is_i64() { - query_one(&pool, &query, &[&n.as_i64()]).await - } else { - query_one(&pool, &query, &[&n.as_f64()]).await - } - } - serde_json::Value::String(s) => { - // Try parsing as UUID so PostgreSQL receives the correct type - if let Ok(uuid) = s.parse::() { - query_one(&pool, &query, &[&uuid]).await - } else { - query_one(&pool, &query, &[&s]).await - } - } - _ => return Err("Unsupported PK type".into()), - }?; + let row = query_one(&pool, &query, &[param.as_ref() as &(dyn ToSql + Sync)]).await?; let bytes: Vec = row.try_get(0).map_err(|e| format_pg_error(&e))?; std::fs::write(file_path, bytes).map_err(|e| e.to_string()) @@ -428,37 +410,97 @@ pub async fn fetch_blob_column_as_data_url( ) -> Result { let pool = get_postgres_pool(params).await?; + let (predicate, param) = build_pk_predicate(pk_col, pk_val, 1)?; let query = format!( - "SELECT \"{}\" FROM \"{}\".\"{}\" WHERE \"{}\" = $1", + "SELECT \"{}\" FROM \"{}\".\"{}\" WHERE {}", escape_identifier(col_name), escape_identifier(schema), escape_identifier(table), - escape_identifier(pk_col) + predicate, ); - let row = match pk_val { - serde_json::Value::Number(n) => { - if n.is_i64() { - query_one(&pool, &query, &[&n.as_i64()]).await - } else { - query_one(&pool, &query, &[&n.as_f64()]).await - } - } - serde_json::Value::String(s) => { - // Try parsing as UUID so PostgreSQL receives the correct type - if let Ok(uuid) = s.parse::() { - query_one(&pool, &query, &[&uuid]).await - } else { - query_one(&pool, &query, &[&s]).await - } - } - _ => return Err("Unsupported PK type".into()), - }?; + let row = query_one(&pool, &query, &[param.as_ref() as &(dyn ToSql + Sync)]).await?; let bytes: Vec = row.try_get(0).map_err(|e| format_pg_error(&e))?; Ok(crate::drivers::common::encode_blob_full(&bytes)) } +async fn get_column_data_type( + pool: &deadpool_postgres::Pool, + schema: &str, + table: &str, + col_name: &str, +) -> Result, String> { + let rows = query_all( + pool, + "SELECT data_type::text, udt_name::text \ +FROM information_schema.columns \ +WHERE table_schema = $1 AND table_name = $2 AND column_name = $3 \ +LIMIT 1", + &[&schema, &table, &col_name], + ) + .await?; + + Ok(rows.first().map(|row| { + row.try_get::<_, String>("data_type") + .or_else(|_| row.try_get::<_, String>("udt_name")) + .unwrap_or_else(|_| "unknown".to_string()) + })) +} + +fn json_value_kind(value: &serde_json::Value) -> &'static str { + match value { + serde_json::Value::Null => "null", + serde_json::Value::Bool(_) => "boolean", + serde_json::Value::Number(_) => "number", + serde_json::Value::String(_) => "string", + serde_json::Value::Array(_) => "array", + serde_json::Value::Object(_) => "object", + } +} + +fn update_record_error_context( + err: String, + schema: &str, + table: &str, + pk_col: &str, + pk_val: &serde_json::Value, + col_name: &str, + new_val: &serde_json::Value, + column_type: Option<&str>, + query: &str, +) -> String { + let column_type = column_type.unwrap_or("unknown"); + let hint = if matches!(new_val, serde_json::Value::String(_)) + && matches!( + column_type.to_ascii_lowercase().as_str(), + "smallint" + | "integer" + | "bigint" + | "int2" + | "int4" + | "int8" + | "serial" + | "bigserial" + | "numeric" + | "decimal" + | "real" + | "double precision" + | "float4" + | "float8" + ) { + "\nHint: the edited value arrived from the grid as a JSON string. For numeric PostgreSQL columns, Tabularis attempts to parse it before binding; check that the value is valid for the target column type." + } else { + "" + }; + + format!( + "{err}\n\nPostgreSQL update context:\n- table: \"{schema}\".\"{table}\"\n- column: \"{col_name}\" ({column_type})\n- new value JSON type: {new_val_kind}\n- primary key: \"{pk_col}\" JSON type {pk_val_kind}\n- SQL: {query}{hint}", + new_val_kind = json_value_kind(new_val), + pk_val_kind = json_value_kind(pk_val), + ) +} + pub async fn delete_record( params: &ConnectionParams, table: &str, @@ -468,33 +510,15 @@ pub async fn delete_record( ) -> Result { let pool = get_postgres_pool(params).await?; + let (predicate, param) = build_pk_predicate(pk_col, pk_val, 1)?; let query = format!( - "DELETE FROM \"{}\".\"{}\" WHERE \"{}\" = $1", + "DELETE FROM \"{}\".\"{}\" WHERE {}", escape_identifier(schema), escape_identifier(table), - escape_identifier(pk_col) + predicate, ); - let result = match pk_val { - serde_json::Value::Number(n) => { - if n.is_i64() { - execute(&pool, &query, &[&n.as_i64()]).await - } else { - execute(&pool, &query, &[&n.as_f64()]).await - } - } - serde_json::Value::String(s) => { - // Try parsing as UUID so PostgreSQL receives the correct type - if let Ok(uuid) = s.parse::() { - execute(&pool, &query, &[&uuid]).await - } else { - execute(&pool, &query, &[&s]).await - } - } - _ => return Err("Unsupported PK type".into()), - }; - - result + execute(&pool, &query, &[param.as_ref() as &(dyn ToSql + Sync)]).await } pub async fn update_record( @@ -508,6 +532,21 @@ pub async fn update_record( max_blob_size: u64, ) -> Result { let pool = get_postgres_pool(params).await?; + let column_data_type = match get_column_data_type(&pool, schema, table, col_name).await { + Ok(data_type) => data_type, + Err(err) => { + log::debug!( + "Could not load PostgreSQL column metadata for {}.{}.{}: {}", + schema, + table, + col_name, + err + ); + None + } + }; + let new_val_for_context = new_val.clone(); + let pk_val_for_context = pk_val.clone(); let mut query = format!( "UPDATE \"{}\".\"{}\" SET \"{}\" = ", @@ -518,91 +557,43 @@ pub async fn update_record( let mut params: Vec> = Vec::new(); - match new_val { - serde_json::Value::Number(n) => { - query.push_str(&format!("${}", params.len() + 1)); - if n.is_i64() { - params.push(Box::new(n.as_i64())); - } else { - params.push(Box::new(n.as_f64())); - } - } - serde_json::Value::String(s) => { - // Check for special sentinel value to use DEFAULT - if s == "__USE_DEFAULT__" { - query.push_str("DEFAULT"); - } else if let Some(bytes) = - crate::drivers::common::decode_blob_wire_format(&s, max_blob_size) - { - // Blob wire format: decode to raw bytes so the DB stores binary data, - // not the internal wire format string. - query.push_str(&format!("${}", params.len() + 1)); - params.push(Box::new(bytes)); - } else if is_raw_sql_function(&s) { - // If it's a raw SQL function (e.g., ST_GeomFromText('POINT(1 2)', 4326)) - // insert it directly without parameter binding - query.push_str(&s); - } else if is_wkt_geometry(&s) { - // If it's WKT geometry format, wrap with ST_GeomFromText - query.push_str(&format!("ST_GeomFromText(${})", params.len() + 1)); - - params.push(Box::new(s)); - } else if s.parse::().is_ok() { - // Wrap in explicit SQL CAST so PostgreSQL receives the correct type - // regardless of how sqlx QueryBuilder infers the parameter OID - query.push_str(&format!("CAST(${} AS uuid)", params.len() + 1)); - - params.push(Box::new(s)); - } else if let Some(pg_arr) = try_parse_pg_array(&s) { - query.push_str(&pg_arr?); - } else { - query.push_str(&format!("${}", params.len() + 1)); - params.push(Box::new(s)); - } - } - serde_json::Value::Bool(b) => { - query.push_str(&format!("${}", params.len() + 1)); - params.push(Box::new(b)); - } - serde_json::Value::Null => { - query.push_str("NULL"); - } - serde_json::Value::Array(arr) => { - query.push_str(&json_array_to_pg_literal(&arr)?); - } - _ => return Err("Unsupported Value type".into()), + let bound = bind_pg_value( + new_val, + params.len() + 1, + PgValueOptions { + column_type: column_data_type.as_deref(), + max_blob_size, + allow_default: true, + }, + )?; + query.push_str(&bound.sql); + if let Some(param) = bound.param { + params.push(param); } - query.push_str(&format!(" WHERE \"{}\" = ", pk_col)); - - match pk_val { - serde_json::Value::Number(n) => { - query.push_str(&format!("${}", params.len() + 1)); - - if n.is_i64() { - params.push(Box::new(n.as_i64())); - } else { - params.push(Box::new(n.as_f64())); - } - } - serde_json::Value::String(s) => { - if s.parse::().is_ok() { - query.push_str(&format!("CAST(${}) AS uuid)", params.len() + 1)); - params.push(Box::new(s)); - } else { - query.push_str(&format!("${}", params.len() + 1)); - params.push(Box::new(s)); - } - } - _ => return Err("Unsupported PK type".into()), - } + let (predicate, pk_param) = build_pk_predicate(pk_col, pk_val, params.len() + 1)?; + query.push_str(" WHERE "); + query.push_str(&predicate); + params.push(pk_param); let params: Vec<&(dyn ToSql + Sync)> = params .iter() .map(|b| b.as_ref() as &(dyn ToSql + Sync)) .collect(); - execute(&pool, &query, ¶ms).await + execute(&pool, &query, ¶ms).await.map_err(|err| { + update_record_error_context( + err, + schema, + table, + pk_col, + &pk_val_for_context, + col_name, + &new_val_for_context, + column_data_type.as_deref(), + &query, + ) + }) } pub async fn insert_record( @@ -640,55 +631,18 @@ pub async fn insert_record( let mut vals_set: Vec = Vec::with_capacity(vals.len()); for val in vals { - match val { - serde_json::Value::Number(n) => { - vals_set.push(format!("${}", params.len() + 1)); - if n.is_i64() { - params.push(Box::new(n.as_i64())); - } else { - params.push(Box::new(n.as_f64())); - } - } - serde_json::Value::String(s) => { - if let Some(bytes) = - crate::drivers::common::decode_blob_wire_format(&s, max_blob_size) - { - // Blob wire format: decode to raw bytes so the DB stores binary data, - // not the internal wire format string. - vals_set.push(format!("${}", params.len() + 1)); - params.push(Box::new(bytes)); - } else if is_raw_sql_function(&s) { - // If it's a raw SQL function (e.g., ST_GeomFromText('POINT(1 2)', 4326)) - // insert it directly without parameter binding - vals_set.push(s); - } else if is_wkt_geometry(&s) { - // If it's WKT geometry format, wrap with ST_GeomFromText - vals_set.push(format!("ST_GeomFromText(${})", params.len() + 1)); - params.push(Box::new(s)); - } else if s.parse::().is_ok() { - // If it's a UUID, cast it to uuid type - vals_set.push(format!("CAST(${} AS uuid)", params.len() + 1)); - params.push(Box::new(s)); - } else if let Some(pg_arr) = try_parse_pg_array(&s) { - vals_set.push(pg_arr?); - } else { - vals_set.push(format!("${}", params.len() + 1)); - params.push(Box::new(s)); - } - } - serde_json::Value::Bool(b) => { - vals_set.push(format!("${}", params.len() + 1)); - params.push(Box::new(b)); - } - serde_json::Value::Null => { - vals_set.push("NULL".to_string()); - } - serde_json::Value::Array(arr) => { - vals_set.push(json_array_to_pg_literal(&arr)?); - } - _ => { - return Err(format!("Unsupported value type: {:?}", val)); - } + let bound = bind_pg_value( + val, + params.len() + 1, + PgValueOptions { + column_type: None, + max_blob_size, + allow_default: false, + }, + )?; + vals_set.push(bound.sql); + if let Some(param) = bound.param { + params.push(param); } } @@ -788,10 +742,12 @@ pub async fn execute_query( let params: Vec = vec![]; // Stream data rows while COUNT runs in the background - let mut rows_stream = std::pin::pin!(client - .query_raw(&final_query, ¶ms) - .await - .map_err(|e| format_pg_error(&e))?); + let mut rows_stream = std::pin::pin!( + client + .query_raw(&final_query, ¶ms) + .await + .map_err(|e| format_pg_error(&e))? + ); let mut columns: Vec = Vec::new(); let mut json_rows = Vec::new(); diff --git a/src-tauri/src/drivers/postgres/tests.rs b/src-tauri/src/drivers/postgres/tests.rs index 5e1b4f05..6d0ee448 100644 --- a/src-tauri/src/drivers/postgres/tests.rs +++ b/src-tauri/src/drivers/postgres/tests.rs @@ -1,3 +1,6 @@ +use super::binding::{ + PgValueOptions, bind_pg_number, bind_pg_numeric_string, bind_pg_value, build_pk_predicate, +}; use super::helpers::{extract_base_type, is_implicit_cast_compatible}; mod extract_base_type_tests { @@ -157,3 +160,208 @@ mod is_implicit_cast_compatible_tests { assert!(!is_implicit_cast_compatible("UUID", "TEXT")); } } + +mod pg_number_binding_tests { + use super::*; + + #[test] + fn positive_i64_casts_to_bigint() { + let n = serde_json::Number::from(42i64); + let bound = bind_pg_number(&n, 1).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + assert!(bound.param.is_some()); + } + + #[test] + fn negative_i64_casts_to_bigint() { + let n = serde_json::Number::from(-7i64); + let bound = bind_pg_number(&n, 5).unwrap(); + assert_eq!(bound.sql, "CAST($5 AS bigint)"); + } + + #[test] + fn zero_casts_to_bigint() { + let n = serde_json::Number::from(0i64); + let bound = bind_pg_number(&n, 2).unwrap(); + assert_eq!(bound.sql, "CAST($2 AS bigint)"); + } + + #[test] + fn f64_casts_to_double_precision() { + let n = serde_json::Number::from_f64(3.14).unwrap(); + let bound = bind_pg_number(&n, 3).unwrap(); + assert_eq!(bound.sql, "CAST($3 AS double precision)"); + } + + #[test] + fn large_u64_falls_back_to_double_precision() { + // u64 above i64::MAX cannot be represented as i64, but as_f64 still returns Some. + let n = serde_json::Number::from(u64::MAX); + let bound = bind_pg_number(&n, 1).unwrap(); + assert_eq!(bound.sql, "CAST($1 AS double precision)"); + } + + #[test] + fn placeholder_index_is_preserved() { + let n = serde_json::Number::from(1i64); + let bound = bind_pg_number(&n, 99).unwrap(); + assert_eq!(bound.sql, "CAST($99 AS bigint)"); + } +} + +mod pg_numeric_string_binding_tests { + use super::*; + + #[test] + fn integer_string_for_integer_column_casts_to_bigint() { + let bound = bind_pg_numeric_string("22", "integer", 1).unwrap().unwrap(); + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + assert!(bound.param.is_some()); + } + + #[test] + fn decimal_string_for_numeric_column_casts_to_numeric() { + let bound = bind_pg_numeric_string("12.34", "numeric(10,2)", 2) + .unwrap() + .unwrap(); + assert_eq!(bound.sql, "CAST($2 AS numeric)"); + } + + #[test] + fn float_string_for_real_column_casts_to_double_precision() { + let bound = bind_pg_numeric_string("3.14", "real", 3).unwrap().unwrap(); + assert_eq!(bound.sql, "CAST($3 AS double precision)"); + } + + #[test] + fn text_column_is_not_handled_as_numeric() { + assert!(bind_pg_numeric_string("22", "text", 1).is_none()); + } + + #[test] + fn invalid_integer_string_returns_detailed_error() { + let err = match bind_pg_numeric_string("not-a-number", "integer", 1).unwrap() { + Ok(_) => panic!("expected invalid integer binding to fail"), + Err(err) => err, + }; + assert!(err.contains("Cannot convert value")); + assert!(err.contains("integer")); + } +} + +mod bind_pg_value_tests { + use super::*; + + #[test] + fn update_string_for_numeric_column_uses_numeric_binding() { + let bound = bind_pg_value( + serde_json::json!("22"), + 1, + PgValueOptions { + column_type: Some("integer"), + max_blob_size: 1024, + allow_default: true, + }, + ) + .unwrap(); + + assert_eq!(bound.sql, "CAST($1 AS bigint)"); + assert!(bound.param.is_some()); + } + + #[test] + fn default_sentinel_is_only_used_when_allowed() { + let bound = bind_pg_value( + serde_json::json!("__USE_DEFAULT__"), + 1, + PgValueOptions { + column_type: None, + max_blob_size: 1024, + allow_default: true, + }, + ) + .unwrap(); + + assert_eq!(bound.sql, "DEFAULT"); + assert!(bound.param.is_none()); + } + + #[test] + fn insert_path_treats_default_sentinel_as_regular_string() { + let bound = bind_pg_value( + serde_json::json!("__USE_DEFAULT__"), + 1, + PgValueOptions { + column_type: None, + max_blob_size: 1024, + allow_default: false, + }, + ) + .unwrap(); + + assert_eq!(bound.sql, "$1"); + assert!(bound.param.is_some()); + } + + #[test] + fn json_array_becomes_literal_without_parameter() { + let bound = bind_pg_value( + serde_json::json!(["a", "b"]), + 1, + PgValueOptions { + column_type: None, + max_blob_size: 1024, + allow_default: false, + }, + ) + .unwrap(); + + assert_eq!(bound.sql, "ARRAY['a', 'b']"); + assert!(bound.param.is_none()); + } +} + +mod build_pk_predicate_tests { + use super::*; + + #[test] + fn integer_pk_uses_bigint_cast() { + let (sql, _) = build_pk_predicate("id", serde_json::json!(1), 1).unwrap(); + assert_eq!(sql, "\"id\" = CAST($1 AS bigint)"); + } + + #[test] + fn float_pk_uses_double_precision_cast() { + let (sql, _) = build_pk_predicate("id", serde_json::json!(1.5), 2).unwrap(); + assert_eq!(sql, "\"id\" = CAST($2 AS double precision)"); + } + + #[test] + fn uuid_string_pk_binds_without_cast() { + let uuid = "550e8400-e29b-41d4-a716-446655440000"; + let (sql, _) = build_pk_predicate("uuid", serde_json::json!(uuid), 1).unwrap(); + assert_eq!(sql, "\"uuid\" = $1"); + } + + #[test] + fn plain_string_pk_binds_without_cast() { + let (sql, _) = build_pk_predicate("name", serde_json::json!("alice"), 1).unwrap(); + assert_eq!(sql, "\"name\" = $1"); + } + + #[test] + fn pk_col_with_quotes_is_escaped() { + let (sql, _) = build_pk_predicate("a\"b", serde_json::json!(1), 1).unwrap(); + assert_eq!(sql, "\"a\"\"b\" = CAST($1 AS bigint)"); + } + + #[test] + fn null_pk_is_rejected() { + assert!(build_pk_predicate("id", serde_json::Value::Null, 1).is_err()); + } + + #[test] + fn bool_pk_is_rejected() { + assert!(build_pk_predicate("id", serde_json::json!(true), 1).is_err()); + } +}