Skip to main content

icydb_schema/
source_digest.rs

1//! Canonical per-entity generated-source meaning.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use sha2::{Digest, Sha256};
6
7use crate::{
8    DeclaredEntityVersion, EntityFragment, EntitySourceDigest, EntitySourceKey, FieldFragment,
9    FieldSourceKey, FieldType, NamedTypeFragment, SchemaContractError, SchemaProposal,
10    TypeSourceKey,
11};
12
13const ENTITY_SOURCE_DIGEST_PROFILE: &[u8] = b"icydb.entity-source-meaning.v1";
14
15impl SchemaProposal {
16    /// Compute the canonical generated-owned meaning for one current entity.
17    ///
18    /// The digest excludes the declared entity version and migration plan. It
19    /// includes the complete entity contract, its reachable named-type
20    /// closure, and the exact target-field contracts referenced by relations.
21    ///
22    /// # Errors
23    ///
24    /// Returns a typed reference or encoding error when the requested entity
25    /// or one of its current relation/type dependencies is absent.
26    pub fn entity_source_digest(
27        &self,
28        source: &EntitySourceKey,
29    ) -> Result<EntitySourceDigest, SchemaContractError> {
30        let mut entities = BTreeMap::new();
31        let mut types = BTreeMap::new();
32        for fragment in self.fragments() {
33            for entity in fragment.entities() {
34                entities.insert(entity.source_key().clone(), entity);
35            }
36            for definition in fragment.types() {
37                types.insert(definition.source_key().clone(), definition);
38            }
39        }
40        let entity = entities
41            .get(source)
42            .copied()
43            .ok_or(SchemaContractError::InvalidMigrationReference)?;
44        let normalized = normalized_entity(entity)?;
45
46        let mut pending_types = Vec::new();
47        for field in entity.fields() {
48            collect_field_type_sources(field.field_type(), &mut pending_types);
49        }
50        let relation_targets = relation_target_meanings(entity, &entities, &mut pending_types)?;
51        let reachable_types = reachable_type_meanings(&types, pending_types)?;
52        let encoded = candid::encode_args((&normalized, &relation_targets, &reachable_types))
53            .map_err(|_| SchemaContractError::Encode)?;
54
55        let mut hasher = Sha256::new();
56        hasher.update(ENTITY_SOURCE_DIGEST_PROFILE);
57        hasher.update(
58            u64::try_from(encoded.len())
59                .unwrap_or(u64::MAX)
60                .to_be_bytes(),
61        );
62        hasher.update(encoded);
63        Ok(EntitySourceDigest::from_bytes(hasher.finalize().into()))
64    }
65}
66
67fn normalized_entity(entity: &EntityFragment) -> Result<EntityFragment, SchemaContractError> {
68    EntityFragment::try_new(
69        entity.name().clone(),
70        DeclaredEntityVersion::try_new(1)?,
71        entity.fields().to_vec(),
72        entity.primary_key().to_vec(),
73        entity.indexes().to_vec(),
74        entity.relations().to_vec(),
75        entity.constraints().to_vec(),
76    )
77}
78
79type RelationTargetMeaning = (EntitySourceKey, Vec<(FieldSourceKey, FieldFragment)>);
80
81fn relation_target_meanings(
82    entity: &EntityFragment,
83    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
84    pending_types: &mut Vec<TypeSourceKey>,
85) -> Result<Vec<RelationTargetMeaning>, SchemaContractError> {
86    let mut targets = BTreeMap::<EntitySourceKey, BTreeSet<FieldSourceKey>>::new();
87    for relation in entity.relations() {
88        targets
89            .entry(relation.target_entity().clone())
90            .or_default()
91            .extend(relation.target_fields().iter().cloned());
92    }
93    targets
94        .into_iter()
95        .map(|(target_source, field_sources)| {
96            let target = entities
97                .get(&target_source)
98                .copied()
99                .ok_or(SchemaContractError::InvalidMigrationReference)?;
100            let fields = field_sources
101                .into_iter()
102                .map(|field_source| {
103                    let field = target
104                        .fields()
105                        .iter()
106                        .find(|field| field.source_key() == &field_source)
107                        .cloned()
108                        .ok_or(SchemaContractError::InvalidMigrationReference)?;
109                    collect_field_type_sources(field.field_type(), pending_types);
110                    Ok((field_source, field))
111                })
112                .collect::<Result<Vec<_>, SchemaContractError>>()?;
113            Ok((target_source, fields))
114        })
115        .collect()
116}
117
118fn reachable_type_meanings(
119    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
120    mut pending: Vec<TypeSourceKey>,
121) -> Result<Vec<NamedTypeFragment>, SchemaContractError> {
122    let mut reachable = BTreeSet::new();
123    while let Some(source) = pending.pop() {
124        if !reachable.insert(source.clone()) {
125            continue;
126        }
127        let definition = types
128            .get(&source)
129            .copied()
130            .ok_or(SchemaContractError::InvalidMigrationReference)?;
131        collect_named_type_sources(definition, &mut pending);
132    }
133    reachable
134        .into_iter()
135        .map(|source| {
136            types
137                .get(&source)
138                .copied()
139                .cloned()
140                .ok_or(SchemaContractError::InvalidMigrationReference)
141        })
142        .collect()
143}
144
145fn collect_named_type_sources(definition: &NamedTypeFragment, pending: &mut Vec<TypeSourceKey>) {
146    match definition {
147        NamedTypeFragment::Record(record) => {
148            for field in record.fields() {
149                collect_field_type_sources(field.field_type(), pending);
150            }
151        }
152        NamedTypeFragment::Enum(r#enum) => {
153            for variant in r#enum.variants() {
154                if let Some(payload) = variant.payload() {
155                    collect_field_type_sources(payload, pending);
156                }
157            }
158        }
159        NamedTypeFragment::Newtype { inner, .. }
160        | NamedTypeFragment::List { item: inner, .. }
161        | NamedTypeFragment::Set { item: inner, .. } => {
162            collect_field_type_sources(inner, pending);
163        }
164        NamedTypeFragment::Map { key, value, .. } => {
165            collect_field_type_sources(key, pending);
166            collect_field_type_sources(value, pending);
167        }
168        NamedTypeFragment::Tuple { members, .. } => {
169            for member in members {
170                collect_field_type_sources(member.field_type(), pending);
171            }
172        }
173    }
174}
175
176fn collect_field_type_sources(field_type: &FieldType, pending: &mut Vec<TypeSourceKey>) {
177    match field_type {
178        FieldType::List(inner) => collect_field_type_sources(inner, pending),
179        FieldType::Named(source) => pending.push(source.clone()),
180        FieldType::Scalar(_) => {}
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use crate::{
187        DeclaredEntityVersion, EntityFragment, EntitySourceKey, EntityStoreAssignment,
188        ExpectedAcceptedHead, FieldFragment, FieldInsertPolicy, FieldSourceKey, FieldType,
189        SchemaFragment, SchemaName, SchemaProposal, SchemaSubmissionKey, TargetDatabaseIdentity,
190        TargetStoreIdentity,
191    };
192
193    fn proposal(version: u32, field_name: &str) -> SchemaProposal {
194        let id = FieldFragment::new(
195            SchemaName::try_new(field_name).expect("field name should admit"),
196            FieldType::Scalar(crate::ScalarType::Nat64),
197            false,
198            FieldInsertPolicy::Required,
199            None,
200        );
201        let entity = EntityFragment::try_new(
202            SchemaName::try_new("User").expect("entity name should admit"),
203            DeclaredEntityVersion::try_new(version).expect("version should admit"),
204            vec![id],
205            vec![FieldSourceKey::try_new(field_name).expect("field key should admit")],
206            Vec::new(),
207            Vec::new(),
208            Vec::new(),
209        )
210        .expect("entity should admit");
211        SchemaProposal::try_compose(
212            Vec::new(),
213            TargetDatabaseIdentity::from_bytes([1; 32]),
214            SchemaSubmissionKey::try_new("source-digest").expect("submission should admit"),
215            ExpectedAcceptedHead::Empty,
216            vec![SchemaFragment::try_new(vec![entity], Vec::new()).expect("fragment should admit")],
217            vec![EntityStoreAssignment::new(
218                EntitySourceKey::try_new("User").expect("entity key should admit"),
219                TargetStoreIdentity::from_bytes([2; 32]),
220            )],
221            Vec::new(),
222            None,
223        )
224        .expect("proposal should admit")
225    }
226
227    #[test]
228    fn source_digest_ignores_declared_version_but_not_entity_meaning() {
229        let source = EntitySourceKey::try_new("User").expect("source should admit");
230        assert_eq!(
231            proposal(1, "id")
232                .entity_source_digest(&source)
233                .expect("digest should derive"),
234            proposal(7, "id")
235                .entity_source_digest(&source)
236                .expect("digest should derive"),
237        );
238        assert_ne!(
239            proposal(1, "id")
240                .entity_source_digest(&source)
241                .expect("digest should derive"),
242            proposal(1, "other")
243                .entity_source_digest(&source)
244                .expect("digest should derive"),
245        );
246    }
247}