use std::{error::Error, path::PathBuf};
use indexmap::IndexMap;
use crate::{
attribute::Attribute,
object::{Enumeration, Object},
prelude::DataModel,
tree::{self},
};
use super::schema::{
AttributeDefinition, ClassDefinition, EnumDefinition, Example, LinkML, PermissibleValue,
SlotUsage,
};
pub fn serialize_linkml(model: DataModel, out: Option<&PathBuf>) -> Result<String, Box<dyn Error>> {
let linkml = LinkML::from(model);
let yaml = serde_yaml::to_string(&linkml)?;
if let Some(out) = out {
std::fs::write(out, &yaml)?;
}
Ok(yaml)
}
impl From<DataModel> for LinkML {
fn from(model: DataModel) -> Self {
let config = model.clone().config.unwrap_or_default();
let id = &config.prefix;
let prefixes: IndexMap<String, String> =
config.prefixes.unwrap_or_default().into_iter().collect();
let name = model
.name
.clone()
.unwrap_or("Unnamed Data Model".to_string());
let mut classes: IndexMap<String, ClassDefinition> = IndexMap::from_iter(
model
.objects
.iter()
.map(|c| (c.name.clone(), c.clone().into())),
);
let slots = extract_slots(&model);
classes.values_mut().for_each(|c| {
remove_global_slots(c, &slots);
});
let graph = tree::dependency_graph(&model);
let class_order = tree::get_topological_order(&graph);
if let Some(root) = class_order.first() {
if let Some(class) = classes.get_mut(root) {
class.tree_root = Some(true);
}
}
let enums: IndexMap<String, EnumDefinition> = model
.enums
.iter()
.map(|e| (e.name.clone(), e.clone().into()))
.collect::<IndexMap<String, EnumDefinition>>();
Self {
id: id.clone(),
name: name.clone(),
title: name,
description: None,
license: None,
see_also: Vec::new(),
prefixes: prefixes.clone(),
default_prefix: id.clone(),
default_range: Some("string".to_string()),
imports: vec!["linkml:types".to_string()],
classes,
slots,
enums,
}
}
}
fn extract_slots(model: &DataModel) -> IndexMap<String, AttributeDefinition> {
let attributes: IndexMap<String, AttributeDefinition> = model
.objects
.iter()
.flat_map(|o| o.attributes.iter())
.map(|a| (a.name.clone(), a.clone().into()))
.collect();
attributes
.clone()
.into_iter()
.filter(
|(name_a, def_a)| {
attributes
.iter()
.filter(|(name_b, def_b)| name_a == *name_b && def_a == *def_b)
.count()
> 1
},
)
.collect()
}
fn remove_global_slots(class: &mut ClassDefinition, slots: &IndexMap<String, AttributeDefinition>) {
let class_attrs = class.attributes.clone().unwrap_or_default();
class.slots = class_attrs
.keys()
.filter(|name| slots.contains_key(*name))
.cloned()
.collect();
class.attributes = Some(
class_attrs
.iter()
.filter(|(name, _)| !slots.contains_key(*name))
.map(|(name, def)| (name.clone(), def.clone()))
.collect(),
);
}
impl From<Object> for ClassDefinition {
fn from(obj: Object) -> Self {
let attrib = obj
.attributes
.iter()
.map(|a| (a.name.clone(), a.clone().into()))
.collect::<IndexMap<String, AttributeDefinition>>();
let mut slot_usage = IndexMap::new();
for attr in obj.attributes.iter() {
let pattern_option = attr.options.iter().find(|o| o.key() == "pattern");
if let Some(pattern) = pattern_option {
slot_usage.insert(
attr.name.clone(),
SlotUsage {
pattern: Some(pattern.value().to_string()),
},
);
}
}
ClassDefinition {
description: Some(obj.docstring),
class_uri: obj.term.clone(),
slots: Vec::new(),
is_a: obj.term,
mixins: obj.mixins,
tree_root: None,
attributes: Some(attrib),
slot_usage: if slot_usage.is_empty() {
None
} else {
Some(slot_usage)
},
}
}
}
impl From<Attribute> for AttributeDefinition {
fn from(attribute: Attribute) -> Self {
let minimum_value = attribute.options.iter().find(|o| o.key() == "minimum");
let maximum_value = attribute.options.iter().find(|o| o.key() == "maximum");
let example = attribute
.options
.iter()
.filter(|o| o.key() == "example")
.map(|o| Example {
value: Some(o.value()),
description: None,
})
.collect::<Vec<_>>();
AttributeDefinition {
slot_uri: attribute.term,
multivalued: Some(attribute.is_array),
range: if attribute.dtypes[0] == "string" {
None
} else {
Some(attribute.dtypes[0].clone())
},
description: Some(attribute.docstring),
identifier: Some(attribute.is_id),
required: Some(attribute.required),
readonly: None,
minimum_value: minimum_value.map(|v| v.value().parse::<i64>().unwrap()),
maximum_value: maximum_value.map(|v| v.value().parse::<i64>().unwrap()),
recommended: None,
examples: example,
annotations: None,
}
}
}
impl From<Enumeration> for EnumDefinition {
fn from(enum_: Enumeration) -> Self {
let mut values = IndexMap::new();
for (key, value) in enum_.mappings.iter() {
values.insert(
key.clone(),
PermissibleValue {
text: None,
description: Some(value.clone()),
meaning: Some(value.clone()),
},
);
}
EnumDefinition {
description: Some(enum_.docstring),
permissible_values: values,
}
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use std::{collections::BTreeMap, path::PathBuf};
use crate::option::AttrOption;
use super::*;
#[test]
fn serialize_linkml_test() {
let model = DataModel::from_markdown(&PathBuf::from("tests/data/model.md")).unwrap();
let yaml = serde_yaml::from_str::<LinkML>(&serialize_linkml(model, None).unwrap()).unwrap();
let expected_yaml = serde_yaml::from_str::<LinkML>(
&std::fs::read_to_string("tests/data/expected_linkml.yml").unwrap(),
)
.unwrap();
assert_eq!(yaml, expected_yaml);
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_class_definition_conversion() {
let mut obj = Object::default();
obj.name = "TestClass".to_string();
obj.docstring = "Test description".to_string();
obj.term = Some("http://example.org/TestClass".to_string());
let mut attr = Attribute::default();
attr.name = "test_attr".to_string();
attr.options = vec![AttrOption::Pattern("^test.*$".to_string())];
attr.dtypes = vec!["string".to_string()];
obj.attributes = vec![attr];
let class_def: ClassDefinition = obj.into();
assert_eq!(class_def.description, Some("Test description".to_string()));
assert_eq!(
class_def.class_uri,
Some("http://example.org/TestClass".to_string())
);
assert!(class_def.is_a.is_some());
assert!(class_def.slot_usage.is_some());
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_attribute_definition_conversion() {
let mut attr = Attribute::default();
attr.is_array = true;
attr.dtypes = vec!["integer".to_string()];
attr.docstring = "Test attribute".to_string();
attr.is_id = true;
attr.required = true;
let attr_def: AttributeDefinition = attr.into();
assert_eq!(attr_def.multivalued, Some(true));
assert_eq!(attr_def.range, Some("integer".to_string()));
assert_eq!(attr_def.description, Some("Test attribute".to_string()));
assert_eq!(attr_def.identifier, Some(true));
assert_eq!(attr_def.required, Some(true));
}
#[test]
#[allow(clippy::field_reassign_with_default)]
fn test_enum_definition_conversion() {
let mut enum_ = Enumeration::default();
enum_.docstring = "Test enum".to_string();
enum_.mappings = BTreeMap::from([
("KEY1".to_string(), "value1".to_string()),
("KEY2".to_string(), "value2".to_string()),
]);
let enum_def: EnumDefinition = enum_.into();
assert_eq!(enum_def.description, Some("Test enum".to_string()));
assert_eq!(enum_def.permissible_values.len(), 2);
assert!(enum_def.permissible_values.contains_key("KEY1"));
assert_eq!(
enum_def.permissible_values["KEY1"].meaning,
Some("value1".to_string())
);
}
}