Skip to content

Commit 8cd53a6

Browse files
committed
RDBC-1023: add session.advanced.what_changed_for(entity) and get_tracked_entities()
C# Advanced.WhatChangedFor(entity) returns DocumentsChanges[] for a single entity. Python had no equivalent. Added _what_changed_for() to InMemoryDocumentSessionOperations and exposed it via Advanced.what_changed_for(). Matches C# behaviour: returns [DocumentDeleted] immediately when the entity has been deleted in the same session, before running the JSON diff. C# Advanced.GetTrackedEntities() returns a dict of all entities currently tracked by the session (stored and deleted). Added _get_tracked_entities() to InMemoryDocumentSessionOperations and exposed it via Advanced.get_tracked_entities(). Each entry maps document ID to a dict with keys: id, entity, is_deleted. Matches C# behaviour: entries deleted by string ID (present in _known_missing_ids but absent from _deleted_entities) appear with is_deleted=True. Regression tests: test_session_advanced_api.py verifies that what_changed_for() returns per-entity changes and correctly reports DocumentDeleted when the entity is deleted before the call; get_tracked_entities() includes newly stored entities with is_deleted=False and entries deleted by string id with is_deleted=True.
1 parent 58297b7 commit 8cd53a6

3 files changed

Lines changed: 385 additions & 0 deletions

File tree

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: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,59 @@ def _what_changed(self) -> Dict[str, List[DocumentsChanges]]:
11831183

11841184
return changes
11851185

