Skip to content

Commit 5975b1a

Browse files
authored
Merge pull request #268 from redknightlois/RDBC-1018
RDBC-1018/1019/1023 Fix change tracking metadata aliasing, type-coercion, add per-entity change API
2 parents fb3454d + cb4ac17 commit 5975b1a

7 files changed

Lines changed: 599 additions & 3 deletions

File tree

ravendb/documents/session/document_info.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,5 @@ def get_new_document_info(cls, document: Dict) -> DocumentInfo:
4444
if not change_vector or not isinstance(change_vector, str):
4545
raise ValueError(f"Document {key} must have a Change Vector")
4646

47-
return cls(key=key, document=document, metadata=metadata, entity=None, change_vector=change_vector)
47+
# Shallow-copy metadata so mutations on this DocumentInfo don't alias the original document dict
48+
return cls(key=key, document=document, metadata=dict(metadata), entity=None, change_vector=change_vector)

ravendb/documents/session/document_session.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,12 @@ def graph_query(self, object_type: type, query: str): # -> GraphDocumentQuery:
803803
def what_changed(self) -> Dict[str, List[DocumentsChanges]]:
804804
return self._session._what_changed()
805805

806+
def what_changed_for(self, entity: object) -> List[DocumentsChanges]:
807+
return self._session._what_changed_for(entity)
808+
809+
def get_tracked_entities(self) -> Dict[str, dict]:
810+
return self._session._get_tracked_entities()
811+
806812
def exists(self, key: str) -> bool:
807813
if key is None:
808814
raise ValueError("Key cannot be None")

ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1191,6 +1191,51 @@ def _what_changed(self) -> Dict[str, List[DocumentsChanges]]:
11911191

11921192
return changes
11931193

1194+
def _what_changed_for(self, entity: object) -> List[DocumentsChanges]:
1195+
if (doc_info := self._documents_by_entity.get(entity)) is None:
1196+
return []
1197+
if entity in self._deleted_entities:
1198+
return [
1199+
DocumentsChanges(
1200+
field_old_value="",
1201+
field_new_value="",
1202+
change=DocumentsChanges.ChangeType.DOCUMENT_DELETED,
1203+
)
1204+
]
1205+
_update_metadata_modifications(doc_info.metadata_instance, doc_info.metadata)
1206+
new_obj = self.entity_to_json.convert_entity_to_json(entity, doc_info)
1207+
changes: Dict[str, List[DocumentsChanges]] = {}
1208+
if not self._entity_changed(new_obj, doc_info, changes):
1209+
return []
1210+
return [
1211+
DocumentsChanges(
1212+
field_old_value=d["old_value"],
1213+
field_new_value=d["new_value"],
1214+
change=d["change"],
1215+
field_name=d["field_name"],
1216+
field_path=d["field_path"],
1217+
)
1218+
for d in changes.get(doc_info.key, [])
1219+
]
1220+
1221+
def _get_tracked_entities(self) -> Dict[str, dict]:
1222+
result = {}
1223+
for entity_result in self._documents_by_entity:
1224+
doc_info = entity_result.value
1225+
result[doc_info.key] = {
1226+
"id": doc_info.key,
1227+
"entity": entity_result.key,
1228+
"is_deleted": self.is_deleted(doc_info.key),
1229+
}
1230+
for key in self._known_missing_ids:
1231+
if key not in result:
1232+
result[key] = {
1233+
"id": key,
1234+
"entity": None,
1235+
"is_deleted": True,
1236+
}
1237+
return result
1238+
11941239
def __get_all_entities_changes(self, changes: Dict[str, List[DocumentsChanges]]) -> None:
11951240
for key, value in self._documents_by_id.items():
11961241
_update_metadata_modifications(value.metadata_instance, value.metadata)

