1use std::fmt;
2
3use anyhow::{Context, Result, anyhow, bail};
4use chrono::{NaiveDate, NaiveDateTime};
5use evalexpr;
6use serde::{Deserialize, Serialize};
7use uuid::Uuid;
8
9use crate::schema::ColumnType;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub enum Value {
13 String(String),
14 Integer(i64),
15 Float(f64),
16 Boolean(bool),
17 Date(NaiveDate),
18 DateTime(NaiveDateTime),
19 Guid(Uuid),
20}
21
22impl Eq for Value {}
23
24impl Value {
25 pub fn as_display(&self) -> String {
26 match self {
27 Value::String(s) => s.clone(),
28 Value::Integer(i) => i.to_string(),
29 Value::Float(f) => {
30 if f.fract() == 0.0 {
31 (*f as i64).to_string()
32 } else {
33 f.to_string()
34 }
35 }
36 Value::Boolean(b) => b.to_string(),
37 Value::Date(d) => d.format("%Y-%m-%d").to_string(),
38 Value::DateTime(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
39 Value::Guid(g) => g.to_string(),
40 }
41 }
42}
43
44impl Ord for Value {
45 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
46 match (self, other) {
47 (Value::String(a), Value::String(b)) => a.cmp(b),
48 (Value::Integer(a), Value::Integer(b)) => a.cmp(b),
49 (Value::Float(a), Value::Float(b)) => a.total_cmp(b),
50 (Value::Boolean(a), Value::Boolean(b)) => a.cmp(b),
51 (Value::Date(a), Value::Date(b)) => a.cmp(b),
52 (Value::DateTime(a), Value::DateTime(b)) => a.cmp(b),
53 (Value::Guid(a), Value::Guid(b)) => a.cmp(b),
54 _ => panic!("Cannot compare heterogeneous Value variants"),
55 }
56 }
57}
58
59impl PartialOrd for Value {
60 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
61 Some(self.cmp(other))
62 }
63}
64
65impl fmt::Display for Value {
66 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67 write!(f, "{}", self.as_display())
68 }
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct ComparableValue(pub Option<Value>);
73
74impl Ord for ComparableValue {
75 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
76 match (&self.0, &other.0) {
77 (None, None) => std::cmp::Ordering::Equal,
78 (None, Some(_)) => std::cmp::Ordering::Less,
79 (Some(_), None) => std::cmp::Ordering::Greater,
80 (Some(left), Some(right)) => left.cmp(right),
81 }
82 }
83}
84
85impl PartialOrd for ComparableValue {
86 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
87 Some(self.cmp(other))
88 }
89}
90
91pub fn parse_naive_date(value: &str) -> Result<NaiveDate> {
92 const DATE_FORMATS: &[&str] = &["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d", "%d-%m-%Y"];
93 for fmt in DATE_FORMATS {
94 if let Ok(parsed) = NaiveDate::parse_from_str(value, fmt) {
95 return Ok(parsed);
96 }
97 }
98 Err(anyhow!("Failed to parse '{value}' as date"))
99}
100
101pub fn parse_naive_datetime(value: &str) -> Result<NaiveDateTime> {
102 const DATETIME_FORMATS: &[&str] = &[
103 "%Y-%m-%d %H:%M:%S",
104 "%Y-%m-%dT%H:%M:%S",
105 "%d/%m/%Y %H:%M:%S",
106 "%m/%d/%Y %H:%M:%S",
107 "%Y-%m-%d %H:%M",
108 "%Y-%m-%dT%H:%M",
109 ];
110 for fmt in DATETIME_FORMATS {
111 if let Ok(parsed) = NaiveDateTime::parse_from_str(value, fmt) {
112 return Ok(parsed);
113 }
114 }
115 Err(anyhow!("Failed to parse '{value}' as datetime"))
116}
117
118pub fn normalize_column_name(name: &str) -> String {
119 name.chars()
120 .map(|c| match c {
121 'a'..='z' | 'A'..='Z' | '0'..='9' => c,
122 _ => '_',
123 })
124 .collect::<String>()
125 .to_ascii_lowercase()
126}
127
128pub fn parse_typed_value(value: &str, ty: &ColumnType) -> Result<Option<Value>> {
129 if value.is_empty() {
130 return Ok(None);
131 }
132 let parsed = match ty {
133 ColumnType::String => Value::String(value.to_string()),
134 ColumnType::Integer => {
135 let parsed: i64 = value
136 .parse()
137 .with_context(|| format!("Failed to parse '{value}' as integer"))?;
138 Value::Integer(parsed)
139 }
140 ColumnType::Float => {
141 let parsed: f64 = value
142 .parse()
143 .with_context(|| format!("Failed to parse '{value}' as float"))?;
144 Value::Float(parsed)
145 }
146 ColumnType::Boolean => {
147 let lowered = value.to_ascii_lowercase();
148 let parsed = match lowered.as_str() {
149 "true" | "t" | "yes" | "y" | "1" => true,
150 "false" | "f" | "no" | "n" | "0" => false,
151 _ => bail!("Failed to parse '{value}' as boolean"),
152 };
153 Value::Boolean(parsed)
154 }
155 ColumnType::Date => {
156 let parsed = parse_naive_date(value)?;
157 Value::Date(parsed)
158 }
159 ColumnType::DateTime => {
160 let parsed = parse_naive_datetime(value)?;
161 Value::DateTime(parsed)
162 }
163 ColumnType::Guid => {
164 let trimmed = value.trim().trim_matches(|c| matches!(c, '{' | '}'));
165 let parsed = Uuid::parse_str(trimmed)
166 .with_context(|| format!("Failed to parse '{value}' as GUID"))?;
167 Value::Guid(parsed)
168 }
169 };
170 Ok(Some(parsed))
171}
172
173pub fn value_to_evalexpr(value: &Value) -> evalexpr::Value {
174 match value {
175 Value::String(s) => evalexpr::Value::String(s.clone()),
176 Value::Integer(i) => evalexpr::Value::Int(*i),
177 Value::Float(f) => evalexpr::Value::Float(*f),
178 Value::Boolean(b) => evalexpr::Value::Boolean(*b),
179 Value::Date(d) => evalexpr::Value::String(d.format("%Y-%m-%d").to_string()),
180 Value::DateTime(dt) => evalexpr::Value::String(dt.format("%Y-%m-%d %H:%M:%S").to_string()),
181 Value::Guid(g) => evalexpr::Value::String(g.to_string()),
182 }
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188 use chrono::{NaiveDate, NaiveDateTime};
189 use evalexpr::Value as EvalValue;
190 use uuid::Uuid;
191
192 #[test]
193 fn normalize_column_name_replaces_non_alphanumeric() {
194 assert_eq!(normalize_column_name("Order ID"), "order_id");
195 assert_eq!(normalize_column_name("$Percent%"), "_percent_");
196 }
197
198 #[test]
199 fn parse_naive_date_supports_multiple_formats() {
200 let expected = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
201 assert_eq!(parse_naive_date("2024-05-06").unwrap(), expected);
202 assert_eq!(parse_naive_date("06/05/2024").unwrap(), expected);
203 assert_eq!(parse_naive_date("2024/05/06").unwrap(), expected);
204 }
205
206 #[test]
207 fn parse_naive_datetime_supports_multiple_formats() {
208 let expected =
209 NaiveDateTime::parse_from_str("2024-05-06 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
210 assert_eq!(
211 parse_naive_datetime("2024-05-06T14:30:00").unwrap(),
212 expected
213 );
214 assert_eq!(
215 parse_naive_datetime("06/05/2024 14:30:00").unwrap(),
216 expected
217 );
218 assert_eq!(parse_naive_datetime("2024-05-06 14:30").unwrap(), expected);
219 }
220
221 #[test]
222 fn parse_typed_value_handles_empty_and_boolean_inputs() {
223 assert_eq!(parse_typed_value("", &ColumnType::Integer).unwrap(), None);
224
225 let truthy = parse_typed_value("Yes", &ColumnType::Boolean)
226 .unwrap()
227 .unwrap();
228 assert_eq!(truthy, Value::Boolean(true));
229
230 let falsy = parse_typed_value("0", &ColumnType::Boolean)
231 .unwrap()
232 .unwrap();
233 assert_eq!(falsy, Value::Boolean(false));
234
235 assert!(parse_typed_value("maybe", &ColumnType::Boolean).is_err());
236 }
237
238 #[test]
239 fn parse_typed_value_supports_guid_inputs() {
240 let raw = "550e8400-e29b-41d4-a716-446655440000";
241 let parsed = parse_typed_value(raw, &ColumnType::Guid).unwrap().unwrap();
242 match parsed {
243 Value::Guid(g) => {
244 assert_eq!(g, Uuid::parse_str(raw).unwrap());
245 }
246 other => panic!("Expected GUID value, got {other:?}"),
247 }
248
249 let braced = "{550e8400-e29b-41d4-a716-446655440000}";
250 let parsed_braced = parse_typed_value(braced, &ColumnType::Guid)
251 .unwrap()
252 .unwrap();
253 assert!(matches!(parsed_braced, Value::Guid(_)));
254
255 assert!(parse_typed_value("not-a-guid", &ColumnType::Guid).is_err());
256 }
257
258 #[test]
259 fn value_to_evalexpr_preserves_variants() {
260 assert_eq!(value_to_evalexpr(&Value::Integer(42)), EvalValue::Int(42));
261 assert_eq!(
262 value_to_evalexpr(&Value::Boolean(false)),
263 EvalValue::Boolean(false)
264 );
265
266 let date = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
267 assert_eq!(
268 value_to_evalexpr(&Value::Date(date)),
269 EvalValue::String("2024-05-06".to_string())
270 );
271 }
272
273 #[test]
274 fn comparable_value_orders_none_before_some() {
275 let none = ComparableValue(None);
276 let some = ComparableValue(Some(Value::Integer(0)));
277 assert!(none < some);
278 }
279}