1186+
def _what_changed_for(self, entity: object) -> List[DocumentsChanges]:
1187+
if self._documents_by_entity.get(entity) is None:
1188+
return []
1189+
if entity in self._deleted_entities:
1190+
return [
1191+
DocumentsChanges(
1192+
field_old_value="",
1193+
field_new_value="",
1194+
change=DocumentsChanges.ChangeType.DOCUMENT_DELETED,
1195+
)
1196+
]
1197+
doc_info = self._get_document_info(entity)
1198+
_update_metadata_modifications(doc_info.metadata_instance, doc_info.metadata)
1199+
new_obj = self.entity_to_json.convert_entity_to_json(entity, doc_info)
1200+
changes: Dict[str, List[DocumentsChanges]] = {}
1201+
self._entity_changed(new_obj, doc_info, changes)
1202+
return [
1203+
DocumentsChanges(
1204+
field_old_value=d["old_value"],
1205+
field_new_value=d["new_value"],
1206+
change=d["change"],
1207+
field_name=d["field_name"],
1208+
field_path=d["field_path"],
1209+
)
1210+
for d in changes.get(doc_info.key, [])
1211+
]
1212+
1213+
def _get_tracked_entities(self) -> Dict[str, dict]:
1214+
result = {}
1215+
for entity_result in self._documents_by_entity:
1216+
doc_info = entity_result.value
1217+
result[doc_info.key] = {
1218+
"id": doc_info.key,
1219+
"entity": entity_result.key,
1220+
"is_deleted": self.is_deleted(doc_info.key),
1221+
}
1222+
for deleted in self._deleted_entities:
1223+
doc_info = self._documents_by_entity.get(deleted.entity)
1224+
if doc_info:
1225+
result[doc_info.key] = {
1226+
"id": doc_info.key,
1227+
"entity": deleted.entity,
1228+
"is_deleted": True,
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+
11861239
def __get_all_entities_changes(self, changes: Dict[str, List[DocumentsChanges]]) -> None:
11871240
for key, value in self._documents_by_id.items():
11881241
_update_metadata_modifications(value.metadata_instance, value.metadata)
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
"""
2+
Session advanced API: what_changed_for(entity) and get_tracked_entities().
3+
4+
C# references:
5+
FastTests/Client/WhatChangedFor.cs — all WhatChangedFor tests
6+
FastTests/Client/TrackEntity.cs — Get_Tracked_Entities
7+
FastTests/Client/WhatChanged.cs — WhatChanged_Delete_After_Change_Value (RavenDB-13501)
8+
"""
9+
10+
from ravendb.documents.session.misc import DocumentsChanges
11+
from ravendb.tests.test_base import TestBase
12+
13+
14+
class User:
15+
def __init__(self, name: str = "", age: int = 0):
16+
self.name = name
17+
self.age = age
18+
19+
20+
class Obj:
21+
def __init__(self, id: str = None, a: str = None, b: str = None):
22+
self.Id = id
23+
self.A = a
24+
self.B = b
25+
26+
27+
class Arr:
28+
def __init__(self, arr=None):
29+
self.arr = arr if arr is not None else []
30+
31+
32+
class TestRavenDBSessionAdvancedApi(TestBase):
33+
def setUp(self):
34+
super().setUp()
35+
36+
def test_what_changed_for_returns_per_entity_changes(self):
37+
"""
38+
session.advanced.what_changed_for(entity) returns the change list
39+
for a single entity (C#: DocumentsChanges[]).
40+
41+
C# ref: all WhatChangedFor.cs tests depend on this method."""
42+
with self.store.open_session() as session:
43+
session.store(User(name="Alice"), "users/1")
44+
session.save_changes()
45+
46+
with self.store.open_session() as session:
47+
user = session.load("users/1", User)
48+
user.age = 5
49+
50+
result = session.advanced.what_changed_for(user)
51+
self.assertEqual(1, len(result), msg=f"Expected 1 change, got {len(result)}")
52+
self.assertEqual(
53+
DocumentsChanges.ChangeType.FIELD_CHANGED,
54+
result[0].change,
55+
msg=f"Expected FIELD_CHANGED, got {result[0].change}",
56+
)
57+
58+
def test_what_changed_for_returns_document_added_for_new_entity(self):
59+
"""
60+
what_changed_for() on a newly stored, unsaved entity returns one
61+
DOCUMENT_ADDED entry.
62+
63+
C# ref: What_Changed_For_New_Field — first block calls WhatChangedFor
64+
before SaveChanges and asserts 1 DocumentAdded entry."""
65+
with self.store.open_session() as session:
66+
user = User(name="Alice")
67+
session.store(user, "users/1")
68+
69+
result = session.advanced.what_changed_for(user)
70+
self.assertEqual(
71+
1,
72+
len(result),
73+
msg="Expected 1 entry (DOCUMENT_ADDED) before SaveChanges",
74+
)
75+
self.assertEqual(
76+
DocumentsChanges.ChangeType.DOCUMENT_ADDED,
77+
result[0].change,
78+
msg=f"Expected DOCUMENT_ADDED, got {result[0].change}",
79+
)
80+
81+
def test_what_changed_for_returns_new_field_when_attribute_added(self):
82+
"""
83+
Dynamically adding an attribute to a loaded entity is reported as
84+
NEW_FIELD by what_changed_for().
85+
86+
C# ref: What_Changed_For_New_Field — load as wider type, set new field,
87+
expect change == NewField."""
88+
with self.store.open_session() as session:
89+
session.store(User(name="Toli"), "users/1")
90+
session.save_changes()
91+
92+
with self.store.open_session() as session:
93+
user = session.load("users/1", User)
94+
user.email = "toli@example.com" # attribute absent from stored document
95+
96+
result = session.advanced.what_changed_for(user)
97+
self.assertEqual(1, len(result), msg=f"Expected 1 NEW_FIELD entry, got {len(result)}")
98+
self.assertEqual(
99+
DocumentsChanges.ChangeType.NEW_FIELD,
100+
result[0].change,
101+
msg=f"Expected NEW_FIELD, got {result[0].change}",
102+
)
103+
104+
def test_what_changed_for_returns_removed_field_when_attribute_deleted(self):
105+
"""
106+
Deleting an attribute from a loaded entity is reported as REMOVED_FIELD
107+
by what_changed_for().
108+
109+
C# ref: What_Changed_For_Removed_Field — load as narrower type,
110+
expect change == RemovedField."""
111+
with self.store.open_session() as session:
112+
session.store(User(name="Toli", age=5), "users/1")
113+
session.save_changes()
114+
115+
with self.store.open_session() as session:
116+
user = session.load("users/1", User)
117+
del user.age # field present in document but removed from entity
118+
119+
result = session.advanced.what_changed_for(user)
120+
self.assertEqual(1, len(result), msg=f"Expected 1 REMOVED_FIELD entry, got {len(result)}")
121+
self.assertEqual(
122+
DocumentsChanges.ChangeType.REMOVED_FIELD,
123+
result[0].change,
124+
msg=f"Expected REMOVED_FIELD, got {result[0].change}",
125+
)
126+
127+
def test_what_changed_for_returns_document_deleted_for_deleted_entity(self):
128+
"""
129+
Modifying fields then deleting an entity in the same session: what_changed_for()
130+
must return exactly one DocumentDeleted entry — field changes are discarded.
131+
132+
C# ref: WhatChangedFor.cs — What_Changed_For_Delete_After_Change_Value (RavenDB-13501)."""
133+
with self.store.open_session() as session:
134+
session.store(Obj(id="DEL", a="A", b="A"), "DEL")
135+
session.save_changes()
136+
137+
with self.store.open_session() as session:
138+
o = session.load("DEL", Obj)
139+
o.A = "B"
140+
o.B = "C"
141+
session.delete(o)
142+
143+
result = session.advanced.what_changed_for(o)
144+
self.assertEqual(1, len(result), msg="Expected exactly 1 change entry (DocumentDeleted)")
145+
self.assertEqual(
146+
DocumentsChanges.ChangeType.DOCUMENT_DELETED,
147+
result[0].change,
148+
msg="Expected DocumentDeleted, got something else",
149+
)
150+
151+
def test_get_tracked_entities_returns_tracked_dict(self):
152+
"""
153+
session.advanced.get_tracked_entities() returns a dict mapping
154+
document ID → EntityInfo dict (keys: id, entity, is_deleted).
155+
The entity value must be the exact stored object.
156+
157+
C# ref: TrackEntity.cs — Get_Tracked_Entities verifies the tracked dict
158+
contents both before and after save, for stored and deleted entities."""
159+
with self.store.open_session() as session:
160+
user = User(name="Bob")
161+
session.store(user, "users/2")
162+
163+
tracked = session.advanced.get_tracked_entities()
164+
self.assertIn(
165+
"users/2",
166+
tracked,
167+
msg="Tracked entities should include 'users/2' after store()",
168+
)
169+
self.assertIs(
170+
user,
171+
tracked["users/2"]["entity"],
172+
msg="Tracked entity must be the exact stored object",
173+
)
174+
self.assertFalse(
175+
tracked["users/2"]["is_deleted"],
176+
msg="Stored entity should not be marked as deleted",
177+
)
178+
179+
def test_get_tracked_entities_shows_delete_by_id_as_deleted(self):
180+
"""
181+
session.delete(string_id) adds the key to _known_missing_ids but does NOT
182+
add to _deleted_entities. get_tracked_entities() must include such entries
183+
with is_deleted=True.
184+
185+
C# ref: TrackEntity.cs — Get_Tracked_Entities, second session block."""
186+
with self.store.open_session() as session:
187+
session.store(User(name="Eve"), "users/3")
188+
session.save_changes()
189+
190+
with self.store.open_session() as session:
191+
session.load("users/3", User)
192+
session.delete("users/3") # delete by string ID
193+
194+
tracked = session.advanced.get_tracked_entities()
195+
self.assertIn("users/3", tracked, msg="Deleted-by-id entry must appear in tracked dict")
196+
self.assertTrue(
197+
tracked["users/3"]["is_deleted"],
198+
msg="Entry deleted by string id must have is_deleted=True",
199+
)
200+
201+
def test_get_tracked_entities_shows_entity_delete_as_deleted(self):
202+
"""
203+
session.delete(entity) — object deletion — marks the entity as deleted
204+
in get_tracked_entities().
205+
206+
C# ref: TrackEntity.cs — Get_Tracked_Entities last block: load user,
207+
delete entity object, tracked count=1, is_deleted=True."""
208+
with self.store.open_session() as session:
209+
session.store(User(name="Frank"), "users/4")
210+
session.save_changes()
211+
212+
with self.store.open_session() as session:
213+
user = session.load("users/4", User)
214+
session.delete(user) # delete entity object, not by string ID
215+
216+
tracked = session.advanced.get_tracked_entities()
217+
self.assertIn("users/4", tracked, msg="Entity-deleted entry must appear in tracked dict")
218+
self.assertTrue(
219+
tracked["users/4"]["is_deleted"],
220+
msg="Entity deleted via session.delete(entity) must have is_deleted=True",
221+
)
222+
223+
def test_delete_overrides_field_changes_in_what_changed(self):
224+
"""
225+
C# spec (RavenDB-13501): modifying fields and then calling Delete() on
226+
the same entity in the same session must cause what_changed() to report
227+
only DOCUMENT_DELETED — the interim field changes are discarded."""
228+
with self.store.open_session() as session:
229+
session.store(Obj(id="ABC", a="A", b="A"), "ABC")
230+
session.save_changes()
231+
232+
with self.store.open_session() as session:
233+
o = session.load("ABC", Obj)
234+
o.A = "B"
235+
o.B = "C"
236+
session.delete(o)
237+
238+
changes = session.advanced.what_changed()
239+
240+
self.assertIn("ABC", changes, msg="Deleted entity must appear in what_changed()")
241+
self.assertEqual(1, len(changes["ABC"]), msg="Only one change entry expected (DocumentDeleted)")
242+
self.assertEqual(
243+
DocumentsChanges.ChangeType.DOCUMENT_DELETED,
244+
changes["ABC"][0].change,
245+
msg="Expected DOCUMENT_DELETED change type",
246+
)
247+
248+
249+
class TestWhatChangedForArrayChanges(TestBase):
250+
def setUp(self):
251+
super().setUp()
252+
253+
def test_what_changed_for_reports_array_value_changed(self):
254+
"""
255+
what_changed_for(entity) reports ARRAY_VALUE_CHANGED for each modified
256+
array element, with correct field_old_value and field_new_value.
257+
258+
C# ref: What_Changed_For_Array_Value_Changed — ["a",1,"b"] → ["a",2,"c"]
259+
produces exactly 2 ARRAY_VALUE_CHANGED entries."""
260+
with self.store.open_session() as session:
261+
session.store(Arr(arr=["a", 1, "b"]), "arr/1")
262+
session.save_changes()
263+
264+
with self.store.open_session() as session:
265+
arr = session.load("arr/1", Arr)
266+
arr.arr = ["a", 2, "c"] # index 0 unchanged; index 1: 1→2; index 2: "b"→"c"
267+
268+
changes = session.advanced.what_changed_for(arr)
269+
270+
self.assertEqual(2, len(changes), msg=f"Expected 2 ARRAY_VALUE_CHANGED entries, got {len(changes)}")
271+
self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_CHANGED, changes[0].change)
272+
self.assertEqual(1, changes[0].field_old_value, msg="First change: old value should be 1")
273+
self.assertEqual(2, changes[0].field_new_value, msg="First change: new value should be 2")
274+
self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_CHANGED, changes[1].change)
275+
self.assertEqual("b", changes[1].field_old_value, msg="Second change: old value should be 'b'")
276+
self.assertEqual("c", changes[1].field_new_value, msg="Second change: new value should be 'c'")
277+
278+
def test_what_changed_for_reports_array_value_added(self):
279+
"""
280+
what_changed_for(entity) reports ARRAY_VALUE_ADDED for each element
281+
appended beyond the original array length, with field_old_value=None.
282+
283+
C# ref: What_Changed_For_Array_Value_Added — extend ["a",1,"b"] by
284+
2 elements → 2 ARRAY_VALUE_ADDED entries."""
285+
with self.store.open_session() as session:
286+
session.store(Arr(arr=["a", 1, "b"]), "arr/1")
287+
session.save_changes()
288+
289+
with self.store.open_session() as session:
290+
arr = session.load("arr/1", Arr)
291+
arr.arr = ["a", 1, "b", "c", 2]
292+
293+
changes = session.advanced.what_changed_for(arr)
294+
295+
self.assertEqual(2, len(changes), msg=f"Expected 2 ARRAY_VALUE_ADDED entries, got {len(changes)}")
296+
self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_ADDED, changes[0].change)
297+
self.assertIsNone(changes[0].field_old_value, msg="Added element must have old_value=None")
298+
self.assertEqual("c", changes[0].field_new_value)
299+
self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_ADDED, changes[1].change)
300+
self.assertIsNone(changes[1].field_old_value, msg="Added element must have old_value=None")
301+
self.assertEqual(2, changes[1].field_new_value)
302+
303+
def test_what_changed_for_reports_array_value_removed(self):
304+
"""
305+
what_changed_for(entity) reports ARRAY_VALUE_REMOVED for each element
306+
dropped from the original array, with field_new_value=None.
307+
308+
C# ref: What_Changed_For_Array_Value_Removed — shrink ["a",1,"b"] to
309+
["a"] → 2 ARRAY_VALUE_REMOVED entries."""
310+
with self.store.open_session() as session:
311+
session.store(Arr(arr=["a", 1, "b"]), "arr/1")
312+
session.save_changes()
313+
314+
with self.store.open_session() as session:
315+
arr = session.load("arr/1", Arr)
316+
arr.arr = ["a"]
317+
318+
changes = session.advanced.what_changed_for(arr)
319+
320+
self.assertEqual(2, len(changes), msg=f"Expected 2 ARRAY_VALUE_REMOVED entries, got {len(changes)}")
321+
self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_REMOVED, changes[0].change)
322+
self.assertEqual(1, changes[0].field_old_value, msg="First removed element should be 1")
323+
self.assertIsNone(changes[0].field_new_value, msg="Removed element must have new_value=None")
324+
self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_REMOVED, changes[1].change)
325+
self.assertEqual("b", changes[1].field_old_value, msg="Second removed element should be 'b'")
326+
self.assertIsNone(changes[1].field_new_value, msg="Removed element must have new_value=None")

0 commit comments

Comments
 (0)