1use std::{cmp::Ordering, collections::HashSet, fmt, num::NonZeroU64, str::FromStr};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use snafu::prelude::*;
8
9#[derive(Debug, Snafu, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum ParseTimeIndexGranularityError {
13 #[snafu(display("time index granularity is empty"))]
15 Empty,
16
17 #[snafu(display("time index granularity '{spec}' is missing a numeric value"))]
19 MissingNumber {
20 spec: String,
22 },
23
24 #[snafu(display(
26 "time index granularity '{spec}' is missing a unit suffix (expected s|m|h|d)"
27 ))]
28 MissingUnit {
29 spec: String,
31 },
32
33 #[snafu(display("invalid index granularity value in '{spec}': {source}"))]
35 InvalidNumber {
36 spec: String,
38 source: std::num::ParseIntError,
40 },
41
42 #[snafu(display("index granularity value must be > 0 (got {value}) in '{spec}'"))]
44 NonPositive {
45 spec: String,
47 value: u64,
49 },
50
51 #[snafu(display("index granularity value too large for u32 (got {value}) in '{spec}'"))]
53 TooLarge {
54 spec: String,
56 value: u64,
58 },
59
60 #[snafu(display(
62 "unknown time index granularity unit '{unit}' in '{spec}' (expected s|m|h|d)"
63 ))]
64 UnknownUnit {
65 spec: String,
67 unit: String,
69 },
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
77pub enum TimeIndexGranularity {
78 Seconds(u32),
80 Minutes(u32),
82 Hours(u32),
84 Days(u32),
86}
87
88impl FromStr for TimeIndexGranularity {
89 type Err = ParseTimeIndexGranularityError;
90
91 fn from_str(input: &str) -> Result<Self, Self::Err> {
92 let spec = input.trim();
93 if spec.is_empty() {
94 return Err(ParseTimeIndexGranularityError::Empty);
95 }
96
97 let unit_start = spec
99 .char_indices()
100 .find(|(_, c)| c.is_ascii_alphabetic())
101 .map(|(i, _)| i);
102
103 let Some(unit_start) = unit_start else {
104 return Err(ParseTimeIndexGranularityError::MissingUnit {
105 spec: spec.to_string(),
106 });
107 };
108
109 if unit_start == 0 {
110 return Err(ParseTimeIndexGranularityError::MissingNumber {
112 spec: spec.to_string(),
113 });
114 }
115
116 let (num_str, unit_str) = spec.split_at(unit_start);
117 let num_str = num_str.trim();
118 let unit_str = unit_str.trim();
119
120 if unit_str.is_empty() {
121 return Err(ParseTimeIndexGranularityError::MissingUnit {
122 spec: spec.to_string(),
123 });
124 }
125
126 let value: u64 =
127 num_str
128 .parse()
129 .map_err(|source| ParseTimeIndexGranularityError::InvalidNumber {
130 spec: spec.to_string(),
131 source,
132 })?;
133
134 if value == 0 {
135 return Err(ParseTimeIndexGranularityError::NonPositive {
136 spec: spec.to_string(),
137 value,
138 });
139 }
140
141 if value > u32::MAX as u64 {
142 return Err(ParseTimeIndexGranularityError::TooLarge {
143 spec: spec.to_string(),
144 value,
145 });
146 }
147
148 let v = value as u32;
149 let unit = unit_str.to_ascii_lowercase();
150
151 match unit.as_str() {
152 "s" | "sec" | "secs" | "second" | "seconds" => Ok(TimeIndexGranularity::Seconds(v)),
153 "m" | "min" | "mins" | "minute" | "minutes" => Ok(TimeIndexGranularity::Minutes(v)),
154 "h" | "hr" | "hrs" | "hour" | "hours" => Ok(TimeIndexGranularity::Hours(v)),
155 "d" | "day" | "days" => Ok(TimeIndexGranularity::Days(v)),
156 _ => Err(ParseTimeIndexGranularityError::UnknownUnit {
157 spec: spec.to_string(),
158 unit: unit_str.to_string(),
159 }),
160 }
161 }
162}
163
164impl TimeIndexGranularity {
165 pub fn parse(spec: &str) -> Result<Self, ParseTimeIndexGranularityError> {
175 spec.parse()
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
181#[serde(deny_unknown_fields)]
182pub struct IndexSpec {
183 pub column: String,
185
186 #[serde(default)]
188 pub entity_columns: Vec<String>,
189
190 pub kind: IndexKind,
192}
193
194impl IndexSpec {
195 pub fn validate(&self) -> Result<(), IndexSpecError> {
202 if self.column.is_empty() {
203 return Err(IndexSpecError::EmptyColumn);
204 }
205
206 let mut seen = HashSet::with_capacity(self.entity_columns.len());
207 for (position, column) in self.entity_columns.iter().enumerate() {
208 if column.is_empty() {
209 return Err(IndexSpecError::EmptyEntityColumn { position });
210 }
211 if column == &self.column {
212 return Err(IndexSpecError::EntityColumnMatchesIndex {
213 column: column.clone(),
214 });
215 }
216 if !seen.insert(column) {
217 return Err(IndexSpecError::DuplicateEntityColumn {
218 column: column.clone(),
219 });
220 }
221 }
222
223 self.kind.validate()
224 }
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
229#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
230pub enum IndexKind {
231 Timestamp {
233 index_granularity: TimeIndexGranularity,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 timezone: Option<String>,
238 },
239 Int64 {
241 index_granularity: NonZeroU64,
243 },
244 #[serde(rename = "uint64")]
246 UInt64 {
247 index_granularity: NonZeroU64,
249 },
250}
251
252impl IndexKind {
253 pub fn name(&self) -> &'static str {
255 match self {
256 Self::Timestamp { .. } => "timestamp",
257 Self::Int64 { .. } => "int64",
258 Self::UInt64 { .. } => "uint64",
259 }
260 }
261
262 pub fn validate(&self) -> Result<(), IndexSpecError> {
264 if let Self::Timestamp {
265 index_granularity, ..
266 } = self
267 {
268 let width = match index_granularity {
269 TimeIndexGranularity::Seconds(width)
270 | TimeIndexGranularity::Minutes(width)
271 | TimeIndexGranularity::Hours(width)
272 | TimeIndexGranularity::Days(width) => *width,
273 };
274 if width == 0 {
275 return Err(IndexSpecError::ZeroTimeIndexGranularity);
276 }
277 }
278 Ok(())
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
284#[serde(
285 tag = "type",
286 content = "value",
287 rename_all = "snake_case",
288 deny_unknown_fields
289)]
290pub enum IndexValue {
291 Timestamp(DateTime<Utc>),
293 Int64(i64),
295 UInt64(u64),
297}
298
299impl IndexValue {
300 pub fn kind_name(&self) -> &'static str {
302 match self {
303 Self::Timestamp(_) => "timestamp",
304 Self::Int64(_) => "int64",
305 Self::UInt64(_) => "uint64",
306 }
307 }
308
309 pub fn compare(&self, other: &Self) -> Result<Ordering, IndexValueError> {
314 match (self, other) {
315 (Self::Timestamp(left), Self::Timestamp(right)) => Ok(left.cmp(right)),
316 (Self::Int64(left), Self::Int64(right)) => Ok(left.cmp(right)),
317 (Self::UInt64(left), Self::UInt64(right)) => Ok(left.cmp(right)),
318 _ => Err(IndexValueError::DomainMismatch {
319 left: self.kind_name(),
320 right: other.kind_name(),
321 }),
322 }
323 }
324
325 pub fn validate_kind(&self, kind: &IndexKind) -> Result<(), IndexValueError> {
330 if self.kind_name() == kind.name() {
331 Ok(())
332 } else {
333 Err(IndexValueError::KindMismatch {
334 expected: kind.name(),
335 actual: self.kind_name(),
336 })
337 }
338 }
339}
340
341impl fmt::Display for IndexValue {
342 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343 match self {
344 Self::Timestamp(value) => write!(f, "timestamp({value})"),
345 Self::Int64(value) => write!(f, "int64({value})"),
346 Self::UInt64(value) => write!(f, "uint64({value})"),
347 }
348 }
349}
350
351impl From<DateTime<Utc>> for IndexValue {
352 fn from(value: DateTime<Utc>) -> Self {
353 Self::Timestamp(value)
354 }
355}
356
357impl From<i64> for IndexValue {
358 fn from(value: i64) -> Self {
359 Self::Int64(value)
360 }
361}
362
363impl From<u64> for IndexValue {
364 fn from(value: u64) -> Self {
365 Self::UInt64(value)
366 }
367}
368
369pub fn validate_index_range(
375 kind: &IndexKind,
376 start: &IndexValue,
377 end: &IndexValue,
378) -> Result<(), IndexValueError> {
379 start.validate_kind(kind)?;
380 end.validate_kind(kind)?;
381 if start.compare(end)? != Ordering::Less {
382 return Err(IndexValueError::InvalidRange {
383 start: start.clone(),
384 end: end.clone(),
385 });
386 }
387 Ok(())
388}
389
390#[derive(Debug, Snafu, PartialEq, Eq)]
392#[non_exhaustive]
393pub enum IndexSpecError {
394 #[snafu(display("ordered index column is empty"))]
396 EmptyColumn,
397 #[snafu(display("entity column at position {position} is empty"))]
399 EmptyEntityColumn {
400 position: usize,
402 },
403 #[snafu(display("duplicate entity column: {column}"))]
405 DuplicateEntityColumn {
406 column: String,
408 },
409 #[snafu(display("entity column cannot also be the ordered index column: {column}"))]
411 EntityColumnMatchesIndex {
412 column: String,
414 },
415 #[snafu(display("timestamp index granularity must be nonzero"))]
417 ZeroTimeIndexGranularity,
418}
419
420#[derive(Debug, Snafu, PartialEq, Eq)]
422#[non_exhaustive]
423pub enum IndexValueError {
424 #[snafu(display("ordered index domain mismatch: left={left}, right={right}"))]
426 DomainMismatch {
427 left: &'static str,
429 right: &'static str,
431 },
432 #[snafu(display("ordered index kind mismatch: expected {expected}, found {actual}"))]
434 KindMismatch {
435 expected: &'static str,
437 actual: &'static str,
439 },
440 #[snafu(display(
442 "invalid ordered index range: start={start}, end={end} (expected start < end)"
443 ))]
444 InvalidRange {
445 start: IndexValue,
447 end: IndexValue,
449 },
450 #[snafu(display("invalid ordered index bounds: min={min}, max={max} (expected min <= max)"))]
452 InvalidBounds {
453 min: IndexValue,
455 max: IndexValue,
457 },
458}
459
460#[cfg(test)]
461mod tests {
462 use chrono::TimeZone;
463
464 use super::*;
465
466 fn sample_time_index_spec() -> IndexSpec {
467 IndexSpec {
468 column: "ts".to_string(),
469 entity_columns: vec!["symbol".to_string()],
470 kind: IndexKind::Timestamp {
471 index_granularity: TimeIndexGranularity::Minutes(1),
472 timezone: None,
473 },
474 }
475 }
476
477 #[test]
478 fn index_spec_json_roundtrips_all_domains() {
479 let cases = [
480 (
481 sample_time_index_spec(),
482 serde_json::json!({
483 "column": "ts",
484 "entity_columns": ["symbol"],
485 "kind": {
486 "type": "timestamp",
487 "index_granularity": {"Minutes": 1}
488 }
489 }),
490 ),
491 (
492 IndexSpec {
493 column: "sequence".to_string(),
494 entity_columns: Vec::new(),
495 kind: IndexKind::Int64 {
496 index_granularity: NonZeroU64::new(u64::MAX).unwrap(),
497 },
498 },
499 serde_json::json!({
500 "column": "sequence",
501 "entity_columns": [],
502 "kind": {
503 "type": "int64",
504 "index_granularity": u64::MAX
505 }
506 }),
507 ),
508 (
509 IndexSpec {
510 column: "offset".to_string(),
511 entity_columns: vec!["source".to_string()],
512 kind: IndexKind::UInt64 {
513 index_granularity: NonZeroU64::new(7).unwrap(),
514 },
515 },
516 serde_json::json!({
517 "column": "offset",
518 "entity_columns": ["source"],
519 "kind": {
520 "type": "uint64",
521 "index_granularity": 7
522 }
523 }),
524 ),
525 ];
526
527 for (spec, expected_json) in cases {
528 let json = serde_json::to_value(&spec).unwrap();
529 assert_eq!(json, expected_json);
530 let restored: IndexSpec = serde_json::from_value(json).unwrap();
531 assert_eq!(restored, spec);
532 }
533 }
534
535 #[test]
536 fn index_spec_json_rejects_impossible_field_combinations() {
537 let timestamp_with_integer_granularity = r#"{
538 "column":"ts","kind":{"type":"timestamp","index_granularity":1}
539 }"#;
540 let integer_with_time_granularity_object = r#"{
541 "column":"id","kind":{"type":"int64","index_granularity":{"Seconds":1}}
542 }"#;
543 let zero_integer_granularity =
544 r#"{"column":"id","kind":{"type":"uint64","index_granularity":0}}"#;
545
546 assert!(serde_json::from_str::<IndexSpec>(timestamp_with_integer_granularity).is_err());
547 assert!(serde_json::from_str::<IndexSpec>(integer_with_time_granularity_object).is_err());
548 assert!(serde_json::from_str::<IndexSpec>(zero_integer_granularity).is_err());
549 }
550
551 #[test]
552 fn index_spec_validation_rejects_invalid_structure_and_time_granularity() {
553 let mut spec = sample_time_index_spec();
554 spec.column.clear();
555 assert_eq!(spec.validate(), Err(IndexSpecError::EmptyColumn));
556
557 let mut spec = sample_time_index_spec();
558 spec.entity_columns.push("symbol".to_string());
559 assert!(matches!(
560 spec.validate(),
561 Err(IndexSpecError::DuplicateEntityColumn { .. })
562 ));
563
564 let mut spec = sample_time_index_spec();
565 spec.entity_columns = vec![spec.column.clone()];
566 assert_eq!(
567 spec.validate(),
568 Err(IndexSpecError::EntityColumnMatchesIndex {
569 column: "ts".to_string(),
570 })
571 );
572
573 let mut spec = sample_time_index_spec();
574 spec.kind = IndexKind::Timestamp {
575 index_granularity: TimeIndexGranularity::Seconds(0),
576 timezone: None,
577 };
578 assert_eq!(
579 spec.validate(),
580 Err(IndexSpecError::ZeroTimeIndexGranularity)
581 );
582 }
583
584 #[test]
585 fn index_value_roundtrips_and_compares_integer_extremes() {
586 let timestamp = Utc.timestamp_opt(1, 987_654_321).single().unwrap();
587 let values = [
588 IndexValue::Timestamp(timestamp),
589 IndexValue::Int64(i64::MIN),
590 IndexValue::Int64(i64::MAX),
591 IndexValue::UInt64(0),
592 IndexValue::UInt64(u64::MAX),
593 ];
594
595 for value in values {
596 let json = serde_json::to_string(&value).unwrap();
597 assert_eq!(serde_json::from_str::<IndexValue>(&json).unwrap(), value);
598 assert_eq!(value.compare(&value).unwrap(), Ordering::Equal);
599 }
600 assert_eq!(
601 IndexValue::Int64(i64::MIN)
602 .compare(&IndexValue::Int64(i64::MAX))
603 .unwrap(),
604 Ordering::Less
605 );
606 assert_eq!(
607 IndexValue::UInt64(u64::MAX)
608 .compare(&IndexValue::UInt64(0))
609 .unwrap(),
610 Ordering::Greater
611 );
612 }
613
614 #[test]
615 fn index_value_cross_domain_comparison_and_ranges_are_typed_errors() {
616 assert_eq!(
617 IndexValue::Int64(0).compare(&IndexValue::UInt64(0)),
618 Err(IndexValueError::DomainMismatch {
619 left: "int64",
620 right: "uint64"
621 })
622 );
623
624 let kind = IndexKind::UInt64 {
625 index_granularity: NonZeroU64::new(1).unwrap(),
626 };
627 assert!(matches!(
628 validate_index_range(&kind, &IndexValue::Int64(0), &IndexValue::Int64(1)),
629 Err(IndexValueError::KindMismatch { .. })
630 ));
631 assert!(matches!(
632 validate_index_range(&kind, &IndexValue::UInt64(1), &IndexValue::UInt64(1)),
633 Err(IndexValueError::InvalidRange { .. })
634 ));
635 }
636
637 #[test]
638 fn time_index_granularity_parse_accepts_basic_units() {
639 let cases = [
640 ("1s", TimeIndexGranularity::Seconds(1)),
641 ("2m", TimeIndexGranularity::Minutes(2)),
642 ("3h", TimeIndexGranularity::Hours(3)),
643 ("4d", TimeIndexGranularity::Days(4)),
644 ];
645
646 for (input, expected) in cases {
647 assert_eq!(input.parse::<TimeIndexGranularity>().unwrap(), expected);
648 }
649 }
650
651 #[test]
652 fn time_index_granularity_parse_accepts_aliases_case_and_whitespace() {
653 let cases = [
654 ("1sec", TimeIndexGranularity::Seconds(1)),
655 ("1secs", TimeIndexGranularity::Seconds(1)),
656 ("1second", TimeIndexGranularity::Seconds(1)),
657 ("1seconds", TimeIndexGranularity::Seconds(1)),
658 ("1min", TimeIndexGranularity::Minutes(1)),
659 ("1mins", TimeIndexGranularity::Minutes(1)),
660 ("1minute", TimeIndexGranularity::Minutes(1)),
661 ("1minutes", TimeIndexGranularity::Minutes(1)),
662 ("1hr", TimeIndexGranularity::Hours(1)),
663 ("1hrs", TimeIndexGranularity::Hours(1)),
664 ("1hour", TimeIndexGranularity::Hours(1)),
665 ("1hours", TimeIndexGranularity::Hours(1)),
666 ("1day", TimeIndexGranularity::Days(1)),
667 ("1days", TimeIndexGranularity::Days(1)),
668 ("1H", TimeIndexGranularity::Hours(1)),
669 ("1MiN", TimeIndexGranularity::Minutes(1)),
670 (" 2h", TimeIndexGranularity::Hours(2)),
671 ("3d ", TimeIndexGranularity::Days(3)),
672 (" 4m ", TimeIndexGranularity::Minutes(4)),
673 ("1 h", TimeIndexGranularity::Hours(1)),
674 ];
675
676 for (input, expected) in cases {
677 assert_eq!(input.parse::<TimeIndexGranularity>().unwrap(), expected);
678 }
679 }
680
681 #[test]
682 fn time_index_granularity_parse_rejects_empty_or_whitespace() {
683 let cases = ["", " ", "\n\t"];
684 for input in cases {
685 let err = input.parse::<TimeIndexGranularity>().unwrap_err();
686 assert!(matches!(err, ParseTimeIndexGranularityError::Empty));
687 }
688 }
689
690 #[test]
691 fn time_index_granularity_parse_rejects_missing_number() {
692 let cases = ["h", " hr", "day", "abcmin"];
693 for input in cases {
694 let err = input.parse::<TimeIndexGranularity>().unwrap_err();
695 assert!(
696 matches!(err, ParseTimeIndexGranularityError::MissingNumber { .. }),
697 "expected MissingNumber for {input:?}, got {err:?}"
698 );
699 }
700 }
701
702 #[test]
703 fn time_index_granularity_parse_rejects_missing_unit() {
704 let cases = ["1", " 42 "];
705 for input in cases {
706 let err = input.parse::<TimeIndexGranularity>().unwrap_err();
707 assert!(
708 matches!(err, ParseTimeIndexGranularityError::MissingUnit { .. }),
709 "expected MissingUnit for {input:?}, got {err:?}"
710 );
711 }
712 }
713
714 #[test]
715 fn time_index_granularity_parse_rejects_invalid_number() {
716 let cases = ["1.5h", "1_000s"];
717 for input in cases {
718 let err = input.parse::<TimeIndexGranularity>().unwrap_err();
719 assert!(
720 matches!(err, ParseTimeIndexGranularityError::InvalidNumber { .. }),
721 "expected InvalidNumber for {input:?}, got {err:?}"
722 );
723 }
724 }
725
726 #[test]
727 fn time_index_granularity_parse_rejects_non_positive() {
728 let cases = ["0s", "0m"];
729 for input in cases {
730 let err = input.parse::<TimeIndexGranularity>().unwrap_err();
731 assert!(
732 matches!(
733 err,
734 ParseTimeIndexGranularityError::NonPositive { value: 0, .. }
735 ),
736 "expected NonPositive for {input:?}, got {err:?}"
737 );
738 }
739 }
740
741 #[test]
742 fn time_index_granularity_parse_rejects_too_large() {
743 let too_large = (u32::MAX as u64 + 1).to_string();
744 let input = format!("{too_large}h");
745 let err = input.parse::<TimeIndexGranularity>().unwrap_err();
746 assert!(
747 matches!(err, ParseTimeIndexGranularityError::TooLarge { value, .. } if value == u32::MAX as u64 + 1),
748 "expected TooLarge for {input:?}, got {err:?}"
749 );
750 }
751
752 #[test]
753 fn time_index_granularity_parse_rejects_unknown_units() {
754 let cases = ["1w", "1ms", "1mo", "10msec"];
755 for input in cases {
756 let err = input.parse::<TimeIndexGranularity>().unwrap_err();
757 assert!(
758 matches!(err, ParseTimeIndexGranularityError::UnknownUnit { .. }),
759 "expected UnknownUnit for {input:?}, got {err:?}"
760 );
761 }
762 }
763
764 #[test]
765 fn time_index_granularity_parse_matches_from_str() {
766 let via_method = TimeIndexGranularity::parse("5m").unwrap();
767 let via_trait: TimeIndexGranularity = "5m".parse().unwrap();
768 assert_eq!(via_method, via_trait);
769 }
770}