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 "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
864pub 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
910const 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
934pub 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 #[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 #[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 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}