Skip to main content

prax_query/relations/
spec.rs

1//! Relation specification types.
2
3use std::collections::HashMap;
4
5/// Type of relation between models.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
7pub enum RelationType {
8    /// One-to-one relation (e.g., User has one Profile).
9    OneToOne,
10    /// One-to-many relation (e.g., User has many Posts).
11    OneToMany,
12    /// Many-to-one relation (e.g., Post belongs to User).
13    ManyToOne,
14    /// Many-to-many relation (e.g., Post has many Tags).
15    ManyToMany,
16}
17
18impl RelationType {
19    /// Check if this relation returns multiple records.
20    pub fn is_many(&self) -> bool {
21        matches!(self, Self::OneToMany | Self::ManyToMany)
22    }
23
24    /// Check if this relation returns a single record.
25    pub fn is_one(&self) -> bool {
26        matches!(self, Self::OneToOne | Self::ManyToOne)
27    }
28}
29
30/// Specification for a relation between models.
31#[derive(Debug, Clone)]
32pub struct RelationSpec {
33    /// Name of the relation (field name).
34    pub name: String,
35    /// Type of relation.
36    pub relation_type: RelationType,
37    /// Name of the related model.
38    pub related_model: String,
39    /// Name of the related table.
40    pub related_table: String,
41    /// Foreign key fields on this model.
42    pub fields: Vec<String>,
43    /// Referenced fields on the related model.
44    pub references: Vec<String>,
45    /// Join table for many-to-many relations.
46    pub join_table: Option<JoinTableSpec>,
47    /// On delete action.
48    pub on_delete: Option<ReferentialAction>,
49    /// On update action.
50    pub on_update: Option<ReferentialAction>,
51    /// Custom foreign key constraint name in the database.
52    pub map: Option<String>,
53}
54
55impl RelationSpec {
56    /// Create a one-to-one relation spec.
57    pub fn one_to_one(
58        name: impl Into<String>,
59        related_model: impl Into<String>,
60        related_table: impl Into<String>,
61    ) -> Self {
62        Self {
63            name: name.into(),
64            relation_type: RelationType::OneToOne,
65            related_model: related_model.into(),
66            related_table: related_table.into(),
67            fields: Vec::new(),
68            references: Vec::new(),
69            join_table: None,
70            on_delete: None,
71            on_update: None,
72            map: None,
73        }
74    }
75
76    /// Create a one-to-many relation spec.
77    pub fn one_to_many(
78        name: impl Into<String>,
79        related_model: impl Into<String>,
80        related_table: impl Into<String>,
81    ) -> Self {
82        Self {
83            name: name.into(),
84            relation_type: RelationType::OneToMany,
85            related_model: related_model.into(),
86            related_table: related_table.into(),
87            fields: Vec::new(),
88            references: Vec::new(),
89            join_table: None,
90            on_delete: None,
91            on_update: None,
92            map: None,
93        }
94    }
95
96    /// Create a many-to-one relation spec.
97    pub fn many_to_one(
98        name: impl Into<String>,
99        related_model: impl Into<String>,
100        related_table: impl Into<String>,
101    ) -> Self {
102        Self {
103            name: name.into(),
104            relation_type: RelationType::ManyToOne,
105            related_model: related_model.into(),
106            related_table: related_table.into(),
107            fields: Vec::new(),
108            references: Vec::new(),
109            join_table: None,
110            on_delete: None,
111            on_update: None,
112            map: None,
113        }
114    }
115
116    /// Create a many-to-many relation spec.
117    pub fn many_to_many(
118        name: impl Into<String>,
119        related_model: impl Into<String>,
120        related_table: impl Into<String>,
121        join_table: JoinTableSpec,
122    ) -> Self {
123        Self {
124            name: name.into(),
125            relation_type: RelationType::ManyToMany,
126            related_model: related_model.into(),
127            related_table: related_table.into(),
128            fields: Vec::new(),
129            references: Vec::new(),
130            join_table: Some(join_table),
131            on_delete: None,
132            on_update: None,
133            map: None,
134        }
135    }
136
137    /// Set the foreign key fields.
138    pub fn fields(mut self, fields: impl IntoIterator<Item = impl Into<String>>) -> Self {
139        self.fields = fields.into_iter().map(Into::into).collect();
140        self
141    }
142
143    /// Set the referenced fields.
144    pub fn references(mut self, refs: impl IntoIterator<Item = impl Into<String>>) -> Self {
145        self.references = refs.into_iter().map(Into::into).collect();
146        self
147    }
148
149    /// Set the on delete action.
150    pub fn on_delete(mut self, action: ReferentialAction) -> Self {
151        self.on_delete = Some(action);
152        self
153    }
154
155    /// Set the on update action.
156    pub fn on_update(mut self, action: ReferentialAction) -> Self {
157        self.on_update = Some(action);
158        self
159    }
160
161    /// Set the custom foreign key constraint name.
162    pub fn map(mut self, name: impl Into<String>) -> Self {
163        self.map = Some(name.into());
164        self
165    }
166
167    /// Generate the JOIN clause for this relation.
168    pub fn to_join_clause(&self, parent_alias: &str, child_alias: &str) -> String {
169        if let Some(ref jt) = self.join_table {
170            // Many-to-many join through join table
171            format!(
172                "JOIN {} ON {}.{} = {}.{} JOIN {} AS {} ON {}.{} = {}.{}",
173                jt.table_name,
174                parent_alias,
175                self.fields.first().unwrap_or(&"id".to_string()),
176                jt.table_name,
177                jt.source_column,
178                self.related_table,
179                child_alias,
180                jt.table_name,
181                jt.target_column,
182                child_alias,
183                self.references.first().unwrap_or(&"id".to_string()),
184            )
185        } else {
186            // Direct join
187            let join_conditions: Vec<_> = self
188                .fields
189                .iter()
190                .zip(self.references.iter())
191                .map(|(f, r)| format!("{}.{} = {}.{}", parent_alias, f, child_alias, r))
192                .collect();
193
194            format!(
195                "JOIN {} AS {} ON {}",
196                self.related_table,
197                child_alias,
198                join_conditions.join(" AND ")
199            )
200        }
201    }
202}
203
204/// Specification for a join table (many-to-many).
205#[derive(Debug, Clone)]
206pub struct JoinTableSpec {
207    /// Name of the join table.
208    pub table_name: String,
209    /// Column referencing the source model.
210    pub source_column: String,
211    /// Column referencing the target model.
212    pub target_column: String,
213}
214
215impl JoinTableSpec {
216    /// Create a new join table spec.
217    pub fn new(
218        table_name: impl Into<String>,
219        source_column: impl Into<String>,
220        target_column: impl Into<String>,
221    ) -> Self {
222        Self {
223            table_name: table_name.into(),
224            source_column: source_column.into(),
225            target_column: target_column.into(),
226        }
227    }
228}
229
230/// Referential action for cascading operations.
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
232pub enum ReferentialAction {
233    /// Cascade the operation to related records.
234    Cascade,
235    /// Set the foreign key to null.
236    SetNull,
237    /// Set the foreign key to default value.
238    SetDefault,
239    /// Restrict the operation if related records exist.
240    Restrict,
241    /// No action (let database handle).
242    NoAction,
243}
244
245impl ReferentialAction {
246    /// Get the SQL keyword for this action.
247    pub fn as_sql(&self) -> &'static str {
248        match self {
249            Self::Cascade => "CASCADE",
250            Self::SetNull => "SET NULL",
251            Self::SetDefault => "SET DEFAULT",
252            Self::Restrict => "RESTRICT",
253            Self::NoAction => "NO ACTION",
254        }
255    }
256}
257
258/// Registry of relation specifications for a model.
259#[derive(Debug, Clone, Default)]
260pub struct RelationRegistry {
261    relations: HashMap<String, RelationSpec>,
262}
263
264impl RelationRegistry {
265    /// Create a new empty registry.
266    pub fn new() -> Self {
267        Self::default()
268    }
269
270    /// Register a relation.
271    pub fn register(&mut self, spec: RelationSpec) {
272        self.relations.insert(spec.name.clone(), spec);
273    }
274
275    /// Get a relation by name.
276    pub fn get(&self, name: &str) -> Option<&RelationSpec> {
277        self.relations.get(name)
278    }
279
280    /// Get all relations.
281    pub fn all(&self) -> impl Iterator<Item = &RelationSpec> {
282        self.relations.values()
283    }
284
285    /// Get all one-to-many relations.
286    pub fn one_to_many(&self) -> impl Iterator<Item = &RelationSpec> {
287        self.relations
288            .values()
289            .filter(|r| r.relation_type == RelationType::OneToMany)
290    }
291
292    /// Get all many-to-one relations.
293    pub fn many_to_one(&self) -> impl Iterator<Item = &RelationSpec> {
294        self.relations
295            .values()
296            .filter(|r| r.relation_type == RelationType::ManyToOne)
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn test_relation_type() {
306        assert!(RelationType::OneToMany.is_many());
307        assert!(RelationType::ManyToMany.is_many());
308        assert!(!RelationType::OneToOne.is_many());
309        assert!(RelationType::OneToOne.is_one());
310    }
311
312    #[test]
313    fn test_relation_spec() {
314        let spec = RelationSpec::one_to_many("posts", "Post", "posts")
315            .fields(["id"])
316            .references(["author_id"]);
317
318        assert_eq!(spec.name, "posts");
319        assert_eq!(spec.relation_type, RelationType::OneToMany);
320        assert_eq!(spec.fields, vec!["id"]);
321        assert_eq!(spec.references, vec!["author_id"]);
322    }
323
324    #[test]
325    fn test_join_table_spec() {
326        let jt = JoinTableSpec::new("_post_tags", "post_id", "tag_id");
327        assert_eq!(jt.table_name, "_post_tags");
328        assert_eq!(jt.source_column, "post_id");
329        assert_eq!(jt.target_column, "tag_id");
330    }
331
332    #[test]
333    fn test_referential_action() {
334        assert_eq!(ReferentialAction::Cascade.as_sql(), "CASCADE");
335        assert_eq!(ReferentialAction::SetNull.as_sql(), "SET NULL");
336    }
337
338    #[test]
339    fn test_relation_registry() {
340        let mut registry = RelationRegistry::new();
341        registry.register(RelationSpec::one_to_many("posts", "Post", "posts"));
342        registry.register(RelationSpec::many_to_one("author", "User", "users"));
343
344        assert!(registry.get("posts").is_some());
345        assert!(registry.get("author").is_some());
346        assert!(registry.get("nonexistent").is_none());
347    }
348}