Skip to content

Commit 2bf82da

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 593287d commit 2bf82da

3 files changed

Lines changed: 384 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
@@ -802,6 +802,12 @@ def graph_query(self, object_type: type, query: str): # -> GraphDocumentQuery:
802802
def what_changed(self) -> Dict[str, List[DocumentsChanges]]:
803803
return self._session._what_changed()
804804

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

0 commit comments

Comments
 (0)