1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::Result;
6use crate::schema::ast::{Annotation, Constraint};
7use crate::types::PropType;
8
9use super::schema_ir::{EdgeIR, InterfaceIR, NodeIR, PropertyIR, SchemaIR};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum SchemaTypeKind {
14 Interface,
15 Node,
16 Edge,
17}
18
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct SchemaMigrationPlan {
21 pub supported: bool,
22 pub steps: Vec<SchemaMigrationStep>,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "snake_case")]
27pub enum SchemaMigrationStep {
28 AddType {
29 type_kind: SchemaTypeKind,
30 name: String,
31 },
32 RenameType {
33 type_kind: SchemaTypeKind,
34 from: String,
35 to: String,
36 },
37 AddProperty {
38 type_kind: SchemaTypeKind,
39 type_name: String,
40 property_name: String,
41 property_type: PropType,
42 },
43 RenameProperty {
44 type_kind: SchemaTypeKind,
45 type_name: String,
46 from: String,
47 to: String,
48 },
49 AddConstraint {
50 type_kind: SchemaTypeKind,
51 type_name: String,
52 constraint: Constraint,
53 },
54 UpdateTypeMetadata {
55 type_kind: SchemaTypeKind,
56 name: String,
57 annotations: Vec<Annotation>,
58 },
59 UpdatePropertyMetadata {
60 type_kind: SchemaTypeKind,
61 type_name: String,
62 property_name: String,
63 annotations: Vec<Annotation>,
64 },
65 UnsupportedChange {
66 entity: String,
67 reason: String,
68 },
69}
70
71pub fn plan_schema_migration(
72 accepted: &SchemaIR,
73 desired: &SchemaIR,
74) -> Result<SchemaMigrationPlan> {
75 let mut steps = Vec::new();
76 let interface_renames = plan_interfaces(&accepted.interfaces, &desired.interfaces, &mut steps);
77 let node_renames = plan_nodes(
78 &accepted.nodes,
79 &desired.nodes,
80 &interface_renames,
81 &mut steps,
82 );
83 plan_edges(&accepted.edges, &desired.edges, &node_renames, &mut steps);
84
85 Ok(SchemaMigrationPlan {
86 supported: !steps
87 .iter()
88 .any(|step| matches!(step, SchemaMigrationStep::UnsupportedChange { .. })),
89 steps,
90 })
91}
92
93fn plan_interfaces(
94 accepted: &[InterfaceIR],
95 desired: &[InterfaceIR],
96 steps: &mut Vec<SchemaMigrationStep>,
97) -> HashMap<String, String> {
98 let accepted_by_name = accepted
99 .iter()
100 .map(|interface| (interface.name.as_str(), interface))
101 .collect::<HashMap<_, _>>();
102 let mut consumed = HashSet::new();
103
104 for interface in desired {
105 if let Some(existing) = accepted_by_name.get(interface.name.as_str()) {
106 consumed.insert(existing.name.clone());
107 let _property_renames = plan_properties(
108 SchemaTypeKind::Interface,
109 &interface.name,
110 &existing.properties,
111 &interface.properties,
112 steps,
113 );
114 continue;
115 }
116
117 steps.push(SchemaMigrationStep::AddType {
118 type_kind: SchemaTypeKind::Interface,
119 name: interface.name.clone(),
120 });
121 }
122
123 for leftover in accepted
124 .iter()
125 .filter(|interface| !consumed.contains(&interface.name))
126 {
127 steps.push(SchemaMigrationStep::UnsupportedChange {
128 entity: format!("interface:{}", leftover.name),
129 reason: format!(
130 "removing interface '{}' is not supported in schema migration v1",
131 leftover.name
132 ),
133 });
134 }
135
136 HashMap::new()
137}
138
139fn plan_nodes(
140 accepted: &[NodeIR],
141 desired: &[NodeIR],
142 interface_renames: &HashMap<String, String>,
143 steps: &mut Vec<SchemaMigrationStep>,
144) -> HashMap<String, String> {
145 let accepted_by_name = accepted
146 .iter()
147 .map(|node| (node.name.as_str(), node))
148 .collect::<HashMap<_, _>>();
149 let mut consumed = HashSet::new();
150 let mut renames = HashMap::new();
151
152 for node in desired {
153 let rename_from = rename_from_value(&node.annotations);
154 let matched = accepted_by_name
155 .get(node.name.as_str())
156 .copied()
157 .or_else(|| {
158 rename_from.and_then(|from| {
159 accepted_by_name
160 .get(from)
161 .copied()
162 .filter(|candidate| candidate.name != node.name)
163 })
164 });
165
166 let Some(existing) = matched else {
167 if let Some(from) = rename_from {
168 steps.push(SchemaMigrationStep::UnsupportedChange {
169 entity: format!("node:{}", node.name),
170 reason: format!(
171 "node '{}' declares @rename_from(\"{}\") but no accepted node with that name exists",
172 node.name, from
173 ),
174 });
175 } else {
176 steps.push(SchemaMigrationStep::AddType {
177 type_kind: SchemaTypeKind::Node,
178 name: node.name.clone(),
179 });
180 }
181 continue;
182 };
183
184 consumed.insert(existing.name.clone());
185 if existing.name != node.name {
186 renames.insert(existing.name.clone(), node.name.clone());
187 steps.push(SchemaMigrationStep::RenameType {
188 type_kind: SchemaTypeKind::Node,
189 from: existing.name.clone(),
190 to: node.name.clone(),
191 });
192 }
193
194 if normalize_strings(&existing.implements, interface_renames)
195 != normalize_strings(&node.implements, &HashMap::new())
196 {
197 steps.push(SchemaMigrationStep::UnsupportedChange {
198 entity: format!("node:{}", node.name),
199 reason: format!(
200 "changing implemented interfaces on node '{}' is not supported in schema migration v1",
201 node.name
202 ),
203 });
204 }
205
206 plan_type_metadata(
207 SchemaTypeKind::Node,
208 &node.name,
209 &existing.annotations,
210 &node.annotations,
211 steps,
212 );
213 let property_renames = plan_properties(
214 SchemaTypeKind::Node,
215 &node.name,
216 &existing.properties,
217 &node.properties,
218 steps,
219 );
220 plan_constraints(
221 SchemaTypeKind::Node,
222 &node.name,
223 &existing.constraints,
224 &node.constraints,
225 &property_renames,
226 steps,
227 );
228 }
229
230 for leftover in accepted
231 .iter()
232 .filter(|node| !consumed.contains(&node.name))
233 {
234 steps.push(SchemaMigrationStep::UnsupportedChange {
235 entity: format!("node:{}", leftover.name),
236 reason: format!(
237 "removing node type '{}' is not supported in schema migration v1",
238 leftover.name
239 ),
240 });
241 }
242
243 renames
244}
245
246fn plan_edges(
247 accepted: &[EdgeIR],
248 desired: &[EdgeIR],
249 node_renames: &HashMap<String, String>,
250 steps: &mut Vec<SchemaMigrationStep>,
251) {
252 let accepted_by_name = accepted
253 .iter()
254 .map(|edge| (edge.name.as_str(), edge))
255 .collect::<HashMap<_, _>>();
256 let mut consumed = HashSet::new();
257
258 for edge in desired {
259 let rename_from = rename_from_value(&edge.annotations);
260 let matched = accepted_by_name
261 .get(edge.name.as_str())
262 .copied()
263 .or_else(|| {
264 rename_from.and_then(|from| {
265 accepted_by_name
266 .get(from)
267 .copied()
268 .filter(|candidate| candidate.name != edge.name)
269 })
270 });
271
272 let Some(existing) = matched else {
273 if let Some(from) = rename_from {
274 steps.push(SchemaMigrationStep::UnsupportedChange {
275 entity: format!("edge:{}", edge.name),
276 reason: format!(
277 "edge '{}' declares @rename_from(\"{}\") but no accepted edge with that name exists",
278 edge.name, from
279 ),
280 });
281 } else {
282 steps.push(SchemaMigrationStep::AddType {
283 type_kind: SchemaTypeKind::Edge,
284 name: edge.name.clone(),
285 });
286 }
287 continue;
288 };
289
290 consumed.insert(existing.name.clone());
291 if existing.name != edge.name {
292 steps.push(SchemaMigrationStep::RenameType {
293 type_kind: SchemaTypeKind::Edge,
294 from: existing.name.clone(),
295 to: edge.name.clone(),
296 });
297 }
298
299 let normalized_from = normalize_type_ref(&existing.from_type, node_renames);
300 let normalized_to = normalize_type_ref(&existing.to_type, node_renames);
301 if normalized_from != edge.from_type || normalized_to != edge.to_type {
302 steps.push(SchemaMigrationStep::UnsupportedChange {
303 entity: format!("edge:{}", edge.name),
304 reason: format!(
305 "changing edge endpoints on '{}' is not supported in schema migration v1",
306 edge.name
307 ),
308 });
309 }
310 if existing.cardinality != edge.cardinality {
311 steps.push(SchemaMigrationStep::UnsupportedChange {
312 entity: format!("edge:{}", edge.name),
313 reason: format!(
314 "changing cardinality on edge '{}' is not supported in schema migration v1",
315 edge.name
316 ),
317 });
318 }
319
320 plan_type_metadata(
321 SchemaTypeKind::Edge,
322 &edge.name,
323 &existing.annotations,
324 &edge.annotations,
325 steps,
326 );
327 let property_renames = plan_properties(
328 SchemaTypeKind::Edge,
329 &edge.name,
330 &existing.properties,
331 &edge.properties,
332 steps,
333 );
334 plan_constraints(
335 SchemaTypeKind::Edge,
336 &edge.name,
337 &existing.constraints,
338 &edge.constraints,
339 &property_renames,
340 steps,
341 );
342 }
343
344 for leftover in accepted
345 .iter()
346 .filter(|edge| !consumed.contains(&edge.name))
347 {
348 steps.push(SchemaMigrationStep::UnsupportedChange {
349 entity: format!("edge:{}", leftover.name),
350 reason: format!(
351 "removing edge type '{}' is not supported in schema migration v1",
352 leftover.name
353 ),
354 });
355 }
356}
357
358fn plan_properties(
359 type_kind: SchemaTypeKind,
360 type_name: &str,
361 accepted: &[PropertyIR],
362 desired: &[PropertyIR],
363 steps: &mut Vec<SchemaMigrationStep>,
364) -> HashMap<String, String> {
365 let accepted_by_name = accepted
366 .iter()
367 .map(|property| (property.name.as_str(), property))
368 .collect::<HashMap<_, _>>();
369 let mut consumed = HashSet::new();
370 let mut renames = HashMap::new();
371
372 for property in desired {
373 let rename_from = rename_from_value(&property.annotations);
374 let matched = accepted_by_name
375 .get(property.name.as_str())
376 .copied()
377 .or_else(|| {
378 rename_from.and_then(|from| {
379 accepted_by_name
380 .get(from)
381 .copied()
382 .filter(|candidate| candidate.name != property.name)
383 })
384 });
385
386 let Some(existing) = matched else {
387 if let Some(from) = rename_from {
388 steps.push(SchemaMigrationStep::UnsupportedChange {
389 entity: format!(
390 "{}:{}.{}",
391 schema_type_kind_key(type_kind),
392 type_name,
393 property.name
394 ),
395 reason: format!(
396 "property '{}.{}' declares @rename_from(\"{}\") but no accepted property with that name exists",
397 type_name, property.name, from
398 ),
399 });
400 } else if property.prop_type.nullable {
401 steps.push(SchemaMigrationStep::AddProperty {
402 type_kind,
403 type_name: type_name.to_string(),
404 property_name: property.name.clone(),
405 property_type: property.prop_type.clone(),
406 });
407 } else {
408 steps.push(SchemaMigrationStep::UnsupportedChange {
409 entity: format!(
410 "{}:{}.{}",
411 schema_type_kind_key(type_kind),
412 type_name,
413 property.name
414 ),
415 reason: format!(
416 "adding required property '{}.{}' requires a backfill and is not supported in schema migration v1",
417 type_name, property.name
418 ),
419 });
420 }
421 continue;
422 };
423
424 consumed.insert(existing.name.clone());
425 if existing.name != property.name {
426 renames.insert(existing.name.clone(), property.name.clone());
427 steps.push(SchemaMigrationStep::RenameProperty {
428 type_kind,
429 type_name: type_name.to_string(),
430 from: existing.name.clone(),
431 to: property.name.clone(),
432 });
433 }
434
435 if existing.prop_type != property.prop_type {
436 steps.push(SchemaMigrationStep::UnsupportedChange {
437 entity: format!(
438 "{}:{}.{}",
439 schema_type_kind_key(type_kind),
440 type_name,
441 property.name
442 ),
443 reason: format!(
444 "changing property type for '{}.{}' is not supported in schema migration v1",
445 type_name, property.name
446 ),
447 });
448 }
449
450 plan_property_metadata(
451 type_kind,
452 type_name,
453 &property.name,
454 &existing.annotations,
455 &property.annotations,
456 steps,
457 );
458 }
459
460 for leftover in accepted
461 .iter()
462 .filter(|property| !consumed.contains(&property.name))
463 {
464 steps.push(SchemaMigrationStep::UnsupportedChange {
465 entity: format!(
466 "{}:{}.{}",
467 schema_type_kind_key(type_kind),
468 type_name,
469 leftover.name
470 ),
471 reason: format!(
472 "removing property '{}.{}' is not supported in schema migration v1",
473 type_name, leftover.name
474 ),
475 });
476 }
477
478 renames
479}
480
481fn plan_constraints(
482 type_kind: SchemaTypeKind,
483 type_name: &str,
484 accepted: &[Constraint],
485 desired: &[Constraint],
486 property_renames: &HashMap<String, String>,
487 steps: &mut Vec<SchemaMigrationStep>,
488) {
489 let accepted = accepted
490 .iter()
491 .cloned()
492 .map(|constraint| rename_constraint_properties(constraint, property_renames))
493 .collect::<Vec<_>>();
494 let desired_map = desired
495 .iter()
496 .cloned()
497 .map(|constraint| (constraint_key(&constraint), constraint))
498 .collect::<BTreeMap<_, _>>();
499 let accepted_map = accepted
500 .into_iter()
501 .map(|constraint| (constraint_key(&constraint), constraint))
502 .collect::<BTreeMap<_, _>>();
503
504 let removed = accepted_map
505 .keys()
506 .filter(|key| !desired_map.contains_key(*key))
507 .cloned()
508 .collect::<Vec<_>>();
509 if !removed.is_empty() {
510 steps.push(SchemaMigrationStep::UnsupportedChange {
511 entity: format!("{}:{}", schema_type_kind_key(type_kind), type_name),
512 reason: format!(
513 "removing constraints from '{}' is not supported in schema migration v1",
514 type_name
515 ),
516 });
517 }
518
519 for (key, constraint) in desired_map {
520 if accepted_map.contains_key(&key) {
521 continue;
522 }
523 match constraint {
524 Constraint::Index(_) => steps.push(SchemaMigrationStep::AddConstraint {
525 type_kind,
526 type_name: type_name.to_string(),
527 constraint,
528 }),
529 _ => steps.push(SchemaMigrationStep::UnsupportedChange {
530 entity: format!("{}:{}", schema_type_kind_key(type_kind), type_name),
531 reason: format!(
532 "adding constraint '{}' to '{}' is not supported in schema migration v1",
533 key, type_name
534 ),
535 }),
536 }
537 }
538}
539
540fn plan_type_metadata(
541 type_kind: SchemaTypeKind,
542 name: &str,
543 accepted: &[Annotation],
544 desired: &[Annotation],
545 steps: &mut Vec<SchemaMigrationStep>,
546) {
547 match annotation_change_kind(accepted, desired) {
548 AnnotationChangeKind::None => {}
549 AnnotationChangeKind::MetadataOnly(metadata) => {
550 steps.push(SchemaMigrationStep::UpdateTypeMetadata {
551 type_kind,
552 name: name.to_string(),
553 annotations: metadata,
554 });
555 }
556 AnnotationChangeKind::Unsupported(reason) => {
557 steps.push(SchemaMigrationStep::UnsupportedChange {
558 entity: format!("{}:{}", schema_type_kind_key(type_kind), name),
559 reason,
560 });
561 }
562 }
563}
564
565fn plan_property_metadata(
566 type_kind: SchemaTypeKind,
567 type_name: &str,
568 property_name: &str,
569 accepted: &[Annotation],
570 desired: &[Annotation],
571 steps: &mut Vec<SchemaMigrationStep>,
572) {
573 match annotation_change_kind(accepted, desired) {
574 AnnotationChangeKind::None => {}
575 AnnotationChangeKind::MetadataOnly(metadata) => {
576 steps.push(SchemaMigrationStep::UpdatePropertyMetadata {
577 type_kind,
578 type_name: type_name.to_string(),
579 property_name: property_name.to_string(),
580 annotations: metadata,
581 });
582 }
583 AnnotationChangeKind::Unsupported(reason) => {
584 steps.push(SchemaMigrationStep::UnsupportedChange {
585 entity: format!(
586 "{}:{}.{}",
587 schema_type_kind_key(type_kind),
588 type_name,
589 property_name
590 ),
591 reason,
592 });
593 }
594 }
595}
596
597enum AnnotationChangeKind {
598 None,
599 MetadataOnly(Vec<Annotation>),
600 Unsupported(String),
601}
602
603fn annotation_change_kind(accepted: &[Annotation], desired: &[Annotation]) -> AnnotationChangeKind {
604 let accepted_non_metadata = strip_metadata_annotations(accepted);
605 let desired_non_metadata = strip_metadata_annotations(desired);
606 if accepted_non_metadata != desired_non_metadata {
607 return AnnotationChangeKind::Unsupported(
608 "changing annotations beyond @description/@instruction is not supported in schema migration v1"
609 .to_string(),
610 );
611 }
612
613 let accepted_metadata = metadata_annotations(accepted);
614 let desired_metadata = metadata_annotations(desired);
615 if accepted_metadata == desired_metadata {
616 AnnotationChangeKind::None
617 } else {
618 AnnotationChangeKind::MetadataOnly(desired_metadata)
619 }
620}
621
622fn strip_metadata_annotations(annotations: &[Annotation]) -> Vec<Annotation> {
623 annotations
624 .iter()
625 .filter(|annotation| {
626 !matches!(
627 annotation.name.as_str(),
628 "description" | "instruction" | "rename_from" | "key" | "unique" | "index"
629 )
630 })
631 .cloned()
632 .collect()
633}
634
635fn metadata_annotations(annotations: &[Annotation]) -> Vec<Annotation> {
636 annotations
637 .iter()
638 .filter(|annotation| matches!(annotation.name.as_str(), "description" | "instruction"))
639 .cloned()
640 .collect()
641}
642
643fn normalize_strings(values: &[String], renames: &HashMap<String, String>) -> BTreeSet<String> {
644 values
645 .iter()
646 .map(|value| normalize_type_ref(value, renames))
647 .collect()
648}
649
650fn normalize_type_ref(value: &str, renames: &HashMap<String, String>) -> String {
651 renames
652 .get(value)
653 .cloned()
654 .unwrap_or_else(|| value.to_string())
655}
656
657fn rename_constraint_properties(
658 constraint: Constraint,
659 property_renames: &HashMap<String, String>,
660) -> Constraint {
661 match constraint {
662 Constraint::Key(columns) => {
663 Constraint::Key(rename_constraint_columns(columns, property_renames))
664 }
665 Constraint::Unique(columns) => {
666 Constraint::Unique(rename_constraint_columns(columns, property_renames))
667 }
668 Constraint::Index(columns) => {
669 Constraint::Index(rename_constraint_columns(columns, property_renames))
670 }
671 Constraint::Range { property, min, max } => Constraint::Range {
672 property: normalize_property_ref(&property, property_renames),
673 min,
674 max,
675 },
676 Constraint::Check { property, pattern } => Constraint::Check {
677 property: normalize_property_ref(&property, property_renames),
678 pattern,
679 },
680 }
681}
682
683fn rename_constraint_columns(
684 columns: Vec<String>,
685 property_renames: &HashMap<String, String>,
686) -> Vec<String> {
687 let mut columns = columns
688 .into_iter()
689 .map(|column| normalize_property_ref(&column, property_renames))
690 .collect::<Vec<_>>();
691 columns.sort();
692 columns
693}
694
695fn normalize_property_ref(value: &str, renames: &HashMap<String, String>) -> String {
696 renames
697 .get(value)
698 .cloned()
699 .unwrap_or_else(|| value.to_string())
700}
701
702fn constraint_key(constraint: &Constraint) -> String {
703 match constraint {
704 Constraint::Key(columns) => format!("key:{}", columns.join(",")),
705 Constraint::Unique(columns) => format!("unique:{}", columns.join(",")),
706 Constraint::Index(columns) => format!("index:{}", columns.join(",")),
707 Constraint::Range { property, min, max } => {
708 format!("range:{}:{:?}:{:?}", property, min, max)
709 }
710 Constraint::Check { property, pattern } => format!("check:{}:{}", property, pattern),
711 }
712}
713
714fn rename_from_value(annotations: &[Annotation]) -> Option<&str> {
715 annotations
716 .iter()
717 .find(|annotation| annotation.name == "rename_from")
718 .and_then(|annotation| annotation.value.as_deref())
719}
720
721fn schema_type_kind_key(kind: SchemaTypeKind) -> &'static str {
722 match kind {
723 SchemaTypeKind::Interface => "interface",
724 SchemaTypeKind::Node => "node",
725 SchemaTypeKind::Edge => "edge",
726 }
727}
728
729#[cfg(test)]
730mod tests {
731 use crate::catalog::schema_ir::build_schema_ir;
732 use crate::schema::parser::parse_schema;
733
734 use super::SchemaMigrationStep::{
735 AddConstraint, AddProperty, RenameProperty, RenameType, UnsupportedChange,
736 UpdateTypeMetadata,
737 };
738 use super::*;
739
740 #[test]
741 fn plan_supports_additive_nullable_property_and_index() {
742 let accepted = build_schema_ir(
743 &parse_schema(
744 r#"
745node Person {
746 name: String @key
747 age: I32?
748}
749"#,
750 )
751 .unwrap(),
752 )
753 .unwrap();
754 let desired = build_schema_ir(
755 &parse_schema(
756 r#"
757node Person {
758 name: String @key
759 age: I32? @index
760 nickname: String?
761}
762"#,
763 )
764 .unwrap(),
765 )
766 .unwrap();
767
768 let plan = plan_schema_migration(&accepted, &desired).unwrap();
769 assert!(plan.supported);
770 assert!(plan.steps.contains(&AddProperty {
771 type_kind: SchemaTypeKind::Node,
772 type_name: "Person".to_string(),
773 property_name: "nickname".to_string(),
774 property_type: PropType::scalar(crate::types::ScalarType::String, true),
775 }));
776 assert!(plan.steps.contains(&AddConstraint {
777 type_kind: SchemaTypeKind::Node,
778 type_name: "Person".to_string(),
779 constraint: Constraint::Index(vec!["age".to_string()]),
780 }));
781 }
782
783 #[test]
784 fn plan_supports_explicit_type_and_property_rename() {
785 let accepted = build_schema_ir(
786 &parse_schema(
787 r#"
788node User {
789 name: String @key
790}
791"#,
792 )
793 .unwrap(),
794 )
795 .unwrap();
796 let desired = build_schema_ir(
797 &parse_schema(
798 r#"
799node Account @rename_from("User") {
800 full_name: String @key @rename_from("name")
801}
802"#,
803 )
804 .unwrap(),
805 )
806 .unwrap();
807
808 let plan = plan_schema_migration(&accepted, &desired).unwrap();
809 assert!(plan.supported);
810 assert!(plan.steps.contains(&RenameType {
811 type_kind: SchemaTypeKind::Node,
812 from: "User".to_string(),
813 to: "Account".to_string(),
814 }));
815 assert!(plan.steps.contains(&RenameProperty {
816 type_kind: SchemaTypeKind::Node,
817 type_name: "Account".to_string(),
818 from: "name".to_string(),
819 to: "full_name".to_string(),
820 }));
821 }
822
823 #[test]
824 fn plan_rejects_required_property_addition() {
825 let accepted = build_schema_ir(
826 &parse_schema(
827 r#"
828node Person {
829 name: String @key
830}
831"#,
832 )
833 .unwrap(),
834 )
835 .unwrap();
836 let desired = build_schema_ir(
837 &parse_schema(
838 r#"
839node Person {
840 name: String @key
841 age: I32
842}
843"#,
844 )
845 .unwrap(),
846 )
847 .unwrap();
848
849 let plan = plan_schema_migration(&accepted, &desired).unwrap();
850 assert!(!plan.supported);
851 assert!(plan.steps.iter().any(|step| matches!(
852 step,
853 UnsupportedChange { entity, reason }
854 if entity.contains("Person.age")
855 && reason.contains("adding required property")
856 )));
857 }
858
859 #[test]
860 fn plan_supports_metadata_only_annotation_changes() {
861 let accepted = build_schema_ir(
862 &parse_schema(
863 r#"
864node Person @description("old") {
865 name: String @key
866}
867"#,
868 )
869 .unwrap(),
870 )
871 .unwrap();
872 let desired = build_schema_ir(
873 &parse_schema(
874 r#"
875node Person @description("new") {
876 name: String @key
877}
878"#,
879 )
880 .unwrap(),
881 )
882 .unwrap();
883
884 let plan = plan_schema_migration(&accepted, &desired).unwrap();
885 assert!(plan.supported);
886 assert!(plan.steps.contains(&UpdateTypeMetadata {
887 type_kind: SchemaTypeKind::Node,
888 name: "Person".to_string(),
889 annotations: vec![Annotation {
890 name: "description".to_string(),
891 value: Some("new".to_string()),
892 }],
893 }));
894 }
895}