use super::error::{BackendError, BackendResult};
use chrono::{DateTime, FixedOffset};
pub mod oid {
pub const BOOL: u32 = 16;
pub const INT8: u32 = 20;
pub const INT4: u32 = 23;
pub const TEXT: u32 = 25;
pub const FLOAT8: u32 = 701;
pub const TIMESTAMPTZ: u32 = 1184;
pub const NUMERIC: u32 = 1700;
pub const PG_LSN: u32 = 3220;
}
#[derive(Debug, Clone, PartialEq)]
pub enum TextValue {
Null,
Text(String),
}
impl TextValue {
pub fn is_null(&self) -> bool {
matches!(self, TextValue::Null)
}
pub fn as_str(&self) -> Option<&str> {
match self {
TextValue::Null => None,
TextValue::Text(s) => Some(s.as_str()),
}
}
pub fn into_string(self) -> Option<String> {
match self {
TextValue::Null => None,
TextValue::Text(s) => Some(s),
}
}
pub fn as_bool(&self, column: &str) -> BackendResult<Option<bool>> {
match self {
TextValue::Null => Ok(None),
TextValue::Text(s) => match s.as_str() {
"t" | "true" | "TRUE" => Ok(Some(true)),
"f" | "false" | "FALSE" => Ok(Some(false)),
other => Err(BackendError::ParseValue {
column: column.to_string(),
reason: format!("expected bool ('t'|'f'), got {:?}", other),
}),
},
}
}
pub fn as_i64(&self, column: &str) -> BackendResult<Option<i64>> {
match self {
TextValue::Null => Ok(None),
TextValue::Text(s) => s.parse::<i64>().map(Some).map_err(|e| {
BackendError::ParseValue {
column: column.to_string(),
reason: format!("i64: {}", e),
}
}),
}
}
pub fn as_f64(&self, column: &str) -> BackendResult<Option<f64>> {
match self {
TextValue::Null => Ok(None),
TextValue::Text(s) => s.parse::<f64>().map(Some).map_err(|e| {
BackendError::ParseValue {
column: column.to_string(),
reason: format!("f64: {}", e),
}
}),
}
}
pub fn as_timestamptz(
&self,
column: &str,
) -> BackendResult<Option<DateTime<FixedOffset>>> {
match self {
TextValue::Null => Ok(None),
TextValue::Text(s) => {
let normalised = if s.contains(' ') && !s.contains('T') {
s.replacen(' ', "T", 1)
} else {
s.clone()
};
let normalised =
if let Some(idx) = normalised.rfind(['+', '-']) {
let off = &normalised[idx + 1..];
if off.len() == 2 && off.bytes().all(|b| b.is_ascii_digit()) {
format!("{}:00", normalised)
} else {
normalised
}
} else {
normalised
};
DateTime::parse_from_rfc3339(&normalised)
.map(Some)
.map_err(|e| BackendError::ParseValue {
column: column.to_string(),
reason: format!("timestamptz {:?}: {}", s, e),
})
}
}
}
pub fn as_pg_lsn(&self, column: &str) -> BackendResult<Option<String>> {
match self {
TextValue::Null => Ok(None),
TextValue::Text(s) => {
if let Some((hi, lo)) = s.split_once('/') {
let hex_ok = |p: &str| {
!p.is_empty() && p.bytes().all(|b| b.is_ascii_hexdigit())
};
if hex_ok(hi) && hex_ok(lo) {
return Ok(Some(s.clone()));
}
}
Err(BackendError::ParseValue {
column: column.to_string(),
reason: format!("pg_lsn {:?}: expected 'H/H' hex pair", s),
})
}
}
}
pub fn as_numeric(&self, column: &str) -> BackendResult<Option<String>> {
match self {
TextValue::Null => Ok(None),
TextValue::Text(s) => {
let bytes = s.as_bytes();
let mut i = 0;
if bytes.first().map_or(false, |&b| b == b'+' || b == b'-') {
i += 1;
}
let mut saw_digit = false;
let mut saw_dot = false;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_digit() {
saw_digit = true;
} else if b == b'.' && !saw_dot {
saw_dot = true;
} else if (b == b'e' || b == b'E') && saw_digit {
saw_digit = true;
break;
} else if s.eq_ignore_ascii_case("NaN") {
return Ok(Some("NaN".to_string()));
} else {
return Err(BackendError::ParseValue {
column: column.to_string(),
reason: format!("numeric {:?}", s),
});
}
i += 1;
}
if saw_digit {
Ok(Some(s.clone()))
} else {
Err(BackendError::ParseValue {
column: column.to_string(),
reason: format!("numeric {:?}: no digits", s),
})
}
}
}
}
}
pub fn encode_literal(v: &ParamValue) -> String {
match v {
ParamValue::Null => "NULL".to_string(),
ParamValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
ParamValue::Int(i) => i.to_string(),
ParamValue::Float(f) => {
if f.is_nan() {
"'NaN'::float8".to_string()
} else if f.is_infinite() {
if *f > 0.0 {
"'Infinity'::float8".to_string()
} else {
"'-Infinity'::float8".to_string()
}
} else {
format!("{:?}", f) }
}
ParamValue::Text(s) => {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
if ch == '\'' {
out.push_str("''");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
ParamValue::Lsn(s) => format!("'{}'::pg_lsn", s),
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ParamValue {
Null,
Bool(bool),
Int(i64),
Float(f64),
Text(String),
Lsn(String),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_text_value_bool() {
let t = TextValue::Text("t".to_string());
assert_eq!(t.as_bool("x").unwrap(), Some(true));
let f = TextValue::Text("f".to_string());
assert_eq!(f.as_bool("x").unwrap(), Some(false));
let n = TextValue::Null;
assert_eq!(n.as_bool("x").unwrap(), None);
let bad = TextValue::Text("maybe".to_string());
assert!(bad.as_bool("x").is_err());
}
#[test]
fn test_text_value_i64() {
assert_eq!(
TextValue::Text("42".to_string()).as_i64("x").unwrap(),
Some(42)
);
assert_eq!(
TextValue::Text("-1".to_string()).as_i64("x").unwrap(),
Some(-1)
);
assert!(TextValue::Text("abc".to_string()).as_i64("x").is_err());
}
#[test]
fn test_text_value_f64() {
assert_eq!(
TextValue::Text("3.14".to_string()).as_f64("x").unwrap(),
Some(3.14)
);
assert!(TextValue::Text("oops".to_string()).as_f64("x").is_err());
}
#[test]
fn test_text_value_timestamptz_pg_format() {
let v = TextValue::Text("2026-04-24 12:34:56.789+00".to_string());
let parsed = v.as_timestamptz("ts").unwrap().expect("some");
assert_eq!(parsed.to_rfc3339().starts_with("2026-04-24T12:34:56.789"), true);
}
#[test]
fn test_text_value_timestamptz_rfc3339() {
let v = TextValue::Text("2026-04-24T12:34:56+02:00".to_string());
assert!(v.as_timestamptz("ts").unwrap().is_some());
}
#[test]
fn test_text_value_pg_lsn_roundtrip() {
assert_eq!(
TextValue::Text("0/16B3758".to_string())
.as_pg_lsn("x")
.unwrap(),
Some("0/16B3758".to_string())
);
assert!(TextValue::Text("nope".to_string()).as_pg_lsn("x").is_err());
assert!(TextValue::Text("/abc".to_string()).as_pg_lsn("x").is_err());
}
#[test]
fn test_text_value_numeric_accepts_valid() {
for s in ["0", "1", "-42", "3.14", "+1.0", "1e10", "-2.5E-3", "NaN"] {
assert!(
TextValue::Text(s.to_string()).as_numeric("x").unwrap().is_some(),
"should accept {:?}",
s
);
}
}
#[test]
fn test_text_value_numeric_rejects_invalid() {
for s in ["", "abc", "1..2", "-", "+"] {
assert!(
TextValue::Text(s.to_string()).as_numeric("x").is_err(),
"should reject {:?}",
s
);
}
}
#[test]
fn test_encode_literal_null_bool_int() {
assert_eq!(encode_literal(&ParamValue::Null), "NULL");
assert_eq!(encode_literal(&ParamValue::Bool(true)), "TRUE");
assert_eq!(encode_literal(&ParamValue::Bool(false)), "FALSE");
assert_eq!(encode_literal(&ParamValue::Int(-7)), "-7");
}
#[test]
fn test_encode_literal_text_escapes_single_quote() {
assert_eq!(encode_literal(&ParamValue::Text("a'b".to_string())), "'a''b'");
assert_eq!(encode_literal(&ParamValue::Text("plain".to_string())), "'plain'");
}
#[test]
fn test_encode_literal_lsn() {
assert_eq!(
encode_literal(&ParamValue::Lsn("0/16B3758".to_string())),
"'0/16B3758'::pg_lsn"
);
}
#[test]
fn test_encode_literal_float_special() {
assert_eq!(
encode_literal(&ParamValue::Float(f64::NAN)),
"'NaN'::float8"
);
assert_eq!(
encode_literal(&ParamValue::Float(f64::INFINITY)),
"'Infinity'::float8"
);
assert_eq!(
encode_literal(&ParamValue::Float(f64::NEG_INFINITY)),
"'-Infinity'::float8"
);
}
}