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 = crate::codec::encode_entity_source_meaning(
53            &normalized,
54            &relation_targets,
55            &reachable_types,
56        )?;
57
58        let mut hasher = Sha256::new();
59        hasher.update(ENTITY_SOURCE_DIGEST_PROFILE);
60        hasher.update(
61            u64::try_from(encoded.len())
62                .unwrap_or(u64::MAX)
63                .to_be_bytes(),
64        );
65        hasher.update(encoded);
66        Ok(EntitySourceDigest::from_bytes(hasher.finalize().into()))
67    }
68}
69
70fn normalized_entity(entity: &EntityFragment) -> Result<EntityFragment, SchemaContractError> {
71    EntityFragment::try_new(
72        entity.name().clone(),
73        DeclaredEntityVersion::try_new(1)?,
74        entity.fields().to_vec(),
75        entity.primary_key().to_vec(),
76        entity.indexes().to_vec(),
77        entity.relations().to_vec(),
78        entity.constraints().to_vec(),
79    )
80}
81
82type RelationTargetMeaning = (EntitySourceKey, Vec<(FieldSourceKey, FieldFragment)>);
83
84fn relation_target_meanings(
85    entity: &EntityFragment,
86    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
87    pending_types: &mut Vec<TypeSourceKey>,
88) -> Result<Vec<RelationTargetMeaning>, SchemaContractError> {
89    let mut targets = BTreeMap::<EntitySourceKey, BTreeSet<FieldSourceKey>>::new();
90    for relation in entity.relations() {
91        targets
92            .entry(relation.target_entity().clone())
93            .or_default()
94            .extend(relation.target_fields().iter().cloned());
95    }
96    targets
97        .into_iter()
98        .map(|(target_source, field_sources)| {
99            let target = entities
100                .get(&target_source)
101                .copied()
102                .ok_or(SchemaContractError::InvalidMigrationReference)?;
103            let fields = field_sources
104                .into_iter()
105                .map(|field_source| {
106                    let field = target
107                        .fields()
108                        .iter()
109                        .find(|field| field.source_key() == &field_source)
110                        .cloned()
111                        .ok_or(SchemaContractError::InvalidMigrationReference)?;
112                    collect_field_type_sources(field.field_type(), pending_types);
113                    Ok((field_source, field))
114                })
115                .collect::<Result<Vec<_>, SchemaContractError>>()?;
116            Ok((target_source, fields))
117        })
118        .collect()
119}
120
121fn reachable_type_meanings(
122    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
123    mut pending: Vec<TypeSourceKey>,
124) -> Result<Vec<NamedTypeFragment>, SchemaContractError> {
125    let mut reachable = BTreeSet::new();
126    while let Some(source) = pending.pop() {
127        if !reachable.insert(source.clone()) {
128            continue;
129        }
130        let definition = types
131            .get(&source)
132            .copied()
133            .ok_or(SchemaContractError::InvalidMigrationReference)?;
134        collect_named_type_sources(definition, &mut pending);
135    }
136    reachable
137        .into_iter()
138        .map(|source| {
139            types
140                .get(&source)
141                .copied()
142                .cloned()
143                .ok_or(SchemaContractError::InvalidMigrationReference)
144        })
145        .collect()
146}
147
148fn collect_named_type_sources(definition: &NamedTypeFragment, pending: &mut Vec<TypeSourceKey>) {
149    match definition {
150        NamedTypeFragment::Record(record) => {
151            for field in record.fields() {
152                collect_field_type_sources(field.field_type(), pending);
153            }
154        }
155        NamedTypeFragment::Enum(r#enum) => {
156            for variant in r#enum.variants() {
157                if let Some(payload) = variant.payload() {
158                    collect_field_type_sources(payload, pending);
159                }
160            }
161        }
162        NamedTypeFragment::Newtype { inner, .. }
163        | NamedTypeFragment::List { item: inner, .. }
164        | NamedTypeFragment::Set { item: inner, .. } => {
165            collect_field_type_sources(inner, pending);
166        }
167        NamedTypeFragment::Map { key, value, .. } => {
168            collect_field_type_sources(key, pending);
169            collect_field_type_sources(value, pending);
170        }
171        NamedTypeFragment::Tuple { members, .. } => {
172            for member in members {
173                collect_field_type_sources(member.field_type(), pending);
174            }
175        }
176    }
177}
178
179fn collect_field_type_sources(field_type: &FieldType, pending: &mut Vec<TypeSourceKey>) {
180    match field_type {
181        FieldType::List(inner) => collect_field_type_sources(inner, pending),
182        FieldType::Named(source) => pending.push(source.clone()),
183        FieldType::Scalar(_) => {}
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use crate::{
190        DeclaredEntityVersion, EntityFragment, EntitySourceKey, EntityStoreAssignment,
191        ExpectedAcceptedHead, FieldFragment, FieldInsertPolicy, FieldSourceKey, FieldType,
192        SchemaFragment, SchemaName, SchemaProposal, SchemaSubmissionKey, TargetDatabaseIdentity,
193        TargetStoreIdentity,
194    };
195
196    fn proposal(version: u32, field_name: &str) -> SchemaProposal {
197        let id = FieldFragment::new(
198            SchemaName::try_new(field_name).expect("field name should admit"),
199            FieldType::Scalar(crate::ScalarType::Nat64),
200            false,
201            FieldInsertPolicy::Required,
202            None,
203        );
204        let entity = EntityFragment::try_new(
205            SchemaName::try_new("User").expect("entity name should admit"),
206            DeclaredEntityVersion::try_new(version).expect("version should admit"),
207            vec![id],
208            vec![FieldSourceKey::try_new(field_name).expect("field key should admit")],
209            Vec::new(),
210            Vec::new(),
211            Vec::new(),
212        )
213        .expect("entity should admit");
214        SchemaProposal::try_compose(
215            Vec::new(),
216            TargetDatabaseIdentity::from_bytes([1; 32]),
217            SchemaSubmissionKey::try_new("source-digest").expect("submission should admit"),
218            ExpectedAcceptedHead::Empty,
219            vec![SchemaFragment::try_new(vec![entity], Vec::new()).expect("fragment should admit")],
220            vec![EntityStoreAssignment::new(
221                EntitySourceKey::try_new("User").expect("entity key should admit"),
222                TargetStoreIdentity::from_bytes([2; 32]),
223            )],
224            Vec::new(),
225            None,
226        )
227        .expect("proposal should admit")
228    }
229
230    #[test]
231    fn source_digest_ignores_declared_version_but_not_entity_meaning() {
232        let source = EntitySourceKey::try_new("User").expect("source should admit");
233        assert_eq!(
234            proposal(1, "id")
235                .entity_source_digest(&source)
236                .expect("digest should derive"),
237            proposal(7, "id")
238                .entity_source_digest(&source)
239                .expect("digest should derive"),
240        );
241        assert_ne!(
242            proposal(1, "id")
243                .entity_source_digest(&source)
244                .expect("digest should derive"),
245            proposal(1, "other")
246                .entity_source_digest(&source)
247                .expect("digest should derive"),
248        );
249    }
250}