Skip to main content

hayson_engine/catalog/
parse.rs

1use std::collections::BTreeMap;
2
3use serde_json::{Map, Value};
4
5use crate::errors::HaysonSchemaError;
6use crate::schema::{parse_schema, FieldType, SCHEMA_VERSION};
7
8use super::indexes::expand_indexes;
9use super::types::{
10    Catalog, CatalogCompaction, CatalogFieldSpec, CatalogIndexKind, CatalogLayout, CatalogPredicate, CatalogSyncMode,
11    CatalogValidation, CheckEntry, HistoryEntry, IndexEntry, LinkParsing, Relation, RelationAction,
12    RelationCardinality, RelationEnd, TableEntry, TableOptions, TriggerEffect, TriggerEntry, TriggerEvent,
13    TriggerFieldMapping, TriggerValueSource, ViewEntry, CATALOG_FORMAT_VERSION, CATALOG_PREDICATE_OPS, CATALOG_VERSION,
14};
15
16const CATALOG_KEYS: &[&str] = &[
17    "catalogVersion",
18    // NOTE: "hayson" is the pre-5.0.0 name of "catalogVersion" — still read, never written again.
19    "hayson",
20    "formatVersion",
21    "createdAt",
22    "updatedAt",
23    "engine",
24    "layout",
25    "defaults",
26    "tables",
27    "relations",
28    "views",
29    "triggers",
30];
31const TABLE_KEYS: &[&str] = &[
32    "revision",
33    "description",
34    "primaryKey",
35    "partitionBy",
36    "createdAt",
37    "updatedAt",
38    "validation",
39    "options",
40    "fields",
41    "indexes",
42    "checks",
43    "history",
44];
45const INDEX_KEYS: &[&str] = &["name", "fields", "kind", "unique", "where", "expireAfterSeconds"];
46const PREDICATE_KEYS: &[&str] = &["field", "op", "value"];
47const CHECK_KEYS: &[&str] = &["name", "expr"];
48const HISTORY_KEYS: &[&str] = &["revision", "at", "note"];
49const OPTION_KEYS: &[&str] = &["sync", "compaction", "linkParsing", "imageDir"];
50const COMPACTION_KEYS: &[&str] = &["deadBytesRatio", "minDeadBytes"];
51const RELATION_KEYS: &[&str] = &["name", "from", "to", "cardinality", "onDelete", "enforce"];
52const RELATION_END_KEYS: &[&str] = &["table", "field"];
53const VIEW_KEYS: &[&str] = &["name", "query", "description"];
54const TRIGGER_KEYS: &[&str] = &["name", "table", "event", "effect"];
55const TRIGGER_EFFECT_KEYS: &[&str] = &["table", "fields"];
56const TRIGGER_FIELD_MAPPING_KEYS: &[&str] = &["target", "source"];
57const TRIGGER_SOURCE_KEYS: &[&str] = &["kind", "field", "value"];
58const FIELD_META_KEYS: &[&str] = &["description", "deprecated"];
59
60fn fail(path: &str, message: impl Into<String>) -> HaysonSchemaError {
61    HaysonSchemaError(format!("{path} {}", message.into()))
62}
63
64fn is_object_name(value: &str) -> bool {
65    let mut chars = value.chars();
66    match chars.next() {
67        Some(c) if c.is_ascii_alphabetic() => {}
68        _ => return false,
69    }
70    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
71}
72
73fn as_object<'a>(value: Option<&'a Value>, path: &str) -> Result<&'a Map<String, Value>, HaysonSchemaError> {
74    value
75        .and_then(Value::as_object)
76        .ok_or_else(|| fail(path, "must be an object"))
77}
78
79fn as_array<'a>(value: Option<&'a Value>, path: &str) -> Result<&'a Vec<Value>, HaysonSchemaError> {
80    value
81        .and_then(Value::as_array)
82        .ok_or_else(|| fail(path, "must be an array"))
83}
84
85fn as_name(value: Option<&Value>, path: &str, what: &str) -> Result<String, HaysonSchemaError> {
86    let text = value.and_then(Value::as_str).filter(|s| is_object_name(s));
87    text.map(str::to_string)
88        .ok_or_else(|| fail(path, format!("must be a usable {what} name, got {:?}", value)))
89}
90
91fn as_text(value: Option<&Value>, path: &str) -> Result<String, HaysonSchemaError> {
92    match value.and_then(Value::as_str) {
93        Some(s) if !s.is_empty() => Ok(s.to_string()),
94        _ => Err(fail(path, "must be a non-empty string")),
95    }
96}
97
98fn optional_text(value: Option<&Value>, path: &str) -> Result<Option<String>, HaysonSchemaError> {
99    match value {
100        None => Ok(None),
101        Some(_) => as_text(value, path).map(Some),
102    }
103}
104
105fn optional_flag(value: Option<&Value>, path: &str) -> Result<Option<bool>, HaysonSchemaError> {
106    match value {
107        None => Ok(None),
108        Some(Value::Bool(b)) => Ok(Some(*b)),
109        Some(_) => Err(fail(path, "must be a boolean")),
110    }
111}
112
113fn as_whole_number(value: Option<&Value>, path: &str, minimum: i64) -> Result<u64, HaysonSchemaError> {
114    match value.and_then(Value::as_i64) {
115        Some(n) if n >= minimum => Ok(n as u64),
116        _ => Err(fail(
117            path,
118            format!("must be an integer of at least {minimum}, got {:?}", value),
119        )),
120    }
121}
122
123fn optional_whole_number(value: Option<&Value>, path: &str, minimum: i64) -> Result<Option<u64>, HaysonSchemaError> {
124    match value {
125        None => Ok(None),
126        Some(_) => as_whole_number(value, path, minimum).map(Some),
127    }
128}
129
130fn reject_unknown(source: &Map<String, Value>, allowed: &[&str], path: &str) -> Result<(), HaysonSchemaError> {
131    for key in source.keys() {
132        if !allowed.contains(&key.as_str()) {
133            return Err(fail(
134                &format!("{path}.{key}"),
135                "is not a known catalog option — it was most likely written by a newer version of hayson, and guessing what it means would read the database wrongly. Upgrade @theritual/hayson.",
136            ));
137        }
138    }
139    Ok(())
140}
141
142fn parse_compaction(raw: Option<&Value>, path: &str) -> Result<Option<CatalogCompaction>, HaysonSchemaError> {
143    let Some(raw) = raw else { return Ok(None) };
144    if raw == &Value::Bool(false) {
145        return Ok(Some(CatalogCompaction::Disabled));
146    }
147    let source = as_object(Some(raw), path)?;
148    reject_unknown(source, COMPACTION_KEYS, path)?;
149    let ratio = source.get("deadBytesRatio").and_then(Value::as_f64);
150    let ratio = match ratio {
151        Some(r) if r > 0.0 && r <= 1.0 => r,
152        _ => {
153            return Err(fail(
154                &format!("{path}.deadBytesRatio"),
155                "must be a number greater than 0 and at most 1",
156            ))
157        }
158    };
159    let min_dead_bytes = as_whole_number(source.get("minDeadBytes"), &format!("{path}.minDeadBytes"), 0)?;
160    Ok(Some(CatalogCompaction::Enabled {
161        dead_bytes_ratio: ratio,
162        min_dead_bytes,
163    }))
164}
165
166fn parse_options(raw: Option<&Value>, path: &str) -> Result<Option<TableOptions>, HaysonSchemaError> {
167    let Some(raw) = raw else { return Ok(None) };
168    let source = as_object(Some(raw), path)?;
169    reject_unknown(source, OPTION_KEYS, path)?;
170    let sync = match source.get("sync").and_then(Value::as_str) {
171        None => None,
172        Some("commit") => Some(CatalogSyncMode::Commit),
173        Some("never") => Some(CatalogSyncMode::Never),
174        Some(_) => return Err(fail(&format!("{path}.sync"), "must be one of \"commit\", \"never\"")),
175    };
176    let compaction = parse_compaction(source.get("compaction"), &format!("{path}.compaction"))?;
177    let link_parsing = match source.get("linkParsing").and_then(Value::as_str) {
178        None => None,
179        Some("eager") => Some(LinkParsing::Eager),
180        Some("lazy") => Some(LinkParsing::Lazy),
181        Some(_) => {
182            return Err(fail(
183                &format!("{path}.linkParsing"),
184                "must be one of \"eager\", \"lazy\"",
185            ))
186        }
187    };
188    let image_dir = optional_text(source.get("imageDir"), &format!("{path}.imageDir"))?;
189    Ok(Some(TableOptions {
190        sync,
191        compaction,
192        link_parsing,
193        image_dir,
194    }))
195}
196
197fn parse_predicate_value(raw: &Value, path: &str) -> Result<Value, HaysonSchemaError> {
198    match raw {
199        Value::Null | Value::String(_) | Value::Number(_) | Value::Bool(_) => Ok(raw.clone()),
200        Value::Array(items) => {
201            for (at, item) in items.iter().enumerate() {
202                if !(item.is_string() || item.is_number() || item.is_boolean()) {
203                    return Err(fail(
204                        &format!("{path}[{at}]"),
205                        "must be a string, a number or a boolean",
206                    ));
207                }
208            }
209            Ok(raw.clone())
210        }
211        _ => Err(fail(path, "must be a string, number, boolean, array or null")),
212    }
213}
214
215fn parse_predicate(
216    raw: &Value,
217    path: &str,
218    fields: &BTreeMap<String, CatalogFieldSpec>,
219) -> Result<CatalogPredicate, HaysonSchemaError> {
220    let source = as_object(Some(raw), path)?;
221    reject_unknown(source, PREDICATE_KEYS, path)?;
222    let field = as_text(source.get("field"), &format!("{path}.field"))?;
223    let spec = fields.get(&field).ok_or_else(|| {
224        fail(
225            &format!("{path}.field"),
226            format!("names a field \"{field}\" the table does not declare"),
227        )
228    })?;
229    if spec.spec.cypher || spec.spec.password_hash {
230        return Err(fail(
231            &format!("{path}.field"),
232            format!("names the encrypted field \"{field}\" — the registry holds it enciphered, so a predicate against it can never match"),
233        ));
234    }
235    let op = as_text(source.get("op"), &format!("{path}.op"))?;
236    if !CATALOG_PREDICATE_OPS.contains(&op.as_str()) {
237        return Err(fail(
238            &format!("{path}.op"),
239            format!("must be one of {}", CATALOG_PREDICATE_OPS.join(", ")),
240        ));
241    }
242    let value = parse_predicate_value(source.get("value").unwrap_or(&Value::Null), &format!("{path}.value"))?;
243    Ok(CatalogPredicate { field, op, value })
244}
245
246fn sortable_in_index_of(field_type: FieldType, width: usize) -> bool {
247    if width == 1 {
248        return field_type.is_sortable();
249    }
250    !field_type.is_array() && field_type != FieldType::Json && !field_type.has_virtual_path()
251}
252
253pub fn parse_index_entry(
254    raw: &Value,
255    path: &str,
256    fields: &BTreeMap<String, CatalogFieldSpec>,
257) -> Result<IndexEntry, HaysonSchemaError> {
258    let source = as_object(Some(raw), path)?;
259    reject_unknown(source, INDEX_KEYS, path)?;
260
261    let name = as_name(source.get("name"), &format!("{path}.name"), "index")?;
262    let kind_raw = source
263        .get("kind")
264        .and_then(Value::as_str)
265        .ok_or_else(|| fail(&format!("{path}.kind"), "is required"))?;
266    let kind = match kind_raw {
267        "exact" => CatalogIndexKind::Exact,
268        "sorted" => CatalogIndexKind::Sorted,
269        "fulltext" => CatalogIndexKind::Fulltext,
270        "ttl" => CatalogIndexKind::Ttl,
271        other => {
272            return Err(fail(
273                &format!("{path}.kind"),
274                format!("must be one of exact, sorted, fulltext, ttl, got {other:?}"),
275            ))
276        }
277    };
278
279    let raw_fields = as_array(source.get("fields"), &format!("{path}.fields"))?;
280    if raw_fields.is_empty() {
281        return Err(fail(&format!("{path}.fields"), "must name at least one field"));
282    }
283    let mut named: Vec<String> = Vec::new();
284    for (at, entry) in raw_fields.iter().enumerate() {
285        let field = as_text(Some(entry), &format!("{path}.fields[{at}]"))?;
286        if named.contains(&field) {
287            return Err(fail(
288                &format!("{path}.fields[{at}]"),
289                format!("repeats the field \"{field}\""),
290            ));
291        }
292        let spec = fields.get(&field).ok_or_else(|| {
293            fail(
294                &format!("{path}.fields[{at}]"),
295                format!("names a field \"{field}\" the table does not declare"),
296            )
297        })?;
298        if spec.spec.cypher || spec.spec.password_hash {
299            return Err(fail(
300                &format!("{path}.fields[{at}]"),
301                format!("names the encrypted field \"{field}\" — a random IV makes two equal values differ"),
302            ));
303        }
304        if kind == CatalogIndexKind::Sorted && !sortable_in_index_of(spec.spec.field_type, raw_fields.len()) {
305            return Err(fail(
306                &format!("{path}.fields[{at}]"),
307                format!(
308                    "names \"{field}\" of type {}, which cannot be sorted",
309                    spec.spec.field_type.as_str()
310                ),
311            ));
312        }
313        if kind == CatalogIndexKind::Fulltext
314            && spec.spec.field_type != FieldType::String
315            && spec.spec.field_type != FieldType::StringArray
316        {
317            return Err(fail(
318                &format!("{path}.fields[{at}]"),
319                format!(
320                    "names \"{field}\" of type {}, which cannot be searched as text",
321                    spec.spec.field_type.as_str()
322                ),
323            ));
324        }
325        if kind == CatalogIndexKind::Ttl && spec.spec.field_type != FieldType::Timestamp {
326            return Err(fail(
327                &format!("{path}.fields[{at}]"),
328                format!(
329                    "names \"{field}\" of type {}, which is not a timestamp",
330                    spec.spec.field_type.as_str()
331                ),
332            ));
333        }
334        named.push(field);
335    }
336    if kind == CatalogIndexKind::Fulltext && named.len() > 1 {
337        return Err(fail(
338            &format!("{path}.fields"),
339            "must name exactly one field for a fulltext index",
340        ));
341    }
342    if kind == CatalogIndexKind::Ttl && named.len() > 1 {
343        return Err(fail(
344            &format!("{path}.fields"),
345            "must name exactly one field for a ttl index",
346        ));
347    }
348    if kind == CatalogIndexKind::Fulltext && source.contains_key("where") {
349        return Err(fail(
350            &format!("{path}.where"),
351            "cannot filter a fulltext index — a record excluded by where would simply not be searchable, which defeats the point",
352        ));
353    }
354
355    let unique = optional_flag(source.get("unique"), &format!("{path}.unique"))?.unwrap_or(false);
356    if unique {
357        if kind != CatalogIndexKind::Exact && kind != CatalogIndexKind::Sorted {
358            return Err(fail(
359                &format!("{path}.unique"),
360                format!("cannot hold on a {} index", kind.as_str()),
361            ));
362        }
363        for field in &named {
364            let spec = &fields[field].spec;
365            if spec.field_type.is_array() {
366                return Err(fail(
367                    &format!("{path}.unique"),
368                    format!("cannot cover \"{field}\" of type {}", spec.field_type.as_str()),
369                ));
370            }
371        }
372    }
373
374    let expire_after_seconds = optional_whole_number(
375        source.get("expireAfterSeconds"),
376        &format!("{path}.expireAfterSeconds"),
377        1,
378    )?;
379    if kind == CatalogIndexKind::Ttl && expire_after_seconds.is_none() {
380        return Err(fail(
381            &format!("{path}.expireAfterSeconds"),
382            "is required on a ttl index",
383        ));
384    }
385    if kind != CatalogIndexKind::Ttl && expire_after_seconds.is_some() {
386        return Err(fail(
387            &format!("{path}.expireAfterSeconds"),
388            format!("only belongs on a ttl index, not on a {} one", kind.as_str()),
389        ));
390    }
391
392    let where_ = match source.get("where") {
393        None => None,
394        Some(raw) => {
395            let clauses = as_array(Some(raw), &format!("{path}.where"))?;
396            if clauses.is_empty() {
397                return Err(fail(&format!("{path}.where"), "must hold at least one predicate"));
398            }
399            let mut parsed = Vec::with_capacity(clauses.len());
400            for (at, clause) in clauses.iter().enumerate() {
401                parsed.push(parse_predicate(clause, &format!("{path}.where[{at}]"), fields)?);
402            }
403            Some(parsed)
404        }
405    };
406
407    Ok(IndexEntry {
408        name,
409        fields: named,
410        kind,
411        unique,
412        where_,
413        expire_after_seconds,
414    })
415}
416
417fn parse_check_entry(raw: &Value, path: &str) -> Result<CheckEntry, HaysonSchemaError> {
418    let source = as_object(Some(raw), path)?;
419    reject_unknown(source, CHECK_KEYS, path)?;
420    Ok(CheckEntry {
421        name: as_name(source.get("name"), &format!("{path}.name"), "check")?,
422        expr: as_text(source.get("expr"), &format!("{path}.expr"))?,
423    })
424}
425
426fn parse_history_entry(raw: &Value, path: &str) -> Result<HistoryEntry, HaysonSchemaError> {
427    let source = as_object(Some(raw), path)?;
428    reject_unknown(source, HISTORY_KEYS, path)?;
429    Ok(HistoryEntry {
430        revision: as_whole_number(source.get("revision"), &format!("{path}.revision"), 1)?,
431        at: as_whole_number(source.get("at"), &format!("{path}.at"), 0)?,
432        note: optional_text(source.get("note"), &format!("{path}.note"))?,
433    })
434}
435
436fn section_document(name: &str, source: &Map<String, Value>) -> Result<Value, HaysonSchemaError> {
437    let raw_fields = as_object(source.get("fields"), &format!("tables.{name}.fields"))?;
438    let mut fields = Map::new();
439    for (field, raw) in raw_fields {
440        let spec = as_object(Some(raw), &format!("tables.{name}.fields.{field}"))?;
441        let mut bare = Map::new();
442        for (key, value) in spec {
443            if FIELD_META_KEYS.contains(&key.as_str()) {
444                continue;
445            }
446            bare.insert(key.clone(), value.clone());
447        }
448        fields.insert(field.clone(), Value::Object(bare));
449    }
450    let mut doc = Map::new();
451    doc.insert("schemaVersion".to_string(), Value::from(SCHEMA_VERSION));
452    doc.insert("table".to_string(), Value::from(name));
453    if let Some(revision) = source.get("revision") {
454        doc.insert("revision".to_string(), revision.clone());
455    }
456    if let Some(primary_key) = source.get("primaryKey") {
457        doc.insert("primaryKey".to_string(), primary_key.clone());
458    }
459    if let Some(partition_by) = source.get("partitionBy") {
460        doc.insert("partitionBy".to_string(), partition_by.clone());
461    }
462    doc.insert("fields".to_string(), Value::Object(fields));
463    Ok(Value::Object(doc))
464}
465
466fn parse_section(name: &str, source: &Map<String, Value>) -> Result<crate::schema::TableSchema, HaysonSchemaError> {
467    let document = section_document(name, source)?;
468    parse_schema(&document)
469        .map_err(|error| HaysonSchemaError(format!("tables.{name} is not a usable table: {}", error.0)))
470}
471
472fn catalog_fields(
473    name: &str,
474    schema: &crate::schema::TableSchema,
475    source: &Map<String, Value>,
476) -> Result<BTreeMap<String, CatalogFieldSpec>, HaysonSchemaError> {
477    let raw_fields = as_object(source.get("fields"), &format!("tables.{name}.fields"))?;
478    let mut fields = BTreeMap::new();
479    for (field, spec) in &schema.fields {
480        let raw = as_object(raw_fields.get(field), &format!("tables.{name}.fields.{field}"))?;
481        let description = optional_text(
482            raw.get("description"),
483            &format!("tables.{name}.fields.{field}.description"),
484        )?;
485        let deprecated = optional_text(
486            raw.get("deprecated"),
487            &format!("tables.{name}.fields.{field}.deprecated"),
488        )?;
489        fields.insert(
490            field.clone(),
491            CatalogFieldSpec {
492                spec: spec.clone(),
493                description,
494                deprecated,
495            },
496        );
497    }
498    Ok(fields)
499}
500
501fn parse_table_entry(name: &str, raw: &Value) -> Result<TableEntry, HaysonSchemaError> {
502    let source = as_object(Some(raw), &format!("tables.{name}"))?;
503    if let Some(declared) = source.get("table") {
504        let message = match declared.as_str() {
505            Some(other) if other != name => {
506                format!("names the table \"{other}\" while the catalog keys the section as \"{name}\"")
507            }
508            _ => "must not repeat the table name — the key in \"tables\" is the name".to_string(),
509        };
510        return Err(fail(&format!("tables.{name}.table"), message));
511    }
512    for marker in ["catalogVersion", "hayson"] {
513        if source.contains_key(marker) {
514            return Err(fail(
515                &format!("tables.{name}.{marker}"),
516                "must not repeat the document marker of the catalog",
517            ));
518        }
519    }
520    reject_unknown(source, TABLE_KEYS, &format!("tables.{name}"))?;
521
522    if let Some(revision) = source.get("revision") {
523        as_whole_number(Some(revision), &format!("tables.{name}.revision"), 1)?;
524    }
525    if let Some(primary_key) = source.get("primaryKey") {
526        as_text(Some(primary_key), &format!("tables.{name}.primaryKey"))?;
527    }
528
529    let schema = parse_section(name, source)?;
530    let fields = catalog_fields(name, &schema, source)?;
531
532    let description = optional_text(source.get("description"), &format!("tables.{name}.description"))?;
533    let created_at = optional_whole_number(source.get("createdAt"), &format!("tables.{name}.createdAt"), 0)?;
534    let updated_at = optional_whole_number(source.get("updatedAt"), &format!("tables.{name}.updatedAt"), 0)?;
535    let validation = match source.get("validation").and_then(Value::as_str) {
536        None => None,
537        Some("strict") => Some(CatalogValidation::Strict),
538        Some("warn") => Some(CatalogValidation::Warn),
539        Some(_) => {
540            return Err(fail(
541                &format!("tables.{name}.validation"),
542                "must be one of \"strict\", \"warn\"",
543            ))
544        }
545    };
546    let options = parse_options(source.get("options"), &format!("tables.{name}.options"))?;
547
548    let mut entry = TableEntry {
549        revision: schema.revision as u64,
550        primary_key: schema.primary_key.clone(),
551        fields,
552        partition_by: schema.partition_by.clone(),
553        description,
554        created_at,
555        updated_at,
556        validation,
557        options,
558        indexes: None,
559        checks: None,
560        history: None,
561    };
562
563    if let Some(raw_indexes) = source.get("indexes") {
564        let raw_indexes = as_array(Some(raw_indexes), &format!("tables.{name}.indexes"))?;
565        let mut indexes = Vec::with_capacity(raw_indexes.len());
566        for (at, raw) in raw_indexes.iter().enumerate() {
567            indexes.push(parse_index_entry(
568                raw,
569                &format!("tables.{name}.indexes[{at}]"),
570                &entry.fields,
571            )?);
572        }
573        for at in 0..indexes.len() {
574            if indexes[..at].iter().any(|other| other.name == indexes[at].name) {
575                return Err(fail(
576                    &format!("tables.{name}.indexes[{at}].name"),
577                    format!("repeats the index name \"{}\"", indexes[at].name),
578                ));
579            }
580        }
581        entry.indexes = Some(indexes);
582    }
583
584    if let Some(raw_checks) = source.get("checks") {
585        let raw_checks = as_array(Some(raw_checks), &format!("tables.{name}.checks"))?;
586        let mut checks = Vec::with_capacity(raw_checks.len());
587        for (at, raw) in raw_checks.iter().enumerate() {
588            checks.push(parse_check_entry(raw, &format!("tables.{name}.checks[{at}]"))?);
589        }
590        for at in 0..checks.len() {
591            if checks[..at].iter().any(|other| other.name == checks[at].name) {
592                return Err(fail(
593                    &format!("tables.{name}.checks[{at}].name"),
594                    format!("repeats the check name \"{}\"", checks[at].name),
595                ));
596            }
597        }
598        entry.checks = Some(checks);
599    }
600
601    if let Some(raw_history) = source.get("history") {
602        let raw_history = as_array(Some(raw_history), &format!("tables.{name}.history"))?;
603        let mut history = Vec::with_capacity(raw_history.len());
604        for (at, raw) in raw_history.iter().enumerate() {
605            history.push(parse_history_entry(raw, &format!("tables.{name}.history[{at}]"))?);
606        }
607        entry.history = Some(history);
608    }
609
610    let expanded = expand_indexes(name, &entry);
611    for at in 0..expanded.len() {
612        let clash = expanded[..at].iter().any(|other| other.name == expanded[at].name);
613        if !clash {
614            continue;
615        }
616        let declared_at = entry
617            .indexes
618            .iter()
619            .flatten()
620            .position(|other| other.name == expanded[at].name);
621        let path = match declared_at {
622            Some(declared_at) => format!("tables.{name}.indexes[{declared_at}].name"),
623            None => format!("tables.{name}.indexes"),
624        };
625        return Err(fail(
626            &path,
627            format!(
628                "collides with the index the field modifiers expand to under the name \"{}\"",
629                expanded[at].name
630            ),
631        ));
632    }
633
634    Ok(entry)
635}
636
637fn parse_relation_end(
638    raw: &Value,
639    path: &str,
640    tables: &BTreeMap<String, TableEntry>,
641) -> Result<RelationEnd, HaysonSchemaError> {
642    let source = as_object(Some(raw), path)?;
643    reject_unknown(source, RELATION_END_KEYS, path)?;
644    let table = as_text(source.get("table"), &format!("{path}.table"))?;
645    let entry = tables.get(&table).ok_or_else(|| {
646        fail(
647            &format!("{path}.table"),
648            format!("names a table \"{table}\" the catalog does not declare"),
649        )
650    })?;
651    let field = as_text(source.get("field"), &format!("{path}.field"))?;
652    if !entry.fields.contains_key(&field) {
653        return Err(fail(
654            &format!("{path}.field"),
655            format!("names a field \"{field}\" table \"{table}\" does not declare"),
656        ));
657    }
658    Ok(RelationEnd { table, field })
659}
660
661fn is_unique_side(entry: &TableEntry, table: &str, field: &str) -> bool {
662    if entry.primary_key == field {
663        return true;
664    }
665    expand_indexes(table, entry)
666        .iter()
667        .any(|index| index.unique && index.fields.len() == 1 && index.fields[0] == field)
668}
669
670pub fn parse_relation(
671    raw: &Value,
672    path: &str,
673    tables: &BTreeMap<String, TableEntry>,
674) -> Result<Relation, HaysonSchemaError> {
675    let source = as_object(Some(raw), path)?;
676    reject_unknown(source, RELATION_KEYS, path)?;
677    let name = as_name(source.get("name"), &format!("{path}.name"), "relation")?;
678    let from = parse_relation_end(
679        source
680            .get("from")
681            .ok_or_else(|| fail(&format!("{path}.from"), "is required"))?,
682        &format!("{path}.from"),
683        tables,
684    )?;
685    let to = parse_relation_end(
686        source
687            .get("to")
688            .ok_or_else(|| fail(&format!("{path}.to"), "is required"))?,
689        &format!("{path}.to"),
690        tables,
691    )?;
692    if from.table == to.table && from.field == to.field {
693        return Err(fail(path, "points at the same field on both sides"));
694    }
695    let target = &tables[&to.table];
696    if !is_unique_side(target, &to.table, &to.field) {
697        return Err(fail(
698            &format!("{path}.to.field"),
699            format!(
700                "names \"{}\", which is neither the primary key of \"{}\" nor unique in it",
701                to.field, to.table
702            ),
703        ));
704    }
705    let cardinality = match source.get("cardinality").and_then(Value::as_str) {
706        Some("one-to-one") => RelationCardinality::OneToOne,
707        Some("many-to-one") => RelationCardinality::ManyToOne,
708        Some(_) => {
709            return Err(fail(
710                &format!("{path}.cardinality"),
711                "must be one of \"one-to-one\", \"many-to-one\"",
712            ))
713        }
714        None => return Err(fail(&format!("{path}.cardinality"), "is required")),
715    };
716    let on_delete = match source.get("onDelete").and_then(Value::as_str) {
717        None => None,
718        Some("restrict") => Some(RelationAction::Restrict),
719        Some("cascade") => Some(RelationAction::Cascade),
720        Some("set-null") => Some(RelationAction::SetNull),
721        Some("no-action") => Some(RelationAction::NoAction),
722        Some(_) => {
723            return Err(fail(
724                &format!("{path}.onDelete"),
725                "must be one of \"restrict\", \"cascade\", \"set-null\", \"no-action\"",
726            ))
727        }
728    };
729    let origin = &tables[&from.table];
730    if on_delete == Some(RelationAction::SetNull) {
731        let required = origin
732            .fields
733            .get(&from.field)
734            .map(|spec| spec.spec.required)
735            .unwrap_or(false);
736        if required {
737            return Err(fail(
738                &format!("{path}.onDelete"),
739                format!("cannot null the required field \"{}.{}\"", from.table, from.field),
740            ));
741        }
742    }
743    let enforce = optional_flag(source.get("enforce"), &format!("{path}.enforce"))?;
744    Ok(Relation {
745        name,
746        from,
747        to,
748        cardinality,
749        on_delete,
750        enforce,
751    })
752}
753
754fn parse_view_entry(raw: &Value, path: &str) -> Result<ViewEntry, HaysonSchemaError> {
755    let source = as_object(Some(raw), path)?;
756    reject_unknown(source, VIEW_KEYS, path)?;
757    Ok(ViewEntry {
758        name: as_name(source.get("name"), &format!("{path}.name"), "view")?,
759        query: as_text(source.get("query"), &format!("{path}.query"))?,
760        description: optional_text(source.get("description"), &format!("{path}.description"))?,
761    })
762}
763
764fn parse_trigger_value_source(
765    raw: &Value,
766    path: &str,
767    fields: &BTreeMap<String, CatalogFieldSpec>,
768) -> Result<TriggerValueSource, HaysonSchemaError> {
769    let source = as_object(Some(raw), path)?;
770    reject_unknown(source, TRIGGER_SOURCE_KEYS, path)?;
771    let kind = source
772        .get("kind")
773        .and_then(Value::as_str)
774        .ok_or_else(|| fail(&format!("{path}.kind"), "is required"))?;
775    match kind {
776        "new" | "old" => {
777            let field = as_text(source.get("field"), &format!("{path}.field"))?;
778            if !fields.contains_key(&field) {
779                return Err(fail(
780                    &format!("{path}.field"),
781                    format!("names a field \"{field}\" the source table does not declare"),
782                ));
783            }
784            Ok(if kind == "new" {
785                TriggerValueSource::NewField(field)
786            } else {
787                TriggerValueSource::OldField(field)
788            })
789        }
790        "constant" => {
791            let value = source
792                .get("value")
793                .ok_or_else(|| fail(&format!("{path}.value"), "is required"))?;
794            Ok(TriggerValueSource::Constant(value.clone()))
795        }
796        "now" => Ok(TriggerValueSource::Now),
797        other => Err(fail(
798            &format!("{path}.kind"),
799            format!("must be one of \"new\", \"old\", \"constant\", \"now\", got {other:?}"),
800        )),
801    }
802}
803
804fn parse_trigger_field_mapping(
805    raw: &Value,
806    path: &str,
807    source_fields: &BTreeMap<String, CatalogFieldSpec>,
808    effect_fields: &BTreeMap<String, CatalogFieldSpec>,
809) -> Result<TriggerFieldMapping, HaysonSchemaError> {
810    let source = as_object(Some(raw), path)?;
811    reject_unknown(source, TRIGGER_FIELD_MAPPING_KEYS, path)?;
812    let target = as_text(source.get("target"), &format!("{path}.target"))?;
813    if !effect_fields.contains_key(&target) {
814        return Err(fail(
815            &format!("{path}.target"),
816            format!("names a field \"{target}\" the effect table does not declare"),
817        ));
818    }
819    let value_source = parse_trigger_value_source(
820        source
821            .get("source")
822            .ok_or_else(|| fail(&format!("{path}.source"), "is required"))?,
823        &format!("{path}.source"),
824        source_fields,
825    )?;
826    Ok(TriggerFieldMapping {
827        target,
828        source: value_source,
829    })
830}
831
832fn parse_trigger_effect(
833    raw: &Value,
834    path: &str,
835    source_fields: &BTreeMap<String, CatalogFieldSpec>,
836    tables: &BTreeMap<String, TableEntry>,
837) -> Result<TriggerEffect, HaysonSchemaError> {
838    let source = as_object(Some(raw), path)?;
839    reject_unknown(source, TRIGGER_EFFECT_KEYS, path)?;
840    let table = as_text(source.get("table"), &format!("{path}.table"))?;
841    let effect_entry = tables.get(&table).ok_or_else(|| {
842        fail(
843            &format!("{path}.table"),
844            format!("names a table \"{table}\" the catalog does not declare"),
845        )
846    })?;
847
848    let raw_fields = as_array(source.get("fields"), &format!("{path}.fields"))?;
849    if raw_fields.is_empty() {
850        return Err(fail(&format!("{path}.fields"), "must name at least one field"));
851    }
852    let mut fields = Vec::with_capacity(raw_fields.len());
853    for (at, raw) in raw_fields.iter().enumerate() {
854        fields.push(parse_trigger_field_mapping(
855            raw,
856            &format!("{path}.fields[{at}]"),
857            source_fields,
858            &effect_entry.fields,
859        )?);
860    }
861    Ok(TriggerEffect { table, fields })
862}
863
864/// Validates `table` and `effect.table` against `tables` before returning anything, so a trigger
865/// naming a table the catalog does not declare is refused here — before `Database::declare_trigger`
866/// ever reaches disk (ADR-s21j/0029, same rule `declare_index`/relations already follow).
867pub fn parse_trigger_entry(
868    raw: &Value,
869    path: &str,
870    tables: &BTreeMap<String, TableEntry>,
871) -> Result<TriggerEntry, HaysonSchemaError> {
872    let source = as_object(Some(raw), path)?;
873    reject_unknown(source, TRIGGER_KEYS, path)?;
874    let name = as_name(source.get("name"), &format!("{path}.name"), "trigger")?;
875    let table = as_text(source.get("table"), &format!("{path}.table"))?;
876    let source_entry = tables.get(&table).ok_or_else(|| {
877        fail(
878            &format!("{path}.table"),
879            format!("names a table \"{table}\" the catalog does not declare"),
880        )
881    })?;
882    let event = match source.get("event").and_then(Value::as_str) {
883        Some("insert") => TriggerEvent::Insert,
884        Some("update") => TriggerEvent::Update,
885        Some("delete") => TriggerEvent::Delete,
886        Some(other) => {
887            return Err(fail(
888                &format!("{path}.event"),
889                format!("must be one of \"insert\", \"update\", \"delete\", got {other:?}"),
890            ))
891        }
892        None => return Err(fail(&format!("{path}.event"), "is required")),
893    };
894    let effect = parse_trigger_effect(
895        source
896            .get("effect")
897            .ok_or_else(|| fail(&format!("{path}.effect"), "is required"))?,
898        &format!("{path}.effect"),
899        &source_entry.fields,
900        tables,
901    )?;
902    Ok(TriggerEntry {
903        name,
904        table,
905        event,
906        effect,
907    })
908}
909
910/// Format 1 is not opened by this engine at all, so every refusal that recognises it has to name
911/// the one way forward: the offline rewrite in `hayson-cli`.
912const FORMAT_1_MIGRATION_HINT: &str =
913    "format 1 is no longer opened — rewrite the directory once with \"hayson-cli migrate-format1 <dir>\", on a copy you can go back to, and open it again";
914
915fn parse_format_version(raw: Option<&Value>) -> Result<u64, HaysonSchemaError> {
916    let value = as_whole_number(raw, "formatVersion", 1)?;
917    if value == CATALOG_FORMAT_VERSION {
918        return Ok(value);
919    }
920    if value < CATALOG_FORMAT_VERSION {
921        return Err(fail(
922            "formatVersion",
923            format!(
924                "is {value}, which describes the manifest of a database that predates the catalog — migrate it to format {CATALOG_FORMAT_VERSION}; {FORMAT_1_MIGRATION_HINT}"
925            ),
926        ));
927    }
928    Err(fail(
929        "formatVersion",
930        format!("is {value}, and this build of hayson reads format {CATALOG_FORMAT_VERSION} — upgrade @theritual/hayson to open it"),
931    ))
932}
933
934/// Validates a parsed `hayson.json` of a format-2 database and returns it typed, without opening
935/// the database. Every message names the path it failed on, such as `tables.users.indexes[1].fields`.
936pub fn parse_catalog(input: &Value) -> Result<Catalog, HaysonSchemaError> {
937    let source = as_object(Some(input), "catalog")?;
938    let marker = source.get("catalogVersion").or_else(|| source.get("hayson"));
939    if marker != Some(&Value::from(CATALOG_VERSION)) {
940        let hint = if marker.and_then(Value::as_u64) == Some(1) {
941            format!("; {FORMAT_1_MIGRATION_HINT}")
942        } else {
943            String::new()
944        };
945        return Err(fail(
946            "catalogVersion",
947            format!(
948                "must be {CATALOG_VERSION}, got {marker:?} — {CATALOG_VERSION} is the marker of a metadata catalog, 1 marks a manifest of the layout that predates it; a catalog written before 5.0.0 carries the same marker under the old key \"hayson\", which is read as well{hint}"
949            ),
950        ));
951    }
952    let format_version = parse_format_version(source.get("formatVersion"))?;
953    reject_unknown(source, CATALOG_KEYS, "catalog")?;
954
955    let created_at = as_whole_number(source.get("createdAt"), "createdAt", 0)?;
956    let updated_at = match source.get("updatedAt") {
957        None => created_at,
958        Some(_) => as_whole_number(source.get("updatedAt"), "updatedAt", 0)?,
959    };
960
961    let raw_tables = as_object(source.get("tables"), "tables")?;
962    let mut tables = BTreeMap::new();
963    for (name, raw) in raw_tables {
964        tables.insert(name.clone(), parse_table_entry(name, raw)?);
965    }
966
967    let engine = optional_text(source.get("engine"), "engine")?;
968    let layout = match source.get("layout").and_then(Value::as_str) {
969        None => None,
970        Some("flat") => Some(CatalogLayout::Flat),
971        Some("nested") => Some(CatalogLayout::Nested),
972        Some(_) => return Err(fail("layout", "must be one of \"flat\", \"nested\"")),
973    };
974    let defaults = parse_options(source.get("defaults"), "defaults")?;
975
976    let relations = match source.get("relations") {
977        None => None,
978        Some(raw) => {
979            let raw_relations = as_array(Some(raw), "relations")?;
980            let mut relations = Vec::with_capacity(raw_relations.len());
981            for (at, raw) in raw_relations.iter().enumerate() {
982                relations.push(parse_relation(raw, &format!("relations[{at}]"), &tables)?);
983            }
984            for at in 0..relations.len() {
985                if relations[..at].iter().any(|other| other.name == relations[at].name) {
986                    return Err(fail(
987                        &format!("relations[{at}].name"),
988                        format!("repeats the relation name \"{}\"", relations[at].name),
989                    ));
990                }
991            }
992            Some(relations)
993        }
994    };
995
996    let views = match source.get("views") {
997        None => None,
998        Some(raw) => {
999            let raw_views = as_array(Some(raw), "views")?;
1000            let mut views = Vec::with_capacity(raw_views.len());
1001            for (at, raw) in raw_views.iter().enumerate() {
1002                views.push(parse_view_entry(raw, &format!("views[{at}]"))?);
1003            }
1004            for at in 0..views.len() {
1005                if views[..at].iter().any(|other| other.name == views[at].name) {
1006                    return Err(fail(
1007                        &format!("views[{at}].name"),
1008                        format!("repeats the view name \"{}\"", views[at].name),
1009                    ));
1010                }
1011            }
1012            Some(views)
1013        }
1014    };
1015
1016    let triggers = match source.get("triggers") {
1017        None => None,
1018        Some(raw) => {
1019            let raw_triggers = as_array(Some(raw), "triggers")?;
1020            let mut triggers = Vec::with_capacity(raw_triggers.len());
1021            for (at, raw) in raw_triggers.iter().enumerate() {
1022                triggers.push(parse_trigger_entry(raw, &format!("triggers[{at}]"), &tables)?);
1023            }
1024            for at in 0..triggers.len() {
1025                if triggers[..at].iter().any(|other| other.name == triggers[at].name) {
1026                    return Err(fail(
1027                        &format!("triggers[{at}].name"),
1028                        format!("repeats the trigger name \"{}\"", triggers[at].name),
1029                    ));
1030                }
1031            }
1032            Some(triggers)
1033        }
1034    };
1035
1036    Ok(Catalog {
1037        catalog_version: CATALOG_VERSION,
1038        format_version,
1039        created_at,
1040        updated_at,
1041        engine,
1042        layout,
1043        defaults,
1044        tables,
1045        relations,
1046        views,
1047        triggers,
1048    })
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use serde_json::json;
1054
1055    use super::*;
1056    use crate::catalog::serialize::{catalog_document, serialize_catalog};
1057
1058    fn reference_document() -> Value {
1059        json!({
1060            "catalogVersion": 2,
1061            "formatVersion": 2,
1062            "createdAt": 1786995100337_u64,
1063            "updatedAt": 1786995400000_u64,
1064            "engine": "1.10.1",
1065            "layout": "flat",
1066            "defaults": { "sync": "commit", "compaction": { "deadBytesRatio": 0.4, "minDeadBytes": 65536 } },
1067            "tables": {
1068                "orders": {
1069                    "revision": 1,
1070                    "primaryKey": "id",
1071                    "fields": {
1072                        "id": { "type": "string" },
1073                        "userId": { "type": "string", "index": "exact" }
1074                    }
1075                },
1076                "users": {
1077                    "revision": 2,
1078                    "description": "konta w panelu",
1079                    "primaryKey": "id",
1080                    "createdAt": 1786995100337_u64,
1081                    "updatedAt": 1786995400000_u64,
1082                    "validation": "strict",
1083                    "fields": {
1084                        "id": { "type": "string" },
1085                        "email": { "type": "string", "unique": true, "description": "login" },
1086                        "role": { "type": "enum", "values": ["admin", "user"], "index": "exact" }
1087                    },
1088                    "indexes": [
1089                        { "name": "users_role_email", "fields": ["role", "email"], "kind": "exact", "unique": true }
1090                    ],
1091                    "checks": [{ "name": "role_present", "expr": "role != ''" }],
1092                    "history": [{ "revision": 2, "at": 1786995400000_u64, "note": "dodane pole role" }]
1093                }
1094            },
1095            "relations": [
1096                {
1097                    "name": "orders_user",
1098                    "from": { "table": "orders", "field": "userId" },
1099                    "to": { "table": "users", "field": "id" },
1100                    "cardinality": "many-to-one",
1101                    "onDelete": "restrict",
1102                    "enforce": false
1103                }
1104            ]
1105        })
1106    }
1107
1108    #[test]
1109    fn parses_the_format_md_reference_document() {
1110        let catalog = parse_catalog(&reference_document()).unwrap();
1111        assert_eq!(catalog.tables.len(), 2);
1112        assert_eq!(catalog.tables["users"].indexes.as_ref().unwrap().len(), 1);
1113        assert_eq!(catalog.relations.as_ref().unwrap().len(), 1);
1114    }
1115
1116    #[test]
1117    fn round_trips_parse_serialize_parse_to_an_identical_catalog() {
1118        let once = parse_catalog(&reference_document()).unwrap();
1119        let reparsed = parse_catalog(&catalog_document(&once)).unwrap();
1120        assert_eq!(once, reparsed);
1121    }
1122
1123    #[test]
1124    fn a_non_addressable_markdown_field_survives_the_catalog_it_is_written_into() {
1125        let mut doc = reference_document();
1126        doc["tables"]["users"]["fields"]["notes"] = json!({ "type": "markdown", "addressable": false });
1127
1128        let once = parse_catalog(&doc).unwrap();
1129        let reparsed = parse_catalog(&catalog_document(&once)).unwrap();
1130
1131        assert!(!once.tables["users"].fields["notes"].spec.addressable);
1132        assert_eq!(once, reparsed);
1133    }
1134
1135    #[test]
1136    fn serializes_to_the_same_bytes_whatever_order_the_keys_arrived_in() {
1137        let forward = parse_catalog(&reference_document()).unwrap();
1138
1139        let reversed_text = {
1140            let mut reordered = Map::new();
1141            let original = reference_document();
1142            let source = original.as_object().unwrap();
1143            for key in source.keys().rev() {
1144                reordered.insert(key.clone(), source[key].clone());
1145            }
1146            Value::Object(reordered)
1147        };
1148        let backward = parse_catalog(&reversed_text).unwrap();
1149
1150        assert_eq!(serialize_catalog(&forward), serialize_catalog(&backward));
1151    }
1152
1153    #[test]
1154    fn rejects_markdown_and_image_in_a_sorted_index_of_any_width() {
1155        for field_type in ["markdown", "image"] {
1156            for fields in [json!(["body"]), json!(["body", "email"])] {
1157                let mut doc = reference_document();
1158                doc["tables"]["users"]["fields"]["body"] = json!({ "type": field_type });
1159                doc["tables"]["users"]["indexes"] =
1160                    json!([{ "name": "users_body", "fields": fields, "kind": "sorted" }]);
1161                let failure = parse_catalog(&doc).unwrap_err();
1162                assert!(
1163                    failure
1164                        .0
1165                        .contains(&format!("of type {field_type}, which cannot be sorted")),
1166                    "{}",
1167                    failure.0
1168                );
1169            }
1170        }
1171    }
1172
1173    /// A format-1 manifest carries marker 1 under the old key and no `catalogVersion` at all.
1174    #[test]
1175    fn rejects_a_catalog_with_marker_1() {
1176        let mut doc = reference_document();
1177        let object = doc.as_object_mut().unwrap();
1178        object.remove("catalogVersion");
1179        object.insert("hayson".to_string(), Value::from(1));
1180        let failure = parse_catalog(&doc).unwrap_err();
1181        assert!(failure.0.contains("hayson-cli migrate-format1"), "{}", failure.0);
1182    }
1183
1184    #[test]
1185    fn rejects_a_marker_that_is_not_format_1_without_naming_the_migration_tool() {
1186        let mut doc = reference_document();
1187        doc["catalogVersion"] = Value::from(7);
1188        let failure = parse_catalog(&doc).unwrap_err();
1189        assert!(!failure.0.contains("hayson-cli migrate-format1"), "{}", failure.0);
1190    }
1191
1192    #[test]
1193    fn parses_a_catalog_that_carries_the_marker_under_the_current_key_alone() {
1194        let doc = reference_document();
1195        assert!(
1196            doc.get("hayson").is_none(),
1197            "the fixture is written the way 5.0.0 writes it"
1198        );
1199        assert_eq!(parse_catalog(&doc).unwrap().catalog_version, CATALOG_VERSION);
1200    }
1201
1202    /// A catalog written before the rename has the marker under `"hayson"`; nothing migrates it, so
1203    /// the read side has to take it as it stands until the next write replaces the key.
1204    #[test]
1205    fn parses_a_catalog_that_still_carries_the_marker_under_the_old_key() {
1206        let mut doc = reference_document();
1207        let object = doc.as_object_mut().unwrap();
1208        let marker = object.remove("catalogVersion").unwrap();
1209        object.insert("hayson".to_string(), marker);
1210        assert_eq!(parse_catalog(&doc).unwrap().catalog_version, CATALOG_VERSION);
1211    }
1212
1213    #[test]
1214    fn rewrites_an_old_keyed_catalog_under_the_current_key_alone() {
1215        let mut doc = reference_document();
1216        let object = doc.as_object_mut().unwrap();
1217        let marker = object.remove("catalogVersion").unwrap();
1218        object.insert("hayson".to_string(), marker);
1219
1220        let rendered = catalog_document(&parse_catalog(&doc).unwrap());
1221
1222        assert_eq!(rendered["catalogVersion"], Value::from(CATALOG_VERSION));
1223        assert!(rendered.get("hayson").is_none(), "the old key is never written again");
1224    }
1225
1226    #[test]
1227    fn rejects_a_formatversion_older_than_the_catalog_and_names_the_migration_tool() {
1228        let mut doc = reference_document();
1229        doc["formatVersion"] = Value::from(1);
1230        let failure = parse_catalog(&doc).unwrap_err();
1231        assert!(failure.0.contains("hayson-cli migrate-format1"), "{}", failure.0);
1232    }
1233
1234    #[test]
1235    fn rejects_a_formatversion_newer_than_this_build_reads() {
1236        let mut doc = reference_document();
1237        doc["formatVersion"] = Value::from(3);
1238        assert!(parse_catalog(&doc).is_err());
1239    }
1240
1241    #[test]
1242    fn rejects_an_unknown_top_level_option() {
1243        let mut doc = reference_document();
1244        doc.as_object_mut()
1245            .unwrap()
1246            .insert("somethingNew".to_string(), Value::from(true));
1247        assert!(parse_catalog(&doc).is_err());
1248    }
1249
1250    #[test]
1251    fn rejects_a_declared_index_whose_name_collides_with_field_modifier_sugar_under_different_content() {
1252        // "email" sugars to an exact index named "users_email_exact" (docs/format.md, "Rozwinięty
1253        // zbiór indeksów"); declaring a *sorted* index under that same generated name is a distinct
1254        // identity that still collides on the name, which parseTableEntry refuses.
1255        let doc = json!({
1256            "catalogVersion": 2,
1257            "formatVersion": 2,
1258            "createdAt": 0,
1259            "tables": {
1260                "users": {
1261                    "revision": 1,
1262                    "primaryKey": "id",
1263                    "fields": {
1264                        "id": { "type": "string" },
1265                        "email": { "type": "string", "index": "exact" }
1266                    },
1267                    "indexes": [
1268                        { "name": "users_email_exact", "fields": ["email"], "kind": "sorted" }
1269                    ]
1270                }
1271            }
1272        });
1273        assert!(parse_catalog(&doc).is_err());
1274    }
1275
1276    #[test]
1277    fn rejects_a_relation_pointing_at_a_non_unique_field() {
1278        let doc = json!({
1279            "catalogVersion": 2,
1280            "formatVersion": 2,
1281            "createdAt": 0,
1282            "tables": {
1283                "orders": {
1284                    "revision": 1,
1285                    "primaryKey": "id",
1286                    "fields": { "id": { "type": "string" }, "userId": { "type": "string" } }
1287                },
1288                "users": {
1289                    "revision": 1,
1290                    "primaryKey": "id",
1291                    "fields": { "id": { "type": "string" }, "name": { "type": "string" } }
1292                }
1293            },
1294            "relations": [
1295                {
1296                    "name": "orders_user",
1297                    "from": { "table": "orders", "field": "userId" },
1298                    "to": { "table": "users", "field": "name" },
1299                    "cardinality": "many-to-one"
1300                }
1301            ]
1302        });
1303        assert!(parse_catalog(&doc).is_err());
1304    }
1305
1306    #[test]
1307    fn rejects_a_where_predicate_naming_an_unknown_field() {
1308        let doc = json!({
1309            "catalogVersion": 2,
1310            "formatVersion": 2,
1311            "createdAt": 0,
1312            "tables": {
1313                "users": {
1314                    "revision": 1,
1315                    "primaryKey": "id",
1316                    "fields": { "id": { "type": "string" }, "age": { "type": "int" } },
1317                    "indexes": [
1318                        {
1319                            "name": "adults",
1320                            "fields": ["id"],
1321                            "kind": "exact",
1322                            "where": [{ "field": "missing", "op": ">=", "value": 18 }]
1323                        }
1324                    ]
1325                }
1326            }
1327        });
1328        assert!(parse_catalog(&doc).is_err());
1329    }
1330
1331    fn users_and_orders() -> Value {
1332        json!({
1333            "catalogVersion": 2,
1334            "formatVersion": 2,
1335            "createdAt": 0,
1336            "tables": {
1337                "orders": {
1338                    "revision": 1,
1339                    "primaryKey": "id",
1340                    "fields": { "id": { "type": "string" }, "userId": { "type": "string" } }
1341                },
1342                "users": {
1343                    "revision": 1,
1344                    "primaryKey": "id",
1345                    "fields": { "id": { "type": "string" }, "name": { "type": "string" } }
1346                }
1347            }
1348        })
1349    }
1350
1351    fn with_trigger(mut doc: Value, trigger: Value) -> Value {
1352        doc["triggers"] = Value::Array(vec![trigger]);
1353        doc
1354    }
1355
1356    fn users_insert_audit_trigger() -> Value {
1357        json!({
1358            "name": "users_insert_audit",
1359            "table": "users",
1360            "event": "insert",
1361            "effect": {
1362                "table": "orders",
1363                "fields": [
1364                    { "target": "id", "source": { "kind": "constant", "value": "o-audit" } },
1365                    { "target": "userId", "source": { "kind": "new", "field": "id" } }
1366                ]
1367            }
1368        })
1369    }
1370
1371    #[test]
1372    fn parses_a_declared_trigger_and_round_trips_it() {
1373        let doc = with_trigger(users_and_orders(), users_insert_audit_trigger());
1374        let catalog = parse_catalog(&doc).unwrap();
1375
1376        let triggers = catalog.triggers.as_ref().unwrap();
1377        assert_eq!(triggers.len(), 1);
1378        assert_eq!(triggers[0].name, "users_insert_audit");
1379        assert_eq!(triggers[0].table, "users");
1380        assert_eq!(triggers[0].effect.table, "orders");
1381
1382        let reparsed = parse_catalog(&catalog_document(&catalog)).unwrap();
1383        assert_eq!(catalog, reparsed);
1384    }
1385
1386    #[test]
1387    fn rejects_a_trigger_naming_a_source_table_the_catalog_does_not_declare() {
1388        let mut trigger = users_insert_audit_trigger();
1389        trigger["table"] = Value::from("ghost");
1390        let doc = with_trigger(users_and_orders(), trigger);
1391        assert!(parse_catalog(&doc).is_err());
1392    }
1393
1394    #[test]
1395    fn rejects_a_trigger_whose_effect_names_a_table_the_catalog_does_not_declare() {
1396        let mut trigger = users_insert_audit_trigger();
1397        trigger["effect"]["table"] = Value::from("ghost");
1398        let doc = with_trigger(users_and_orders(), trigger);
1399        assert!(parse_catalog(&doc).is_err());
1400    }
1401
1402    #[test]
1403    fn rejects_two_triggers_sharing_a_name() {
1404        let doc = users_and_orders();
1405        let mut doc = with_trigger(doc, users_insert_audit_trigger());
1406        doc["triggers"]
1407            .as_array_mut()
1408            .unwrap()
1409            .push(users_insert_audit_trigger());
1410        assert!(parse_catalog(&doc).is_err());
1411    }
1412
1413    #[test]
1414    fn parses_a_table_declared_lazy_link_parsing_option() {
1415        let mut doc = users_and_orders();
1416        doc["tables"]["users"]["options"] = json!({ "linkParsing": "lazy" });
1417        let catalog = parse_catalog(&doc).unwrap();
1418        assert_eq!(
1419            catalog.tables["users"].options.as_ref().unwrap().link_parsing,
1420            Some(LinkParsing::Lazy)
1421        );
1422    }
1423
1424    #[test]
1425    fn rejects_an_unsupported_link_parsing_mode() {
1426        let mut doc = users_and_orders();
1427        doc["tables"]["users"]["options"] = json!({ "linkParsing": "sometimes" });
1428        assert!(parse_catalog(&doc).is_err());
1429    }
1430
1431    #[test]
1432    fn parses_a_table_declared_image_dir_option() {
1433        let mut doc = users_and_orders();
1434        doc["tables"]["users"]["options"] = json!({ "imageDir": "images/users" });
1435        let catalog = parse_catalog(&doc).unwrap();
1436        assert_eq!(
1437            catalog.tables["users"].options.as_ref().unwrap().image_dir,
1438            Some("images/users".to_string())
1439        );
1440    }
1441}