1use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9
10use corium_core::{Cardinality, Keyword, Unique, ValueType};
11use corium_query::edn::Edn;
12use serde::de::value::MapAccessDeserializer;
13use serde::de::{MapAccess, Visitor};
14use serde::{Deserialize, Deserializer};
15use thiserror::Error;
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct AttributeDefinition {
20 pub group: Option<String>,
22 pub name: String,
24 pub value_type: ValueType,
26 pub cardinality: Cardinality,
28 pub unique: Option<Unique>,
30 pub indexed: bool,
32 pub component: bool,
34 pub no_history: bool,
36}
37
38impl AttributeDefinition {
39 #[must_use]
41 pub fn ident(&self) -> Keyword {
42 Keyword::new(self.group.as_deref(), &self.name)
43 }
44
45 #[must_use]
47 pub fn to_edn(&self) -> Edn {
48 let mut pairs = vec![
49 (kw("db/ident"), Edn::Keyword(self.ident())),
50 (
51 kw("db/valueType"),
52 kw(&format!("db.type/{}", value_type_name(self.value_type))),
53 ),
54 (
55 kw("db/cardinality"),
56 kw(match self.cardinality {
57 Cardinality::One => "db.cardinality/one",
58 Cardinality::Many => "db.cardinality/many",
59 }),
60 ),
61 ];
62 if let Some(unique) = self.unique {
63 pairs.push((
64 kw("db/unique"),
65 kw(match unique {
66 Unique::Identity => "db.unique/identity",
67 Unique::Value => "db.unique/value",
68 }),
69 ));
70 }
71 if self.indexed {
72 pairs.push((kw("db/index"), Edn::Bool(true)));
73 }
74 if self.component {
75 pairs.push((kw("db/isComponent"), Edn::Bool(true)));
76 }
77 if self.no_history {
78 pairs.push((kw("db/noHistory"), Edn::Bool(true)));
79 }
80 pairs.sort_unstable();
81 Edn::Map(pairs)
82 }
83}
84
85#[derive(Debug, Error)]
87pub enum TomlSchemaError {
88 #[error("invalid TOML schema: {0}")]
90 Parse(#[from] toml::de::Error),
91 #[error("unsupported schema-version {0}; expected 1")]
93 UnsupportedVersion(u32),
94 #[error(
96 "invalid {kind} name {name:?}: expected a non-empty EDN keyword component \
97 without reserved punctuation, whitespace, or a leading digit"
98 )]
99 InvalidName {
100 kind: &'static str,
102 name: String,
104 },
105 #[error("unknown value type {value:?} for {ident}")]
107 UnknownValueType {
108 ident: String,
110 value: String,
112 },
113 #[error("unknown cardinality {value:?} for {ident}")]
115 UnknownCardinality {
116 ident: String,
118 value: String,
120 },
121 #[error("unknown unique mode {value:?} for {ident}")]
123 UnknownUnique {
124 ident: String,
126 value: String,
128 },
129 #[error("{ident} specifies both `cardinality` and `many`; use only one")]
131 ConflictingCardinality {
132 ident: String,
134 },
135 #[error("duplicate attribute {0}")]
137 DuplicateAttribute(String),
138 #[error("duplicate entity group {0:?}")]
140 DuplicateEntity(String),
141}
142
143#[derive(Debug, Deserialize)]
144#[serde(deny_unknown_fields, rename_all = "kebab-case")]
145struct SchemaDocument {
146 #[serde(default = "default_schema_version")]
147 schema_version: u32,
148 #[serde(default, rename = "entity")]
149 entities: Vec<EntityDeclaration>,
150 #[serde(default, rename = "attribute")]
151 attributes: Vec<FlatAttributeDeclaration>,
152}
153
154const fn default_schema_version() -> u32 {
155 1
156}
157
158#[derive(Debug, Deserialize)]
159#[serde(deny_unknown_fields)]
160struct EntityDeclaration {
161 name: String,
162 #[serde(default)]
165 attributes: BTreeMap<String, RawAttribute>,
166}
167
168#[derive(Debug)]
169enum RawAttribute {
170 Shorthand(String),
171 Detailed(AttributeOptions),
172}
173
174impl<'de> Deserialize<'de> for RawAttribute {
175 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
176 where
177 D: Deserializer<'de>,
178 {
179 struct RawAttributeVisitor;
180
181 impl<'de> Visitor<'de> for RawAttributeVisitor {
182 type Value = RawAttribute;
183
184 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
185 formatter.write_str("a value type string or an attribute options table")
186 }
187
188 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
189 where
190 E: serde::de::Error,
191 {
192 Ok(RawAttribute::Shorthand(value.to_owned()))
193 }
194
195 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
196 where
197 E: serde::de::Error,
198 {
199 Ok(RawAttribute::Shorthand(value))
200 }
201
202 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
203 where
204 M: MapAccess<'de>,
205 {
206 AttributeOptions::deserialize(MapAccessDeserializer::new(map))
207 .map(RawAttribute::Detailed)
208 }
209 }
210
211 deserializer.deserialize_any(RawAttributeVisitor)
212 }
213}
214
215#[derive(Debug, Deserialize)]
216#[serde(deny_unknown_fields, rename_all = "kebab-case")]
217struct FlatAttributeDeclaration {
218 #[serde(default)]
219 group: Option<String>,
220 name: String,
221 #[serde(flatten)]
222 options: AttributeOptions,
223}
224
225#[derive(Debug, Deserialize)]
226#[serde(deny_unknown_fields, rename_all = "kebab-case")]
227struct AttributeOptions {
228 #[serde(rename = "type")]
229 value_type: String,
230 #[serde(default)]
231 cardinality: Option<String>,
232 #[serde(default)]
233 many: Option<bool>,
234 #[serde(default)]
235 unique: Option<String>,
236 #[serde(default)]
237 index: bool,
238 #[serde(default)]
239 component: bool,
240 #[serde(default)]
241 no_history: bool,
242}
243
244impl RawAttribute {
245 fn into_options(self) -> AttributeOptions {
246 match self {
247 Self::Shorthand(value_type) => AttributeOptions {
248 value_type,
249 cardinality: None,
250 many: None,
251 unique: None,
252 index: false,
253 component: false,
254 no_history: false,
255 },
256 Self::Detailed(options) => options,
257 }
258 }
259}
260
261pub fn parse(input: &str) -> Result<Vec<AttributeDefinition>, TomlSchemaError> {
271 let document: SchemaDocument = toml::from_str(input)?;
272 if document.schema_version != 1 {
273 return Err(TomlSchemaError::UnsupportedVersion(document.schema_version));
274 }
275
276 let mut definitions = Vec::new();
277 let mut seen_entities = BTreeSet::new();
278 for entity in document.entities {
279 validate_name("entity", &entity.name)?;
280 if !seen_entities.insert(entity.name.clone()) {
281 return Err(TomlSchemaError::DuplicateEntity(entity.name));
282 }
283 for (name, raw) in entity.attributes {
284 definitions.push(normalize(
285 Some(entity.name.clone()),
286 name,
287 raw.into_options(),
288 )?);
289 }
290 }
291 for attribute in document.attributes {
292 definitions.push(normalize(
293 attribute.group,
294 attribute.name,
295 attribute.options,
296 )?);
297 }
298
299 let mut seen = BTreeSet::new();
300 for definition in &definitions {
301 let ident = definition.ident().to_string();
302 if !seen.insert(ident.clone()) {
303 return Err(TomlSchemaError::DuplicateAttribute(ident));
304 }
305 }
306 Ok(definitions)
307}
308
309pub fn parse_edn(input: &str) -> Result<Vec<Edn>, TomlSchemaError> {
314 parse(input).map(|definitions| {
315 definitions
316 .into_iter()
317 .map(|definition| definition.to_edn())
318 .collect()
319 })
320}
321
322fn normalize(
323 group: Option<String>,
324 name: String,
325 options: AttributeOptions,
326) -> Result<AttributeDefinition, TomlSchemaError> {
327 if let Some(group) = &group {
328 validate_name("group", group)?;
329 }
330 validate_name("attribute", &name)?;
331 let ident = display_ident(group.as_deref(), &name);
332 let value_type =
333 parse_value_type(&options.value_type).ok_or_else(|| TomlSchemaError::UnknownValueType {
334 ident: ident.clone(),
335 value: options.value_type,
336 })?;
337 let cardinality = match (options.cardinality, options.many) {
338 (Some(_), Some(_)) => {
339 return Err(TomlSchemaError::ConflictingCardinality { ident });
340 }
341 (Some(value), None) => {
342 parse_cardinality(&value).ok_or_else(|| TomlSchemaError::UnknownCardinality {
343 ident: ident.clone(),
344 value,
345 })?
346 }
347 (None, Some(true)) => Cardinality::Many,
348 (None, Some(false) | None) => Cardinality::One,
349 };
350 let unique = options
351 .unique
352 .map(|value| {
353 parse_unique(&value).ok_or_else(|| TomlSchemaError::UnknownUnique {
354 ident: ident.clone(),
355 value,
356 })
357 })
358 .transpose()?;
359 let indexed = options.index || unique.is_some();
360
361 Ok(AttributeDefinition {
362 group,
363 name,
364 value_type,
365 cardinality,
366 unique,
367 indexed,
368 component: options.component,
369 no_history: options.no_history,
370 })
371}
372
373fn validate_name(kind: &'static str, name: &str) -> Result<(), TomlSchemaError> {
374 if is_valid_edn_keyword_component(name) {
375 Ok(())
376 } else {
377 Err(TomlSchemaError::InvalidName {
378 kind,
379 name: name.to_owned(),
380 })
381 }
382}
383
384fn is_valid_edn_keyword_component(name: &str) -> bool {
385 let Some(first) = name.chars().next() else {
386 return false;
387 };
388 if first.is_ascii_digit() {
389 return false;
390 }
391 !name.chars().any(|character| {
392 character.is_whitespace()
393 || character.is_control()
394 || matches!(
395 character,
396 '/' | ':'
397 | ';'
398 | '"'
399 | '('
400 | ')'
401 | '['
402 | ']'
403 | '{'
404 | '}'
405 | ','
406 | '`'
407 | '~'
408 | '@'
409 | '^'
410 | '\\'
411 )
412 })
413}
414
415fn display_ident(group: Option<&str>, name: &str) -> String {
416 group.map_or_else(|| format!(":{name}"), |group| format!(":{group}/{name}"))
417}
418
419fn parse_value_type(value: &str) -> Option<ValueType> {
420 match value {
421 "boolean" => Some(ValueType::Bool),
422 "long" => Some(ValueType::Long),
423 "double" => Some(ValueType::Double),
424 "instant" => Some(ValueType::Instant),
425 "uuid" => Some(ValueType::Uuid),
426 "keyword" => Some(ValueType::Keyword),
427 "string" => Some(ValueType::Str),
428 "bytes" => Some(ValueType::Bytes),
429 "ref" => Some(ValueType::Ref),
430 _ => None,
431 }
432}
433
434const fn value_type_name(value_type: ValueType) -> &'static str {
435 match value_type {
436 ValueType::Bool => "boolean",
437 ValueType::Long => "long",
438 ValueType::Double => "double",
439 ValueType::Instant => "instant",
440 ValueType::Uuid => "uuid",
441 ValueType::Keyword => "keyword",
442 ValueType::Str => "string",
443 ValueType::Bytes => "bytes",
444 ValueType::Ref => "ref",
445 }
446}
447
448fn parse_cardinality(value: &str) -> Option<Cardinality> {
449 match value {
450 "one" => Some(Cardinality::One),
451 "many" => Some(Cardinality::Many),
452 _ => None,
453 }
454}
455
456fn parse_unique(value: &str) -> Option<Unique> {
457 match value {
458 "identity" => Some(Unique::Identity),
459 "value" => Some(Unique::Value),
460 _ => None,
461 }
462}
463
464fn kw(text: &str) -> Edn {
465 Edn::keyword(text)
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::schemaform::schema_from_edn;
472 use corium_query::edn::read_one;
473
474 const SCHEMA: &str = r#"
475schema-version = 1
476
477[[entity]]
478name = "person"
479
480[entity.attributes]
481age = "long"
482id = { type = "uuid", unique = "identity" }
483name = { type = "string", index = true }
484tags = { type = "keyword", many = true }
485address = { type = "ref", component = true }
486
487[[entity]]
488name = "organization"
489
490[entity.attributes]
491name = "string"
492employees = { type = "ref", cardinality = "many", no-history = true }
493
494[[attribute]]
495name = "created-at"
496type = "instant"
497index = true
498
499[[attribute]]
500group = "audit"
501name = "created-by"
502type = "ref"
503"#;
504
505 #[test]
506 fn parses_grouped_and_flat_attributes() {
507 let definitions = parse(SCHEMA).expect("schema parses");
508 assert_eq!(definitions.len(), 9);
509 assert_eq!(definitions[0].ident().to_string(), ":person/address");
510 assert_eq!(definitions[1].ident().to_string(), ":person/age");
511 assert_eq!(definitions[3].ident().to_string(), ":person/name");
512 assert_eq!(
513 definitions[5].ident().to_string(),
514 ":organization/employees"
515 );
516 assert_eq!(definitions[7].ident().to_string(), ":created-at");
517 assert_eq!(definitions[8].ident().to_string(), ":audit/created-by");
518 assert_eq!(definitions[2].unique, Some(Unique::Identity));
519 assert!(definitions[2].indexed);
520 assert_eq!(definitions[4].cardinality, Cardinality::Many);
521 assert!(definitions[5].no_history);
522 }
523
524 #[test]
525 fn generated_edn_installs_through_existing_schema_path() {
526 let forms = parse_edn(SCHEMA).expect("schema parses");
527 let (schema, idents) = schema_from_edn(&forms).expect("schema installs");
528 assert_eq!(schema.iter().count(), forms.len() + 1);
529 assert_eq!(
530 idents.entid(&corium_db::bootstrap::tx_instant_ident()),
531 Some(corium_db::bootstrap::TX_INSTANT)
532 );
533 let tags = idents
534 .entid(&Keyword::new(Some("person"), "tags"))
535 .expect("tags ident");
536 assert_eq!(
537 schema.get(tags).expect("tags attribute").cardinality,
538 Cardinality::Many
539 );
540 let id = idents
541 .entid(&Keyword::new(Some("person"), "id"))
542 .expect("id ident");
543 let id_meta = schema.get(id).expect("id attribute");
544 assert_eq!(id_meta.unique, Some(Unique::Identity));
545 assert!(id_meta.indexed);
546 let address = idents
547 .entid(&Keyword::new(Some("person"), "address"))
548 .expect("address ident");
549 assert!(schema.get(address).expect("address attribute").is_component);
550 let employees = idents
551 .entid(&Keyword::new(Some("organization"), "employees"))
552 .expect("employees ident");
553 assert!(
554 schema
555 .get(employees)
556 .expect("employees attribute")
557 .no_history
558 );
559 }
560
561 #[test]
562 fn rejects_duplicate_canonical_attributes() {
563 let error = parse(
564 r#"
565[[entity]]
566name = "person"
567[entity.attributes]
568name = "string"
569
570[[attribute]]
571group = "person"
572name = "name"
573type = "string"
574"#,
575 )
576 .expect_err("duplicate must fail");
577 assert_eq!(error.to_string(), "duplicate attribute :person/name");
578 }
579
580 #[test]
581 fn rejects_unknown_fields_and_values() {
582 let unknown_field = parse(
583 r#"
584[[entity]]
585name = "person"
586[entity.attributes]
587name = { type = "string", required = true }
588"#,
589 )
590 .expect_err("unknown option must fail");
591 assert!(
592 unknown_field
593 .to_string()
594 .contains("unknown field `required`"),
595 "{unknown_field}"
596 );
597
598 let unknown_type = parse(
599 r#"
600[[attribute]]
601name = "score"
602type = "integer"
603"#,
604 )
605 .expect_err("unknown type must fail");
606 assert_eq!(
607 unknown_type.to_string(),
608 "unknown value type \"integer\" for :score"
609 );
610 }
611
612 #[test]
613 fn rejects_names_that_cannot_round_trip_through_edn() {
614 for name in [
615 "",
616 "123name",
617 "my attr",
618 "a:b[c]",
619 "semi;colon",
620 "quoted\"name",
621 "person/legacy",
622 ] {
623 let input = format!(
624 r#"
625[[attribute]]
626name = {name:?}
627type = "string"
628"#
629 );
630 let error = parse(&input).expect_err("invalid name must fail");
631 assert!(
632 error
633 .to_string()
634 .contains("expected a non-empty EDN keyword component"),
635 "{error}"
636 );
637 }
638
639 let group_error = parse(
640 r#"
641[[attribute]]
642group = "group name"
643name = "value"
644type = "string"
645"#,
646 )
647 .expect_err("invalid group must fail");
648 assert!(
649 group_error
650 .to_string()
651 .contains("invalid group name \"group name\"")
652 );
653 }
654
655 #[test]
656 fn quoted_toml_keys_still_round_trip_as_edn_keywords() {
657 let forms = parse_edn(
658 r#"
659[[entity]]
660name = "person"
661[entity.attributes]
662"active?" = "boolean"
663"#,
664 )
665 .expect("valid quoted key");
666 let printed = forms[0].to_string();
667 assert_eq!(read_one(&printed).expect("printed EDN parses"), forms[0]);
668 }
669
670 #[test]
671 fn rejects_conflicting_cardinality_spellings() {
672 let error = parse(
673 r#"
674[[attribute]]
675name = "tags"
676type = "string"
677cardinality = "many"
678many = true
679"#,
680 )
681 .expect_err("conflict must fail");
682 assert_eq!(
683 error.to_string(),
684 ":tags specifies both `cardinality` and `many`; use only one"
685 );
686 }
687
688 #[test]
689 fn rejects_unsupported_version_and_unknown_unique_mode() {
690 let version = parse("schema-version = 2").expect_err("version must fail");
691 assert_eq!(
692 version.to_string(),
693 "unsupported schema-version 2; expected 1"
694 );
695
696 let unique = parse(
697 r#"
698[[attribute]]
699name = "id"
700type = "uuid"
701unique = "primary"
702"#,
703 )
704 .expect_err("unique mode must fail");
705 assert_eq!(
706 unique.to_string(),
707 "unknown unique mode \"primary\" for :id"
708 );
709 }
710
711 #[test]
712 fn allows_empty_entity_groups_but_rejects_duplicate_groups() {
713 let definitions = parse(
714 r#"
715[[entity]]
716name = "person"
717
718[[attribute]]
719group = "person"
720name = "name"
721type = "string"
722"#,
723 )
724 .expect("empty group is authoring-only");
725 assert_eq!(definitions.len(), 1);
726
727 let duplicate = parse(
728 r#"
729[[entity]]
730name = "person"
731
732[[entity]]
733name = "person"
734"#,
735 )
736 .expect_err("duplicate entity group must fail");
737 assert_eq!(duplicate.to_string(), "duplicate entity group \"person\"");
738 }
739}