Skip to main content

hayson_engine/catalog/
build.rs

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
66/// `\b<field>\b` on ASCII bytes — good enough for field names, which schema already restricts to
67/// `[A-Za-z][A-Za-z0-9_]*`.
68fn 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
97/// Builds the next `TableEntry` for `createTable`/migration: field specs come from `schema`, but
98/// documentation (`description`/`deprecated`), `options`, `checks` and `history` of a same-named
99/// previous entry are carried over, and a declared index survives only if every field it names is
100/// still present.
101pub 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
138/// `db.createTable()` — addytywna zmiana albo nowa tabela, patrz `docs/schema.md` „Ewolucja schemy".
139/// Migracja niekompatybilna (zmiana typu, zwężenie itd.) nie jest tu sprawdzana: `schema` już
140/// przeszła przez `schema::parse_schema`, a diff rewizji zostaje na liście TODO niżej.
141pub 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
146/// `db.dropField(table, field)` na poziomie samego katalogu — `docs/format.md`
147/// „`dropField` odmawia jednolicie na wszystkich czterech klasach zależności" i ADR-qtr2 (cerebro,
148/// „0030 — force nie wyburza deklaracji"): `force` potwierdza tylko rekordy nadal niosące pole
149/// w logu, nigdy deklaracje katalogu — ten test jest więc zawsze egzekwowany, bez parametru
150/// `force`, bo nic tutaj go nie omija.
151///
152/// TODO: sprawdzenie rezydualnych rekordów (`HaysonFieldInUseError`, `{ force: true }`) potrzebuje
153/// żywej tabeli/logu i należy do `database::drop_field`, gdy ta warstwa powstanie — patrz ADR-qtr2.
154pub 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    // NOTE: catches indexes[].where[]/relations[] pointing at the dropped field — the checks above
215    // only cover indexes[].fields and checks[].expr (docs/format.md, the four dependency classes).
216    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
226/// `db.declareTrigger()` — adds a declarative AFTER trigger to `triggers[]`. `trigger_spec.table`
227/// and `trigger_spec.effect.table` must already be declared tables of `catalog` (checked by
228/// `parse_trigger_entry` before anything else runs, ADR-s21j/0029): a spec naming an undeclared
229/// table is refused here, before `Database::declare_trigger` ever reaches disk. Nothing is written
230/// when it refuses — the catalog handed back on success is the only side effect, `write_catalog` is
231/// left to the caller (mirrors `declare_index`).
232pub 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}