1use 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 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}