icydb-schema 0.164.3

IcyDB — A schema-first typed query engine and persistence runtime for Internet Computer canisters
Documentation
use crate::prelude::*;
use std::ops::Not;

///
/// Item
///
/// Canonical schema item descriptor for one scalar, relation, or primitive
/// field target plus its attached sanitizers and validators.
///

#[derive(Clone, Debug, Serialize)]
pub struct Item {
    target: ItemTarget,

    #[serde(skip_serializing_if = "Option::is_none")]
    relation: Option<&'static str>,

    #[serde(skip_serializing_if = "Option::is_none")]
    scale: Option<u32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    max_len: Option<u32>,

    #[serde(skip_serializing_if = "Option::is_none")]
    max_bytes: Option<u32>,

    #[serde(skip_serializing_if = "<[_]>::is_empty")]
    validators: &'static [TypeValidator],

    #[serde(skip_serializing_if = "<[_]>::is_empty")]
    sanitizers: &'static [TypeSanitizer],

    #[serde(skip_serializing_if = "Not::not")]
    indirect: bool,
}

impl Item {
    #[must_use]
    #[expect(
        clippy::too_many_arguments,
        reason = "schema item construction keeps generated scalar, relation, and validation metadata explicit"
    )]
    pub const fn new(
        target: ItemTarget,
        relation: Option<&'static str>,
        scale: Option<u32>,
        max_len: Option<u32>,
        max_bytes: Option<u32>,
        validators: &'static [TypeValidator],
        sanitizers: &'static [TypeSanitizer],
        indirect: bool,
    ) -> Self {
        Self {
            target,
            relation,
            scale,
            max_len,
            max_bytes,
            validators,
            sanitizers,
            indirect,
        }
    }

    #[must_use]
    pub const fn target(&self) -> &ItemTarget {
        &self.target
    }

    #[must_use]
    pub const fn relation(&self) -> Option<&'static str> {
        self.relation
    }

    #[must_use]
    pub const fn scale(&self) -> Option<u32> {
        self.scale
    }

    #[must_use]
    pub const fn max_len(&self) -> Option<u32> {
        self.max_len
    }

    #[must_use]
    pub const fn max_bytes(&self) -> Option<u32> {
        self.max_bytes
    }

    #[must_use]
    pub const fn validators(&self) -> &'static [TypeValidator] {
        self.validators
    }

    #[must_use]
    pub const fn sanitizers(&self) -> &'static [TypeSanitizer] {
        self.sanitizers
    }

    #[must_use]
    pub const fn indirect(&self) -> bool {
        self.indirect
    }

    #[must_use]
    pub const fn is_relation(&self) -> bool {
        self.relation().is_some()
    }
}

impl ValidateNode for Item {
    fn validate(&self) -> Result<(), ErrorTree> {
        let mut errs = ErrorTree::new();
        let schema = schema_read();

        // Phase 1: validate target shape.
        match self.target() {
            ItemTarget::Is(path) => {
                // cannot be an entity
                if schema.check_node_as::<Entity>(path).is_ok() {
                    err!(errs, "a non-relation Item cannot reference an Entity");
                }
            }

            ItemTarget::Primitive(_) => {}
        }

        // Phase 2: validate relation target compatibility.
        if let Some(relation) = self.relation() {
            match schema.cast_node::<Entity>(relation) {
                Ok(entity) => {
                    if entity.primary_key().fields().len() != 1 {
                        err!(
                            errs,
                            "relation entity '{relation}' uses composite primary key fields {:?}; single-field relation targets require a scalar primary key; use ordered relation tuple metadata for composite targets",
                            entity.primary_key().fields()
                        );
                    } else if let Some(primary_field) = entity.scalar_primary_key_field() {
                        let relation_target = primary_field.value().item().target();

                        let relation_scale = primary_field.value().item().scale();
                        let relation_max_len = primary_field.value().item().max_len();
                        let relation_max_bytes = primary_field.value().item().max_bytes();
                        if self.target() != relation_target
                            || self.scale() != relation_scale
                            || self.max_len() != relation_max_len
                            || self.max_bytes() != relation_max_bytes
                        {
                            err!(
                                errs,
                                "relation target type mismatch: expected ({:?}, scale={:?}, max_len={:?}, max_bytes={:?}), found ({:?}, scale={:?}, max_len={:?}, max_bytes={:?})",
                                relation_target,
                                relation_scale,
                                relation_max_len,
                                relation_max_bytes,
                                self.target(),
                                self.scale(),
                                self.max_len(),
                                self.max_bytes()
                            );
                        }
                    } else {
                        let primary_key_field =
                            entity.primary_key().scalar_field().unwrap_or("<composite>");
                        err!(
                            errs,
                            "relation entity '{relation}' missing primary key field '{0}'",
                            primary_key_field
                        );
                    }
                }
                Err(_) => {
                    err!(errs, "relation entity '{relation}' not found");
                }
            }
        }

        errs.result()
    }
}

