mirror of
https://github.com/ModernRelay/omnigraph.git
synced 2026-07-06 02:52:11 +02:00
Annotations gain optional comma-separated key=value kwargs. Annotation keeps value (existing consumers unchanged) and adds kwargs: BTreeMap with serde(default, skip_serializing_if) so empty kwargs are omitted and existing schemas' IR JSON/hash stay byte-identical. The parser rejects any @embed kwarg other than model. render_annotations shows kwargs. 3 new parser tests.
1195 lines
38 KiB
Rust
1195 lines
38 KiB
Rust
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::error::Result;
|
|
use crate::schema::ast::{Annotation, Constraint};
|
|
use crate::types::PropType;
|
|
|
|
use super::schema_ir::{EdgeIR, InterfaceIR, NodeIR, PropertyIR, SchemaIR};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum SchemaTypeKind {
|
|
Interface,
|
|
Node,
|
|
Edge,
|
|
}
|
|
|
|
/// How a drop step interacts with data.
|
|
///
|
|
/// - **`Soft`** — catalog tombstone only. The type / property is hidden
|
|
/// from queries but the underlying Lance column / dataset is retained
|
|
/// on disk. Reversible via `omnigraph schema unhide` (forthcoming).
|
|
/// Tier: `safe`.
|
|
/// - **`Hard`** — actual data removal. The Lance column is rewritten
|
|
/// without the property, or the Lance dataset is dropped. Irreversible
|
|
/// short of branch / snapshot restore. Tier: `destructive`; requires
|
|
/// `--allow-data-loss` to apply.
|
|
///
|
|
/// The planner emits `Soft` by default; `--allow-data-loss` on the apply
|
|
/// CLI promotes drops to `Hard`. This is the dimension orthogonal to
|
|
/// `SafetyTier` from the schema-lint chassis (`crate::lint`): tier
|
|
/// describes the rule's class; mode describes the operator's intent for
|
|
/// data treatment.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum DropMode {
|
|
Soft,
|
|
Hard,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
pub struct SchemaMigrationPlan {
|
|
pub supported: bool,
|
|
pub steps: Vec<SchemaMigrationStep>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum SchemaMigrationStep {
|
|
AddType {
|
|
type_kind: SchemaTypeKind,
|
|
name: String,
|
|
},
|
|
RenameType {
|
|
type_kind: SchemaTypeKind,
|
|
from: String,
|
|
to: String,
|
|
},
|
|
AddProperty {
|
|
type_kind: SchemaTypeKind,
|
|
type_name: String,
|
|
property_name: String,
|
|
property_type: PropType,
|
|
},
|
|
RenameProperty {
|
|
type_kind: SchemaTypeKind,
|
|
type_name: String,
|
|
from: String,
|
|
to: String,
|
|
},
|
|
AddConstraint {
|
|
type_kind: SchemaTypeKind,
|
|
type_name: String,
|
|
constraint: Constraint,
|
|
},
|
|
UpdateTypeMetadata {
|
|
type_kind: SchemaTypeKind,
|
|
name: String,
|
|
annotations: Vec<Annotation>,
|
|
},
|
|
UpdatePropertyMetadata {
|
|
type_kind: SchemaTypeKind,
|
|
type_name: String,
|
|
property_name: String,
|
|
annotations: Vec<Annotation>,
|
|
},
|
|
/// Remove a node or edge type. Soft mode tombstones in the catalog
|
|
/// and retains data on disk; Hard mode drops the Lance dataset and
|
|
/// requires `--allow-data-loss`.
|
|
///
|
|
/// Dormant in this commit — emitted by the planner in a later
|
|
/// commit (see `docs/schema-lint-v1-plan.md`).
|
|
DropType {
|
|
type_kind: SchemaTypeKind,
|
|
name: String,
|
|
mode: DropMode,
|
|
},
|
|
/// Remove a property from an existing type. Soft mode tombstones
|
|
/// the property in the catalog and retains the Lance column; Hard
|
|
/// mode rewrites the column out and requires `--allow-data-loss`.
|
|
///
|
|
/// Dormant in this commit.
|
|
DropProperty {
|
|
type_kind: SchemaTypeKind,
|
|
type_name: String,
|
|
property_name: String,
|
|
mode: DropMode,
|
|
},
|
|
UnsupportedChange {
|
|
entity: String,
|
|
reason: String,
|
|
/// Stable schema-lint code (`OG-XXX-NNN`) for this rejection,
|
|
/// or `None` if the path predates the chassis catalog. See
|
|
/// [`crate::lint::codes`] for the registry. Renderers should
|
|
/// prefix the message with `[code]` when present so operators
|
|
/// can suppress, look up docs, or filter on stable identifiers
|
|
/// rather than free-text prose.
|
|
///
|
|
/// Stored as `String` (not `&'static str`) so the enum stays
|
|
/// serde-friendly. Emitters pass the catalog constant's
|
|
/// `.code` (a `&'static str`) and we own a clone here.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
code: Option<String>,
|
|
},
|
|
}
|
|
|
|
impl SchemaMigrationStep {
|
|
/// Returns the formatted error message for an `UnsupportedChange`
|
|
/// step, prefixed with `[code] ` when a schema-lint code is attached.
|
|
/// Returns `None` for every other variant.
|
|
pub fn unsupported_error_message(&self) -> Option<String> {
|
|
match self {
|
|
Self::UnsupportedChange { reason, code, .. } => Some(match code {
|
|
Some(c) => format!("[{}] {}", c, reason),
|
|
None => reason.clone(),
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// If this step carries a schema-lint code, return the full
|
|
/// catalog entry — including family, safety tier, and default
|
|
/// severity. Used by renderers that want to display richer
|
|
/// context than just the code string (e.g. `omnigraph schema
|
|
/// plan` annotating each line with its tier).
|
|
///
|
|
/// Returns `None` for steps that carry no code (the 12 of 17
|
|
/// `UnsupportedChange` paths still untagged in v0, plus every
|
|
/// non-`UnsupportedChange` variant).
|
|
pub fn diagnostic(&self) -> Option<&'static crate::lint::DiagnosticCode> {
|
|
match self {
|
|
Self::UnsupportedChange { code: Some(c), .. } => crate::lint::lookup(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn plan_schema_migration(
|
|
accepted: &SchemaIR,
|
|
desired: &SchemaIR,
|
|
) -> Result<SchemaMigrationPlan> {
|
|
let mut steps = Vec::new();
|
|
let interface_renames = plan_interfaces(&accepted.interfaces, &desired.interfaces, &mut steps);
|
|
let node_renames = plan_nodes(
|
|
&accepted.nodes,
|
|
&desired.nodes,
|
|
&interface_renames,
|
|
&mut steps,
|
|
);
|
|
plan_edges(&accepted.edges, &desired.edges, &node_renames, &mut steps);
|
|
|
|
Ok(SchemaMigrationPlan {
|
|
supported: !steps
|
|
.iter()
|
|
.any(|step| matches!(step, SchemaMigrationStep::UnsupportedChange { .. })),
|
|
steps,
|
|
})
|
|
}
|
|
|
|
fn plan_interfaces(
|
|
accepted: &[InterfaceIR],
|
|
desired: &[InterfaceIR],
|
|
steps: &mut Vec<SchemaMigrationStep>,
|
|
) -> HashMap<String, String> {
|
|
let accepted_by_name = accepted
|
|
.iter()
|
|
.map(|interface| (interface.name.as_str(), interface))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut consumed = HashSet::new();
|
|
|
|
for interface in desired {
|
|
if let Some(existing) = accepted_by_name.get(interface.name.as_str()) {
|
|
consumed.insert(existing.name.clone());
|
|
let _property_renames = plan_properties(
|
|
SchemaTypeKind::Interface,
|
|
&interface.name,
|
|
&existing.properties,
|
|
&interface.properties,
|
|
steps,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
steps.push(SchemaMigrationStep::AddType {
|
|
type_kind: SchemaTypeKind::Interface,
|
|
name: interface.name.clone(),
|
|
});
|
|
}
|
|
|
|
for leftover in accepted
|
|
.iter()
|
|
.filter(|interface| !consumed.contains(&interface.name))
|
|
{
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("interface:{}", leftover.name),
|
|
reason: format!(
|
|
"removing interface '{}' is not supported in schema migration v1",
|
|
leftover.name
|
|
),
|
|
code: None,
|
|
});
|
|
}
|
|
|
|
HashMap::new()
|
|
}
|
|
|
|
fn plan_nodes(
|
|
accepted: &[NodeIR],
|
|
desired: &[NodeIR],
|
|
interface_renames: &HashMap<String, String>,
|
|
steps: &mut Vec<SchemaMigrationStep>,
|
|
) -> HashMap<String, String> {
|
|
let accepted_by_name = accepted
|
|
.iter()
|
|
.map(|node| (node.name.as_str(), node))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut consumed = HashSet::new();
|
|
let mut renames = HashMap::new();
|
|
|
|
for node in desired {
|
|
let rename_from = rename_from_value(&node.annotations);
|
|
let matched = accepted_by_name
|
|
.get(node.name.as_str())
|
|
.copied()
|
|
.or_else(|| {
|
|
rename_from.and_then(|from| {
|
|
accepted_by_name
|
|
.get(from)
|
|
.copied()
|
|
.filter(|candidate| candidate.name != node.name)
|
|
})
|
|
});
|
|
|
|
let Some(existing) = matched else {
|
|
if let Some(from) = rename_from {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("node:{}", node.name),
|
|
reason: format!(
|
|
"node '{}' declares @rename_from(\"{}\") but no accepted node with that name exists",
|
|
node.name, from
|
|
),
|
|
code: None,
|
|
});
|
|
} else {
|
|
steps.push(SchemaMigrationStep::AddType {
|
|
type_kind: SchemaTypeKind::Node,
|
|
name: node.name.clone(),
|
|
});
|
|
}
|
|
continue;
|
|
};
|
|
|
|
consumed.insert(existing.name.clone());
|
|
if existing.name != node.name {
|
|
renames.insert(existing.name.clone(), node.name.clone());
|
|
steps.push(SchemaMigrationStep::RenameType {
|
|
type_kind: SchemaTypeKind::Node,
|
|
from: existing.name.clone(),
|
|
to: node.name.clone(),
|
|
});
|
|
}
|
|
|
|
if normalize_strings(&existing.implements, interface_renames)
|
|
!= normalize_strings(&node.implements, &HashMap::new())
|
|
{
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("node:{}", node.name),
|
|
reason: format!(
|
|
"changing implemented interfaces on node '{}' is not supported in schema migration v1",
|
|
node.name
|
|
),
|
|
code: None,
|
|
});
|
|
}
|
|
|
|
plan_type_metadata(
|
|
SchemaTypeKind::Node,
|
|
&node.name,
|
|
&existing.annotations,
|
|
&node.annotations,
|
|
steps,
|
|
);
|
|
let property_renames = plan_properties(
|
|
SchemaTypeKind::Node,
|
|
&node.name,
|
|
&existing.properties,
|
|
&node.properties,
|
|
steps,
|
|
);
|
|
plan_constraints(
|
|
SchemaTypeKind::Node,
|
|
&node.name,
|
|
&existing.constraints,
|
|
&node.constraints,
|
|
&property_renames,
|
|
steps,
|
|
);
|
|
}
|
|
|
|
for leftover in accepted
|
|
.iter()
|
|
.filter(|node| !consumed.contains(&node.name))
|
|
{
|
|
// Node type removed from the desired schema: emit
|
|
// DropType { Node, Soft } per docs/dev/schema-lint-v1-plan.md
|
|
// commit #4. Soft = remove the table's entry from the current
|
|
// __manifest version; data files retained; previous manifest
|
|
// versions still reference the table, so Lance time travel
|
|
// restores it until cleanup_old_versions ages out the older
|
|
// __manifest entries. Hard mode (immediate dataset deletion)
|
|
// lands in commit #5 gated by --allow-data-loss.
|
|
steps.push(SchemaMigrationStep::DropType {
|
|
type_kind: SchemaTypeKind::Node,
|
|
name: leftover.name.clone(),
|
|
mode: DropMode::Soft,
|
|
});
|
|
}
|
|
|
|
renames
|
|
}
|
|
|
|
fn plan_edges(
|
|
accepted: &[EdgeIR],
|
|
desired: &[EdgeIR],
|
|
node_renames: &HashMap<String, String>,
|
|
steps: &mut Vec<SchemaMigrationStep>,
|
|
) {
|
|
let accepted_by_name = accepted
|
|
.iter()
|
|
.map(|edge| (edge.name.as_str(), edge))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut consumed = HashSet::new();
|
|
|
|
for edge in desired {
|
|
let rename_from = rename_from_value(&edge.annotations);
|
|
let matched = accepted_by_name
|
|
.get(edge.name.as_str())
|
|
.copied()
|
|
.or_else(|| {
|
|
rename_from.and_then(|from| {
|
|
accepted_by_name
|
|
.get(from)
|
|
.copied()
|
|
.filter(|candidate| candidate.name != edge.name)
|
|
})
|
|
});
|
|
|
|
let Some(existing) = matched else {
|
|
if let Some(from) = rename_from {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("edge:{}", edge.name),
|
|
reason: format!(
|
|
"edge '{}' declares @rename_from(\"{}\") but no accepted edge with that name exists",
|
|
edge.name, from
|
|
),
|
|
code: None,
|
|
});
|
|
} else {
|
|
steps.push(SchemaMigrationStep::AddType {
|
|
type_kind: SchemaTypeKind::Edge,
|
|
name: edge.name.clone(),
|
|
});
|
|
}
|
|
continue;
|
|
};
|
|
|
|
consumed.insert(existing.name.clone());
|
|
if existing.name != edge.name {
|
|
steps.push(SchemaMigrationStep::RenameType {
|
|
type_kind: SchemaTypeKind::Edge,
|
|
from: existing.name.clone(),
|
|
to: edge.name.clone(),
|
|
});
|
|
}
|
|
|
|
let normalized_from = normalize_type_ref(&existing.from_type, node_renames);
|
|
let normalized_to = normalize_type_ref(&existing.to_type, node_renames);
|
|
if normalized_from != edge.from_type || normalized_to != edge.to_type {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("edge:{}", edge.name),
|
|
reason: format!(
|
|
"changing edge endpoints on '{}' is not supported in schema migration v1",
|
|
edge.name
|
|
),
|
|
code: None,
|
|
});
|
|
}
|
|
if existing.cardinality != edge.cardinality {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("edge:{}", edge.name),
|
|
reason: format!(
|
|
"changing cardinality on edge '{}' is not supported in schema migration v1",
|
|
edge.name
|
|
),
|
|
code: None,
|
|
});
|
|
}
|
|
|
|
plan_type_metadata(
|
|
SchemaTypeKind::Edge,
|
|
&edge.name,
|
|
&existing.annotations,
|
|
&edge.annotations,
|
|
steps,
|
|
);
|
|
let property_renames = plan_properties(
|
|
SchemaTypeKind::Edge,
|
|
&edge.name,
|
|
&existing.properties,
|
|
&edge.properties,
|
|
steps,
|
|
);
|
|
plan_constraints(
|
|
SchemaTypeKind::Edge,
|
|
&edge.name,
|
|
&existing.constraints,
|
|
&edge.constraints,
|
|
&property_renames,
|
|
steps,
|
|
);
|
|
}
|
|
|
|
for leftover in accepted
|
|
.iter()
|
|
.filter(|edge| !consumed.contains(&edge.name))
|
|
{
|
|
// Edge type removed from the desired schema: emit
|
|
// DropType { Edge, Soft } per docs/dev/schema-lint-v1-plan.md
|
|
// commit #4. Same Soft mechanics as node-type drops — manifest
|
|
// entry tombstoned, data files retained, reversible via Lance
|
|
// time travel until cleanup.
|
|
steps.push(SchemaMigrationStep::DropType {
|
|
type_kind: SchemaTypeKind::Edge,
|
|
name: leftover.name.clone(),
|
|
mode: DropMode::Soft,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn plan_properties(
|
|
type_kind: SchemaTypeKind,
|
|
type_name: &str,
|
|
accepted: &[PropertyIR],
|
|
desired: &[PropertyIR],
|
|
steps: &mut Vec<SchemaMigrationStep>,
|
|
) -> HashMap<String, String> {
|
|
let accepted_by_name = accepted
|
|
.iter()
|
|
.map(|property| (property.name.as_str(), property))
|
|
.collect::<HashMap<_, _>>();
|
|
let mut consumed = HashSet::new();
|
|
let mut renames = HashMap::new();
|
|
|
|
for property in desired {
|
|
let rename_from = rename_from_value(&property.annotations);
|
|
let matched = accepted_by_name
|
|
.get(property.name.as_str())
|
|
.copied()
|
|
.or_else(|| {
|
|
rename_from.and_then(|from| {
|
|
accepted_by_name
|
|
.get(from)
|
|
.copied()
|
|
.filter(|candidate| candidate.name != property.name)
|
|
})
|
|
});
|
|
|
|
let Some(existing) = matched else {
|
|
if let Some(from) = rename_from {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!(
|
|
"{}:{}.{}",
|
|
schema_type_kind_key(type_kind),
|
|
type_name,
|
|
property.name
|
|
),
|
|
reason: format!(
|
|
"property '{}.{}' declares @rename_from(\"{}\") but no accepted property with that name exists",
|
|
type_name, property.name, from
|
|
),
|
|
code: None,
|
|
});
|
|
} else if property.prop_type.nullable {
|
|
steps.push(SchemaMigrationStep::AddProperty {
|
|
type_kind,
|
|
type_name: type_name.to_string(),
|
|
property_name: property.name.clone(),
|
|
property_type: property.prop_type.clone(),
|
|
});
|
|
} else {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!(
|
|
"{}:{}.{}",
|
|
schema_type_kind_key(type_kind),
|
|
type_name,
|
|
property.name
|
|
),
|
|
reason: format!(
|
|
"adding required property '{}.{}' requires a backfill and is not supported in schema migration v1",
|
|
type_name, property.name
|
|
),
|
|
code: Some(crate::lint::codes::OG_MF_103.code.to_string()),
|
|
});
|
|
}
|
|
continue;
|
|
};
|
|
|
|
consumed.insert(existing.name.clone());
|
|
if existing.name != property.name {
|
|
renames.insert(existing.name.clone(), property.name.clone());
|
|
steps.push(SchemaMigrationStep::RenameProperty {
|
|
type_kind,
|
|
type_name: type_name.to_string(),
|
|
from: existing.name.clone(),
|
|
to: property.name.clone(),
|
|
});
|
|
}
|
|
|
|
if existing.prop_type != property.prop_type {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!(
|
|
"{}:{}.{}",
|
|
schema_type_kind_key(type_kind),
|
|
type_name,
|
|
property.name
|
|
),
|
|
reason: format!(
|
|
"changing property type for '{}.{}' is not supported in schema migration v1",
|
|
type_name, property.name
|
|
),
|
|
code: Some(crate::lint::codes::OG_MF_106.code.to_string()),
|
|
});
|
|
}
|
|
|
|
plan_property_metadata(
|
|
type_kind,
|
|
type_name,
|
|
&property.name,
|
|
&existing.annotations,
|
|
&property.annotations,
|
|
steps,
|
|
);
|
|
}
|
|
|
|
for leftover in accepted
|
|
.iter()
|
|
.filter(|property| !consumed.contains(&property.name))
|
|
{
|
|
// Property removed from the desired schema: emit
|
|
// DropProperty { Soft } per docs/schema-lint-v1-plan.md
|
|
// commit #3. The Soft mode reuses the existing
|
|
// stage_overwrite rewrite path — batch_for_schema_apply_rewrite
|
|
// iterates target_schema.fields(), so the dropped column is
|
|
// naturally projected away. The prior Lance version retains
|
|
// the column until cleanup_old_versions runs, matching the
|
|
// OG-DS-104 destructive-tier expectation that data remains
|
|
// recoverable via time travel until cleanup. Hard mode (with
|
|
// immediate compact_files + cleanup_old_versions) lands in
|
|
// commit #5, gated by --allow-data-loss.
|
|
steps.push(SchemaMigrationStep::DropProperty {
|
|
type_kind,
|
|
type_name: type_name.to_string(),
|
|
property_name: leftover.name.clone(),
|
|
mode: DropMode::Soft,
|
|
});
|
|
}
|
|
|
|
renames
|
|
}
|
|
|
|
fn plan_constraints(
|
|
type_kind: SchemaTypeKind,
|
|
type_name: &str,
|
|
accepted: &[Constraint],
|
|
desired: &[Constraint],
|
|
property_renames: &HashMap<String, String>,
|
|
steps: &mut Vec<SchemaMigrationStep>,
|
|
) {
|
|
let accepted = accepted
|
|
.iter()
|
|
.cloned()
|
|
.map(|constraint| rename_constraint_properties(constraint, property_renames))
|
|
.collect::<Vec<_>>();
|
|
let desired_map = desired
|
|
.iter()
|
|
.cloned()
|
|
.map(|constraint| (constraint_key(&constraint), constraint))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let accepted_map = accepted
|
|
.into_iter()
|
|
.map(|constraint| (constraint_key(&constraint), constraint))
|
|
.collect::<BTreeMap<_, _>>();
|
|
|
|
let removed = accepted_map
|
|
.keys()
|
|
.filter(|key| !desired_map.contains_key(*key))
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
if !removed.is_empty() {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("{}:{}", schema_type_kind_key(type_kind), type_name),
|
|
reason: format!(
|
|
"removing constraints from '{}' is not supported in schema migration v1",
|
|
type_name
|
|
),
|
|
code: None,
|
|
});
|
|
}
|
|
|
|
for (key, constraint) in desired_map {
|
|
if accepted_map.contains_key(&key) {
|
|
continue;
|
|
}
|
|
match constraint {
|
|
Constraint::Index(_) => steps.push(SchemaMigrationStep::AddConstraint {
|
|
type_kind,
|
|
type_name: type_name.to_string(),
|
|
constraint,
|
|
}),
|
|
_ => steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("{}:{}", schema_type_kind_key(type_kind), type_name),
|
|
reason: format!(
|
|
"adding constraint '{}' to '{}' is not supported in schema migration v1",
|
|
key, type_name
|
|
),
|
|
code: None,
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn plan_type_metadata(
|
|
type_kind: SchemaTypeKind,
|
|
name: &str,
|
|
accepted: &[Annotation],
|
|
desired: &[Annotation],
|
|
steps: &mut Vec<SchemaMigrationStep>,
|
|
) {
|
|
match annotation_change_kind(accepted, desired) {
|
|
AnnotationChangeKind::None => {}
|
|
AnnotationChangeKind::MetadataOnly(metadata) => {
|
|
steps.push(SchemaMigrationStep::UpdateTypeMetadata {
|
|
type_kind,
|
|
name: name.to_string(),
|
|
annotations: metadata,
|
|
});
|
|
}
|
|
AnnotationChangeKind::Unsupported(reason) => {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!("{}:{}", schema_type_kind_key(type_kind), name),
|
|
reason,
|
|
code: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
fn plan_property_metadata(
|
|
type_kind: SchemaTypeKind,
|
|
type_name: &str,
|
|
property_name: &str,
|
|
accepted: &[Annotation],
|
|
desired: &[Annotation],
|
|
steps: &mut Vec<SchemaMigrationStep>,
|
|
) {
|
|
match annotation_change_kind(accepted, desired) {
|
|
AnnotationChangeKind::None => {}
|
|
AnnotationChangeKind::MetadataOnly(metadata) => {
|
|
steps.push(SchemaMigrationStep::UpdatePropertyMetadata {
|
|
type_kind,
|
|
type_name: type_name.to_string(),
|
|
property_name: property_name.to_string(),
|
|
annotations: metadata,
|
|
});
|
|
}
|
|
AnnotationChangeKind::Unsupported(reason) => {
|
|
steps.push(SchemaMigrationStep::UnsupportedChange {
|
|
entity: format!(
|
|
"{}:{}.{}",
|
|
schema_type_kind_key(type_kind),
|
|
type_name,
|
|
property_name
|
|
),
|
|
reason,
|
|
code: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
enum AnnotationChangeKind {
|
|
None,
|
|
MetadataOnly(Vec<Annotation>),
|
|
Unsupported(String),
|
|
}
|
|
|
|
fn annotation_change_kind(accepted: &[Annotation], desired: &[Annotation]) -> AnnotationChangeKind {
|
|
let accepted_non_metadata = strip_metadata_annotations(accepted);
|
|
let desired_non_metadata = strip_metadata_annotations(desired);
|
|
if accepted_non_metadata != desired_non_metadata {
|
|
return AnnotationChangeKind::Unsupported(
|
|
"changing annotations beyond @description/@instruction is not supported in schema migration v1"
|
|
.to_string(),
|
|
);
|
|
}
|
|
|
|
let accepted_metadata = metadata_annotations(accepted);
|
|
let desired_metadata = metadata_annotations(desired);
|
|
if accepted_metadata == desired_metadata {
|
|
AnnotationChangeKind::None
|
|
} else {
|
|
AnnotationChangeKind::MetadataOnly(desired_metadata)
|
|
}
|
|
}
|
|
|
|
fn strip_metadata_annotations(annotations: &[Annotation]) -> Vec<Annotation> {
|
|
annotations
|
|
.iter()
|
|
.filter(|annotation| {
|
|
!matches!(
|
|
annotation.name.as_str(),
|
|
"description" | "instruction" | "rename_from" | "key" | "unique" | "index"
|
|
)
|
|
})
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn metadata_annotations(annotations: &[Annotation]) -> Vec<Annotation> {
|
|
annotations
|
|
.iter()
|
|
.filter(|annotation| matches!(annotation.name.as_str(), "description" | "instruction"))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
|
|
fn normalize_strings(values: &[String], renames: &HashMap<String, String>) -> BTreeSet<String> {
|
|
values
|
|
.iter()
|
|
.map(|value| normalize_type_ref(value, renames))
|
|
.collect()
|
|
}
|
|
|
|
fn normalize_type_ref(value: &str, renames: &HashMap<String, String>) -> String {
|
|
renames
|
|
.get(value)
|
|
.cloned()
|
|
.unwrap_or_else(|| value.to_string())
|
|
}
|
|
|
|
fn rename_constraint_properties(
|
|
constraint: Constraint,
|
|
property_renames: &HashMap<String, String>,
|
|
) -> Constraint {
|
|
match constraint {
|
|
Constraint::Key(columns) => {
|
|
Constraint::Key(rename_constraint_columns(columns, property_renames))
|
|
}
|
|
Constraint::Unique(columns) => {
|
|
Constraint::Unique(rename_constraint_columns(columns, property_renames))
|
|
}
|
|
Constraint::Index(columns) => {
|
|
Constraint::Index(rename_constraint_columns(columns, property_renames))
|
|
}
|
|
Constraint::Range { property, min, max } => Constraint::Range {
|
|
property: normalize_property_ref(&property, property_renames),
|
|
min,
|
|
max,
|
|
},
|
|
Constraint::Check { property, pattern } => Constraint::Check {
|
|
property: normalize_property_ref(&property, property_renames),
|
|
pattern,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn rename_constraint_columns(
|
|
columns: Vec<String>,
|
|
property_renames: &HashMap<String, String>,
|
|
) -> Vec<String> {
|
|
let mut columns = columns
|
|
.into_iter()
|
|
.map(|column| normalize_property_ref(&column, property_renames))
|
|
.collect::<Vec<_>>();
|
|
columns.sort();
|
|
columns
|
|
}
|
|
|
|
fn normalize_property_ref(value: &str, renames: &HashMap<String, String>) -> String {
|
|
renames
|
|
.get(value)
|
|
.cloned()
|
|
.unwrap_or_else(|| value.to_string())
|
|
}
|
|
|
|
fn constraint_key(constraint: &Constraint) -> String {
|
|
match constraint {
|
|
Constraint::Key(columns) => format!("key:{}", columns.join(",")),
|
|
Constraint::Unique(columns) => format!("unique:{}", columns.join(",")),
|
|
Constraint::Index(columns) => format!("index:{}", columns.join(",")),
|
|
Constraint::Range { property, min, max } => {
|
|
format!("range:{}:{:?}:{:?}", property, min, max)
|
|
}
|
|
Constraint::Check { property, pattern } => format!("check:{}:{}", property, pattern),
|
|
}
|
|
}
|
|
|
|
fn rename_from_value(annotations: &[Annotation]) -> Option<&str> {
|
|
annotations
|
|
.iter()
|
|
.find(|annotation| annotation.name == "rename_from")
|
|
.and_then(|annotation| annotation.value.as_deref())
|
|
}
|
|
|
|
fn schema_type_kind_key(kind: SchemaTypeKind) -> &'static str {
|
|
match kind {
|
|
SchemaTypeKind::Interface => "interface",
|
|
SchemaTypeKind::Node => "node",
|
|
SchemaTypeKind::Edge => "edge",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::catalog::schema_ir::build_schema_ir;
|
|
use crate::schema::parser::parse_schema;
|
|
|
|
use super::SchemaMigrationStep::{
|
|
AddConstraint, AddProperty, RenameProperty, RenameType, UnsupportedChange,
|
|
UpdateTypeMetadata,
|
|
};
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn plan_supports_additive_nullable_property_and_index() {
|
|
let accepted = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
age: I32?
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let desired = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
age: I32? @index
|
|
nickname: String?
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let plan = plan_schema_migration(&accepted, &desired).unwrap();
|
|
assert!(plan.supported);
|
|
assert!(plan.steps.contains(&AddProperty {
|
|
type_kind: SchemaTypeKind::Node,
|
|
type_name: "Person".to_string(),
|
|
property_name: "nickname".to_string(),
|
|
property_type: PropType::scalar(crate::types::ScalarType::String, true),
|
|
}));
|
|
assert!(plan.steps.contains(&AddConstraint {
|
|
type_kind: SchemaTypeKind::Node,
|
|
type_name: "Person".to_string(),
|
|
constraint: Constraint::Index(vec!["age".to_string()]),
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn plan_supports_explicit_type_and_property_rename() {
|
|
let accepted = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node User {
|
|
name: String @key
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let desired = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Account @rename_from("User") {
|
|
full_name: String @key @rename_from("name")
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let plan = plan_schema_migration(&accepted, &desired).unwrap();
|
|
assert!(plan.supported);
|
|
assert!(plan.steps.contains(&RenameType {
|
|
type_kind: SchemaTypeKind::Node,
|
|
from: "User".to_string(),
|
|
to: "Account".to_string(),
|
|
}));
|
|
assert!(plan.steps.contains(&RenameProperty {
|
|
type_kind: SchemaTypeKind::Node,
|
|
type_name: "Account".to_string(),
|
|
from: "name".to_string(),
|
|
to: "full_name".to_string(),
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn plan_emits_soft_drop_for_removed_nullable_property() {
|
|
// Removing a property from the desired schema emits
|
|
// DropProperty { Soft } (schema-lint v1 chassis commit #3,
|
|
// MR-694). The plan is `supported = true` — the apply path
|
|
// handles soft drop via the existing stage_overwrite rewrite
|
|
// projection. Verified at the integration level by
|
|
// `apply_schema_drops_a_nullable_property_softly_preserves_prior_version`
|
|
// in `crates/omnigraph/tests/schema_apply.rs`.
|
|
let accepted = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
age: I32?
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let desired = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let plan = plan_schema_migration(&accepted, &desired).unwrap();
|
|
assert!(
|
|
plan.supported,
|
|
"drop-property plan must be supported: {plan:?}"
|
|
);
|
|
assert!(
|
|
plan.steps.iter().any(|step| matches!(
|
|
step,
|
|
SchemaMigrationStep::DropProperty {
|
|
type_kind: SchemaTypeKind::Node,
|
|
type_name,
|
|
property_name,
|
|
mode: DropMode::Soft,
|
|
..
|
|
} if type_name == "Person" && property_name == "age"
|
|
)),
|
|
"expected DropProperty {{ Soft }} step in plan: {plan:?}",
|
|
);
|
|
// Negative: no UnsupportedChange anywhere in the plan.
|
|
assert!(
|
|
!plan
|
|
.steps
|
|
.iter()
|
|
.any(|step| matches!(step, UnsupportedChange { .. })),
|
|
"soft drop must not emit UnsupportedChange: {plan:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_emits_soft_drop_for_removed_node_and_edge_types() {
|
|
// Removing a node type + the edge type that references it
|
|
// emits two DropType { Soft } steps (chassis v1 commit #4,
|
|
// MR-694). The plan is `supported = true` — apply tombstones
|
|
// both manifest entries. Time-travel reversibility is verified
|
|
// at the integration level by
|
|
// `apply_schema_drops_node_and_referencing_edge_softly`
|
|
// in `crates/omnigraph/tests/schema_apply.rs`.
|
|
let accepted = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
}
|
|
|
|
node Company {
|
|
name: String @key
|
|
}
|
|
|
|
edge WorksAt: Person -> Company
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let desired = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let plan = plan_schema_migration(&accepted, &desired).unwrap();
|
|
assert!(plan.supported, "drop-type plan must be supported: {plan:?}");
|
|
assert!(
|
|
plan.steps.iter().any(|step| matches!(
|
|
step,
|
|
SchemaMigrationStep::DropType {
|
|
type_kind: SchemaTypeKind::Node,
|
|
name,
|
|
mode: DropMode::Soft,
|
|
} if name == "Company"
|
|
)),
|
|
"expected DropType {{ Node, Company, Soft }} in plan: {plan:?}",
|
|
);
|
|
assert!(
|
|
plan.steps.iter().any(|step| matches!(
|
|
step,
|
|
SchemaMigrationStep::DropType {
|
|
type_kind: SchemaTypeKind::Edge,
|
|
name,
|
|
mode: DropMode::Soft,
|
|
} if name == "WorksAt"
|
|
)),
|
|
"expected DropType {{ Edge, WorksAt, Soft }} in plan: {plan:?}",
|
|
);
|
|
// Negative: no UnsupportedChange anywhere in the plan.
|
|
assert!(
|
|
!plan
|
|
.steps
|
|
.iter()
|
|
.any(|step| matches!(step, UnsupportedChange { .. })),
|
|
"soft type drop must not emit UnsupportedChange: {plan:?}",
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plan_rejects_required_property_addition() {
|
|
let accepted = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let desired = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person {
|
|
name: String @key
|
|
age: I32
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let plan = plan_schema_migration(&accepted, &desired).unwrap();
|
|
assert!(!plan.supported);
|
|
assert!(plan.steps.iter().any(|step| matches!(
|
|
step,
|
|
UnsupportedChange { entity, code, .. }
|
|
if entity.contains("Person.age")
|
|
&& code.as_deref() == Some(crate::lint::codes::OG_MF_103.code)
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn plan_supports_metadata_only_annotation_changes() {
|
|
let accepted = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person @description("old") {
|
|
name: String @key
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
let desired = build_schema_ir(
|
|
&parse_schema(
|
|
r#"
|
|
node Person @description("new") {
|
|
name: String @key
|
|
}
|
|
"#,
|
|
)
|
|
.unwrap(),
|
|
)
|
|
.unwrap();
|
|
|
|
let plan = plan_schema_migration(&accepted, &desired).unwrap();
|
|
assert!(plan.supported);
|
|
assert!(plan.steps.contains(&UpdateTypeMetadata {
|
|
type_kind: SchemaTypeKind::Node,
|
|
name: "Person".to_string(),
|
|
annotations: vec![Annotation {
|
|
name: "description".to_string(),
|
|
value: Some("new".to_string()),
|
|
kwargs: Default::default(),
|
|
}],
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn drop_steps_round_trip_through_serde() {
|
|
// The DropType / DropProperty variants are dormant in this
|
|
// commit — the planner doesn't emit them yet — but their
|
|
// serde shape needs to be stable from day one. A future
|
|
// SchemaIR JSON containing one of these must deserialize
|
|
// back to the same value. This test pins the wire format
|
|
// so a v0 schema-ir consumer never sees a surprise variant
|
|
// shape after v1 ships.
|
|
let steps = vec![
|
|
SchemaMigrationStep::DropType {
|
|
type_kind: SchemaTypeKind::Node,
|
|
name: "Person".to_string(),
|
|
mode: DropMode::Soft,
|
|
},
|
|
SchemaMigrationStep::DropType {
|
|
type_kind: SchemaTypeKind::Edge,
|
|
name: "Knows".to_string(),
|
|
mode: DropMode::Hard,
|
|
},
|
|
SchemaMigrationStep::DropProperty {
|
|
type_kind: SchemaTypeKind::Node,
|
|
type_name: "Person".to_string(),
|
|
property_name: "age".to_string(),
|
|
mode: DropMode::Soft,
|
|
},
|
|
SchemaMigrationStep::DropProperty {
|
|
type_kind: SchemaTypeKind::Interface,
|
|
type_name: "Named".to_string(),
|
|
property_name: "alias".to_string(),
|
|
mode: DropMode::Hard,
|
|
},
|
|
];
|
|
|
|
for step in steps {
|
|
let json = serde_json::to_string(&step).expect("serialize");
|
|
let round_trip: SchemaMigrationStep = serde_json::from_str(&json).expect("deserialize");
|
|
assert_eq!(step, round_trip, "round-trip mismatch on {json}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn drop_mode_serde_uses_snake_case() {
|
|
// External tools may write SchemaIR JSON by hand. Pin the
|
|
// wire form so we don't silently break them later.
|
|
assert_eq!(serde_json::to_string(&DropMode::Soft).unwrap(), "\"soft\"");
|
|
assert_eq!(serde_json::to_string(&DropMode::Hard).unwrap(), "\"hard\"");
|
|
let soft: DropMode = serde_json::from_str("\"soft\"").unwrap();
|
|
assert_eq!(soft, DropMode::Soft);
|
|
}
|
|
}
|