robust decimal solution to push tables data to the backend

This commit is contained in:
filipriec
2025-06-22 22:08:22 +02:00
parent 1b8f19f1ce
commit 1b1e7b7205
7 changed files with 1902 additions and 98 deletions

217
Cargo.lock generated
View File

@@ -65,6 +65,17 @@ version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
[[package]]
name = "ahash"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9"
dependencies = [
"getrandom 0.2.15",
"once_cell",
"version_check",
]
[[package]]
name = "ahash"
version = "0.8.11"
@@ -125,6 +136,12 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as_derive_utils"
version = "0.11.0"
@@ -312,6 +329,18 @@ dependencies = [
"crunchy",
]
[[package]]
name = "bitvec"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -356,12 +385,57 @@ dependencies = [
"syn 2.0.100",
]
[[package]]
name = "borsh"
version = "1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8646f98db542e39fc66e68a20b2144f6a732636df7c2354e74645faaa433ce"
dependencies = [
"borsh-derive",
"cfg_aliases",
]
[[package]]
name = "borsh-derive"
version = "1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdd1d3c0c2f5833f22386f252fe8ed005c7f59fdcddeef025c01b4c3b9fd9ac3"
dependencies = [
"once_cell",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.100",
]
[[package]]
name = "bumpalo"
version = "3.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
[[package]]
name = "bytecheck"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2"
dependencies = [
"bytecheck_derive",
"ptr_meta",
"simdutf8",
]
[[package]]
name = "bytecheck_derive"
version = "0.6.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -412,6 +486,12 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "chrono"
version = "0.4.40"
@@ -967,6 +1047,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "funty"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "futures"
version = "0.3.31"
@@ -1164,6 +1250,9 @@ name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
dependencies = [
"ahash 0.7.8",
]
[[package]]
name = "hashbrown"
@@ -1171,7 +1260,7 @@ version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"ahash 0.8.11",
"allocator-api2",
"serde",
]
@@ -1675,7 +1764,7 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e14eda50a3494b3bf7b9ce51c52434a761e383d7238ce1dd5dcec2fbc13e9fb"
dependencies = [
"ahash",
"ahash 0.8.11",
"dashmap",
"hashbrown 0.14.5",
"serde",
@@ -2269,7 +2358,7 @@ version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac98773b7109bc75f475ab5a134c9b64b87e59d776d31098d8f346922396a477"
dependencies = [
"arrayvec",
"arrayvec 0.5.2",
"typed-arena",
"unicode-width 0.1.14",
]
@@ -2376,6 +2465,26 @@ dependencies = [
"prost",
]
[[package]]
name = "ptr_meta"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1"
dependencies = [
"ptr_meta_derive",
]
[[package]]
name = "ptr_meta_derive"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "quickscope"
version = "0.2.0"
@@ -2401,6 +2510,12 @@ version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5"
[[package]]
name = "radium"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "radix_fmt"
version = "1.0.0"
@@ -2592,6 +2707,15 @@ version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "rend"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c"
dependencies = [
"bytecheck",
]
[[package]]
name = "repr_offset"
version = "0.2.2"
@@ -2615,6 +2739,35 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rkyv"
version = "0.7.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b"
dependencies = [
"bitvec",
"bytecheck",
"bytes",
"hashbrown 0.12.3",
"ptr_meta",
"rend",
"rkyv_derive",
"seahash",
"tinyvec",
"uuid",
]
[[package]]
name = "rkyv_derive"
version = "0.7.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "rsa"
version = "0.9.8"
@@ -2675,6 +2828,32 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "rust_decimal"
version = "1.37.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b203a6425500a03e0919c42d3c47caca51e79f1132046626d2c8871c5092035d"
dependencies = [
"arrayvec 0.7.6",
"borsh",
"bytes",
"num-traits",
"rand 0.8.5",
"rkyv",
"serde",
"serde_json",
]
[[package]]
name = "rust_decimal_macros"
version = "1.37.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6268b74858287e1a062271b988a0c534bf85bbeb567fe09331bf40ed78113d5"
dependencies = [
"quote",
"syn 2.0.100",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
@@ -2749,6 +2928,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "seahash"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "search"
version = "0.3.13"
@@ -2867,6 +3052,8 @@ dependencies = [
"regex",
"rstest",
"rust-stemmers",
"rust_decimal",
"rust_decimal_macros",
"search",
"serde",
"serde_json",
@@ -2961,6 +3148,12 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "simple_asn1"
version = "0.6.3"
@@ -3077,6 +3270,7 @@ dependencies = [
"native-tls",
"once_cell",
"percent-encoding",
"rust_decimal",
"serde",
"serde_json",
"sha2",
@@ -3160,6 +3354,7 @@ dependencies = [
"percent-encoding",
"rand 0.8.5",
"rsa",
"rust_decimal",
"serde",
"sha1",
"sha2",
@@ -3200,6 +3395,7 @@ dependencies = [
"memchr",
"once_cell",
"rand 0.8.5",
"rust_decimal",
"serde",
"serde_json",
"sha2",
@@ -3577,6 +3773,12 @@ dependencies = [
"serde",
]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tempfile"
version = "3.19.1"
@@ -4523,6 +4725,15 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51"
[[package]]
name = "wyz"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
dependencies = [
"tap",
]
[[package]]
name = "yoke"
version = "0.7.5"

View File

@@ -16,7 +16,7 @@ dotenvy = "0.15.7"
prost = "0.13.5"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.5", features = ["chrono", "postgres", "runtime-tokio", "runtime-tokio-native-tls", "time", "uuid"] }
sqlx = { version = "0.8.5", features = ["chrono", "postgres", "runtime-tokio", "runtime-tokio-native-tls", "rust_decimal", "time", "uuid"] }
tokio = { version = "1.44.2", features = ["full", "macros"] }
tonic = "0.13.0"
tonic-reflection = "0.13.0"
@@ -33,6 +33,8 @@ validator = { version = "0.20.0", features = ["derive"] }
uuid = { version = "1.16.0", features = ["serde", "v4"] }
jsonwebtoken = "9.3.1"
rust-stemmers = "1.2.0"
rust_decimal = "1.37.2"
rust_decimal_macros = "1.37.1"
[lib]
name = "server"

View File

@@ -8,6 +8,8 @@ use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataR
use std::collections::HashMap;
use std::sync::Arc;
use prost_types::value::Kind;
use rust_decimal::Decimal;
use std::str::FromStr;
use crate::steel::server::execution::{self, Value};
use crate::steel::server::functions::SteelContext;
@@ -24,7 +26,6 @@ pub async fn post_table_data(
let profile_name = request.profile_name;
let table_name = request.table_name;
// Lookup profile
let schema = sqlx::query!(
"SELECT id FROM schemas WHERE name = $1",
profile_name
@@ -35,7 +36,6 @@ pub async fn post_table_data(
let schema_id = schema.ok_or_else(|| Status::not_found("Profile not found"))?.id;
// Lookup table_definition
let table_def = sqlx::query!(
r#"SELECT id, columns FROM table_definitions
WHERE schema_id = $1 AND table_name = $2"#,
@@ -48,7 +48,6 @@ pub async fn post_table_data(
let table_def = table_def.ok_or_else(|| Status::not_found("Table not found"))?;
// Parse columns from JSON
let columns_json: Vec<String> = serde_json::from_value(table_def.columns.clone())
.map_err(|e| Status::internal(format!("Column parsing error: {}", e)))?;
@@ -63,7 +62,6 @@ pub async fn post_table_data(
columns.push((name, sql_type));
}
// Get all foreign key columns for this table
let fk_columns = sqlx::query!(
r#"SELECT ltd.table_name
FROM table_definition_links tdl
@@ -75,17 +73,13 @@ pub async fn post_table_data(
.await
.map_err(|e| Status::internal(format!("Foreign key lookup error: {}", e)))?;
// Build system columns with foreign keys
let mut system_columns = vec!["deleted".to_string()];
for fk in fk_columns {
let base_name = fk.table_name.split_once('_').map_or(fk.table_name.as_str(), |(_, rest)| rest);
system_columns.push(format!("{}_id", base_name));
system_columns.push(format!("{}_id", fk.table_name));
}
// Convert to HashSet for faster lookups
let system_columns_set: std::collections::HashSet<_> = system_columns.iter().map(|s| s.as_str()).collect();
// Validate all data columns
let user_columns: Vec<&String> = columns.iter().map(|(name, _)| name).collect();
for key in request.data.keys() {
if !system_columns_set.contains(key.as_str()) &&
@@ -94,17 +88,18 @@ pub async fn post_table_data(
}
}
// ========================================================================
// FIX #1: SCRIPT VALIDATION LOOP
// This loop now correctly handles JSON `null` (which becomes `None`).
// ========================================================================
let mut string_data_for_scripts = HashMap::new();
for (key, proto_value) in &request.data {
let str_val = match &proto_value.kind {
Some(Kind::StringValue(s)) => s.clone(),
Some(Kind::StringValue(s)) => {
let trimmed = s.trim();
if trimmed.is_empty() {
continue;
}
trimmed.to_string()
},
Some(Kind::NumberValue(n)) => n.to_string(),
Some(Kind::BoolValue(b)) => b.to_string(),
// This now correctly skips both protobuf `NULL` and JSON `null`.
Some(Kind::NullValue(_)) | None => continue,
Some(Kind::StructValue(_)) | Some(Kind::ListValue(_)) => {
return Err(Status::invalid_argument(format!("Unsupported type for script validation in column '{}'", key)));
@@ -113,7 +108,6 @@ pub async fn post_table_data(
string_data_for_scripts.insert(key.clone(), str_val);
}
// Validate Steel scripts
let scripts = sqlx::query!(
"SELECT target_column, script FROM table_scripts WHERE table_definitions_id = $1",
table_def.id
@@ -163,17 +157,11 @@ pub async fn post_table_data(
}
}
// Prepare SQL parameters
let mut params = PgArguments::default();
let mut columns_list = Vec::new();
let mut placeholders = Vec::new();
let mut param_idx = 1;
// ========================================================================
// FIX #2: DATABASE INSERTION LOOP
// This loop now correctly handles JSON `null` (which becomes `None`)
// without crashing and correctly inserts a SQL NULL.
// ========================================================================
for (col, proto_value) in request.data {
let sql_type = if system_columns_set.contains(col.as_str()) {
match col.as_str() {
@@ -188,67 +176,96 @@ pub async fn post_table_data(
.ok_or_else(|| Status::invalid_argument(format!("Column not found: {}", col)))?
};
// Check for `None` (from JSON null) or `Some(NullValue)` first.
let kind = match &proto_value.kind {
None | Some(Kind::NullValue(_)) => {
// It's a null value. Add the correct SQL NULL type and continue.
match sql_type {
"BOOLEAN" => params.add(None::<bool>),
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => params.add(None::<String>),
"TEXT" => params.add(None::<String>),
"TIMESTAMPTZ" => params.add(None::<DateTime<Utc>>),
"BIGINT" => params.add(None::<i64>),
s if s.starts_with("NUMERIC") => params.add(None::<Decimal>),
_ => return Err(Status::invalid_argument(format!("Unsupported type for null value: {}", sql_type))),
}.map_err(|e| Status::internal(format!("Failed to add null parameter for {}: {}", col, e)))?;
columns_list.push(format!("\"{}\"", col));
placeholders.push(format!("${}", param_idx));
param_idx += 1;
continue; // Skip to the next column in the loop
continue;
}
// If it's not null, just pass the inner `Kind` through.
Some(k) => k,
};
// From here, we know `kind` is not a null type.
match sql_type {
"TEXT" | "VARCHAR(15)" | "VARCHAR(255)" => {
if let Kind::StringValue(value) = kind {
if let Some(max_len) = sql_type.strip_prefix("VARCHAR(").and_then(|s| s.strip_suffix(')')).and_then(|s| s.parse::<usize>().ok()) {
if value.len() > max_len {
return Err(Status::internal(format!("Value too long for {}", col)));
}
if sql_type == "TEXT" {
if let Kind::StringValue(value) = kind {
let trimmed_value = value.trim();
if trimmed_value.is_empty() {
params.add(None::<String>).map_err(|e| Status::internal(format!("Failed to add null parameter for {}: {}", col, e)))?;
} else {
if col == "telefon" && trimmed_value.len() > 15 {
return Err(Status::internal(format!("Value too long for {}", col)));
}
params.add(value).map_err(|e| Status::invalid_argument(format!("Failed to add text parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected string for column '{}'", col)));
params.add(trimmed_value).map_err(|e| Status::invalid_argument(format!("Failed to add text parameter for {}: {}", col, e)))?;
}
},
"BOOLEAN" => {
if let Kind::BoolValue(val) = kind {
params.add(val).map_err(|e| Status::invalid_argument(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected boolean for column '{}'", col)));
} else {
return Err(Status::invalid_argument(format!("Expected string for column '{}'", col)));
}
} else if sql_type == "BOOLEAN" {
if let Kind::BoolValue(val) = kind {
params.add(val).map_err(|e| Status::invalid_argument(format!("Failed to add boolean parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected boolean for column '{}'", col)));
}
} else if sql_type == "TIMESTAMPTZ" {
if let Kind::StringValue(value) = kind {
let dt = DateTime::parse_from_rfc3339(value).map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?;
params.add(dt.with_timezone(&Utc)).map_err(|e| Status::invalid_argument(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected ISO 8601 string for column '{}'", col)));
}
} else if sql_type == "BIGINT" {
if let Kind::NumberValue(val) = kind {
if val.fract() != 0.0 {
return Err(Status::invalid_argument(format!("Expected integer for column '{}', but got a float", col)));
}
},
"TIMESTAMPTZ" => {
if let Kind::StringValue(value) = kind {
let dt = DateTime::parse_from_rfc3339(value).map_err(|_| Status::invalid_argument(format!("Invalid timestamp for {}", col)))?;
params.add(dt.with_timezone(&Utc)).map_err(|e| Status::invalid_argument(format!("Failed to add timestamp parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected ISO 8601 string for column '{}'", col)));
}
},
"BIGINT" => {
if let Kind::NumberValue(val) = kind {
if val.fract() != 0.0 {
return Err(Status::invalid_argument(format!("Expected integer for column '{}', but got a float", col)));
params.add(*val as i64).map_err(|e| Status::invalid_argument(format!("Failed to add integer parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected number for column '{}'", col)));
}
} else if sql_type.starts_with("NUMERIC") {
// MODIFIED: This block is now stricter.
let decimal_val = match kind {
Kind::StringValue(s) => {
let trimmed = s.trim();
if trimmed.is_empty() {
None // Treat empty string as NULL
} else {
// This is the only valid path: parse from a string.
Some(Decimal::from_str(trimmed).map_err(|_| {
Status::invalid_argument(format!(
"Invalid decimal string format for column '{}': {}",
col, s
))
})?)
}
params.add(*val as i64).map_err(|e| Status::invalid_argument(format!("Failed to add integer parameter for {}: {}", col, e)))?;
} else {
return Err(Status::invalid_argument(format!("Expected number for column '{}'", col)));
}
},
_ => return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type))),
// CATCH-ALL: Reject NumberValue, BoolValue, etc. for NUMERIC fields.
_ => {
return Err(Status::invalid_argument(format!(
"Expected a string representation for decimal column '{}', but received a different type.",
col
)));
}
};
params.add(decimal_val).map_err(|e| {
Status::invalid_argument(format!(
"Failed to add decimal parameter for {}: {}",
col, e
))
})?;
} else {
return Err(Status::invalid_argument(format!("Unsupported type {}", sql_type)));
}
columns_list.push(format!("\"{}\"", col));
@@ -260,7 +277,6 @@ pub async fn post_table_data(
return Err(Status::invalid_argument("No valid columns to insert"));
}
// Qualify table name with schema
let qualified_table = crate::shared::schema_qualifier::qualify_table_name_for_data(
db_pool,
&profile_name,
@@ -283,6 +299,12 @@ pub async fn post_table_data(
Ok(id) => id,
Err(e) => {
if let Some(db_err) = e.as_database_error() {
if db_err.code() == Some(std::borrow::Cow::Borrowed("22P02")) ||
db_err.code() == Some(std::borrow::Cow::Borrowed("22003")) {
return Err(Status::invalid_argument(format!(
"Numeric field overflow or invalid format. Check precision and scale. Details: {}", db_err.message()
)));
}
if db_err.code() == Some(std::borrow::Cow::Borrowed("42P01")) {
return Err(Status::internal(format!(
"Table '{}' is defined but does not physically exist in the database as {}",

View File

@@ -5,6 +5,7 @@ use std::collections::HashMap;
use prost_types::Value;
use prost_types::value::Kind;
use common::proto::multieko2::tables_data::{PostTableDataRequest, PostTableDataResponse};
use common::proto::multieko2::table_definition::TableLink;
use common::proto::multieko2::table_definition::{
PostTableDefinitionRequest, ColumnDefinition as TableColumnDefinition
};
@@ -18,10 +19,11 @@ use server::indexer::IndexCommand;
use sqlx::Row;
use rand::distr::Alphanumeric;
use rand::Rng;
use rust_decimal::prelude::FromPrimitive;
// Helper function to generate unique identifiers for test isolation
fn generate_unique_id() -> String {
rand::thread_rng()
rand::rng()
.sample_iter(&Alphanumeric)
.take(8)
.map(char::from)
@@ -311,21 +313,6 @@ async fn test_create_table_data_empty_optional_fields(
assert!(telefon.is_none());
}
#[rstest]
#[tokio::test]
async fn test_create_table_data_invalid_firma(
#[future] test_context: TestContext,
valid_request: HashMap<String, String>,
) {
let context = test_context.await;
let mut request = valid_request;
request.insert("firma".into(), " ".into());
let result = post_table_data(&context.pool, create_table_request(&context, request), &context.indexer_tx).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument);
}
#[rstest]
#[tokio::test]
async fn test_create_table_data_minimal_request(
@@ -392,21 +379,6 @@ async fn test_create_table_data_database_error(
assert_eq!(result.unwrap_err().code(), tonic::Code::Internal);
}
#[rstest]
#[tokio::test]
async fn test_create_table_data_empty_firma(
#[future] test_context: TestContext,
minimal_request: HashMap<String, String>,
) {
let context = test_context.await;
let mut request = minimal_request;
request.insert("firma".into(), "".into());
let result = post_table_data(&context.pool, create_table_request(&context, request), &context.indexer_tx).await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().code(), tonic::Code::InvalidArgument);
}
#[rstest]
#[tokio::test]
async fn test_create_table_data_optional_fields_null_vs_empty(
@@ -504,3 +476,7 @@ async fn test_create_table_data_with_null_values(
assert!(telefon.is_none());
assert!(ulica.is_none());
}
include!("post_table_data_test2.rs");
include!("post_table_data_test3.rs");
include!("post_table_data_test4.rs");

View File

@@ -0,0 +1,484 @@
// tests/tables_data/handlers/post_table_data_test2.rs
// ========= Additional helper functions for test2 =========
async fn create_test_indexer_channel() -> mpsc::Sender<IndexCommand> {
let (tx, mut rx) = mpsc::channel(100);
// Spawn a task to consume indexer messages to prevent blocking
tokio::spawn(async move {
while let Some(_) = rx.recv().await {
// Just consume the messages
}
});
tx
}
// ========= Extended Data Type Validation Tests =========
#[rstest]
#[tokio::test]
async fn test_boolean_system_column_validation(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// Test setting the deleted flag with string (should fail)
let mut data = HashMap::new();
data.insert("firma".into(), "System Test Company".to_string());
data.insert("deleted".into(), "true".to_string()); // String instead of boolean
let result = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Expected boolean for column 'deleted'"));
}
}
// ========= String Processing and Edge Cases =========
#[rstest]
#[tokio::test]
async fn test_unicode_special_characters_comprehensive(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let special_strings = vec![
"José María González", // Accented characters
"Москва", // Cyrillic
"北京市", // Chinese
"🚀 Tech Company 🌟", // Emoji
"Line\nBreak\tTab", // Control characters
"Quote\"Test'Apostrophe", // Quotes
"SQL'; DROP TABLE test; --", // SQL injection attempt
"Price: $1,000.50 (50% off!)", // Special symbols
];
for (i, test_string) in special_strings.into_iter().enumerate() {
let mut data = HashMap::new();
data.insert("firma".into(), test_string.to_string());
data.insert("kz".into(), format!("TEST{}", i));
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
assert!(response.success, "Failed for string: '{}'", test_string);
// Verify the data was stored correctly
let query = format!(
r#"SELECT firma FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let stored_firma: Option<String> = sqlx::query_scalar::<_, Option<String>>(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
assert_eq!(stored_firma.unwrap(), test_string.trim());
}
}
#[rstest]
#[tokio::test]
async fn test_field_length_boundaries(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// Test telefon field length validation (should reject >15 chars)
let length_test_cases = vec![
("1234567890123456", true), // 16 chars - should fail
("123456789012345", false), // 15 chars - should pass
("", false), // Empty - should pass (becomes NULL)
("1", false), // Single char - should pass
];
for (test_string, should_fail) in length_test_cases {
let mut data = HashMap::new();
data.insert("firma".into(), "Length Test Company".to_string());
data.insert("telefon".into(), test_string.to_string());
let result = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await;
if should_fail {
assert!(result.is_err(), "Should fail for telefon length: {}", test_string.len());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::Internal);
assert!(err.message().contains("Value too long for telefon"));
}
} else {
assert!(result.is_ok(), "Should succeed for telefon length: {}", test_string.len());
}
}
}
// ========= NULL vs Empty String Handling =========
#[rstest]
#[tokio::test]
async fn test_empty_strings_become_null(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let test_cases = vec![
("", "empty_string"),
(" ", "whitespace_only"),
("\t\n", "tabs_newlines"),
(" Normal Value ", "padded_value"),
];
for (input, test_name) in test_cases {
let mut data = HashMap::new();
data.insert("firma".into(), format!("Test {}", test_name));
data.insert("ulica".into(), input.to_string());
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
assert!(response.success, "Failed for test case: {}", test_name);
// Check what was actually stored
let query = format!(
r#"SELECT ulica FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let stored_ulica: Option<String> = sqlx::query_scalar::<_, Option<String>>(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
let trimmed = input.trim();
if trimmed.is_empty() {
assert!(stored_ulica.is_none(), "Empty/whitespace string should be NULL for: {}", test_name);
} else {
assert_eq!(stored_ulica.unwrap(), trimmed, "String should be trimmed for: {}", test_name);
}
}
}
// ========= Concurrent Operations Testing =========
#[rstest]
#[tokio::test]
async fn test_concurrent_inserts_same_table(#[future] test_context: TestContext) {
let context = test_context.await;
use futures::future::join_all;
// Create multiple concurrent insert operations
let futures = (0..10).map(|i| {
let context = context.clone();
async move {
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("firma".into(), format!("Concurrent Company {}", i));
data.insert("kz".into(), format!("CONC{}", i));
data.insert("mesto".into(), format!("City {}", i));
post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await
}
});
let results = join_all(futures).await;
// All inserts should succeed
for (i, result) in results.into_iter().enumerate() {
assert!(result.is_ok(), "Concurrent insert {} should succeed", i);
}
// Verify all records were inserted
let query = format!(
r#"SELECT COUNT(*) FROM "{}"."{}" WHERE firma LIKE 'Concurrent Company%'"#,
context.profile_name, context.table_name
);
let count: i64 = sqlx::query_scalar::<_, i64>(&query)
.fetch_one(&context.pool)
.await
.unwrap();
assert_eq!(count, 10);
}
// ========= Error Scenarios =========
#[rstest]
#[tokio::test]
async fn test_invalid_column_names(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("firma".into(), "Valid Company".to_string());
data.insert("nonexistent_column".into(), "Invalid".to_string());
let result = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Invalid column: nonexistent_column"));
}
}
#[rstest]
#[tokio::test]
async fn test_empty_data_request(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// Try to insert completely empty data
let data = HashMap::new();
let result = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("No valid columns to insert"));
}
}
// ========= Performance and Stress Testing =========
#[rstest]
#[tokio::test]
async fn test_rapid_sequential_inserts(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let start_time = std::time::Instant::now();
// Perform rapid sequential inserts
for i in 0..50 {
let mut data = HashMap::new();
data.insert("firma".into(), format!("Rapid Company {}", i));
data.insert("kz".into(), format!("RAP{}", i));
data.insert("telefon".into(), format!("+421{:09}", i));
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
assert!(response.success, "Rapid insert {} should succeed", i);
}
let duration = start_time.elapsed();
println!("50 rapid inserts took: {:?}", duration);
// Verify all records were inserted
let query = format!(
r#"SELECT COUNT(*) FROM "{}"."{}" WHERE firma LIKE 'Rapid Company%'"#,
context.profile_name, context.table_name
);
let count: i64 = sqlx::query_scalar::<_, i64>(&query)
.fetch_one(&context.pool)
.await
.unwrap();
assert_eq!(count, 50);
}
// ========= SQL Injection Protection =========
#[rstest]
#[tokio::test]
async fn test_sql_injection_protection(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let injection_attempts = vec![
"'; DROP TABLE users; --",
"1; DELETE FROM adresar; --",
"admin'; UPDATE adresar SET firma='hacked' WHERE '1'='1",
"' OR '1'='1",
"'; INSERT INTO adresar (firma) VALUES ('injected'); --",
"Robert'); DROP TABLE students; --", // Classic Bobby Tables
];
let injection_count = injection_attempts.len();
for (i, injection) in injection_attempts.into_iter().enumerate() {
let mut data = HashMap::new();
data.insert("firma".into(), injection.to_string());
data.insert("kz".into(), format!("INJ{}", i));
// These should all succeed because values are properly parameterized
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
assert!(response.success, "SQL injection attempt should be safely handled: {}", injection);
// Verify the injection attempt was stored as literal text
let query = format!(
r#"SELECT firma FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let stored_firma: String = sqlx::query_scalar::<_, String>(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
assert_eq!(stored_firma, injection);
}
// Verify the table still exists and has the expected number of records
let query = format!(
r#"SELECT COUNT(*) FROM "{}"."{}" WHERE kz LIKE 'INJ%'"#,
context.profile_name, context.table_name
);
let count: i64 = sqlx::query_scalar::<_, i64>(&query)
.fetch_one(&context.pool)
.await
.unwrap();
assert_eq!(count, injection_count as i64);
}
// ========= Large Data Testing =========
#[rstest]
#[tokio::test]
async fn test_large_text_fields(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// Test various large text sizes (except telefon which has length limits)
let sizes = vec![1000, 5000, 10000];
for size in sizes {
let large_text = "A".repeat(size);
let mut data = HashMap::new();
data.insert("firma".into(), large_text.clone());
data.insert("ulica".into(), format!("Street with {} chars", size));
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
assert!(response.success, "Failed for size: {}", size);
// Verify the large text was stored correctly
let query = format!(
r#"SELECT firma FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let stored_firma: String = sqlx::query_scalar::<_, String>(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
assert_eq!(stored_firma.len(), size);
assert_eq!(stored_firma, large_text);
}
}
// ========= Indexer Integration Testing =========
#[rstest]
#[tokio::test]
async fn test_indexer_command_generation(#[future] test_context: TestContext) {
let context = test_context.await;
let (indexer_tx, mut indexer_rx) = mpsc::channel(100);
let mut data = HashMap::new();
data.insert("firma".into(), "Indexer Test Company".to_string());
data.insert("kz".into(), "IDX123".to_string());
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
assert!(response.success);
// Check that indexer command was sent
let indexer_command = tokio::time::timeout(
tokio::time::Duration::from_millis(100),
indexer_rx.recv()
).await;
assert!(indexer_command.is_ok());
let command = indexer_command.unwrap().unwrap();
match command {
IndexCommand::AddOrUpdate(data) => {
assert_eq!(data.table_name, context.table_name);
assert_eq!(data.row_id, response.inserted_id);
},
IndexCommand::Delete(_) => panic!("Expected AddOrUpdate command, got Delete"),
}
}
#[rstest]
#[tokio::test]
async fn test_indexer_failure_resilience(#[future] test_context: TestContext) {
let context = test_context.await;
// Create a closed channel to simulate indexer failure
let (indexer_tx, indexer_rx) = mpsc::channel(1);
drop(indexer_rx); // Close receiver to simulate failure
let mut data = HashMap::new();
data.insert("firma".into(), "Resilience Test Company".to_string());
data.insert("kz".into(), "RES123".to_string());
// Insert should still succeed even if indexer fails
let response = post_table_data(&context.pool, create_table_request(&context, data), &indexer_tx).await.unwrap();
assert!(response.success);
// Verify data was inserted despite indexer failure
let query = format!(
r#"SELECT COUNT(*) FROM "{}"."{}" WHERE kz = 'RES123'"#,
context.profile_name, context.table_name
);
let count: i64 = sqlx::query_scalar::<_, i64>(&query)
.fetch_one(&context.pool)
.await
.unwrap();
assert_eq!(count, 1);
}
// ========= Profile and Table Validation =========
#[rstest]
#[tokio::test]
async fn test_nonexistent_profile_error(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("firma".into(), "Test Company".to_string());
let invalid_request = PostTableDataRequest {
profile_name: "nonexistent_profile".into(),
table_name: context.table_name.clone(),
data: convert_to_proto_values(data),
};
let result = post_table_data(&context.pool, invalid_request, &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::NotFound);
assert!(err.message().contains("Profile not found"));
}
}
#[rstest]
#[tokio::test]
async fn test_nonexistent_table_error(#[future] test_context: TestContext) {
let context = test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("firma".into(), "Test Company".to_string());
let invalid_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: "nonexistent_table".into(),
data: convert_to_proto_values(data),
};
let result = post_table_data(&context.pool, invalid_request, &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::NotFound);
assert!(err.message().contains("Table not found"));
}
}

View File

@@ -0,0 +1,845 @@
// tests/tables_data/handlers/post_table_data_test3.rs
// ========================================================================
// ADDITIONAL HELPER FUNCTIONS FOR TEST3
// ========================================================================
// Helper to create different Value types
fn create_string_value(s: &str) -> Value {
Value { kind: Some(Kind::StringValue(s.to_string())) }
}
fn create_number_value(n: f64) -> Value {
Value { kind: Some(Kind::NumberValue(n)) }
}
fn create_bool_value(b: bool) -> Value {
Value { kind: Some(Kind::BoolValue(b)) }
}
fn create_null_value() -> Value {
Value { kind: Some(Kind::NullValue(0)) }
}
// ========================================================================
// FIXTURES AND CONTEXT SETUP FOR ADVANCED TESTS
// ========================================================================
#[derive(Clone)]
struct DataTypeTestContext {
pool: PgPool,
profile_name: String,
table_name: String,
}
#[derive(Clone)]
struct ForeignKeyTestContext {
pool: PgPool,
profile_name: String,
category_table: String,
product_table: String,
order_table: String,
}
// Create a table with various data types for comprehensive testing
async fn create_data_type_test_table(pool: &PgPool, table_name: &str, profile_name: &str) -> Result<(), tonic::Status> {
let table_def_request = PostTableDefinitionRequest {
profile_name: profile_name.into(),
table_name: table_name.into(),
columns: vec![
TableColumnDefinition { name: "my_text".into(), field_type: "text".into() },
TableColumnDefinition { name: "my_bool".into(), field_type: "boolean".into() },
TableColumnDefinition { name: "my_timestamp".into(), field_type: "timestamp".into() },
TableColumnDefinition { name: "my_bigint".into(), field_type: "integer".into() },
TableColumnDefinition { name: "my_money".into(), field_type: "decimal(19,4)".into() },
TableColumnDefinition { name: "my_date".into(), field_type: "date".into() },
TableColumnDefinition { name: "my_decimal".into(), field_type: "decimal(10,2)".into() },
],
indexes: vec![],
links: vec![],
};
post_table_definition(pool, table_def_request).await?;
Ok(())
}
// Create foreign key test tables (category -> product -> order)
async fn create_foreign_key_test_tables(pool: &PgPool, profile_name: &str, category_table: &str, product_table: &str, order_table: &str) -> Result<(), tonic::Status> {
// Create category table first (no dependencies)
let category_def = PostTableDefinitionRequest {
profile_name: profile_name.into(),
table_name: category_table.into(),
columns: vec![
TableColumnDefinition { name: "name".into(), field_type: "text".into() },
TableColumnDefinition { name: "description".into(), field_type: "text".into() },
],
indexes: vec![],
links: vec![],
};
post_table_definition(pool, category_def).await?;
// Create product table with required link to category
let product_def = PostTableDefinitionRequest {
profile_name: profile_name.into(),
table_name: product_table.into(),
columns: vec![
TableColumnDefinition { name: "name".into(), field_type: "text".into() },
TableColumnDefinition { name: "price".into(), field_type: "decimal(10,2)".into() },
],
indexes: vec![],
links: vec![
TableLink { linked_table_name: category_table.into(), required: true },
],
};
post_table_definition(pool, product_def).await?;
// Create order table with required link to product and optional link to category
let order_def = PostTableDefinitionRequest {
profile_name: profile_name.into(),
table_name: order_table.into(),
columns: vec![
TableColumnDefinition { name: "quantity".into(), field_type: "integer".into() },
TableColumnDefinition { name: "notes".into(), field_type: "text".into() },
],
indexes: vec![],
links: vec![
TableLink { linked_table_name: product_table.into(), required: true },
TableLink { linked_table_name: category_table.into(), required: false }, // Optional link
],
};
post_table_definition(pool, order_def).await?;
Ok(())
}
#[fixture]
async fn data_type_test_context() -> DataTypeTestContext {
let pool = setup_test_db().await;
let unique_id = generate_unique_id();
let profile_name = format!("dtype_profile_{}", unique_id);
let table_name = format!("dtype_table_{}", unique_id);
create_data_type_test_table(&pool, &table_name, &profile_name).await
.expect("Failed to create data type test table");
DataTypeTestContext { pool, profile_name, table_name }
}
#[fixture]
async fn foreign_key_test_context() -> ForeignKeyTestContext {
let pool = setup_test_db().await;
let unique_id = generate_unique_id();
let profile_name = format!("fk_profile_{}", unique_id);
let category_table = format!("category_{}", unique_id);
let product_table = format!("product_{}", unique_id);
let order_table = format!("order_{}", unique_id);
create_foreign_key_test_tables(&pool, &profile_name, &category_table, &product_table, &order_table).await
.expect("Failed to create foreign key test tables");
ForeignKeyTestContext { pool, profile_name, category_table, product_table, order_table }
}
// ========================================================================
// DATA TYPE VALIDATION TESTS
// ========================================================================
#[rstest]
#[tokio::test]
async fn test_correct_data_types_success(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Test String"));
data.insert("my_bool".into(), create_bool_value(true));
data.insert("my_timestamp".into(), create_string_value("2024-01-15T10:30:00Z"));
data.insert("my_bigint".into(), create_number_value(42.0));
data.insert("my_money".into(), create_string_value("123.45")); // Use string for decimal
data.insert("my_decimal".into(), create_string_value("999.99")); // Use string for decimal
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let response = post_table_data(&context.pool, request, &indexer_tx).await.unwrap();
assert!(response.success);
assert!(response.inserted_id > 0);
// Verify data was stored correctly
let query = format!(
r#"SELECT my_text, my_bool, my_timestamp, my_bigint FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let row = sqlx::query(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
let stored_text: String = row.get("my_text");
let stored_bool: bool = row.get("my_bool");
let stored_bigint: i64 = row.get("my_bigint");
assert_eq!(stored_text, "Test String");
assert_eq!(stored_bool, true);
assert_eq!(stored_bigint, 42);
}
#[rstest]
#[tokio::test]
async fn test_type_mismatch_string_for_boolean(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Required field"));
data.insert("my_bool".into(), create_string_value("true")); // String instead of boolean
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Expected boolean for column 'my_bool'"));
}
}
#[rstest]
#[tokio::test]
async fn test_type_mismatch_string_for_integer(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Required field"));
data.insert("my_bigint".into(), create_string_value("42")); // String instead of number
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Expected number for column 'my_bigint'"));
}
}
#[rstest]
#[tokio::test]
async fn test_type_mismatch_number_for_boolean(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Required field"));
data.insert("my_bool".into(), create_number_value(1.0)); // Number instead of boolean
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Expected boolean for column 'my_bool'"));
}
}
#[rstest]
#[tokio::test]
async fn test_decimal_requires_string_not_number(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Required field"));
data.insert("my_money".into(), create_number_value(123.45)); // Number instead of string for decimal
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Expected a string representation for decimal column 'my_money'"));
}
}
#[rstest]
#[tokio::test]
async fn test_invalid_timestamp_format(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Required field"));
data.insert("my_timestamp".into(), create_string_value("not-a-date"));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Invalid timestamp for my_timestamp"));
}
}
#[rstest]
#[tokio::test]
async fn test_float_for_integer_field(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Required field"));
data.insert("my_bigint".into(), create_number_value(123.45)); // Float for integer field
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_err());
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Expected integer for column 'my_bigint', but got a float"));
}
}
#[rstest]
#[tokio::test]
async fn test_valid_timestamp_formats(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let valid_timestamps = vec![
"2024-01-15T10:30:00Z",
"2024-01-15T10:30:00+00:00",
"2024-01-15T10:30:00.123Z",
"2024-12-31T23:59:59Z",
"1970-01-01T00:00:00Z", // Unix epoch
];
for (i, timestamp) in valid_timestamps.into_iter().enumerate() {
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value(&format!("Test {}", i)));
data.insert("my_timestamp".into(), create_string_value(timestamp));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_ok(), "Failed for timestamp: {}", timestamp);
}
}
#[rstest]
#[tokio::test]
async fn test_boundary_integer_values(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let boundary_values = vec![
0.0,
1.0,
-1.0,
9223372036854775807.0, // i64::MAX
-9223372036854775808.0, // i64::MIN
];
for (i, value) in boundary_values.into_iter().enumerate() {
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value(&format!("Boundary test {}", i)));
data.insert("my_bigint".into(), create_number_value(value));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_ok(), "Failed for boundary value: {}", value);
}
}
#[rstest]
#[tokio::test]
async fn test_null_values_for_all_types(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Required for test"));
data.insert("my_bool".into(), create_null_value());
data.insert("my_timestamp".into(), create_null_value());
data.insert("my_bigint".into(), create_null_value());
data.insert("my_money".into(), create_null_value());
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let response = post_table_data(&context.pool, request, &indexer_tx).await.unwrap();
assert!(response.success);
// Verify nulls were stored correctly
let query = format!(
r#"SELECT my_bool, my_timestamp, my_bigint FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let row = sqlx::query(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
let stored_bool: Option<bool> = row.get("my_bool");
let stored_timestamp: Option<chrono::DateTime<Utc>> = row.get("my_timestamp");
let stored_bigint: Option<i64> = row.get("my_bigint");
assert!(stored_bool.is_none());
assert!(stored_timestamp.is_none());
assert!(stored_bigint.is_none());
}
// ========================================================================
// FOREIGN KEY CONSTRAINT TESTS
// ========================================================================
#[rstest]
#[tokio::test]
async fn test_insert_with_valid_foreign_key(#[future] foreign_key_test_context: ForeignKeyTestContext) {
let context = foreign_key_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// First, insert a category
let mut category_data = HashMap::new();
category_data.insert("name".into(), create_string_value("Electronics"));
category_data.insert("description".into(), create_string_value("Electronic devices"));
let category_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.category_table.clone(),
data: category_data,
};
let category_response = post_table_data(&context.pool, category_request, &indexer_tx).await.unwrap();
let category_id = category_response.inserted_id;
// Now insert a product with the valid category_id
let mut product_data = HashMap::new();
product_data.insert("name".into(), create_string_value("Laptop"));
product_data.insert("price".into(), create_string_value("999.99")); // Use string for decimal
product_data.insert(format!("{}_id", context.category_table), create_number_value(category_id as f64));
let product_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.product_table.clone(),
data: product_data,
};
let result = post_table_data(&context.pool, product_request, &indexer_tx).await;
assert!(result.is_ok(), "Insert with valid foreign key should succeed");
}
#[rstest]
#[tokio::test]
async fn test_insert_with_nonexistent_foreign_key(#[future] foreign_key_test_context: ForeignKeyTestContext) {
let context = foreign_key_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// Try to insert product with non-existent category_id
let mut product_data = HashMap::new();
product_data.insert("name".into(), create_string_value("Laptop"));
product_data.insert("price".into(), create_string_value("999.99"));
product_data.insert(format!("{}_id", context.category_table), create_number_value(99999.0)); // Non-existent ID
let product_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.product_table.clone(),
data: product_data,
};
let result = post_table_data(&context.pool, product_request, &indexer_tx).await;
assert!(result.is_err(), "Insert with non-existent foreign key should fail");
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::Internal);
assert!(err.message().contains("Insert failed"));
}
}
#[rstest]
#[tokio::test]
async fn test_insert_with_null_required_foreign_key(#[future] foreign_key_test_context: ForeignKeyTestContext) {
let context = foreign_key_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// Try to insert product without category_id (required foreign key)
let mut product_data = HashMap::new();
product_data.insert("name".into(), create_string_value("Laptop"));
product_data.insert("price".into(), create_string_value("999.99"));
// Intentionally omit category_id
let product_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.product_table.clone(),
data: product_data,
};
let result = post_table_data(&context.pool, product_request, &indexer_tx).await;
assert!(result.is_err(), "Insert without required foreign key should fail");
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::Internal);
assert!(err.message().contains("Insert failed"));
}
}
#[rstest]
#[tokio::test]
async fn test_insert_with_null_optional_foreign_key(#[future] foreign_key_test_context: ForeignKeyTestContext) {
let context = foreign_key_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// First create a category and product for the required FK
let mut category_data = HashMap::new();
category_data.insert("name".into(), create_string_value("Electronics"));
let category_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.category_table.clone(),
data: category_data,
};
let category_response = post_table_data(&context.pool, category_request, &indexer_tx).await.unwrap();
let mut product_data = HashMap::new();
product_data.insert("name".into(), create_string_value("Laptop"));
product_data.insert("price".into(), create_string_value("999.99"));
product_data.insert(format!("{}_id", context.category_table), create_number_value(category_response.inserted_id as f64));
let product_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.product_table.clone(),
data: product_data,
};
let product_response = post_table_data(&context.pool, product_request, &indexer_tx).await.unwrap();
// Now insert order with required product_id but without optional category_id
let mut order_data = HashMap::new();
order_data.insert("quantity".into(), create_number_value(2.0));
order_data.insert("notes".into(), create_string_value("Test order"));
order_data.insert(format!("{}_id", context.product_table), create_number_value(product_response.inserted_id as f64));
// Intentionally omit optional category_id
let order_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.order_table.clone(),
data: order_data,
};
let result = post_table_data(&context.pool, order_request, &indexer_tx).await;
assert!(result.is_ok(), "Insert with NULL optional foreign key should succeed");
}
#[rstest]
#[tokio::test]
async fn test_multiple_foreign_keys_scenario(#[future] foreign_key_test_context: ForeignKeyTestContext) {
let context = foreign_key_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
// Create category
let mut category_data = HashMap::new();
category_data.insert("name".into(), create_string_value("Books"));
let category_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.category_table.clone(),
data: category_data,
};
let category_response = post_table_data(&context.pool, category_request, &indexer_tx).await.unwrap();
// Create product
let mut product_data = HashMap::new();
product_data.insert("name".into(), create_string_value("Programming Book"));
product_data.insert("price".into(), create_string_value("49.99"));
product_data.insert(format!("{}_id", context.category_table), create_number_value(category_response.inserted_id as f64));
let product_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.product_table.clone(),
data: product_data,
};
let product_response = post_table_data(&context.pool, product_request, &indexer_tx).await.unwrap();
// Create order with both foreign keys
let mut order_data = HashMap::new();
order_data.insert("quantity".into(), create_number_value(3.0));
order_data.insert("notes".into(), create_string_value("Bulk order"));
order_data.insert(format!("{}_id", context.product_table), create_number_value(product_response.inserted_id as f64));
order_data.insert(format!("{}_id", context.category_table), create_number_value(category_response.inserted_id as f64));
let order_request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.order_table.clone(),
data: order_data,
};
let result = post_table_data(&context.pool, order_request, &indexer_tx).await;
assert!(result.is_ok(), "Insert with multiple valid foreign keys should succeed");
// Verify the data was inserted correctly
let product_id_col = format!("{}_id", context.product_table);
let category_id_col = format!("{}_id", context.category_table);
let query = format!(
r#"SELECT quantity, "{}", "{}" FROM "{}"."{}" WHERE id = $1"#,
product_id_col, category_id_col, context.profile_name, context.order_table
);
let row = sqlx::query(&query)
.bind(result.unwrap().inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
let quantity: i64 = row.get("quantity");
let stored_product_id: i64 = row.get(product_id_col.as_str());
let stored_category_id: Option<i64> = row.get(category_id_col.as_str());
assert_eq!(quantity, 3);
assert_eq!(stored_product_id, product_response.inserted_id);
assert_eq!(stored_category_id.unwrap(), category_response.inserted_id);
}
// ========================================================================
// ADDITIONAL EDGE CASE TESTS
// ========================================================================
#[rstest]
#[tokio::test]
async fn test_extremely_large_decimal_numbers(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let large_decimals = vec![
"1000000000.0000",
"999999999999.99",
"-999999999999.99",
"0.0001",
];
for (i, decimal_str) in large_decimals.into_iter().enumerate() {
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value(&format!("Large decimal test {}", i)));
data.insert("my_money".into(), create_string_value(decimal_str));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_ok(), "Failed for large decimal: {}", decimal_str);
}
}
#[rstest]
#[tokio::test]
async fn test_boolean_edge_cases(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let boolean_values = vec![true, false];
for (i, bool_val) in boolean_values.into_iter().enumerate() {
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value(&format!("Boolean test {}", i)));
data.insert("my_bool".into(), create_bool_value(bool_val));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let response = post_table_data(&context.pool, request, &indexer_tx).await.unwrap();
// Verify boolean was stored correctly
let query = format!(
r#"SELECT my_bool FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let stored_bool: bool = sqlx::query_scalar::<_, bool>(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
assert_eq!(stored_bool, bool_val);
}
}
#[rstest]
#[tokio::test]
async fn test_decimal_precision_handling(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let decimal_values = vec![
"0.01",
"99.99",
"123.45",
"999.99",
"-123.45",
];
for (i, decimal_val) in decimal_values.into_iter().enumerate() {
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value(&format!("Decimal test {}", i)));
data.insert("my_decimal".into(), create_string_value(decimal_val));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
assert!(result.is_ok(), "Failed for decimal value: {}", decimal_val);
}
}
#[rstest]
#[tokio::test]
async fn test_invalid_decimal_string_formats(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let invalid_decimals = vec![
"not-a-number",
"123.45.67",
"abc123",
"",
" ",
];
for (i, invalid_decimal) in invalid_decimals.into_iter().enumerate() {
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value(&format!("Invalid decimal test {}", i)));
data.insert("my_decimal".into(), create_string_value(invalid_decimal));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &indexer_tx).await;
if invalid_decimal.trim().is_empty() {
// Empty strings should be treated as NULL and succeed
assert!(result.is_ok(), "Empty string should be treated as NULL for: {}", invalid_decimal);
} else {
// Invalid decimal strings should fail
assert!(result.is_err(), "Should fail for invalid decimal: {}", invalid_decimal);
if let Err(err) = result {
assert_eq!(err.code(), tonic::Code::InvalidArgument);
assert!(err.message().contains("Invalid decimal string format"));
}
}
}
}
#[rstest]
#[tokio::test]
async fn test_mixed_null_and_valid_data(#[future] data_type_test_context: DataTypeTestContext) {
let context = data_type_test_context.await;
let indexer_tx = create_test_indexer_channel().await;
let mut data = HashMap::new();
data.insert("my_text".into(), create_string_value("Mixed data test"));
data.insert("my_bool".into(), create_bool_value(true));
data.insert("my_timestamp".into(), create_null_value());
data.insert("my_bigint".into(), create_number_value(42.0));
data.insert("my_money".into(), create_null_value());
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let response = post_table_data(&context.pool, request, &indexer_tx).await.unwrap();
assert!(response.success);
// Verify mixed null and valid data was stored correctly
let query = format!(
r#"SELECT my_text, my_bool, my_timestamp, my_bigint, my_money FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let row = sqlx::query(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
let stored_text: String = row.get("my_text");
let stored_bool: bool = row.get("my_bool");
let stored_timestamp: Option<chrono::DateTime<Utc>> = row.get("my_timestamp");
let stored_bigint: i64 = row.get("my_bigint");
let stored_money: Option<rust_decimal::Decimal> = row.get("my_money");
assert_eq!(stored_text, "Mixed data test");
assert_eq!(stored_bool, true);
assert!(stored_timestamp.is_none());
assert_eq!(stored_bigint, 42);
assert!(stored_money.is_none());
}

View File

@@ -0,0 +1,264 @@
// tests/tables_data/handlers/post_table_data_test4.rs
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
// Helper to create a protobuf Value from a string
fn proto_string(s: &str) -> Value {
Value {
kind: Some(Kind::StringValue(s.to_string())),
}
}
// Helper to create a protobuf Value from a number
fn proto_number(n: f64) -> Value {
Value {
kind: Some(Kind::NumberValue(n)),
}
}
// Helper to create a protobuf Null Value
fn proto_null() -> Value {
Value {
kind: Some(Kind::NullValue(0)),
}
}
// Helper function to create a table with various decimal types for testing
async fn create_financial_table(
pool: &PgPool,
table_name: &str,
profile_name: &str,
) -> Result<(), tonic::Status> {
let table_def_request = PostTableDefinitionRequest {
profile_name: profile_name.into(),
table_name: table_name.into(),
columns: vec![
TableColumnDefinition {
name: "product_name".into(),
field_type: "text".into(),
},
// Standard money column
TableColumnDefinition {
name: "price".into(),
field_type: "decimal(19, 4)".into(),
},
// Column for things like exchange rates or precise factors
TableColumnDefinition {
name: "rate".into(),
field_type: "decimal(10, 5)".into(),
},
],
indexes: vec![],
links: vec![],
};
post_table_definition(pool, table_def_request).await?;
Ok(())
}
// A new test context fixture for our financial table
#[fixture]
async fn decimal_test_context() -> TestContext {
let pool = setup_test_db().await;
let unique_id = generate_unique_id();
let profile_name = format!("decimal_profile_{}", unique_id);
let table_name = format!("invoices_{}", unique_id);
create_financial_table(&pool, &table_name, &profile_name)
.await
.expect("Failed to create decimal test table");
let (tx, _rx) = mpsc::channel(100);
TestContext {
pool,
profile_name,
table_name,
indexer_tx: tx,
}
}
// ========= DECIMAL/NUMERIC DATA TYPE TESTS =========
#[rstest]
#[tokio::test]
async fn test_insert_valid_decimal_string(#[future] decimal_test_context: TestContext) {
let context = decimal_test_context.await;
let mut data = HashMap::new();
data.insert("product_name".into(), proto_string("Laptop"));
data.insert("price".into(), proto_string("1499.99"));
data.insert("rate".into(), proto_string("-0.12345"));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let response = post_table_data(&context.pool, request, &context.indexer_tx)
.await
.unwrap();
assert!(response.success);
let query = format!(
r#"SELECT price, rate FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
);
let row = sqlx::query(&query)
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
let price: Decimal = row.get("price");
let rate: Decimal = row.get("rate");
assert_eq!(price, dec!(1499.99));
assert_eq!(rate, dec!(-0.12345));
}
#[rstest]
#[tokio::test]
async fn test_insert_decimal_from_number_fails(#[future] decimal_test_context: TestContext) {
let context = decimal_test_context.await;
let mut data = HashMap::new();
data.insert("product_name".into(), proto_string("Mouse"));
// THIS IS THE INVALID PART: using a number for a decimal field.
data.insert("price".into(), proto_number(75.50));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
// The operation should fail.
let result = post_table_data(&context.pool, request, &context.indexer_tx).await;
assert!(result.is_err());
// Verify the error is correct.
let status = result.unwrap_err();
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert!(status
.message()
.contains("Expected a string representation for decimal column 'price'"));
}
#[rstest]
#[tokio::test]
async fn test_decimal_rounding_behavior(#[future] decimal_test_context: TestContext) {
let context = decimal_test_context.await;
let mut data = HashMap::new();
data.insert("product_name".into(), proto_string("Keyboard"));
// price is NUMERIC(19, 4), so this should be rounded up by the database
data.insert("price".into(), proto_string("99.12345"));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let response = post_table_data(&context.pool, request, &context.indexer_tx)
.await
.unwrap();
assert!(response.success);
let price: Decimal = sqlx::query_scalar(&format!(
r#"SELECT price FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
))
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
// PostgreSQL rounds away from zero (0.5 rounds up)
assert_eq!(price, dec!(99.1235));
}
#[rstest]
#[tokio::test]
async fn test_insert_null_and_empty_string_for_decimal(
#[future] decimal_test_context: TestContext,
) {
let context = decimal_test_context.await;
let mut data = HashMap::new();
data.insert("product_name".into(), proto_string("Monitor"));
data.insert("price".into(), proto_string(" ")); // Empty string should be NULL
data.insert("rate".into(), proto_null()); // Explicit NULL
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let response = post_table_data(&context.pool, request, &context.indexer_tx)
.await
.unwrap();
assert!(response.success);
let row = sqlx::query(&format!(
r#"SELECT price, rate FROM "{}"."{}" WHERE id = $1"#,
context.profile_name, context.table_name
))
.bind(response.inserted_id)
.fetch_one(&context.pool)
.await
.unwrap();
let price: Option<Decimal> = row.get("price");
let rate: Option<Decimal> = row.get("rate");
assert!(price.is_none());
assert!(rate.is_none());
}
#[rstest]
#[tokio::test]
async fn test_invalid_decimal_string_fails(#[future] decimal_test_context: TestContext) {
let context = decimal_test_context.await;
let mut data = HashMap::new();
data.insert("product_name".into(), proto_string("Bad Data"));
data.insert("price".into(), proto_string("not-a-number"));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &context.indexer_tx).await;
assert!(result.is_err());
let status = result.unwrap_err();
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert!(status
.message()
.contains("Invalid decimal string format for column 'price'"));
}
#[rstest]
#[tokio::test]
async fn test_decimal_precision_overflow_fails(#[future] decimal_test_context: TestContext) {
let context = decimal_test_context.await;
let mut data = HashMap::new();
data.insert("product_name".into(), proto_string("Too Expensive"));
// rate is NUMERIC(10, 5), so it allows 5 digits before the decimal.
// 123456.1 is 6 digits before, so it should fail at the database level.
data.insert("rate".into(), proto_string("123456.1"));
let request = PostTableDataRequest {
profile_name: context.profile_name.clone(),
table_name: context.table_name.clone(),
data,
};
let result = post_table_data(&context.pool, request, &context.indexer_tx).await;
assert!(result.is_err());
let status = result.unwrap_err();
// This error comes from the database itself.
assert_eq!(status.code(), tonic::Code::InvalidArgument);
assert!(status.message().contains("Numeric field overflow"));
}