mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-12 03:12:11 +02:00
feat(compiler): plan pure enum widening as a supported ExtendEnum step
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.
This commit is contained in:
parent
e211060119
commit
bc434fb577
2 changed files with 179 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
},
|
||||
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<Vec<String>> {
|
||||
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<Annotation>),
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue