use serde::{Deserialize, Serialize};
use type_bridge_orm::schema::info::{
AttributeSchemaEntry, EntitySchemaEntry, OwnedAttributeEntry, RelationSchemaEntry, RoleEntry,
SchemaInfo,
};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MigrationDependencySpec {
pub app_label: String,
pub migration_name: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MigrationSpec {
pub app_label: String,
pub name: String,
#[serde(default)]
pub dependencies: Vec<MigrationDependencySpec>,
#[serde(default)]
pub operations: Vec<OperationSpec>,
#[serde(default)]
pub checksum: Option<String>,
pub reversible: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MigrationGraph {
pub migrations: Vec<MigrationSpec>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum OperationSpec {
DefineSchema {
schema: SchemaInfo,
},
AddAttribute {
attribute: AttributeSchemaEntry,
},
RemoveAttribute {
attr_name: String,
},
AddEntity {
entity: EntitySchemaEntry,
},
RemoveEntity {
type_name: String,
},
AddRelation {
relation: RelationSchemaEntry,
},
RemoveRelation {
type_name: String,
},
AddOwnership {
owner_type: String,
attribute: OwnedAttributeEntry,
},
RemoveOwnership {
owner_type: String,
attr_name: String,
},
ModifyOwnership {
owner_type: String,
attr_name: String,
old_annotations: String,
new_annotations: String,
},
ModifyTypeAnnotations {
type_name: String,
#[serde(default)]
old_doc: Option<String>,
#[serde(default)]
new_doc: Option<String>,
#[serde(default)]
old_meta: std::collections::BTreeMap<String, String>,
#[serde(default)]
new_meta: std::collections::BTreeMap<String, String>,
},
ModifyRoleAnnotations {
relation_type: String,
role_name: String,
#[serde(default)]
old_doc: Option<String>,
#[serde(default)]
new_doc: Option<String>,
#[serde(default)]
old_meta: std::collections::BTreeMap<String, String>,
#[serde(default)]
new_meta: std::collections::BTreeMap<String, String>,
},
AddRole {
relation_type: String,
role: RoleEntry,
},
RemoveRole {
relation_type: String,
role_name: String,
},
AddRolePlayer {
relation_type: String,
role_name: String,
player_type_name: String,
},
RemoveRolePlayer {
relation_type: String,
role_name: String,
player_type_name: String,
},
RunTypeql {
forward: String,
#[serde(default)]
reverse: Option<String>,
},
RenameAttribute {
old_name: String,
new_name: String,
value_type: String,
},
CopyAttribute {
#[serde(default, skip_serializing_if = "Option::is_none")]
owner: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
dest: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
filter: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
forward: Option<String>,
#[serde(default)]
reverse: Option<String>,
},
}
pub fn copy_attribute_typeql(op: &OperationSpec) -> crate::Result<(String, Option<String>)> {
let OperationSpec::CopyAttribute {
owner,
source,
dest,
filter,
forward,
reverse,
} = op
else {
return Err(crate::error::MigrationError::AuthoringInput {
message: "copy_attribute_typeql called on a non-copy_attribute operation".to_string(),
});
};
if let Some(forward) = forward {
return Ok((forward.clone(), reverse.clone()));
}
let (Some(owner), Some(source), Some(dest)) = (owner, source, dest) else {
return Err(crate::error::MigrationError::AuthoringInput {
message: "copy_attribute requires either forward TypeQL or the structured \
owner/source/dest fields"
.to_string(),
});
};
let filter_line = match filter {
Some(filter) => format!("\n {filter};"),
None => String::new(),
};
let synthesized_forward = format!(
"match\n $x isa {owner}, has {source} $v;\n not {{ $x has {dest} $d; }};{filter_line}\ninsert\n $x has {dest} == $v;"
);
let synthesized_reverse = format!("match $x isa {owner}, has {dest} $v;\ndelete $v of $x;");
Ok((
synthesized_forward,
Some(reverse.clone().unwrap_or(synthesized_reverse)),
))
}
impl OperationSpec {
pub fn normalized(self) -> crate::Result<OperationSpec> {
if !matches!(self, OperationSpec::CopyAttribute { .. }) {
return Ok(self);
}
let (forward, reverse) = copy_attribute_typeql(&self)?;
let OperationSpec::CopyAttribute {
owner,
source,
dest,
filter,
..
} = self
else {
unreachable!("guarded by the matches! check above");
};
Ok(OperationSpec::CopyAttribute {
owner,
source,
dest,
filter,
forward: Some(forward),
reverse,
})
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use type_bridge_orm::Annotation;
use type_bridge_orm::ValueType;
fn attribute(name: &str) -> AttributeSchemaEntry {
AttributeSchemaEntry::new(name, ValueType::String)
}
fn schema() -> SchemaInfo {
let mut schema = SchemaInfo::default();
schema
.attributes
.insert("name".to_string(), attribute("name"));
schema.entities.insert(
"person".to_string(),
EntitySchemaEntry {
type_name: "person".to_string(),
is_abstract: false,
parent_type: None,
owned_attributes: vec![OwnedAttributeEntry {
attr_name: "name".to_string(),
value_type: ValueType::String,
annotations: vec![Annotation::Key],
is_ordered: false,
doc: None,
meta: Default::default(),
}],
plays_cardinalities: BTreeMap::new(),
doc: None,
meta: Default::default(),
},
);
schema
}
#[test]
fn define_schema_operation_round_trips_json() {
let operation = OperationSpec::DefineSchema { schema: schema() };
let json = serde_json::to_string(&operation).unwrap();
assert!(json.contains("\"kind\":\"define_schema\""));
let parsed: OperationSpec = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, operation);
}
#[test]
fn schema_bearing_operation_round_trips_json() {
let operation = OperationSpec::AddOwnership {
owner_type: "person".to_string(),
attribute: OwnedAttributeEntry {
attr_name: "name".to_string(),
value_type: ValueType::String,
annotations: vec![Annotation::Key],
is_ordered: false,
doc: None,
meta: Default::default(),
},
};
let parsed: OperationSpec =
serde_json::from_str(&serde_json::to_string(&operation).unwrap()).unwrap();
assert_eq!(parsed, operation);
}
#[test]
fn run_typeql_operation_round_trips_json() {
let operation = OperationSpec::RunTypeql {
forward: "define attribute nickname, value string;".to_string(),
reverse: Some("undefine attribute nickname;".to_string()),
};
let json = serde_json::to_value(&operation).unwrap();
assert_eq!(json["kind"], "run_typeql");
let parsed: OperationSpec = serde_json::from_value(json).unwrap();
assert_eq!(parsed, operation);
}
fn lowered_copy_attribute(forward: &str, reverse: Option<&str>) -> OperationSpec {
OperationSpec::CopyAttribute {
owner: None,
source: None,
dest: None,
filter: None,
forward: Some(forward.to_string()),
reverse: reverse.map(str::to_string),
}
}
fn structured_copy_attribute(filter: Option<&str>) -> OperationSpec {
OperationSpec::CopyAttribute {
owner: Some("person".to_string()),
source: Some("old-name".to_string()),
dest: Some("new-name".to_string()),
filter: filter.map(str::to_string),
forward: None,
reverse: None,
}
}
#[test]
fn copy_attribute_operation_round_trips_json() {
let operation = lowered_copy_attribute(
"match\n $x isa person, has old-name $v;\n not { $x has new-name $d; };\ninsert\n $x has new-name == $v;",
Some("match $x isa person, has new-name $v;\ndelete $v of $x;"),
);
let json = serde_json::to_value(&operation).unwrap();
assert_eq!(json["kind"], "copy_attribute");
assert!(
json["forward"]
.as_str()
.unwrap()
.contains("has new-name == $v")
);
assert!(json.get("owner").is_none());
let parsed: OperationSpec = serde_json::from_value(json).unwrap();
assert_eq!(parsed, operation);
}
#[test]
fn copy_attribute_without_reverse_round_trips_json() {
let operation = lowered_copy_attribute(
"match\n $x isa company, has legacy-id $v;\n not { $x has new-id $d; };\ninsert\n $x has new-id == $v;",
None,
);
let json = serde_json::to_value(&operation).unwrap();
assert_eq!(json["kind"], "copy_attribute");
let parsed: OperationSpec = serde_json::from_value(json).unwrap();
assert_eq!(parsed, operation);
}
#[test]
fn legacy_copy_attribute_sidecar_json_still_parses() {
let json = r#"{"kind":"copy_attribute","forward":"match ...;","reverse":null}"#;
let parsed: OperationSpec = serde_json::from_str(json).unwrap();
assert_eq!(parsed, lowered_copy_attribute("match ...;", None));
}
#[test]
fn structured_copy_attribute_round_trips_json() {
let operation = structured_copy_attribute(Some("$x has age $a;"));
let json = serde_json::to_value(&operation).unwrap();
assert_eq!(json["kind"], "copy_attribute");
assert_eq!(json["owner"], "person");
assert!(json.get("forward").is_none());
let parsed: OperationSpec = serde_json::from_value(json).unwrap();
assert_eq!(parsed, operation);
}
#[test]
fn structured_copy_attribute_synthesizes_python_shaped_typeql() {
let (forward, reverse) = copy_attribute_typeql(&structured_copy_attribute(None)).unwrap();
assert_eq!(
forward,
"match\n $x isa person, has old-name $v;\n not { $x has new-name $d; };\ninsert\n $x has new-name == $v;"
);
assert_eq!(
reverse.as_deref(),
Some("match $x isa person, has new-name $v;\ndelete $v of $x;")
);
}
#[test]
fn structured_copy_attribute_synthesizes_filter_line() {
let (forward, _) =
copy_attribute_typeql(&structured_copy_attribute(Some("$x has age $a"))).unwrap();
assert_eq!(
forward,
"match\n $x isa person, has old-name $v;\n not { $x has new-name $d; };\n $x has age $a;\ninsert\n $x has new-name == $v;"
);
}
#[test]
fn carried_typeql_wins_over_structured_fields() {
let operation = OperationSpec::CopyAttribute {
owner: Some("person".to_string()),
source: Some("old-name".to_string()),
dest: Some("new-name".to_string()),
filter: None,
forward: Some("match carried;".to_string()),
reverse: Some("match carried-reverse;".to_string()),
};
let (forward, reverse) = copy_attribute_typeql(&operation).unwrap();
assert_eq!(forward, "match carried;");
assert_eq!(reverse.as_deref(), Some("match carried-reverse;"));
}
#[test]
fn copy_attribute_without_typeql_or_fields_is_rejected() {
let operation = OperationSpec::CopyAttribute {
owner: Some("person".to_string()),
source: None,
dest: Some("new-name".to_string()),
filter: None,
forward: None,
reverse: None,
};
let error = copy_attribute_typeql(&operation).unwrap_err();
assert!(matches!(
error,
crate::error::MigrationError::AuthoringInput { .. }
));
}
#[test]
fn normalized_fills_structured_copy_attribute_and_passes_others_through() {
let normalized = structured_copy_attribute(None).normalized().unwrap();
let OperationSpec::CopyAttribute {
owner,
forward,
reverse,
..
} = &normalized
else {
panic!("normalized must stay a copy_attribute");
};
assert_eq!(owner.as_deref(), Some("person"));
assert!(forward.as_deref().unwrap().contains("has new-name == $v"));
assert!(reverse.as_deref().unwrap().contains("delete $v of $x"));
let passthrough = OperationSpec::RunTypeql {
forward: "match $x isa person;".to_string(),
reverse: None,
};
assert_eq!(passthrough.clone().normalized().unwrap(), passthrough);
}
#[test]
fn graph_preserves_spec_order() {
let graph = MigrationGraph {
migrations: vec![
MigrationSpec {
app_label: "app".to_string(),
name: "0001_initial".to_string(),
dependencies: vec![],
operations: vec![OperationSpec::DefineSchema { schema: schema() }],
checksum: Some("aaa".to_string()),
reversible: true,
},
MigrationSpec {
app_label: "app".to_string(),
name: "0002_custom".to_string(),
dependencies: vec![MigrationDependencySpec {
app_label: "app".to_string(),
migration_name: "0001_initial".to_string(),
}],
operations: vec![OperationSpec::RunTypeql {
forward: "define attribute nickname, value string;".to_string(),
reverse: None,
}],
checksum: Some("bbb".to_string()),
reversible: false,
},
],
};
let parsed: MigrationGraph =
serde_json::from_str(&serde_json::to_string(&graph).unwrap()).unwrap();
assert_eq!(parsed.migrations[0].name, "0001_initial");
assert_eq!(parsed.migrations[1].name, "0002_custom");
assert_eq!(parsed, graph);
}
#[test]
fn annotation_operations_round_trip_json() {
let operations = vec![
OperationSpec::ModifyTypeAnnotations {
type_name: "person".to_string(),
old_doc: None,
new_doc: Some("A person.".to_string()),
old_meta: BTreeMap::new(),
new_meta: BTreeMap::from([("owner".to_string(), "core".to_string())]),
},
OperationSpec::ModifyRoleAnnotations {
relation_type: "employment".to_string(),
role_name: "employee".to_string(),
old_doc: Some("old".to_string()),
new_doc: None,
old_meta: BTreeMap::new(),
new_meta: BTreeMap::new(),
},
];
for operation in operations {
let json = serde_json::to_string(&operation).expect("serialize");
let back: OperationSpec = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, operation);
}
let json = serde_json::to_string(&OperationSpec::ModifyTypeAnnotations {
type_name: "person".to_string(),
old_doc: None,
new_doc: None,
old_meta: BTreeMap::new(),
new_meta: BTreeMap::new(),
})
.expect("serialize");
assert!(json.contains("\"kind\":\"modify_type_annotations\""));
}
}