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#[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#[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
48pub(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
83pub 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}