diff --git a/ravendb/documents/session/document_info.py b/ravendb/documents/session/document_info.py index bc8de6a1..f5f12a87 100644 --- a/ravendb/documents/session/document_info.py +++ b/ravendb/documents/session/document_info.py @@ -44,4 +44,4 @@ def get_new_document_info(cls, document: Dict) -> DocumentInfo: if not change_vector or not isinstance(change_vector, str): raise ValueError(f"Document {key} must have a Change Vector") - return cls(key=key, document=document, metadata=metadata, entity=None, change_vector=change_vector) + return cls(key=key, document=document, metadata=dict(metadata), entity=None, change_vector=change_vector) diff --git a/ravendb/documents/session/document_session.py b/ravendb/documents/session/document_session.py index f288b546..3170cbb7 100644 --- a/ravendb/documents/session/document_session.py +++ b/ravendb/documents/session/document_session.py @@ -802,6 +802,12 @@ def graph_query(self, object_type: type, query: str): # -> GraphDocumentQuery: def what_changed(self) -> Dict[str, List[DocumentsChanges]]: return self._session._what_changed() + def what_changed_for(self, entity: object) -> List[DocumentsChanges]: + return self._session._what_changed_for(entity) + + def get_tracked_entities(self) -> Dict[str, dict]: + return self._session._get_tracked_entities() + def exists(self, key: str) -> bool: if key is None: raise ValueError("Key cannot be None") diff --git a/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py b/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py index c1802819..eb99add7 100644 --- a/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py +++ b/ravendb/documents/session/document_session_operations/in_memory_document_session_operations.py @@ -1183,6 +1183,59 @@ def _what_changed(self) -> Dict[str, List[DocumentsChanges]]: return changes + def _what_changed_for(self, entity: object) -> List[DocumentsChanges]: + if self._documents_by_entity.get(entity) is None: + return [] + if entity in self._deleted_entities: + return [ + DocumentsChanges( + field_old_value="", + field_new_value="", + change=DocumentsChanges.ChangeType.DOCUMENT_DELETED, + ) + ] + doc_info = self._get_document_info(entity) + _update_metadata_modifications(doc_info.metadata_instance, doc_info.metadata) + new_obj = self.entity_to_json.convert_entity_to_json(entity, doc_info) + changes: Dict[str, List[DocumentsChanges]] = {} + self._entity_changed(new_obj, doc_info, changes) + return [ + DocumentsChanges( + field_old_value=d["old_value"], + field_new_value=d["new_value"], + change=d["change"], + field_name=d["field_name"], + field_path=d["field_path"], + ) + for d in changes.get(doc_info.key, []) + ] + + def _get_tracked_entities(self) -> Dict[str, dict]: + result = {} + for entity_result in self._documents_by_entity: + doc_info = entity_result.value + result[doc_info.key] = { + "id": doc_info.key, + "entity": entity_result.key, + "is_deleted": self.is_deleted(doc_info.key), + } + for deleted in self._deleted_entities: + doc_info = self._documents_by_entity.get(deleted.entity) + if doc_info: + result[doc_info.key] = { + "id": doc_info.key, + "entity": deleted.entity, + "is_deleted": True, + } + for key in self._known_missing_ids: + if key not in result: + result[key] = { + "id": key, + "entity": None, + "is_deleted": True, + } + return result + def __get_all_entities_changes(self, changes: Dict[str, List[DocumentsChanges]]) -> None: for key, value in self._documents_by_id.items(): _update_metadata_modifications(value.metadata_instance, value.metadata) diff --git a/ravendb/json/json_operation.py b/ravendb/json/json_operation.py index 7d33e5b4..1a6ae7dd 100644 --- a/ravendb/json/json_operation.py +++ b/ravendb/json/json_operation.py @@ -82,7 +82,7 @@ def compare_json( old_prop = original_json[prop] if isinstance(new_prop, (int, float, bool, str)): - if new_prop == old_prop or JsonOperation.compare_values(old_prop, new_prop): + if JsonOperation.compare_values(old_prop, new_prop): continue if changes is None: return True @@ -193,7 +193,7 @@ def compare_json_array(field_path: str, key: str, old_collection, new_collection DocumentsChanges.ChangeType.ARRAY_VALUE_CHANGED, ) elif isinstance(new_collection_item, (int, float, bool, str)): - if not str(old_collection_item) == str(new_collection_item): + if type(old_collection_item) != type(new_collection_item) or old_collection_item != new_collection_item: if changes is not None: JsonOperation.new_change( JsonOperation.add_index_field_path(field_path, position), diff --git a/ravendb/tests/session_tests/test_change_tracking_metadata.py b/ravendb/tests/session_tests/test_change_tracking_metadata.py new file mode 100644 index 00000000..1bedf3fc --- /dev/null +++ b/ravendb/tests/session_tests/test_change_tracking_metadata.py @@ -0,0 +1,419 @@ +""" +WhatChanged tracks metadata-only changes and is idempotent across repeated calls. + +C# reference: FastTests/Client/WhatChanged.cs + WhatChanged_should_be_idempotent_operation (RavenDB-9150) +""" + +from ravendb.tests.test_base import TestBase + + +class User: + def __init__(self, name: str = "", age: int = 0): + self.name = name + self.age = age + + +class TestRavenDBWhatChangedIdempotent(TestBase): + def setUp(self): + super().setUp() + + def test_what_changed_is_idempotent_with_no_modifications(self): + """ + After saving a document and loading it without any modification, + calling what_changed() twice must return the same (empty) result. + + C# equivalent: both WhatChanged() calls after load return Count == 0.""" + with self.store.open_session() as session: + session.store(User(name="Alice", age=30), "users/1") + session.save_changes() + + with self.store.open_session() as session: + session.load("users/1", User) # no modification + + result_1 = session.advanced.what_changed() + result_2 = session.advanced.what_changed() + + self.assertEqual( + result_1, + result_2, + msg=( + f"what_changed() is not idempotent (no-modification case). " + f"First call: {result_1!r}, second call: {result_2!r}" + ), + ) + + def test_what_changed_is_idempotent_with_field_change(self): + """ + After modifying a field, calling what_changed() twice must return the + same result both times. + + C# equivalent: + user1.Age = 10; + session.Delete(user2); + Assert.Equal(2, session.Advanced.WhatChanged().Count); + Assert.Equal(2, session.Advanced.WhatChanged().Count);""" + with self.store.open_session() as session: + session.store(User(name="user1"), "users/2") + session.store(User(name="user2", age=1), "users/3") + session.save_changes() + + with self.store.open_session() as session: + user1 = session.load("users/2", User) + user2 = session.load("users/3", User) + + user1.age = 10 + session.delete(user2) + + result_1 = session.advanced.what_changed() + result_2 = session.advanced.what_changed() + + self.assertEqual( + 2, + len(result_1), + msg=f"Expected 2 changed entities on first call, got: {list(result_1.keys())}", + ) + self.assertEqual( + len(result_1), + len(result_2), + msg=( + f"what_changed() is not idempotent (field change case). " + f"First call: {len(result_1)} entries, second call: {len(result_2)} entries" + ), + ) + + def test_what_changed_detects_metadata_modification(self): + """ + A metadata-only modification (via get_metadata_for()) is detected by + what_changed() and reported as a change for the affected document. + + C# spec: what_changed() reports metadata mutations. + """ + with self.store.open_session() as session: + session.store(User(name="Bob"), "users/4") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/4", User) + # Write a custom metadata key — marks MetadataAsDictionary as dirty. + meta = session.advanced.get_metadata_for(user) + meta["@custom-tag"] = "v1" + + changes = session.advanced.what_changed() + + # what_changed() must report the pending metadata modification. + self.assertIn( + "users/4", + changes, + msg="what_changed() reported no changes after metadata modification.", + ) + + def test_what_changed_and_save_changes_agree_on_metadata_write(self): + """ + what_changed() and save_changes() agree: a pending metadata write is + reported as a change and is persisted to the server. + + C# spec: what_changed() and save_changes() are consistent for metadata.""" + with self.store.open_session() as session: + session.store(User(name="Carol"), "users/5") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/5", User) + meta = session.advanced.get_metadata_for(user) + meta["@custom-tag"] = "v2" + + pending = session.advanced.what_changed() + self.assertIn( + "users/5", + pending, + msg=("what_changed() returned no changes despite pending metadata write. "), + ) + + session.save_changes() + + with self.store.open_session() as session: + reloaded_meta = session.advanced.get_metadata_for(session.load("users/5", User)) + self.assertEqual( + "v2", + reloaded_meta.get("@custom-tag"), + msg="Server did not receive the metadata update.", + ) + + +class TestRavenDBWhatChangedMetadataOps(TestBase): + def setUp(self): + super().setUp() + + def test_has_changed_returns_true_for_metadata_only_modification(self): + """ + session.advanced.has_changed(entity) must return True when the only + pending change is a new metadata key written via get_metadata_for(). + + C# equivalent: session.Advanced.HasChanged(user) returns true when any + pending modification (including metadata) is present.""" + with self.store.open_session() as session: + session.store(User(name="Alice"), "users/1") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/1", User) + meta = session.advanced.get_metadata_for(user) + meta["@custom"] = "v1" + + result = session.advanced.has_changed(user) + + self.assertTrue( + result, + msg="has_changed(entity) returned False after metadata modification.", + ) + + def test_has_changed_returns_false_when_no_modification(self): + """ + Baseline: has_changed() must return False for an unmodified loaded entity.""" + with self.store.open_session() as session: + session.store(User(name="Bob"), "users/2") + session.save_changes() + + with self.store.open_session() as session: + session.load("users/2", User) + # load without touching the entity + user = session.load("users/2", User) + + self.assertFalse( + session.advanced.has_changed(user), + msg="has_changed() should return False for an unmodified entity.", + ) + + def test_has_changes_returns_true_for_metadata_only_modification(self): + """ + session.advanced.has_changes() must return True when any entity in the + session has a pending metadata-only change.""" + with self.store.open_session() as session: + session.store(User(name="Carol"), "users/3") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/3", User) + meta = session.advanced.get_metadata_for(user) + meta["@custom"] = "v1" + + result = session.advanced.has_changes() + + self.assertTrue( + result, + msg=("has_changes() returned False after metadata modification. "), + ) + + def test_has_changes_returns_false_when_no_modification(self): + """ + Baseline: has_changes() must return False for a session with no pending + changes.""" + with self.store.open_session() as session: + session.store(User(name="Dave"), "users/4") + session.save_changes() + + with self.store.open_session() as session: + session.load("users/4", User) + + self.assertFalse( + session.advanced.has_changes(), + msg="has_changes() should return False when nothing is modified.", + ) + + def test_what_changed_detects_metadata_key_deletion(self): + """ + Deleting a previously persisted metadata key via get_metadata_for() must + be reported by what_changed().""" + # First store a doc WITH a custom metadata key. + with self.store.open_session() as session: + session.store(User(name="Eve"), "users/5") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/5", User) + meta = session.advanced.get_metadata_for(user) + meta["@custom"] = "to-be-deleted" + session.save_changes() + + # Now open a new session, load the doc, and delete the custom key. + with self.store.open_session() as session: + user = session.load("users/5", User) + meta = session.advanced.get_metadata_for(user) + self.assertEqual( + "to-be-deleted", + meta.get("@custom"), + msg="Precondition: custom metadata key should exist after first save.", + ) + del meta["@custom"] + + changes = session.advanced.what_changed() + + self.assertIn( + "users/5", + changes, + msg="what_changed() did not detect metadata key deletion.", + ) + + def test_metadata_deletion_is_detected_and_persisted(self): + """ + Deleting a metadata key via get_metadata_for() is detected by what_changed() + and persisted by save_changes(). + + C# spec: what_changed() reports metadata deletions; save_changes() removes + the key from the server.""" + with self.store.open_session() as session: + session.store(User(name="Frank"), "users/6") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/6", User) + meta = session.advanced.get_metadata_for(user) + meta["@tag"] = "remove-me" + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/6", User) + meta = session.advanced.get_metadata_for(user) + del meta["@tag"] + + pending = session.advanced.what_changed() + self.assertIn( + "users/6", + pending, + msg="what_changed() did not detect pending metadata deletion", + ) + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/6", User) + meta = session.advanced.get_metadata_for(user) + self.assertNotIn( + "@tag", + meta, + msg="Server still has the deleted metadata key — save_changes() was a no-op.", + ) + + +class Doc: + def __init__(self, name: str = ""): + self.name = name # non-empty __dict__ required for JSON serialization + + +class TestRavenDBWhatChangedMetadataMutations(TestBase): + def setUp(self): + super().setUp() + # Create the base document with two metadata arrays. + with self.store.open_session() as session: + d = Doc() + session.store(d, "d/1") + meta = session.advanced.get_metadata_for(d) + meta["Test-A"] = ["a", "a", "a"] + meta["Test-C"] = ["c", "c", "c"] + session.save_changes() + + def test_metadata_array_value_change_detected(self): + """ + Changing a metadata array value must produce ARRAY_VALUE_CHANGED entries. + + C# reference: + meta["Test-A"] = new[] { "b", "a", "c" }; // was ["a","a","a"] + changes["d/1"].Length == 2 + changes["d/1"][0].Change == ArrayValueChanged, FieldName == "Test-A" + changes["d/1"][1].Change == ArrayValueChanged, FieldName == "Test-A" + """ + with self.store.open_session() as session: + d = session.load("d/1", Doc) + meta = session.advanced.get_metadata_for(d) + meta["Test-A"] = ["b", "a", "c"] # first and last elements changed + + changes = session.advanced.what_changed() + + self.assertIn( + "d/1", + changes, + msg=("what_changed() reported no changes after modifying a metadata array. "), + ) + self.assertEqual( + 2, + len(changes["d/1"]), + msg=f"Expected 2 ARRAY_VALUE_CHANGED entries, got {len(changes['d/1'])}", + ) + + def test_metadata_key_removal_detected(self): + """ + Removing a metadata key must produce a REMOVED_FIELD entry. + + C# reference: + meta.Remove("Test-A"); + changes.Count == 1 + changes.Values.First()[0].Change == RemovedField""" + with self.store.open_session() as session: + d = session.load("d/1", Doc) + meta = session.advanced.get_metadata_for(d) + meta.pop("Test-A") + + changes = session.advanced.what_changed() + + self.assertIn( + "d/1", + changes, + msg=("what_changed() reported no changes after removing a metadata key. "), + ) + change_types = [str(c["change"]) for c in changes["d/1"]] + self.assertIn( + "removed_field", + change_types, + msg=f"Expected REMOVED_FIELD, got: {change_types}", + ) + + def test_metadata_remove_two_add_two_detected(self): + """ + Removing two metadata keys and adding two new ones must produce 4 change entries: + REMOVED_FIELD × 2 for the removed keys, NEW_FIELD × 2 for the added keys. + + C# reference: + meta.Remove("Test-A"); meta.Remove("Test-C"); + meta["Test-B"] = new[]{"b","b","b"}; meta["Test-D"] = new[]{"d","d","d"}; + changes["d/1"].Length == 4 + Test-A: RemovedField, Test-C: RemovedField, Test-B: NewField, Test-D: NewField""" + with self.store.open_session() as session: + d = session.load("d/1", Doc) + meta = session.advanced.get_metadata_for(d) + meta.pop("Test-A") + meta.pop("Test-C") + meta["Test-B"] = ["b", "b", "b"] + meta["Test-D"] = ["d", "d", "d"] + + changes = session.advanced.what_changed() + + self.assertIn("d/1", changes, msg="what_changed() returned {} — no metadata mutations detected") + self.assertEqual( + 4, + len(changes["d/1"]), + msg=f"Expected 4 change entries (2 removed + 2 new), got {len(changes['d/1'])}", + ) + + def test_metadata_remove_one_add_one_detected(self): + """ + Removing one metadata key and adding a different one must produce 2 change entries. + + C# reference: + meta.Remove("Test-A"); + meta["Test-B"] = new[]{"b","b","b"}; + changes["d/1"].Length == 2 + Test-A: RemovedField, Test-B: NewField""" + with self.store.open_session() as session: + d = session.load("d/1", Doc) + meta = session.advanced.get_metadata_for(d) + meta.pop("Test-A") + meta["Test-B"] = ["b", "b", "b"] + + changes = session.advanced.what_changed() + + self.assertIn("d/1", changes, msg="what_changed() returned {} — no metadata mutations detected") + self.assertEqual( + 2, + len(changes["d/1"]), + msg=f"Expected 2 change entries (1 removed + 1 new), got {len(changes['d/1'])}", + ) diff --git a/ravendb/tests/session_tests/test_change_tracking_type_coercion.py b/ravendb/tests/session_tests/test_change_tracking_type_coercion.py new file mode 100644 index 00000000..49d045a1 --- /dev/null +++ b/ravendb/tests/session_tests/test_change_tracking_type_coercion.py @@ -0,0 +1,138 @@ +""" +WhatChanged detects array element type changes (e.g. int to str) as modifications. + +C# reference: FastTests/Client/WhatChanged.cs + What_Changed_Array_Value_Changed (RavenDB-8169) +""" + +from ravendb.tests.test_base import TestBase + + +class Doc: + def __init__(self, numbers=None): + self.numbers = numbers if numbers is not None else [] + + +class TestRavenDBWhatChangedTypeCoercion(TestBase): + def setUp(self): + super().setUp() + + def test_int_to_string_array_change_is_detected(self): + """ + Changing array elements from int to string (same digit representation) + must produce ARRAY_VALUE_CHANGED entries — one per changed element. + + C# reference: What_Changed_Array_Value_Changed verifies that elements + with different values produce ArrayValueChanged entries. The type-coercion + variant is: old=[1, 2, 3], new=["1", "2", "3"]. These have different + types despite the same digit representation, so a change must be reported.""" + with self.store.open_session() as session: + session.store(Doc(numbers=[1, 2, 3]), "docs/1") + session.save_changes() + + with self.store.open_session() as session: + doc = session.load("docs/1", Doc) + # Change each element from int to its string equivalent. + doc.numbers = ["1", "2", "3"] + + changes = session.advanced.what_changed() + + self.assertIn( + "docs/1", + changes, + msg="No changes detected after int→str array change.", + ) + doc_changes = changes["docs/1"] + changed = [c for c in doc_changes if str(c["change"]) == "array_value_changed"] + self.assertEqual( + 3, + len(changed), + msg=( + f"Expected 3 ARRAY_VALUE_CHANGED entries (int→str), got {len(changed)}. " f"All changes: {doc_changes}" + ), + ) + + def test_int_to_string_change_is_detected_and_persisted(self): + """ + After changing array elements from int to string, save_changes() persists + the new string values to the server. + + C# ref: What_Changed_Array_Value_Changed — type changes are detected and + saved.""" + with self.store.open_session() as session: + session.store(Doc(numbers=[1, 2, 3]), "docs/2") + session.save_changes() + + with self.store.open_session() as session: + doc = session.load("docs/2", Doc) + doc.numbers = ["1", "2", "3"] + session.save_changes() + + with self.store.open_session() as session: + reloaded = session.load("docs/2", Doc) + self.assertEqual( + ["1", "2", "3"], + reloaded.numbers, + msg=f"Server was not updated after int→str array change. Got: {reloaded.numbers!r}", + ) + + +class ScalarDoc: + def __init__(self, value=None, flag=None): + self.value = value + self.flag = flag + + +class TestRavenDBWhatChangedScalarTypeCoercion(TestBase): + def setUp(self): + super().setUp() + + def test_int_to_string_scalar_field_change_is_detected(self): + """ + Changing a top-level scalar field from int to its string equivalent must + produce a FIELD_CHANGED entry — the same type-coercion rule applied to + array elements (RDBC-1019) must also cover top-level scalar fields. + + compare_json uses compare_values(old, new) = old==new AND type(old)==type(new), + so int 1 and str '1' are distinct even though 1 == '1' is False in Python. + The regression: an `or old==new` short-circuit was masking the type check.""" + with self.store.open_session() as session: + session.store(ScalarDoc(value=1), "scalar/1") + session.save_changes() + + with self.store.open_session() as session: + doc = session.load("scalar/1", ScalarDoc) + doc.value = "1" # int → string with same digit representation + + changes = session.advanced.what_changed() + + self.assertIn("scalar/1", changes, msg="No changes detected after int→str scalar change.") + field_changes = [c for c in changes["scalar/1"] if str(c["change"]) == "field_changed"] + self.assertEqual( + 1, + len(field_changes), + msg=f"Expected 1 FIELD_CHANGED entry (int→str scalar), got {len(field_changes)}.", + ) + + def test_bool_to_int_scalar_field_change_is_detected(self): + """ + Changing a top-level scalar field from bool True to int 1 must produce a + FIELD_CHANGED entry. In Python, True == 1 evaluates to True, so the old + `new_prop == old_prop` short-circuit would silently suppress this change.""" + with self.store.open_session() as session: + session.store(ScalarDoc(flag=True), "scalar/2") + session.save_changes() + + with self.store.open_session() as session: + doc = session.load("scalar/2", ScalarDoc) + doc.flag = 1 # bool True → int 1 (Python: True == 1 is True) + + changes = session.advanced.what_changed() + + self.assertIn("scalar/2", changes, msg="No changes detected after bool→int scalar change.") + field_changes = [c for c in changes["scalar/2"] if str(c["change"]) == "field_changed"] + self.assertEqual( + 1, + len(field_changes), + msg=f"Expected 1 FIELD_CHANGED entry (bool→int scalar), got {len(field_changes)}.", + ) diff --git a/ravendb/tests/session_tests/test_session_advanced_api.py b/ravendb/tests/session_tests/test_session_advanced_api.py new file mode 100644 index 00000000..8af8cadf --- /dev/null +++ b/ravendb/tests/session_tests/test_session_advanced_api.py @@ -0,0 +1,326 @@ +""" +Session advanced API: what_changed_for(entity) and get_tracked_entities(). + +C# references: + FastTests/Client/WhatChangedFor.cs — all WhatChangedFor tests + FastTests/Client/TrackEntity.cs — Get_Tracked_Entities + FastTests/Client/WhatChanged.cs — WhatChanged_Delete_After_Change_Value (RavenDB-13501) +""" + +from ravendb.documents.session.misc import DocumentsChanges +from ravendb.tests.test_base import TestBase + + +class User: + def __init__(self, name: str = "", age: int = 0): + self.name = name + self.age = age + + +class Obj: + def __init__(self, id: str = None, a: str = None, b: str = None): + self.Id = id + self.A = a + self.B = b + + +class Arr: + def __init__(self, arr=None): + self.arr = arr if arr is not None else [] + + +class TestRavenDBSessionAdvancedApi(TestBase): + def setUp(self): + super().setUp() + + def test_what_changed_for_returns_per_entity_changes(self): + """ + session.advanced.what_changed_for(entity) returns the change list + for a single entity (C#: DocumentsChanges[]). + + C# ref: all WhatChangedFor.cs tests depend on this method.""" + with self.store.open_session() as session: + session.store(User(name="Alice"), "users/1") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/1", User) + user.age = 5 + + result = session.advanced.what_changed_for(user) + self.assertEqual(1, len(result), msg=f"Expected 1 change, got {len(result)}") + self.assertEqual( + DocumentsChanges.ChangeType.FIELD_CHANGED, + result[0].change, + msg=f"Expected FIELD_CHANGED, got {result[0].change}", + ) + + def test_what_changed_for_returns_document_added_for_new_entity(self): + """ + what_changed_for() on a newly stored, unsaved entity returns one + DOCUMENT_ADDED entry. + + C# ref: What_Changed_For_New_Field — first block calls WhatChangedFor + before SaveChanges and asserts 1 DocumentAdded entry.""" + with self.store.open_session() as session: + user = User(name="Alice") + session.store(user, "users/1") + + result = session.advanced.what_changed_for(user) + self.assertEqual( + 1, + len(result), + msg="Expected 1 entry (DOCUMENT_ADDED) before SaveChanges", + ) + self.assertEqual( + DocumentsChanges.ChangeType.DOCUMENT_ADDED, + result[0].change, + msg=f"Expected DOCUMENT_ADDED, got {result[0].change}", + ) + + def test_what_changed_for_returns_new_field_when_attribute_added(self): + """ + Dynamically adding an attribute to a loaded entity is reported as + NEW_FIELD by what_changed_for(). + + C# ref: What_Changed_For_New_Field — load as wider type, set new field, + expect change == NewField.""" + with self.store.open_session() as session: + session.store(User(name="Toli"), "users/1") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/1", User) + user.email = "toli@example.com" # attribute absent from stored document + + result = session.advanced.what_changed_for(user) + self.assertEqual(1, len(result), msg=f"Expected 1 NEW_FIELD entry, got {len(result)}") + self.assertEqual( + DocumentsChanges.ChangeType.NEW_FIELD, + result[0].change, + msg=f"Expected NEW_FIELD, got {result[0].change}", + ) + + def test_what_changed_for_returns_removed_field_when_attribute_deleted(self): + """ + Deleting an attribute from a loaded entity is reported as REMOVED_FIELD + by what_changed_for(). + + C# ref: What_Changed_For_Removed_Field — load as narrower type, + expect change == RemovedField.""" + with self.store.open_session() as session: + session.store(User(name="Toli", age=5), "users/1") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/1", User) + del user.age # field present in document but removed from entity + + result = session.advanced.what_changed_for(user) + self.assertEqual(1, len(result), msg=f"Expected 1 REMOVED_FIELD entry, got {len(result)}") + self.assertEqual( + DocumentsChanges.ChangeType.REMOVED_FIELD, + result[0].change, + msg=f"Expected REMOVED_FIELD, got {result[0].change}", + ) + + def test_what_changed_for_returns_document_deleted_for_deleted_entity(self): + """ + Modifying fields then deleting an entity in the same session: what_changed_for() + must return exactly one DocumentDeleted entry — field changes are discarded. + + C# ref: WhatChangedFor.cs — What_Changed_For_Delete_After_Change_Value (RavenDB-13501).""" + with self.store.open_session() as session: + session.store(Obj(id="DEL", a="A", b="A"), "DEL") + session.save_changes() + + with self.store.open_session() as session: + o = session.load("DEL", Obj) + o.A = "B" + o.B = "C" + session.delete(o) + + result = session.advanced.what_changed_for(o) + self.assertEqual(1, len(result), msg="Expected exactly 1 change entry (DocumentDeleted)") + self.assertEqual( + DocumentsChanges.ChangeType.DOCUMENT_DELETED, + result[0].change, + msg="Expected DocumentDeleted, got something else", + ) + + def test_get_tracked_entities_returns_tracked_dict(self): + """ + session.advanced.get_tracked_entities() returns a dict mapping + document ID → EntityInfo dict (keys: id, entity, is_deleted). + The entity value must be the exact stored object. + + C# ref: TrackEntity.cs — Get_Tracked_Entities verifies the tracked dict + contents both before and after save, for stored and deleted entities.""" + with self.store.open_session() as session: + user = User(name="Bob") + session.store(user, "users/2") + + tracked = session.advanced.get_tracked_entities() + self.assertIn( + "users/2", + tracked, + msg="Tracked entities should include 'users/2' after store()", + ) + self.assertIs( + user, + tracked["users/2"]["entity"], + msg="Tracked entity must be the exact stored object", + ) + self.assertFalse( + tracked["users/2"]["is_deleted"], + msg="Stored entity should not be marked as deleted", + ) + + def test_get_tracked_entities_shows_delete_by_id_as_deleted(self): + """ + session.delete(string_id) adds the key to _known_missing_ids but does NOT + add to _deleted_entities. get_tracked_entities() must include such entries + with is_deleted=True. + + C# ref: TrackEntity.cs — Get_Tracked_Entities, second session block.""" + with self.store.open_session() as session: + session.store(User(name="Eve"), "users/3") + session.save_changes() + + with self.store.open_session() as session: + session.load("users/3", User) + session.delete("users/3") # delete by string ID + + tracked = session.advanced.get_tracked_entities() + self.assertIn("users/3", tracked, msg="Deleted-by-id entry must appear in tracked dict") + self.assertTrue( + tracked["users/3"]["is_deleted"], + msg="Entry deleted by string id must have is_deleted=True", + ) + + def test_get_tracked_entities_shows_entity_delete_as_deleted(self): + """ + session.delete(entity) — object deletion — marks the entity as deleted + in get_tracked_entities(). + + C# ref: TrackEntity.cs — Get_Tracked_Entities last block: load user, + delete entity object, tracked count=1, is_deleted=True.""" + with self.store.open_session() as session: + session.store(User(name="Frank"), "users/4") + session.save_changes() + + with self.store.open_session() as session: + user = session.load("users/4", User) + session.delete(user) # delete entity object, not by string ID + + tracked = session.advanced.get_tracked_entities() + self.assertIn("users/4", tracked, msg="Entity-deleted entry must appear in tracked dict") + self.assertTrue( + tracked["users/4"]["is_deleted"], + msg="Entity deleted via session.delete(entity) must have is_deleted=True", + ) + + def test_delete_overrides_field_changes_in_what_changed(self): + """ + C# spec (RavenDB-13501): modifying fields and then calling Delete() on + the same entity in the same session must cause what_changed() to report + only DOCUMENT_DELETED — the interim field changes are discarded.""" + with self.store.open_session() as session: + session.store(Obj(id="ABC", a="A", b="A"), "ABC") + session.save_changes() + + with self.store.open_session() as session: + o = session.load("ABC", Obj) + o.A = "B" + o.B = "C" + session.delete(o) + + changes = session.advanced.what_changed() + + self.assertIn("ABC", changes, msg="Deleted entity must appear in what_changed()") + self.assertEqual(1, len(changes["ABC"]), msg="Only one change entry expected (DocumentDeleted)") + self.assertEqual( + DocumentsChanges.ChangeType.DOCUMENT_DELETED, + changes["ABC"][0].change, + msg="Expected DOCUMENT_DELETED change type", + ) + + +class TestWhatChangedForArrayChanges(TestBase): + def setUp(self): + super().setUp() + + def test_what_changed_for_reports_array_value_changed(self): + """ + what_changed_for(entity) reports ARRAY_VALUE_CHANGED for each modified + array element, with correct field_old_value and field_new_value. + + C# ref: What_Changed_For_Array_Value_Changed — ["a",1,"b"] → ["a",2,"c"] + produces exactly 2 ARRAY_VALUE_CHANGED entries.""" + with self.store.open_session() as session: + session.store(Arr(arr=["a", 1, "b"]), "arr/1") + session.save_changes() + + with self.store.open_session() as session: + arr = session.load("arr/1", Arr) + arr.arr = ["a", 2, "c"] # index 0 unchanged; index 1: 1→2; index 2: "b"→"c" + + changes = session.advanced.what_changed_for(arr) + + self.assertEqual(2, len(changes), msg=f"Expected 2 ARRAY_VALUE_CHANGED entries, got {len(changes)}") + self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_CHANGED, changes[0].change) + self.assertEqual(1, changes[0].field_old_value, msg="First change: old value should be 1") + self.assertEqual(2, changes[0].field_new_value, msg="First change: new value should be 2") + self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_CHANGED, changes[1].change) + self.assertEqual("b", changes[1].field_old_value, msg="Second change: old value should be 'b'") + self.assertEqual("c", changes[1].field_new_value, msg="Second change: new value should be 'c'") + + def test_what_changed_for_reports_array_value_added(self): + """ + what_changed_for(entity) reports ARRAY_VALUE_ADDED for each element + appended beyond the original array length, with field_old_value=None. + + C# ref: What_Changed_For_Array_Value_Added — extend ["a",1,"b"] by + 2 elements → 2 ARRAY_VALUE_ADDED entries.""" + with self.store.open_session() as session: + session.store(Arr(arr=["a", 1, "b"]), "arr/1") + session.save_changes() + + with self.store.open_session() as session: + arr = session.load("arr/1", Arr) + arr.arr = ["a", 1, "b", "c", 2] + + changes = session.advanced.what_changed_for(arr) + + self.assertEqual(2, len(changes), msg=f"Expected 2 ARRAY_VALUE_ADDED entries, got {len(changes)}") + self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_ADDED, changes[0].change) + self.assertIsNone(changes[0].field_old_value, msg="Added element must have old_value=None") + self.assertEqual("c", changes[0].field_new_value) + self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_ADDED, changes[1].change) + self.assertIsNone(changes[1].field_old_value, msg="Added element must have old_value=None") + self.assertEqual(2, changes[1].field_new_value) + + def test_what_changed_for_reports_array_value_removed(self): + """ + what_changed_for(entity) reports ARRAY_VALUE_REMOVED for each element + dropped from the original array, with field_new_value=None. + + C# ref: What_Changed_For_Array_Value_Removed — shrink ["a",1,"b"] to + ["a"] → 2 ARRAY_VALUE_REMOVED entries.""" + with self.store.open_session() as session: + session.store(Arr(arr=["a", 1, "b"]), "arr/1") + session.save_changes() + + with self.store.open_session() as session: + arr = session.load("arr/1", Arr) + arr.arr = ["a"] + + changes = session.advanced.what_changed_for(arr) + + self.assertEqual(2, len(changes), msg=f"Expected 2 ARRAY_VALUE_REMOVED entries, got {len(changes)}") + self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_REMOVED, changes[0].change) + self.assertEqual(1, changes[0].field_old_value, msg="First removed element should be 1") + self.assertIsNone(changes[0].field_new_value, msg="Removed element must have new_value=None") + self.assertEqual(DocumentsChanges.ChangeType.ARRAY_VALUE_REMOVED, changes[1].change) + self.assertEqual("b", changes[1].field_old_value, msg="Second removed element should be 'b'") + self.assertIsNone(changes[1].field_new_value, msg="Removed element must have new_value=None")