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