Skip to main content

dbrest_core/schema_cache/
relationship.rs

1//! Relationship types for schema cache
2//!
3//! This module defines types for representing PostgreSQL foreign key relationships
4//! and computed (function-based) relationships.
5
6use compact_str::CompactString;
7use smallvec::SmallVec;
8
9use crate::types::QualifiedIdentifier;
10
11/// Foreign key relationship between two tables
12///
13/// Represents the relationship from one table to another via a foreign key constraint.
14#[derive(Debug, Clone)]
15pub struct Relationship {
16    /// Source table (the table containing the FK columns)
17    pub table: QualifiedIdentifier,
18    /// Target table (the table being referenced)
19    pub foreign_table: QualifiedIdentifier,
20    /// Whether this is a self-referencing relationship
21    pub is_self: bool,
22    /// Relationship cardinality (M2O, O2M, O2O, M2M)
23    pub cardinality: Cardinality,
24    /// Whether the source table is a view
25    pub table_is_view: bool,
26    /// Whether the target table is a view
27    pub foreign_table_is_view: bool,
28}
29
30impl Relationship {
31    /// Check if this is a to-one relationship (M2O or O2O)
32    ///
33    /// Returns true if following this relationship yields at most one row.
34    pub fn is_to_one(&self) -> bool {
35        matches!(
36            self.cardinality,
37            Cardinality::M2O { .. } | Cardinality::O2O { .. }
38        )
39    }
40
41    /// Check if this is a to-many relationship (O2M or M2M)
42    pub fn is_to_many(&self) -> bool {
43        matches!(
44            self.cardinality,
45            Cardinality::O2M { .. } | Cardinality::M2M(_)
46        )
47    }
48
49    /// Get the constraint name for this relationship
50    pub fn constraint_name(&self) -> &str {
51        match &self.cardinality {
52            Cardinality::M2O { constraint, .. } => constraint,
53            Cardinality::O2M { constraint, .. } => constraint,
54            Cardinality::O2O { constraint, .. } => constraint,
55            Cardinality::M2M(j) => &j.constraint1,
56        }
57    }
58
59    /// Get the column mappings for this relationship
60    ///
61    /// Returns pairs of (source_column, target_column).
62    pub fn columns(&self) -> &[(CompactString, CompactString)] {
63        match &self.cardinality {
64            Cardinality::M2O { columns, .. } => columns,
65            Cardinality::O2M { columns, .. } => columns,
66            Cardinality::O2O { columns, .. } => columns,
67            Cardinality::M2M(j) => &j.cols_source,
68        }
69    }
70
71    /// Get the source column names
72    pub fn source_columns(&self) -> impl Iterator<Item = &str> {
73        self.columns().iter().map(|(src, _)| src.as_str())
74    }
75
76    /// Get the target column names
77    pub fn target_columns(&self) -> impl Iterator<Item = &str> {
78        self.columns().iter().map(|(_, tgt)| tgt.as_str())
79    }
80
81    /// Check if this relationship uses a specific column
82    pub fn uses_column(&self, col_name: &str) -> bool {
83        self.columns()
84            .iter()
85            .any(|(src, tgt)| src.as_str() == col_name || tgt.as_str() == col_name)
86    }
87
88    /// Check if this is a many-to-many relationship
89    pub fn is_m2m(&self) -> bool {
90        matches!(self.cardinality, Cardinality::M2M(_))
91    }
92
93    /// Get the junction table if this is an M2M relationship
94    pub fn junction(&self) -> Option<&Junction> {
95        match &self.cardinality {
96            Cardinality::M2M(j) => Some(j),
97            _ => None,
98        }
99    }
100
101    /// Create the reverse direction of this relationship.
102    ///
103    /// Swaps `table` / `foreign_table` and flips cardinality:
104    /// - M2O → O2M (and vice-versa)
105    /// - O2O child → O2O parent (and vice-versa)
106    ///
107    /// Column pairs are swapped so `(src, tgt)` becomes `(tgt, src)`.
108    pub fn reverse(&self) -> Self {
109        let rev_cardinality = match &self.cardinality {
110            Cardinality::M2O {
111                constraint,
112                columns,
113            } => Cardinality::O2M {
114                constraint: constraint.clone(),
115                columns: columns
116                    .iter()
117                    .map(|(a, b)| (b.clone(), a.clone()))
118                    .collect(),
119            },
120            Cardinality::O2M {
121                constraint,
122                columns,
123            } => Cardinality::M2O {
124                constraint: constraint.clone(),
125                columns: columns
126                    .iter()
127                    .map(|(a, b)| (b.clone(), a.clone()))
128                    .collect(),
129            },
130            Cardinality::O2O {
131                constraint,
132                columns,
133                is_parent,
134            } => Cardinality::O2O {
135                constraint: constraint.clone(),
136                columns: columns
137                    .iter()
138                    .map(|(a, b)| (b.clone(), a.clone()))
139                    .collect(),
140                is_parent: !is_parent,
141            },
142            Cardinality::M2M(j) => Cardinality::M2M(j.clone()), // M2M is symmetric
143        };
144
145        Relationship {
146            table: self.foreign_table.clone(),
147            foreign_table: self.table.clone(),
148            is_self: self.is_self,
149            cardinality: rev_cardinality,
150            table_is_view: self.foreign_table_is_view,
151            foreign_table_is_view: self.table_is_view,
152        }
153    }
154
155    /// Check if this is an O2O relationship where we are the parent side
156    pub fn is_o2o_parent(&self) -> bool {
157        matches!(
158            &self.cardinality,
159            Cardinality::O2O {
160                is_parent: true,
161                ..
162            }
163        )
164    }
165
166    /// Check if this is an O2O relationship where we are the child side
167    pub fn is_o2o_child(&self) -> bool {
168        matches!(
169            &self.cardinality,
170            Cardinality::O2O {
171                is_parent: false,
172                ..
173            }
174        )
175    }
176}
177
178/// Relationship cardinality
179///
180/// Describes the cardinality of a relationship between two tables.
181#[derive(Debug, Clone)]
182pub enum Cardinality {
183    /// Many-to-One: the source table has an FK pointing to the target's PK
184    ///
185    /// Following this relationship from source yields at most one target row.
186    M2O {
187        /// Foreign key constraint name
188        constraint: CompactString,
189        /// Column mappings: (source_column, target_column)
190        columns: SmallVec<[(CompactString, CompactString); 2]>,
191    },
192
193    /// One-to-Many: the source table's PK is referenced by the target's FK
194    ///
195    /// Following this relationship from source yields potentially many target rows.
196    O2M {
197        /// Foreign key constraint name (on the target table)
198        constraint: CompactString,
199        /// Column mappings: (source_column, target_column)
200        columns: SmallVec<[(CompactString, CompactString); 2]>,
201    },
202
203    /// One-to-One: like M2O but the FK columns are also unique
204    ///
205    /// Following this relationship from either side yields at most one row.
206    O2O {
207        /// Foreign key constraint name
208        constraint: CompactString,
209        /// Column mappings: (source_column, target_column)
210        columns: SmallVec<[(CompactString, CompactString); 2]>,
211        /// Whether this is the parent side (referenced) or child side (referencing)
212        is_parent: bool,
213    },
214
215    /// Many-to-Many: relationship via a junction table
216    ///
217    /// Both tables are connected through an intermediate junction table.
218    M2M(Junction),
219}
220
221impl Cardinality {
222    /// Get a short string representation of this cardinality
223    pub fn as_str(&self) -> &'static str {
224        match self {
225            Cardinality::M2O { .. } => "M2O",
226            Cardinality::O2M { .. } => "O2M",
227            Cardinality::O2O { .. } => "O2O",
228            Cardinality::M2M(_) => "M2M",
229        }
230    }
231}
232
233/// Junction table for many-to-many relationships
234///
235/// Represents the intermediate table that connects two tables in an M2M relationship.
236#[derive(Debug, Clone)]
237pub struct Junction {
238    /// The junction table
239    pub table: QualifiedIdentifier,
240    /// FK constraint from junction to source table
241    pub constraint1: CompactString,
242    /// FK constraint from junction to target table
243    pub constraint2: CompactString,
244    /// Column mappings from junction to source: (junction_col, source_col)
245    pub cols_source: SmallVec<[(CompactString, CompactString); 2]>,
246    /// Column mappings from junction to target: (junction_col, target_col)
247    pub cols_target: SmallVec<[(CompactString, CompactString); 2]>,
248}
249
250impl Junction {
251    /// Get all junction table columns used in the relationship
252    pub fn junction_columns(&self) -> impl Iterator<Item = &str> {
253        self.cols_source
254            .iter()
255            .chain(self.cols_target.iter())
256            .map(|(junc_col, _)| junc_col.as_str())
257    }
258
259    /// Get the source table column names
260    pub fn source_columns(&self) -> impl Iterator<Item = &str> {
261        self.cols_source.iter().map(|(_, src_col)| src_col.as_str())
262    }
263
264    /// Get the target table column names
265    pub fn target_columns(&self) -> impl Iterator<Item = &str> {
266        self.cols_target.iter().map(|(_, tgt_col)| tgt_col.as_str())
267    }
268}
269
270/// Computed relationship (function-based)
271///
272/// A relationship defined by a function that takes a row from the source table
273/// and returns related rows.
274#[derive(Debug, Clone)]
275pub struct ComputedRelationship {
276    /// Source table
277    pub table: QualifiedIdentifier,
278    /// Function that computes the relationship
279    pub function: QualifiedIdentifier,
280    /// Target table (return type of the function)
281    pub foreign_table: QualifiedIdentifier,
282    /// Alias for the source table in the function context
283    pub table_alias: QualifiedIdentifier,
284    /// Whether this is a self-referencing relationship
285    pub is_self: bool,
286    /// Whether the function returns a single row
287    pub single_row: bool,
288}
289
290impl ComputedRelationship {
291    /// Check if this computed relationship returns multiple rows
292    pub fn returns_set(&self) -> bool {
293        !self.single_row
294    }
295}
296
297/// Either a FK relationship or computed relationship
298///
299/// Used to represent any type of relationship in a unified way.
300#[derive(Debug, Clone)]
301#[allow(clippy::large_enum_variant)] // Relationship is the common case, boxing hurts ergonomics
302pub enum AnyRelationship {
303    /// Standard foreign key relationship
304    ForeignKey(Relationship),
305    /// Function-based computed relationship
306    Computed(ComputedRelationship),
307}
308
309impl AnyRelationship {
310    /// Get the source table
311    pub fn table(&self) -> &QualifiedIdentifier {
312        match self {
313            AnyRelationship::ForeignKey(r) => &r.table,
314            AnyRelationship::Computed(r) => &r.table,
315        }
316    }
317
318    /// Get the target/foreign table
319    pub fn foreign_table(&self) -> &QualifiedIdentifier {
320        match self {
321            AnyRelationship::ForeignKey(r) => &r.foreign_table,
322            AnyRelationship::Computed(r) => &r.foreign_table,
323        }
324    }
325
326    /// Check if this is a self-referencing relationship
327    pub fn is_self(&self) -> bool {
328        match self {
329            AnyRelationship::ForeignKey(r) => r.is_self,
330            AnyRelationship::Computed(r) => r.is_self,
331        }
332    }
333
334    /// Check if this relationship yields at most one row
335    pub fn is_to_one(&self) -> bool {
336        match self {
337            AnyRelationship::ForeignKey(r) => r.is_to_one(),
338            AnyRelationship::Computed(r) => r.single_row,
339        }
340    }
341
342    /// Check if this is a foreign key relationship
343    pub fn is_fk(&self) -> bool {
344        matches!(self, AnyRelationship::ForeignKey(_))
345    }
346
347    /// Check if this is a computed relationship
348    pub fn is_computed(&self) -> bool {
349        matches!(self, AnyRelationship::Computed(_))
350    }
351
352    /// Get the foreign key relationship if this is one
353    pub fn as_fk(&self) -> Option<&Relationship> {
354        match self {
355            AnyRelationship::ForeignKey(r) => Some(r),
356            AnyRelationship::Computed(_) => None,
357        }
358    }
359
360    /// Get the computed relationship if this is one
361    pub fn as_computed(&self) -> Option<&ComputedRelationship> {
362        match self {
363            AnyRelationship::ForeignKey(_) => None,
364            AnyRelationship::Computed(r) => Some(r),
365        }
366    }
367}
368
369impl From<Relationship> for AnyRelationship {
370    fn from(r: Relationship) -> Self {
371        AnyRelationship::ForeignKey(r)
372    }
373}
374
375impl From<ComputedRelationship> for AnyRelationship {
376    fn from(r: ComputedRelationship) -> Self {
377        AnyRelationship::Computed(r)
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384    use crate::test_helpers::*;
385
386    // ========================================================================
387    // Relationship Tests
388    // ========================================================================
389
390    #[test]
391    fn test_relationship_is_to_one_m2o() {
392        let rel = test_relationship()
393            .m2o("fk_user", &[("user_id", "id")])
394            .build();
395        assert!(rel.is_to_one());
396        assert!(!rel.is_to_many());
397    }
398
399    #[test]
400    fn test_relationship_is_to_one_o2o() {
401        let rel = test_relationship()
402            .o2o("fk_profile", &[("user_id", "id")], false)
403            .build();
404        assert!(rel.is_to_one());
405        assert!(!rel.is_to_many());
406    }
407
408    #[test]
409    fn test_relationship_is_to_many_o2m() {
410        let rel = test_relationship()
411            .o2m("fk_posts", &[("id", "user_id")])
412            .build();
413        assert!(!rel.is_to_one());
414        assert!(rel.is_to_many());
415    }
416
417    #[test]
418    fn test_relationship_is_to_many_m2m() {
419        let junction = test_junction()
420            .table("public", "user_roles")
421            .cols_source(&[("user_id", "id")])
422            .cols_target(&[("role_id", "id")])
423            .build();
424
425        let rel = test_relationship().m2m(junction).build();
426        assert!(!rel.is_to_one());
427        assert!(rel.is_to_many());
428    }
429
430    #[test]
431    fn test_relationship_constraint_name() {
432        let rel = test_relationship()
433            .m2o("my_constraint", &[("fk_col", "pk_col")])
434            .build();
435        assert_eq!(rel.constraint_name(), "my_constraint");
436    }
437
438    #[test]
439    fn test_relationship_columns() {
440        let rel = test_relationship()
441            .m2o("fk_test", &[("col_a", "col_b"), ("col_c", "col_d")])
442            .build();
443
444        let cols = rel.columns();
445        assert_eq!(cols.len(), 2);
446        assert_eq!(cols[0].0.as_str(), "col_a");
447        assert_eq!(cols[0].1.as_str(), "col_b");
448    }
449
450    #[test]
451    fn test_relationship_source_columns() {
452        let rel = test_relationship()
453            .m2o("fk", &[("src1", "tgt1"), ("src2", "tgt2")])
454            .build();
455
456        let sources: Vec<_> = rel.source_columns().collect();
457        assert_eq!(sources, vec!["src1", "src2"]);
458    }
459
460    #[test]
461    fn test_relationship_target_columns() {
462        let rel = test_relationship()
463            .m2o("fk", &[("src1", "tgt1"), ("src2", "tgt2")])
464            .build();
465
466        let targets: Vec<_> = rel.target_columns().collect();
467        assert_eq!(targets, vec!["tgt1", "tgt2"]);
468    }
469
470    #[test]
471    fn test_relationship_uses_column() {
472        let rel = test_relationship().m2o("fk", &[("user_id", "id")]).build();
473
474        assert!(rel.uses_column("user_id"));
475        assert!(rel.uses_column("id"));
476        assert!(!rel.uses_column("name"));
477    }
478
479    #[test]
480    fn test_relationship_is_m2m() {
481        let junction = test_junction().build();
482        let m2m_rel = test_relationship().m2m(junction).build();
483        assert!(m2m_rel.is_m2m());
484
485        let m2o_rel = test_relationship().m2o("fk", &[("a", "b")]).build();
486        assert!(!m2o_rel.is_m2m());
487    }
488
489    #[test]
490    fn test_relationship_junction() {
491        let junction = test_junction().table("public", "user_roles").build();
492        let rel = test_relationship().m2m(junction).build();
493
494        let j = rel.junction().unwrap();
495        assert_eq!(j.table.name.as_str(), "user_roles");
496    }
497
498    #[test]
499    fn test_relationship_o2o_parent_child() {
500        let parent_rel = test_relationship()
501            .o2o("fk", &[("id", "user_id")], true)
502            .build();
503        assert!(parent_rel.is_o2o_parent());
504        assert!(!parent_rel.is_o2o_child());
505
506        let child_rel = test_relationship()
507            .o2o("fk", &[("user_id", "id")], false)
508            .build();
509        assert!(!child_rel.is_o2o_parent());
510        assert!(child_rel.is_o2o_child());
511    }
512
513    #[test]
514    fn test_relationship_is_self() {
515        let self_rel = test_relationship()
516            .table("public", "employees")
517            .foreign_table("public", "employees")
518            .is_self(true)
519            .build();
520        assert!(self_rel.is_self);
521
522        let normal_rel = test_relationship()
523            .table("public", "posts")
524            .foreign_table("public", "users")
525            .is_self(false)
526            .build();
527        assert!(!normal_rel.is_self);
528    }
529
530    // ========================================================================
531    // Cardinality Tests
532    // ========================================================================
533
534    #[test]
535    fn test_cardinality_as_str() {
536        assert_eq!(
537            Cardinality::M2O {
538                constraint: "fk".into(),
539                columns: smallvec::smallvec![]
540            }
541            .as_str(),
542            "M2O"
543        );
544        assert_eq!(
545            Cardinality::O2M {
546                constraint: "fk".into(),
547                columns: smallvec::smallvec![]
548            }
549            .as_str(),
550            "O2M"
551        );
552        assert_eq!(
553            Cardinality::O2O {
554                constraint: "fk".into(),
555                columns: smallvec::smallvec![],
556                is_parent: false
557            }
558            .as_str(),
559            "O2O"
560        );
561        assert_eq!(Cardinality::M2M(test_junction().build()).as_str(), "M2M");
562    }
563
564    // ========================================================================
565    // Junction Tests
566    // ========================================================================
567
568    #[test]
569    fn test_junction_columns() {
570        let junction = test_junction()
571            .cols_source(&[("user_id", "id")])
572            .cols_target(&[("role_id", "id")])
573            .build();
574
575        let junc_cols: Vec<_> = junction.junction_columns().collect();
576        assert_eq!(junc_cols, vec!["user_id", "role_id"]);
577    }
578
579    #[test]
580    fn test_junction_source_columns() {
581        let junction = test_junction().cols_source(&[("user_id", "id")]).build();
582
583        let cols: Vec<_> = junction.source_columns().collect();
584        assert_eq!(cols, vec!["id"]);
585    }
586
587    #[test]
588    fn test_junction_target_columns() {
589        let junction = test_junction().cols_target(&[("role_id", "id")]).build();
590
591        let cols: Vec<_> = junction.target_columns().collect();
592        assert_eq!(cols, vec!["id"]);
593    }
594
595    // ========================================================================
596    // ComputedRelationship Tests
597    // ========================================================================
598
599    #[test]
600    fn test_computed_rel_returns_set() {
601        let single_row = test_computed_rel().single_row(true).build();
602        assert!(!single_row.returns_set());
603
604        let multi_row = test_computed_rel().single_row(false).build();
605        assert!(multi_row.returns_set());
606    }
607
608    // ========================================================================
609    // AnyRelationship Tests
610    // ========================================================================
611
612    #[test]
613    fn test_any_relationship_table() {
614        let fk_rel: AnyRelationship = test_relationship().table("api", "posts").build().into();
615
616        assert_eq!(fk_rel.table().schema.as_str(), "api");
617        assert_eq!(fk_rel.table().name.as_str(), "posts");
618
619        let computed_rel: AnyRelationship =
620            test_computed_rel().table("api", "users").build().into();
621
622        assert_eq!(computed_rel.table().schema.as_str(), "api");
623        assert_eq!(computed_rel.table().name.as_str(), "users");
624    }
625
626    #[test]
627    fn test_any_relationship_foreign_table() {
628        let fk_rel: AnyRelationship = test_relationship()
629            .foreign_table("api", "users")
630            .build()
631            .into();
632
633        assert_eq!(fk_rel.foreign_table().name.as_str(), "users");
634    }
635
636    #[test]
637    fn test_any_relationship_is_self() {
638        let self_rel: AnyRelationship = test_relationship().is_self(true).build().into();
639        assert!(self_rel.is_self());
640
641        let computed_self: AnyRelationship = test_computed_rel().is_self(true).build().into();
642        assert!(computed_self.is_self());
643    }
644
645    #[test]
646    fn test_any_relationship_is_to_one() {
647        let m2o: AnyRelationship = test_relationship().m2o("fk", &[("a", "b")]).build().into();
648        assert!(m2o.is_to_one());
649
650        let o2m: AnyRelationship = test_relationship().o2m("fk", &[("a", "b")]).build().into();
651        assert!(!o2m.is_to_one());
652
653        let computed_single: AnyRelationship = test_computed_rel().single_row(true).build().into();
654        assert!(computed_single.is_to_one());
655
656        let computed_multi: AnyRelationship = test_computed_rel().single_row(false).build().into();
657        assert!(!computed_multi.is_to_one());
658    }
659
660    #[test]
661    fn test_any_relationship_is_fk_computed() {
662        let fk_rel: AnyRelationship = test_relationship().build().into();
663        assert!(fk_rel.is_fk());
664        assert!(!fk_rel.is_computed());
665
666        let computed_rel: AnyRelationship = test_computed_rel().build().into();
667        assert!(!computed_rel.is_fk());
668        assert!(computed_rel.is_computed());
669    }
670
671    #[test]
672    fn test_any_relationship_as_fk_computed() {
673        let fk_rel: AnyRelationship = test_relationship().build().into();
674        assert!(fk_rel.as_fk().is_some());
675        assert!(fk_rel.as_computed().is_none());
676
677        let computed_rel: AnyRelationship = test_computed_rel().build().into();
678        assert!(computed_rel.as_fk().is_none());
679        assert!(computed_rel.as_computed().is_some());
680    }
681}