diff --git a/Cargo.lock b/Cargo.lock index e884172..09360f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,17 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom 0.2.17", - "once_cell", - "version_check", -] - [[package]] name = "ahash" version = "0.8.12" @@ -920,7 +909,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" dependencies = [ - "ahash 0.8.12", + "ahash", "ark-ff 0.5.0", "ark-poly", "ark-serialize 0.5.0", @@ -1067,7 +1056,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" dependencies = [ - "ahash 0.8.12", + "ahash", "ark-ff 0.5.0", "ark-serialize 0.5.0", "ark-std 0.5.0", @@ -1512,28 +1501,6 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" -[[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" @@ -2583,9 +2550,6 @@ 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" @@ -3279,7 +3243,7 @@ version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8" dependencies = [ - "ahash 0.8.12", + "ahash", "portable-atomic", ] @@ -3979,26 +3943,6 @@ dependencies = [ "cc", ] -[[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 = "quanta" version = "0.12.6" @@ -4305,15 +4249,6 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" -[[package]] -name = "rend" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" -dependencies = [ - "bytecheck", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -4654,35 +4589,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "rkyv" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" -dependencies = [ - "bitvec", - "bytecheck", - "bytes", - "hashbrown 0.12.3", - "ptr_meta", - "rend", - "rkyv_derive", - "seahash", - "tinyvec", - "uuid", -] - -[[package]] -name = "rkyv_derive" -version = "0.7.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "rlp" version = "0.5.2" @@ -4727,23 +4633,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" -[[package]] -name = "rust_decimal" -version = "1.40.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61f703d19852dbf87cbc513643fa81428361eb6940f1ac14fd58155d295a3eb0" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "postgres-types", - "rand 0.8.5", - "rkyv", - "serde", - "serde_json", -] - [[package]] name = "rustc-hash" version = "2.1.1" @@ -4905,12 +4794,6 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - [[package]] name = "sec1" version = "0.7.3" @@ -5240,12 +5123,6 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - [[package]] name = "similar" version = "2.7.0" @@ -5603,7 +5480,6 @@ dependencies = [ "rand 0.9.2", "regex-lite", "reqwest", - "rust_decimal", "serde", "serde_json", "serial_test", @@ -6146,16 +6022,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "uuid" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 9b6851d..2fe2934 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ clickhouse = { version = "0.14", features = ["lz4", "chrono", "rustls-tls"] } # Hex encoding hex = "0.4" -rust_decimal = { version = "1.40.0", features = ["db-tokio-postgres"] } + # Random rand = "0.9" diff --git a/src/service/mod.rs b/src/service/mod.rs index a32d20f..80b1f39 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -294,17 +294,10 @@ pub fn format_column_json(row: &tokio_postgres::Row, idx: usize) -> serde_json:: .try_get::<_, i64>(idx) .ok() .map_or(serde_json::Value::Null, |v| serde_json::Value::Number(v.into())), - "numeric" => { - // rust_decimal::Decimal panics (not errors) for values exceeding its - // 96-bit mantissa (~28 digits). Postgres NUMERIC is arbitrary precision - // (e.g. abi_uint() on uint256 = 78 digits), so catch the panic. - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - row.try_get::<_, rust_decimal::Decimal>(idx) - })) { - Ok(Ok(v)) => serde_json::Value::String(v.to_string()), - _ => serde_json::Value::Null, - } - } + "numeric" => row + .try_get::<_, PgNumeric>(idx) + .ok() + .map_or(serde_json::Value::Null, |v| serde_json::Value::String(v.0)), "float4" | "float8" => row .try_get::<_, f64>(idx) .ok() @@ -330,6 +323,112 @@ pub fn format_column_json(row: &tokio_postgres::Row, idx: usize) -> serde_json:: } } +/// Wrapper that decodes PostgreSQL NUMERIC binary format directly into a String, +/// avoiding `rust_decimal::Decimal` which panics on values exceeding its 96-bit +/// mantissa (~28 digits). Postgres NUMERIC is arbitrary precision (e.g. abi_uint() +/// on uint256 produces 78-digit values). +struct PgNumeric(String); + +impl<'a> postgres_types::FromSql<'a> for PgNumeric { + fn from_sql( + _ty: &postgres_types::Type, + raw: &'a [u8], + ) -> Result> { + if raw.len() < 8 { + return Err("NUMERIC binary too short".into()); + } + let ndigits = u16::from_be_bytes([raw[0], raw[1]]) as usize; + let weight = i16::from_be_bytes([raw[2], raw[3]]); + let sign = u16::from_be_bytes([raw[4], raw[5]]); + let dscale = u16::from_be_bytes([raw[6], raw[7]]) as usize; + + const SIGN_POS: u16 = 0x0000; + const SIGN_NEG: u16 = 0x4000; + const SIGN_NAN: u16 = 0xC000; + const SIGN_PINF: u16 = 0xD000; + const SIGN_NINF: u16 = 0xF000; + + match sign { + SIGN_NAN => return Ok(PgNumeric("NaN".to_string())), + SIGN_PINF => return Ok(PgNumeric("Infinity".to_string())), + SIGN_NINF => return Ok(PgNumeric("-Infinity".to_string())), + SIGN_POS | SIGN_NEG => {} + _ => return Err("invalid NUMERIC sign".into()), + } + + if ndigits == 0 { + return Ok(PgNumeric(if dscale > 0 { + format!("0.{}", "0".repeat(dscale)) + } else { + "0".to_string() + })); + } + + let expected_len = ndigits + .checked_mul(2) + .and_then(|n| n.checked_add(8)) + .ok_or("NUMERIC length overflow")?; + if raw.len() < expected_len { + return Err("NUMERIC binary truncated".into()); + } + + let mut digits = Vec::with_capacity(ndigits); + for i in 0..ndigits { + let off = 8 + i * 2; + let d = u16::from_be_bytes([raw[off], raw[off + 1]]); + if d > 9999 { + return Err("invalid NUMERIC digit".into()); + } + digits.push(d); + } + + let mut s = String::new(); + if sign == SIGN_NEG { + s.push('-'); + } + + // Integer part: digit groups at positions 0..=weight + let weight_i = i32::from(weight); + let int_groups = (weight_i + 1).max(0) as usize; + if int_groups == 0 { + s.push('0'); + } else { + for i in 0..int_groups { + let d = if i < ndigits { digits[i] } else { 0 }; + if i == 0 { + s.push_str(&d.to_string()); + } else { + s.push_str(&format!("{d:04}")); + } + } + } + + // Fractional part + if dscale > 0 { + s.push('.'); + let mut frac = String::new(); + // Leading zero groups for weight < -1 (e.g. 0.00000042 has weight=-2) + let frac_leading_zero_groups = (-weight_i - 1).max(0) as usize; + for _ in 0..frac_leading_zero_groups { + frac.push_str("0000"); + } + for i in int_groups..ndigits { + frac.push_str(&format!("{:04}", digits[i])); + } + while frac.len() < dscale { + frac.push('0'); + } + s.push_str(&frac[..dscale]); + } + + Ok(PgNumeric(s)) + } + + fn accepts(ty: &postgres_types::Type) -> bool { + *ty == postgres_types::Type::NUMERIC + } +} + pub fn format_column_string(row: &tokio_postgres::Row, idx: usize) -> String { match format_column_json(row, idx) { serde_json::Value::Null => "NULL".to_string(), @@ -375,6 +474,7 @@ mod tests { use super::*; use crate::query::EventSignature; use insta::assert_snapshot; + use postgres_types::FromSql; // ======================================================================== // Event CTE SQL Generation Tests (Both Engines) @@ -513,5 +613,122 @@ mod tests { assert!(sanitized.ends_with("...")); } + // ======================================================================== + // PgNumeric Wire Format Decoding Tests + // ======================================================================== + + fn encode_pg_numeric(ndigits: i16, weight: i16, sign: u16, dscale: u16, digits: &[u16]) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&ndigits.to_be_bytes()); + buf.extend_from_slice(&weight.to_be_bytes()); + buf.extend_from_slice(&sign.to_be_bytes()); + buf.extend_from_slice(&dscale.to_be_bytes()); + for &d in digits { + buf.extend_from_slice(&d.to_be_bytes()); + } + buf + } + + #[test] + fn test_pg_numeric_zero() { + let raw = encode_pg_numeric(0, 0, 0, 0, &[]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "0"); + } + + #[test] + fn test_pg_numeric_small_int() { + // 42 = weight=0, digits=[42] + let raw = encode_pg_numeric(1, 0, 0, 0, &[42]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "42"); + } + + #[test] + fn test_pg_numeric_large_int() { + // 1_000_000 = weight=1, digits=[100, 0] + let raw = encode_pg_numeric(2, 1, 0, 0, &[100, 0]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "1000000"); + } + + #[test] + fn test_pg_numeric_negative() { + let raw = encode_pg_numeric(1, 0, 0x4000, 0, &[123]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "-123"); + } + + #[test] + fn test_pg_numeric_nan() { + let raw = encode_pg_numeric(0, 0, 0xC000, 0, &[]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "NaN"); + } + + #[test] + fn test_pg_numeric_decimal() { + // 3.14 = weight=0, dscale=2, digits=[3, 1400] + let raw = encode_pg_numeric(2, 0, 0, 2, &[3, 1400]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "3.14"); + } + + #[test] + fn test_pg_numeric_uint256_max() { + // 2^256 - 1 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 + // This is a 78-digit number that would panic with rust_decimal::Decimal. + // PG NUMERIC base-10000 encoding: weight=19 (20 groups for integer part) + let digits: Vec = vec![ + 11, 5792, 892, 3731, 6195, 4235, 7098, 5008, + 6879, 785, 3269, 9846, 6564, 564, 394, 5758, + 4007, 9131, 2963, 9935, + ]; + let raw = encode_pg_numeric(20, 19, 0, 0, &digits); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "115792089237316195423570985008687907853269984665640564039457584007913129639935"); + } + + #[test] + fn test_pg_numeric_fractional_only() { + // 0.0042 = weight=-1, dscale=4, digits=[42] + let raw = encode_pg_numeric(1, -1, 0, 4, &[42]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "0.0042"); + } + + #[test] + fn test_pg_numeric_deep_fraction() { + // 0.00000042 = weight=-2, dscale=8, digits=[42] + let raw = encode_pg_numeric(1, -2, 0, 8, &[42]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "0.00000042"); + } + + #[test] + fn test_pg_numeric_infinity() { + let raw = encode_pg_numeric(0, 0, 0xD000, 0, &[]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "Infinity"); + } + + #[test] + fn test_pg_numeric_neg_infinity() { + let raw = encode_pg_numeric(0, 0, 0xF000, 0, &[]); + let v = PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).unwrap(); + assert_eq!(v.0, "-Infinity"); + } + + #[test] + fn test_pg_numeric_invalid_digit() { + let raw = encode_pg_numeric(1, 0, 0, 0, &[10000]); + assert!(PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).is_err()); + } + + #[test] + fn test_pg_numeric_invalid_sign() { + let raw = encode_pg_numeric(1, 0, 0x1234, 0, &[42]); + assert!(PgNumeric::from_sql(&postgres_types::Type::NUMERIC, &raw).is_err()); + } }