Skip to main content

omnigraph_compiler/catalog/
schema_plan.rs

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}