1use std::fmt;
2
3use anyhow::{Context, Result, anyhow, bail, ensure};
4use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
5use evalexpr;
6use rust_decimal::Decimal;
7use rust_decimal::RoundingStrategy;
8use rust_decimal::prelude::ToPrimitive;
9use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser::SerializeStruct};
10use std::str::FromStr;
11use uuid::Uuid;
12
13use crate::schema::{ColumnType, DecimalSpec};
14
15pub const CURRENCY_ALLOWED_SCALES: [u32; 2] = [2, 4];
16
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
18pub struct FixedDecimalValue {
19 amount: Decimal,
20 precision: u32,
21 scale: u32,
22}
23
24impl FixedDecimalValue {
25 pub fn parse(raw: &str, spec: &DecimalSpec) -> Result<Self> {
26 let decimal = parse_decimal_literal(raw)?;
27 Self::from_decimal(decimal, spec, None)
28 }
29
30 pub fn from_decimal(
31 value: Decimal,
32 spec: &DecimalSpec,
33 strategy: Option<&str>,
34 ) -> Result<Self> {
35 let mut decimal = value;
36 if let Some(strategy) = strategy {
37 decimal = match strategy {
38 "truncate" => decimal.round_dp_with_strategy(spec.scale, RoundingStrategy::ToZero),
39 "round" | "round-half-up" | "roundhalfup" => decimal
40 .round_dp_with_strategy(spec.scale, RoundingStrategy::MidpointAwayFromZero),
41 other => bail!("Unsupported decimal rounding strategy '{other}'"),
42 };
43 }
44 Self::validate_decimal(&decimal, spec)?;
45 let mut quantized = decimal;
46 if quantized.scale() < spec.scale {
47 quantized.rescale(spec.scale);
48 }
49 Ok(Self {
50 amount: quantized,
51 precision: spec.precision,
52 scale: spec.scale,
53 })
54 }
55
56 pub fn amount(&self) -> &Decimal {
57 &self.amount
58 }
59
60 pub fn precision(&self) -> u32 {
61 self.precision
62 }
63
64 pub fn scale(&self) -> u32 {
65 self.scale
66 }
67
68 pub fn to_string_fixed(&self) -> String {
69 format_decimal_with_scale(self.amount, self.scale as usize)
70 }
71
72 pub fn to_f64(&self) -> Option<f64> {
73 self.amount.to_f64()
74 }
75
76 fn validate_decimal(decimal: &Decimal, spec: &DecimalSpec) -> Result<()> {
77 ensure!(
78 decimal.scale() <= spec.scale,
79 "Decimal values must not exceed scale {} (found {})",
80 spec.scale,
81 decimal.scale()
82 );
83 let integer_digits = count_integer_digits(decimal);
84 let max_integer_digits = (spec.precision - spec.scale) as usize;
85 ensure!(
86 integer_digits <= max_integer_digits,
87 "Decimal values must not exceed {} digit(s) to the left of the decimal point",
88 max_integer_digits
89 );
90 Ok(())
91 }
92}
93
94impl Serialize for FixedDecimalValue {
95 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
96 where
97 S: Serializer,
98 {
99 let mut state = serializer.serialize_struct("FixedDecimalValue", 3)?;
100 state.serialize_field("amount", &self.to_string_fixed())?;
101 state.serialize_field("precision", &self.precision)?;
102 state.serialize_field("scale", &self.scale)?;
103 state.end()
104 }
105}
106
107impl<'de> Deserialize<'de> for FixedDecimalValue {
108 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
109 where
110 D: Deserializer<'de>,
111 {
112 #[derive(Deserialize)]
113 struct FixedDecimalValueRepr {
114 amount: String,
115 precision: u32,
116 scale: u32,
117 }
118
119 let repr = FixedDecimalValueRepr::deserialize(deserializer)?;
120 let spec = DecimalSpec::new(repr.precision, repr.scale)
121 .map_err(|err| de::Error::custom(err.to_string()))?;
122 let decimal =
123 Decimal::from_str(&repr.amount).map_err(|err| de::Error::custom(err.to_string()))?;
124 FixedDecimalValue::from_decimal(decimal, &spec, None)
125 .map_err(|err| de::Error::custom(err.to_string()))
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
130pub struct CurrencyValue {
131 amount: Decimal,
132}
133
134impl CurrencyValue {
135 pub fn parse(raw: &str) -> Result<Self> {
136 let decimal = parse_currency_decimal(raw)?;
137 Self::from_decimal(decimal).with_context(|| format!("Parsing '{raw}' as currency"))
138 }
139
140 pub fn from_decimal(mut amount: Decimal) -> Result<Self> {
141 match amount.scale() {
142 0 => {
143 amount.rescale(2);
144 }
145 scale if CURRENCY_ALLOWED_SCALES.contains(&scale) => {}
146 other => {
147 bail!("Currency values must have 2 or 4 decimal places (found {other})");
148 }
149 }
150 Ok(Self { amount })
151 }
152
153 pub fn quantize(mut amount: Decimal, scale: u32, strategy: Option<&str>) -> Result<Self> {
154 ensure!(
155 CURRENCY_ALLOWED_SCALES.contains(&scale),
156 "Currency scale must be 2 or 4"
157 );
158 match strategy {
159 Some("truncate") => {
160 amount = amount.round_dp_with_strategy(scale, RoundingStrategy::ToZero);
161 }
162 Some("round") | Some("round-half-up") | Some("roundhalfup") | None => {
163 amount =
164 amount.round_dp_with_strategy(scale, RoundingStrategy::MidpointAwayFromZero);
165 }
166 Some(other) => {
167 bail!("Unsupported currency rounding strategy '{other}'");
168 }
169 }
170 Self::from_decimal(amount)
171 }
172
173 pub fn amount(&self) -> &Decimal {
174 &self.amount
175 }
176
177 pub fn scale(&self) -> u32 {
178 self.amount.scale()
179 }
180
181 pub fn to_string_fixed(&self) -> String {
182 format_decimal_with_scale(self.amount, self.amount.scale() as usize)
183 }
184
185 pub fn to_f64(&self) -> Option<f64> {
186 self.amount.to_f64()
187 }
188}
189
190impl Serialize for CurrencyValue {
191 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
192 where
193 S: Serializer,
194 {
195 serializer.serialize_str(&self.to_string_fixed())
196 }
197}
198
199impl<'de> Deserialize<'de> for CurrencyValue {
200 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
201 where
202 D: Deserializer<'de>,
203 {
204 let token = String::deserialize(deserializer)?;
205 CurrencyValue::parse(&token).map_err(|err| de::Error::custom(err.to_string()))
206 }
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
210pub enum Value {
211 String(String),
212 Integer(i64),
213 Float(f64),
214 Boolean(bool),
215 Date(NaiveDate),
216 DateTime(NaiveDateTime),
217 Time(NaiveTime),
218 Guid(Uuid),
219 Decimal(FixedDecimalValue),
220 Currency(CurrencyValue),
221}
222
223impl Eq for Value {}
224
225impl Value {
226 pub fn as_display(&self) -> String {
227 match self {
228 Value::String(s) => s.clone(),
229 Value::Integer(i) => i.to_string(),
230 Value::Float(f) => {
231 if f.fract() == 0.0 {
232 (*f as i64).to_string()
233 } else {
234 f.to_string()
235 }
236 }
237 Value::Boolean(b) => b.to_string(),
238 Value::Date(d) => d.format("%Y-%m-%d").to_string(),
239 Value::DateTime(dt) => dt.format("%Y-%m-%d %H:%M:%S").to_string(),
240 Value::Time(t) => t.format("%H:%M:%S").to_string(),
241 Value::Guid(g) => g.to_string(),
242 Value::Decimal(d) => d.to_string_fixed(),
243 Value::Currency(c) => c.to_string_fixed(),
244 }
245 }
246}
247
248impl Ord for Value {
249 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
250 match (self, other) {
251 (Value::String(a), Value::String(b)) => a.cmp(b),
252 (Value::Integer(a), Value::Integer(b)) => a.cmp(b),
253 (Value::Float(a), Value::Float(b)) => a.total_cmp(b),
254 (Value::Boolean(a), Value::Boolean(b)) => a.cmp(b),
255 (Value::Date(a), Value::Date(b)) => a.cmp(b),
256 (Value::DateTime(a), Value::DateTime(b)) => a.cmp(b),
257 (Value::Time(a), Value::Time(b)) => a.cmp(b),
258 (Value::Guid(a), Value::Guid(b)) => a.cmp(b),
259 (Value::Decimal(a), Value::Decimal(b)) => a.cmp(b),
260 (Value::Currency(a), Value::Currency(b)) => a.cmp(b),
261 _ => panic!("Cannot compare heterogeneous Value variants"),
262 }
263 }
264}
265
266impl PartialOrd for Value {
267 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
268 Some(self.cmp(other))
269 }
270}
271
272impl fmt::Display for Value {
273 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274 write!(f, "{}", self.as_display())
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
279pub struct ComparableValue(pub Option<Value>);
280
281impl Ord for ComparableValue {
282 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
283 match (&self.0, &other.0) {
284 (None, None) => std::cmp::Ordering::Equal,
285 (None, Some(_)) => std::cmp::Ordering::Less,
286 (Some(_), None) => std::cmp::Ordering::Greater,
287 (Some(left), Some(right)) => left.cmp(right),
288 }
289 }
290}
291
292impl PartialOrd for ComparableValue {
293 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
294 Some(self.cmp(other))
295 }
296}
297
298pub fn parse_naive_date(value: &str) -> Result<NaiveDate> {
299 const DATE_FORMATS: &[&str] = &["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d", "%d-%m-%Y"];
300 for fmt in DATE_FORMATS {
301 if let Ok(parsed) = NaiveDate::parse_from_str(value, fmt) {
302 return Ok(parsed);
303 }
304 }
305 Err(anyhow!("Failed to parse '{value}' as date"))
306}
307
308pub fn parse_naive_datetime(value: &str) -> Result<NaiveDateTime> {
309 const DATETIME_FORMATS: &[&str] = &[
310 "%Y-%m-%d %H:%M:%S",
311 "%Y-%m-%dT%H:%M:%S",
312 "%d/%m/%Y %H:%M:%S",
313 "%m/%d/%Y %H:%M:%S",
314 "%Y-%m-%d %H:%M",
315 "%Y-%m-%dT%H:%M",
316 ];
317 for fmt in DATETIME_FORMATS {
318 if let Ok(parsed) = NaiveDateTime::parse_from_str(value, fmt) {
319 return Ok(parsed);
320 }
321 }
322 Err(anyhow!("Failed to parse '{value}' as datetime"))
323}
324
325pub fn parse_naive_time(value: &str) -> Result<NaiveTime> {
326 const TIME_FORMATS: &[&str] = &["%H:%M:%S", "%H:%M"];
327 for fmt in TIME_FORMATS {
328 if let Ok(parsed) = NaiveTime::parse_from_str(value, fmt) {
329 return Ok(parsed);
330 }
331 }
332 Err(anyhow!("Failed to parse '{value}' as time"))
333}
334
335pub fn normalize_column_name(name: &str) -> String {
336 let mut normalized: String = name
337 .chars()
338 .map(|c| match c {
339 'a'..='z' | 'A'..='Z' | '0'..='9' => c,
340 _ => '_',
341 })
342 .collect();
343
344 if normalized.is_empty() {
345 normalized.push_str("column");
346 }
347
348 if normalized
349 .chars()
350 .next()
351 .is_none_or(|c| !(c.is_ascii_alphabetic() || c == '_'))
352 {
353 normalized.insert(0, '_');
354 }
355
356 normalized.to_ascii_lowercase()
357}
358
359pub fn parse_typed_value(value: &str, ty: &ColumnType) -> Result<Option<Value>> {
360 if value.is_empty() {
361 return Ok(None);
362 }
363 let parsed = match ty {
364 ColumnType::String => Value::String(value.to_string()),
365 ColumnType::Integer => {
366 let parsed: i64 = value
367 .parse()
368 .with_context(|| format!("Failed to parse '{value}' as integer"))?;
369 Value::Integer(parsed)
370 }
371 ColumnType::Float => {
372 let parsed: f64 = value
373 .parse()
374 .with_context(|| format!("Failed to parse '{value}' as float"))?;
375 Value::Float(parsed)
376 }
377 ColumnType::Boolean => {
378 let lowered = value.to_ascii_lowercase();
379 let parsed = match lowered.as_str() {
380 "true" | "t" | "yes" | "y" | "1" => true,
381 "false" | "f" | "no" | "n" | "0" => false,
382 _ => bail!("Failed to parse '{value}' as boolean"),
383 };
384 Value::Boolean(parsed)
385 }
386 ColumnType::Date => {
387 let parsed = parse_naive_date(value)?;
388 Value::Date(parsed)
389 }
390 ColumnType::DateTime => {
391 let parsed = parse_naive_datetime(value)?;
392 Value::DateTime(parsed)
393 }
394 ColumnType::Time => {
395 let parsed = parse_naive_time(value)?;
396 Value::Time(parsed)
397 }
398 ColumnType::Guid => {
399 let trimmed = value.trim().trim_matches(|c| matches!(c, '{' | '}'));
400 let parsed = Uuid::parse_str(trimmed)
401 .with_context(|| format!("Failed to parse '{value}' as GUID"))?;
402 Value::Guid(parsed)
403 }
404 ColumnType::Decimal(spec) => {
405 let parsed = FixedDecimalValue::parse(value, spec)?;
406 Value::Decimal(parsed)
407 }
408 ColumnType::Currency => {
409 let parsed = CurrencyValue::parse(value)?;
410 Value::Currency(parsed)
411 }
412 };
413 Ok(Some(parsed))
414}
415
416pub fn value_to_evalexpr(value: &Value) -> evalexpr::Value {
417 match value {
418 Value::String(s) => evalexpr::Value::String(s.clone()),
419 Value::Integer(i) => evalexpr::Value::Int(*i),
420 Value::Float(f) => evalexpr::Value::Float(*f),
421 Value::Boolean(b) => evalexpr::Value::Boolean(*b),
422 Value::Date(d) => evalexpr::Value::String(d.format("%Y-%m-%d").to_string()),
423 Value::DateTime(dt) => evalexpr::Value::String(dt.format("%Y-%m-%d %H:%M:%S").to_string()),
424 Value::Time(t) => evalexpr::Value::String(t.format("%H:%M:%S").to_string()),
425 Value::Guid(g) => evalexpr::Value::String(g.to_string()),
426 Value::Decimal(d) => d
427 .to_f64()
428 .map(evalexpr::Value::Float)
429 .unwrap_or_else(|| evalexpr::Value::String(d.to_string_fixed())),
430 Value::Currency(c) => c
431 .to_f64()
432 .map(evalexpr::Value::Float)
433 .unwrap_or_else(|| evalexpr::Value::String(c.to_string_fixed())),
434 }
435}
436
437pub fn parse_decimal_literal(raw: &str) -> Result<Decimal> {
438 let trimmed = raw.trim();
439 if trimmed.is_empty() {
440 bail!("Decimal value is empty");
441 }
442
443 let mut negative = false;
444 let mut body = trimmed;
445 if body.starts_with('(') && body.ends_with(')') {
446 negative = true;
447 body = &body[1..body.len() - 1];
448 }
449
450 body = body.trim();
451 if body.starts_with('-') {
452 negative = true;
453 body = &body[1..];
454 } else if body.starts_with('+') {
455 body = &body[1..];
456 }
457
458 body = body.trim();
459 let mut sanitized = String::with_capacity(body.len() + 1);
460 let mut decimal_seen = false;
461 for ch in body.chars() {
462 match ch {
463 '0'..='9' => sanitized.push(ch),
464 '.' => {
465 if decimal_seen {
466 bail!("Decimal value '{raw}' contains multiple decimal points");
467 }
468 decimal_seen = true;
469 sanitized.push(ch);
470 }
471 ',' | '_' | ' ' => {
472 }
474 _ => {
475 bail!("Decimal value '{raw}' contains unsupported character '{ch}'");
476 }
477 }
478 }
479
480 ensure!(
481 sanitized.chars().any(|c| c.is_ascii_digit()),
482 "Decimal value '{raw}' does not contain digits"
483 );
484
485 if negative {
486 sanitized.insert(0, '-');
487 }
488
489 Decimal::from_str(&sanitized).with_context(|| format!("Parsing '{raw}' as decimal"))
490}
491
492fn format_decimal_with_scale(mut value: Decimal, scale: usize) -> String {
493 let target_scale = scale as u32;
494 if value.scale() < target_scale {
495 value.rescale(target_scale);
496 }
497 if scale == 0 {
498 let mut rendered = value.to_string();
499 if let Some(idx) = rendered.find('.') {
500 rendered.truncate(idx);
501 }
502 return rendered;
503 }
504 let rendered = value.to_string();
505 let actual = rendered
506 .split_once('.')
507 .map(|(_, frac)| frac.len())
508 .unwrap_or(0);
509 if actual == scale {
510 return rendered;
511 }
512 if let Some((whole, frac)) = rendered.split_once('.') {
513 let mut buf = String::new();
514 buf.push_str(whole);
515 buf.push('.');
516 buf.push_str(frac);
517 for _ in 0..(scale.saturating_sub(actual)) {
518 buf.push('0');
519 }
520 return buf;
521 }
522 let mut buf = String::new();
523 buf.push_str(&rendered);
524 buf.push('.');
525 for _ in 0..scale {
526 buf.push('0');
527 }
528 buf
529}
530
531fn count_integer_digits(decimal: &Decimal) -> usize {
532 let abs = decimal.abs();
533 if abs < Decimal::ONE {
534 return 0;
535 }
536 abs.trunc()
537 .to_string()
538 .chars()
539 .filter(|c| c.is_ascii_digit())
540 .count()
541}
542
543pub fn parse_currency_decimal(raw: &str) -> Result<Decimal> {
544 let trimmed = raw.trim();
545 if trimmed.is_empty() {
546 bail!("Currency value is empty");
547 }
548
549 let mut negative = false;
550 let mut body = trimmed;
551 if body.starts_with('(') && body.ends_with(')') {
552 negative = true;
553 body = &body[1..body.len() - 1];
554 }
555
556 body = body.trim();
557 if body.starts_with('-') {
558 negative = true;
559 body = &body[1..];
560 } else if body.starts_with('+') {
561 body = &body[1..];
562 }
563
564 body = body.trim();
565 let mut sanitized = String::with_capacity(body.len() + 1);
566 let mut decimal_seen = false;
567 for ch in body.chars() {
568 match ch {
569 '0'..='9' => sanitized.push(ch),
570 '.' => {
571 if decimal_seen {
572 bail!("Currency value '{raw}' contains multiple decimal points");
573 }
574 decimal_seen = true;
575 sanitized.push(ch);
576 }
577 ',' | '_' | ' ' => {
578 }
580 '$' | '€' | '£' | '¥' => {
581 }
583 _ => {
584 bail!("Currency value '{raw}' contains unsupported character '{ch}'");
585 }
586 }
587 }
588
589 ensure!(
590 sanitized.chars().any(|c| c.is_ascii_digit()),
591 "Currency value '{raw}' does not contain digits"
592 );
593
594 if negative {
595 sanitized.insert(0, '-');
596 }
597
598 Decimal::from_str(&sanitized).with_context(|| format!("Parsing '{raw}' as decimal"))
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use crate::schema::{ColumnType, DecimalSpec};
605 use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
606 use evalexpr::Value as EvalValue;
607 use rust_decimal::Decimal;
608 use std::str::FromStr;
609 use uuid::Uuid;
610
611 #[test]
612 fn normalize_column_name_replaces_non_alphanumeric() {
613 assert_eq!(normalize_column_name("Order ID"), "order_id");
614 assert_eq!(normalize_column_name("$Percent%"), "_percent_");
615 assert_eq!(normalize_column_name("123Metric"), "_123metric");
616 assert_eq!(normalize_column_name(""), "column");
617 }
618
619 #[test]
620 fn parse_naive_date_supports_multiple_formats() {
621 let expected = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
622 assert_eq!(parse_naive_date("2024-05-06").unwrap(), expected);
623 assert_eq!(parse_naive_date("06/05/2024").unwrap(), expected);
624 assert_eq!(parse_naive_date("2024/05/06").unwrap(), expected);
625 }
626
627 #[test]
628 fn parse_naive_datetime_supports_multiple_formats() {
629 let expected =
630 NaiveDateTime::parse_from_str("2024-05-06 14:30:00", "%Y-%m-%d %H:%M:%S").unwrap();
631 assert_eq!(
632 parse_naive_datetime("2024-05-06T14:30:00").unwrap(),
633 expected
634 );
635 assert_eq!(
636 parse_naive_datetime("06/05/2024 14:30:00").unwrap(),
637 expected
638 );
639 assert_eq!(parse_naive_datetime("2024-05-06 14:30").unwrap(), expected);
640 }
641
642 #[test]
643 fn parse_naive_time_supports_multiple_formats() {
644 let expected = NaiveTime::from_hms_opt(14, 30, 0).unwrap();
645 assert_eq!(parse_naive_time("14:30:00").unwrap(), expected);
646 assert_eq!(parse_naive_time("14:30").unwrap(), expected);
647 assert!(parse_naive_time("24:61").is_err());
648 }
649
650 #[test]
651 fn parse_typed_value_handles_empty_and_boolean_inputs() {
652 assert_eq!(parse_typed_value("", &ColumnType::Integer).unwrap(), None);
653
654 let truthy = parse_typed_value("Yes", &ColumnType::Boolean)
655 .unwrap()
656 .unwrap();
657 assert_eq!(truthy, Value::Boolean(true));
658
659 let falsy = parse_typed_value("0", &ColumnType::Boolean)
660 .unwrap()
661 .unwrap();
662 assert_eq!(falsy, Value::Boolean(false));
663
664 assert!(parse_typed_value("maybe", &ColumnType::Boolean).is_err());
665 }
666
667 #[test]
668 fn parse_typed_value_supports_guid_inputs() {
669 let raw = "550e8400-e29b-41d4-a716-446655440000";
670 let parsed = parse_typed_value(raw, &ColumnType::Guid).unwrap().unwrap();
671 match parsed {
672 Value::Guid(g) => {
673 assert_eq!(g, Uuid::parse_str(raw).unwrap());
674 }
675 other => panic!("Expected GUID value, got {other:?}"),
676 }
677
678 let braced = "{550e8400-e29b-41d4-a716-446655440000}";
679 let parsed_braced = parse_typed_value(braced, &ColumnType::Guid)
680 .unwrap()
681 .unwrap();
682 assert!(matches!(parsed_braced, Value::Guid(_)));
683
684 assert!(parse_typed_value("not-a-guid", &ColumnType::Guid).is_err());
685 }
686
687 #[test]
688 fn value_to_evalexpr_preserves_variants() {
689 assert_eq!(value_to_evalexpr(&Value::Integer(42)), EvalValue::Int(42));
690 assert_eq!(
691 value_to_evalexpr(&Value::Boolean(false)),
692 EvalValue::Boolean(false)
693 );
694
695 let date = NaiveDate::from_ymd_opt(2024, 5, 6).unwrap();
696 assert_eq!(
697 value_to_evalexpr(&Value::Date(date)),
698 EvalValue::String("2024-05-06".to_string())
699 );
700 }
701
702 #[test]
703 fn comparable_value_orders_none_before_some() {
704 let none = ComparableValue(None);
705 let some = ComparableValue(Some(Value::Integer(0)));
706 assert!(none < some);
707 }
708
709 #[test]
710 fn parse_currency_values_accepts_two_and_four_decimals() {
711 let two = parse_typed_value("$1,234.56", &ColumnType::Currency)
712 .unwrap()
713 .unwrap();
714 let four = parse_typed_value("123.4567", &ColumnType::Currency)
715 .unwrap()
716 .unwrap();
717 match (two, four) {
718 (Value::Currency(a), Value::Currency(b)) => {
719 assert_eq!(a.scale(), 2);
720 assert_eq!(a.to_string_fixed(), "1234.56");
721 assert_eq!(b.scale(), 4);
722 assert_eq!(b.to_string_fixed(), "123.4567");
723 }
724 _ => panic!("Expected currency values"),
725 }
726 }
727
728 #[test]
729 fn parse_currency_rejects_invalid_precision() {
730 assert!(parse_typed_value("1.234", &ColumnType::Currency).is_err());
731 assert!(parse_typed_value("abc", &ColumnType::Currency).is_err());
732 }
733
734 #[test]
735 fn parse_currency_rejects_embedded_letters() {
736 let err = parse_typed_value("12a.34", &ColumnType::Currency)
737 .expect_err("currency parser should reject embedded letters");
738 assert!(err.to_string().contains("contains unsupported character"));
739 }
740
741 #[test]
742 fn currency_quantize_rounds_half_away_from_zero() {
743 let decimal = Decimal::from_str("10.005").unwrap();
744 let value = CurrencyValue::quantize(decimal, 2, None).expect("round currency");
745 assert_eq!(value.to_string_fixed(), "10.01");
746 }
747
748 #[test]
749 fn currency_quantize_truncates_values() {
750 let decimal = Decimal::from_str("7.899").unwrap();
751 let value =
752 CurrencyValue::quantize(decimal, 2, Some("truncate")).expect("truncate currency");
753 assert_eq!(value.to_string_fixed(), "7.89");
754 }
755
756 #[test]
757 fn currency_quantize_truncates_four_decimal_precision() {
758 let decimal = Decimal::from_str("1.234567").unwrap();
759 let value =
760 CurrencyValue::quantize(decimal, 4, Some("truncate")).expect("truncate currency");
761 assert_eq!(value.to_string_fixed(), "1.2345");
762 }
763
764 #[test]
765 fn currency_quantize_rejects_invalid_strategy() {
766 let decimal = Decimal::from_str("1.00").unwrap();
767 assert!(CurrencyValue::quantize(decimal, 2, Some("ceil")).is_err());
768 }
769
770 #[test]
771 fn currency_quantize_rejects_invalid_scale() {
772 let decimal = Decimal::from_str("1.00").unwrap();
773 assert!(CurrencyValue::quantize(decimal, 3, None).is_err());
774 }
775
776 #[test]
777 fn currency_to_string_fixed_pads_fractional_zeros() {
778 let value = CurrencyValue::parse("42").expect("parse integer currency");
779 assert_eq!(value.to_string_fixed(), "42.00");
780 }
781
782 #[test]
783 fn fixed_decimal_value_truncate_strategy_respects_scale() {
784 let spec = DecimalSpec::new(8, 2).expect("valid decimal spec");
785 let decimal = Decimal::from_str("123.456").expect("valid decimal literal");
786 let value = FixedDecimalValue::from_decimal(decimal, &spec, Some("truncate"))
787 .expect("truncate decimal");
788 assert_eq!(value.to_string_fixed(), "123.45");
789 assert_eq!(value.scale(), 2);
790 }
791
792 #[test]
793 fn fixed_decimal_value_round_strategy_respects_scale() {
794 let spec = DecimalSpec::new(10, 3).expect("valid decimal spec");
795 let decimal = Decimal::from_str("-87.6549").expect("valid decimal literal");
796 let value =
797 FixedDecimalValue::from_decimal(decimal, &spec, Some("round")).expect("round decimal");
798 assert_eq!(value.to_string_fixed(), "-87.655");
799 assert_eq!(value.scale(), 3);
800 }
801
802 #[test]
803 fn fixed_decimal_value_rejects_precision_overflow() {
804 let spec = DecimalSpec::new(6, 2).expect("valid decimal spec");
805 let decimal = Decimal::from_str("12345.67").expect("decimal literal");
806 let err =
807 FixedDecimalValue::from_decimal(decimal, &spec, None).expect_err("precision overflow");
808 assert!(err.to_string().contains("must not exceed"));
809 }
810
811 #[test]
812 fn fixed_decimal_value_rescales_short_fractional_parts() {
813 let spec = DecimalSpec::new(12, 4).expect("valid decimal spec");
814 let decimal = Decimal::from_str("42").expect("whole number decimal");
815 let value = FixedDecimalValue::from_decimal(decimal, &spec, None).expect("rescale decimal");
816 assert_eq!(value.to_string_fixed(), "42.0000");
817 assert_eq!(value.scale(), 4);
818 }
819
820 #[test]
821 fn parse_decimal_literal_supports_parentheses_and_separators() {
822 let parsed = parse_decimal_literal("(1,234.50)").expect("parse negative grouped decimal");
823 assert_eq!(parsed, Decimal::from_str("-1234.50").unwrap());
824 }
825
826 #[test]
827 fn parse_decimal_literal_supports_positive_sign_and_underscores() {
828 let parsed = parse_decimal_literal(" +7_654.321 ").expect("parse underscored decimal");
829 assert_eq!(parsed, Decimal::from_str("7654.321").unwrap());
830 }
831
832 #[test]
833 fn parse_decimal_literal_rejects_invalid_characters() {
834 assert!(parse_decimal_literal("12a.34").is_err());
835 assert!(parse_decimal_literal("#42.0").is_err());
836 }
837
838 #[test]
839 fn parse_decimal_literal_rejects_multiple_decimal_points() {
840 assert!(parse_decimal_literal("1.2.3").is_err());
841 }
842
843 #[test]
844 fn parse_decimal_values_enforce_precision_and_scale() {
845 let spec = DecimalSpec::new(10, 4).expect("valid decimal spec");
846 let decimal_type = ColumnType::Decimal(spec.clone());
847 let parsed = parse_typed_value("123.4567", &decimal_type)
848 .expect("parse decimal")
849 .expect("non-empty decimal");
850 match parsed {
851 Value::Decimal(value) => {
852 assert_eq!(value.scale(), 4);
853 assert_eq!(value.precision(), 10);
854 assert_eq!(value.to_string_fixed(), "123.4567");
855 }
856 other => panic!("Expected decimal value, got {other:?}"),
857 }
858
859 let narrow_spec = DecimalSpec::new(6, 2).expect("valid decimal spec");
860 let narrow_type = ColumnType::Decimal(narrow_spec);
861 assert!(parse_typed_value("123.456", &narrow_type).is_err());
862 assert!(parse_typed_value("1234567", &narrow_type).is_err());
863 }
864}