1use super::error::{BackendError, BackendResult};
9use chrono::{DateTime, FixedOffset};
10
11pub mod oid {
18 pub const BOOL: u32 = 16;
19 pub const INT8: u32 = 20;
20 pub const INT4: u32 = 23;
21 pub const TEXT: u32 = 25;
22 pub const FLOAT8: u32 = 701;
23 pub const TIMESTAMPTZ: u32 = 1184;
24 pub const NUMERIC: u32 = 1700;
25 pub const PG_LSN: u32 = 3220;
27}
28
29#[derive(Debug, Clone, PartialEq)]
34pub enum TextValue {
35 Null,
37 Text(String),
39}
40
41impl TextValue {
42 pub fn is_null(&self) -> bool {
44 matches!(self, TextValue::Null)
45 }
46
47 pub fn as_str(&self) -> Option<&str> {
49 match self {
50 TextValue::Null => None,
51 TextValue::Text(s) => Some(s.as_str()),
52 }
53 }
54
55 pub fn into_string(self) -> Option<String> {
57 match self {
58 TextValue::Null => None,
59 TextValue::Text(s) => Some(s),
60 }
61 }
62
63 pub fn as_bool(&self, column: &str) -> BackendResult<Option<bool>> {
65 match self {
66 TextValue::Null => Ok(None),
67 TextValue::Text(s) => match s.as_str() {
68 "t" | "true" | "TRUE" => Ok(Some(true)),
69 "f" | "false" | "FALSE" => Ok(Some(false)),
70 other => Err(BackendError::ParseValue {
71 column: column.to_string(),
72 reason: format!("expected bool ('t'|'f'), got {:?}", other),
73 }),
74 },
75 }
76 }
77
78 pub fn as_i64(&self, column: &str) -> BackendResult<Option<i64>> {
81 match self {
82 TextValue::Null => Ok(None),
83 TextValue::Text(s) => {
84 s.parse::<i64>()
85 .map(Some)
86 .map_err(|e| BackendError::ParseValue {
87 column: column.to_string(),
88 reason: format!("i64: {}", e),
89 })
90 }
91 }
92 }
93
94 pub fn as_f64(&self, column: &str) -> BackendResult<Option<f64>> {
96 match self {
97 TextValue::Null => Ok(None),
98 TextValue::Text(s) => {
99 s.parse::<f64>()
100 .map(Some)
101 .map_err(|e| BackendError::ParseValue {
102 column: column.to_string(),
103 reason: format!("f64: {}", e),
104 })
105 }
106 }
107 }
108
109 pub fn as_timestamptz(&self, column: &str) -> BackendResult<Option<DateTime<FixedOffset>>> {
112 match self {
113 TextValue::Null => Ok(None),
114 TextValue::Text(s) => {
115 let normalised = if s.contains(' ') && !s.contains('T') {
118 s.replacen(' ', "T", 1)
119 } else {
120 s.clone()
121 };
122 let normalised = if let Some(idx) = normalised.rfind(['+', '-']) {
124 let off = &normalised[idx + 1..];
125 if off.len() == 2 && off.bytes().all(|b| b.is_ascii_digit()) {
126 format!("{}:00", normalised)
127 } else {
128 normalised
129 }
130 } else {
131 normalised
132 };
133 DateTime::parse_from_rfc3339(&normalised)
134 .map(Some)
135 .map_err(|e| BackendError::ParseValue {
136 column: column.to_string(),
137 reason: format!("timestamptz {:?}: {}", s, e),
138 })
139 }
140 }
141 }
142
143 pub fn as_pg_lsn(&self, column: &str) -> BackendResult<Option<String>> {
149 match self {
150 TextValue::Null => Ok(None),
151 TextValue::Text(s) => {
152 if let Some((hi, lo)) = s.split_once('/') {
154 let hex_ok =
155 |p: &str| !p.is_empty() && p.bytes().all(|b| b.is_ascii_hexdigit());
156 if hex_ok(hi) && hex_ok(lo) {
157 return Ok(Some(s.clone()));
158 }
159 }
160 Err(BackendError::ParseValue {
161 column: column.to_string(),
162 reason: format!("pg_lsn {:?}: expected 'H/H' hex pair", s),
163 })
164 }
165 }
166 }
167
168 pub fn as_numeric(&self, column: &str) -> BackendResult<Option<String>> {
172 match self {
173 TextValue::Null => Ok(None),
174 TextValue::Text(s) => {
175 let bytes = s.as_bytes();
178 let mut i = 0;
179 if bytes.first().is_some_and(|&b| b == b'+' || b == b'-') {
180 i += 1;
181 }
182 let mut saw_digit = false;
183 let mut saw_dot = false;
184 while i < bytes.len() {
185 let b = bytes[i];
186 if b.is_ascii_digit() {
187 saw_digit = true;
188 } else if b == b'.' && !saw_dot {
189 saw_dot = true;
190 } else if (b == b'e' || b == b'E') && saw_digit {
191 saw_digit = true;
193 break;
194 } else if s.eq_ignore_ascii_case("NaN") {
195 return Ok(Some("NaN".to_string()));
196 } else {
197 return Err(BackendError::ParseValue {
198 column: column.to_string(),
199 reason: format!("numeric {:?}", s),
200 });
201 }
202 i += 1;
203 }
204 if saw_digit {
205 Ok(Some(s.clone()))
206 } else {
207 Err(BackendError::ParseValue {
208 column: column.to_string(),
209 reason: format!("numeric {:?}: no digits", s),
210 })
211 }
212 }
213 }
214 }
215}
216
217pub fn encode_literal(v: &ParamValue) -> String {
228 match v {
229 ParamValue::Null => "NULL".to_string(),
230 ParamValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(),
231 ParamValue::Int(i) => i.to_string(),
232 ParamValue::Float(f) => {
233 if f.is_nan() {
237 "'NaN'::float8".to_string()
238 } else if f.is_infinite() {
239 if *f > 0.0 {
240 "'Infinity'::float8".to_string()
241 } else {
242 "'-Infinity'::float8".to_string()
243 }
244 } else {
245 format!("{:?}", f) }
247 }
248 ParamValue::Text(s) => {
249 let mut out = String::with_capacity(s.len() + 2);
252 out.push('\'');
253 for ch in s.chars() {
254 if ch == '\'' {
255 out.push_str("''");
256 } else {
257 out.push(ch);
258 }
259 }
260 out.push('\'');
261 out
262 }
263 ParamValue::Lsn(s) => format!("'{}'::pg_lsn", s),
264 }
265}
266
267#[derive(Debug, Clone, PartialEq)]
272pub enum ParamValue {
273 Null,
274 Bool(bool),
275 Int(i64),
276 Float(f64),
277 Text(String),
278 Lsn(String),
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_text_value_bool() {
287 let t = TextValue::Text("t".to_string());
288 assert_eq!(t.as_bool("x").unwrap(), Some(true));
289 let f = TextValue::Text("f".to_string());
290 assert_eq!(f.as_bool("x").unwrap(), Some(false));
291 let n = TextValue::Null;
292 assert_eq!(n.as_bool("x").unwrap(), None);
293 let bad = TextValue::Text("maybe".to_string());
294 assert!(bad.as_bool("x").is_err());
295 }
296
297 #[test]
298 fn test_text_value_i64() {
299 assert_eq!(
300 TextValue::Text("42".to_string()).as_i64("x").unwrap(),
301 Some(42)
302 );
303 assert_eq!(
304 TextValue::Text("-1".to_string()).as_i64("x").unwrap(),
305 Some(-1)
306 );
307 assert!(TextValue::Text("abc".to_string()).as_i64("x").is_err());
308 }
309
310 #[test]
311 #[allow(clippy::approx_constant)]
315 fn test_text_value_f64() {
316 assert_eq!(
317 TextValue::Text("3.14".to_string()).as_f64("x").unwrap(),
318 Some(3.14)
319 );
320 assert!(TextValue::Text("oops".to_string()).as_f64("x").is_err());
321 }
322
323 #[test]
324 fn test_text_value_timestamptz_pg_format() {
325 let v = TextValue::Text("2026-04-24 12:34:56.789+00".to_string());
326 let parsed = v.as_timestamptz("ts").unwrap().expect("some");
327 assert!(parsed.to_rfc3339().starts_with("2026-04-24T12:34:56.789"));
328 }
329
330 #[test]
331 fn test_text_value_timestamptz_rfc3339() {
332 let v = TextValue::Text("2026-04-24T12:34:56+02:00".to_string());
333 assert!(v.as_timestamptz("ts").unwrap().is_some());
334 }
335
336 #[test]
337 fn test_text_value_pg_lsn_roundtrip() {
338 assert_eq!(
339 TextValue::Text("0/16B3758".to_string())
340 .as_pg_lsn("x")
341 .unwrap(),
342 Some("0/16B3758".to_string())
343 );
344 assert!(TextValue::Text("nope".to_string()).as_pg_lsn("x").is_err());
345 assert!(TextValue::Text("/abc".to_string()).as_pg_lsn("x").is_err());
346 }
347
348 #[test]
349 fn test_text_value_numeric_accepts_valid() {
350 for s in ["0", "1", "-42", "3.14", "+1.0", "1e10", "-2.5E-3", "NaN"] {
351 assert!(
352 TextValue::Text(s.to_string())
353 .as_numeric("x")
354 .unwrap()
355 .is_some(),
356 "should accept {:?}",
357 s
358 );
359 }
360 }
361
362 #[test]
363 fn test_text_value_numeric_rejects_invalid() {
364 for s in ["", "abc", "1..2", "-", "+"] {
365 assert!(
366 TextValue::Text(s.to_string()).as_numeric("x").is_err(),
367 "should reject {:?}",
368 s
369 );
370 }
371 }
372
373 #[test]
374 fn test_encode_literal_null_bool_int() {
375 assert_eq!(encode_literal(&ParamValue::Null), "NULL");
376 assert_eq!(encode_literal(&ParamValue::Bool(true)), "TRUE");
377 assert_eq!(encode_literal(&ParamValue::Bool(false)), "FALSE");
378 assert_eq!(encode_literal(&ParamValue::Int(-7)), "-7");
379 }
380
381 #[test]
382 fn test_encode_literal_text_escapes_single_quote() {
383 assert_eq!(
384 encode_literal(&ParamValue::Text("a'b".to_string())),
385 "'a''b'"
386 );
387 assert_eq!(
388 encode_literal(&ParamValue::Text("plain".to_string())),
389 "'plain'"
390 );
391 }
392
393 #[test]
394 fn test_encode_literal_lsn() {
395 assert_eq!(
396 encode_literal(&ParamValue::Lsn("0/16B3758".to_string())),
397 "'0/16B3758'::pg_lsn"
398 );
399 }
400
401 #[test]
402 fn test_encode_literal_float_special() {
403 assert_eq!(
404 encode_literal(&ParamValue::Float(f64::NAN)),
405 "'NaN'::float8"
406 );
407 assert_eq!(
408 encode_literal(&ParamValue::Float(f64::INFINITY)),
409 "'Infinity'::float8"
410 );
411 assert_eq!(
412 encode_literal(&ParamValue::Float(f64::NEG_INFINITY)),
413 "'-Infinity'::float8"
414 );
415 }
416}