1use std::collections::BTreeMap;
2
3use serde_json::Value;
4
5use crate::errors::HaysonSchemaError;
6use crate::schema::TableSchema;
7
8use super::parse::{parse_catalog, parse_trigger_entry};
9use super::serialize::catalog_document;
10use super::types::{
11 CatalogFieldSpec, CatalogLayout, CheckEntry, IndexEntry, TableEntry, CATALOG_FORMAT_VERSION, CATALOG_VERSION,
12};
13use super::Catalog;
14
15pub fn empty_catalog(layout: CatalogLayout, now: u64) -> Catalog {
16 Catalog {
17 catalog_version: CATALOG_VERSION,
18 format_version: CATALOG_FORMAT_VERSION,
19 created_at: now,
20 updated_at: now,
21 engine: None,
22 layout: Some(layout),
23 defaults: None,
24 tables: BTreeMap::new(),
25 relations: None,
26 views: None,
27 triggers: None,
28 }
29}
30
31fn is_keyed_by_present_fields(index: &IndexEntry, fields: &BTreeMap<String, CatalogFieldSpec>) -> bool {
32 index.fields.iter().all(|field| fields.contains_key(field))
33}
34
35fn kept_indexes(
36 indexes: &Option<Vec<IndexEntry>>,
37 fields: &BTreeMap<String, CatalogFieldSpec>,
38) -> Option<Vec<IndexEntry>> {
39 let indexes = indexes.as_ref()?;
40 let kept: Vec<IndexEntry> = indexes
41 .iter()
42 .filter(|index| is_keyed_by_present_fields(index, fields))
43 .cloned()
44 .collect();
45 if kept.is_empty() {
46 None
47 } else {
48 Some(kept)
49 }
50}
51
52pub fn dropped_indexes_of(previous: &TableEntry, fields: &BTreeMap<String, CatalogFieldSpec>) -> Vec<IndexEntry> {
53 previous
54 .indexes
55 .iter()
56 .flatten()
57 .filter(|index| !is_keyed_by_present_fields(index, fields))
58 .cloned()
59 .collect()
60}
61
62fn is_word_char(byte: u8) -> bool {
63 byte.is_ascii_alphanumeric() || byte == b'_'
64}
65
66fn mentions_word(haystack: &str, word: &str) -> bool {
69 if word.is_empty() {
70 return false;
71 }
72 let bytes = haystack.as_bytes();
73 let mut search_from = 0;
74 while let Some(offset) = haystack[search_from..].find(word) {
75 let at = search_from + offset;
76 let before_ok = at == 0 || !is_word_char(bytes[at - 1]);
77 let after = at + word.len();
78 let after_ok = after == bytes.len() || !is_word_char(bytes[after]);
79 if before_ok && after_ok {
80 return true;
81 }
82 search_from = at + 1;
83 }
84 false
85}
86
87pub fn checks_mentioning(previous: &TableEntry, field: &str) -> Vec<CheckEntry> {
88 previous
89 .checks
90 .iter()
91 .flatten()
92 .filter(|check| mentions_word(&check.expr, field))
93 .cloned()
94 .collect()
95}
96
97pub fn table_entry_of(schema: &TableSchema, previous: Option<&TableEntry>, now: u64) -> TableEntry {
102 let mut fields = BTreeMap::new();
103 for (name, spec) in &schema.fields {
104 let previous_field = previous.and_then(|entry| entry.fields.get(name));
105 fields.insert(
106 name.clone(),
107 CatalogFieldSpec {
108 spec: spec.clone(),
109 description: previous_field.and_then(|f| f.description.clone()),
110 deprecated: previous_field.and_then(|f| f.deprecated.clone()),
111 },
112 );
113 }
114 let indexes = kept_indexes(&previous.and_then(|p| p.indexes.clone()), &fields);
115 TableEntry {
116 revision: schema.revision as u64,
117 primary_key: schema.primary_key.clone(),
118 fields,
119 partition_by: schema.partition_by.clone(),
120 description: previous.and_then(|p| p.description.clone()),
121 created_at: Some(previous.and_then(|p| p.created_at).unwrap_or(now)),
122 updated_at: Some(now),
123 validation: previous.and_then(|p| p.validation),
124 options: previous.and_then(|p| p.options.clone()),
125 indexes,
126 checks: previous.and_then(|p| p.checks.clone()),
127 history: previous.and_then(|p| p.history.clone()),
128 }
129}
130
131pub fn with_table_entry(catalog: &Catalog, name: &str, entry: TableEntry, now: u64) -> Catalog {
132 let mut next = catalog.clone();
133 next.updated_at = now;
134 next.tables.insert(name.to_string(), entry);
135 next
136}
137
138pub fn create_table(catalog: &Catalog, name: &str, schema: &TableSchema, now: u64) -> Catalog {
142 let entry = table_entry_of(schema, catalog.tables.get(name), now);
143 with_table_entry(catalog, name, entry, now)
144}
145
146pub fn drop_field(
155 catalog: &Catalog,
156 table: &str,
157 field: &str,
158 now: u64,
159) -> Result<(Catalog, TableEntry), HaysonSchemaError> {
160 let entry = catalog
161 .tables
162 .get(table)
163 .ok_or_else(|| HaysonSchemaError(format!("table \"{table}\" does not exist in the catalog")))?;
164 if !entry.fields.contains_key(field) {
165 return Err(HaysonSchemaError(format!(
166 "field \"{field}\" is not declared in the schema of table \"{table}\""
167 )));
168 }
169 if entry.primary_key == field {
170 return Err(HaysonSchemaError(format!(
171 "field \"{field}\" is the primary key of table \"{table}\" and cannot be dropped — rewriting the table is the only way out"
172 )));
173 }
174 if entry.partition_by.as_deref() == Some(field) {
175 return Err(HaysonSchemaError(format!(
176 "field \"{field}\" is what table \"{table}\" is partitioned by and cannot be dropped — every record would lose the file it belongs to"
177 )));
178 }
179
180 let mut remaining_fields = entry.fields.clone();
181 remaining_fields.remove(field);
182
183 let keyed_by = dropped_indexes_of(entry, &remaining_fields);
184 if !keyed_by.is_empty() {
185 let named = keyed_by
186 .iter()
187 .map(|index| format!("\"{}\"", index.name))
188 .collect::<Vec<_>>()
189 .join(", ");
190 return Err(HaysonSchemaError(format!(
191 "field \"{field}\" is used by index(es) {named} of table \"{table}\" and dropping it would silently discard them — drop the index(es) first"
192 )));
193 }
194 let mentioned_in = checks_mentioning(entry, field);
195 if !mentioned_in.is_empty() {
196 let named = mentioned_in
197 .iter()
198 .map(|check| format!("\"{}\"", check.name))
199 .collect::<Vec<_>>()
200 .join(", ");
201 return Err(HaysonSchemaError(format!(
202 "check(s) {named} of table \"{table}\" mention \"{field}\" — dropping \"{field}\" may break the check; this is a text-only match on the expression, not semantic analysis, so if the check does not actually depend on \"{field}\", drop it and recreate it without the mention"
203 )));
204 }
205
206 let mut next_entry = entry.clone();
207 next_entry.fields = remaining_fields;
208 next_entry.updated_at = Some(now);
209
210 let mut next_catalog = catalog.clone();
211 next_catalog.updated_at = now;
212 next_catalog.tables.insert(table.to_string(), next_entry.clone());
213
214 parse_catalog(&catalog_document(&next_catalog)).map_err(|error| {
217 HaysonSchemaError(format!(
218 "dropping field \"{field}\" from table \"{table}\" would leave a catalog this build of hayson cannot read back — {}; nothing was changed",
219 error.0
220 ))
221 })?;
222
223 Ok((next_catalog, next_entry))
224}
225
226pub fn declare_trigger(catalog: &Catalog, trigger_spec: &Value, now: u64) -> Result<Catalog, HaysonSchemaError> {
233 let declared = catalog.triggers.clone().unwrap_or_default();
234 let path = format!("triggers[{}]", declared.len());
235 let trigger = parse_trigger_entry(trigger_spec, &path, &catalog.tables)?;
236
237 if declared.iter().any(|other| other.name == trigger.name) {
238 return Err(HaysonSchemaError(format!(
239 "the catalog already declares a trigger named \"{}\" — a trigger name is what the catalog identifies it by, so take the declared one off the catalog before declaring another under that name",
240 trigger.name
241 )));
242 }
243
244 let mut next_declared = declared;
245 next_declared.push(trigger.clone());
246
247 let mut next_catalog = catalog.clone();
248 next_catalog.updated_at = now;
249 next_catalog.triggers = Some(next_declared);
250
251 parse_catalog(&catalog_document(&next_catalog)).map_err(|error| {
252 HaysonSchemaError(format!(
253 "declaring trigger \"{}\" would leave a catalog this build of hayson cannot read back — {}; nothing was changed",
254 trigger.name, error.0
255 ))
256 })?;
257
258 Ok(next_catalog)
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264 use crate::catalog::types::CatalogIndexKind;
265 use crate::schema::parse_schema;
266 use serde_json::{json, Value};
267
268 fn users_schema(fields_extra: Value) -> TableSchema {
269 let mut fields = json!({ "id": { "type": "string" } });
270 for (key, value) in fields_extra.as_object().unwrap() {
271 fields[key] = value.clone();
272 }
273 parse_schema(&json!({
274 "schemaVersion": 1,
275 "table": "users",
276 "primaryKey": "id",
277 "fields": fields,
278 }))
279 .unwrap()
280 }
281
282 #[test]
283 fn create_table_adds_a_fresh_table_to_an_empty_catalog() {
284 let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
285 let schema = users_schema(json!({ "name": { "type": "string" } }));
286
287 let next = create_table(&catalog, "users", &schema, 2_000);
288
289 assert!(next.tables.contains_key("users"));
290 assert_eq!(next.updated_at, 2_000);
291 assert_eq!(next.tables["users"].created_at, Some(2_000));
292 }
293
294 #[test]
295 fn create_table_keeps_documentation_and_created_at_of_a_previous_entry() {
296 let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
297 let schema = users_schema(json!({ "name": { "type": "string" } }));
298 let first = create_table(&catalog, "users", &schema, 1_000);
299
300 let mut with_docs = first.clone();
301 with_docs
302 .tables
303 .get_mut("users")
304 .unwrap()
305 .fields
306 .get_mut("name")
307 .unwrap()
308 .description = Some("display name".to_string());
309
310 let migrated_schema = users_schema(json!({
311 "name": { "type": "string" },
312 "age": { "type": "int" },
313 }));
314 let second = create_table(&with_docs, "users", &migrated_schema, 3_000);
315
316 assert_eq!(second.tables["users"].created_at, Some(1_000));
317 assert_eq!(second.tables["users"].updated_at, Some(3_000));
318 assert_eq!(
319 second.tables["users"].fields["name"].description,
320 Some("display name".to_string())
321 );
322 }
323
324 #[test]
325 fn drop_field_refuses_when_a_named_index_still_uses_the_field() {
326 let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
327 let schema = users_schema(json!({ "email": { "type": "string" } }));
328 let mut catalog = create_table(&catalog, "users", &schema, 1_000);
329 catalog.tables.get_mut("users").unwrap().indexes = Some(vec![IndexEntry {
330 name: "users_email_custom".to_string(),
331 fields: vec!["email".to_string()],
332 kind: CatalogIndexKind::Exact,
333 unique: false,
334 where_: None,
335 expire_after_seconds: None,
336 }]);
337
338 let result = drop_field(&catalog, "users", "email", 2_000);
339 assert!(result.is_err());
340 assert!(result.unwrap_err().0.contains("users_email_custom"));
341 }
342
343 #[test]
344 fn drop_field_refuses_when_a_check_mentions_the_field_by_name() {
345 let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
346 let schema = users_schema(json!({ "age": { "type": "int" } }));
347 let mut catalog = create_table(&catalog, "users", &schema, 1_000);
348 catalog.tables.get_mut("users").unwrap().checks = Some(vec![CheckEntry {
349 name: "adult".to_string(),
350 expr: "age >= 18".to_string(),
351 }]);
352
353 let result = drop_field(&catalog, "users", "age", 2_000);
354 assert!(result.is_err());
355 assert!(result.unwrap_err().0.contains("adult"));
356 }
357
358 #[test]
359 fn drop_field_succeeds_when_nothing_depends_on_the_field() {
360 let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
361 let schema = users_schema(json!({ "nickname": { "type": "string" } }));
362 let catalog = create_table(&catalog, "users", &schema, 1_000);
363
364 let (next, entry) = drop_field(&catalog, "users", "nickname", 2_000).unwrap();
365 assert!(!entry.fields.contains_key("nickname"));
366 assert!(!next.tables["users"].fields.contains_key("nickname"));
367 assert_eq!(next.tables["users"].updated_at, Some(2_000));
368 }
369
370 #[test]
371 fn drop_field_refuses_the_primary_key() {
372 let catalog = empty_catalog(CatalogLayout::Flat, 1_000);
373 let schema = users_schema(json!({ "name": { "type": "string" } }));
374 let catalog = create_table(&catalog, "users", &schema, 1_000);
375
376 assert!(drop_field(&catalog, "users", "id", 2_000).is_err());
377 }
378}