Skip to main content

corium_forms/
toml_schema.rs

1//! Hierarchical TOML schema declarations.
2//!
3//! `[[entity]]` declarations are authoring sugar: their names become keyword
4//! namespaces, but they do not install or enforce entity types. `[[attribute]]`
5//! declarations expose the same model without an entity block.
6
7use 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/// One normalized attribute declaration, independent of its source syntax.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct AttributeDefinition {
20    /// Optional authoring group, mapped to the keyword namespace.
21    pub group: Option<String>,
22    /// Attribute name within its group, or its complete unnamespaced name.
23    pub name: String,
24    /// Stored value type.
25    pub value_type: ValueType,
26    /// Whether entities may hold one or many values for the attribute.
27    pub cardinality: Cardinality,
28    /// Optional uniqueness behavior.
29    pub unique: Option<Unique>,
30    /// Whether the attribute requests AVET coverage.
31    pub indexed: bool,
32    /// Whether a reference is a component of its parent.
33    pub component: bool,
34    /// Whether history storage is disabled for the attribute.
35    pub no_history: bool,
36}
37
38impl AttributeDefinition {
39    /// Returns the canonical Corium keyword ident.
40    #[must_use]
41    pub fn ident(&self) -> Keyword {
42        Keyword::new(self.group.as_deref(), &self.name)
43    }
44
45    /// Converts the declaration to the existing Datomic-style EDN schema form.
46    #[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/// TOML schema parsing or normalization failure.
86#[derive(Debug, Error)]
87pub enum TomlSchemaError {
88    /// The input is not valid TOML or does not match the schema document shape.
89    #[error("invalid TOML schema: {0}")]
90    Parse(#[from] toml::de::Error),
91    /// The document declares an unsupported format version.
92    #[error("unsupported schema-version {0}; expected 1")]
93    UnsupportedVersion(u32),
94    /// A group or attribute name is not a valid EDN keyword component.
95    #[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 of declaration containing the name.
101        kind: &'static str,
102        /// Invalid source name.
103        name: String,
104    },
105    /// A value type name is not part of Corium's schema model.
106    #[error("unknown value type {value:?} for {ident}")]
107    UnknownValueType {
108        /// Attribute being normalized.
109        ident: String,
110        /// Unknown source value.
111        value: String,
112    },
113    /// A cardinality name is neither `one` nor `many`.
114    #[error("unknown cardinality {value:?} for {ident}")]
115    UnknownCardinality {
116        /// Attribute being normalized.
117        ident: String,
118        /// Unknown source value.
119        value: String,
120    },
121    /// A uniqueness mode is neither `identity` nor `value`.
122    #[error("unknown unique mode {value:?} for {ident}")]
123    UnknownUnique {
124        /// Attribute being normalized.
125        ident: String,
126        /// Unknown source value.
127        value: String,
128    },
129    /// Both cardinality spellings were supplied for one declaration.
130    #[error("{ident} specifies both `cardinality` and `many`; use only one")]
131    ConflictingCardinality {
132        /// Attribute being normalized.
133        ident: String,
134    },
135    /// Grouped and/or flat syntax declared the same canonical attribute twice.
136    #[error("duplicate attribute {0}")]
137    DuplicateAttribute(String),
138    /// Two entity authoring blocks use the same group name.
139    #[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    // TOML tables are unordered. Sorting local names makes initial attribute-id
163    // allocation deterministic rather than dependent on parser insertion order.
164    #[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
261/// Parses hierarchical TOML into normalized attribute declarations.
262///
263/// Grouped syntax uses `[[entity]]` followed by `[entity.attributes]`. The
264/// entity name supplies the attribute group. Flat syntax uses `[[attribute]]`
265/// with an optional `group`. Entity groups are not persisted as types.
266///
267/// # Errors
268/// Returns [`TomlSchemaError`] for invalid TOML, unsupported values, invalid
269/// names, conflicting cardinality spellings, or duplicate canonical idents.
270pub 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
309/// Parses TOML and emits equivalent EDN attribute maps for existing APIs.
310///
311/// # Errors
312/// Returns the same failures as [`parse`].
313pub 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}