ravendb/json/json_operation.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def compare_json(
8282
old_prop = original_json[prop]
8383

8484
if isinstance(new_prop, (int, float, bool, str)):
85-
if new_prop == old_prop or JsonOperation.compare_values(old_prop, new_prop):
85+
if JsonOperation.compare_values(old_prop, new_prop):
8686
continue
8787
if changes is None:
8888
return True
@@ -193,7 +193,10 @@ def compare_json_array(field_path: str, key: str, old_collection, new_collection
193193
DocumentsChanges.ChangeType.ARRAY_VALUE_CHANGED,
194194
)
195195
elif isinstance(new_collection_item, (int, float, bool, str)):
196-
if not str(old_collection_item) == str(new_collection_item):
196+
if (
197+
type(old_collection_item) is not type(new_collection_item)
198+
or old_collection_item != new_collection_item
199+
):
197200
if changes is not None:
198201
JsonOperation.new_change(
199202
JsonOperation.add_index_field_path(field_path, position),
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""C# ref: FastTests/Client/WhatChanged.cs — WhatChanged_should_be_idempotent_operation (RavenDB-9150)"""
2+
3+
from ravendb.tests.test_base import TestBase
4+
5+
6+
class User:
7+
def __init__(self, name: str = "", age: int = 0):
8+
self.name = name
9+
self.age = age
10+
11+
12+
class TestRavenDBWhatChangedIdempotent(TestBase):
13+
def setUp(self):
14+
super().setUp()
15+
16+
def test_what_changed_should_be_idempotent_operation(self):
17+
"""No modifications — two calls return the same empty result."""
18+
with self.store.open_session() as session:
19+
session.store(User(name="Alice", age=30), "users/1")
20+
session.save_changes()
21+
22+
with self.store.open_session() as session:
23+
session.load("users/1", User)
24+
25+
result_1 = session.advanced.what_changed()
26+
result_2 = session.advanced.what_changed()
27+
28+
self.assertEqual(result_1, result_2)
29+
30+
def test_what_changed_should_be_idempotent_operation_with_changes(self):
31+
"""With modifications — two calls return the same non-empty result."""
32+
with self.store.open_session() as session:
33+
session.store(User(name="user1"), "users/2")
34+
session.store(User(name="user2", age=1), "users/3")
35+
session.save_changes()
36+
37+
with self.store.open_session() as session:
38+
user1 = session.load("users/2", User)
39+
user2 = session.load("users/3", User)
40+
41+
user1.age = 10
42+
session.delete(user2)
43+
44+
result_1 = session.advanced.what_changed()
45+
result_2 = session.advanced.what_changed()
46+
47+
self.assertEqual(2, len(result_1))
48+
self.assertEqual(len(result_1), len(result_2))
49+
50+
def test_what_changed_detects_metadata_modification(self):
51+
with self.store.open_session() as session:
52+
session.store(User(name="Bob"), "users/4")
53+
session.save_changes()
54+
55+
with self.store.open_session() as session:
56+
user = session.load("users/4", User)
57+
meta = session.advanced.get_metadata_for(user)
58+
meta["@custom-tag"] = "v1"
59+
60+
changes = session.advanced.what_changed()
61+
self.assertIn("users/4", changes)
62+
63+
def test_what_changed_and_save_changes_agree_on_metadata_write(self):
64+
with self.store.open_session() as session:
65+
session.store(User(name="Carol"), "users/5")
66+
session.save_changes()
67+
68+
with self.store.open_session() as session:
69+
user = session.load("users/5", User)
70+
meta = session.advanced.get_metadata_for(user)
71+
meta["@custom-tag"] = "v2"
72+
73+
pending = session.advanced.what_changed()
74+
self.assertIn("users/5", pending)
75+
76+
session.save_changes()
77+
78+
with self.store.open_session() as session:
79+
reloaded_meta = session.advanced.get_metadata_for(session.load("users/5", User))
80+
self.assertEqual("v2", reloaded_meta.get("@custom-tag"))
81+
82+
83+
class TestRavenDBWhatChangedMetadataOps(TestBase):
84+
def setUp(self):
85+
super().setUp()
86+
87+
def test_has_changed_returns_true_for_metadata_only_modification(self):
88+
with self.store.open_session() as session:
89+
session.store(User(name="Alice"), "users/1")
90+
session.save_changes()
91+
92+
with self.store.open_session() as session:
93+
user = session.load("users/1", User)
94+
meta = session.advanced.get_metadata_for(user)
95+
meta["@custom"] = "v1"
96+
97+
self.assertTrue(session.advanced.has_changed(user))
98+
99+
def test_has_changed_returns_false_when_no_modification(self):
100+
with self.store.open_session() as session:
101+
session.store(User(name="Bob"), "users/2")
102+
session.save_changes()
103+
104+
with self.store.open_session() as session:
105+
session.load("users/2", User)
106+
user = session.load("users/2", User)
107+
108+
self.assertFalse(session.advanced.has_changed(user))
109+
110+
def test_has_changes_returns_true_for_metadata_only_modification(self):
111+
with self.store.open_session() as session:
112+
session.store(User(name="Carol"), "users/3")
113+
session.save_changes()
114+
115+
with self.store.open_session() as session:
116+
user = session.load("users/3", User)
117+
meta = session.advanced.get_metadata_for(user)
118+
meta["@custom"] = "v1"
119+
120+
self.assertTrue(session.advanced.has_changes())
121+
122+
def test_has_changes_returns_false_when_no_modification(self):
123+
with self.store.open_session() as session:
124+
session.store(User(name="Dave"), "users/4")
125+
session.save_changes()
126+
127+
with self.store.open_session() as session:
128+
session.load("users/4", User)
129+
self.assertFalse(session.advanced.has_changes())
130+
131+
def test_what_changed_detects_metadata_key_deletion(self):
132+
with self.store.open_session() as session:
133+
session.store(User(name="Eve"), "users/5")
134+
session.save_changes()
135+
136+
with self.store.open_session() as session:
137+
user = session.load("users/5", User)
138+
meta = session.advanced.get_metadata_for(user)
139+
meta["@custom"] = "to-be-deleted"
140+
session.save_changes()
141+
142+
with self.store.open_session() as session:
143+
user = session.load("users/5", User)
144+
meta = session.advanced.get_metadata_for(user)
145+
self.assertEqual("to-be-deleted", meta.get("@custom"))
146+
del meta["@custom"]
147+
148+
changes = session.advanced.what_changed()
149+
self.assertIn("users/5", changes)
150+
151+
def test_metadata_deletion_is_detected_and_persisted(self):
152+
with self.store.open_session() as session:
153+
session.store(User(name="Frank"), "users/6")
154+
session.save_changes()
155+
156+
with self.store.open_session() as session:
157+
user = session.load("users/6", User)
158+
meta = session.advanced.get_metadata_for(user)
159+
meta["@tag"] = "remove-me"
160+
session.save_changes()
161+
162+
with self.store.open_session() as session:
163+
user = session.load("users/6", User)
164+
meta = session.advanced.get_metadata_for(user)
165+
del meta["@tag"]
166+
167+
self.assertIn("users/6", session.advanced.what_changed())
168+
session.save_changes()
169+
170+
with self.store.open_session() as session:
171+
meta = session.advanced.get_metadata_for(session.load("users/6", User))
172+
self.assertNotIn("@tag", meta)
173+
174+
175+
class Doc:
176+
def __init__(self, name: str = ""):
177+
self.name = name
178+
179+
180+
class TestRavenDBWhatChangedMetadataMutations(TestBase):
181+
def setUp(self):
182+
super().setUp()
183+
with self.store.open_session() as session:
184+
d = Doc()
185+
session.store(d, "d/1")
186+
meta = session.advanced.get_metadata_for(d)
187+
meta["Test-A"] = ["a", "a", "a"]
188+
meta["Test-C"] = ["c", "c", "c"]
189+
session.save_changes()
190+
191+
def test_metadata_array_value_change_detected(self):
192+
"""meta["Test-A"] = ["b","a","c"] -> 2 ARRAY_VALUE_CHANGED entries."""
193+
with self.store.open_session() as session:
194+
d = session.load("d/1", Doc)
195+
meta = session.advanced.get_metadata_for(d)
196+
meta["Test-A"] = ["b", "a", "c"]
197+
198+
changes = session.advanced.what_changed()
199+
200+
self.assertIn("d/1", changes)
201+
self.assertEqual(2, len(changes["d/1"]))
202+
203+
def test_metadata_key_removal_detected(self):
204+
"""meta.Remove("Test-A") -> 1 REMOVED_FIELD entry."""
205+
with self.store.open_session() as session:
206+
d = session.load("d/1", Doc)
207+
meta = session.advanced.get_metadata_for(d)
208+
meta.pop("Test-A")
209+
210+
changes = session.advanced.what_changed()
211+
212+
self.assertIn("d/1", changes)
213+
change_types = [str(c["change"]) for c in changes["d/1"]]
214+
self.assertIn("removed_field", change_types)
215+
216+
def test_metadata_remove_two_add_two_detected(self):
217+
"""Remove Test-A, Test-C; add Test-B, Test-D -> 4 entries."""
218+
with self.store.open_session() as session:
219+
d = session.load("d/1", Doc)
220+
meta = session.advanced.get_metadata_for(d)
221+
meta.pop("Test-A")
222+
meta.pop("Test-C")
223+
meta["Test-B"] = ["b", "b", "b"]
224+
meta["Test-D"] = ["d", "d", "d"]
225+
226+
changes = session.advanced.what_changed()
227+
228+
self.assertIn("d/1", changes)
229+
self.assertEqual(4, len(changes["d/1"]))
230+
231+
def test_metadata_remove_one_add_one_detected(self):
232+
"""Remove Test-A; add Test-B -> 2 entries."""
233+
with self.store.open_session() as session:
234+
d = session.load("d/1", Doc)
235+
meta = session.advanced.get_metadata_for(d)
236+
meta.pop("Test-A")
237+
meta["Test-B"] = ["b", "b", "b"]
238+
239+
changes = session.advanced.what_changed()
240+
241+
self.assertIn("d/1", changes)
242+
self.assertEqual(2, len(changes["d/1"]))

0 commit comments

Comments
 (0)