Skip to main content

reinhardt_urls/proxy/
joins.rs

1//! Join operations for proxy relationships
2
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5
6/// Configuration for join operations on proxy relationships.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct JoinConfig {
9	/// Whether to eagerly load the relationship.
10	pub eager_load: bool,
11	/// Maximum depth for nested relationship traversal.
12	pub max_depth: Option<usize>,
13	/// Strategy to use when loading the relationship.
14	#[serde(skip)]
15	pub loading_strategy: Option<crate::proxy::LoadingStrategy>,
16	/// SQL join type (e.g., `"LEFT JOIN"`, `"INNER JOIN"`).
17	pub join_type: Option<String>,
18	/// SQL join condition expression.
19	pub condition: Option<String>,
20}
21
22impl JoinConfig {
23	/// Create a new `JoinConfig` with default values.
24	pub fn new() -> Self {
25		Self {
26			eager_load: false,
27			max_depth: None,
28			loading_strategy: None,
29			join_type: None,
30			condition: None,
31		}
32	}
33
34	/// Set the loading strategy (builder pattern)
35	///
36	/// # Examples
37	///
38	/// ```
39	/// use reinhardt_urls::proxy::{JoinConfig, LoadingStrategy};
40	///
41	/// let config = JoinConfig::new()
42	///     .with_loading_strategy(LoadingStrategy::Joined);
43	/// ```
44	pub fn with_loading_strategy(mut self, strategy: crate::proxy::LoadingStrategy) -> Self {
45		self.loading_strategy = Some(strategy);
46		self
47	}
48
49	/// Set the join type (builder pattern)
50	///
51	/// # Examples
52	///
53	/// ```
54	/// use reinhardt_urls::proxy::JoinConfig;
55	///
56	/// let config = JoinConfig::new()
57	///     .with_join_type("LEFT JOIN");
58	/// ```
59	pub fn with_join_type(mut self, join_type: &str) -> Self {
60		self.join_type = Some(join_type.to_string());
61		self
62	}
63
64	/// Set the join condition (builder pattern)
65	///
66	/// # Examples
67	///
68	/// ```
69	/// use reinhardt_urls::proxy::JoinConfig;
70	///
71	/// let config = JoinConfig::new()
72	///     .with_condition("users.id = posts.user_id");
73	/// ```
74	pub fn with_condition(mut self, condition: &str) -> Self {
75		self.condition = Some(condition.to_string());
76		self
77	}
78}
79
80impl Default for JoinConfig {
81	fn default() -> Self {
82		Self::new()
83	}
84}
85
86// LoadingStrategy is now re-exported from reinhardt-orm in lib.rs
87
88/// A proxy for traversing nested relationships with conditions.
89#[derive(Debug, Clone)]
90pub struct NestedProxy {
91	/// Sequence of relationship names forming the nested path.
92	pub path: Vec<String>,
93	/// Filter conditions applied along the path.
94	pub conditions: Vec<String>,
95	/// Final attribute to extract from the deepest relationship.
96	pub final_attribute: Option<String>,
97}
98
99impl NestedProxy {
100	/// Create a new empty nested proxy
101	///
102	/// # Examples
103	///
104	/// ```
105	/// use reinhardt_urls::proxy::NestedProxy;
106	///
107	/// let proxy = NestedProxy::new();
108	/// assert_eq!(proxy.depth(), 0);
109	/// ```
110	pub fn new() -> Self {
111		Self {
112			path: Vec::new(),
113			conditions: Vec::new(),
114			final_attribute: None,
115		}
116	}
117
118	/// Create from a path vector (for backward compatibility)
119	pub fn from_path(path: Vec<String>) -> Self {
120		Self {
121			path,
122			conditions: Vec::new(),
123			final_attribute: None,
124		}
125	}
126
127	/// Add a level to the nested path (builder pattern)
128	///
129	/// # Examples
130	///
131	/// ```
132	/// use reinhardt_urls::proxy::NestedProxy;
133	///
134	/// let proxy = NestedProxy::new()
135	///     .add_level("posts")
136	///     .add_level("comments");
137	/// assert_eq!(proxy.depth(), 2);
138	/// ```
139	pub fn add_level(mut self, level: &str) -> Self {
140		self.path.push(level.to_string());
141		self
142	}
143
144	/// Add a condition to the nested proxy (builder pattern)
145	///
146	/// # Examples
147	///
148	/// ```
149	/// use reinhardt_urls::proxy::NestedProxy;
150	///
151	/// let proxy = NestedProxy::new()
152	///     .add_level("posts")
153	///     .with_condition("published = true");
154	/// assert_eq!(proxy.conditions().len(), 1);
155	/// ```
156	pub fn with_condition(mut self, condition: &str) -> Self {
157		self.conditions.push(condition.to_string());
158		self
159	}
160
161	/// Set the final attribute to access (builder pattern)
162	///
163	/// # Examples
164	///
165	/// ```
166	/// use reinhardt_urls::proxy::NestedProxy;
167	///
168	/// let proxy = NestedProxy::new()
169	///     .add_level("posts")
170	///     .with_attribute("title");
171	/// assert_eq!(proxy.attribute(), "title");
172	/// ```
173	pub fn with_attribute(mut self, attr: &str) -> Self {
174		self.final_attribute = Some(attr.to_string());
175		self
176	}
177
178	/// Get the depth (number of levels) in the nested path
179	///
180	/// # Examples
181	///
182	/// ```
183	/// use reinhardt_urls::proxy::NestedProxy;
184	///
185	/// let proxy = NestedProxy::new()
186	///     .add_level("posts")
187	///     .add_level("comments")
188	///     .add_level("author");
189	/// assert_eq!(proxy.depth(), 3);
190	/// ```
191	pub fn depth(&self) -> usize {
192		self.path.len()
193	}
194
195	/// Get the final attribute name
196	///
197	/// # Examples
198	///
199	/// ```
200	/// use reinhardt_urls::proxy::NestedProxy;
201	///
202	/// let proxy = NestedProxy::new()
203	///     .add_level("posts")
204	///     .with_attribute("title");
205	/// assert_eq!(proxy.attribute(), "title");
206	/// ```
207	pub fn attribute(&self) -> &str {
208		self.final_attribute.as_deref().unwrap_or("")
209	}
210
211	/// Get all conditions
212	///
213	/// # Examples
214	///
215	/// ```
216	/// use reinhardt_urls::proxy::NestedProxy;
217	///
218	/// let proxy = NestedProxy::new()
219	///     .add_level("posts")
220	///     .with_condition("published = true")
221	///     .add_level("comments")
222	///     .with_condition("approved = true");
223	/// assert_eq!(proxy.conditions().len(), 2);
224	/// ```
225	pub fn conditions(&self) -> &[String] {
226		&self.conditions
227	}
228}
229
230impl Default for NestedProxy {
231	fn default() -> Self {
232		Self::new()
233	}
234}
235
236/// A path through relationships with circular reference detection
237///
238/// RelationshipPath provides a builder API for constructing paths through
239/// model relationships while detecting and preventing circular references.
240///
241/// # Examples
242///
243/// ```rust,no_run
244/// # use reinhardt_urls::proxy::RelationshipPath;
245/// // Valid path: user -> posts -> comments
246/// let path = RelationshipPath::new()
247///     .through("posts")
248///     .through("comments")
249///     .attribute("content");
250///
251/// // Circular path: posts -> author -> posts (ERROR)
252/// let result = RelationshipPath::new()
253///     .try_through("posts").unwrap()
254///     .try_through("author").unwrap()
255///     .try_through("posts");  // This creates a cycle
256/// assert!(result.is_err());
257/// ```
258#[derive(Debug, Clone)]
259pub struct RelationshipPath {
260	/// Sequence of relationship names in the path
261	pub segments: Vec<String>,
262	/// Set of visited relationship names for cycle detection
263	visited: HashSet<String>,
264	/// Filters applied at each relationship level
265	filters: HashMap<String, Vec<(String, String)>>,
266	/// Transformations applied at each relationship level
267	transforms: HashMap<String, Vec<(String, String)>>,
268	/// Final attribute to access
269	attribute: Option<String>,
270}
271
272impl RelationshipPath {
273	/// Create a new empty relationship path
274	///
275	/// # Examples
276	///
277	/// ```
278	/// use reinhardt_urls::proxy::RelationshipPath;
279	///
280	/// let path = RelationshipPath::new();
281	/// assert_eq!(path.path().len(), 0);
282	/// ```
283	pub fn new() -> Self {
284		Self {
285			segments: Vec::new(),
286			visited: HashSet::new(),
287			filters: HashMap::new(),
288			transforms: HashMap::new(),
289			attribute: None,
290		}
291	}
292
293	/// Add a relationship to the path
294	///
295	/// Returns self for chaining. Use `try_through()` if you need error handling.
296	///
297	/// # Examples
298	///
299	/// ```
300	/// use reinhardt_urls::proxy::RelationshipPath;
301	///
302	/// let path = RelationshipPath::new()
303	///     .through("posts")
304	///     .through("comments");
305	/// assert_eq!(path.path().len(), 2);
306	/// ```
307	pub fn through(mut self, relationship: &str) -> Self {
308		let rel = relationship.to_string();
309		self.segments.push(rel.clone());
310		self.visited.insert(rel);
311		self
312	}
313
314	/// Add a relationship to the path with error handling for circular references
315	///
316	/// # Errors
317	///
318	/// Returns an error if adding this relationship would create a cycle.
319	///
320	/// # Examples
321	///
322	/// ```
323	/// use reinhardt_urls::proxy::RelationshipPath;
324	///
325	/// let path = RelationshipPath::new()
326	///     .try_through("posts").unwrap()
327	///     .try_through("author").unwrap();
328	///
329	/// // This would create a cycle
330	/// let result = path.try_through("posts");
331	/// assert!(result.is_err());
332	/// ```
333	pub fn try_through(mut self, relationship: &str) -> Result<Self, CircularReferenceError> {
334		let rel = relationship.to_string();
335
336		if self.visited.contains(&rel) {
337			return Err(CircularReferenceError {
338				relationship: rel,
339				path: self.segments.clone(),
340			});
341		}
342
343		self.segments.push(rel.clone());
344		self.visited.insert(rel);
345		Ok(self)
346	}
347
348	/// Add a filter at the current relationship level
349	///
350	/// # Examples
351	///
352	/// ```
353	/// use reinhardt_urls::proxy::RelationshipPath;
354	///
355	/// let path = RelationshipPath::new()
356	///     .through("posts")
357	///     .with_filter("published", "true");
358	/// assert!(path.has_filters());
359	/// ```
360	pub fn with_filter(mut self, field: &str, value: &str) -> Self {
361		let current_rel = self.segments.last().cloned().unwrap_or_default();
362		self.filters
363			.entry(current_rel)
364			.or_default()
365			.push((field.to_string(), value.to_string()));
366		self
367	}
368
369	/// Add a transformation at a specific relationship level
370	///
371	/// # Examples
372	///
373	/// ```
374	/// use reinhardt_urls::proxy::RelationshipPath;
375	///
376	/// let path = RelationshipPath::new()
377	///     .through("posts")
378	///     .through("comments")
379	///     .with_transform("author", "upper");
380	/// assert!(path.has_transforms());
381	/// ```
382	pub fn with_transform(mut self, relationship: &str, transform: &str) -> Self {
383		self.transforms
384			.entry(relationship.to_string())
385			.or_default()
386			.push((relationship.to_string(), transform.to_string()));
387		self
388	}
389
390	/// Set the final attribute to access
391	///
392	/// # Examples
393	///
394	/// ```
395	/// use reinhardt_urls::proxy::RelationshipPath;
396	///
397	/// let path = RelationshipPath::new()
398	///     .through("posts")
399	///     .through("comments")
400	///     .attribute("content");
401	/// assert_eq!(path.get_attribute(), "content");
402	/// ```
403	pub fn attribute(mut self, attr: &str) -> Self {
404		self.attribute = Some(attr.to_string());
405		self
406	}
407
408	/// Get the path segments
409	pub fn path(&self) -> &[String] {
410		&self.segments
411	}
412
413	/// Get the final attribute name
414	pub fn get_attribute(&self) -> &str {
415		self.attribute.as_deref().unwrap_or("")
416	}
417
418	/// Check if any filters are configured
419	pub fn has_filters(&self) -> bool {
420		!self.filters.is_empty()
421	}
422
423	/// Get all filters
424	pub fn filters(&self) -> Vec<(String, String)> {
425		self.filters
426			.values()
427			.flat_map(|v| v.iter().cloned())
428			.collect()
429	}
430
431	/// Check if any transformations are configured
432	pub fn has_transforms(&self) -> bool {
433		!self.transforms.is_empty()
434	}
435
436	/// Get all transformations
437	pub fn transforms(&self) -> Vec<(String, String)> {
438		self.transforms
439			.values()
440			.flat_map(|v| v.iter().cloned())
441			.collect()
442	}
443
444	/// Check if a relationship is in the path (for cycle detection)
445	pub fn contains(&self, relationship: &str) -> bool {
446		self.visited.contains(relationship)
447	}
448
449	/// Validate the path and return self or error
450	///
451	/// This method is primarily for testing - the builder methods
452	/// already prevent invalid paths from being constructed.
453	pub fn validate(self) -> Result<Self, CircularReferenceError> {
454		// Path is already validated during construction
455		Ok(self)
456	}
457}
458
459impl Default for RelationshipPath {
460	fn default() -> Self {
461		Self::new()
462	}
463}
464
465/// Error returned when a circular reference is detected in a relationship path
466#[derive(Debug, Clone, thiserror::Error)]
467#[error("Circular reference detected: relationship '{relationship}' already exists in path {}", path_display(.path))]
468pub struct CircularReferenceError {
469	/// The relationship that would create a cycle
470	pub relationship: String,
471	/// The current path when the cycle was detected
472	pub path: Vec<String>,
473}
474
475fn path_display(path: &[String]) -> String {
476	if path.is_empty() {
477		"(empty)".to_string()
478	} else {
479		format!("[{}]", path.join(" -> "))
480	}
481}
482
483/// Extract relationship segments from a dot-separated path string.
484pub fn extract_through_path(path: &str) -> Vec<String> {
485	path.split('.').map(|s| s.to_string()).collect()
486}
487
488/// Check if any segment in a `RelationshipPath` matches the given predicate.
489pub fn filter_through_path(path: &RelationshipPath, predicate: impl Fn(&str) -> bool) -> bool {
490	path.segments.iter().any(|s| predicate(s))
491}
492
493/// Extract the path segments from a `NestedProxy`.
494pub fn traverse_and_extract(proxy: &NestedProxy) -> Vec<String> {
495	proxy.path.clone()
496}
497
498/// Extract the path segments from a `RelationshipPath`.
499pub fn traverse_relationships(path: &RelationshipPath) -> Vec<String> {
500	path.segments.clone()
501}
502
503#[cfg(test)]
504mod tests {
505	use super::*;
506
507	/// Test basic path construction without cycles
508	#[test]
509	fn test_relationship_path_no_cycle() {
510		let path = RelationshipPath::new()
511			.through("posts")
512			.through("comments")
513			.through("author")
514			.attribute("name");
515
516		assert_eq!(path.path().len(), 3);
517		assert_eq!(path.path()[0], "posts");
518		assert_eq!(path.path()[1], "comments");
519		assert_eq!(path.path()[2], "author");
520		assert_eq!(path.get_attribute(), "name");
521	}
522
523	/// Test simple cycle detection: A -> B -> A
524	#[test]
525	fn test_simple_cycle_detection() {
526		let path = RelationshipPath::new().through("posts").through("author");
527
528		// Try to add "posts" again - should create a cycle
529		let result = path.try_through("posts");
530
531		assert!(result.is_err());
532		let err = result.unwrap_err();
533		assert_eq!(err.relationship, "posts");
534		assert_eq!(err.path, vec!["posts", "author"]);
535	}
536
537	/// Test complex cycle detection: A -> B -> C -> A
538	#[test]
539	fn test_complex_cycle_detection() {
540		let path = RelationshipPath::new()
541			.through("user")
542			.through("posts")
543			.through("comments");
544
545		// Try to add "user" again - should create a cycle
546		let result = path.try_through("user");
547
548		assert!(result.is_err());
549		let err = result.unwrap_err();
550		assert_eq!(err.relationship, "user");
551		assert_eq!(err.path, vec!["user", "posts", "comments"]);
552	}
553
554	/// Test that different relationships don't trigger cycle detection
555	#[test]
556	fn test_no_false_positive_cycle() {
557		let path = RelationshipPath::new()
558			.through("posts")
559			.through("comments")
560			.through("author")
561			.through("profile");
562
563		assert_eq!(path.path().len(), 4);
564	}
565
566	/// Test contains method for cycle detection
567	#[test]
568	fn test_contains_relationship() {
569		let path = RelationshipPath::new().through("posts").through("comments");
570
571		assert!(path.contains("posts"));
572		assert!(path.contains("comments"));
573		assert!(!path.contains("author"));
574	}
575
576	/// Test path with filters
577	#[test]
578	fn test_path_with_filters() {
579		let path = RelationshipPath::new()
580			.through("posts")
581			.with_filter("published", "true")
582			.through("comments")
583			.attribute("content");
584
585		assert!(path.has_filters());
586		assert_eq!(path.filters().len(), 1);
587		assert_eq!(
588			path.filters()[0],
589			("published".to_string(), "true".to_string())
590		);
591	}
592
593	/// Test path with transforms
594	#[test]
595	fn test_path_with_transforms() {
596		let path = RelationshipPath::new()
597			.through("posts")
598			.through("comments")
599			.with_transform("author", "upper")
600			.attribute("name");
601
602		assert!(path.has_transforms());
603		assert_eq!(path.transforms().len(), 1);
604	}
605
606	/// Test multiple filters on different relationships
607	#[test]
608	fn test_multiple_filters() {
609		let path = RelationshipPath::new()
610			.through("posts")
611			.with_filter("published", "true")
612			.through("comments")
613			.with_filter("approved", "true")
614			.attribute("content");
615
616		assert!(path.has_filters());
617		assert_eq!(path.filters().len(), 2);
618	}
619
620	/// Test error message formatting
621	#[test]
622	fn test_error_message_format() {
623		let path = RelationshipPath::new().through("posts").through("author");
624
625		let result = path.try_through("posts");
626		assert!(result.is_err());
627
628		let err = result.unwrap_err();
629		let error_msg = err.to_string();
630		assert!(error_msg.contains("Circular reference detected"));
631		assert!(error_msg.contains("posts"));
632		assert!(error_msg.contains("author"));
633	}
634
635	/// Test default implementation
636	#[test]
637	fn test_default_path() {
638		let path = RelationshipPath::default();
639		assert_eq!(path.path().len(), 0);
640		assert!(!path.has_filters());
641		assert!(!path.has_transforms());
642	}
643
644	/// Test empty path display in error
645	#[test]
646	fn test_empty_path_error() {
647		let err = CircularReferenceError {
648			relationship: "posts".to_string(),
649			path: vec![],
650		};
651		let msg = err.to_string();
652		assert!(msg.contains("(empty)"));
653	}
654
655	/// Test clone functionality
656	#[test]
657	fn test_path_clone() {
658		let path1 = RelationshipPath::new()
659			.through("posts")
660			.through("comments")
661			.attribute("content");
662
663		let path2 = path1.clone();
664
665		assert_eq!(path1.path(), path2.path());
666		assert_eq!(path1.get_attribute(), path2.get_attribute());
667	}
668
669	/// Test using through() method (non-checked version)
670	#[test]
671	fn test_through_allows_cycles() {
672		// through() method doesn't check for cycles - it just adds to the path
673		// This is by design for backwards compatibility
674		let path = RelationshipPath::new()
675			.through("posts")
676			.through("author")
677			.through("posts"); // No error, just adds to path
678
679		assert_eq!(path.path().len(), 3);
680		// But the visited set will detect it
681		assert!(path.contains("posts"));
682	}
683
684	/// Test validate method
685	#[test]
686	fn test_validate_method() {
687		let path = RelationshipPath::new()
688			.through("posts")
689			.through("comments")
690			.attribute("content");
691
692		let result = path.validate();
693		assert!(result.is_ok());
694	}
695
696	/// Test path with only attribute (no relationships)
697	#[test]
698	fn test_attribute_only_path() {
699		let path = RelationshipPath::new().attribute("name");
700
701		assert_eq!(path.path().len(), 0);
702		assert_eq!(path.get_attribute(), "name");
703	}
704
705	/// Test filter on empty path (edge case)
706	#[test]
707	fn test_filter_on_empty_path() {
708		let path = RelationshipPath::new().with_filter("field", "value");
709
710		// Filter is added with empty relationship name
711		assert!(path.has_filters());
712	}
713}