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, NamedTypeFragment, RelationFragment, SchemaContractError, SchemaMigrationPlan,
10    SchemaProposal, 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        self.entity_source_digest_with_targets(source, &BTreeMap::new())
31    }
32
33    /// Compute source meaning with relation targets mapped to the explicit
34    /// predecessor entity names in this proposal's migration plan.
35    ///
36    /// This comparison proof changes only target names, not target fields or
37    /// reachable type contracts. The planner compares it to accepted lineage to
38    /// admit a dependency-only transition; it is not runtime schema authority.
39    ///
40    /// # Errors
41    ///
42    /// Returns a typed reference or encoding error for invalid dependencies or
43    /// colliding predecessor target names.
44    pub fn entity_source_digest_before_entity_renames(
45        &self,
46        source: &EntitySourceKey,
47    ) -> Result<EntitySourceDigest, SchemaContractError> {
48        let targets = self
49            .migration()
50            .into_iter()
51            .flat_map(SchemaMigrationPlan::transitions)
52            .filter_map(|transition| {
53                transition
54                    .from_name()
55                    .map(|from| (transition.entity(), from))
56            })
57            .collect();
58        self.entity_source_digest_with_targets(source, &targets)
59    }
60
61    // Share the exact canonical encoding with ordinary lineage digests. Only
62    // explicit relation target correspondences may differ in the proof.
63    fn entity_source_digest_with_targets(
64        &self,
65        source: &EntitySourceKey,
66        target_names: &BTreeMap<&EntitySourceKey, &EntitySourceKey>,
67    ) -> Result<EntitySourceDigest, SchemaContractError> {
68        let mut entities = BTreeMap::new();
69        let mut types = BTreeMap::new();
70        for fragment in self.fragments() {
71            for entity in fragment.entities() {
72                entities.insert(entity.source_key().clone(), entity);
73            }
74            for definition in fragment.types() {
75                types.insert(definition.source_key().clone(), definition);
76            }
77        }
78        let entity = entities
79            .get(source)
80            .copied()
81            .ok_or(SchemaContractError::InvalidMigrationReference)?;
82        let normalized = normalized_entity(entity, target_names)?;
83
84        let mut pending_types = Vec::new();
85        for field in entity.fields() {
86            field
87                .field_type()
88                .append_named_type_dependencies(&mut pending_types);
89        }
90        let mut relation_targets = relation_target_meanings(entity, &entities, &mut pending_types)?;
91        if !target_names.is_empty() {
92            for (target, _) in &mut relation_targets {
93                if let Some(predecessor) = target_names.get(target) {
94                    target.clone_from(predecessor);
95                }
96            }
97            // Renames can reverse lexical order. Preserve canonical ordering
98            // without merging ambiguous predecessor targets.
99            crate::compact_sort_unstable_by(&mut relation_targets, |left, right| {
100                left.0.cmp(&right.0)
101            });
102            if relation_targets
103                .windows(2)
104                .any(|pair| pair[0].0 == pair[1].0)
105            {
106                return Err(SchemaContractError::InvalidMigrationReference);
107            }
108        }
109        let reachable_types = reachable_type_meanings(&types, pending_types)?;
110        let encoded = crate::codec::encode_entity_source_meaning(
111            &normalized,
112            &relation_targets,
113            &reachable_types,
114        )?;
115
116        let mut hasher = Sha256::new();
117        hasher.update(ENTITY_SOURCE_DIGEST_PROFILE);
118        hasher.update(
119            u64::try_from(encoded.len())
120                .unwrap_or(u64::MAX)
121                .to_be_bytes(),
122        );
123        hasher.update(encoded);
124        Ok(EntitySourceDigest::from_bytes(hasher.finalize().into()))
125    }
126}
127
128fn normalized_entity(
129    entity: &EntityFragment,
130    target_names: &BTreeMap<&EntitySourceKey, &EntitySourceKey>,
131) -> Result<EntityFragment, SchemaContractError> {
132    let relations = entity
133        .relations()
134        .iter()
135        .map(|relation| {
136            let Some(predecessor) = target_names.get(relation.target_entity()) else {
137                return Ok(relation.clone());
138            };
139            RelationFragment::try_new(
140                relation.name().clone(),
141                relation.source().clone(),
142                (*predecessor).clone(),
143                relation.target_fields().to_vec(),
144                relation.on_delete(),
145            )
146        })
147        .collect::<Result<Vec<_>, _>>()?;
148    EntityFragment::try_new(
149        entity.name().clone(),
150        DeclaredEntityVersion::try_new(1)?,
151        entity.fields().to_vec(),
152        entity.primary_key().to_vec(),
153        entity.indexes().to_vec(),
154        relations,
155        entity.constraints().to_vec(),
156    )
157}
158
159type RelationTargetMeaning = (EntitySourceKey, Vec<(FieldSourceKey, FieldFragment)>);
160
161fn relation_target_meanings(
162    entity: &EntityFragment,
163    entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
164    pending_types: &mut Vec<TypeSourceKey>,
165) -> Result<Vec<RelationTargetMeaning>, SchemaContractError> {
166    let mut targets = BTreeMap::<EntitySourceKey, BTreeSet<FieldSourceKey>>::new();
167    for relation in entity.relations() {
168        targets
169            .entry(relation.target_entity().clone())
170            .or_default()
171            .extend(relation.target_fields().iter().cloned());
172    }
173    targets
174        .into_iter()
175        .map(|(target_source, field_sources)| {
176            let target = entities
177                .get(&target_source)
178                .copied()
179                .ok_or(SchemaContractError::InvalidMigrationReference)?;
180            let fields = field_sources
181                .into_iter()
182                .map(|field_source| {
183                    let field = target
184                        .fields()
185                        .iter()
186                        .find(|field| field.source_key() == &field_source)
187                        .cloned()
188                        .ok_or(SchemaContractError::InvalidMigrationReference)?;
189                    field
190                        .field_type()
191                        .append_named_type_dependencies(pending_types);
192                    Ok((field_source, field))
193                })
194                .collect::<Result<Vec<_>, SchemaContractError>>()?;
195            Ok((target_source, fields))
196        })
197        .collect()
198}
199
200fn reachable_type_meanings(
201    types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
202    mut pending: Vec<TypeSourceKey>,
203) -> Result<Vec<NamedTypeFragment>, SchemaContractError> {
204    let mut reachable = BTreeSet::new();
205    while let Some(source) = pending.pop() {
206        if !reachable.insert(source.clone()) {
207            continue;
208        }
209        let definition = types
210            .get(&source)
211            .copied()
212            .ok_or(SchemaContractError::InvalidMigrationReference)?;
213        definition.append_named_type_dependencies(&mut pending);
214    }
215    reachable
216        .into_iter()
217        .map(|source| {
218            types
219                .get(&source)
220                .copied()
221                .cloned()
222                .ok_or(SchemaContractError::InvalidMigrationReference)
223        })
224        .collect()
225}
226
227#[cfg(test)]
228mod tests {
229    use crate::{
230        DeclaredEntityVersion, EntityFragment, EntityMigration, EntitySourceKey,
231        EntityStoreAssignment, ExpectedAcceptedHead, FieldFragment, FieldInsertPolicy,
232        FieldSourceKey, FieldType, NamedTypeFragment, RelationDeleteAction, RelationFragment,
233        RelationSourceFragment, ScalarType, SchemaCapability, SchemaFragment, SchemaMigrationPlan,
234        SchemaName, SchemaProposal, SchemaSubmissionKey, TargetDatabaseIdentity,
235        TargetStoreIdentity, TypeSourceKey,
236    };
237
238    fn proposal(version: u32, field_name: &str) -> SchemaProposal {
239        let id = FieldFragment::new(
240            SchemaName::try_new(field_name).expect("field name should admit"),
241            FieldType::Scalar(crate::ScalarType::Nat64),
242            false,
243            FieldInsertPolicy::Required,
244            None,
245        );
246        let entity = EntityFragment::try_new(
247            SchemaName::try_new("User").expect("entity name should admit"),
248            DeclaredEntityVersion::try_new(version).expect("version should admit"),
249            vec![id],
250            vec![FieldSourceKey::try_new(field_name).expect("field key should admit")],
251            Vec::new(),
252            Vec::new(),
253            Vec::new(),
254        )
255        .expect("entity should admit");
256        SchemaProposal::try_compose(
257            Vec::new(),
258            TargetDatabaseIdentity::from_bytes([1; 32]),
259            SchemaSubmissionKey::try_new("source-digest").expect("submission should admit"),
260            ExpectedAcceptedHead::Empty,
261            vec![SchemaFragment::try_new(vec![entity], Vec::new()).expect("fragment should admit")],
262            vec![EntityStoreAssignment::new(
263                EntitySourceKey::try_new("User").expect("entity key should admit"),
264                TargetStoreIdentity::from_bytes([2; 32]),
265            )],
266            Vec::new(),
267            None,
268        )
269        .expect("proposal should admit")
270    }
271
272    #[test]
273    fn source_digest_ignores_declared_version_but_not_entity_meaning() {
274        let source = EntitySourceKey::try_new("User").expect("source should admit");
275        assert_eq!(
276            proposal(1, "id")
277                .entity_source_digest(&source)
278                .expect("digest should derive"),
279            proposal(7, "id")
280                .entity_source_digest(&source)
281                .expect("digest should derive"),
282        );
283        assert_ne!(
284            proposal(1, "id")
285                .entity_source_digest(&source)
286                .expect("digest should derive"),
287            proposal(1, "other")
288                .entity_source_digest(&source)
289                .expect("digest should derive"),
290        );
291    }
292
293    // Zebra -> Alpha crosses Middle in canonical target order. Payload keeps a
294    // reachable named-type contract in the owner's source meaning.
295    #[expect(
296        clippy::too_many_lines,
297        reason = "one predecessor/successor fixture keeps the complete dependency contract visible"
298    )]
299    fn dependency_proposal(
300        renamed: bool,
301        payload: ScalarType,
302        target: FieldInsertPolicy,
303    ) -> SchemaProposal {
304        let name = |value| SchemaName::try_new(value).unwrap();
305        let key = |value| EntitySourceKey::try_new(value).unwrap();
306        let field = |value| FieldSourceKey::try_new(value).unwrap();
307        let id = |policy| {
308            FieldFragment::new(
309                name("id"),
310                FieldType::Scalar(ScalarType::Nat64),
311                false,
312                policy,
313                None,
314            )
315        };
316        let target_name = if renamed { "Alpha" } else { "Zebra" };
317        let mut entities = Vec::new();
318        for entity in [target_name, "Middle", "Holder"] {
319            let mut fields = vec![id(if entity == target_name {
320                target.clone()
321            } else {
322                FieldInsertPolicy::Required
323            })];
324            let relations = if entity == "Holder" {
325                fields.push(FieldFragment::new(
326                    name("payload"),
327                    FieldType::Named(TypeSourceKey::try_new("Payload").unwrap()),
328                    false,
329                    FieldInsertPolicy::Required,
330                    None,
331                ));
332                [target_name, "Middle"]
333                    .into_iter()
334                    .enumerate()
335                    .map(|(ordinal, target)| {
336                        RelationFragment::try_new(
337                            name(if ordinal == 0 { "first" } else { "second" }),
338                            RelationSourceFragment::direct(vec![field("id")]),
339                            key(target),
340                            vec![field("id")],
341                            RelationDeleteAction::Restrict,
342                        )
343                        .unwrap()
344                    })
345                    .collect()
346            } else {
347                Vec::new()
348            };
349            entities.push(
350                EntityFragment::try_new(
351                    name(entity),
352                    DeclaredEntityVersion::try_new(if renamed && entity != "Middle" {
353                        2
354                    } else {
355                        1
356                    })
357                    .unwrap(),
358                    fields,
359                    vec![field("id")],
360                    Vec::new(),
361                    relations,
362                    Vec::new(),
363                )
364                .unwrap(),
365            );
366        }
367        let migration = renamed.then(|| {
368            SchemaMigrationPlan::try_new(vec![
369                EntityMigration::try_new(
370                    key("Alpha"),
371                    DeclaredEntityVersion::try_new(1).unwrap(),
372                    Some(key("Zebra")),
373                    Vec::new(),
374                    Vec::new(),
375                )
376                .unwrap(),
377                EntityMigration::try_new(
378                    key("Holder"),
379                    DeclaredEntityVersion::try_new(1).unwrap(),
380                    None,
381                    Vec::new(),
382                    Vec::new(),
383                )
384                .unwrap(),
385            ])
386            .unwrap()
387        });
388        let assignments = entities
389            .iter()
390            .map(|entity| {
391                EntityStoreAssignment::new(
392                    entity.source_key().clone(),
393                    TargetStoreIdentity::from_bytes([2; 32]),
394                )
395            })
396            .collect();
397        let mut capabilities = vec![SchemaCapability::RESTRICTIVE_RELATIONS];
398        if renamed {
399            capabilities.push(SchemaCapability::VERSIONED_MIGRATIONS);
400        }
401        SchemaProposal::try_compose(
402            capabilities,
403            TargetDatabaseIdentity::from_bytes([1; 32]),
404            SchemaSubmissionKey::try_new("dependency").unwrap(),
405            ExpectedAcceptedHead::Empty,
406            vec![
407                SchemaFragment::try_new(
408                    entities,
409                    vec![NamedTypeFragment::newtype(
410                        name("Payload"),
411                        FieldType::Scalar(payload),
412                    )],
413                )
414                .unwrap(),
415            ],
416            assignments,
417            Vec::new(),
418            migration,
419        )
420        .unwrap()
421    }
422
423    #[test]
424    fn entity_rename_dependency_proof_preserves_canonical_order_and_complete_meaning() {
425        let holder = EntitySourceKey::try_new("Holder").unwrap();
426        let before = dependency_proposal(false, ScalarType::Nat64, FieldInsertPolicy::Required)
427            .entity_source_digest(&holder)
428            .unwrap();
429        let renamed = dependency_proposal(true, ScalarType::Nat64, FieldInsertPolicy::Required);
430        assert_ne!(before, renamed.entity_source_digest(&holder).unwrap());
431        assert_eq!(
432            before,
433            renamed
434                .entity_source_digest_before_entity_renames(&holder)
435                .unwrap()
436        );
437        for (payload, target) in [
438            (ScalarType::Nat32, FieldInsertPolicy::Required),
439            (
440                ScalarType::Nat64,
441                FieldInsertPolicy::Default(crate::ScalarLiteral::Nat(1)),
442            ),
443        ] {
444            assert_ne!(
445                before,
446                dependency_proposal(true, payload, target)
447                    .entity_source_digest_before_entity_renames(&holder)
448                    .unwrap()
449            );
450        }
451    }
452}