laminar_connectors/mongodb/
timeseries.rs1use crate::error::ConnectorError;
16
17const MAX_CUSTOM_BUCKET_SPAN_SECONDS: u32 = 31_536_000;
18
19#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
21#[serde(tag = "kind", rename_all = "snake_case")]
22pub enum CollectionKind {
23 #[default]
25 Standard,
26 TimeSeries(TimeSeriesConfig),
28}
29
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
35pub struct TimeSeriesConfig {
36 pub time_field: String,
38
39 pub meta_field: Option<String>,
42
43 pub granularity: TimeSeriesGranularity,
45
46 pub expire_after_seconds: Option<u64>,
48}
49
50impl TimeSeriesConfig {
51 pub(crate) fn validate(&self) -> Result<(), ConnectorError> {
53 validate_field_name(&self.time_field, "time_field")?;
54 if let Some(meta_field) = self.meta_field.as_deref() {
55 validate_field_name(meta_field, "meta_field")?;
56 if meta_field == self.time_field {
57 return Err(ConnectorError::ConfigurationError(
58 "time series meta_field must differ from time_field".into(),
59 ));
60 }
61 if meta_field == "_id" {
62 return Err(ConnectorError::ConfigurationError(
63 "time series meta_field must not be '_id'".into(),
64 ));
65 }
66 }
67 if self
68 .expire_after_seconds
69 .is_some_and(|ttl| ttl > u64::try_from(i64::MAX).expect("i64::MAX fits u64"))
70 {
71 return Err(ConnectorError::ConfigurationError(
72 "time series expire_after_seconds exceeds MongoDB's signed 64-bit range".into(),
73 ));
74 }
75 if let TimeSeriesGranularity::Custom {
76 bucket_max_span_seconds,
77 bucket_rounding_seconds,
78 } = self.granularity
79 {
80 validate_custom_bucket(bucket_max_span_seconds, bucket_rounding_seconds)?;
81 }
82 Ok(())
83 }
84}
85
86fn validate_field_name(field: &str, label: &str) -> Result<(), ConnectorError> {
87 if field.trim().is_empty() {
88 return Err(ConnectorError::ConfigurationError(format!(
89 "time series {label} must not be empty"
90 )));
91 }
92 if field.contains('\0') {
93 return Err(ConnectorError::ConfigurationError(format!(
94 "time series {label} must not contain NUL"
95 )));
96 }
97 Ok(())
98}
99
100fn validate_custom_bucket(
101 bucket_max_span_seconds: u32,
102 bucket_rounding_seconds: u32,
103) -> Result<(), ConnectorError> {
104 if bucket_max_span_seconds != bucket_rounding_seconds {
105 return Err(ConnectorError::ConfigurationError(format!(
106 "time series custom granularity requires bucket_max_span_seconds ({bucket_max_span_seconds}) \
107 == bucket_rounding_seconds ({bucket_rounding_seconds})"
108 )));
109 }
110 if !(1..=MAX_CUSTOM_BUCKET_SPAN_SECONDS).contains(&bucket_max_span_seconds) {
111 return Err(ConnectorError::ConfigurationError(format!(
112 "time series custom granularity bucket_max_span_seconds must be between 1 and \
113 {MAX_CUSTOM_BUCKET_SPAN_SECONDS}"
114 )));
115 }
116 Ok(())
117}
118
119#[derive(
131 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
132)]
133#[serde(tag = "type", rename_all = "snake_case")]
134pub enum TimeSeriesGranularity {
135 #[default]
137 Seconds,
138 Minutes,
140 Hours,
142 Custom {
148 bucket_max_span_seconds: u32,
150 bucket_rounding_seconds: u32,
152 },
153}
154
155impl TimeSeriesGranularity {
156 pub fn custom(
163 bucket_max_span_seconds: u32,
164 bucket_rounding_seconds: u32,
165 ) -> Result<Self, ConnectorError> {
166 validate_custom_bucket(bucket_max_span_seconds, bucket_rounding_seconds)?;
167 Ok(Self::Custom {
168 bucket_max_span_seconds,
169 bucket_rounding_seconds,
170 })
171 }
172}
173
174impl std::fmt::Display for TimeSeriesGranularity {
175 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176 match self {
177 Self::Seconds => f.write_str("seconds"),
178 Self::Minutes => f.write_str("minutes"),
179 Self::Hours => f.write_str("hours"),
180 Self::Custom {
181 bucket_max_span_seconds,
182 ..
183 } => write!(f, "custom({bucket_max_span_seconds}s)"),
184 }
185 }
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn test_granularity_default() {
194 assert_eq!(
195 TimeSeriesGranularity::default(),
196 TimeSeriesGranularity::Seconds
197 );
198 }
199
200 #[test]
201 fn test_custom_granularity_valid() {
202 let g = TimeSeriesGranularity::custom(3600, 3600).unwrap();
203 assert!(matches!(g, TimeSeriesGranularity::Custom { .. }));
204 }
205
206 #[test]
207 fn test_custom_granularity_mismatch() {
208 let err = TimeSeriesGranularity::custom(3600, 1800).unwrap_err();
209 assert!(err.to_string().contains("bucket_max_span_seconds"));
210 }
211
212 #[test]
213 fn test_custom_granularity_zero() {
214 let err = TimeSeriesGranularity::custom(0, 0).unwrap_err();
215 assert!(err.to_string().contains("between 1"));
216 }
217
218 #[test]
219 fn custom_granularity_rejects_server_limit_overflow() {
220 let error = TimeSeriesGranularity::custom(31_536_001, 31_536_001).unwrap_err();
221 assert!(error.to_string().contains("31536000"));
222 }
223
224 #[test]
225 fn test_collection_kind_default() {
226 assert!(matches!(
227 CollectionKind::default(),
228 CollectionKind::Standard
229 ));
230 }
231
232 #[test]
233 fn test_granularity_display() {
234 assert_eq!(TimeSeriesGranularity::Seconds.to_string(), "seconds");
235 assert_eq!(TimeSeriesGranularity::Minutes.to_string(), "minutes");
236 assert_eq!(TimeSeriesGranularity::Hours.to_string(), "hours");
237 let custom = TimeSeriesGranularity::custom(7200, 7200).unwrap();
238 assert_eq!(custom.to_string(), "custom(7200s)");
239 }
240
241 #[test]
242 fn time_series_config_rejects_conflicting_metadata_and_ttl_overflow() {
243 let mut config = TimeSeriesConfig {
244 time_field: "ts".into(),
245 meta_field: Some("ts".into()),
246 granularity: TimeSeriesGranularity::Seconds,
247 expire_after_seconds: None,
248 };
249 assert!(config
250 .validate()
251 .unwrap_err()
252 .to_string()
253 .contains("differ"));
254
255 config.meta_field = Some("_id".into());
256 assert!(config.validate().unwrap_err().to_string().contains("_id"));
257
258 config.meta_field = None;
259 config.expire_after_seconds = Some(u64::try_from(i64::MAX).unwrap() + 1);
260 assert!(config
261 .validate()
262 .unwrap_err()
263 .to_string()
264 .contains("signed 64-bit"));
265 }
266
267 #[test]
268 fn config_validation_cannot_bypass_field_and_custom_bucket_invariants() {
269 let mut config = TimeSeriesConfig {
270 time_field: "bad\0field".into(),
271 meta_field: None,
272 granularity: TimeSeriesGranularity::Seconds,
273 expire_after_seconds: None,
274 };
275 assert!(config.validate().unwrap_err().to_string().contains("NUL"));
276
277 config.time_field = "ts".into();
278 config.granularity = TimeSeriesGranularity::Custom {
279 bucket_max_span_seconds: 60,
280 bucket_rounding_seconds: 30,
281 };
282 assert!(config
283 .validate()
284 .unwrap_err()
285 .to_string()
286 .contains("bucket_rounding_seconds"));
287 }
288}