Skip to main content

allsource_core/domain/value_objects/
projection_name.rs

1use crate::error::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4
5/// Value Object: ProjectionName
6///
7/// Represents the name of a projection in the event store.
8/// Projections are materialized views derived from event streams.
9///
10/// Domain Rules:
11/// - Cannot be empty
12/// - Must be between 1 and 100 characters
13/// - Must be alphanumeric with hyphens/underscores only
14/// - Case-sensitive
15/// - Immutable once created
16/// - Must be unique within a tenant
17///
18/// This is a Value Object:
19/// - Defined by its value, not identity
20/// - Immutable
21/// - Self-validating
22/// - Compared by value equality
23#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
24pub struct ProjectionName(String);
25
26impl ProjectionName {
27    /// Create a new ProjectionName with validation
28    ///
29    /// # Errors
30    /// Returns error if:
31    /// - Name is empty
32    /// - Name is longer than 100 characters
33    /// - Name contains invalid characters
34    ///
35    /// # Examples
36    /// ```
37    /// use allsource_core::domain::value_objects::ProjectionName;
38    ///
39    /// let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
40    /// assert_eq!(name.as_str(), "user_snapshot");
41    /// ```
42    pub fn new(value: String) -> Result<Self> {
43        Self::validate(&value)?;
44        Ok(Self(value))
45    }
46
47    /// Create ProjectionName without validation (for internal use, e.g., from trusted storage)
48    ///
49    /// # Safety
50    /// This bypasses validation. Only use when loading from trusted sources
51    /// where validation has already occurred.
52    pub(crate) fn new_unchecked(value: String) -> Self {
53        Self(value)
54    }
55
56    /// Get the string value
57    pub fn as_str(&self) -> &str {
58        &self.0
59    }
60
61    /// Get the inner String (consumes self)
62    pub fn into_inner(self) -> String {
63        self.0
64    }
65
66    /// Check if this name starts with a prefix
67    ///
68    /// Useful for grouping projections by naming convention.
69    ///
70    /// # Examples
71    /// ```
72    /// use allsource_core::domain::value_objects::ProjectionName;
73    ///
74    /// let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
75    /// assert!(name.starts_with("user"));
76    /// assert!(!name.starts_with("order"));
77    /// ```
78    pub fn starts_with(&self, prefix: &str) -> bool {
79        self.0.starts_with(prefix)
80    }
81
82    /// Check if this name ends with a suffix
83    ///
84    /// Useful for identifying projection types by naming convention.
85    ///
86    /// # Examples
87    /// ```
88    /// use allsource_core::domain::value_objects::ProjectionName;
89    ///
90    /// let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
91    /// assert!(name.ends_with("snapshot"));
92    /// assert!(!name.ends_with("counter"));
93    /// ```
94    pub fn ends_with(&self, suffix: &str) -> bool {
95        self.0.ends_with(suffix)
96    }
97
98    /// Check if this name contains a substring
99    pub fn contains(&self, pattern: &str) -> bool {
100        self.0.contains(pattern)
101    }
102
103    /// Validate a projection name string
104    fn validate(value: &str) -> Result<()> {
105        // Rule: Cannot be empty
106        if value.is_empty() {
107            return Err(crate::error::AllSourceError::InvalidInput(
108                "Projection name cannot be empty".to_string(),
109            ));
110        }
111
112        // Rule: Maximum length 100 characters
113        if value.len() > 100 {
114            return Err(crate::error::AllSourceError::InvalidInput(format!(
115                "Projection name cannot exceed 100 characters, got {}",
116                value.len()
117            )));
118        }
119
120        // Rule: Only alphanumeric, hyphens, and underscores
121        if !value
122            .chars()
123            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
124        {
125            return Err(crate::error::AllSourceError::InvalidInput(format!(
126                "Projection name '{}' must be alphanumeric with hyphens or underscores",
127                value
128            )));
129        }
130
131        // Rule: Cannot start or end with hyphen/underscore
132        if value.starts_with('-')
133            || value.starts_with('_')
134            || value.ends_with('-')
135            || value.ends_with('_')
136        {
137            return Err(crate::error::AllSourceError::InvalidInput(format!(
138                "Projection name '{}' cannot start or end with hyphen or underscore",
139                value
140            )));
141        }
142
143        Ok(())
144    }
145}
146
147impl fmt::Display for ProjectionName {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        write!(f, "{}", self.0)
150    }
151}
152
153impl TryFrom<&str> for ProjectionName {
154    type Error = crate::error::AllSourceError;
155
156    fn try_from(value: &str) -> Result<Self> {
157        ProjectionName::new(value.to_string())
158    }
159}
160
161impl TryFrom<String> for ProjectionName {
162    type Error = crate::error::AllSourceError;
163
164    fn try_from(value: String) -> Result<Self> {
165        ProjectionName::new(value)
166    }
167}
168
169impl AsRef<str> for ProjectionName {
170    fn as_ref(&self) -> &str {
171        &self.0
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn test_create_valid_names() {
181        // Simple name
182        let name = ProjectionName::new("user_snapshot".to_string());
183        assert!(name.is_ok());
184        assert_eq!(name.unwrap().as_str(), "user_snapshot");
185
186        // With hyphen
187        let name = ProjectionName::new("event-counter".to_string());
188        assert!(name.is_ok());
189
190        // Just alphanumeric
191        let name = ProjectionName::new("projection123".to_string());
192        assert!(name.is_ok());
193
194        // Mixed
195        let name = ProjectionName::new("user-account_snapshot123".to_string());
196        assert!(name.is_ok());
197    }
198
199    #[test]
200    fn test_reject_empty_name() {
201        let result = ProjectionName::new("".to_string());
202        assert!(result.is_err());
203
204        if let Err(e) = result {
205            assert!(e.to_string().contains("cannot be empty"));
206        }
207    }
208
209    #[test]
210    fn test_reject_too_long_name() {
211        let long_name = "a".repeat(101);
212        let result = ProjectionName::new(long_name);
213        assert!(result.is_err());
214
215        if let Err(e) = result {
216            assert!(e.to_string().contains("cannot exceed 100 characters"));
217        }
218    }
219
220    #[test]
221    fn test_accept_max_length_name() {
222        let max_name = "a".repeat(100);
223        let result = ProjectionName::new(max_name);
224        assert!(result.is_ok());
225    }
226
227    #[test]
228    fn test_reject_invalid_characters() {
229        // Space
230        let result = ProjectionName::new("user snapshot".to_string());
231        assert!(result.is_err());
232
233        // Dot
234        let result = ProjectionName::new("user.snapshot".to_string());
235        assert!(result.is_err());
236
237        // Colon
238        let result = ProjectionName::new("user:snapshot".to_string());
239        assert!(result.is_err());
240
241        // Special characters
242        let result = ProjectionName::new("user@snapshot".to_string());
243        assert!(result.is_err());
244    }
245
246    #[test]
247    fn test_reject_starting_with_special_char() {
248        let result = ProjectionName::new("-projection".to_string());
249        assert!(result.is_err());
250
251        let result = ProjectionName::new("_projection".to_string());
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn test_reject_ending_with_special_char() {
257        let result = ProjectionName::new("projection-".to_string());
258        assert!(result.is_err());
259
260        let result = ProjectionName::new("projection_".to_string());
261        assert!(result.is_err());
262    }
263
264    #[test]
265    fn test_starts_with() {
266        let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
267        assert!(name.starts_with("user"));
268        assert!(name.starts_with("user_"));
269        assert!(!name.starts_with("order"));
270    }
271
272    #[test]
273    fn test_ends_with() {
274        let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
275        assert!(name.ends_with("snapshot"));
276        assert!(name.ends_with("_snapshot"));
277        assert!(!name.ends_with("counter"));
278    }
279
280    #[test]
281    fn test_contains() {
282        let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
283        assert!(name.contains("user"));
284        assert!(name.contains("_"));
285        assert!(name.contains("snap"));
286        assert!(!name.contains("order"));
287    }
288
289    #[test]
290    fn test_display_trait() {
291        let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
292        assert_eq!(format!("{}", name), "user_snapshot");
293    }
294
295    #[test]
296    fn test_try_from_str() {
297        let name: Result<ProjectionName> = "user_snapshot".try_into();
298        assert!(name.is_ok());
299        assert_eq!(name.unwrap().as_str(), "user_snapshot");
300
301        let invalid: Result<ProjectionName> = "".try_into();
302        assert!(invalid.is_err());
303    }
304
305    #[test]
306    fn test_try_from_string() {
307        let name: Result<ProjectionName> = "event_counter".to_string().try_into();
308        assert!(name.is_ok());
309
310        let invalid: Result<ProjectionName> = String::new().try_into();
311        assert!(invalid.is_err());
312    }
313
314    #[test]
315    fn test_into_inner() {
316        let name = ProjectionName::new("test_projection".to_string()).unwrap();
317        let inner = name.into_inner();
318        assert_eq!(inner, "test_projection");
319    }
320
321    #[test]
322    fn test_equality() {
323        let name1 = ProjectionName::new("user_snapshot".to_string()).unwrap();
324        let name2 = ProjectionName::new("user_snapshot".to_string()).unwrap();
325        let name3 = ProjectionName::new("order_snapshot".to_string()).unwrap();
326
327        assert_eq!(name1, name2);
328        assert_ne!(name1, name3);
329    }
330
331    #[test]
332    fn test_cloning() {
333        let name1 = ProjectionName::new("test_projection".to_string()).unwrap();
334        let name2 = name1.clone();
335        assert_eq!(name1, name2);
336    }
337
338    #[test]
339    fn test_hash_consistency() {
340        use std::collections::HashSet;
341
342        let name1 = ProjectionName::new("user_snapshot".to_string()).unwrap();
343        let name2 = ProjectionName::new("user_snapshot".to_string()).unwrap();
344
345        let mut set = HashSet::new();
346        set.insert(name1);
347
348        assert!(set.contains(&name2));
349    }
350
351    #[test]
352    fn test_serde_serialization() {
353        let name = ProjectionName::new("user_snapshot".to_string()).unwrap();
354
355        // Serialize
356        let json = serde_json::to_string(&name).unwrap();
357        assert_eq!(json, "\"user_snapshot\"");
358
359        // Deserialize
360        let deserialized: ProjectionName = serde_json::from_str(&json).unwrap();
361        assert_eq!(deserialized, name);
362    }
363
364    #[test]
365    fn test_as_ref() {
366        let name = ProjectionName::new("test_projection".to_string()).unwrap();
367        let str_ref: &str = name.as_ref();
368        assert_eq!(str_ref, "test_projection");
369    }
370
371    #[test]
372    fn test_new_unchecked() {
373        // Should create without validation (for internal use)
374        let name = ProjectionName::new_unchecked("invalid name!".to_string());
375        assert_eq!(name.as_str(), "invalid name!");
376    }
377}