Skip to main content

surreal_sync_core/
foreign_keys.rs

1//! Foreign key definitions and table classification.
2//!
3//! This module provides types for representing foreign key constraints
4//! and logic for classifying tables as entity tables or relation (join) tables.
5
6use crate::schema::TableDefinition;
7use serde::{Deserialize, Serialize};
8
9/// A foreign key constraint on a table.
10///
11/// Represents a single FK constraint linking one or more columns in the
12/// current table to columns in a referenced table.
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
14pub struct ForeignKeyDefinition {
15    /// Constraint name in the source database
16    pub constraint_name: String,
17    /// Column(s) in this table that form the FK
18    pub columns: Vec<String>,
19    /// The table referenced by this FK
20    pub referenced_table: String,
21    /// The column(s) in the referenced table
22    pub referenced_columns: Vec<String>,
23}
24
25/// Classification of a table for SurrealDB sync purposes.
26///
27/// Entity tables are synced as regular SurrealDB records (with FK columns
28/// converted to record links). Relation tables are synced as SurrealDB
29/// graph edges via `RELATE`.
30#[derive(Debug, Clone)]
31pub enum TableKind {
32    /// A normal entity table. FK columns become record links.
33    Entity,
34    /// A join/relation table. The two FKs become the `in` and `out`
35    /// endpoints of a SurrealDB graph edge.
36    Relation {
37        in_fk: ForeignKeyDefinition,
38        out_fk: ForeignKeyDefinition,
39    },
40}
41
42/// Classify a table as entity or relation.
43///
44/// **Auto-detection heuristic**: a table is a relation table when it has
45/// exactly 2 foreign keys that point to 2 distinct tables, and the primary
46/// key is composed entirely of those FK columns (classic composite-PK join table).
47///
48/// **Override**: if `relation_table_overrides` contains the table name the
49/// table is forced to `TableKind::Relation` regardless of the heuristic
50/// (provided it has at least 2 FKs pointing to distinct tables).
51pub fn classify_table(table: &TableDefinition, relation_table_overrides: &[String]) -> TableKind {
52    let forced = relation_table_overrides.contains(&table.name);
53
54    let fks = &table.foreign_keys;
55
56    // Need at least 2 FKs to form a relation
57    if fks.len() < 2 {
58        return TableKind::Entity;
59    }
60
61    if forced {
62        // When forced, use the first two FKs (even if they reference the same table,
63        // e.g. self-referencing relations like mentorship or friendship).
64        return TableKind::Relation {
65            in_fk: fks[0].clone(),
66            out_fk: fks[1].clone(),
67        };
68    }
69
70    // Auto-detection: pick the first two FKs referencing distinct tables.
71    let (in_fk, out_fk) = match find_distinct_fk_pair(fks) {
72        Some(pair) => pair,
73        None => return TableKind::Entity,
74    };
75
76    // PK must be composed entirely of those FK columns
77    if pk_is_composed_of_fk_columns(table, in_fk, out_fk) {
78        TableKind::Relation {
79            in_fk: in_fk.clone(),
80            out_fk: out_fk.clone(),
81        }
82    } else {
83        TableKind::Entity
84    }
85}
86
87/// Find the first pair of FKs that reference two distinct tables.
88fn find_distinct_fk_pair(
89    fks: &[ForeignKeyDefinition],
90) -> Option<(&ForeignKeyDefinition, &ForeignKeyDefinition)> {
91    for (i, a) in fks.iter().enumerate() {
92        for b in &fks[i + 1..] {
93            if a.referenced_table != b.referenced_table {
94                return Some((a, b));
95            }
96        }
97    }
98    None
99}
100
101/// Check whether the table's primary key columns are exactly the union
102/// of the two FK column sets.
103fn pk_is_composed_of_fk_columns(
104    table: &TableDefinition,
105    in_fk: &ForeignKeyDefinition,
106    out_fk: &ForeignKeyDefinition,
107) -> bool {
108    let mut fk_cols: Vec<&str> = in_fk
109        .columns
110        .iter()
111        .chain(out_fk.columns.iter())
112        .map(|s| s.as_str())
113        .collect();
114    fk_cols.sort();
115    fk_cols.dedup();
116
117    let mut pk_cols = table.primary_key_column_names();
118    pk_cols.sort();
119
120    pk_cols == fk_cols
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use crate::schema::ColumnDefinition;
127    use crate::types::Type;
128
129    fn make_table(
130        name: &str,
131        pk_names: Vec<&str>,
132        column_names: Vec<&str>,
133        fks: Vec<ForeignKeyDefinition>,
134    ) -> TableDefinition {
135        let primary_key = ColumnDefinition::new(pk_names[0], Type::Int64);
136
137        let mut columns: Vec<ColumnDefinition> = column_names
138            .iter()
139            .map(|n| ColumnDefinition::new(*n, Type::Int64))
140            .collect();
141
142        // For composite PK, add the extra PK columns to columns list
143        for pk in &pk_names[1..] {
144            if !column_names.contains(pk) {
145                columns.push(ColumnDefinition::new(*pk, Type::Int64));
146            }
147        }
148
149        let mut td = TableDefinition::new(name, primary_key, columns);
150        td.foreign_keys = fks;
151        if pk_names.len() > 1 {
152            td.composite_primary_key = Some(pk_names.iter().map(|s| s.to_string()).collect());
153        }
154        td
155    }
156
157    fn fk(
158        name: &str,
159        cols: Vec<&str>,
160        ref_table: &str,
161        ref_cols: Vec<&str>,
162    ) -> ForeignKeyDefinition {
163        ForeignKeyDefinition {
164            constraint_name: name.to_string(),
165            columns: cols.iter().map(|s| s.to_string()).collect(),
166            referenced_table: ref_table.to_string(),
167            referenced_columns: ref_cols.iter().map(|s| s.to_string()).collect(),
168        }
169    }
170
171    #[test]
172    fn test_entity_no_fks() {
173        let table = make_table("authors", vec!["id"], vec!["name"], vec![]);
174        assert!(matches!(classify_table(&table, &[]), TableKind::Entity));
175    }
176
177    #[test]
178    fn test_entity_one_fk() {
179        let table = make_table(
180            "books",
181            vec!["id"],
182            vec!["title", "author_id"],
183            vec![fk("fk_author", vec!["author_id"], "authors", vec!["id"])],
184        );
185        assert!(matches!(classify_table(&table, &[]), TableKind::Entity));
186    }
187
188    #[test]
189    fn test_entity_two_fks_but_pk_not_composed_of_fks() {
190        let table = make_table(
191            "collaborations",
192            vec!["id"],
193            vec!["author1_id", "author2_id", "project"],
194            vec![
195                fk("fk_a1", vec!["author1_id"], "authors", vec!["id"]),
196                fk("fk_a2", vec!["author2_id"], "editors", vec!["id"]),
197            ],
198        );
199        assert!(matches!(classify_table(&table, &[]), TableKind::Entity));
200    }
201
202    #[test]
203    fn test_relation_classic_join_table() {
204        let table = make_table(
205            "book_tags",
206            vec!["book_id", "tag_id"],
207            vec!["created_at"],
208            vec![
209                fk("fk_book", vec!["book_id"], "books", vec!["id"]),
210                fk("fk_tag", vec!["tag_id"], "tags", vec!["id"]),
211            ],
212        );
213        match classify_table(&table, &[]) {
214            TableKind::Relation { in_fk, out_fk } => {
215                assert_eq!(in_fk.referenced_table, "books");
216                assert_eq!(out_fk.referenced_table, "tags");
217            }
218            TableKind::Entity => panic!("Expected Relation"),
219        }
220    }
221
222    #[test]
223    fn test_entity_pk_has_extra_col_beyond_fks() {
224        let table = make_table(
225            "events",
226            vec!["book_id", "tag_id", "seq"],
227            vec!["data"],
228            vec![
229                fk("fk_book", vec!["book_id"], "books", vec!["id"]),
230                fk("fk_tag", vec!["tag_id"], "tags", vec!["id"]),
231            ],
232        );
233        assert!(matches!(classify_table(&table, &[]), TableKind::Entity));
234    }
235
236    #[test]
237    fn test_override_forces_relation() {
238        let table = make_table(
239            "collaborations",
240            vec!["id"],
241            vec!["author1_id", "author2_id", "project"],
242            vec![
243                fk("fk_a1", vec!["author1_id"], "authors", vec!["id"]),
244                fk("fk_a2", vec!["author2_id"], "editors", vec!["id"]),
245            ],
246        );
247        let overrides = vec!["collaborations".to_string()];
248        match classify_table(&table, &overrides) {
249            TableKind::Relation { in_fk, out_fk } => {
250                assert_eq!(in_fk.referenced_table, "authors");
251                assert_eq!(out_fk.referenced_table, "editors");
252            }
253            TableKind::Entity => panic!("Expected Relation with override"),
254        }
255    }
256
257    #[test]
258    fn test_override_not_matching_uses_heuristic() {
259        let table = make_table(
260            "books",
261            vec!["id"],
262            vec!["title", "author_id"],
263            vec![fk("fk_author", vec!["author_id"], "authors", vec!["id"])],
264        );
265        let overrides = vec!["other_table".to_string()];
266        assert!(matches!(
267            classify_table(&table, &overrides),
268            TableKind::Entity
269        ));
270    }
271
272    #[test]
273    fn test_two_fks_same_table_no_relation() {
274        let table = make_table(
275            "friendships",
276            vec!["user1_id", "user2_id"],
277            vec![],
278            vec![
279                fk("fk_u1", vec!["user1_id"], "users", vec!["id"]),
280                fk("fk_u2", vec!["user2_id"], "users", vec!["id"]),
281            ],
282        );
283        // Both FKs point to the same table, so auto-detection returns Entity
284        assert!(matches!(classify_table(&table, &[]), TableKind::Entity));
285    }
286
287    #[test]
288    fn test_override_same_table_fks_forces_relation() {
289        let table = make_table(
290            "friendships",
291            vec!["user1_id", "user2_id"],
292            vec![],
293            vec![
294                fk("fk_u1", vec!["user1_id"], "users", vec!["id"]),
295                fk("fk_u2", vec!["user2_id"], "users", vec!["id"]),
296            ],
297        );
298        let overrides = vec!["friendships".to_string()];
299        match classify_table(&table, &overrides) {
300            TableKind::Relation { in_fk, out_fk } => {
301                assert_eq!(in_fk.referenced_table, "users");
302                assert_eq!(out_fk.referenced_table, "users");
303            }
304            TableKind::Entity => panic!("Override should force Relation even with same-table FKs"),
305        }
306    }
307}