Skip to content

Commit 91ec1de

Browse files
committed
diff: treat identifier/key changes as object replacement; add tests for single, list, and mapping inlined cases
1 parent 3d99bd4 commit 91ec1de

File tree

2 files changed

+270
-0
lines changed

2 files changed

+270
-0
lines changed

src/runtime/src/diff.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,30 @@ pub fn diff(source: &LinkMLValue, target: &LinkMLValue, treat_missing_as_null: b
8181
..
8282
},
8383
) => {
84+
// If objects have an identifier or key slot and it changed, treat as whole-object replacement
85+
// This applies for single-valued and list-valued inlined objects.
86+
let key_slot_name = sc
87+
.key_or_identifier_slot()
88+
.or_else(|| tc.key_or_identifier_slot())
89+
.map(|s| s.name.clone());
90+
if let Some(ks) = key_slot_name {
91+
let sid = sm.get(&ks);
92+
let tid = tm.get(&ks);
93+
if let (
94+
Some(LinkMLValue::Scalar { value: s_id, .. }),
95+
Some(LinkMLValue::Scalar { value: t_id, .. }),
96+
) = (sid, tid)
97+
{
98+
if s_id != t_id {
99+
out.push(Delta {
100+
path: path.clone(),
101+
old: Some(s.to_json()),
102+
new: Some(t.to_json()),
103+
});
104+
return;
105+
}
106+
}
107+
}
84108
for (k, sv) in sm {
85109
let slot_view = sc
86110
.slots()
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
use linkml_runtime::{diff, load_json_str, load_yaml_file, patch};
2+
use linkml_schemaview::identifier::{converter_from_schema, Identifier};
3+
use linkml_schemaview::io::from_yaml;
4+
use linkml_schemaview::schemaview::SchemaView;
5+
use serde_json::Value as JsonValue;
6+
use std::path::{Path, PathBuf};
7+
8+
fn data_path(name: &str) -> PathBuf {
9+
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
10+
p.push("tests");
11+
p.push("data");
12+
p.push(name);
13+
p
14+
}
15+
16+
#[test]
17+
fn single_inlined_object_identifier_change_is_replacement() {
18+
// Use personinfo schema; diagnosis is an inlined object with identifier (via NamedThing)
19+
let schema = from_yaml(Path::new(&data_path("personinfo.yaml"))).unwrap();
20+
let mut sv = SchemaView::new();
21+
sv.add_schema(schema.clone()).unwrap();
22+
let conv = converter_from_schema(&schema);
23+
let container = sv
24+
.get_class(&Identifier::new("Container"), &conv)
25+
.unwrap()
26+
.expect("class not found");
27+
28+
let src = load_yaml_file(
29+
Path::new(&data_path("example_personinfo_data.yaml")),
30+
&sv,
31+
&container,
32+
&conv,
33+
)
34+
.unwrap();
35+
36+
// Modify diagnosis.id of the first medical history event for P:002
37+
let mut tgt_json = src.to_json();
38+
if let JsonValue::Object(ref mut root) = tgt_json {
39+
if let Some(JsonValue::Array(objects)) = root.get_mut("objects") {
40+
if let Some(JsonValue::Object(p2)) = objects.get_mut(2) {
41+
if let Some(JsonValue::Array(mh)) = p2.get_mut("has_medical_history") {
42+
if let Some(JsonValue::Object(ev0)) = mh.get_mut(0) {
43+
if let Some(JsonValue::Object(diag)) = ev0.get_mut("diagnosis") {
44+
diag.insert(
45+
"id".to_string(),
46+
JsonValue::String("CODE:D9999".to_string()),
47+
);
48+
}
49+
}
50+
}
51+
}
52+
}
53+
}
54+
let tgt = load_json_str(
55+
&serde_json::to_string(&tgt_json).unwrap(),
56+
&sv,
57+
&container,
58+
&conv,
59+
)
60+
.unwrap();
61+
62+
let deltas = diff(&src, &tgt, false);
63+
// Expect a single replacement at the diagnosis object path
64+
assert_eq!(deltas.len(), 1);
65+
let d = &deltas[0];
66+
assert_eq!(
67+
d.path,
68+
vec![
69+
"objects".to_string(),
70+
"2".to_string(),
71+
"has_medical_history".to_string(),
72+
"0".to_string(),
73+
"diagnosis".to_string()
74+
]
75+
);
76+
assert!(d.old.is_some() && d.new.is_some());
77+
78+
// Patch should yield target
79+
let (patched, _trace) = patch(&src, &deltas, &sv).unwrap();
80+
assert_eq!(patched.to_json(), tgt.to_json());
81+
}
82+
83+
#[test]
84+
fn single_inlined_object_non_identifier_change_is_field_delta() {
85+
let schema = from_yaml(Path::new(&data_path("personinfo.yaml"))).unwrap();
86+
let mut sv = SchemaView::new();
87+
sv.add_schema(schema.clone()).unwrap();
88+
let conv = converter_from_schema(&schema);
89+
let container = sv
90+
.get_class(&Identifier::new("Container"), &conv)
91+
.unwrap()
92+
.expect("class not found");
93+
94+
let src = load_yaml_file(
95+
Path::new(&data_path("example_personinfo_data.yaml")),
96+
&sv,
97+
&container,
98+
&conv,
99+
)
100+
.unwrap();
101+
102+
// Modify diagnosis.name only
103+
let mut tgt_json = src.to_json();
104+
if let JsonValue::Object(ref mut root) = tgt_json {
105+
if let Some(JsonValue::Array(objects)) = root.get_mut("objects") {
106+
if let Some(JsonValue::Object(p2)) = objects.get_mut(2) {
107+
if let Some(JsonValue::Array(mh)) = p2.get_mut("has_medical_history") {
108+
if let Some(JsonValue::Object(ev0)) = mh.get_mut(0) {
109+
if let Some(JsonValue::Object(diag)) = ev0.get_mut("diagnosis") {
110+
diag.insert(
111+
"name".to_string(),
112+
JsonValue::String("new name".to_string()),
113+
);
114+
}
115+
}
116+
}
117+
}
118+
}
119+
}
120+
let tgt = load_json_str(
121+
&serde_json::to_string(&tgt_json).unwrap(),
122+
&sv,
123+
&container,
124+
&conv,
125+
)
126+
.unwrap();
127+
128+
let deltas = diff(&src, &tgt, false);
129+
assert!(deltas.iter().any(|d| d.path
130+
== vec![
131+
"objects".to_string(),
132+
"2".to_string(),
133+
"has_medical_history".to_string(),
134+
"0".to_string(),
135+
"diagnosis".to_string(),
136+
"name".to_string()
137+
]));
138+
// Must not collapse to whole-object replacement here
139+
assert!(!deltas.iter().any(|d| d.path
140+
== vec![
141+
"objects".to_string(),
142+
"2".to_string(),
143+
"has_medical_history".to_string(),
144+
"0".to_string(),
145+
"diagnosis".to_string()
146+
]));
147+
148+
let (patched, _trace) = patch(&src, &deltas, &sv).unwrap();
149+
assert_eq!(patched.to_json(), tgt.to_json());
150+
}
151+
152+
#[test]
153+
fn list_inlined_object_identifier_change_is_replacement() {
154+
let schema = from_yaml(Path::new(&data_path("personinfo.yaml"))).unwrap();
155+
let mut sv = SchemaView::new();
156+
sv.add_schema(schema.clone()).unwrap();
157+
let conv = converter_from_schema(&schema);
158+
let container = sv
159+
.get_class(&Identifier::new("Container"), &conv)
160+
.unwrap()
161+
.expect("class not found");
162+
163+
let src = load_yaml_file(
164+
Path::new(&data_path("example_personinfo_data.yaml")),
165+
&sv,
166+
&container,
167+
&conv,
168+
)
169+
.unwrap();
170+
171+
// Change the id of the third object (P:002)
172+
let mut tgt_json = src.to_json();
173+
if let JsonValue::Object(ref mut root) = tgt_json {
174+
if let Some(JsonValue::Array(objects)) = root.get_mut("objects") {
175+
if let Some(JsonValue::Object(p2)) = objects.get_mut(2) {
176+
p2.insert("id".to_string(), JsonValue::String("P:099".to_string()));
177+
}
178+
}
179+
}
180+
let tgt = load_json_str(
181+
&serde_json::to_string(&tgt_json).unwrap(),
182+
&sv,
183+
&container,
184+
&conv,
185+
)
186+
.unwrap();
187+
188+
let deltas = diff(&src, &tgt, false);
189+
// Expect a single replacement at the list item path
190+
assert!(deltas
191+
.iter()
192+
.any(|d| d.path == vec!["objects".to_string(), "2".to_string()]));
193+
assert!(!deltas
194+
.iter()
195+
.any(|d| d.path == vec!["objects".to_string(), "2".to_string(), "id".to_string()]));
196+
197+
let (patched, _trace) = patch(&src, &deltas, &sv).unwrap();
198+
assert_eq!(patched.to_json(), tgt.to_json());
199+
}
200+
201+
#[test]
202+
fn mapping_inlined_identifier_change_is_add_delete() {
203+
// Use mapping schema with inlined_as_dict keyed by 'key'
204+
let schema = from_yaml(Path::new(&data_path("mapping_schema.yaml"))).unwrap();
205+
let mut sv = SchemaView::new();
206+
sv.add_schema(schema.clone()).unwrap();
207+
let conv = converter_from_schema(&schema);
208+
let bag = sv
209+
.get_class(&Identifier::new("Bag"), &conv)
210+
.unwrap()
211+
.expect("class not found");
212+
213+
let src = linkml_runtime::load_json_file(
214+
Path::new(&data_path("mapping_data.json")),
215+
&sv,
216+
&bag,
217+
&conv,
218+
)
219+
.unwrap();
220+
221+
// Rename mapping key 'alpha' to 'alpha2'
222+
let mut tgt_json = src.to_json();
223+
if let JsonValue::Object(ref mut root) = tgt_json {
224+
if let Some(JsonValue::Object(things)) = root.get_mut("things") {
225+
if let Some(alpha) = things.remove("alpha") {
226+
things.insert("alpha2".to_string(), alpha);
227+
}
228+
}
229+
}
230+
let tgt = load_json_str(&serde_json::to_string(&tgt_json).unwrap(), &sv, &bag, &conv).unwrap();
231+
232+
let deltas = diff(&src, &tgt, false);
233+
// Expect one delete and one add at mapping keys; no inner key-slot deltas
234+
assert!(deltas
235+
.iter()
236+
.any(|d| d.path == vec!["things".to_string(), "alpha".to_string()] && d.new.is_none()));
237+
assert!(deltas
238+
.iter()
239+
.any(|d| d.path == vec!["things".to_string(), "alpha2".to_string()] && d.old.is_none()));
240+
assert!(!deltas
241+
.iter()
242+
.any(|d| d.path == vec!["things".to_string(), "alpha".to_string(), "key".to_string()]));
243+
244+
let (patched, _trace) = patch(&src, &deltas, &sv).unwrap();
245+
assert_eq!(patched.to_json(), tgt.to_json());
246+
}

0 commit comments

Comments
 (0)