1use std::collections::{BTreeMap, BTreeSet};
4
5use sha2::{Digest, Sha256};
6
7use crate::{
8 DeclaredEntityVersion, EntityFragment, EntitySourceDigest, EntitySourceKey, FieldFragment,
9 FieldSourceKey, FieldType, NamedTypeFragment, RelationFragment, SchemaContractError,
10 SchemaMigrationPlan, 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 collect_field_type_sources(field.field_type(), &mut pending_types);
87 }
88 let mut relation_targets = relation_target_meanings(entity, &entities, &mut pending_types)?;
89 if !target_names.is_empty() {
90 for (target, _) in &mut relation_targets {
91 if let Some(predecessor) = target_names.get(target) {
92 target.clone_from(predecessor);
93 }
94 }
95 crate::compact_sort_unstable_by(&mut relation_targets, |left, right| {
98 left.0.cmp(&right.0)
99 });
100 if relation_targets
101 .windows(2)
102 .any(|pair| pair[0].0 == pair[1].0)
103 {
104 return Err(SchemaContractError::InvalidMigrationReference);
105 }
106 }
107 let reachable_types = reachable_type_meanings(&types, pending_types)?;
108 let encoded = crate::codec::encode_entity_source_meaning(
109 &normalized,
110 &relation_targets,
111 &reachable_types,
112 )?;
113
114 let mut hasher = Sha256::new();
115 hasher.update(ENTITY_SOURCE_DIGEST_PROFILE);
116 hasher.update(
117 u64::try_from(encoded.len())
118 .unwrap_or(u64::MAX)
119 .to_be_bytes(),
120 );
121 hasher.update(encoded);
122 Ok(EntitySourceDigest::from_bytes(hasher.finalize().into()))
123 }
124}
125
126fn normalized_entity(
127 entity: &EntityFragment,
128 target_names: &BTreeMap<&EntitySourceKey, &EntitySourceKey>,
129) -> Result<EntityFragment, SchemaContractError> {
130 let relations = entity
131 .relations()
132 .iter()
133 .map(|relation| {
134 let Some(predecessor) = target_names.get(relation.target_entity()) else {
135 return Ok(relation.clone());
136 };
137 RelationFragment::try_new(
138 relation.name().clone(),
139 relation.source().clone(),
140 (*predecessor).clone(),
141 relation.target_fields().to_vec(),
142 relation.on_delete(),
143 )
144 })
145 .collect::<Result<Vec<_>, _>>()?;
146 EntityFragment::try_new(
147 entity.name().clone(),
148 DeclaredEntityVersion::try_new(1)?,
149 entity.fields().to_vec(),
150 entity.primary_key().to_vec(),
151 entity.indexes().to_vec(),
152 relations,
153 entity.constraints().to_vec(),
154 )
155}
156
157type RelationTargetMeaning = (EntitySourceKey, Vec<(FieldSourceKey, FieldFragment)>);
158
159fn relation_target_meanings(
160 entity: &EntityFragment,
161 entities: &BTreeMap<EntitySourceKey, &EntityFragment>,
162 pending_types: &mut Vec<TypeSourceKey>,
163) -> Result<Vec<RelationTargetMeaning>, SchemaContractError> {
164 let mut targets = BTreeMap::<EntitySourceKey, BTreeSet<FieldSourceKey>>::new();
165 for relation in entity.relations() {
166 targets
167 .entry(relation.target_entity().clone())
168 .or_default()
169 .extend(relation.target_fields().iter().cloned());
170 }
171 targets
172 .into_iter()
173 .map(|(target_source, field_sources)| {
174 let target = entities
175 .get(&target_source)
176 .copied()
177 .ok_or(SchemaContractError::InvalidMigrationReference)?;
178 let fields = field_sources
179 .into_iter()
180 .map(|field_source| {
181 let field = target
182 .fields()
183 .iter()
184 .find(|field| field.source_key() == &field_source)
185 .cloned()
186 .ok_or(SchemaContractError::InvalidMigrationReference)?;
187 collect_field_type_sources(field.field_type(), pending_types);
188 Ok((field_source, field))
189 })
190 .collect::<Result<Vec<_>, SchemaContractError>>()?;
191 Ok((target_source, fields))
192 })
193 .collect()
194}
195
196fn reachable_type_meanings(
197 types: &BTreeMap<TypeSourceKey, &NamedTypeFragment>,
198 mut pending: Vec<TypeSourceKey>,
199) -> Result<Vec<NamedTypeFragment>, SchemaContractError> {
200 let mut reachable = BTreeSet::new();
201 while let Some(source) = pending.pop() {
202 if !reachable.insert(source.clone()) {
203 continue;
204 }
205 let definition = types
206 .get(&source)
207 .copied()
208 .ok_or(SchemaContractError::InvalidMigrationReference)?;
209 collect_named_type_sources(definition, &mut pending);
210 }
211 reachable
212 .into_iter()
213 .map(|source| {
214 types
215 .get(&source)
216 .copied()
217 .cloned()
218 .ok_or(SchemaContractError::InvalidMigrationReference)
219 })
220 .collect()
221}
222
223fn collect_named_type_sources(definition: &NamedTypeFragment, pending: &mut Vec<TypeSourceKey>) {
224 match definition {
225 NamedTypeFragment::Record(record) => {
226 for field in record.fields() {
227 collect_field_type_sources(field.field_type(), pending);
228 }
229 }
230 NamedTypeFragment::Enum(r#enum) => {
231 for variant in r#enum.variants() {
232 if let Some(payload) = variant.payload() {
233 collect_field_type_sources(payload, pending);
234 }
235 }
236 }
237 NamedTypeFragment::Newtype { inner, .. }
238 | NamedTypeFragment::List { item: inner, .. }
239 | NamedTypeFragment::Set { item: inner, .. } => {
240 collect_field_type_sources(inner, pending);
241 }
242 NamedTypeFragment::Map { key, value, .. } => {
243 collect_field_type_sources(key, pending);
244 collect_field_type_sources(value, pending);
245 }
246 NamedTypeFragment::Tuple { members, .. } => {
247 for member in members {
248 collect_field_type_sources(member.field_type(), pending);
249 }
250 }
251 }
252}
253
254fn collect_field_type_sources(field_type: &FieldType, pending: &mut Vec<TypeSourceKey>) {
255 match field_type {
256 FieldType::List(inner) => collect_field_type_sources(inner, pending),
257 FieldType::Named(source) => pending.push(source.clone()),
258 FieldType::Scalar(_) => {}
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use crate::{
265 DeclaredEntityVersion, EntityFragment, EntityMigration, EntitySourceKey,
266 EntityStoreAssignment, ExpectedAcceptedHead, FieldFragment, FieldInsertPolicy,
267 FieldSourceKey, FieldType, NamedTypeFragment, RelationDeleteAction, RelationFragment,
268 RelationSourceFragment, ScalarType, SchemaCapability, SchemaFragment, SchemaMigrationPlan,
269 SchemaName, SchemaProposal, SchemaSubmissionKey, TargetDatabaseIdentity,
270 TargetStoreIdentity, TypeSourceKey,
271 };
272
273 fn proposal(version: u32, field_name: &str) -> SchemaProposal {
274 let id = FieldFragment::new(
275 SchemaName::try_new(field_name).expect("field name should admit"),
276 FieldType::Scalar(crate::ScalarType::Nat64),
277 false,
278 FieldInsertPolicy::Required,
279 None,
280 );
281 let entity = EntityFragment::try_new(
282 SchemaName::try_new("User").expect("entity name should admit"),
283 DeclaredEntityVersion::try_new(version).expect("version should admit"),
284 vec![id],
285 vec![FieldSourceKey::try_new(field_name).expect("field key should admit")],
286 Vec::new(),
287 Vec::new(),
288 Vec::new(),
289 )
290 .expect("entity should admit");
291 SchemaProposal::try_compose(
292 Vec::new(),
293 TargetDatabaseIdentity::from_bytes([1; 32]),
294 SchemaSubmissionKey::try_new("source-digest").expect("submission should admit"),
295 ExpectedAcceptedHead::Empty,
296 vec![SchemaFragment::try_new(vec![entity], Vec::new()).expect("fragment should admit")],
297 vec![EntityStoreAssignment::new(
298 EntitySourceKey::try_new("User").expect("entity key should admit"),
299 TargetStoreIdentity::from_bytes([2; 32]),
300 )],
301 Vec::new(),
302 None,
303 )
304 .expect("proposal should admit")
305 }
306
307 #[test]
308 fn source_digest_ignores_declared_version_but_not_entity_meaning() {
309 let source = EntitySourceKey::try_new("User").expect("source should admit");
310 assert_eq!(
311 proposal(1, "id")
312 .entity_source_digest(&source)
313 .expect("digest should derive"),
314 proposal(7, "id")
315 .entity_source_digest(&source)
316 .expect("digest should derive"),
317 );
318 assert_ne!(
319 proposal(1, "id")
320 .entity_source_digest(&source)
321 .expect("digest should derive"),
322 proposal(1, "other")
323 .entity_source_digest(&source)
324 .expect("digest should derive"),
325 );
326 }
327
328 #[expect(
331 clippy::too_many_lines,
332 reason = "one predecessor/successor fixture keeps the complete dependency contract visible"
333 )]
334 fn dependency_proposal(
335 renamed: bool,
336 payload: ScalarType,
337 target: FieldInsertPolicy,
338 ) -> SchemaProposal {
339 let name = |value| SchemaName::try_new(value).unwrap();
340 let key = |value| EntitySourceKey::try_new(value).unwrap();
341 let field = |value| FieldSourceKey::try_new(value).unwrap();
342 let id = |policy| {
343 FieldFragment::new(
344 name("id"),
345 FieldType::Scalar(ScalarType::Nat64),
346 false,
347 policy,
348 None,
349 )
350 };
351 let target_name = if renamed { "Alpha" } else { "Zebra" };
352 let mut entities = Vec::new();
353 for entity in [target_name, "Middle", "Holder"] {
354 let mut fields = vec![id(if entity == target_name {
355 target.clone()
356 } else {
357 FieldInsertPolicy::Required
358 })];
359 let relations = if entity == "Holder" {
360 fields.push(FieldFragment::new(
361 name("payload"),
362 FieldType::Named(TypeSourceKey::try_new("Payload").unwrap()),
363 false,
364 FieldInsertPolicy::Required,
365 None,
366 ));
367 [target_name, "Middle"]
368 .into_iter()
369 .enumerate()
370 .map(|(ordinal, target)| {
371 RelationFragment::try_new(
372 name(if ordinal == 0 { "first" } else { "second" }),
373 RelationSourceFragment::direct(vec![field("id")]),
374 key(target),
375 vec![field("id")],
376 RelationDeleteAction::Restrict,
377 )
378 .unwrap()
379 })
380 .collect()
381 } else {
382 Vec::new()
383 };
384 entities.push(
385 EntityFragment::try_new(
386 name(entity),
387 DeclaredEntityVersion::try_new(if renamed && entity != "Middle" {
388 2
389 } else {
390 1
391 })
392 .unwrap(),
393 fields,
394 vec![field("id")],
395 Vec::new(),
396 relations,
397 Vec::new(),
398 )
399 .unwrap(),
400 );
401 }
402 let migration = renamed.then(|| {
403 SchemaMigrationPlan::try_new(vec![
404 EntityMigration::try_new(
405 key("Alpha"),
406 DeclaredEntityVersion::try_new(1).unwrap(),
407 Some(key("Zebra")),
408 Vec::new(),
409 Vec::new(),
410 )
411 .unwrap(),
412 EntityMigration::try_new(
413 key("Holder"),
414 DeclaredEntityVersion::try_new(1).unwrap(),
415 None,
416 Vec::new(),
417 Vec::new(),
418 )
419 .unwrap(),
420 ])
421 .unwrap()
422 });
423 let assignments = entities
424 .iter()
425 .map(|entity| {
426 EntityStoreAssignment::new(
427 entity.source_key().clone(),
428 TargetStoreIdentity::from_bytes([2; 32]),
429 )
430 })
431 .collect();
432 let mut capabilities = vec![SchemaCapability::RESTRICTIVE_RELATIONS];
433 if renamed {
434 capabilities.push(SchemaCapability::VERSIONED_MIGRATIONS);
435 }
436 SchemaProposal::try_compose(
437 capabilities,
438 TargetDatabaseIdentity::from_bytes([1; 32]),
439 SchemaSubmissionKey::try_new("dependency").unwrap(),
440 ExpectedAcceptedHead::Empty,
441 vec![
442 SchemaFragment::try_new(
443 entities,
444 vec![NamedTypeFragment::newtype(
445 name("Payload"),
446 FieldType::Scalar(payload),
447 )],
448 )
449 .unwrap(),
450 ],
451 assignments,
452 Vec::new(),
453 migration,
454 )
455 .unwrap()
456 }
457
458 #[test]
459 fn entity_rename_dependency_proof_preserves_canonical_order_and_complete_meaning() {
460 let holder = EntitySourceKey::try_new("Holder").unwrap();
461 let before = dependency_proposal(false, ScalarType::Nat64, FieldInsertPolicy::Required)
462 .entity_source_digest(&holder)
463 .unwrap();
464 let renamed = dependency_proposal(true, ScalarType::Nat64, FieldInsertPolicy::Required);
465 assert_ne!(before, renamed.entity_source_digest(&holder).unwrap());
466 assert_eq!(
467 before,
468 renamed
469 .entity_source_digest_before_entity_renames(&holder)
470 .unwrap()
471 );
472 for (payload, target) in [
473 (ScalarType::Nat32, FieldInsertPolicy::Required),
474 (
475 ScalarType::Nat64,
476 FieldInsertPolicy::Default(crate::ScalarLiteral::Nat(1)),
477 ),
478 ] {
479 assert_ne!(
480 before,
481 dependency_proposal(true, payload, target)
482 .entity_source_digest_before_entity_renames(&holder)
483 .unwrap()
484 );
485 }
486 }
487}