Skip to main content

hayson_engine/catalog/
integrity.rs

1use std::collections::{HashMap, HashSet};
2use std::io;
3
4use serde_json::Value;
5
6use crate::query::builder::QueryBuilder;
7use crate::schema::HaysonRecord;
8use crate::table::Table;
9
10use super::types::{Catalog, Relation};
11
12/// One record holding a reference no record on the other side of the relation answers.
13#[derive(Debug, Clone, PartialEq)]
14pub struct OrphanRecord {
15    pub relation: String,
16    pub table: String,
17    pub field: String,
18    pub primary_key: Value,
19    pub value: Value,
20}
21
22/// What `check_integrity` found. An empty `orphans` means every declared relation holds right now.
23#[derive(Debug, Clone, PartialEq, Default)]
24pub struct IntegrityReport {
25    pub orphans: Vec<OrphanRecord>,
26}
27
28pub fn relations_touching(catalog: &Catalog, table: Option<&str>) -> Vec<Relation> {
29    let declared = catalog.relations.iter().flatten();
30    match table {
31        None => declared.cloned().collect(),
32        Some(table) => declared
33            .filter(|relation| relation.from.table == table || relation.to.table == table)
34            .cloned()
35            .collect(),
36    }
37}
38
39fn reference_key(value: &Value) -> String {
40    match value {
41        Value::String(s) => format!("s:{s}"),
42        Value::Number(n) => format!("n:{n}"),
43        Value::Bool(b) => format!("b:{b}"),
44        other => format!("j:{other}"),
45    }
46}
47
48/// A reference is a scalar the other side of a relation can hold as a primary key or a unique
49/// value — an array or object can never match one, and `null`/missing is unlinked, not orphaned.
50pub(crate) fn reference_of(value: Option<&Value>) -> Option<Value> {
51    match value {
52        None | Some(Value::Null) | Some(Value::Array(_)) | Some(Value::Object(_)) => None,
53        Some(other) => Some(other.clone()),
54    }
55}
56
57fn records_of(
58    tables: &HashMap<String, &Table>,
59    cache: &mut HashMap<String, Vec<HaysonRecord>>,
60    name: &str,
61) -> io::Result<()> {
62    if cache.contains_key(name) {
63        return Ok(());
64    }
65    let rows = match tables.get(name) {
66        None => Vec::new(),
67        Some(table) => {
68            let spec = QueryBuilder::new().spec();
69            let result = table.query(&spec)?;
70            let mut rows = Vec::with_capacity(result.keys.len());
71            for key in &result.keys {
72                if let Some(record) = table.get(key)? {
73                    rows.push(record);
74                }
75            }
76            rows
77        }
78    };
79    cache.insert(name.to_string(), rows);
80    Ok(())
81}
82
83/// `db.checkIntegrity()` — walks every declared relation and reports the records whose reference
84/// has no counterpart. A diagnosis, not a gate: nothing here is repaired, removed or written.
85/// `tables` supplies the open handles this call can read; a relation naming a table not in it
86/// reads as empty, exactly like the TS reference's `this.#tables.has(name) ? ... : []`.
87pub fn check_integrity(catalog: &Catalog, tables: &HashMap<String, &Table>) -> io::Result<IntegrityReport> {
88    let mut orphans = Vec::new();
89    let mut cache: HashMap<String, Vec<HaysonRecord>> = HashMap::new();
90
91    for relation in catalog.relations.iter().flatten() {
92        records_of(tables, &mut cache, &relation.from.table)?;
93        if cache[&relation.from.table].is_empty() {
94            continue;
95        }
96
97        records_of(tables, &mut cache, &relation.to.table)?;
98        let known: HashSet<String> = cache[&relation.to.table]
99            .iter()
100            .filter_map(|target| reference_of(target.get(&relation.to.field)))
101            .map(|value| reference_key(&value))
102            .collect();
103
104        let primary_key = catalog
105            .tables
106            .get(&relation.from.table)
107            .map(|entry| entry.primary_key.clone());
108        for origin in &cache[&relation.from.table] {
109            let Some(value) = reference_of(origin.get(&relation.from.field)) else {
110                continue;
111            };
112            if known.contains(&reference_key(&value)) {
113                continue;
114            }
115            let primary_key_value = primary_key
116                .as_ref()
117                .and_then(|key| origin.get(key))
118                .cloned()
119                .unwrap_or(Value::Null);
120            orphans.push(OrphanRecord {
121                relation: relation.name.clone(),
122                table: relation.from.table.clone(),
123                field: relation.from.field.clone(),
124                primary_key: primary_key_value,
125                value,
126            });
127        }
128    }
129
130    Ok(IntegrityReport { orphans })
131}
132
133#[cfg(test)]
134mod tests {
135    use serde_json::json;
136    use tempfile::tempdir;
137
138    use super::*;
139    use crate::catalog::build::{create_table, empty_catalog};
140    use crate::catalog::types::{CatalogLayout, RelationAction, RelationCardinality, RelationEnd};
141    use crate::schema::parse_schema;
142
143    fn users_orders_catalog() -> Catalog {
144        let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
145        let users = parse_schema(&json!({
146            "schemaVersion": 1, "table": "users", "primaryKey": "id",
147            "fields": { "id": { "type": "string" } },
148        }))
149        .unwrap();
150        let catalog = create_table(&catalog, "users", &users, 1_000);
151        let orders = parse_schema(&json!({
152            "schemaVersion": 1, "table": "orders", "primaryKey": "id",
153            "fields": { "id": { "type": "string" }, "userId": { "type": "string" } },
154        }))
155        .unwrap();
156        let mut catalog = create_table(&catalog, "orders", &orders, 1_000);
157        catalog.relations = Some(vec![Relation {
158            name: "orders_user".to_string(),
159            from: RelationEnd {
160                table: "orders".to_string(),
161                field: "userId".to_string(),
162            },
163            to: RelationEnd {
164                table: "users".to_string(),
165                field: "id".to_string(),
166            },
167            cardinality: RelationCardinality::ManyToOne,
168            on_delete: Some(RelationAction::Restrict),
169            enforce: Some(false),
170        }]);
171        catalog
172    }
173
174    fn open_table(dir: &tempfile::TempDir, name: &str, fields: Value) -> Table {
175        let mut document =
176            json!({ "schemaVersion": 1, "table": name, "primaryKey": "id", "fields": { "id": { "type": "string" } } });
177        for (key, value) in fields.as_object().unwrap() {
178            document["fields"][key] = value.clone();
179        }
180        let schema = parse_schema(&document).unwrap();
181        Table::open(schema, dir.path().join(format!("{name}.jsonl"))).unwrap()
182    }
183
184    #[test]
185    fn relations_touching_filters_by_either_end() {
186        let catalog = users_orders_catalog();
187        assert_eq!(relations_touching(&catalog, Some("users")).len(), 1);
188        assert_eq!(relations_touching(&catalog, Some("orders")).len(), 1);
189        assert_eq!(relations_touching(&catalog, Some("nope")).len(), 0);
190        assert_eq!(relations_touching(&catalog, None).len(), 1);
191    }
192
193    #[test]
194    fn reports_a_record_whose_reference_has_no_counterpart() {
195        let dir = tempdir().unwrap();
196        let mut users = open_table(&dir, "users", json!({}));
197        let mut orders = open_table(&dir, "orders", json!({ "userId": { "type": "string" } }));
198        users.put(&json!({ "id": "u1" }), false).unwrap();
199        orders.put(&json!({ "id": "o1", "userId": "u1" }), false).unwrap();
200        orders.put(&json!({ "id": "o2", "userId": "ghost" }), false).unwrap();
201
202        let catalog = users_orders_catalog();
203        let mut tables: HashMap<String, &Table> = HashMap::new();
204        tables.insert("users".to_string(), &users);
205        tables.insert("orders".to_string(), &orders);
206
207        let report = check_integrity(&catalog, &tables).unwrap();
208        assert_eq!(report.orphans.len(), 1);
209        assert_eq!(report.orphans[0].primary_key, json!("o2"));
210        assert_eq!(report.orphans[0].value, json!("ghost"));
211    }
212
213    #[test]
214    fn a_record_with_no_value_on_the_from_field_is_unlinked_not_orphaned() {
215        let dir = tempdir().unwrap();
216        let users = open_table(&dir, "users", json!({}));
217        let mut orders = open_table(&dir, "orders", json!({ "userId": { "type": "string" } }));
218        orders.put(&json!({ "id": "o1" }), false).unwrap();
219
220        let catalog = users_orders_catalog();
221        let mut tables: HashMap<String, &Table> = HashMap::new();
222        tables.insert("users".to_string(), &users);
223        tables.insert("orders".to_string(), &orders);
224
225        let report = check_integrity(&catalog, &tables).unwrap();
226        assert!(report.orphans.is_empty());
227    }
228
229    #[test]
230    fn a_relation_naming_a_table_not_supplied_reads_as_empty() {
231        let dir = tempdir().unwrap();
232        let mut orders = open_table(&dir, "orders", json!({ "userId": { "type": "string" } }));
233        orders.put(&json!({ "id": "o1", "userId": "u1" }), false).unwrap();
234
235        let catalog = users_orders_catalog();
236        let mut tables: HashMap<String, &Table> = HashMap::new();
237        tables.insert("orders".to_string(), &orders);
238
239        let report = check_integrity(&catalog, &tables).unwrap();
240        assert_eq!(report.orphans.len(), 1);
241    }
242}