From bc434fb57746e10235438262e0d39d3f29038182 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Sun, 5 Jul 2026 01:16:13 +0300 Subject: [PATCH] feat(compiler): plan pure enum widening as a supported ExtendEnum step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding variants to an enum property previously planned as an unsupported property-type change (OG-MF-106): enum values live inside PropType and the planner compared whole types. Special-case the one safe shape — same scalar/list/nullability and the desired value set a strict superset of the accepted one — into a new ExtendEnum step carrying the added values. Narrowing, variant renames, and enum<->String conversions still plan as OG-MF-106; a pure reorder was already a no-op (the schema IR sorts + dedups enum values). Planner matrix pinned in in-source tests (widening, reorder-as-no-change, narrowing, rename, widen+nullability-flip, enum<->String both ways); CLI plan rendering gains the ExtendEnum arm. --- crates/omnigraph-cli/src/output.rs | 13 ++ .../src/catalog/schema_plan.rs | 167 +++++++++++++++++- 2 files changed, 179 insertions(+), 1 deletion(-) diff --git a/crates/omnigraph-cli/src/output.rs b/crates/omnigraph-cli/src/output.rs index 101ace47..d7375588 100644 --- a/crates/omnigraph-cli/src/output.rs +++ b/crates/omnigraph-cli/src/output.rs @@ -566,6 +566,19 @@ pub(crate) fn render_schema_plan_step(step: &SchemaMigrationStep) -> String { schema_type_kind_label(*type_kind), type_name ), + SchemaMigrationStep::ExtendEnum { + type_kind, + type_name, + property_name, + added_values, + } => format!( + "extend enum '{}.{}' (+{}) on {} '{}'", + type_name, + property_name, + added_values.join(", +"), + schema_type_kind_label(*type_kind), + type_name + ), SchemaMigrationStep::UpdateTypeMetadata { type_kind, name, diff --git a/crates/omnigraph-compiler/src/catalog/schema_plan.rs b/crates/omnigraph-compiler/src/catalog/schema_plan.rs index dc9d4663..0e01811a 100644 --- a/crates/omnigraph-compiler/src/catalog/schema_plan.rs +++ b/crates/omnigraph-compiler/src/catalog/schema_plan.rs @@ -74,6 +74,23 @@ pub enum SchemaMigrationStep { type_name: String, constraint: Constraint, }, + /// Widen an enum property's value set. Emitted only for a PURE widening — + /// same scalar/list shape, same nullability, and the desired value set is + /// a superset of the accepted one (order-insensitive; enum semantics are + /// set membership, not position). Metadata-only at apply time: every + /// committed row is valid under a superset, so no table data is touched + /// and the unified validator accepts the new variants on all three write + /// surfaces the moment the accepted catalog updates. Narrowing, renames, + /// and enum<->free-String conversions still plan as `UnsupportedChange` + /// (OG-MF-106). + ExtendEnum { + type_kind: SchemaTypeKind, + type_name: String, + property_name: String, + /// The variants the desired schema adds (accepted-set order preserved + /// for the untouched prefix; display/debug aid). + added_values: Vec, + }, UpdateTypeMetadata { type_kind: SchemaTypeKind, name: String, @@ -537,7 +554,14 @@ fn plan_properties( }); } - if existing.prop_type != property.prop_type { + if let Some(added_values) = enum_widening(&existing.prop_type, &property.prop_type) { + steps.push(SchemaMigrationStep::ExtendEnum { + type_kind, + type_name: type_name.to_string(), + property_name: property.name.clone(), + added_values, + }); + } else if existing.prop_type != property.prop_type { steps.push(SchemaMigrationStep::UnsupportedChange { entity: format!( "{}:{}.{}", @@ -709,6 +733,46 @@ fn plan_property_metadata( } } +/// `Some(added)` when `desired` is a PURE enum widening of `accepted`: both +/// are enums of the same scalar/list shape and nullability, every accepted +/// value is retained, and at least one value is new. Enum values arrive +/// sorted + deduped from the schema IR (`normalize` in schema_ir.rs), so a +/// bare reorder is already type equality (no step), and a returned `added` +/// is never empty. Everything else (narrowing, renamed values, enum<->plain +/// conversions, shape changes) returns `None` and falls through to +/// OG-MF-106. +fn enum_widening(accepted: &PropType, desired: &PropType) -> Option> { + let accepted_values = accepted.enum_values.as_ref()?; + let desired_values = desired.enum_values.as_ref()?; + if accepted.scalar != desired.scalar + || accepted.nullable != desired.nullable + || accepted.list != desired.list + { + return None; + } + if accepted_values == desired_values { + // Identical type — not a change at all; let the equality check pass. + return None; + } + let desired_set: std::collections::HashSet<&str> = + desired_values.iter().map(String::as_str).collect(); + if !accepted_values + .iter() + .all(|v| desired_set.contains(v.as_str())) + { + return None; // narrowing or rename + } + let accepted_set: std::collections::HashSet<&str> = + accepted_values.iter().map(String::as_str).collect(); + Some( + desired_values + .iter() + .filter(|v| !accepted_set.contains(v.as_str())) + .cloned() + .collect(), + ) +} + enum AnnotationChangeKind { None, MetadataOnly(Vec), @@ -852,6 +916,107 @@ mod tests { }; use super::*; + fn ir(source: &str) -> crate::catalog::schema_ir::SchemaIR { + build_schema_ir(&parse_schema(source).unwrap()).unwrap() + } + + const ENUM_ACCEPTED: &str = r#" +node Ticket { + slug: String @key + status: enum(todo, doing, done) +} +"#; + + #[test] + fn plan_supports_pure_enum_widening() { + let accepted = ir(ENUM_ACCEPTED); + let desired = ir( + r#" +node Ticket { + slug: String @key + status: enum(todo, doing, done, blocked, canceled) +} +"#, + ); + let plan = plan_schema_migration(&accepted, &desired).unwrap(); + assert!(plan.supported, "widening must be a supported plan: {plan:?}"); + assert!(plan.steps.contains(&SchemaMigrationStep::ExtendEnum { + type_kind: SchemaTypeKind::Node, + type_name: "Ticket".to_string(), + property_name: "status".to_string(), + added_values: vec!["blocked".to_string(), "canceled".to_string()], + })); + } + + #[test] + fn plan_treats_pure_reorder_as_no_change() { + // Enum values are sorted + deduped by the schema IR, so a reorder is + // type-identical — no step at all, not even a widening. + let accepted = ir(ENUM_ACCEPTED); + let desired = ir( + r#" +node Ticket { + slug: String @key + status: enum(done, todo, doing) +} +"#, + ); + let plan = plan_schema_migration(&accepted, &desired).unwrap(); + assert!(plan.supported); + assert!( + plan.steps.is_empty(), + "reorder must be a no-op plan: {:?}", + plan.steps + ); + } + + #[test] + fn plan_rejects_enum_narrowing_and_rename() { + let accepted = ir(ENUM_ACCEPTED); + for desired_src in [ + // narrowing + "node Ticket {\n slug: String @key\n status: enum(todo, done)\n}\n", + // rename of a variant (doing -> in_progress) = remove + add + "node Ticket {\n slug: String @key\n status: enum(todo, in_progress, done)\n}\n", + ] { + let desired = ir(desired_src); + let plan = plan_schema_migration(&accepted, &desired).unwrap(); + assert!(!plan.supported, "must be unsupported: {desired_src}"); + assert!( + plan.steps.iter().any(|s| matches!( + s, + UnsupportedChange { code: Some(c), .. } if c == crate::lint::codes::OG_MF_106.code + )), + "expected OG-MF-106: {plan:?}" + ); + } + } + + #[test] + fn plan_rejects_widening_combined_with_nullability_change() { + let accepted = ir(ENUM_ACCEPTED); + let desired = ir( + r#" +node Ticket { + slug: String @key + status: enum(todo, doing, done, blocked)? +} +"#, + ); + let plan = plan_schema_migration(&accepted, &desired).unwrap(); + assert!(!plan.supported, "widen+nullable-flip must stay unsupported"); + } + + #[test] + fn plan_rejects_enum_to_free_string_and_back() { + let accepted = ir(ENUM_ACCEPTED); + let free = ir("node Ticket {\n slug: String @key\n status: String\n}\n"); + let plan = plan_schema_migration(&accepted, &free).unwrap(); + assert!(!plan.supported, "enum->String must stay unsupported"); + let plan_back = plan_schema_migration(&free, &accepted).unwrap(); + assert!(!plan_back.supported, "String->enum must stay unsupported"); + } + #[test] fn plan_supports_additive_nullable_property_and_index() { let accepted = build_schema_ir(