impl VisitableNode for Item {
    fn drive<V: Visitor>(&self, v: &mut V) {
        for node in self.validators() {
            node.accept(v);
        }
    }
}

///
/// ItemTarget
///
/// Local item target declaration, either by schema path or primitive runtime
/// kind.
///

#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub enum ItemTarget {
    Is(&'static str),
    Primitive(Primitive),
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::build::schema_write;

    fn primitive_item(primitive: Primitive) -> Item {
        Item::new(
            ItemTarget::Primitive(primitive),
            None,
            None,
            None,
            None,
            &[],
            &[],
            false,
        )
    }

    fn relation_item(target_path: &'static str, primitive: Primitive) -> Item {
        Item::new(
            ItemTarget::Primitive(primitive),
            Some(target_path),
            None,
            None,
            None,
            &[],
            &[],
            false,
        )
    }

    fn field(ident: &'static str, primitive: Primitive) -> Field {
        Field::new(
            ident,
            Value::new(Cardinality::One, primitive_item(primitive)),
            None,
            None,
            None,
        )
    }

    fn insert_entity(
        module: &'static str,
        ident: &'static str,
        pk_fields: &'static [&'static str],
        fields: &'static [Field],
    ) -> &'static str {
        let path = Box::leak(format!("{module}::{ident}").into_boxed_str());
        schema_write().insert_node(SchemaNode::Entity(Entity::new(
            Def::new(module, ident),
            "SchemaItemRelationStore",
            PrimaryKey::new(pk_fields, PrimaryKeySource::External),
            None,
            &[],
            FieldList::new(fields),
            Type::new(&[], &[]),
        )));
        path
    }

    #[test]
    fn relation_to_composite_target_rejects_even_when_first_component_matches() {
        let fields = Box::leak(
            vec![
                field("tenant_id", Primitive::Nat64),
                field("local_id", Primitive::Nat64),
            ]
            .into_boxed_slice(),
        );
        let target_path = insert_entity(
            "schema_item_relation_composite_target",
            "CompositeTarget",
            &["tenant_id", "local_id"],
            fields,
        );

        let err = relation_item(target_path, Primitive::Nat64)
            .validate()
            .expect_err("relation to composite target must fail before first-field matching");

        assert!(
            err.messages().iter().any(|message| {
                message.contains("uses composite primary key fields")
                    && message
                        .contains("single-field relation targets require a scalar primary key")
            }),
            "unexpected relation validation errors: {err}",
        );
    }

    #[test]
    fn scalar_128_bit_relation_targets_validate_at_schema_node_boundary() {
        for (module, ident, primitive) in [
            (
                "schema_item_relation_int128_target",
                "Int128Target",
                Primitive::Int128,
            ),
            (
                "schema_item_relation_nat128_target",
                "Nat128Target",
                Primitive::Nat128,
            ),
        ] {
            let fields = Box::leak(vec![field("id", primitive)].into_boxed_slice());
            let target_path = insert_entity(module, ident, &["id"], fields);

            relation_item(target_path, primitive)
                .validate()
                .expect("scalar 128-bit relation target should validate");
        }
    }
}