use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use uuid::Uuid;
use crate::graph::{
StaticCard, StaticCardsXNodesXWidgets, StaticEdge, StaticGraph, StaticNode, StaticNodegroup,
StaticTranslatableString,
};
const ALIZARIN_NAMESPACE: &str = "1a79f1c8-9505-4bea-a18e-28a053f725ca";
pub fn generate_uuid_v5(group: (&str, Option<&str>), key: &str) -> String {
generate_uuid_v5_with_ns(ALIZARIN_NAMESPACE, group, key)
}
pub fn generate_uuid_v5_with_ns(
base_namespace_str: &str,
group: (&str, Option<&str>),
key: &str,
) -> String {
let namespace_str = match group.1 {
Some(id) => format!("{}/{}", group.0, id),
None => group.0.to_string(),
};
let base_namespace = Uuid::parse_str(base_namespace_str).expect("Invalid base namespace");
let namespace = Uuid::new_v5(&base_namespace, namespace_str.as_bytes());
Uuid::new_v5(&namespace, key.as_bytes()).to_string()
}
pub fn slugify(name: &str) -> String {
name.to_lowercase().replace(' ', "_")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Widget {
pub id: String,
pub name: String,
pub datatype: String,
pub default_config: serde_json::Value,
}
impl Widget {
pub fn new(id: &str, name: &str, datatype: &str, default_config_json: &str) -> Self {
Self {
id: id.to_string(),
name: name.to_string(),
datatype: datatype.to_string(),
default_config: serde_json::from_str(default_config_json)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
}
}
pub fn get_default_config(&self) -> serde_json::Value {
self.default_config.clone()
}
}
impl From<crate::registry::RegisteredWidget> for Widget {
fn from(registered: crate::registry::RegisteredWidget) -> Self {
Self {
id: registered.id,
name: registered.name,
datatype: registered.datatype,
default_config: registered.default_config,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CardComponent {
pub id: String,
pub name: String,
}
impl CardComponent {
pub fn new(id: &str, name: &str) -> Self {
Self {
id: id.to_string(),
name: name.to_string(),
}
}
}
pub const DEFAULT_CARD_COMPONENT_ID: &str = "f05e4d3a-53c1-11e8-b0ea-784f435179ea";
pub const DEFAULT_CARD_COMPONENT_NAME: &str = "Default Card";
pub fn default_card_component() -> CardComponent {
CardComponent::new(DEFAULT_CARD_COMPONENT_ID, DEFAULT_CARD_COMPONENT_NAME)
}
lazy_static::lazy_static! {
pub static ref WIDGETS: HashMap<String, Widget> = {
let mut m = HashMap::new();
m.insert("text-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000001",
"text-widget",
"string",
r#"{ "placeholder": "Enter text", "width": "100%", "maxLength": null}"#
));
m.insert("concept-select-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000002",
"concept-select-widget",
"concept",
r#"{ "placeholder": "Select an option", "options": [] }"#
));
m.insert("resource-instance-multiselect-widget".to_string(), Widget::new(
"ff3c400a-76ec-11e7-a793-784f435179ea",
"resource-instance-multiselect-widget",
"resource-instance-list",
r#"{ "placeholder": "Select an option", "options": [] }"#
));
m.insert("concept-multiselect-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000012",
"concept-multiselect-widget",
"concept-list",
r#"{ "placeholder": "Select an option", "options": [] }"#
));
m.insert("domain-select-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000015",
"domain-select-widget",
"domain-value",
r#"{ "placeholder": "Select an option" }"#
));
m.insert("domain-multiselect-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000016",
"domain-multiselect-widget",
"domain-value-list",
r#"{ "placeholder": "Select an option" }"#
));
m.insert("switch-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000003",
"switch-widget",
"boolean",
r#"{ "subtitle": "Click to switch"}"#
));
m.insert("datepicker-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000004",
"datepicker-widget",
"date",
r#"{
"placeholder": "Enter date",
"viewMode": "days",
"dateFormat": "YYYY-MM-DD",
"minDate": false,
"maxDate": false
}"#
));
m.insert("rich-text-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000005",
"rich-text-widget",
"string",
r#"{}"#
));
m.insert("radio-boolean-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000006",
"radio-boolean-widget",
"boolean",
r#"{"trueLabel": "Yes", "falseLabel": "No"}"#
));
m.insert("map-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000007",
"map-widget",
"geojson-feature-collection",
r#"{
"basemap": "streets",
"geometryTypes": [{"text":"Point", "id":"Point"}, {"text":"Line", "id":"Line"}, {"text":"Polygon", "id":"Polygon"}],
"overlayConfigs": [],
"overlayOpacity": 0.0,
"geocodeProvider": "MapzenGeocoder",
"zoom": 0,
"maxZoom": 20,
"minZoom": 0,
"centerX": 0,
"centerY": 0,
"pitch": 0.0,
"bearing": 0.0,
"geocodePlaceholder": "Search",
"geocoderVisible": true,
"featureColor": null,
"featureLineWidth": null,
"featurePointSize": null
}"#
));
m.insert("number-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000008",
"number-widget",
"number",
r#"{ "placeholder": "Enter number", "width": "100%", "min":"", "max":""}"#
));
m.insert("concept-radio-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000009",
"concept-radio-widget",
"concept",
r#"{ "options": [] }"#
));
m.insert("concept-checkbox-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000013",
"concept-checkbox-widget",
"concept-list",
r#"{ "options": [] }"#
));
m.insert("domain-radio-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000017",
"domain-radio-widget",
"domain-value",
r#"{}"#
));
m.insert("domain-checkbox-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000018",
"domain-checkbox-widget",
"domain-value-list",
r#"{}"#
));
m.insert("file-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000019",
"file-widget",
"file-list",
r#"{"acceptedFiles": "", "maxFilesize": "200"}"#
));
m.insert("urldatatype-widget".to_string(), Widget::new(
"ca0c43ff-af73-4349-bafd-53ff9f22eebd",
"urldatatype-widget",
"url",
r#"{ "placeholder": "Enter URL", "url_placeholder": "Enter URL", "url_label_placeholder": "Enter URL label" }"#
));
m.insert("resource-instance-select-widget".to_string(), Widget::new(
"31f3728c-7613-11e7-a139-784f435179ea",
"resource-instance-select-widget",
"resource-instance",
r#"{ "placeholder": "Select a resource" }"#
));
m.insert("edtf-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000010",
"edtf-widget",
"edtf",
r#"{ "placeholder": "Enter EDTF date" }"#
));
m.insert("non-localized-text-widget".to_string(), Widget::new(
"10000000-0000-0000-0000-000000000011",
"non-localized-text-widget",
"non-localized-string",
r#"{ "placeholder": "Enter text", "width": "100%" }"#
));
m
};
}
lazy_static::lazy_static! {
pub static ref WIDGET_BY_ID: HashMap<String, String> = {
WIDGETS.iter().map(|(name, w)| (w.id.clone(), name.clone())).collect()
};
}
pub fn get_widget_name_by_id(widget_id: &str) -> Option<String> {
if let Some(name) = WIDGET_BY_ID.get(widget_id) {
return Some(name.clone());
}
for name in crate::registry::registered_widgets() {
if let Some(widget) = crate::registry::get_registered_widget(&name) {
if widget.id == widget_id {
return Some(name);
}
}
}
None
}
pub fn get_default_widget_for_datatype(datatype: &str) -> Result<Widget, MutationError> {
if let Some(widget_name) = crate::registry::get_widget_for_datatype(datatype) {
if let Some(registered) = crate::registry::get_registered_widget(&widget_name) {
return Ok(Widget::from(registered));
}
return WIDGETS
.get(&widget_name)
.cloned()
.ok_or(MutationError::WidgetNotFound(widget_name));
}
let widget_name = match datatype {
"number" => "number-widget",
"string" => "text-widget",
"concept" => "concept-select-widget",
"concept-list" => "concept-multiselect-widget",
"resource-instance-list" => "resource-instance-multiselect-widget",
"domain-value" => "domain-select-widget",
"domain-value-list" => "domain-multiselect-widget",
"geojson-feature-collection" => "map-widget",
"boolean" => "switch-widget",
"date" => "datepicker-widget",
"url" => "urldatatype-widget",
"resource-instance" => "resource-instance-select-widget",
"edtf" => "edtf-widget",
"non-localized-string" => "non-localized-text-widget",
"file-list" => "file-widget",
"semantic" => return Err(MutationError::NoWidgetForDatatype(datatype.to_string())),
other => return Err(MutationError::NoWidgetForDatatype(other.to_string())),
};
WIDGETS
.get(widget_name)
.cloned()
.ok_or_else(|| MutationError::WidgetNotFound(widget_name.to_string()))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String")]
pub enum Cardinality {
One,
N,
}
impl Cardinality {
pub fn as_str(&self) -> &'static str {
match self {
Cardinality::One => "1",
Cardinality::N => "n",
}
}
}
impl From<&str> for Cardinality {
fn from(s: &str) -> Self {
match s.to_lowercase().as_str() {
"1" | "one" => Cardinality::One,
_ => Cardinality::N,
}
}
}
impl From<String> for Cardinality {
fn from(s: String) -> Self {
Cardinality::from(s.as_str())
}
}
#[derive(Debug, Clone)]
pub enum MutationError {
ParentNotFound(String),
NodeNotFound(String),
NodegroupNotFound(String),
CardNotFound(String),
CardAlreadyExists(String),
NoWidgetForDatatype(String),
WidgetNotFound(String),
JsonError(String),
AliasClash(String),
BranchHasNoRoot,
InvalidSubgraph(String),
InconsistentBranchPublication {
expected: String,
found: Option<String>,
node_id: String,
},
NoBranchNodesFound(String),
InvalidDatatype {
expected: String,
found: String,
node_id: String,
},
FunctionNotFound(String),
CannotDeleteRootNode(String),
NodeHasDependentWidgets(String),
AliasAlreadyExists(String),
InvalidConfig { alias: String, error: String },
ExtensionNotFound(String),
NoExtensionRegistry(String),
OntologyValidation(crate::ontology::OntologyValidationDetail),
Other(String),
}
impl std::fmt::Display for MutationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MutationError::ParentNotFound(alias) => write!(f, "Parent node not found: {}", alias),
MutationError::NodeNotFound(id) => write!(f, "Node not found: {}", id),
MutationError::NodegroupNotFound(id) => write!(f, "Nodegroup not found: {}", id),
MutationError::CardNotFound(ng) => write!(f, "Card not found for nodegroup: {}", ng),
MutationError::CardAlreadyExists(ng) => {
write!(f, "Nodegroup already has a card: {}", ng)
}
MutationError::NoWidgetForDatatype(dt) => {
write!(f, "No default widget for datatype: {} (is the relevant extension loaded? e.g. import alizarin_clm)", dt)
}
MutationError::WidgetNotFound(name) => write!(f, "Widget not found: {}", name),
MutationError::JsonError(msg) => write!(f, "JSON error: {}", msg),
MutationError::AliasClash(alias) => {
write!(f, "Alias already exists in target graph: {}", alias)
}
MutationError::BranchHasNoRoot => write!(f, "Branch has no root node"),
MutationError::InvalidSubgraph(msg) => write!(f, "Invalid subgraph: {}", msg),
MutationError::InconsistentBranchPublication {
expected,
found,
node_id,
} => {
write!(
f,
"Inconsistent branch publication ID at node {}: expected {}, found {:?}",
node_id, expected, found
)
}
MutationError::NoBranchNodesFound(target_id) => {
write!(f, "No branch nodes found at target: {}", target_id)
}
MutationError::InvalidDatatype {
expected,
found,
node_id,
} => {
write!(
f,
"Invalid datatype for node {}: expected {}, found {}",
node_id, expected, found
)
}
MutationError::FunctionNotFound(id) => write!(f, "Function mapping not found: {}", id),
MutationError::CannotDeleteRootNode(id) => write!(f, "Cannot delete root node: {}", id),
MutationError::NodeHasDependentWidgets(id) => {
write!(f, "Node has dependent widgets, cannot change type: {}", id)
}
MutationError::AliasAlreadyExists(alias) => {
write!(f, "Alias already exists: {}", alias)
}
MutationError::InvalidConfig { alias, error } => {
write!(f, "Invalid config for node '{}': {}", alias, error)
}
MutationError::ExtensionNotFound(name) => {
write!(f, "Extension mutation not found: {}", name)
}
MutationError::NoExtensionRegistry(name) => write!(
f,
"Extension mutation '{}' used but no registry provided",
name
),
MutationError::OntologyValidation(detail) => {
write!(f, "Ontology validation error: {}", detail)
}
MutationError::Other(msg) => write!(f, "{}", msg),
}
}
}
impl std::error::Error for MutationError {}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddNodeParams {
pub parent_alias: Option<String>,
pub alias: String,
pub name: String,
pub cardinality: Cardinality,
pub datatype: String,
#[serde(default, with = "crate::graph::serde_helpers::optional_string_or_vec")]
pub ontology_class: Option<Vec<String>>,
pub parent_property: String,
pub description: Option<String>,
pub config: Option<serde_json::Value>,
pub options: NodeOptions,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NodeOptions {
pub exportable: Option<bool>,
pub fieldname: Option<String>,
pub hascustomalias: Option<bool>,
pub is_collector: Option<bool>,
pub isrequired: Option<bool>,
pub issearchable: Option<bool>,
pub istopnode: Option<bool>,
pub sortorder: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddCardParams {
pub nodegroup_id: String,
pub name: StaticTranslatableString,
pub component_id: Option<String>,
pub options: CardOptions,
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CardOptions {
pub active: Option<bool>,
pub cssclass: Option<String>,
pub helpenabled: Option<bool>,
pub helptext: Option<StaticTranslatableString>,
pub helptitle: Option<StaticTranslatableString>,
pub instructions: Option<StaticTranslatableString>,
pub is_editable: Option<bool>,
pub description: Option<StaticTranslatableString>,
pub sortorder: Option<i32>,
pub visible: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddWidgetParams {
pub node_id: String,
pub widget_id: String,
pub label: String,
pub config: serde_json::Value,
pub sortorder: Option<i32>,
pub visible: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddNodegroupParams {
pub parent_alias: Option<String>,
pub nodegroup_id: String,
pub cardinality: Cardinality,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddEdgeParams {
pub from_node_id: String,
pub to_node_id: String,
pub ontology_property: String,
pub name: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddSubgraphParams {
pub subgraph: StaticGraph,
pub target_node_id: String,
pub ontology_property: String,
#[serde(default)]
pub alias_suffix: Option<String>,
#[serde(default)]
pub alias_prefix: Option<String>,
#[serde(default)]
pub name_prefix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateSubgraphParams {
pub subgraph: StaticGraph,
pub target_node_id: String,
pub ontology_property: String,
#[serde(default)]
pub alias_suffix: Option<String>,
#[serde(default)]
pub remove_orphaned: bool,
#[serde(default)]
pub alias_prefix: Option<String>,
#[serde(default)]
pub name_prefix: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConceptChangeCollectionParams {
pub node_id: String,
pub collection_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteCardParams {
pub card_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteWidgetParams {
pub widget_mapping_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetDescriptorFunctionParams {
pub function_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddFunctionParams {
pub function_id: String,
#[serde(default)]
pub config: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteFunctionParams {
pub function_mapping_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetDescriptorTemplateParams {
pub descriptor_type: String,
pub string_template: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteNodeParams {
pub node_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteNodegroupParams {
pub nodegroup_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateNodeParams {
pub node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::graph::serde_helpers::optional_string_or_vec"
)]
pub ontology_class: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_property: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(default)]
pub options: UpdateNodeOptions,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateNodeOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exportable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fieldname: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub isrequired: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issearchable: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sortorder: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeNodeTypeParams {
pub node_id: String,
pub datatype: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "crate::graph::serde_helpers::optional_string_or_vec"
)]
pub ontology_class: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_property: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub config: Option<serde_json::Value>,
#[serde(default)]
pub options: UpdateNodeOptions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameNodeParams {
pub node_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alias: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default = "default_true")]
pub realign_card: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameCardParams {
pub card_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub language: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_i18n: Option<HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description_i18n: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RealignCardFromNodeParams {
pub node_alias: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChangeCardinalityParams {
pub node_id: String,
pub cardinality: Cardinality,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateGraphParams {
pub name: String,
pub is_resource: bool,
pub root_alias: String,
#[serde(default, with = "crate::graph::serde_helpers::optional_string_or_vec")]
pub root_ontology_class: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graph_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, with = "crate::graph::serde_helpers::optional_string_or_vec")]
pub ontology_id: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RenameGraphParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<std::collections::HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<std::collections::HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subtitle: Option<std::collections::HashMap<String, String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoppiceSubgraphParams {
pub subject: String,
pub publication_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateWidgetConfigParams {
pub node_id: String,
pub config: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtensionMutationParams {
pub name: String,
pub params: serde_json::Value,
#[serde(default = "default_extension_conformance")]
pub conformance: MutationConformance,
}
fn default_extension_conformance() -> MutationConformance {
MutationConformance::AlwaysConformant
}
pub trait ExtensionMutationHandler: Send + Sync {
fn apply(
&self,
graph: &mut StaticGraph,
params: &serde_json::Value,
options: &MutatorOptions,
) -> Result<(), MutationError>;
fn conformance(&self) -> MutationConformance;
fn description(&self) -> &str {
"Extension mutation"
}
}
pub struct ExtensionMutationRegistry {
handlers: std::collections::HashMap<String, std::sync::Arc<dyn ExtensionMutationHandler>>,
}
impl ExtensionMutationRegistry {
pub fn new() -> Self {
Self {
handlers: std::collections::HashMap::new(),
}
}
pub fn register(
&mut self,
name: impl Into<String>,
handler: std::sync::Arc<dyn ExtensionMutationHandler>,
) {
self.handlers.insert(name.into(), handler);
}
pub fn get(&self, name: &str) -> Option<&std::sync::Arc<dyn ExtensionMutationHandler>> {
self.handlers.get(name)
}
pub fn has(&self, name: &str) -> bool {
self.handlers.contains_key(name)
}
pub fn list(&self) -> Vec<&str> {
self.handlers.keys().map(|s| s.as_str()).collect()
}
}
impl Default for ExtensionMutationRegistry {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Debug for ExtensionMutationRegistry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExtensionMutationRegistry")
.field("handlers", &self.handlers.keys().collect::<Vec<_>>())
.finish()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MutationConformance {
AlwaysConformant,
BranchConformant,
ModelConformant,
NonConformant,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GraphMutation {
AddNode(AddNodeParams),
AddNodegroup(AddNodegroupParams),
AddEdge(AddEdgeParams),
AddCard(AddCardParams),
AddWidgetToCard(AddWidgetParams),
AddSubgraph(AddSubgraphParams),
UpdateSubgraph(UpdateSubgraphParams),
ConceptChangeCollection(ConceptChangeCollectionParams),
DeleteCard(DeleteCardParams),
DeleteWidget(DeleteWidgetParams),
AddFunction(AddFunctionParams),
SetDescriptorFunction(SetDescriptorFunctionParams),
DeleteFunction(DeleteFunctionParams),
DeleteNode(DeleteNodeParams),
DeleteNodegroup(DeleteNodegroupParams),
UpdateNode(UpdateNodeParams),
ChangeNodeType(ChangeNodeTypeParams),
ChangeCardinality(ChangeCardinalityParams),
RenameNode(RenameNodeParams),
RenameCard(RenameCardParams),
RealignCardFromNode(RealignCardFromNodeParams),
RenameGraph(RenameGraphParams),
SetDescriptorTemplate(SetDescriptorTemplateParams),
CreateGraph(CreateGraphParams),
CoppiceSubgraph(CoppiceSubgraphParams),
UpdateWidgetConfig(UpdateWidgetConfigParams),
Extension(ExtensionMutationParams),
}
impl GraphMutation {
pub fn conformance(&self) -> MutationConformance {
match self {
GraphMutation::AddNode(_) => MutationConformance::BranchConformant,
GraphMutation::AddNodegroup(_) => MutationConformance::BranchConformant,
GraphMutation::AddEdge(_) => MutationConformance::BranchConformant,
GraphMutation::AddCard(_) => MutationConformance::BranchConformant,
GraphMutation::AddWidgetToCard(_) => MutationConformance::BranchConformant,
GraphMutation::AddSubgraph(_) => MutationConformance::ModelConformant,
GraphMutation::UpdateSubgraph(_) => MutationConformance::ModelConformant,
GraphMutation::ConceptChangeCollection(_) => MutationConformance::AlwaysConformant,
GraphMutation::DeleteCard(_) => MutationConformance::AlwaysConformant,
GraphMutation::DeleteWidget(_) => MutationConformance::AlwaysConformant,
GraphMutation::AddFunction(_) => MutationConformance::ModelConformant,
GraphMutation::SetDescriptorFunction(_) => MutationConformance::ModelConformant,
GraphMutation::DeleteFunction(_) => MutationConformance::AlwaysConformant,
GraphMutation::DeleteNode(_) => MutationConformance::AlwaysConformant,
GraphMutation::DeleteNodegroup(_) => MutationConformance::AlwaysConformant,
GraphMutation::UpdateNode(_) => MutationConformance::BranchConformant,
GraphMutation::ChangeNodeType(_) => MutationConformance::BranchConformant,
GraphMutation::ChangeCardinality(_) => MutationConformance::BranchConformant,
GraphMutation::RenameNode(_) => MutationConformance::AlwaysConformant,
GraphMutation::RenameCard(_) => MutationConformance::AlwaysConformant,
GraphMutation::RealignCardFromNode(_) => MutationConformance::AlwaysConformant,
GraphMutation::RenameGraph(_) => MutationConformance::AlwaysConformant,
GraphMutation::SetDescriptorTemplate(_) => MutationConformance::AlwaysConformant,
GraphMutation::CreateGraph(_) => MutationConformance::NonConformant,
GraphMutation::CoppiceSubgraph(_) => MutationConformance::AlwaysConformant,
GraphMutation::UpdateWidgetConfig(_) => MutationConformance::AlwaysConformant,
GraphMutation::Extension(params) => params.conformance,
}
}
}
#[derive(Debug, Clone)]
pub struct MutatorOptions {
pub autocreate_card: bool,
pub autocreate_widget: bool,
pub ontology_validator: Option<crate::ontology::OntologyValidator>,
pub skip_publication: bool,
}
impl Default for MutatorOptions {
fn default() -> Self {
Self {
autocreate_card: true,
autocreate_widget: true,
ontology_validator: None,
skip_publication: false,
}
}
}
fn normalise_class_arg(class: &str) -> Option<Vec<String>> {
sanitize_class_list(Some(vec![class.to_string()]))
}
fn sanitize_class_list(list: Option<Vec<String>>) -> Option<Vec<String>> {
match list {
None => None,
Some(v) => {
let cleaned: Vec<String> = v.into_iter().filter(|s| !s.trim().is_empty()).collect();
if cleaned.is_empty() {
None
} else {
Some(cleaned)
}
}
}
}
pub struct GraphMutator {
base_graph: StaticGraph,
mutations: Vec<GraphMutation>,
options: MutatorOptions,
}
impl GraphMutator {
pub fn new(base_graph: StaticGraph) -> Self {
Self {
base_graph,
mutations: Vec::new(),
options: MutatorOptions::default(),
}
}
pub fn with_options(base_graph: StaticGraph, options: MutatorOptions) -> Self {
Self {
base_graph,
mutations: Vec::new(),
options,
}
}
pub fn mutations(&self) -> &[GraphMutation] {
&self.mutations
}
#[allow(clippy::too_many_arguments)]
pub fn add_semantic_node(
mut self,
parent_alias: Option<&str>,
alias: &str,
name: &str,
cardinality: Cardinality,
ontology_class: &str,
parent_property: &str,
description: Option<&str>,
options: NodeOptions,
config: Option<serde_json::Value>,
) -> Self {
self.add_generic_node_mut(
parent_alias,
alias,
name,
cardinality,
"semantic",
ontology_class,
parent_property,
description,
options,
config,
);
self
}
#[allow(clippy::too_many_arguments)]
pub fn add_string_node(
mut self,
parent_alias: Option<&str>,
alias: &str,
name: &str,
cardinality: Cardinality,
ontology_class: &str,
parent_property: &str,
description: Option<&str>,
options: NodeOptions,
config: Option<serde_json::Value>,
) -> Self {
self.add_generic_node_mut(
parent_alias,
alias,
name,
cardinality,
"string",
ontology_class,
parent_property,
description,
options,
config,
);
self
}
#[allow(clippy::too_many_arguments)]
pub fn add_concept_node(
mut self,
parent_alias: Option<&str>,
alias: &str,
name: &str,
collection_id: Option<&str>,
is_list: bool,
cardinality: Cardinality,
ontology_class: &str,
parent_property: &str,
description: Option<&str>,
options: NodeOptions,
config: Option<serde_json::Value>,
) -> Self {
let mut node_config = config.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
if let Some(coll_id) = collection_id {
if let serde_json::Value::Object(ref mut map) = node_config {
map.insert(
"rdmCollection".to_string(),
serde_json::Value::String(coll_id.to_string()),
);
}
}
let datatype = if is_list { "concept-list" } else { "concept" };
self.add_generic_node_mut(
parent_alias,
alias,
name,
cardinality,
datatype,
ontology_class,
parent_property,
description,
options,
Some(node_config),
);
self
}
#[allow(clippy::too_many_arguments)]
pub fn add_number_node(
mut self,
parent_alias: Option<&str>,
alias: &str,
name: &str,
cardinality: Cardinality,
ontology_class: &str,
parent_property: &str,
description: Option<&str>,
options: NodeOptions,
config: Option<serde_json::Value>,
) -> Self {
self.add_generic_node_mut(
parent_alias,
alias,
name,
cardinality,
"number",
ontology_class,
parent_property,
description,
options,
config,
);
self
}
#[allow(clippy::too_many_arguments)]
pub fn add_date_node(
mut self,
parent_alias: Option<&str>,
alias: &str,
name: &str,
cardinality: Cardinality,
ontology_class: &str,
parent_property: &str,
description: Option<&str>,
options: NodeOptions,
config: Option<serde_json::Value>,
) -> Self {
self.add_generic_node_mut(
parent_alias,
alias,
name,
cardinality,
"date",
ontology_class,
parent_property,
description,
options,
config,
);
self
}
#[allow(clippy::too_many_arguments)]
pub fn add_boolean_node(
mut self,
parent_alias: Option<&str>,
alias: &str,
name: &str,
cardinality: Cardinality,
ontology_class: &str,
parent_property: &str,
description: Option<&str>,
options: NodeOptions,
config: Option<serde_json::Value>,
) -> Self {
self.add_generic_node_mut(
parent_alias,
alias,
name,
cardinality,
"boolean",
ontology_class,
parent_property,
description,
options,
config,
);
self
}
#[allow(clippy::too_many_arguments)]
pub fn add_generic_node(
mut self,
parent_alias: Option<&str>,
alias: &str,
name: &str,
cardinality: Cardinality,
datatype: &str,
ontology_class: &str,
parent_property: &str,
description: Option<&str>,
options: NodeOptions,
config: Option<serde_json::Value>,
) -> Self {
self.add_generic_node_mut(
parent_alias,
alias,
name,
cardinality,
datatype,
ontology_class,
parent_property,
description,
options,
config,
);
self
}
#[allow(clippy::too_many_arguments)]
fn add_generic_node_mut(
&mut self,
parent_alias: Option<&str>,
alias: &str,
name: &str,
cardinality: Cardinality,
datatype: &str,
ontology_class: &str,
parent_property: &str,
description: Option<&str>,
options: NodeOptions,
config: Option<serde_json::Value>,
) {
let ontology_class = normalise_class_arg(ontology_class);
self.mutations.push(GraphMutation::AddNode(AddNodeParams {
parent_alias: parent_alias.map(String::from),
alias: alias.to_string(),
name: name.to_string(),
cardinality,
datatype: datatype.to_string(),
ontology_class,
parent_property: parent_property.to_string(),
description: description.map(String::from),
config,
options,
}));
}
pub fn add_card(
mut self,
nodegroup_id: &str,
name: &str,
options: CardOptions,
config: Option<serde_json::Value>,
) -> Self {
self.mutations.push(GraphMutation::AddCard(AddCardParams {
nodegroup_id: nodegroup_id.to_string(),
name: StaticTranslatableString::from_string(name),
component_id: Some(DEFAULT_CARD_COMPONENT_ID.to_string()),
options,
config,
}));
self
}
pub fn add_widget_to_card(
mut self,
node_id: &str,
widget: &Widget,
label: &str,
config: serde_json::Value,
sortorder: Option<i32>,
visible: Option<bool>,
) -> Self {
self.mutations
.push(GraphMutation::AddWidgetToCard(AddWidgetParams {
node_id: node_id.to_string(),
widget_id: widget.id.clone(),
label: label.to_string(),
config,
sortorder,
visible,
}));
self
}
pub fn build(self) -> Result<StaticGraph, MutationError> {
let mut graph = self.base_graph.deep_clone();
for mutation in self.mutations {
apply_mutation(&mut graph, mutation, &self.options)?;
}
graph.build_indices();
Ok(graph)
}
}
fn apply_mutation(
graph: &mut StaticGraph,
mutation: GraphMutation,
options: &MutatorOptions,
) -> Result<(), MutationError> {
apply_mutation_with_extensions(graph, mutation, options, None)
}
fn apply_mutation_with_extensions(
graph: &mut StaticGraph,
mutation: GraphMutation,
options: &MutatorOptions,
registry: Option<&ExtensionMutationRegistry>,
) -> Result<(), MutationError> {
match mutation {
GraphMutation::AddNode(params) => apply_add_node(graph, params, options),
GraphMutation::AddNodegroup(params) => apply_add_nodegroup(graph, params, options),
GraphMutation::AddEdge(params) => apply_add_edge(graph, params),
GraphMutation::AddCard(params) => apply_add_card(graph, params),
GraphMutation::AddWidgetToCard(params) => apply_add_widget(graph, params),
GraphMutation::AddSubgraph(params) => apply_add_subgraph(graph, params),
GraphMutation::UpdateSubgraph(params) => apply_update_subgraph(graph, params),
GraphMutation::ConceptChangeCollection(params) => apply_concept_change_collection(graph, params),
GraphMutation::DeleteCard(params) => apply_delete_card(graph, params),
GraphMutation::DeleteWidget(params) => apply_delete_widget(graph, params),
GraphMutation::AddFunction(params) => apply_add_function(graph, params),
GraphMutation::SetDescriptorFunction(params) => apply_set_descriptor_function(graph, params),
GraphMutation::DeleteFunction(params) => apply_delete_function(graph, params),
GraphMutation::DeleteNode(params) => apply_delete_node(graph, params),
GraphMutation::DeleteNodegroup(params) => apply_delete_nodegroup(graph, params),
GraphMutation::UpdateNode(params) => apply_update_node(graph, params, options),
GraphMutation::ChangeNodeType(params) => apply_change_node_type(graph, params),
GraphMutation::ChangeCardinality(params) => apply_change_cardinality(graph, params),
GraphMutation::RenameNode(params) => apply_rename_node(graph, params),
GraphMutation::RenameCard(params) => apply_rename_card(graph, params),
GraphMutation::RealignCardFromNode(params) => apply_realign_card_from_node(graph, params),
GraphMutation::RenameGraph(params) => apply_rename_graph(graph, params),
GraphMutation::SetDescriptorTemplate(params) => apply_set_descriptor_template(graph, params),
GraphMutation::CoppiceSubgraph(params) => apply_coppice_subgraph(graph, params),
GraphMutation::UpdateWidgetConfig(params) => apply_update_widget_config(graph, params),
GraphMutation::CreateGraph(_) => {
Err(MutationError::Other(
"CreateGraph cannot be used as a regular mutation. Use apply_mutations_create_from_json instead.".to_string()
))
}
GraphMutation::Extension(params) => {
match registry {
Some(reg) => {
let handler = reg.get(¶ms.name)
.ok_or_else(|| MutationError::ExtensionNotFound(params.name.clone()))?;
handler.apply(graph, ¶ms.params, options)
}
None => Err(MutationError::NoExtensionRegistry(params.name)),
}
}
}
}
fn apply_add_node(
graph: &mut StaticGraph,
params: AddNodeParams,
options: &MutatorOptions,
) -> Result<(), MutationError> {
if graph.find_node_by_alias(¶ms.alias).is_some() {
return Err(MutationError::AliasAlreadyExists(params.alias.clone()));
}
let parent = if let Some(ref parent_alias) = params.parent_alias {
graph
.find_node_by_alias(parent_alias)
.ok_or_else(|| MutationError::ParentNotFound(parent_alias.clone()))?
} else {
graph.get_root()
};
let parent_nodeid = parent.nodeid.clone();
let parent_nodegroup_id = parent.nodegroup_id.clone();
let parent_classes: Vec<String> = parent.ontologyclass.clone().unwrap_or_default();
let node_classes: Option<Vec<String>> = sanitize_class_list(params.ontology_class);
if let Some(ref validator) = options.ontology_validator {
if let Some(ref classes) = node_classes {
validator
.validate_edge_multi(&parent_classes, ¶ms.parent_property, classes)
.map_err(MutationError::OntologyValidation)?;
}
}
let node_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("node-{}", params.alias),
);
let (nodegroup_id, created_new_nodegroup) = if params.cardinality == Cardinality::N
|| parent.is_root()
|| params.options.is_collector == Some(true)
{
let ng_id = node_id.clone();
let nodegroup = StaticNodegroup {
nodegroupid: ng_id.clone(),
cardinality: Some(params.cardinality.as_str().to_string()),
parentnodegroup_id: parent_nodegroup_id.clone(),
legacygroupid: None,
grouping_node_id: None,
};
graph.push_nodegroup(nodegroup);
if options.autocreate_card {
let card_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("card-ng-{}", ng_id),
);
let card = StaticCard {
active: true,
cardid: card_id,
component_id: DEFAULT_CARD_COMPONENT_ID.to_string(),
config: None,
constraints: vec![],
cssclass: None,
description: None,
graph_id: graph.graphid.clone(),
helpenabled: false,
helptext: StaticTranslatableString::empty(),
helptitle: StaticTranslatableString::empty(),
instructions: StaticTranslatableString::empty(),
is_editable: Some(true),
name: StaticTranslatableString::from_string(¶ms.name),
nodegroup_id: ng_id.clone(),
sortorder: Some(0),
visible: true,
source_identifier_id: None,
};
graph.push_card(card);
}
(Some(ng_id), true)
} else {
(parent_nodegroup_id, false)
};
let config: HashMap<String, serde_json::Value> = match params.config {
Some(v) => serde_json::from_value(v).map_err(|e| MutationError::InvalidConfig {
alias: params.alias.clone(),
error: e.to_string(),
})?,
None => HashMap::new(),
};
let node = StaticNode {
nodeid: node_id.clone(),
name: params.name.clone(),
alias: Some(params.alias.clone()),
datatype: params.datatype.clone(),
nodegroup_id: nodegroup_id.clone(),
graph_id: graph.graphid.clone(),
is_collector: params.options.is_collector.unwrap_or(false),
isrequired: params.options.isrequired.unwrap_or(false),
exportable: params.options.exportable.unwrap_or(false),
sortorder: Some(params.options.sortorder.unwrap_or(0)),
config,
parentproperty: Some(params.parent_property.clone()),
ontologyclass: node_classes,
description: params
.description
.map(|d| StaticTranslatableString::from_string(&d)),
fieldname: params.options.fieldname,
hascustomalias: params.options.hascustomalias.unwrap_or(false),
issearchable: params.options.issearchable.unwrap_or(true),
istopnode: params.options.istopnode.unwrap_or(false),
sourcebranchpublication_id: None,
source_identifier_id: None,
is_immutable: None,
};
graph.push_node(node);
let edge_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("edge-{}-{}", parent_nodeid, node_id),
);
let edge = StaticEdge {
domainnode_id: parent_nodeid,
rangenode_id: node_id.clone(),
edgeid: edge_id,
graph_id: graph.graphid.clone(),
name: None,
ontologyproperty: Some(params.parent_property),
description: None,
source_identifier_id: None,
};
graph.push_edge(edge);
if options.autocreate_widget && params.datatype != "semantic" {
let widget = get_default_widget_for_datatype(¶ms.datatype)
.map_err(|_| MutationError::NoWidgetForDatatype(params.datatype.clone()))?;
let ng_id = nodegroup_id.as_ref().ok_or_else(|| {
MutationError::Other(format!(
"Cannot create widget for node '{}': no nodegroup",
params.alias
))
})?;
if let Some(card) = graph.find_card_by_nodegroup(ng_id) {
let mut widget_config = widget.get_default_config();
if let serde_json::Value::Object(ref mut map) = widget_config {
map.insert(
"label".to_string(),
serde_json::Value::String(params.name.clone()),
);
}
let cxnxw_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("cxnxw-{}-{}", node_id, widget.id),
);
let cxnxw = StaticCardsXNodesXWidgets {
card_id: card.cardid.clone(),
config: widget_config,
id: cxnxw_id,
label: StaticTranslatableString::from_string(¶ms.name),
node_id: node_id.clone(),
sortorder: Some(params.options.sortorder.unwrap_or(0)),
visible: true,
widget_id: widget.id.clone(),
source_identifier_id: None,
};
graph.push_card_x_node_x_widget(cxnxw);
} else if created_new_nodegroup {
return Err(MutationError::CardNotFound(ng_id.clone()));
}
}
Ok(())
}
fn apply_add_nodegroup(
graph: &mut StaticGraph,
params: AddNodegroupParams,
options: &MutatorOptions,
) -> Result<(), MutationError> {
let parent_nodegroup_id = if let Some(ref parent_alias) = params.parent_alias {
let parent = graph
.find_node_by_alias(parent_alias)
.ok_or_else(|| MutationError::ParentNotFound(parent_alias.clone()))?;
parent.nodegroup_id.clone()
} else {
graph.get_root().nodegroup_id.clone()
};
let nodegroup = StaticNodegroup {
nodegroupid: params.nodegroup_id.clone(),
cardinality: Some(params.cardinality.as_str().to_string()),
parentnodegroup_id: parent_nodegroup_id,
legacygroupid: None,
grouping_node_id: None,
};
graph.push_nodegroup(nodegroup);
if options.autocreate_card {
let card_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("card-ng-{}", params.nodegroup_id),
);
let card = StaticCard {
active: true,
cardid: card_id,
component_id: DEFAULT_CARD_COMPONENT_ID.to_string(),
config: None,
constraints: vec![],
cssclass: None,
description: None,
graph_id: graph.graphid.clone(),
helpenabled: false,
helptext: StaticTranslatableString::empty(),
helptitle: StaticTranslatableString::empty(),
instructions: StaticTranslatableString::empty(),
is_editable: Some(true),
name: StaticTranslatableString::from_string("(unnamed)"),
nodegroup_id: params.nodegroup_id,
sortorder: Some(0),
visible: true,
source_identifier_id: None,
};
graph.push_card(card);
}
Ok(())
}
fn apply_add_edge(graph: &mut StaticGraph, params: AddEdgeParams) -> Result<(), MutationError> {
let edge_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("edge-{}-{}", params.from_node_id, params.to_node_id),
);
let edge = StaticEdge {
domainnode_id: params.from_node_id,
rangenode_id: params.to_node_id,
edgeid: edge_id,
graph_id: graph.graphid.clone(),
name: params.name,
ontologyproperty: Some(params.ontology_property),
description: params.description,
source_identifier_id: None,
};
graph.push_edge(edge);
Ok(())
}
fn apply_add_card(graph: &mut StaticGraph, params: AddCardParams) -> Result<(), MutationError> {
let nodegroup_id = graph
.find_node_by_alias(¶ms.nodegroup_id)
.and_then(|n| n.nodegroup_id.clone())
.unwrap_or(params.nodegroup_id);
if graph.find_card_by_nodegroup(&nodegroup_id).is_some() {
return Err(MutationError::CardAlreadyExists(nodegroup_id));
}
let card_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("card-ng-{}", nodegroup_id),
);
let card = StaticCard {
active: params.options.active.unwrap_or(true),
cardid: card_id,
component_id: params
.component_id
.unwrap_or_else(|| DEFAULT_CARD_COMPONENT_ID.to_string()),
config: params.config,
constraints: vec![],
cssclass: params.options.cssclass,
description: params.options.description,
graph_id: graph.graphid.clone(),
helpenabled: params.options.helpenabled.unwrap_or(false),
helptext: params
.options
.helptext
.unwrap_or_else(StaticTranslatableString::empty),
helptitle: params
.options
.helptitle
.unwrap_or_else(StaticTranslatableString::empty),
instructions: params
.options
.instructions
.unwrap_or_else(StaticTranslatableString::empty),
is_editable: params.options.is_editable,
name: params.name,
nodegroup_id,
sortorder: Some(params.options.sortorder.unwrap_or(0)),
visible: params.options.visible.unwrap_or(true),
source_identifier_id: None,
};
graph.push_card(card);
Ok(())
}
fn apply_add_widget(graph: &mut StaticGraph, params: AddWidgetParams) -> Result<(), MutationError> {
let node = graph
.nodes
.iter()
.find(|n| n.nodeid == params.node_id)
.ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
let nodegroup_id = node
.nodegroup_id
.clone()
.ok_or_else(|| MutationError::NodegroupNotFound(params.node_id.clone()))?;
let card = graph
.find_card_by_nodegroup(&nodegroup_id)
.ok_or_else(|| MutationError::CardNotFound(nodegroup_id.clone()))?;
let card_id = card.cardid.clone();
let cxnxw_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("cxnxw-{}-{}", params.node_id, params.widget_id),
);
let cxnxw = StaticCardsXNodesXWidgets {
card_id,
config: params.config,
id: cxnxw_id,
label: StaticTranslatableString::from_string(¶ms.label),
node_id: params.node_id,
sortorder: Some(params.sortorder.unwrap_or(0)),
visible: params.visible.unwrap_or(true),
widget_id: params.widget_id,
source_identifier_id: None,
};
graph.push_card_x_node_x_widget(cxnxw);
Ok(())
}
const CONCEPT_DATATYPES: &[&str] = &["concept", "concept-list"];
fn apply_concept_change_collection(
graph: &mut StaticGraph,
params: ConceptChangeCollectionParams,
) -> Result<(), MutationError> {
let node = graph
.find_node_by_alias(¶ms.node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
.ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
if !CONCEPT_DATATYPES.contains(&node.datatype.as_str()) {
return Err(MutationError::InvalidDatatype {
expected: "concept or concept-list".to_string(),
found: node.datatype.clone(),
node_id: params.node_id.clone(),
});
}
let node_id = node.nodeid.clone();
let node_mut = graph
.nodes
.iter_mut()
.find(|n| n.nodeid == node_id)
.ok_or_else(|| MutationError::NodeNotFound(node_id.clone()))?;
node_mut.config.insert(
"rdmCollection".to_string(),
serde_json::Value::String(params.collection_id),
);
Ok(())
}
fn apply_delete_card(
graph: &mut StaticGraph,
params: DeleteCardParams,
) -> Result<(), MutationError> {
let card_exists = graph
.cards
.as_ref()
.map(|cards| cards.iter().any(|c| c.cardid == params.card_id))
.unwrap_or(false);
if !card_exists {
return Err(MutationError::CardNotFound(params.card_id));
}
if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
cxnxws.retain(|c| c.card_id != params.card_id);
}
if let Some(ref mut cards) = graph.cards {
cards.retain(|c| c.cardid != params.card_id);
}
Ok(())
}
fn apply_delete_widget(
graph: &mut StaticGraph,
params: DeleteWidgetParams,
) -> Result<(), MutationError> {
let widget_exists = graph
.cards_x_nodes_x_widgets
.as_ref()
.map(|cxnxws| cxnxws.iter().any(|c| c.id == params.widget_mapping_id))
.unwrap_or(false);
if !widget_exists {
return Err(MutationError::WidgetNotFound(params.widget_mapping_id));
}
if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
cxnxws.retain(|c| c.id != params.widget_mapping_id);
}
Ok(())
}
fn apply_update_widget_config(
graph: &mut StaticGraph,
params: UpdateWidgetConfigParams,
) -> Result<(), MutationError> {
let node_id = graph
.find_node_by_alias(¶ms.node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
.ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?
.nodeid
.clone();
let cxnxw = graph
.cards_x_nodes_x_widgets
.as_mut()
.and_then(|cxnxws| cxnxws.iter_mut().find(|c| c.node_id == node_id))
.ok_or_else(|| {
MutationError::WidgetNotFound(format!("no widget for node {}", params.node_id))
})?;
if let serde_json::Value::Object(patch) = params.config {
if let serde_json::Value::Object(ref mut existing) = cxnxw.config {
for (key, value) in patch {
existing.insert(key, value);
}
} else {
cxnxw.config = serde_json::Value::Object(patch);
}
} else {
return Err(MutationError::Other(
"update_widget_config: config must be a JSON object".to_string(),
));
}
Ok(())
}
fn resolve_function_id(raw: &str) -> String {
if uuid::Uuid::parse_str(raw).is_ok() {
return raw.to_string();
}
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, raw.as_bytes()).to_string()
}
fn apply_add_function(
graph: &mut StaticGraph,
params: AddFunctionParams,
) -> Result<(), MutationError> {
use crate::graph::StaticFunctionsXGraphs;
let function_id = resolve_function_id(¶ms.function_id);
let fxg = graph.functions_x_graphs.get_or_insert_with(Vec::new);
if fxg
.iter()
.any(|f| f.function_id == function_id && f.graph_id == graph.graphid)
{
return Err(MutationError::Other(format!(
"Function {} already mapped to graph {}",
function_id, graph.graphid
)));
}
fxg.push(StaticFunctionsXGraphs {
id: generate_uuid_v5(("function", Some(&graph.graphid)), &function_id),
function_id,
graph_id: graph.graphid.clone(),
config: params
.config
.unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::new())),
});
Ok(())
}
fn apply_set_descriptor_function(
graph: &mut StaticGraph,
params: SetDescriptorFunctionParams,
) -> Result<(), MutationError> {
use crate::graph::StaticFunctionsXGraphs;
use crate::graph::DESCRIPTOR_FUNCTION_ID;
let function_id = resolve_function_id(¶ms.function_id);
let fxg = graph.functions_x_graphs.get_or_insert_with(Vec::new);
fxg.retain(|f| {
if f.function_id == DESCRIPTOR_FUNCTION_ID {
return false;
}
if f.function_id == function_id {
return false; }
if f.config
.as_object()
.is_some_and(|c| c.contains_key("descriptor_types"))
{
return false;
}
true
});
fxg.push(StaticFunctionsXGraphs {
id: generate_uuid_v5(("function", Some(&graph.graphid)), &function_id),
function_id,
graph_id: graph.graphid.clone(),
config: serde_json::Value::Object(serde_json::Map::new()),
});
Ok(())
}
fn apply_delete_function(
graph: &mut StaticGraph,
params: DeleteFunctionParams,
) -> Result<(), MutationError> {
let function_exists = graph
.functions_x_graphs
.as_ref()
.map(|fxgs| fxgs.iter().any(|f| f.id == params.function_mapping_id))
.unwrap_or(false);
if !function_exists {
return Err(MutationError::FunctionNotFound(params.function_mapping_id));
}
if let Some(ref mut fxgs) = graph.functions_x_graphs {
fxgs.retain(|f| f.id != params.function_mapping_id);
}
Ok(())
}
fn apply_set_descriptor_template(
graph: &mut StaticGraph,
params: SetDescriptorTemplateParams,
) -> Result<(), MutationError> {
graph
.set_descriptor_template(¶ms.descriptor_type, ¶ms.string_template)
.map_err(MutationError::Other)
}
fn apply_delete_node(
graph: &mut StaticGraph,
params: DeleteNodeParams,
) -> Result<(), MutationError> {
let node = graph
.find_node_by_alias(¶ms.node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
.ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
if node.istopnode {
return Err(MutationError::CannotDeleteRootNode(params.node_id.clone()));
}
let root_id = node.nodeid.clone();
let mut nodes_to_delete = vec![root_id.clone()];
let mut i = 0;
while i < nodes_to_delete.len() {
let current = &nodes_to_delete[i].clone();
for edge in &graph.edges {
if edge.domainnode_id == *current && !nodes_to_delete.contains(&edge.rangenode_id) {
nodes_to_delete.push(edge.rangenode_id.clone());
}
}
i += 1;
}
for nid in &nodes_to_delete {
if let Some(n) = graph.nodes.iter().find(|n| n.nodeid == *nid) {
if n.istopnode {
return Err(MutationError::CannotDeleteRootNode(nid.clone()));
}
}
}
let mut nodegroups_to_delete: Vec<String> = Vec::new();
for nid in &nodes_to_delete {
if let Some(n) = graph.nodes.iter().find(|n| n.nodeid == *nid) {
if let Some(ref ng_id) = n.nodegroup_id {
if *ng_id == n.nodeid && !nodegroups_to_delete.contains(ng_id) {
nodegroups_to_delete.push(ng_id.clone());
}
}
}
}
if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
cxnxws.retain(|c| !nodes_to_delete.contains(&c.node_id));
}
graph.edges.retain(|e| {
!nodes_to_delete.contains(&e.domainnode_id) && !nodes_to_delete.contains(&e.rangenode_id)
});
if let Some(ref mut cards) = graph.cards {
cards.retain(|c| !nodegroups_to_delete.contains(&c.nodegroup_id));
}
graph
.nodegroups
.retain(|ng| !nodegroups_to_delete.contains(&ng.nodegroupid));
graph.nodes.retain(|n| !nodes_to_delete.contains(&n.nodeid));
graph.invalidate_indices();
Ok(())
}
fn apply_delete_nodegroup(
graph: &mut StaticGraph,
params: DeleteNodegroupParams,
) -> Result<(), MutationError> {
if !graph
.nodegroups
.iter()
.any(|ng| ng.nodegroupid == params.nodegroup_id)
{
return Err(MutationError::NodegroupNotFound(params.nodegroup_id));
}
let mut nodegroups_to_delete: Vec<String> = vec![params.nodegroup_id.clone()];
let mut i = 0;
while i < nodegroups_to_delete.len() {
let current_ng = nodegroups_to_delete[i].clone();
for ng in &graph.nodegroups {
if ng.parentnodegroup_id.as_ref() == Some(¤t_ng)
&& !nodegroups_to_delete.contains(&ng.nodegroupid)
{
nodegroups_to_delete.push(ng.nodegroupid.clone());
}
}
i += 1;
}
let nodes_to_delete: Vec<String> = graph
.nodes
.iter()
.filter(|n| {
n.nodegroup_id
.as_ref()
.map(|ng| nodegroups_to_delete.contains(ng))
.unwrap_or(false)
})
.map(|n| n.nodeid.clone())
.collect();
for node_id in &nodes_to_delete {
if let Some(node) = graph.nodes.iter().find(|n| n.nodeid == *node_id) {
if node.istopnode {
return Err(MutationError::CannotDeleteRootNode(node_id.clone()));
}
}
}
if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
cxnxws.retain(|c| !nodes_to_delete.contains(&c.node_id));
}
graph.edges.retain(|e| {
!nodes_to_delete.contains(&e.domainnode_id) && !nodes_to_delete.contains(&e.rangenode_id)
});
if let Some(ref mut cards) = graph.cards {
cards.retain(|c| !nodegroups_to_delete.contains(&c.nodegroup_id));
}
graph
.nodegroups
.retain(|ng| !nodegroups_to_delete.contains(&ng.nodegroupid));
graph.nodes.retain(|n| !nodes_to_delete.contains(&n.nodeid));
graph.invalidate_indices();
Ok(())
}
fn apply_update_node(
graph: &mut StaticGraph,
params: UpdateNodeParams,
options: &MutatorOptions,
) -> Result<(), MutationError> {
let node = graph
.find_node_by_alias(¶ms.node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
.ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
let node_id = node.nodeid.clone();
let class_update: Option<Option<Vec<String>>> =
params.ontology_class.map(|v| sanitize_class_list(Some(v)));
if let Some(ref validator) = options.ontology_validator {
if let Some(Some(ref classes)) = class_update {
for c in classes {
if !validator.is_valid_class(c) {
return Err(MutationError::OntologyValidation(
crate::ontology::OntologyValidationDetail::UnknownClass(c.clone()),
));
}
}
}
}
let node_mut = graph
.nodes
.iter_mut()
.find(|n| n.nodeid == node_id)
.ok_or_else(|| MutationError::NodeNotFound(node_id.clone()))?;
if let Some(name) = params.name {
node_mut.name = name;
}
if let Some(new_classes) = class_update {
node_mut.ontologyclass = new_classes;
}
if let Some(parent_property) = params.parent_property {
node_mut.parentproperty = if parent_property.is_empty() {
None
} else {
Some(parent_property)
};
}
if let Some(description) = params.description {
node_mut.description = Some(StaticTranslatableString::from_string(&description));
}
if let Some(serde_json::Value::Object(map)) = params.config {
for (k, v) in map {
node_mut.config.insert(k, v);
}
}
if let Some(exportable) = params.options.exportable {
node_mut.exportable = exportable;
}
if let Some(fieldname) = params.options.fieldname {
node_mut.fieldname = if fieldname.is_empty() {
None
} else {
Some(fieldname)
};
}
if let Some(isrequired) = params.options.isrequired {
node_mut.isrequired = isrequired;
}
if let Some(issearchable) = params.options.issearchable {
node_mut.issearchable = issearchable;
}
if let Some(sortorder) = params.options.sortorder {
node_mut.sortorder = Some(sortorder);
}
Ok(())
}
fn apply_change_node_type(
graph: &mut StaticGraph,
params: ChangeNodeTypeParams,
) -> Result<(), MutationError> {
let node = graph
.find_node_by_alias(¶ms.node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
.ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
let node_id = node.nodeid.clone();
let has_widgets = graph
.cards_x_nodes_x_widgets
.as_ref()
.map(|cxnxws| cxnxws.iter().any(|c| c.node_id == node_id))
.unwrap_or(false);
if has_widgets {
return Err(MutationError::NodeHasDependentWidgets(params.node_id));
}
let node_mut = graph
.nodes
.iter_mut()
.find(|n| n.nodeid == node_id)
.ok_or_else(|| MutationError::NodeNotFound(node_id.clone()))?;
node_mut.datatype = params.datatype;
if let Some(name) = params.name {
node_mut.name = name;
}
if let Some(classes) = params.ontology_class {
node_mut.ontologyclass = sanitize_class_list(Some(classes));
}
if let Some(parent_property) = params.parent_property {
node_mut.parentproperty = if parent_property.is_empty() {
None
} else {
Some(parent_property)
};
}
if let Some(description) = params.description {
node_mut.description = Some(StaticTranslatableString::from_string(&description));
}
if let Some(serde_json::Value::Object(map)) = params.config {
for (k, v) in map {
node_mut.config.insert(k, v);
}
}
if let Some(exportable) = params.options.exportable {
node_mut.exportable = exportable;
}
if let Some(fieldname) = params.options.fieldname {
node_mut.fieldname = if fieldname.is_empty() {
None
} else {
Some(fieldname)
};
}
if let Some(isrequired) = params.options.isrequired {
node_mut.isrequired = isrequired;
}
if let Some(issearchable) = params.options.issearchable {
node_mut.issearchable = issearchable;
}
if let Some(sortorder) = params.options.sortorder {
node_mut.sortorder = Some(sortorder);
}
Ok(())
}
fn apply_change_cardinality(
graph: &mut StaticGraph,
params: ChangeCardinalityParams,
) -> Result<(), MutationError> {
let (node_id, nodegroup_id) = {
let node = graph
.find_node_by_alias(¶ms.node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
.ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
let nodegroup_id = node.nodegroup_id.clone().ok_or_else(|| {
MutationError::Other(format!(
"Node '{}' has no nodegroup_id - cannot change cardinality",
params.node_id
))
})?;
(node.nodeid.clone(), nodegroup_id)
};
let nodegroup = graph
.nodegroups
.iter()
.find(|ng| ng.nodegroupid == nodegroup_id)
.ok_or_else(|| MutationError::NodegroupNotFound(nodegroup_id.clone()))?;
let is_grouping_node = match &nodegroup.grouping_node_id {
Some(grouping_id) => grouping_id == &node_id,
None => {
nodegroup_id == node_id
}
};
if !is_grouping_node {
return Err(MutationError::Other(format!(
"Node '{}' is not the grouping node for nodegroup '{}'. Only the grouping node can change cardinality.",
params.node_id, nodegroup_id
)));
}
let nodegroup_mut = graph
.nodegroups
.iter_mut()
.find(|ng| ng.nodegroupid == nodegroup_id)
.ok_or_else(|| MutationError::NodegroupNotFound(nodegroup_id.clone()))?;
nodegroup_mut.cardinality = Some(params.cardinality.as_str().to_string());
Ok(())
}
fn apply_rename_node(
graph: &mut StaticGraph,
params: RenameNodeParams,
) -> Result<(), MutationError> {
let node = graph
.find_node_by_alias(¶ms.node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_id))
.ok_or_else(|| MutationError::NodeNotFound(params.node_id.clone()))?;
let node_id = node.nodeid.clone();
if let Some(ref new_alias) = params.alias {
let alias_exists = graph
.nodes
.iter()
.any(|n| n.nodeid != node_id && n.alias.as_ref() == Some(new_alias));
if alias_exists {
return Err(MutationError::AliasAlreadyExists(new_alias.clone()));
}
}
let node_mut = graph
.nodes
.iter_mut()
.find(|n| n.nodeid == node_id)
.ok_or_else(|| MutationError::NodeNotFound(node_id.clone()))?;
if let Some(alias) = params.alias {
node_mut.alias = if alias.is_empty() { None } else { Some(alias) };
}
let name_changed = params.name.is_some();
if let Some(name) = params.name {
node_mut.name = name;
}
if let Some(description) = params.description {
node_mut.description = Some(StaticTranslatableString::from_string(&description));
}
if name_changed && params.realign_card {
let _ = apply_realign_card_from_node(
graph,
RealignCardFromNodeParams {
node_alias: node_id,
},
);
}
Ok(())
}
fn apply_rename_card(
graph: &mut StaticGraph,
params: RenameCardParams,
) -> Result<(), MutationError> {
let lang = params.language.unwrap_or_else(|| "en".to_string());
let card_mut = graph
.cards
.as_mut()
.and_then(|cards| {
cards
.iter_mut()
.find(|c| c.cardid == params.card_id || c.nodegroup_id == params.card_id)
})
.ok_or(MutationError::CardNotFound(params.card_id))?;
if let Some(name_i18n) = params.name_i18n {
card_mut.name = StaticTranslatableString::from_translations(name_i18n, Some(lang.clone()));
} else if let Some(name) = params.name {
card_mut.name.translations.insert(lang.clone(), name);
}
if let Some(desc_i18n) = params.description_i18n {
card_mut.description = Some(StaticTranslatableString::from_translations(
desc_i18n,
Some(lang),
));
} else if let Some(desc) = params.description {
let description = card_mut
.description
.get_or_insert_with(StaticTranslatableString::empty);
description.translations.insert(lang, desc);
}
Ok(())
}
fn apply_realign_card_from_node(
graph: &mut StaticGraph,
params: RealignCardFromNodeParams,
) -> Result<(), MutationError> {
let node = graph
.find_node_by_alias(¶ms.node_alias)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == params.node_alias))
.ok_or_else(|| MutationError::NodeNotFound(params.node_alias.clone()))?;
let node_name = node.name.clone();
let node_id = node.nodeid.clone();
let nodegroup_id = node
.nodegroup_id
.clone()
.ok_or_else(|| MutationError::NodegroupNotFound(params.node_alias.clone()))?;
if let Some(cards) = graph.cards.as_mut() {
if let Some(card) = cards.iter_mut().find(|c| c.nodegroup_id == nodegroup_id) {
card.name = StaticTranslatableString::from_string(&node_name);
}
}
if let Some(cxnxws) = graph.cards_x_nodes_x_widgets.as_mut() {
for cxnxw in cxnxws.iter_mut().filter(|c| c.node_id == node_id) {
cxnxw.label = StaticTranslatableString::from_string(&node_name);
if let serde_json::Value::Object(ref mut map) = cxnxw.config {
map.insert(
"label".to_string(),
serde_json::Value::String(node_name.clone()),
);
}
}
}
Ok(())
}
fn apply_rename_graph(
graph: &mut StaticGraph,
params: RenameGraphParams,
) -> Result<(), MutationError> {
if let Some(name_map) = params.name {
let new_name = StaticTranslatableString::from_translations(name_map, None);
graph.name = new_name.clone();
let root_display_name = new_name.to_string_default();
graph.root.name = root_display_name.clone();
let new_slug = slugify(&root_display_name);
graph.slug = Some(new_slug.clone());
graph.root.alias = Some(new_slug.clone());
if let Some(root_node) = graph.nodes.iter_mut().find(|n| n.istopnode) {
root_node.name = root_display_name;
root_node.alias = Some(new_slug);
}
}
if let Some(desc_map) = params.description {
graph.description = Some(StaticTranslatableString::from_translations(desc_map, None));
}
if let Some(subtitle_map) = params.subtitle {
graph.subtitle = Some(StaticTranslatableString::from_translations(
subtitle_map,
None,
));
}
if let Some(author) = params.author {
graph.author = if author.is_empty() {
None
} else {
Some(author)
};
}
Ok(())
}
fn apply_coppice_subgraph(
graph: &mut StaticGraph,
params: CoppiceSubgraphParams,
) -> Result<(), MutationError> {
let root_nodeid = graph
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some(¶ms.subject))
.map(|n| n.nodeid.clone())
.ok_or_else(|| {
MutationError::NodeNotFound(format!(
"coppice_subgraph: node with alias '{}' not found",
params.subject
))
})?;
let mut children: HashMap<String, Vec<String>> = HashMap::new();
for edge in &graph.edges {
children
.entry(edge.domainnode_id.clone())
.or_default()
.push(edge.rangenode_id.clone());
}
let mut queue = std::collections::VecDeque::new();
queue.push_back(root_nodeid);
while let Some(nid) = queue.pop_front() {
if let Some(node) = graph.nodes.iter_mut().find(|n| n.nodeid == nid) {
let existing = node.sourcebranchpublication_id.as_deref();
if existing.is_some() && existing != Some(¶ms.publication_id) {
continue;
}
node.sourcebranchpublication_id = Some(params.publication_id.clone());
}
if let Some(child_ids) = children.get(&nid) {
for child_id in child_ids {
queue.push_back(child_id.clone());
}
}
}
Ok(())
}
struct IdRemapper {
graph_id: String,
suffix: String,
branch_publication_id: Option<String>,
node_map: HashMap<String, String>,
nodegroup_map: HashMap<String, String>,
edge_map: HashMap<String, String>,
card_map: HashMap<String, String>,
cxnxw_map: HashMap<String, String>,
constraint_map: HashMap<String, String>,
alias_map: HashMap<String, String>,
}
impl IdRemapper {
fn new(graph_id: &str, suffix: Option<&str>, branch_publication_id: Option<String>) -> Self {
Self {
graph_id: graph_id.to_string(),
suffix: suffix.unwrap_or("").to_string(),
branch_publication_id,
node_map: HashMap::new(),
nodegroup_map: HashMap::new(),
edge_map: HashMap::new(),
card_map: HashMap::new(),
cxnxw_map: HashMap::new(),
constraint_map: HashMap::new(),
alias_map: HashMap::new(),
}
}
fn remap_node(&mut self, old_id: &str) -> String {
let new_id = generate_uuid_v5(
("graph", Some(&self.graph_id)),
&format!("subgraph-node-{}-{}", old_id, self.suffix),
);
self.node_map.insert(old_id.to_string(), new_id.clone());
new_id
}
fn remap_nodegroup(&mut self, old_id: &str) -> String {
let new_id = generate_uuid_v5(
("graph", Some(&self.graph_id)),
&format!("subgraph-ng-{}-{}", old_id, self.suffix),
);
self.nodegroup_map
.insert(old_id.to_string(), new_id.clone());
new_id
}
fn remap_edge(&mut self, old_id: &str) -> String {
let new_id = generate_uuid_v5(
("graph", Some(&self.graph_id)),
&format!("subgraph-edge-{}-{}", old_id, self.suffix),
);
self.edge_map.insert(old_id.to_string(), new_id.clone());
new_id
}
fn remap_card(&mut self, old_id: &str) -> String {
let new_id = generate_uuid_v5(
("graph", Some(&self.graph_id)),
&format!("subgraph-card-{}-{}", old_id, self.suffix),
);
self.card_map.insert(old_id.to_string(), new_id.clone());
new_id
}
fn remap_cxnxw(&mut self, old_id: &str) -> String {
let new_id = generate_uuid_v5(
("graph", Some(&self.graph_id)),
&format!("subgraph-cxnxw-{}-{}", old_id, self.suffix),
);
self.cxnxw_map.insert(old_id.to_string(), new_id.clone());
new_id
}
fn remap_constraint(&mut self, old_id: &str) -> String {
let new_id = generate_uuid_v5(
("graph", Some(&self.graph_id)),
&format!("subgraph-constraint-{}-{}", old_id, self.suffix),
);
self.constraint_map
.insert(old_id.to_string(), new_id.clone());
new_id
}
fn get_node(&self, old_id: &str) -> Option<&String> {
self.node_map.get(old_id)
}
fn get_nodegroup(&self, old_id: &str) -> Option<&String> {
self.nodegroup_map.get(old_id)
}
fn get_card(&self, old_id: &str) -> Option<&String> {
self.card_map.get(old_id)
}
fn register_alias(&mut self, old_alias: &str, new_alias: String) {
self.alias_map.insert(old_alias.to_string(), new_alias);
}
fn get_alias(&self, alias: Option<&str>) -> Option<String> {
alias.map(|a| {
self.alias_map
.get(a)
.cloned()
.unwrap_or_else(|| a.to_string())
})
}
}
fn make_name_unique(name: &str, existing: &HashSet<String>) -> String {
if !existing.contains(name) {
return name.to_string();
}
let mut counter = 1;
loop {
let candidate = format!("{}_n{}", name, counter);
if !existing.contains(&candidate) {
return candidate;
}
counter += 1;
}
}
fn apply_add_subgraph(
graph: &mut StaticGraph,
params: AddSubgraphParams,
) -> Result<(), MutationError> {
let subgraph = params.subgraph;
let target_node_id = params.target_node_id;
let ontology_property = params.ontology_property;
let alias_suffix = params.alias_suffix;
let branch_publication_id = subgraph
.publication
.as_ref()
.and_then(|p| p.get("publicationid"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| MutationError::InvalidSubgraph(format!(
"Subgraph '{}' has no publication.publicationid — the branch must be published before it can be added as a subgraph",
subgraph.graphid
)))?;
let target_node = graph
.find_node_by_alias(&target_node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == target_node_id))
.ok_or_else(|| MutationError::NodeNotFound(target_node_id.clone()))?;
let target_node_id = target_node.nodeid.clone();
let target_nodegroup_id = target_node.nodegroup_id.clone();
let root_node = subgraph
.nodes
.iter()
.find(|n| n.istopnode)
.or(Some(&subgraph.root))
.ok_or(MutationError::BranchHasNoRoot)?;
let root_node_id = root_node.nodeid.clone();
let root_nodegroup_id = root_node
.nodegroup_id
.clone()
.unwrap_or_else(|| root_node_id.clone());
let mut existing_aliases: HashSet<String> =
graph.nodes.iter().filter_map(|n| n.alias.clone()).collect();
let suffix_ref = alias_suffix.as_deref();
let mut remapper = IdRemapper::new(&graph.graphid, suffix_ref, Some(branch_publication_id));
for node in &subgraph.nodes {
if node.nodeid == root_node_id {
continue; }
if let Some(ref alias) = node.alias {
let prefixed_alias = if let Some(ref prefix) = params.alias_prefix {
format!("{}_{}", prefix, alias)
} else {
alias.clone()
};
let new_alias = make_name_unique(&prefixed_alias, &existing_aliases);
if new_alias != *alias {
remapper.register_alias(alias, new_alias.clone());
}
existing_aliases.insert(new_alias);
}
}
for node in &subgraph.nodes {
remapper.remap_node(&node.nodeid);
}
for nodegroup in &subgraph.nodegroups {
if nodegroup.nodegroupid != root_nodegroup_id {
if let Some(node_id) = remapper.get_node(&nodegroup.nodegroupid) {
let node_id = node_id.clone();
remapper
.nodegroup_map
.insert(nodegroup.nodegroupid.clone(), node_id);
} else {
remapper.remap_nodegroup(&nodegroup.nodegroupid);
}
}
}
for edge in &subgraph.edges {
remapper.remap_edge(&edge.edgeid);
}
if let Some(ref cards) = subgraph.cards {
for card in cards {
remapper.remap_card(&card.cardid);
}
}
if let Some(ref cxnxws) = subgraph.cards_x_nodes_x_widgets {
for cxnxw in cxnxws {
remapper.remap_cxnxw(&cxnxw.id);
}
}
for node in subgraph.nodes {
let is_branch_root = node.nodeid == root_node_id;
let new_node_id = remapper
.get_node(&node.nodeid)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!("Node {} not mapped", node.nodeid))
})?
.clone();
let new_nodegroup_id = if is_branch_root {
Some(new_node_id.clone())
} else {
node.nodegroup_id.as_ref().and_then(|ng_id| {
if *ng_id == root_nodegroup_id {
remapper.get_node(&root_node_id).cloned()
} else {
remapper.get_nodegroup(ng_id).cloned()
}
})
};
let prefixed_name = if let Some(ref prefix) = params.name_prefix {
format!("{} {}", prefix, node.name)
} else {
node.name
};
let new_node = StaticNode {
nodeid: new_node_id,
name: prefixed_name,
alias: remapper.get_alias(node.alias.as_deref()),
datatype: node.datatype,
nodegroup_id: new_nodegroup_id,
graph_id: graph.graphid.clone(),
is_collector: node.is_collector,
isrequired: node.isrequired,
exportable: node.exportable,
sortorder: node.sortorder,
config: node.config,
parentproperty: node.parentproperty,
ontologyclass: node.ontologyclass,
description: node.description,
fieldname: node.fieldname,
hascustomalias: node.hascustomalias,
issearchable: node.issearchable,
istopnode: false, sourcebranchpublication_id: remapper.branch_publication_id.clone(),
source_identifier_id: node.source_identifier_id,
is_immutable: node.is_immutable,
};
graph.push_node(new_node);
}
for nodegroup in subgraph.nodegroups {
if nodegroup.nodegroupid == root_nodegroup_id {
let new_root_id = remapper
.get_node(&root_node_id)
.ok_or_else(|| {
MutationError::InvalidSubgraph("Branch root not mapped".to_string())
})?
.clone();
let new_ng = StaticNodegroup {
nodegroupid: new_root_id.clone(),
cardinality: nodegroup.cardinality.clone(),
parentnodegroup_id: target_nodegroup_id.clone(),
legacygroupid: nodegroup.legacygroupid.clone(),
grouping_node_id: Some(new_root_id),
};
graph.push_nodegroup(new_ng);
continue;
}
let new_ng_id = remapper
.get_nodegroup(&nodegroup.nodegroupid)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!(
"Nodegroup {} not mapped",
nodegroup.nodegroupid
))
})?
.clone();
let new_parent_ng_id = nodegroup.parentnodegroup_id.as_ref().and_then(|parent_id| {
if *parent_id == root_nodegroup_id {
remapper.get_node(&root_node_id).cloned()
} else {
remapper.get_nodegroup(parent_id).cloned()
}
});
let new_grouping_node_id = nodegroup
.grouping_node_id
.as_ref()
.and_then(|gn_id| remapper.get_node(gn_id).cloned());
let new_nodegroup = StaticNodegroup {
nodegroupid: new_ng_id,
cardinality: nodegroup.cardinality,
parentnodegroup_id: new_parent_ng_id,
legacygroupid: nodegroup.legacygroupid,
grouping_node_id: new_grouping_node_id,
};
graph.push_nodegroup(new_nodegroup);
}
{
let new_root_id = remapper
.get_node(&root_node_id)
.ok_or_else(|| MutationError::InvalidSubgraph("Branch root not mapped".to_string()))?
.clone();
let connect_edge_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!(
"subgraph-connect-{}-{}-{}",
target_node_id, new_root_id, remapper.suffix
),
);
let connect_edge = StaticEdge {
edgeid: connect_edge_id,
domainnode_id: target_node_id.clone(),
rangenode_id: new_root_id,
graph_id: graph.graphid.clone(),
name: None,
ontologyproperty: if ontology_property.is_empty() {
None
} else {
Some(ontology_property.clone())
},
description: None,
source_identifier_id: None,
};
graph.push_edge(connect_edge);
}
for edge in subgraph.edges {
{
let new_edge_id = remapper
.edge_map
.get(&edge.edgeid)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!("Edge {} not mapped", edge.edgeid))
})?
.clone();
let new_domain = remapper
.get_node(&edge.domainnode_id)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!(
"Domain node {} not mapped",
edge.domainnode_id
))
})?
.clone();
let new_range = remapper
.get_node(&edge.rangenode_id)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!(
"Range node {} not mapped",
edge.rangenode_id
))
})?
.clone();
let new_edge = StaticEdge {
edgeid: new_edge_id,
domainnode_id: new_domain,
rangenode_id: new_range,
graph_id: graph.graphid.clone(),
name: edge.name,
ontologyproperty: edge.ontologyproperty,
description: edge.description,
source_identifier_id: None,
};
graph.push_edge(new_edge);
}
}
if let Some(cards) = subgraph.cards {
for card in cards {
let new_card_id = remapper
.get_card(&card.cardid)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!("Card {} not mapped", card.cardid))
})?
.clone();
let new_ng_id = if card.nodegroup_id == root_nodegroup_id {
remapper
.get_node(&root_node_id)
.ok_or_else(|| {
MutationError::InvalidSubgraph("Branch root not mapped".to_string())
})?
.clone()
} else {
remapper
.get_nodegroup(&card.nodegroup_id)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!(
"Card nodegroup {} not mapped",
card.nodegroup_id
))
})?
.clone()
};
let new_constraints: Vec<_> = card
.constraints
.into_iter()
.map(|c| {
let new_constraint_id = remapper.remap_constraint(&c.constraintid);
let new_nodes: Vec<_> = c
.nodes
.into_iter()
.filter_map(|n| remapper.get_node(&n).cloned())
.collect();
crate::graph::StaticConstraint {
card_id: new_card_id.clone(),
constraintid: new_constraint_id,
nodes: new_nodes,
uniquetoallinstances: c.uniquetoallinstances,
}
})
.collect();
let new_card = StaticCard {
active: card.active,
cardid: new_card_id,
component_id: card.component_id, config: card.config,
constraints: new_constraints,
cssclass: card.cssclass,
description: card.description,
graph_id: graph.graphid.clone(),
helpenabled: card.helpenabled,
helptext: card.helptext,
helptitle: card.helptitle,
instructions: card.instructions,
is_editable: card.is_editable,
name: card.name,
nodegroup_id: new_ng_id,
sortorder: Some(card.sortorder.unwrap_or(0)),
visible: card.visible,
source_identifier_id: None,
};
graph.push_card(new_card);
}
}
if let Some(cxnxws) = subgraph.cards_x_nodes_x_widgets {
for cxnxw in cxnxws {
let new_id = remapper
.cxnxw_map
.get(&cxnxw.id)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!("CXNXW {} not mapped", cxnxw.id))
})?
.clone();
let new_card_id = remapper
.get_card(&cxnxw.card_id)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!(
"CXNXW card {} not mapped",
cxnxw.card_id
))
})?
.clone();
let new_node_id = remapper
.get_node(&cxnxw.node_id)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!(
"CXNXW node {} not mapped",
cxnxw.node_id
))
})?
.clone();
let new_cxnxw = StaticCardsXNodesXWidgets {
id: new_id,
card_id: new_card_id,
node_id: new_node_id,
widget_id: cxnxw.widget_id, config: cxnxw.config,
label: cxnxw.label,
sortorder: Some(cxnxw.sortorder.unwrap_or(0)),
visible: cxnxw.visible,
source_identifier_id: None,
};
graph.push_card_x_node_x_widget(new_cxnxw);
}
}
Ok(())
}
fn apply_update_subgraph(
graph: &mut StaticGraph,
params: UpdateSubgraphParams,
) -> Result<(), MutationError> {
let subgraph = params.subgraph;
let target_node_id = params.target_node_id.clone();
let ontology_property = params.ontology_property;
let remove_orphaned = params.remove_orphaned;
let branch_publication_id = subgraph
.publication
.as_ref()
.and_then(|p| p.get("publicationid"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| MutationError::InvalidSubgraph(format!(
"Subgraph '{}' has no publication.publicationid — the branch must be published before it can be added as a subgraph",
subgraph.graphid
)))?;
let target_node_ref = graph
.find_node_by_alias(&target_node_id)
.or_else(|| graph.nodes.iter().find(|n| n.nodeid == target_node_id))
.ok_or_else(|| MutationError::NodeNotFound(target_node_id.clone()))?;
let target_node_id = target_node_ref.nodeid.clone();
let root_node = subgraph
.nodes
.iter()
.find(|n| n.istopnode)
.or(Some(&subgraph.root))
.ok_or(MutationError::BranchHasNoRoot)?;
let root_node_id = root_node.nodeid.clone();
let existing_branch_nodes =
find_branch_nodes_by_traversal(graph, &target_node_id, &branch_publication_id)?;
if existing_branch_nodes.is_empty() {
return apply_add_subgraph(
graph,
AddSubgraphParams {
subgraph,
target_node_id: params.target_node_id,
ontology_property,
alias_suffix: params.alias_suffix,
alias_prefix: params.alias_prefix,
name_prefix: params.name_prefix,
},
);
}
let existing_by_alias: HashMap<String, String> = existing_branch_nodes
.iter()
.filter_map(|(node_id, alias)| alias.clone().map(|a| (a, node_id.clone())))
.collect();
let new_branch_aliases: HashSet<String> = subgraph
.nodes
.iter()
.filter(|n| n.nodeid != root_node_id)
.filter_map(|n| n.alias.clone())
.collect();
let mut nodes_to_update: Vec<(&StaticNode, String)> = Vec::new(); let mut nodes_to_add: Vec<&StaticNode> = Vec::new();
for node in &subgraph.nodes {
if node.nodeid == root_node_id {
continue; }
if let Some(ref alias) = node.alias {
let lookup_alias = if let Some(ref prefix) = params.alias_prefix {
format!("{}_{}", prefix, alias)
} else {
alias.clone()
};
if let Some(existing_node_id) = existing_by_alias.get(&lookup_alias) {
nodes_to_update.push((node, existing_node_id.clone()));
} else {
nodes_to_add.push(node);
}
} else {
nodes_to_add.push(node);
}
}
let expected_existing_aliases: HashSet<String> = if let Some(ref prefix) = params.alias_prefix {
new_branch_aliases
.iter()
.map(|a| format!("{}_{}", prefix, a))
.collect()
} else {
new_branch_aliases.clone()
};
let orphaned_node_ids: HashSet<String> = if remove_orphaned {
existing_branch_nodes
.iter()
.filter(|(_, alias)| {
alias
.as_ref()
.map(|a| !expected_existing_aliases.contains(a))
.unwrap_or(true)
})
.map(|(node_id, _)| node_id.clone())
.collect()
} else {
HashSet::new()
};
for (new_node, existing_node_id) in nodes_to_update {
if let Some(existing) = graph
.nodes
.iter_mut()
.find(|n| n.nodeid == existing_node_id)
{
existing.name = if let Some(ref prefix) = params.name_prefix {
format!("{} {}", prefix, new_node.name)
} else {
new_node.name.clone()
};
existing.datatype = new_node.datatype.clone();
existing.ontologyclass = new_node.ontologyclass.clone();
existing.config = new_node.config.clone();
existing.description = new_node.description.clone();
existing.isrequired = new_node.isrequired;
existing.issearchable = new_node.issearchable;
existing.exportable = new_node.exportable;
existing.sortorder = new_node.sortorder;
existing.is_collector = new_node.is_collector;
existing.is_immutable = new_node.is_immutable;
}
}
if !nodes_to_add.is_empty() {
let target_node = graph
.nodes
.iter()
.find(|n| n.nodeid == target_node_id)
.ok_or_else(|| MutationError::NodeNotFound(target_node_id.clone()))?;
let target_nodegroup_id = target_node.nodegroup_id.clone();
let root_nodegroup_id = root_node
.nodegroup_id
.clone()
.unwrap_or_else(|| root_node_id.clone());
let mut existing_aliases: HashSet<String> =
graph.nodes.iter().filter_map(|n| n.alias.clone()).collect();
let suffix_ref = params.alias_suffix.as_deref();
let mut remapper = IdRemapper::new(
&graph.graphid,
suffix_ref,
Some(branch_publication_id.clone()),
);
for node in &nodes_to_add {
if let Some(ref alias) = node.alias {
let prefixed_alias = if let Some(ref prefix) = params.alias_prefix {
format!("{}_{}", prefix, alias)
} else {
alias.clone()
};
let new_alias = make_name_unique(&prefixed_alias, &existing_aliases);
if new_alias != *alias {
remapper.register_alias(alias, new_alias.clone());
}
existing_aliases.insert(new_alias);
}
}
for node in &nodes_to_add {
remapper.remap_node(&node.nodeid);
}
let new_node_nodegroups: HashSet<String> = nodes_to_add
.iter()
.filter_map(|n| n.nodegroup_id.clone())
.filter(|ng_id| *ng_id != root_nodegroup_id)
.collect();
for nodegroup in &subgraph.nodegroups {
if new_node_nodegroups.contains(&nodegroup.nodegroupid) {
remapper.remap_nodegroup(&nodegroup.nodegroupid);
}
}
for node in nodes_to_add {
let new_node_id = remapper
.get_node(&node.nodeid)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!("Node {} not mapped", node.nodeid))
})?
.clone();
let new_nodegroup_id = node.nodegroup_id.as_ref().and_then(|ng_id| {
if *ng_id == root_nodegroup_id {
target_nodegroup_id.clone()
} else {
remapper.get_nodegroup(ng_id).cloned()
}
});
let prefixed_name = if let Some(ref prefix) = params.name_prefix {
format!("{} {}", prefix, node.name)
} else {
node.name.clone()
};
let new_node = StaticNode {
nodeid: new_node_id.clone(),
name: prefixed_name,
alias: remapper.get_alias(node.alias.as_deref()),
datatype: node.datatype.clone(),
nodegroup_id: new_nodegroup_id,
graph_id: graph.graphid.clone(),
is_collector: node.is_collector,
isrequired: node.isrequired,
exportable: node.exportable,
sortorder: node.sortorder,
config: node.config.clone(),
parentproperty: node.parentproperty.clone(),
ontologyclass: node.ontologyclass.clone(),
description: node.description.clone(),
fieldname: node.fieldname.clone(),
hascustomalias: node.hascustomalias,
issearchable: node.issearchable,
istopnode: false,
sourcebranchpublication_id: Some(branch_publication_id.clone()),
source_identifier_id: node.source_identifier_id.clone(),
is_immutable: node.is_immutable,
};
graph.push_node(new_node);
let original_edge = subgraph
.edges
.iter()
.find(|e| e.domainnode_id == root_node_id && e.rangenode_id == node.nodeid);
let new_edge_id = generate_uuid_v5(
("graph", Some(&graph.graphid)),
&format!("update-subgraph-edge-{}-{}", target_node_id, new_node_id),
);
let new_edge = StaticEdge {
edgeid: new_edge_id,
domainnode_id: target_node_id.clone(),
rangenode_id: new_node_id,
graph_id: graph.graphid.clone(),
name: original_edge.and_then(|e| e.name.clone()),
ontologyproperty: if ontology_property.is_empty() {
original_edge.and_then(|e| e.ontologyproperty.clone())
} else {
Some(ontology_property.clone())
},
description: original_edge.and_then(|e| e.description.clone()),
source_identifier_id: None,
};
graph.push_edge(new_edge);
}
for nodegroup in subgraph.nodegroups {
if !new_node_nodegroups.contains(&nodegroup.nodegroupid) {
continue;
}
let new_ng_id = remapper
.get_nodegroup(&nodegroup.nodegroupid)
.ok_or_else(|| {
MutationError::InvalidSubgraph(format!(
"Nodegroup {} not mapped",
nodegroup.nodegroupid
))
})?
.clone();
let new_parent_ng_id = nodegroup.parentnodegroup_id.as_ref().and_then(|parent_id| {
if *parent_id == root_nodegroup_id {
target_nodegroup_id.clone()
} else {
remapper.get_nodegroup(parent_id).cloned()
}
});
let new_grouping_node_id = nodegroup
.grouping_node_id
.as_ref()
.and_then(|gn_id| remapper.get_node(gn_id).cloned());
let new_nodegroup = StaticNodegroup {
nodegroupid: new_ng_id,
cardinality: nodegroup.cardinality,
parentnodegroup_id: new_parent_ng_id,
legacygroupid: nodegroup.legacygroupid,
grouping_node_id: new_grouping_node_id,
};
graph.push_nodegroup(new_nodegroup);
}
}
if remove_orphaned && !orphaned_node_ids.is_empty() {
graph
.nodes
.retain(|n| !orphaned_node_ids.contains(&n.nodeid));
graph.edges.retain(|e| {
!orphaned_node_ids.contains(&e.domainnode_id)
&& !orphaned_node_ids.contains(&e.rangenode_id)
});
if let Some(ref mut cxnxws) = graph.cards_x_nodes_x_widgets {
cxnxws.retain(|c| !orphaned_node_ids.contains(&c.node_id));
}
}
Ok(())
}
fn find_branch_nodes_by_traversal(
graph: &StaticGraph,
target_node_id: &str,
expected_branch_id: &str,
) -> Result<HashMap<String, Option<String>>, MutationError> {
let mut branch_nodes: HashMap<String, Option<String>> = HashMap::new();
let mut visited: HashSet<String> = HashSet::new();
let mut queue: Vec<String> = Vec::new();
for edge in &graph.edges {
if edge.domainnode_id == target_node_id {
queue.push(edge.rangenode_id.clone());
}
}
while let Some(node_id) = queue.pop() {
if visited.contains(&node_id) {
continue;
}
visited.insert(node_id.clone());
let node = match graph.nodes.iter().find(|n| n.nodeid == node_id) {
Some(n) => n,
None => continue, };
match &node.sourcebranchpublication_id {
Some(pub_id) if pub_id == expected_branch_id => {
branch_nodes.insert(node_id.clone(), node.alias.clone());
for edge in &graph.edges {
if edge.domainnode_id == node_id && !visited.contains(&edge.rangenode_id) {
queue.push(edge.rangenode_id.clone());
}
}
}
Some(_pub_id) => {
continue;
}
None => {
continue;
}
}
}
Ok(branch_nodes)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MutationRequest {
pub mutations: Vec<GraphMutation>,
#[serde(default)]
pub options: MutationRequestOptions,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MutationRequestOptions {
#[serde(default = "default_true")]
pub autocreate_card: bool,
#[serde(default = "default_true")]
pub autocreate_widget: bool,
}
fn default_true() -> bool {
true
}
impl From<MutationRequestOptions> for MutatorOptions {
fn from(opts: MutationRequestOptions) -> Self {
MutatorOptions {
autocreate_card: opts.autocreate_card,
autocreate_widget: opts.autocreate_widget,
ontology_validator: None,
skip_publication: false,
}
}
}
pub fn apply_mutations_from_json(
graph: &StaticGraph,
mutations_json: &str,
) -> Result<StaticGraph, String> {
apply_mutations_from_json_with_extensions(graph, mutations_json, None)
}
pub fn apply_mutations_from_json_with_extensions(
graph: &StaticGraph,
mutations_json: &str,
registry: Option<&ExtensionMutationRegistry>,
) -> Result<StaticGraph, String> {
let request: MutationRequest = serde_json::from_str(mutations_json)
.map_err(|e| format!("Failed to parse mutations JSON: {}", e))?;
apply_mutations_with_extensions(graph, request.mutations, request.options.into(), registry)
}
pub fn apply_mutations_create_from_json(
mutations_json: &str,
graph: Option<&StaticGraph>,
) -> Result<StaticGraph, String> {
let request: MutationRequest = serde_json::from_str(mutations_json)
.map_err(|e| format!("Failed to parse mutations JSON: {}", e))?;
let mut mutations = request.mutations;
let options: MutatorOptions = request.options.into();
match graph {
None => {
if mutations.is_empty() {
return Err("No graph provided and no mutations to apply".to_string());
}
let first = mutations.remove(0);
match first {
GraphMutation::CreateGraph(params) => {
let mut new_graph = create_skeleton_graph(
¶ms.name,
¶ms.root_alias,
params.is_resource,
params.root_ontology_class.as_deref(),
);
if let Some(ref custom_id) = params.graph_id {
let old_id = new_graph.graphid.clone();
new_graph.graphid = custom_id.clone();
for node in &mut new_graph.nodes {
if node.graph_id == old_id {
node.graph_id = custom_id.clone();
}
}
if new_graph.root.graph_id == old_id {
new_graph.root.graph_id = custom_id.clone();
}
}
if let Some(author) = params.author {
new_graph.author = Some(author);
}
if let Some(desc) = params.description {
new_graph.description = Some(StaticTranslatableString::from_string(&desc));
}
if params.ontology_id.is_some() {
new_graph.ontology_id = params.ontology_id;
}
if mutations.is_empty() {
if !options.skip_publication {
stamp_publication(&mut new_graph);
}
new_graph.build_indices();
Ok(new_graph)
} else {
apply_mutations_with_extensions(&new_graph, mutations, options, None)
}
}
_ => Err("No graph provided and first mutation is not CreateGraph".to_string()),
}
}
Some(existing_graph) => {
if let Some(GraphMutation::CreateGraph(_)) = mutations.first() {
return Err("CreateGraph cannot be used when a graph already exists".to_string());
}
apply_mutations_with_extensions(existing_graph, mutations, options, None)
}
}
}
pub fn apply_mutations(
graph: &StaticGraph,
mutations: Vec<GraphMutation>,
options: MutatorOptions,
) -> Result<StaticGraph, String> {
apply_mutations_with_extensions(graph, mutations, options, None)
}
pub fn apply_mutations_with_extensions(
graph: &StaticGraph,
mutations: Vec<GraphMutation>,
options: MutatorOptions,
registry: Option<&ExtensionMutationRegistry>,
) -> Result<StaticGraph, String> {
let mut result = graph.deep_clone();
for mutation in mutations {
apply_mutation_with_extensions(&mut result, mutation, &options, registry)
.map_err(|e| e.to_string())?;
}
if !options.skip_publication {
stamp_publication(&mut result);
}
result.build_indices();
Ok(result)
}
fn stamp_publication(graph: &mut StaticGraph) {
let now = chrono::Utc::now();
let timestamp = now.timestamp_millis().to_string();
let publication_id = generate_uuid_v5(("publication", Some(&graph.graphid)), ×tamp);
let published_time = now.format("%Y-%m-%dT%H:%M:%S%.3f").to_string();
graph.publication = Some(serde_json::json!({
"publicationid": publication_id,
"graph_id": graph.graphid,
"published_time": published_time,
"notes": null
}));
}
pub fn mutations_to_json(mutations: &[GraphMutation]) -> Result<String, String> {
serde_json::to_string_pretty(mutations)
.map_err(|e| format!("Failed to serialize mutations: {}", e))
}
pub fn create_skeleton_graph(
name: &str,
root_alias: &str,
is_resource: bool,
ontology_classes: Option<&[String]>,
) -> StaticGraph {
let graphid = generate_uuid_v5(("skeleton", None), name);
let root_nodeid = generate_uuid_v5(("graph", Some(&graphid)), &format!("root-{}", root_alias));
let root_nodegroup_id: serde_json::Value = if is_resource {
serde_json::Value::Null
} else {
serde_json::Value::String(root_nodeid.clone())
};
let root_is_collector = !is_resource;
let ontology_class_json = match ontology_classes {
None | Some([]) => serde_json::Value::Null,
Some([single]) => serde_json::Value::String(single.clone()),
Some(list) => serde_json::Value::Array(
list.iter()
.map(|s| serde_json::Value::String(s.clone()))
.collect(),
),
};
let graph_json = serde_json::json!({
"graphid": graphid,
"name": { "en": name },
"isresource": is_resource,
"is_active": is_resource,
"is_editable": true,
"config": {},
"template_id": "50000000-0000-0000-0000-000000000001",
"version": "1",
"nodes": [{
"nodeid": root_nodeid,
"name": name,
"alias": root_alias,
"datatype": "semantic",
"nodegroup_id": root_nodegroup_id,
"graph_id": graphid,
"is_collector": root_is_collector,
"isrequired": false,
"exportable": true,
"sortorder": 0,
"istopnode": true,
"issearchable": true,
"ontologyclass": ontology_class_json.clone()
}],
"root": {
"nodeid": root_nodeid,
"name": name,
"alias": root_alias,
"datatype": "semantic",
"nodegroup_id": root_nodegroup_id,
"graph_id": graphid,
"is_collector": root_is_collector,
"isrequired": false,
"exportable": true,
"sortorder": 0,
"istopnode": true,
"issearchable": true,
"ontologyclass": ontology_class_json.clone()
},
"nodegroups": [],
"edges": [],
"cards": [],
"cards_x_nodes_x_widgets": [],
"functions_x_graphs": []
});
let mut graph: StaticGraph =
serde_json::from_value(graph_json).expect("Failed to create skeleton graph");
graph.build_indices();
graph
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphInstruction {
pub action: String,
pub subject: String,
#[serde(default)]
pub object: String,
#[serde(default)]
pub params: HashMap<String, serde_json::Value>,
}
impl GraphInstruction {
pub fn new(action: &str, subject: &str, object: &str) -> Self {
Self {
action: action.to_string(),
subject: subject.to_string(),
object: object.to_string(),
params: HashMap::new(),
}
}
pub fn with_param(mut self, key: &str, value: serde_json::Value) -> Self {
self.params.insert(key.to_string(), value);
self
}
pub fn with_str(self, key: &str, value: &str) -> Self {
self.with_param(key, serde_json::Value::String(value.to_string()))
}
fn get_str(&self, key: &str) -> Option<String> {
self.params
.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
}
fn get_str_or(&self, key: &str, default: &str) -> String {
self.get_str(key).unwrap_or_else(|| default.to_string())
}
fn get_class_list(&self, key: &str) -> Option<Vec<String>> {
let raw: Vec<String> = match self.params.get(key)? {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => return None,
};
sanitize_class_list(Some(raw))
}
fn resolve_subgraph(&self) -> Result<StaticGraph, MutationError> {
if let Some(subgraph_value) = self.params.get("subgraph") {
serde_json::from_value(subgraph_value.clone()).map_err(|e| {
MutationError::InvalidSubgraph(format!("Failed to parse subgraph: {}", e))
})
} else if !self.object.is_empty() {
let graph = crate::registry::get_graph(&self.object).ok_or_else(|| {
MutationError::InvalidSubgraph(format!(
"Branch '{}' not found in graph registry",
self.object
))
})?;
Ok((*graph).clone())
} else {
Err(MutationError::InvalidSubgraph(
"add_subgraph/update_subgraph requires either 'subgraph' param or a branch graph ID as object".to_string(),
))
}
}
fn get_translatable_map(&self, key: &str) -> Option<HashMap<String, String>> {
self.params.get(key).and_then(|v| {
if let Some(obj) = v.as_object() {
let map: HashMap<String, String> = obj
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
if map.is_empty() {
None
} else {
Some(map)
}
} else {
None
}
})
}
pub fn to_mutation(&self) -> Result<GraphMutation, MutationError> {
match self.action.as_str() {
"add_node" => {
let cardinality_str = self.get_str_or("cardinality", "1");
let cardinality = match cardinality_str.as_str() {
"1" | "one" | "One" => Cardinality::One,
"n" | "N" | "many" => Cardinality::N,
_ => {
return Err(MutationError::InvalidSubgraph(format!(
"Invalid cardinality: {}",
cardinality_str
)))
}
};
Ok(GraphMutation::AddNode(AddNodeParams {
parent_alias: if self.subject.is_empty() {
None
} else {
Some(self.subject.clone())
},
alias: self.object.clone(),
name: self.get_str_or("name", &self.object),
cardinality,
datatype: self.get_str_or("datatype", "semantic"),
ontology_class: self.get_class_list("ontology_class"),
parent_property: self.get_str_or("parent_property", ""),
description: self.get_str("description"),
config: self.params.get("config").cloned(),
options: {
let mut opts: NodeOptions = self
.params
.get("options")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
if opts.is_collector.is_none() {
let dt = self.get_str_or("datatype", "semantic");
if dt == "semantic" && cardinality == Cardinality::N {
opts.is_collector = Some(true);
}
}
opts
},
}))
}
"add_edge" => Ok(GraphMutation::AddEdge(AddEdgeParams {
from_node_id: self.subject.clone(),
to_node_id: self.object.clone(),
ontology_property: self.get_str_or("ontology_property", ""),
name: self.get_str("name"),
description: self.get_str("description"),
})),
"add_nodegroup" => {
let cardinality_str = self.get_str_or("cardinality", "n");
let cardinality = match cardinality_str.as_str() {
"1" | "one" | "One" => Cardinality::One,
"n" | "N" | "many" => Cardinality::N,
_ => Cardinality::N,
};
let nodegroup_id = self
.get_str("nodegroup_id")
.unwrap_or_else(|| format!("ng-{}", self.subject));
Ok(GraphMutation::AddNodegroup(AddNodegroupParams {
nodegroup_id,
cardinality,
parent_alias: if self.subject.is_empty() {
None
} else {
Some(self.subject.clone())
},
}))
}
"add_card" => {
let name = if self.object.is_empty() {
StaticTranslatableString::from_string("Card")
} else {
StaticTranslatableString::from_string(&self.object)
};
Ok(GraphMutation::AddCard(AddCardParams {
nodegroup_id: self.subject.clone(),
name,
component_id: self.get_str("component_id"),
options: CardOptions {
description: self
.get_str("description")
.map(|s| StaticTranslatableString::from_string(&s)),
..CardOptions::default()
},
config: self.params.get("config").cloned(),
}))
}
"add_widget" => Ok(GraphMutation::AddWidgetToCard(AddWidgetParams {
node_id: self.subject.clone(),
widget_id: self.get_str_or("widget_id", "10000000-0000-0000-0000-000000000001"),
label: self.get_str_or("label", ""),
config: self
.params
.get("config")
.cloned()
.unwrap_or(serde_json::Value::Object(serde_json::Map::new())),
sortorder: self
.params
.get("sortorder")
.and_then(|v| v.as_i64())
.map(|i| i as i32),
visible: self.params.get("visible").and_then(|v| v.as_bool()),
})),
"add_subgraph" => {
let subgraph = self.resolve_subgraph()?;
Ok(GraphMutation::AddSubgraph(AddSubgraphParams {
subgraph,
target_node_id: self.subject.clone(),
ontology_property: self.get_str_or("ontology_property", ""),
alias_suffix: self.get_str("alias_suffix"),
alias_prefix: self.get_str("alias_prefix"),
name_prefix: self.get_str("name_prefix"),
}))
}
"update_subgraph" => {
let subgraph = self.resolve_subgraph()?;
let remove_orphaned = self
.params
.get("remove_orphaned")
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(GraphMutation::UpdateSubgraph(UpdateSubgraphParams {
subgraph,
target_node_id: self.subject.clone(),
ontology_property: self.get_str_or("ontology_property", ""),
alias_suffix: self.get_str("alias_suffix"),
remove_orphaned,
alias_prefix: self.get_str("alias_prefix"),
name_prefix: self.get_str("name_prefix"),
}))
}
"concept_change_collection" => Ok(GraphMutation::ConceptChangeCollection(
ConceptChangeCollectionParams {
node_id: self.subject.clone(),
collection_id: self.object.clone(),
},
)),
"delete_card" => Ok(GraphMutation::DeleteCard(DeleteCardParams {
card_id: self.subject.clone(),
})),
"delete_widget" => Ok(GraphMutation::DeleteWidget(DeleteWidgetParams {
widget_mapping_id: self.subject.clone(),
})),
"add_function" => Ok(GraphMutation::AddFunction(AddFunctionParams {
function_id: self.subject.clone(),
config: self.params.get("config").cloned(),
})),
"set_descriptor_function" => Ok(GraphMutation::SetDescriptorFunction(
SetDescriptorFunctionParams {
function_id: self.subject.clone(),
},
)),
"delete_function" => Ok(GraphMutation::DeleteFunction(DeleteFunctionParams {
function_mapping_id: self.subject.clone(),
})),
"set_descriptor_template" => Ok(GraphMutation::SetDescriptorTemplate(
SetDescriptorTemplateParams {
descriptor_type: self.subject.clone(),
string_template: self.object.clone(),
},
)),
"delete_node" => Ok(GraphMutation::DeleteNode(DeleteNodeParams {
node_id: self.subject.clone(),
})),
"delete_nodegroup" => Ok(GraphMutation::DeleteNodegroup(DeleteNodegroupParams {
nodegroup_id: self.subject.clone(),
})),
"update_node" => Ok(GraphMutation::UpdateNode(UpdateNodeParams {
node_id: self.subject.clone(),
name: self.get_str("name"),
ontology_class: self.get_class_list("ontology_class"),
parent_property: self.get_str("parent_property"),
description: self.get_str("description"),
config: self.params.get("config").cloned(),
options: UpdateNodeOptions {
exportable: self.params.get("exportable").and_then(|v| v.as_bool()),
fieldname: self.get_str("fieldname"),
isrequired: self.params.get("isrequired").and_then(|v| v.as_bool()),
issearchable: self.params.get("issearchable").and_then(|v| v.as_bool()),
sortorder: self
.params
.get("sortorder")
.and_then(|v| v.as_i64())
.map(|i| i as i32),
},
})),
"change_node_type" => {
let datatype = self
.get_str("datatype")
.or_else(|| {
if self.object.is_empty() {
None
} else {
Some(self.object.clone())
}
})
.ok_or_else(|| {
MutationError::InvalidSubgraph(
"change_node_type requires 'datatype' param or object".to_string(),
)
})?;
Ok(GraphMutation::ChangeNodeType(ChangeNodeTypeParams {
node_id: self.subject.clone(),
datatype,
name: self.get_str("name"),
ontology_class: self.get_class_list("ontology_class"),
parent_property: self.get_str("parent_property"),
description: self.get_str("description"),
config: self.params.get("config").cloned(),
options: UpdateNodeOptions {
exportable: self.params.get("exportable").and_then(|v| v.as_bool()),
fieldname: self.get_str("fieldname"),
isrequired: self.params.get("isrequired").and_then(|v| v.as_bool()),
issearchable: self.params.get("issearchable").and_then(|v| v.as_bool()),
sortorder: self
.params
.get("sortorder")
.and_then(|v| v.as_i64())
.map(|i| i as i32),
},
}))
}
"change_cardinality" => {
let cardinality_str = self
.get_str("cardinality")
.or_else(|| {
if self.object.is_empty() {
None
} else {
Some(self.object.clone())
}
})
.ok_or_else(|| {
MutationError::InvalidSubgraph(
"change_cardinality requires 'cardinality' param or object (1 or n)"
.to_string(),
)
})?;
let cardinality = match cardinality_str.to_lowercase().as_str() {
"1" | "one" => Cardinality::One,
"n" | "many" => Cardinality::N,
_ => {
return Err(MutationError::InvalidSubgraph(format!(
"Invalid cardinality '{}', expected '1', 'one', 'n', or 'many'",
cardinality_str
)))
}
};
Ok(GraphMutation::ChangeCardinality(ChangeCardinalityParams {
node_id: self.subject.clone(),
cardinality,
}))
}
"rename_node" => Ok(GraphMutation::RenameNode(RenameNodeParams {
node_id: self.subject.clone(),
alias: self.get_str("alias").or_else(|| {
if self.object.is_empty() {
None
} else {
Some(self.object.clone())
}
}),
name: self.get_str("name"),
description: self.get_str("description"),
realign_card: self
.params
.get("realign_card")
.and_then(|v| v.as_bool())
.unwrap_or(true),
})),
"rename_card" => {
let name = self.get_str("name").or_else(|| {
if self.object.is_empty() {
None
} else {
Some(self.object.clone())
}
});
Ok(GraphMutation::RenameCard(RenameCardParams {
card_id: self.subject.clone(),
language: self.get_str("language"),
name,
name_i18n: self.get_translatable_map("name_i18n"),
description: self.get_str("description"),
description_i18n: self.get_translatable_map("description_i18n"),
}))
}
"realign_card_from_node" => Ok(GraphMutation::RealignCardFromNode(
RealignCardFromNodeParams {
node_alias: self.subject.clone(),
},
)),
"rename_graph" => {
let name = self.get_translatable_map("name").or_else(|| {
if self.object.is_empty() {
None
} else {
let mut map = HashMap::new();
map.insert("en".to_string(), self.object.clone());
Some(map)
}
});
Ok(GraphMutation::RenameGraph(RenameGraphParams {
name,
description: self.get_translatable_map("description"),
subtitle: self.get_translatable_map("subtitle"),
author: self.get_str("author"),
}))
}
"update_widget_config" => {
let config = self.params.get("config").cloned().ok_or_else(|| {
MutationError::Other("update_widget_config requires params.config".to_string())
})?;
Ok(GraphMutation::UpdateWidgetConfig(
UpdateWidgetConfigParams {
node_id: self.subject.clone(),
config,
},
))
}
"coppice_subgraph" => {
let publication_id = self.get_str("publication_id").ok_or_else(|| {
MutationError::Other(
"coppice_subgraph requires params.publication_id".to_string(),
)
})?;
Ok(GraphMutation::CoppiceSubgraph(CoppiceSubgraphParams {
subject: self.subject.clone(),
publication_id,
}))
}
"create_model" | "create_branch" => Err(MutationError::InvalidSubgraph(format!(
"'{}' creates a new graph, use build_graph_from_instructions() instead",
self.action
))),
other => Ok(GraphMutation::Extension(ExtensionMutationParams {
name: other.to_string(),
params: {
let mut map = serde_json::Map::new();
if !self.subject.is_empty() {
map.insert(
"subject".to_string(),
serde_json::Value::String(self.subject.clone()),
);
}
if !self.object.is_empty() {
map.insert(
"object".to_string(),
serde_json::Value::String(self.object.clone()),
);
}
for (k, v) in &self.params {
map.insert(k.clone(), v.clone());
}
serde_json::Value::Object(map)
},
conformance: MutationConformance::AlwaysConformant,
})),
}
}
pub fn is_create_action(&self) -> bool {
matches!(
self.action.as_str(),
"create_model" | "create_branch" | "load_graph"
)
}
pub fn conformance(&self) -> MutationConformance {
match self.action.as_str() {
"add_node" | "add_edge" | "add_nodegroup" | "add_card" | "add_widget" => {
MutationConformance::BranchConformant
}
"add_subgraph" | "update_subgraph" | "add_function" | "set_descriptor_function" => {
MutationConformance::ModelConformant
}
"concept_change_collection" => MutationConformance::AlwaysConformant,
"delete_card" | "delete_widget" | "delete_function" | "delete_node"
| "delete_nodegroup" => MutationConformance::AlwaysConformant,
"update_node" | "change_node_type" | "change_cardinality" => {
MutationConformance::BranchConformant
}
"rename_node" | "rename_graph" | "coppice_subgraph" => {
MutationConformance::AlwaysConformant
}
"create_model" => MutationConformance::ModelConformant,
"create_branch" => MutationConformance::BranchConformant,
_ => MutationConformance::NonConformant,
}
}
pub fn to_skeleton_graph(&self) -> Result<StaticGraph, MutationError> {
let is_resource = match self.action.as_str() {
"create_model" => true,
"create_branch" => false,
_ => {
return Err(MutationError::InvalidSubgraph(format!(
"'{}' is not a create action, use to_mutation() instead",
self.action
)))
}
};
let root_alias = &self.subject;
let name = self.get_str_or("name", root_alias);
let ontology_classes = self.get_class_list("ontology_class");
let mut graph =
create_skeleton_graph(&name, root_alias, is_resource, ontology_classes.as_deref());
let ontology_ids = self.get_class_list("ontology_id");
if ontology_ids.is_some() {
graph.ontology_id = ontology_ids;
}
if !self.object.is_empty() {
let new_graphid = self.object.clone();
graph.graphid = new_graphid.clone();
graph.root.graph_id = new_graphid.clone();
for node in &mut graph.nodes {
node.graph_id = new_graphid.clone();
}
}
graph.slug = self
.get_str("slug")
.or_else(|| Some(root_alias.to_lowercase()));
Ok(graph)
}
}
pub fn build_graph_from_instructions(
instructions: Vec<GraphInstruction>,
options: MutatorOptions,
) -> Result<StaticGraph, String> {
build_graph_from_instructions_with_extensions(instructions, options, None)
}
pub fn build_graph_from_instructions_with_extensions(
instructions: Vec<GraphInstruction>,
options: MutatorOptions,
registry: Option<&ExtensionMutationRegistry>,
) -> Result<StaticGraph, String> {
if instructions.is_empty() {
return Err("No instructions provided".to_string());
}
let mut iter = instructions.into_iter();
let first = iter.next().unwrap();
if !first.is_create_action() {
return Err(format!(
"First instruction must be 'create_model', 'create_branch', or 'load_graph', got '{}'",
first.action
));
}
let graph = if first.action == "load_graph" {
let graph_id = &first.subject;
let arc = crate::registry::get_graph(graph_id).ok_or_else(|| {
format!(
"Graph '{}' not found in registry. Call register_graph() first.",
graph_id
)
})?;
(*arc).clone()
} else {
first.to_skeleton_graph().map_err(|e| e.to_string())?
};
let remaining: Vec<GraphInstruction> = iter.collect();
if remaining.is_empty() {
let mut graph = graph;
if !options.skip_publication {
stamp_publication(&mut graph);
}
return Ok(graph);
}
apply_instructions(&graph, remaining, options, registry)
}
pub fn build_graph_from_instructions_json(json: &str) -> Result<StaticGraph, String> {
#[derive(Deserialize)]
struct BuildRequest {
instructions: Vec<GraphInstruction>,
#[serde(default)]
options: MutationRequestOptions,
}
let request: BuildRequest = serde_json::from_str(json)
.map_err(|e| format!("Failed to parse build request JSON: {}", e))?;
build_graph_from_instructions(request.instructions, request.options.into())
}
pub fn parse_instructions_from_csv(csv_text: &str) -> Result<Vec<GraphInstruction>, String> {
let filtered: String = csv_text
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.starts_with('#')
})
.collect::<Vec<_>>()
.join("\n");
let mut reader = csv::Reader::from_reader(filtered.as_bytes());
let headers = reader
.headers()
.map_err(|e| format!("Failed to parse CSV headers: {}", e))?
.clone();
let param_indices: Vec<(usize, String)> = headers
.iter()
.enumerate()
.filter_map(|(i, h)| h.strip_prefix("params.").map(|p| (i, p.to_string())))
.collect();
let action_idx = headers
.iter()
.position(|h| h == "action")
.ok_or("CSV missing 'action' column")?;
let subject_idx = headers
.iter()
.position(|h| h == "subject")
.ok_or("CSV missing 'subject' column")?;
let object_idx = headers
.iter()
.position(|h| h == "object")
.ok_or("CSV missing 'object' column")?;
let mut instructions = Vec::new();
for result in reader.records() {
let record = result.map_err(|e| format!("Failed to parse CSV row: {}", e))?;
let action = record.get(action_idx).unwrap_or("").to_string();
if action.is_empty() || action.starts_with('#') {
continue;
}
let mut params = serde_json::Map::new();
for (idx, param_name) in ¶m_indices {
if let Some(value) = record.get(*idx) {
if !value.is_empty() {
let json_value = serde_json::from_str(value)
.unwrap_or(serde_json::Value::String(value.to_string()));
let parts: Vec<&str> = param_name.splitn(2, '.').collect();
if parts.len() == 2 {
let outer = parts[0];
let inner = parts[1];
let nested = params
.entry(outer.to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let serde_json::Value::Object(ref mut map) = nested {
map.insert(inner.to_string(), json_value);
}
} else {
params.insert(param_name.clone(), json_value);
}
}
}
}
instructions.push(GraphInstruction {
action,
subject: record.get(subject_idx).unwrap_or("").to_string(),
object: record.get(object_idx).unwrap_or("").to_string(),
params: serde_json::Value::Object(params)
.as_object()
.unwrap()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
});
}
Ok(instructions)
}
pub fn build_graph_from_instructions_csv(
csv_text: &str,
options: MutatorOptions,
) -> Result<StaticGraph, String> {
let instructions = parse_instructions_from_csv(csv_text)?;
build_graph_from_instructions(instructions, options)
}
pub fn apply_instructions(
graph: &StaticGraph,
instructions: Vec<GraphInstruction>,
options: MutatorOptions,
registry: Option<&ExtensionMutationRegistry>,
) -> Result<StaticGraph, String> {
let mutations: Vec<GraphMutation> = instructions
.into_iter()
.map(|i| i.to_mutation())
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
apply_mutations_with_extensions(graph, mutations, options, registry)
}
pub fn apply_instructions_from_json(
graph: &StaticGraph,
json: &str,
) -> Result<StaticGraph, String> {
#[derive(Deserialize)]
struct InstructionRequest {
instructions: Vec<GraphInstruction>,
#[serde(default)]
options: MutationRequestOptions,
}
let request: InstructionRequest = serde_json::from_str(json)
.map_err(|e| format!("Failed to parse instructions JSON: {}", e))?;
apply_instructions(graph, request.instructions, request.options.into(), None)
}
pub fn get_mutation_schema() -> serde_json::Value {
serde_json::json!({
"MutationRequest": {
"description": "Container for a list of mutations to apply",
"properties": {
"mutations": {
"type": "array",
"items": { "$ref": "#/GraphMutation" }
},
"options": { "$ref": "#/MutationRequestOptions" }
}
},
"MutationRequestOptions": {
"properties": {
"autocreate_card": { "type": "boolean", "default": true },
"autocreate_widget": { "type": "boolean", "default": true }
}
},
"GraphMutation": {
"oneOf": [
{ "AddNode": { "$ref": "#/AddNodeParams" } },
{ "AddNodegroup": { "$ref": "#/AddNodegroupParams" } },
{ "AddEdge": { "$ref": "#/AddEdgeParams" } },
{ "AddCard": { "$ref": "#/AddCardParams" } },
{ "AddWidgetToCard": { "$ref": "#/AddWidgetParams" } },
{ "CreateGraph": { "$ref": "#/CreateGraphParams" } },
{ "SetDescriptorTemplate": { "$ref": "#/SetDescriptorTemplateParams" } }
]
},
"SetDescriptorTemplateParams": {
"required": ["descriptor_type", "string_template"],
"properties": {
"descriptor_type": { "type": "string", "description": "Descriptor type (e.g. 'name', 'slug', 'description', 'map_popup')" },
"string_template": { "type": "string", "description": "String template with <Node Name> placeholders" }
}
},
"CreateGraphParams": {
"required": ["name", "is_resource", "root_alias"],
"properties": {
"name": { "type": "string", "description": "Name for the graph" },
"is_resource": { "type": "boolean", "description": "Whether this is a resource model (true) or branch (false)" },
"root_alias": { "type": "string", "description": "Alias for the root node" },
"root_ontology_class": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } },
{ "type": "null" }
],
"nullable": true,
"description": "Ontology class URI(s) for the root node. Accepts a single string or an array of strings."
},
"graph_id": { "type": "string", "nullable": true, "description": "Optional custom graph ID" },
"author": { "type": "string", "nullable": true, "description": "Optional author" },
"description": { "type": "string", "nullable": true, "description": "Optional description" }
}
},
"AddNodeParams": {
"required": ["alias", "name", "cardinality", "datatype", "parent_property"],
"properties": {
"parent_alias": { "type": "string", "nullable": true },
"alias": { "type": "string" },
"name": { "type": "string" },
"cardinality": { "enum": ["One", "N"] },
"datatype": { "type": "string", "examples": ["semantic", "string", "number", "date", "boolean", "concept", "concept-list"] },
"ontology_class": {
"oneOf": [
{ "type": "string" },
{ "type": "array", "items": { "type": "string" } },
{ "type": "null" }
],
"nullable": true,
"description": "Ontology class URI(s) for the node. Accepts a single string or an array of strings."
},
"parent_property": { "type": "string" },
"description": { "type": "string", "nullable": true },
"config": { "type": "object", "nullable": true },
"options": { "$ref": "#/NodeOptions" }
}
},
"NodeOptions": {
"properties": {
"exportable": { "type": "boolean" },
"fieldname": { "type": "string" },
"hascustomalias": { "type": "boolean" },
"is_collector": { "type": "boolean" },
"isrequired": { "type": "boolean" },
"issearchable": { "type": "boolean" },
"istopnode": { "type": "boolean" },
"sortorder": { "type": "integer" }
}
},
"Cardinality": {
"enum": ["One", "N"],
"description": "One = single instance only, N = multiple instances allowed"
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn create_test_graph() -> StaticGraph {
let graph_json = r#"{
"graphid": "test-graph-id",
"name": {"en": "Test Graph"},
"isresource": true,
"is_editable": true,
"nodes": [{
"nodeid": "root-node-id",
"name": "Root",
"alias": "root",
"datatype": "semantic",
"nodegroup_id": "root-nodegroup",
"graph_id": "test-graph-id",
"is_collector": false,
"isrequired": false,
"exportable": false,
"ontologyclass": "E1_CRM_Entity",
"hascustomalias": false,
"issearchable": false,
"istopnode": true
}],
"nodegroups": [{
"nodegroupid": "root-nodegroup",
"cardinality": "1"
}],
"edges": [],
"cards": [],
"cards_x_nodes_x_widgets": [],
"root": {
"nodeid": "root-node-id",
"name": "Root",
"alias": "root",
"datatype": "semantic",
"nodegroup_id": "root-nodegroup",
"graph_id": "test-graph-id",
"is_collector": false,
"isrequired": false,
"exportable": false,
"ontologyclass": "E1_CRM_Entity",
"hascustomalias": false,
"issearchable": false,
"istopnode": true
}
}"#;
let mut graph: StaticGraph =
serde_json::from_str(graph_json).expect("Failed to parse test graph JSON");
graph.build_indices();
graph
}
#[test]
fn test_uuid_generation() {
let uuid1 = generate_uuid_v5(("graph", Some("test-id")), "node-1");
let uuid2 = generate_uuid_v5(("graph", Some("test-id")), "node-1");
let uuid3 = generate_uuid_v5(("graph", Some("test-id")), "node-2");
assert_eq!(uuid1, uuid2);
assert_ne!(uuid1, uuid3);
assert!(Uuid::parse_str(&uuid1).is_ok());
}
#[test]
fn test_add_semantic_node() {
let graph = create_test_graph();
let result = GraphMutator::new(graph)
.add_semantic_node(
Some("root"),
"child",
"Child Node",
Cardinality::N,
"E1_CRM_Entity",
"P1_is_identified_by",
Some("A child node"),
NodeOptions::default(),
None,
)
.build();
assert!(result.is_ok());
let built_graph = result.unwrap();
assert_eq!(built_graph.nodes.len(), 2);
assert_eq!(built_graph.nodegroups.len(), 2);
assert_eq!(built_graph.edges.len(), 1);
assert_eq!(built_graph.cards_slice().len(), 1);
}
#[test]
fn test_add_string_node() {
let graph = create_test_graph();
let result = GraphMutator::new(graph)
.add_string_node(
Some("root"),
"name",
"Name",
Cardinality::One,
"E41_Appellation",
"P1_is_identified_by",
None,
NodeOptions::default(),
None,
)
.build();
assert!(result.is_ok());
let built_graph = result.unwrap();
assert_eq!(built_graph.nodegroups.len(), 1);
}
#[test]
fn test_add_node_duplicate_alias_error() {
let graph = create_test_graph();
let result = GraphMutator::new(graph)
.add_string_node(
Some("root"),
"root", "Duplicate",
Cardinality::One,
"E41_Appellation",
"P1_is_identified_by",
None,
NodeOptions::default(),
None,
)
.build();
assert!(result.is_err());
assert!(matches!(result, Err(MutationError::AliasAlreadyExists(_))));
}
#[test]
fn test_add_node_invalid_config_error() {
let graph = create_test_graph();
let result = GraphMutator::new(graph)
.add_generic_node(
Some("root"),
"child",
"Child",
Cardinality::N,
"string",
"E41_Appellation",
"P1_is_identified_by",
None,
NodeOptions::default(),
Some(serde_json::json!("not an object")), )
.build();
assert!(result.is_err());
assert!(matches!(result, Err(MutationError::InvalidConfig { .. })));
}
#[test]
fn test_get_default_widget() {
assert!(get_default_widget_for_datatype("string").is_ok());
assert!(get_default_widget_for_datatype("number").is_ok());
assert!(get_default_widget_for_datatype("concept").is_ok());
assert!(get_default_widget_for_datatype("semantic").is_err());
assert!(get_default_widget_for_datatype("unknown").is_err());
}
#[test]
fn test_json_mutation_api() {
let graph = create_test_graph();
let mutations_json = r#"{
"mutations": [
{
"AddNode": {
"parent_alias": "root",
"alias": "child",
"name": "Child Node",
"cardinality": "N",
"datatype": "string",
"ontology_class": "E41_Appellation",
"parent_property": "P1_is_identified_by",
"description": "A test child node",
"config": null,
"options": {
"isrequired": true
}
}
}
],
"options": {
"autocreate_card": true,
"autocreate_widget": true
}
}"#;
let result = apply_mutations_from_json(&graph, mutations_json);
assert!(result.is_ok(), "JSON mutation failed: {:?}", result.err());
let mutated = result.unwrap();
assert_eq!(mutated.nodes.len(), 2);
assert_eq!(mutated.nodegroups.len(), 2);
assert_eq!(mutated.edges.len(), 1);
}
#[test]
fn test_mutations_serialization() {
let mutations = vec![GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("root".to_string()),
alias: "test".to_string(),
name: "Test".to_string(),
cardinality: Cardinality::One,
datatype: "string".to_string(),
ontology_class: Some(vec!["E41".to_string()]),
parent_property: "P1".to_string(),
description: None,
config: None,
options: NodeOptions::default(),
})];
let json = mutations_to_json(&mutations);
assert!(json.is_ok());
let parsed: Result<Vec<GraphMutation>, _> = serde_json::from_str(&json.unwrap());
assert!(parsed.is_ok());
}
fn create_test_subgraph() -> StaticGraph {
let subgraph_json = r#"{
"graphid": "subgraph-id",
"name": {"en": "Test Subgraph"},
"isresource": false,
"publication": {
"publicationid": "test-publication-id",
"graph_id": "subgraph-id",
"published_time": "2024-01-01T00:00:00.000"
},
"nodes": [
{
"nodeid": "sub-root-id",
"name": "Subgraph Root",
"alias": "sub_root",
"datatype": "semantic",
"nodegroup_id": "sub-root-ng",
"graph_id": "subgraph-id",
"is_collector": true,
"isrequired": false,
"exportable": false,
"ontologyclass": "E41_Appellation",
"hascustomalias": false,
"issearchable": false,
"istopnode": true
},
{
"nodeid": "sub-child1-id",
"name": "Child 1",
"alias": "child1",
"datatype": "string",
"nodegroup_id": "sub-child1-ng",
"graph_id": "subgraph-id",
"is_collector": false,
"isrequired": false,
"exportable": true,
"ontologyclass": "E41_Appellation",
"hascustomalias": false,
"issearchable": true,
"istopnode": false
},
{
"nodeid": "sub-child2-id",
"name": "Child 2",
"alias": "child2",
"datatype": "concept",
"nodegroup_id": "sub-child1-ng",
"graph_id": "subgraph-id",
"is_collector": false,
"isrequired": false,
"exportable": true,
"ontologyclass": "E55_Type",
"hascustomalias": false,
"issearchable": true,
"istopnode": false
}
],
"nodegroups": [
{
"nodegroupid": "sub-root-ng",
"cardinality": "n",
"parentnodegroup_id": null
},
{
"nodegroupid": "sub-child1-ng",
"cardinality": "1",
"parentnodegroup_id": "sub-root-ng"
}
],
"edges": [
{
"edgeid": "sub-edge1-id",
"domainnode_id": "sub-root-id",
"rangenode_id": "sub-child1-id",
"graph_id": "subgraph-id",
"ontologyproperty": "P3_has_note"
},
{
"edgeid": "sub-edge2-id",
"domainnode_id": "sub-child1-id",
"rangenode_id": "sub-child2-id",
"graph_id": "subgraph-id",
"ontologyproperty": "P2_has_type"
}
],
"cards": [
{
"cardid": "sub-card1-id",
"nodegroup_id": "sub-child1-ng",
"graph_id": "subgraph-id",
"name": {"en": "Child Card"},
"active": true,
"visible": true,
"component_id": "f05e4d3a-53c1-11e8-b0ea-784f435179ea",
"helpenabled": false,
"helptext": {"en": ""},
"helptitle": {"en": ""},
"instructions": {"en": ""},
"constraints": []
}
],
"cards_x_nodes_x_widgets": [
{
"id": "sub-cxnxw1-id",
"card_id": "sub-card1-id",
"node_id": "sub-child1-id",
"widget_id": "10000000-0000-0000-0000-000000000001",
"config": {},
"label": {"en": "Child 1 Label"},
"sortorder": 1,
"visible": true
},
{
"id": "sub-cxnxw2-id",
"card_id": "sub-card1-id",
"node_id": "sub-child2-id",
"widget_id": "10000000-0000-0000-0000-000000000002",
"config": {},
"label": {"en": "Child 2 Label"},
"sortorder": 2,
"visible": true
}
],
"root": {
"nodeid": "sub-root-id",
"name": "Subgraph Root",
"alias": "sub_root",
"datatype": "semantic",
"nodegroup_id": "sub-root-ng",
"graph_id": "subgraph-id",
"is_collector": true,
"isrequired": false,
"exportable": false,
"ontologyclass": "E41_Appellation",
"hascustomalias": false,
"issearchable": false,
"istopnode": true
}
}"#;
let mut graph: StaticGraph =
serde_json::from_str(subgraph_json).expect("Failed to parse test subgraph JSON");
graph.build_indices();
graph
}
#[test]
fn test_add_subgraph_basic() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let params = AddSubgraphParams {
subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
alias_prefix: None,
name_prefix: None,
};
let mut graph_clone = graph.deep_clone();
let result = apply_add_subgraph(&mut graph_clone, params);
assert!(result.is_ok(), "AddSubgraph failed: {:?}", result.err());
assert_eq!(graph_clone.nodes.len(), 4);
assert_eq!(graph_clone.nodegroups.len(), 3);
assert_eq!(graph_clone.edges.len(), 3);
assert_eq!(graph_clone.cards_slice().len(), 1);
assert_eq!(graph_clone.cards_x_nodes_x_widgets_slice().len(), 2);
}
#[test]
fn test_add_subgraph_with_alias_suffix() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let params = AddSubgraphParams {
subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: Some("v2".to_string()),
alias_prefix: None,
name_prefix: None,
};
let mut graph_clone = graph.deep_clone();
let result = apply_add_subgraph(&mut graph_clone, params);
assert!(
result.is_ok(),
"AddSubgraph with suffix failed: {:?}",
result.err()
);
let child1 = graph_clone
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child1"));
assert!(
child1.is_some(),
"Node with alias 'child1' not found (aliases should be preserved when no clash)"
);
let child2 = graph_clone
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child2"));
assert!(
child2.is_some(),
"Node with alias 'child2' not found (aliases should be preserved when no clash)"
);
let child1_node = child1.unwrap();
assert!(
child1_node.sourcebranchpublication_id.is_some(),
"sourcebranchpublication_id should be set on branch nodes"
);
}
#[test]
fn test_add_subgraph_alias_clash() {
let graph = create_test_graph();
let mut graph_with_child = GraphMutator::new(graph)
.add_string_node(
Some("root"),
"child1",
"Existing Child",
Cardinality::N,
"E41_Appellation",
"P1_is_identified_by",
None,
NodeOptions::default(),
None,
)
.build()
.expect("Failed to create graph with child");
let subgraph = create_test_subgraph();
let params = AddSubgraphParams {
subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
alias_prefix: None,
name_prefix: None,
};
let result = apply_add_subgraph(&mut graph_with_child, params);
assert!(
result.is_ok(),
"AddSubgraph should auto-suffix clashing aliases: {:?}",
result.err()
);
let original_child1 = graph_with_child
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child1") && n.name == "Existing Child");
assert!(
original_child1.is_some(),
"Original 'child1' node should still exist"
);
let new_child1 = graph_with_child
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child1_n1"));
assert!(
new_child1.is_some(),
"Clashing alias should be renamed to 'child1_n1'"
);
let child2 = graph_with_child
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child2"));
assert!(
child2.is_some(),
"Non-clashing alias 'child2' should be preserved"
);
}
#[test]
fn test_add_subgraph_id_remapping() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let params = AddSubgraphParams {
subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
alias_prefix: None,
name_prefix: None,
};
let mut graph_clone = graph.deep_clone();
let result = apply_add_subgraph(&mut graph_clone, params);
assert!(result.is_ok());
let original_ids = ["sub-root-id", "sub-child1-id", "sub-child2-id"];
for node in &graph_clone.nodes {
assert!(
!original_ids.contains(&node.nodeid.as_str()),
"Node ID {} was not remapped",
node.nodeid
);
}
let original_edge_ids = ["sub-edge1-id", "sub-edge2-id"];
for edge in &graph_clone.edges {
assert!(
!original_edge_ids.contains(&edge.edgeid.as_str()),
"Edge ID {} was not remapped",
edge.edgeid
);
}
for node in &graph_clone.nodes {
assert_eq!(
node.graph_id, "test-graph-id",
"Node graph_id not remapped to target graph"
);
}
}
#[test]
fn test_add_subgraph_preserves_external_ids() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let params = AddSubgraphParams {
subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
alias_prefix: None,
name_prefix: None,
};
let mut graph_clone = graph.deep_clone();
let result = apply_add_subgraph(&mut graph_clone, params);
assert!(result.is_ok());
let cxnxws = graph_clone.cards_x_nodes_x_widgets_slice();
assert!(
cxnxws
.iter()
.any(|c| c.widget_id == "10000000-0000-0000-0000-000000000001"),
"Widget ID for text-widget should be preserved"
);
assert!(
cxnxws
.iter()
.any(|c| c.widget_id == "10000000-0000-0000-0000-000000000002"),
"Widget ID for concept-select-widget should be preserved"
);
let cards = graph_clone.cards_slice();
assert!(
cards
.iter()
.any(|c| c.component_id == "f05e4d3a-53c1-11e8-b0ea-784f435179ea"),
"Component ID should be preserved"
);
}
#[test]
fn test_add_subgraph_target_not_found() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let params = AddSubgraphParams {
subgraph,
target_node_id: "nonexistent-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
alias_prefix: None,
name_prefix: None,
};
let mut graph_clone = graph.deep_clone();
let result = apply_add_subgraph(&mut graph_clone, params);
assert!(result.is_err(), "Expected NodeNotFound error");
match result {
Err(MutationError::NodeNotFound(id)) => {
assert_eq!(id, "nonexistent-node-id");
}
Err(e) => panic!("Expected NodeNotFound error, got: {:?}", e),
Ok(_) => panic!("Expected error but got Ok"),
}
}
#[test]
fn test_add_subgraph_via_json_api() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let subgraph_json = serde_json::to_string(&subgraph).expect("Failed to serialize subgraph");
let mutations_json = format!(
r#"{{
"mutations": [
{{
"AddSubgraph": {{
"subgraph": {},
"target_node_id": "root-node-id",
"ontology_property": "P106_is_composed_of",
"alias_suffix": "json"
}}
}}
],
"options": {{
"autocreate_card": true,
"autocreate_widget": true
}}
}}"#,
subgraph_json
);
let result = apply_mutations_from_json(&graph, &mutations_json);
assert!(
result.is_ok(),
"JSON AddSubgraph mutation failed: {:?}",
result.err()
);
let mutated = result.unwrap();
assert_eq!(mutated.nodes.len(), 4);
assert!(
mutated
.nodes
.iter()
.any(|n| n.alias.as_deref() == Some("child1")),
"Alias 'child1' should be preserved (no clash)"
);
let branch_node = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child1"))
.unwrap();
assert!(
branch_node.sourcebranchpublication_id.is_some(),
"sourcebranchpublication_id should be set on branch nodes"
);
}
#[test]
fn test_update_subgraph_first_time_acts_like_add() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let params = UpdateSubgraphParams {
subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
remove_orphaned: false,
alias_prefix: None,
name_prefix: None,
};
let mut graph_clone = graph.deep_clone();
let result = apply_update_subgraph(&mut graph_clone, params);
assert!(
result.is_ok(),
"UpdateSubgraph should succeed: {:?}",
result.err()
);
assert_eq!(
graph_clone.nodes.len(),
4,
"Should have 4 nodes: root + 3 from branch (branch root kept as collector)"
);
let child1 = graph_clone
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child1"))
.unwrap();
assert!(child1.sourcebranchpublication_id.is_some());
}
#[test]
fn test_update_subgraph_updates_existing_nodes() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let add_params = AddSubgraphParams {
subgraph: subgraph.clone(),
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
alias_prefix: None,
name_prefix: None,
};
let mut graph_with_branch = graph.deep_clone();
apply_add_subgraph(&mut graph_with_branch, add_params).expect("Add should succeed");
let mut updated_subgraph = subgraph.deep_clone();
for node in &mut updated_subgraph.nodes {
if node.alias.as_deref() == Some("child1") {
node.name = "Updated Child 1".to_string();
}
}
let update_params = UpdateSubgraphParams {
subgraph: updated_subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
remove_orphaned: false,
alias_prefix: None,
name_prefix: None,
};
let result = apply_update_subgraph(&mut graph_with_branch, update_params);
assert!(
result.is_ok(),
"UpdateSubgraph should succeed: {:?}",
result.err()
);
assert_eq!(graph_with_branch.nodes.len(), 4);
let child1 = graph_with_branch
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child1"))
.unwrap();
assert_eq!(
child1.name, "Updated Child 1",
"Node name should be updated"
);
}
#[test]
fn test_update_subgraph_adds_new_nodes() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let add_params = AddSubgraphParams {
subgraph: subgraph.clone(),
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
alias_prefix: None,
name_prefix: None,
};
let mut graph_with_branch = graph.deep_clone();
apply_add_subgraph(&mut graph_with_branch, add_params).expect("Add should succeed");
assert_eq!(graph_with_branch.nodes.len(), 4);
let mut updated_subgraph = subgraph.deep_clone();
let new_node = StaticNode {
nodeid: "sub-child3-id".to_string(),
name: "Child 3".to_string(),
alias: Some("child3".to_string()),
datatype: "string".to_string(),
nodegroup_id: Some("sub-child-ng-id".to_string()),
graph_id: "sub-graph-id".to_string(),
is_collector: false,
isrequired: false,
exportable: true,
sortorder: Some(3),
config: HashMap::new(),
parentproperty: None,
ontologyclass: Some(vec!["E41_Appellation".to_string()]),
description: None,
fieldname: None,
hascustomalias: false,
issearchable: true,
istopnode: false,
sourcebranchpublication_id: None,
source_identifier_id: None,
is_immutable: None,
};
updated_subgraph.nodes.push(new_node);
let update_params = UpdateSubgraphParams {
subgraph: updated_subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
remove_orphaned: false,
alias_prefix: None,
name_prefix: None,
};
let result = apply_update_subgraph(&mut graph_with_branch, update_params);
assert!(
result.is_ok(),
"UpdateSubgraph should succeed: {:?}",
result.err()
);
assert_eq!(
graph_with_branch.nodes.len(),
5,
"Should have added new node"
);
let child3 = graph_with_branch
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child3"));
assert!(child3.is_some(), "New node child3 should be added");
}
#[test]
fn test_update_subgraph_removes_orphaned() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let add_params = AddSubgraphParams {
subgraph: subgraph.clone(),
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
alias_prefix: None,
name_prefix: None,
};
let mut graph_with_branch = graph.deep_clone();
apply_add_subgraph(&mut graph_with_branch, add_params).expect("Add should succeed");
assert_eq!(graph_with_branch.nodes.len(), 4);
let mut updated_subgraph = subgraph.deep_clone();
updated_subgraph
.nodes
.retain(|n| n.alias.as_deref() != Some("child2"));
let update_params = UpdateSubgraphParams {
subgraph: updated_subgraph,
target_node_id: "root-node-id".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
remove_orphaned: true, alias_prefix: None,
name_prefix: None,
};
let result = apply_update_subgraph(&mut graph_with_branch, update_params);
assert!(
result.is_ok(),
"UpdateSubgraph should succeed: {:?}",
result.err()
);
assert_eq!(
graph_with_branch.nodes.len(),
2,
"Orphaned child2 and branch root should be removed"
);
let child2 = graph_with_branch
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child2"));
assert!(child2.is_none(), "child2 should be removed");
}
#[test]
fn test_update_subgraph_target_not_found() {
let graph = create_test_graph();
let subgraph = create_test_subgraph();
let params = UpdateSubgraphParams {
subgraph,
target_node_id: "non-existent-node".to_string(),
ontology_property: "P106_is_composed_of".to_string(),
alias_suffix: None,
remove_orphaned: false,
alias_prefix: None,
name_prefix: None,
};
let mut graph_clone = graph.deep_clone();
let result = apply_update_subgraph(&mut graph_clone, params);
assert!(result.is_err(), "Should fail when target not found");
match result {
Err(MutationError::NodeNotFound(id)) => {
assert_eq!(id, "non-existent-node");
}
Err(e) => panic!("Expected NodeNotFound error, got: {:?}", e),
Ok(_) => panic!("Expected error but got Ok"),
}
}
#[test]
fn test_concept_change_collection_concept_node() {
let mut graph = create_test_graph();
let concept_node = StaticNode {
nodeid: "concept-node-id".to_string(),
name: "Test Concept".to_string(),
alias: Some("test_concept".to_string()),
datatype: "concept".to_string(),
nodegroup_id: Some("root-node-id".to_string()),
graph_id: "test-graph-id".to_string(),
is_collector: false,
isrequired: false,
exportable: true,
sortorder: Some(1),
config: HashMap::new(),
parentproperty: None,
ontologyclass: Some(vec!["E55_Type".to_string()]),
description: None,
fieldname: None,
hascustomalias: false,
issearchable: true,
istopnode: false,
sourcebranchpublication_id: None,
source_identifier_id: None,
is_immutable: None,
};
graph.push_node(concept_node);
let params = ConceptChangeCollectionParams {
node_id: "test_concept".to_string(),
collection_id: "550e8400-e29b-41d4-a716-446655440000".to_string(),
};
let result = apply_concept_change_collection(&mut graph, params);
assert!(
result.is_ok(),
"ConceptChangeCollection should succeed: {:?}",
result.err()
);
let node = graph.find_node_by_alias("test_concept").unwrap();
let rdm_collection = node.config.get("rdmCollection").and_then(|v| v.as_str());
assert_eq!(rdm_collection, Some("550e8400-e29b-41d4-a716-446655440000"));
}
#[test]
fn test_concept_change_collection_concept_list_node() {
let mut graph = create_test_graph();
let concept_list_node = StaticNode {
nodeid: "concept-list-node-id".to_string(),
name: "Test Concept List".to_string(),
alias: Some("test_concept_list".to_string()),
datatype: "concept-list".to_string(),
nodegroup_id: Some("root-node-id".to_string()),
graph_id: "test-graph-id".to_string(),
is_collector: false,
isrequired: false,
exportable: true,
sortorder: Some(1),
config: HashMap::new(),
parentproperty: None,
ontologyclass: Some(vec!["E55_Type".to_string()]),
description: None,
fieldname: None,
hascustomalias: false,
issearchable: true,
istopnode: false,
sourcebranchpublication_id: None,
source_identifier_id: None,
is_immutable: None,
};
graph.push_node(concept_list_node);
let params = ConceptChangeCollectionParams {
node_id: "test_concept_list".to_string(),
collection_id: "my-new-collection-id".to_string(),
};
let result = apply_concept_change_collection(&mut graph, params);
assert!(
result.is_ok(),
"ConceptChangeCollection should succeed for concept-list: {:?}",
result.err()
);
let node = graph.find_node_by_alias("test_concept_list").unwrap();
assert_eq!(
node.config.get("rdmCollection").and_then(|v| v.as_str()),
Some("my-new-collection-id")
);
}
#[test]
fn test_concept_change_collection_invalid_datatype() {
let mut graph = create_test_graph();
let string_node = StaticNode {
nodeid: "string-node-id".to_string(),
name: "Test String".to_string(),
alias: Some("test_string".to_string()),
datatype: "string".to_string(),
nodegroup_id: Some("root-node-id".to_string()),
graph_id: "test-graph-id".to_string(),
is_collector: false,
isrequired: false,
exportable: true,
sortorder: Some(1),
config: HashMap::new(),
parentproperty: None,
ontologyclass: Some(vec!["E41_Appellation".to_string()]),
description: None,
fieldname: None,
hascustomalias: false,
issearchable: true,
istopnode: false,
sourcebranchpublication_id: None,
source_identifier_id: None,
is_immutable: None,
};
graph.push_node(string_node);
let params = ConceptChangeCollectionParams {
node_id: "test_string".to_string(),
collection_id: "some-collection".to_string(),
};
let result = apply_concept_change_collection(&mut graph, params);
assert!(result.is_err(), "Should fail for non-concept datatype");
match result {
Err(MutationError::InvalidDatatype {
expected,
found,
node_id,
}) => {
assert!(expected.contains("concept"));
assert_eq!(found, "string");
assert_eq!(node_id, "test_string");
}
Err(e) => panic!("Expected InvalidDatatype error, got: {:?}", e),
Ok(_) => panic!("Expected error but got Ok"),
}
}
#[test]
fn test_concept_change_collection_node_not_found() {
let mut graph = create_test_graph();
let params = ConceptChangeCollectionParams {
node_id: "nonexistent_node".to_string(),
collection_id: "some-collection".to_string(),
};
let result = apply_concept_change_collection(&mut graph, params);
assert!(result.is_err(), "Should fail when node not found");
match result {
Err(MutationError::NodeNotFound(id)) => {
assert_eq!(id, "nonexistent_node");
}
Err(e) => panic!("Expected NodeNotFound error, got: {:?}", e),
Ok(_) => panic!("Expected error but got Ok"),
}
}
#[test]
fn test_concept_change_collection_by_node_id() {
let mut graph = create_test_graph();
let concept_node = StaticNode {
nodeid: "concept-node-uuid".to_string(),
name: "Test Concept".to_string(),
alias: None, datatype: "concept".to_string(),
nodegroup_id: Some("root-node-id".to_string()),
graph_id: "test-graph-id".to_string(),
is_collector: false,
isrequired: false,
exportable: true,
sortorder: Some(1),
config: HashMap::new(),
parentproperty: None,
ontologyclass: Some(vec!["E55_Type".to_string()]),
description: None,
fieldname: None,
hascustomalias: false,
issearchable: true,
istopnode: false,
sourcebranchpublication_id: None,
source_identifier_id: None,
is_immutable: None,
};
graph.push_node(concept_node);
let params = ConceptChangeCollectionParams {
node_id: "concept-node-uuid".to_string(), collection_id: "new-collection".to_string(),
};
let result = apply_concept_change_collection(&mut graph, params);
assert!(result.is_ok(), "Should find node by ID: {:?}", result.err());
let node = graph
.nodes
.iter()
.find(|n| n.nodeid == "concept-node-uuid")
.unwrap();
assert_eq!(
node.config.get("rdmCollection").and_then(|v| v.as_str()),
Some("new-collection")
);
}
#[test]
fn test_concept_change_collection_via_instruction() {
let mut graph = create_test_graph();
let concept_node = StaticNode {
nodeid: "concept-node-id".to_string(),
name: "Test Concept".to_string(),
alias: Some("my_concept".to_string()),
datatype: "concept".to_string(),
nodegroup_id: Some("root-node-id".to_string()),
graph_id: "test-graph-id".to_string(),
is_collector: false,
isrequired: false,
exportable: true,
sortorder: Some(1),
config: HashMap::new(),
parentproperty: None,
ontologyclass: Some(vec!["E55_Type".to_string()]),
description: None,
fieldname: None,
hascustomalias: false,
issearchable: true,
istopnode: false,
sourcebranchpublication_id: None,
source_identifier_id: None,
is_immutable: None,
};
graph.push_node(concept_node);
let instruction = GraphInstruction {
action: "concept_change_collection".to_string(),
subject: "my_concept".to_string(),
object: "new-collection-uuid".to_string(),
params: HashMap::new(),
};
let mutation = instruction.to_mutation().expect("Should create mutation");
let options = MutatorOptions::default();
let result = apply_mutation(&mut graph, mutation, &options);
assert!(
result.is_ok(),
"Instruction should apply: {:?}",
result.err()
);
let node = graph.find_node_by_alias("my_concept").unwrap();
assert_eq!(
node.config.get("rdmCollection").and_then(|v| v.as_str()),
Some("new-collection-uuid")
);
}
#[test]
fn test_create_skeleton_graph() {
let classes = vec!["http://example.org/Person".to_string()];
let graph = create_skeleton_graph("Person", "person", true, Some(classes.as_slice()));
assert!(!graph.graphid.is_empty());
assert_eq!(graph.name.to_string_default(), "Person".to_string());
assert_eq!(graph.isresource, Some(true));
assert_eq!(graph.root.alias, Some("person".to_string()));
assert_eq!(graph.root.datatype, "semantic");
assert!(graph.root.istopnode);
assert!(
graph.root.nodegroup_id.is_none(),
"Root should have no nodegroup"
);
assert_eq!(
graph.root.ontologyclass,
Some(vec!["http://example.org/Person".to_string()])
);
assert_eq!(graph.nodes.len(), 1);
assert_eq!(graph.nodes[0].nodeid, graph.root.nodeid);
assert!(graph.nodegroups.is_empty());
assert!(graph.edges.is_empty());
}
#[test]
fn test_skeleton_graph_deterministic_ids() {
let graph1 = create_skeleton_graph("Person", "person", true, None);
let graph2 = create_skeleton_graph("Person", "person", true, None);
assert_eq!(graph1.graphid, graph2.graphid);
assert_eq!(graph1.root.nodeid, graph2.root.nodeid);
let graph3 = create_skeleton_graph("Monument", "monument", true, None);
assert_ne!(graph1.graphid, graph3.graphid);
}
#[test]
fn test_skeleton_graph_branch_vs_resource() {
let resource = create_skeleton_graph("Person", "person", true, None);
let branch = create_skeleton_graph("Addresses", "addresses", false, None);
assert_eq!(resource.isresource, Some(true));
assert_eq!(branch.isresource, Some(false));
}
#[test]
fn test_instruction_add_node() {
let graph = create_skeleton_graph("Person", "person", true, None);
let instructions = vec![GraphInstruction::new("add_node", "person", "name")
.with_str("datatype", "string")
.with_str("name", "Full Name")
.with_str("cardinality", "n")
.with_str("ontology_class", "http://example.org/Name")
.with_str("parent_property", "http://example.org/hasName")];
let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
assert!(result.is_ok(), "Instruction failed: {:?}", result.err());
let mutated = result.unwrap();
assert_eq!(mutated.nodes.len(), 2);
let name_node = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("name"));
assert!(name_node.is_some(), "Should find 'name' node");
let name_node = name_node.unwrap();
assert_eq!(name_node.datatype, "string");
assert_eq!(name_node.name, "Full Name");
assert!(
name_node.nodegroup_id.is_some(),
"Non-root node should have nodegroup"
);
}
#[test]
fn test_instruction_multiple_nodes() {
let graph = create_skeleton_graph("Person", "person", true, None);
let instructions = vec![
GraphInstruction::new("add_node", "person", "names")
.with_str("datatype", "semantic")
.with_str("cardinality", "n"),
GraphInstruction::new("add_node", "names", "full_name")
.with_str("datatype", "string")
.with_str("cardinality", "1"),
GraphInstruction::new("add_node", "names", "alias_name")
.with_str("datatype", "string")
.with_str("cardinality", "1"),
];
let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
assert!(result.is_ok(), "Instructions failed: {:?}", result.err());
let mutated = result.unwrap();
assert_eq!(mutated.nodes.len(), 4);
let names_node = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("names"))
.unwrap();
assert!(names_node.nodegroup_id.is_some());
let names_ng = names_node.nodegroup_id.clone().unwrap();
let full_name = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("full_name"))
.unwrap();
let alias_name = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("alias_name"))
.unwrap();
assert_eq!(full_name.nodegroup_id, Some(names_ng.clone()));
assert_eq!(alias_name.nodegroup_id, Some(names_ng.clone()));
}
#[test]
fn test_instruction_from_json() {
let graph = create_skeleton_graph("Person", "person", true, None);
let json = r#"{
"instructions": [
{
"action": "add_node",
"subject": "person",
"object": "name",
"params": {
"datatype": "string",
"cardinality": "n"
}
}
],
"options": {
"autocreate_card": true,
"autocreate_widget": true
}
}"#;
let result = apply_instructions_from_json(&graph, json);
assert!(
result.is_ok(),
"JSON instructions failed: {:?}",
result.err()
);
let mutated = result.unwrap();
assert_eq!(mutated.nodes.len(), 2);
}
#[test]
fn test_instruction_unknown_action_becomes_extension() {
let graph = create_skeleton_graph("Test", "test", true, None);
let instructions = vec![GraphInstruction::new("invalid_action", "test", "foo")];
let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("Extension mutation"));
}
#[test]
fn test_root_children_always_get_nodegroup() {
let graph = create_skeleton_graph("Test", "test", true, None);
let instructions = vec![
GraphInstruction::new("add_node", "test", "child")
.with_str("datatype", "string")
.with_str("cardinality", "1"),
];
let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
assert!(result.is_ok());
let mutated = result.unwrap();
let child = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("child"))
.unwrap();
assert!(
child.nodegroup_id.is_some(),
"Direct children of root must have their own nodegroup"
);
}
#[test]
fn test_is_collector_option_creates_own_nodegroup() {
let graph = create_skeleton_graph("Test", "test", true, None);
let instructions = vec![
GraphInstruction::new("add_node", "test", "parent_group")
.with_str("datatype", "semantic")
.with_str("cardinality", "n"),
GraphInstruction::new("add_node", "parent_group", "collector_child")
.with_str("datatype", "string")
.with_str("cardinality", "1")
.with_param("options", serde_json::json!({"is_collector": true})),
GraphInstruction::new("add_node", "parent_group", "plain_child")
.with_str("datatype", "string")
.with_str("cardinality", "1"),
];
let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
assert!(result.is_ok(), "Instructions failed: {:?}", result.err());
let mutated = result.unwrap();
let parent = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("parent_group"))
.unwrap();
let collector = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("collector_child"))
.unwrap();
let plain = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("plain_child"))
.unwrap();
assert_eq!(
collector.nodegroup_id.as_ref(),
Some(&collector.nodeid),
"is_collector node should have nodegroup_id == nodeid"
);
assert!(collector.is_collector, "is_collector flag should be set");
let collector_ng = mutated
.nodegroups
.iter()
.find(|ng| ng.nodegroupid == collector.nodeid);
assert!(
collector_ng.is_some(),
"Collector node must have a nodegroup entry"
);
assert_eq!(
collector_ng.unwrap().parentnodegroup_id,
parent.nodegroup_id,
"Collector nodegroup parent should be the parent's nodegroup"
);
assert_eq!(
plain.nodegroup_id, parent.nodegroup_id,
"Non-collector child should share parent's nodegroup"
);
}
#[test]
fn test_is_collector_via_csv_dot_notation() {
let graph = create_skeleton_graph("Test", "test", true, None);
let csv = "\
action,subject,object,params.name,params.datatype,params.cardinality,params.ontology_class,params.parent_property,params.options.is_collector
add_node,test,parent_group,Parent,semantic,n,,,
add_node,parent_group,impact,Impact,string,1,,,true
add_node,parent_group,other,Other,string,1,,,
";
let instructions = parse_instructions_from_csv(csv).expect("CSV should parse");
let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
assert!(
result.is_ok(),
"CSV instructions failed: {:?}",
result.err()
);
let mutated = result.unwrap();
let impact = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("impact"))
.unwrap();
let other = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("other"))
.unwrap();
let parent = mutated
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("parent_group"))
.unwrap();
assert_eq!(
impact.nodegroup_id.as_ref(),
Some(&impact.nodeid),
"CSV is_collector node should have nodegroup_id == nodeid"
);
assert_eq!(
other.nodegroup_id, parent.nodegroup_id,
"Non-collector CSV node should share parent's nodegroup"
);
}
#[test]
fn test_create_model_instruction() {
let instructions = vec![GraphInstruction::new("create_model", "person", "")
.with_str("name", "Person")
.with_str("ontology_class", "http://example.org/Person")
.with_str("slug", "person")];
let result = build_graph_from_instructions(instructions, MutatorOptions::default());
assert!(result.is_ok(), "create_model failed: {:?}", result.err());
let graph = result.unwrap();
assert_eq!(graph.isresource, Some(true));
assert_eq!(graph.name.to_string_default(), "Person");
assert_eq!(graph.root.alias, Some("person".to_string()));
assert_eq!(
graph.root.ontologyclass,
Some(vec!["http://example.org/Person".to_string()])
);
assert_eq!(graph.slug, Some("person".to_string()));
assert!(
graph.root.nodegroup_id.is_none(),
"Root should have no nodegroup"
);
}
#[test]
fn test_create_model_default_slug() {
let instructions =
vec![GraphInstruction::new("create_model", "Person", "").with_str("name", "Person")];
let result = build_graph_from_instructions(instructions, MutatorOptions::default());
assert!(result.is_ok());
let graph = result.unwrap();
assert_eq!(graph.slug, Some("person".to_string()));
}
#[test]
fn test_create_branch_instruction() {
let instructions = vec![GraphInstruction::new("create_branch", "addresses", "")
.with_str("name", "Addresses")
.with_str("slug", "addresses-branch")];
let result = build_graph_from_instructions(instructions, MutatorOptions::default());
assert!(result.is_ok(), "create_branch failed: {:?}", result.err());
let graph = result.unwrap();
assert_eq!(graph.isresource, Some(false));
assert_eq!(graph.name.to_string_default(), "Addresses");
assert_eq!(graph.root.alias, Some("addresses".to_string()));
assert_eq!(graph.slug, Some("addresses-branch".to_string()));
}
#[test]
fn test_create_branch_default_slug() {
let instructions = vec![GraphInstruction::new("create_branch", "MyAddresses", "")
.with_str("name", "My Addresses")];
let result = build_graph_from_instructions(instructions, MutatorOptions::default());
assert!(result.is_ok());
let graph = result.unwrap();
assert_eq!(graph.slug, Some("myaddresses".to_string()));
}
#[test]
fn test_create_with_explicit_graphid() {
let custom_graphid = "12345678-1234-1234-1234-123456789abc";
let instructions = vec![
GraphInstruction::new("create_model", "person", custom_graphid)
.with_str("name", "Person"),
];
let result = build_graph_from_instructions(instructions, MutatorOptions::default());
assert!(result.is_ok());
let graph = result.unwrap();
assert_eq!(graph.graphid, custom_graphid);
assert_eq!(graph.root.graph_id, custom_graphid);
}
#[test]
fn test_build_graph_with_nodes() {
let instructions = vec![
GraphInstruction::new("create_model", "person", "").with_str("name", "Person"),
GraphInstruction::new("add_node", "person", "names")
.with_str("datatype", "semantic")
.with_str("cardinality", "n"),
GraphInstruction::new("add_node", "names", "full_name")
.with_str("datatype", "string")
.with_str("cardinality", "1"),
];
let result = build_graph_from_instructions(instructions, MutatorOptions::default());
assert!(result.is_ok(), "Build failed: {:?}", result.err());
let graph = result.unwrap();
assert_eq!(graph.nodes.len(), 3);
let names = graph
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("names"))
.unwrap();
let full_name = graph
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("full_name"))
.unwrap();
assert!(names.nodegroup_id.is_some());
assert_eq!(full_name.nodegroup_id, names.nodegroup_id);
}
#[test]
fn test_build_graph_from_json() {
let json = r#"{
"instructions": [
{
"action": "create_model",
"subject": "monument",
"object": "",
"params": { "name": "Monument" }
},
{
"action": "add_node",
"subject": "monument",
"object": "name",
"params": { "datatype": "string", "cardinality": "n" }
}
],
"options": {
"autocreate_card": true,
"autocreate_widget": true
}
}"#;
let result = build_graph_from_instructions_json(json);
assert!(result.is_ok(), "JSON build failed: {:?}", result.err());
let graph = result.unwrap();
assert_eq!(graph.isresource, Some(true));
assert_eq!(graph.nodes.len(), 2);
}
#[test]
fn test_build_graph_requires_create_first() {
let instructions = vec![
GraphInstruction::new("add_node", "person", "name").with_str("datatype", "string"),
];
let result = build_graph_from_instructions(instructions, MutatorOptions::default());
assert!(result.is_err());
assert!(result.unwrap_err().contains("First instruction must be"));
}
#[test]
fn test_build_graph_empty_instructions() {
let result = build_graph_from_instructions(vec![], MutatorOptions::default());
assert!(result.is_err());
assert!(result.unwrap_err().contains("No instructions provided"));
}
#[test]
fn test_create_action_in_apply_instructions_errors() {
let graph = create_skeleton_graph("Test", "test", true, None);
let instructions = vec![GraphInstruction::new("create_model", "other", "")];
let result = apply_instructions(&graph, instructions, MutatorOptions::default(), None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("creates a new graph"));
}
#[test]
fn test_delete_card() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "field1".to_string(),
name: "Field 1".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let card_id = graph.cards.as_ref().unwrap()[0].cardid.clone();
assert!(!card_id.is_empty());
assert!(graph
.cards_x_nodes_x_widgets
.as_ref()
.map(|c| !c.is_empty())
.unwrap_or(false));
apply_mutation(
&mut graph,
GraphMutation::DeleteCard(DeleteCardParams {
card_id: card_id.clone(),
}),
&options,
)
.unwrap();
assert!(graph
.cards
.as_ref()
.map(|c| c.iter().all(|card| card.cardid != card_id))
.unwrap_or(true));
assert!(graph
.cards_x_nodes_x_widgets
.as_ref()
.map(|c| c.iter().all(|w| w.card_id != card_id))
.unwrap_or(true));
}
#[test]
fn test_delete_card_not_found() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::DeleteCard(DeleteCardParams {
card_id: "nonexistent".to_string(),
}),
&options,
);
assert!(matches!(result, Err(MutationError::CardNotFound(_))));
}
#[test]
fn test_rename_card_by_nodegroup_id() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "my_field".to_string(),
name: "My Field".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let node = graph.find_node_by_alias("my_field").unwrap();
let ng_id = node.nodegroup_id.clone().unwrap();
let card = graph.find_card_by_nodegroup(&ng_id).unwrap();
assert_eq!(card.name.get("en"), "My Field");
apply_mutation(
&mut graph,
GraphMutation::RenameCard(RenameCardParams {
card_id: ng_id.clone(),
language: None,
name: Some("Renamed Card".to_string()),
name_i18n: None,
description: Some("A description".to_string()),
description_i18n: None,
}),
&options,
)
.unwrap();
let card = graph.find_card_by_nodegroup(&ng_id).unwrap();
assert_eq!(card.name.get("en"), "Renamed Card");
assert_eq!(
card.description.as_ref().unwrap().get("en"),
"A description"
);
}
#[test]
fn test_rename_card_multilingual() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "my_field".to_string(),
name: "My Field".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let node = graph.find_node_by_alias("my_field").unwrap();
let ng_id = node.nodegroup_id.clone().unwrap();
let mut name_map = HashMap::new();
name_map.insert("en".to_string(), "English Name".to_string());
name_map.insert("fr".to_string(), "Nom Français".to_string());
apply_mutation(
&mut graph,
GraphMutation::RenameCard(RenameCardParams {
card_id: ng_id.clone(),
language: None,
name: None,
name_i18n: Some(name_map),
description: None,
description_i18n: None,
}),
&options,
)
.unwrap();
let card = graph.find_card_by_nodegroup(&ng_id).unwrap();
assert_eq!(card.name.get("en"), "English Name");
assert_eq!(card.name.get("fr"), "Nom Français");
}
#[test]
fn test_rename_card_specific_language() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "my_field".to_string(),
name: "My Field".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let node = graph.find_node_by_alias("my_field").unwrap();
let ng_id = node.nodegroup_id.clone().unwrap();
apply_mutation(
&mut graph,
GraphMutation::RenameCard(RenameCardParams {
card_id: ng_id.clone(),
language: Some("fr".to_string()),
name: Some("Mon Champ".to_string()),
name_i18n: None,
description: None,
description_i18n: None,
}),
&options,
)
.unwrap();
let card = graph.find_card_by_nodegroup(&ng_id).unwrap();
assert_eq!(card.name.get("en"), "My Field");
assert_eq!(card.name.get("fr"), "Mon Champ");
}
#[test]
fn test_rename_card_not_found() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::RenameCard(RenameCardParams {
card_id: "nonexistent".to_string(),
language: None,
name: Some("Whatever".to_string()),
name_i18n: None,
description: None,
description_i18n: None,
}),
&options,
);
assert!(matches!(result, Err(MutationError::CardNotFound(_))));
}
#[test]
fn test_realign_card_from_node() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "my_field".to_string(),
name: "Original Name".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let node = graph.find_node_by_alias("my_field").unwrap();
let ng_id = node.nodegroup_id.clone().unwrap();
let node_id = node.nodeid.clone();
assert_eq!(
graph.find_card_by_nodegroup(&ng_id).unwrap().name.get("en"),
"Original Name"
);
let widget = graph
.cards_x_nodes_x_widgets
.as_ref()
.unwrap()
.iter()
.find(|c| c.node_id == node_id)
.unwrap();
assert_eq!(widget.label.get("en"), "Original Name");
apply_mutation(
&mut graph,
GraphMutation::RenameNode(RenameNodeParams {
node_id: "my_field".to_string(),
alias: None,
name: Some("Updated Name".to_string()),
description: None,
realign_card: false,
}),
&options,
)
.unwrap();
assert_eq!(
graph.find_card_by_nodegroup(&ng_id).unwrap().name.get("en"),
"Original Name"
);
apply_mutation(
&mut graph,
GraphMutation::RealignCardFromNode(RealignCardFromNodeParams {
node_alias: "my_field".to_string(),
}),
&options,
)
.unwrap();
assert_eq!(
graph.find_card_by_nodegroup(&ng_id).unwrap().name.get("en"),
"Updated Name"
);
let widget = graph
.cards_x_nodes_x_widgets
.as_ref()
.unwrap()
.iter()
.find(|c| c.node_id == node_id)
.unwrap();
assert_eq!(widget.label.get("en"), "Updated Name");
}
#[test]
fn test_realign_card_from_node_not_found() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::RealignCardFromNode(RealignCardFromNodeParams {
node_alias: "nonexistent".to_string(),
}),
&options,
);
assert!(matches!(result, Err(MutationError::NodeNotFound(_))));
}
#[test]
fn test_delete_widget() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "field1".to_string(),
name: "Field 1".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let widget_id = graph.cards_x_nodes_x_widgets.as_ref().unwrap()[0]
.id
.clone();
let initial_count = graph.cards_x_nodes_x_widgets.as_ref().unwrap().len();
apply_mutation(
&mut graph,
GraphMutation::DeleteWidget(DeleteWidgetParams {
widget_mapping_id: widget_id.clone(),
}),
&options,
)
.unwrap();
let final_count = graph
.cards_x_nodes_x_widgets
.as_ref()
.map(|c| c.len())
.unwrap_or(0);
assert_eq!(final_count, initial_count - 1);
}
#[test]
fn test_delete_widget_not_found() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::DeleteWidget(DeleteWidgetParams {
widget_mapping_id: "nonexistent".to_string(),
}),
&options,
);
assert!(matches!(result, Err(MutationError::WidgetNotFound(_))));
}
#[test]
fn test_add_function_with_uuid() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddFunction(AddFunctionParams {
function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
config: Some(serde_json::json!({"key": "value"})),
}),
&options,
)
.unwrap();
let fxgs = graph.functions_x_graphs.as_ref().unwrap();
assert_eq!(fxgs.len(), 1);
assert_eq!(fxgs[0].function_id, "00b2d15a-fda0-4578-b79a-784e4138664b");
assert_eq!(fxgs[0].graph_id, graph.graphid);
assert_eq!(fxgs[0].config["key"], "value");
}
#[test]
fn test_add_function_with_non_uuid_string() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddFunction(AddFunctionParams {
function_id: "com.flaxandteal.app/my-func".to_string(),
config: None,
}),
&options,
)
.unwrap();
let fxgs = graph.functions_x_graphs.as_ref().unwrap();
assert_eq!(fxgs.len(), 1);
assert!(uuid::Uuid::parse_str(&fxgs[0].function_id).is_ok());
assert_ne!(fxgs[0].function_id, "com.flaxandteal.app/my-func");
let expected =
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, b"com.flaxandteal.app/my-func")
.to_string();
assert_eq!(fxgs[0].function_id, expected);
}
#[test]
fn test_add_function_duplicate() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddFunction(AddFunctionParams {
function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
config: None,
}),
&options,
)
.unwrap();
let result = apply_mutation(
&mut graph,
GraphMutation::AddFunction(AddFunctionParams {
function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
config: None,
}),
&options,
);
assert!(matches!(result, Err(MutationError::Other(_))));
}
#[test]
fn test_set_descriptor_template_with_non_default_function_allows_multi_nodegroup() {
use crate::graph::StaticFunctionsXGraphs;
let mut graph = create_skeleton_graph("Test", "test", true, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "name_node".to_string(),
name: "Name".to_string(),
datatype: "string".to_string(),
cardinality: Cardinality::One,
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "desc_node".to_string(),
name: "Description".to_string(),
datatype: "string".to_string(),
cardinality: Cardinality::One,
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let name_ng = graph
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("name_node"))
.unwrap()
.nodegroup_id
.as_ref()
.unwrap()
.clone();
let desc_ng = graph
.nodes
.iter()
.find(|n| n.alias.as_deref() == Some("desc_node"))
.unwrap()
.nodegroup_id
.as_ref()
.unwrap()
.clone();
assert_ne!(
name_ng, desc_ng,
"Test setup: nodes should be in different nodegroups"
);
let result = graph.set_descriptor_template("name", "<Name> - <Description>");
assert!(
result.is_err(),
"Default function should reject multi-nodegroup template"
);
assert!(result.unwrap_err().contains("expected exactly 1"));
let fxg = graph.functions_x_graphs.get_or_insert_with(Vec::new);
fxg.push(StaticFunctionsXGraphs {
config: serde_json::json!({}),
function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
graph_id: graph.graphid.clone(),
id: "test-fxg-1".to_string(),
});
let result = graph.set_descriptor_template("name", "<Name> - <Description>");
assert!(
result.is_ok(),
"Non-default function should allow multi-nodegroup template: {:?}",
result
);
let func = graph
.functions_x_graphs
.as_ref()
.unwrap()
.iter()
.find(|f| f.function_id == "00b2d15a-fda0-4578-b79a-784e4138664b")
.expect("Non-default function should still exist");
let dt = func.config["descriptor_types"]["name"].as_object().unwrap();
assert_eq!(dt["string_template"], "<Name> - <Description>");
assert_eq!(dt["nodegroup_id"], "");
assert_eq!(
func.config["descriptor_types"]["description"]["string_template"],
""
);
assert_eq!(
func.config["descriptor_types"]["map_popup"]["string_template"],
""
);
}
#[test]
fn test_set_descriptor_template_with_non_default_function_single_nodegroup() {
use crate::graph::StaticFunctionsXGraphs;
let mut graph = create_skeleton_graph("Test", "test", true, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "name_node".to_string(),
name: "Name".to_string(),
datatype: "string".to_string(),
cardinality: Cardinality::One,
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let fxg = graph.functions_x_graphs.get_or_insert_with(Vec::new);
fxg.push(StaticFunctionsXGraphs {
config: serde_json::json!({}),
function_id: "00b2d15a-fda0-4578-b79a-784e4138664b".to_string(),
graph_id: graph.graphid.clone(),
id: "test-fxg-1".to_string(),
});
let result = graph.set_descriptor_template("name", "<Name>");
assert!(
result.is_ok(),
"Non-default function should allow single-nodegroup template: {:?}",
result
);
let func = graph
.functions_x_graphs
.as_ref()
.unwrap()
.iter()
.find(|f| f.function_id == "00b2d15a-fda0-4578-b79a-784e4138664b")
.expect("Non-default function should still exist");
let dt = func.config["descriptor_types"]["name"].as_object().unwrap();
assert_eq!(dt["string_template"], "<Name>");
}
#[test]
fn test_delete_function() {
use crate::graph::StaticFunctionsXGraphs;
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
graph.functions_x_graphs = Some(vec![StaticFunctionsXGraphs {
id: "func-mapping-1".to_string(),
function_id: "60000000-0000-0000-0000-000000000001".to_string(),
graph_id: graph.graphid.clone(),
config: serde_json::Value::Object(serde_json::Map::new()),
}]);
apply_mutation(
&mut graph,
GraphMutation::DeleteFunction(DeleteFunctionParams {
function_mapping_id: "func-mapping-1".to_string(),
}),
&options,
)
.unwrap();
assert!(graph
.functions_x_graphs
.as_ref()
.map(|f| f.is_empty())
.unwrap_or(true));
}
#[test]
fn test_delete_function_not_found() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::DeleteFunction(DeleteFunctionParams {
function_mapping_id: "nonexistent".to_string(),
}),
&options,
);
assert!(matches!(result, Err(MutationError::FunctionNotFound(_))));
}
#[test]
fn test_delete_node() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "field1".to_string(),
name: "Field 1".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let initial_node_count = graph.nodes.len();
let initial_edge_count = graph.edges.len();
apply_mutation(
&mut graph,
GraphMutation::DeleteNode(DeleteNodeParams {
node_id: "field1".to_string(),
}),
&options,
)
.unwrap();
assert_eq!(graph.nodes.len(), initial_node_count - 1);
assert!(graph.find_node_by_alias("field1").is_none());
assert!(graph.edges.len() < initial_edge_count);
let has_widget_for_node = graph
.cards_x_nodes_x_widgets
.as_ref()
.map(|c| c.iter().any(|w| w.node_id.contains("field1")))
.unwrap_or(false);
assert!(!has_widget_for_node);
}
#[test]
fn test_delete_node_not_found() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::DeleteNode(DeleteNodeParams {
node_id: "nonexistent".to_string(),
}),
&options,
);
assert!(matches!(result, Err(MutationError::NodeNotFound(_))));
}
#[test]
fn test_delete_node_cannot_delete_root() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::DeleteNode(DeleteNodeParams {
node_id: "test".to_string(),
}),
&options,
);
assert!(matches!(
result,
Err(MutationError::CannotDeleteRootNode(_))
));
}
#[test]
fn test_delete_nodegroup_cascade() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "parent_field".to_string(),
name: "Parent Field".to_string(),
cardinality: Cardinality::N,
datatype: "semantic".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("parent_field".to_string()),
alias: "child_field".to_string(),
name: "Child Field".to_string(),
cardinality: Cardinality::One,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let parent_node = graph.find_node_by_alias("parent_field").unwrap();
let nodegroup_id = parent_node.nodegroup_id.clone().unwrap();
let initial_node_count = graph.nodes.len();
let initial_nodegroup_count = graph.nodegroups.len();
apply_mutation(
&mut graph,
GraphMutation::DeleteNodegroup(DeleteNodegroupParams {
nodegroup_id: nodegroup_id.clone(),
}),
&options,
)
.unwrap();
assert!(graph
.nodegroups
.iter()
.all(|ng| ng.nodegroupid != nodegroup_id));
assert!(graph.find_node_by_alias("parent_field").is_none());
assert!(graph.find_node_by_alias("child_field").is_none());
assert!(graph.nodes.len() < initial_node_count);
assert!(graph.nodegroups.len() < initial_nodegroup_count);
}
#[test]
fn test_delete_nodegroup_not_found() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::DeleteNodegroup(DeleteNodegroupParams {
nodegroup_id: "nonexistent".to_string(),
}),
&options,
);
assert!(matches!(result, Err(MutationError::NodegroupNotFound(_))));
}
#[test]
fn test_delete_node_via_instruction() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "my_field".to_string(),
name: "My Field".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let instruction = GraphInstruction::new("delete_node", "my_field", "");
let mutation = instruction.to_mutation().unwrap();
apply_mutation(&mut graph, mutation, &options).unwrap();
assert!(graph.find_node_by_alias("my_field").is_none());
}
#[test]
fn test_update_node() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "field1".to_string(),
name: "Original Name".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
apply_mutation(
&mut graph,
GraphMutation::UpdateNode(UpdateNodeParams {
node_id: "field1".to_string(),
name: Some("Updated Name".to_string()),
ontology_class: Some(vec!["http://example.org/Class".to_string()]),
parent_property: None,
description: Some("A description".to_string()),
config: None,
options: UpdateNodeOptions {
isrequired: Some(true),
..UpdateNodeOptions::default()
},
}),
&options,
)
.unwrap();
let node = graph.find_node_by_alias("field1").unwrap();
assert_eq!(node.name, "Updated Name");
assert_eq!(
node.ontologyclass,
Some(vec!["http://example.org/Class".to_string()])
);
assert!(node.description.is_some());
assert!(node.isrequired);
assert_eq!(node.datatype, "string");
}
#[test]
fn test_update_node_not_found() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
let result = apply_mutation(
&mut graph,
GraphMutation::UpdateNode(UpdateNodeParams {
node_id: "nonexistent".to_string(),
name: Some("New Name".to_string()),
ontology_class: None,
parent_property: None,
description: None,
config: None,
options: UpdateNodeOptions::default(),
}),
&options,
);
assert!(matches!(result, Err(MutationError::NodeNotFound(_))));
}
#[test]
fn test_change_node_type() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions {
autocreate_card: true,
autocreate_widget: false,
ontology_validator: None,
skip_publication: false,
};
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "field1".to_string(),
name: "Field 1".to_string(),
cardinality: Cardinality::N,
datatype: "semantic".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
apply_mutation(
&mut graph,
GraphMutation::ChangeNodeType(ChangeNodeTypeParams {
node_id: "field1".to_string(),
datatype: "string".to_string(),
name: Some("Field 1 String".to_string()),
ontology_class: None,
parent_property: None,
description: None,
config: None,
options: UpdateNodeOptions::default(),
}),
&options,
)
.unwrap();
let node = graph.find_node_by_alias("field1").unwrap();
assert_eq!(node.datatype, "string");
assert_eq!(node.name, "Field 1 String");
}
#[test]
fn test_change_node_type_with_widgets_error() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "field1".to_string(),
name: "Field 1".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let node = graph.find_node_by_alias("field1").unwrap();
let has_widget = graph
.cards_x_nodes_x_widgets
.as_ref()
.map(|cxnxws| cxnxws.iter().any(|c| c.node_id == node.nodeid))
.unwrap_or(false);
assert!(has_widget, "Widget should exist");
let result = apply_mutation(
&mut graph,
GraphMutation::ChangeNodeType(ChangeNodeTypeParams {
node_id: "field1".to_string(),
datatype: "number".to_string(),
name: None,
ontology_class: None,
parent_property: None,
description: None,
config: None,
options: UpdateNodeOptions::default(),
}),
&options,
);
assert!(matches!(
result,
Err(MutationError::NodeHasDependentWidgets(_))
));
}
#[test]
fn test_rename_node() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "old_alias".to_string(),
name: "Old Name".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
apply_mutation(
&mut graph,
GraphMutation::RenameNode(RenameNodeParams {
node_id: "old_alias".to_string(),
alias: Some("new_alias".to_string()),
name: Some("New Name".to_string()),
description: Some("New description".to_string()),
realign_card: true,
}),
&options,
)
.unwrap();
assert!(graph.find_node_by_alias("old_alias").is_none());
let node = graph.find_node_by_alias("new_alias").unwrap();
assert_eq!(node.name, "New Name");
assert!(node.description.is_some());
}
#[test]
fn test_rename_node_alias_conflict() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "field1".to_string(),
name: "Field 1".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "field2".to_string(),
name: "Field 2".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let result = apply_mutation(
&mut graph,
GraphMutation::RenameNode(RenameNodeParams {
node_id: "field1".to_string(),
alias: Some("field2".to_string()),
name: None,
description: None,
realign_card: true,
}),
&options,
);
assert!(matches!(result, Err(MutationError::AliasAlreadyExists(_))));
}
#[test]
fn test_update_node_via_instruction() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "my_field".to_string(),
name: "My Field".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let instruction = GraphInstruction::new("update_node", "my_field", "")
.with_str("name", "Updated Field Name")
.with_param("isrequired", serde_json::Value::Bool(true));
let mutation = instruction.to_mutation().unwrap();
apply_mutation(&mut graph, mutation, &options).unwrap();
let node = graph.find_node_by_alias("my_field").unwrap();
assert_eq!(node.name, "Updated Field Name");
assert!(node.isrequired);
}
#[test]
fn test_rename_node_via_instruction() {
let mut graph = create_skeleton_graph("Test", "test", false, None);
let options = MutatorOptions::default();
apply_mutation(
&mut graph,
GraphMutation::AddNode(AddNodeParams {
parent_alias: Some("test".to_string()),
alias: "old_name".to_string(),
name: "Old Name".to_string(),
cardinality: Cardinality::N,
datatype: "string".to_string(),
ontology_class: None,
parent_property: String::new(),
description: None,
config: None,
options: NodeOptions::default(),
}),
&options,
)
.unwrap();
let instruction = GraphInstruction::new("rename_node", "old_name", "new_name")
.with_str("name", "New Display Name");
let mutation = instruction.to_mutation().unwrap();
apply_mutation(&mut graph, mutation, &options).unwrap();
assert!(graph.find_node_by_alias("old_name").is_none());
let node = graph.find_node_by_alias("new_name").unwrap();
assert_eq!(node.name, "New Display Name");
}
struct TestPrefixHandler {
prefix: String,
}
impl ExtensionMutationHandler for TestPrefixHandler {
fn apply(
&self,
graph: &mut StaticGraph,
params: &serde_json::Value,
_options: &MutatorOptions,
) -> Result<(), MutationError> {
let suffix = params.get("suffix").and_then(|v| v.as_str()).unwrap_or("");
let root_id = graph.get_root().nodeid.clone();
let root_name = graph.get_root().name.clone();
if let Some(node) = graph.nodes.iter_mut().find(|n| n.nodeid == root_id) {
node.name = format!("{}{}{}", self.prefix, root_name, suffix);
}
Ok(())
}
fn conformance(&self) -> MutationConformance {
MutationConformance::AlwaysConformant
}
fn description(&self) -> &str {
"Test handler that adds prefix/suffix to root name"
}
}
#[test]
fn test_extension_mutation_with_registry() {
let graph = create_test_graph();
let options = MutatorOptions::default();
let mut registry = ExtensionMutationRegistry::new();
registry.register(
"test.prefix_name",
std::sync::Arc::new(TestPrefixHandler {
prefix: "[PREFIX] ".to_string(),
}),
);
let mutation = GraphMutation::Extension(ExtensionMutationParams {
name: "test.prefix_name".to_string(),
params: serde_json::json!({"suffix": " [SUFFIX]"}),
conformance: MutationConformance::AlwaysConformant,
});
let result =
apply_mutations_with_extensions(&graph, vec![mutation], options, Some(®istry));
assert!(result.is_ok());
let mutated = result.unwrap();
let root_node = mutated.nodes.iter().find(|n| n.istopnode).unwrap();
assert_eq!(root_node.name, "[PREFIX] Root [SUFFIX]");
}
#[test]
fn test_extension_mutation_without_registry() {
let graph = create_test_graph();
let options = MutatorOptions::default();
let mutation = GraphMutation::Extension(ExtensionMutationParams {
name: "test.some_mutation".to_string(),
params: serde_json::json!({}),
conformance: MutationConformance::AlwaysConformant,
});
let result = apply_mutations(&graph, vec![mutation], options);
assert!(result.is_err());
assert!(result.unwrap_err().contains("no registry provided"));
}
#[test]
fn test_extension_mutation_not_found() {
let graph = create_test_graph();
let options = MutatorOptions::default();
let registry = ExtensionMutationRegistry::new();
let mutation = GraphMutation::Extension(ExtensionMutationParams {
name: "test.nonexistent".to_string(),
params: serde_json::json!({}),
conformance: MutationConformance::AlwaysConformant,
});
let result =
apply_mutations_with_extensions(&graph, vec![mutation], options, Some(®istry));
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[test]
fn test_extension_registry_operations() {
let mut registry = ExtensionMutationRegistry::new();
assert!(!registry.has("test.handler"));
assert!(registry.list().is_empty());
registry.register(
"test.handler",
std::sync::Arc::new(TestPrefixHandler {
prefix: "x".to_string(),
}),
);
assert!(registry.has("test.handler"));
assert!(!registry.has("test.other"));
assert_eq!(registry.list().len(), 1);
assert!(registry.get("test.handler").is_some());
}
#[test]
fn test_extension_mutation_conformance() {
let mutation = GraphMutation::Extension(ExtensionMutationParams {
name: "test.mutation".to_string(),
params: serde_json::json!({}),
conformance: MutationConformance::BranchConformant,
});
assert_eq!(
mutation.conformance(),
MutationConformance::BranchConformant
);
let mutation2 = GraphMutation::Extension(ExtensionMutationParams {
name: "test.mutation".to_string(),
params: serde_json::json!({}),
conformance: MutationConformance::ModelConformant,
});
assert_eq!(
mutation2.conformance(),
MutationConformance::ModelConformant
);
}
#[test]
fn test_extension_mutation_serialization() {
let mutation = GraphMutation::Extension(ExtensionMutationParams {
name: "clm.reference_change_collection".to_string(),
params: serde_json::json!({
"node_id": "my_node",
"collection_id": "new-collection"
}),
conformance: MutationConformance::AlwaysConformant,
});
let json = serde_json::to_string(&mutation).unwrap();
assert!(json.contains("clm.reference_change_collection"));
assert!(json.contains("my_node"));
let parsed: GraphMutation = serde_json::from_str(&json).unwrap();
if let GraphMutation::Extension(params) = parsed {
assert_eq!(params.name, "clm.reference_change_collection");
assert_eq!(params.params["node_id"], "my_node");
} else {
panic!("Expected Extension mutation");
}
}
#[test]
fn test_extension_mutation_from_json() {
let graph = create_test_graph();
let mut registry = ExtensionMutationRegistry::new();
registry.register(
"test.prefix_name",
std::sync::Arc::new(TestPrefixHandler {
prefix: "[TEST] ".to_string(),
}),
);
let mutations_json = r#"{
"mutations": [{
"Extension": {
"name": "test.prefix_name",
"params": {"suffix": "!"},
"conformance": "AlwaysConformant"
}
}],
"options": {}
}"#;
let result =
apply_mutations_from_json_with_extensions(&graph, mutations_json, Some(®istry));
assert!(result.is_ok());
let mutated = result.unwrap();
let root_node = mutated.nodes.iter().find(|n| n.istopnode).unwrap();
assert_eq!(root_node.name, "[TEST] Root!");
}
#[test]
fn test_rename_graph() {
let mut graph = create_skeleton_graph("Test Graph", "test", false, None);
let options = MutatorOptions::default();
assert_eq!(graph.name.get("en"), "Test Graph");
assert!(graph.description.is_none());
assert!(graph.subtitle.is_none());
assert!(graph.author.is_none());
let mut name_map = HashMap::new();
name_map.insert("en".to_string(), "New Name".to_string());
name_map.insert("es".to_string(), "Nuevo Nombre".to_string());
let mut desc_map = HashMap::new();
desc_map.insert("en".to_string(), "A description".to_string());
let mut subtitle_map = HashMap::new();
subtitle_map.insert("en".to_string(), "A subtitle".to_string());
apply_mutation(
&mut graph,
GraphMutation::RenameGraph(RenameGraphParams {
name: Some(name_map),
description: Some(desc_map),
subtitle: Some(subtitle_map),
author: Some("Test Author".to_string()),
}),
&options,
)
.unwrap();
assert_eq!(graph.name.get("en"), "New Name");
assert_eq!(graph.name.translations.get("es").unwrap(), "Nuevo Nombre");
assert!(graph.description.is_some());
assert_eq!(
graph.description.as_ref().unwrap().get("en"),
"A description"
);
assert!(graph.subtitle.is_some());
assert_eq!(graph.subtitle.as_ref().unwrap().get("en"), "A subtitle");
assert_eq!(graph.author, Some("Test Author".to_string()));
assert_eq!(graph.root.name, "New Name");
let root_in_nodes = graph.nodes.iter().find(|n| n.istopnode).unwrap();
assert_eq!(root_in_nodes.name, "New Name");
assert_eq!(graph.slug, Some("new_name".to_string()));
assert_eq!(graph.root.alias, Some("new_name".to_string()));
assert_eq!(root_in_nodes.alias, Some("new_name".to_string()));
}
#[test]
fn test_rename_graph_partial() {
let mut graph = create_skeleton_graph("Original Name", "test", false, None);
let options = MutatorOptions::default();
let mut desc_map = HashMap::new();
desc_map.insert("en".to_string(), "New description".to_string());
apply_mutation(
&mut graph,
GraphMutation::RenameGraph(RenameGraphParams {
name: None,
description: Some(desc_map),
subtitle: None,
author: None,
}),
&options,
)
.unwrap();
assert_eq!(graph.name.get("en"), "Original Name");
assert!(graph.description.is_some());
assert_eq!(
graph.description.as_ref().unwrap().get("en"),
"New description"
);
}
#[test]
fn test_rename_graph_via_instruction() {
let mut graph = create_skeleton_graph("Test Graph", "test", false, None);
let options = MutatorOptions::default();
let instruction = GraphInstruction::new("rename_graph", "test", "New Graph Name")
.with_str("author", "Instruction Author");
assert_eq!(
instruction.conformance(),
MutationConformance::AlwaysConformant
);
let mutation = instruction.to_mutation().unwrap();
apply_mutation(&mut graph, mutation, &options).unwrap();
assert_eq!(graph.name.get("en"), "New Graph Name");
assert_eq!(graph.author, Some("Instruction Author".to_string()));
}
#[test]
fn test_rename_graph_via_instruction_multilingual() {
let mut graph = create_skeleton_graph("Test Graph", "test", false, None);
let options = MutatorOptions::default();
let mut name_obj = serde_json::Map::new();
name_obj.insert(
"en".to_string(),
serde_json::Value::String("English Name".to_string()),
);
name_obj.insert(
"de".to_string(),
serde_json::Value::String("Deutscher Name".to_string()),
);
let mut desc_obj = serde_json::Map::new();
desc_obj.insert(
"en".to_string(),
serde_json::Value::String("English description".to_string()),
);
let instruction = GraphInstruction::new("rename_graph", "test", "")
.with_param("name", serde_json::Value::Object(name_obj))
.with_param("description", serde_json::Value::Object(desc_obj));
let mutation = instruction.to_mutation().unwrap();
apply_mutation(&mut graph, mutation, &options).unwrap();
assert_eq!(graph.name.get("en"), "English Name");
assert_eq!(graph.name.translations.get("de").unwrap(), "Deutscher Name");
assert!(graph.description.is_some());
assert_eq!(
graph.description.as_ref().unwrap().get("en"),
"English description"
);
}
}