fix(graph): soft-delete graph relationships instead of hard DELETE (#4188)
Signed-off-by: sxu75374 <imshuaixu@gmail.com> Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
+46
-18
@@ -174,6 +174,7 @@ class MemoryGraph:
|
|||||||
|
|
||||||
query = f"""
|
query = f"""
|
||||||
MATCH (n {self.node_label} {{{node_props_str}}})-[r]->(m {self.node_label} {{{node_props_str}}})
|
MATCH (n {self.node_label} {{{node_props_str}}})-[r]->(m {self.node_label} {{{node_props_str}}})
|
||||||
|
WHERE r.valid IS NULL OR r.valid = true
|
||||||
RETURN n.name AS source, type(r) AS relationship, m.name AS target
|
RETURN n.name AS source, type(r) AS relationship, m.name AS target
|
||||||
LIMIT $limit
|
LIMIT $limit
|
||||||
"""
|
"""
|
||||||
@@ -291,10 +292,12 @@ class MemoryGraph:
|
|||||||
CALL {{
|
CALL {{
|
||||||
WITH n
|
WITH n
|
||||||
MATCH (n)-[r]->(m {self.node_label} {{{node_props_str}}})
|
MATCH (n)-[r]->(m {self.node_label} {{{node_props_str}}})
|
||||||
|
WHERE r.valid IS NULL OR r.valid = true
|
||||||
RETURN n.name AS source, elementId(n) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, m.name AS destination, elementId(m) AS destination_id
|
RETURN n.name AS source, elementId(n) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, m.name AS destination, elementId(m) AS destination_id
|
||||||
UNION
|
UNION
|
||||||
WITH n
|
WITH n
|
||||||
MATCH (n)<-[r]-(m {self.node_label} {{{node_props_str}}})
|
MATCH (n)<-[r]-(m {self.node_label} {{{node_props_str}}})
|
||||||
|
WHERE r.valid IS NULL OR r.valid = true
|
||||||
RETURN m.name AS source, elementId(m) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, n.name AS destination, elementId(n) AS destination_id
|
RETURN m.name AS source, elementId(m) AS source_id, type(r) AS relationship, elementId(r) AS relation_id, n.name AS destination, elementId(n) AS destination_id
|
||||||
}}
|
}}
|
||||||
WITH distinct source, source_id, relationship, relation_id, destination, destination_id, similarity
|
WITH distinct source, source_id, relationship, relation_id, destination, destination_id, similarity
|
||||||
@@ -392,13 +395,15 @@ class MemoryGraph:
|
|||||||
source_props_str = ", ".join(source_props)
|
source_props_str = ", ".join(source_props)
|
||||||
dest_props_str = ", ".join(dest_props)
|
dest_props_str = ", ".join(dest_props)
|
||||||
|
|
||||||
# Delete the specific relationship between nodes
|
# Soft-delete: mark relationship as invalid instead of removing it,
|
||||||
|
# enabling temporal reasoning over historical graph state.
|
||||||
|
# See: https://github.com/mem0ai/mem0/issues/4187
|
||||||
cypher = f"""
|
cypher = f"""
|
||||||
MATCH (n {self.node_label} {{{source_props_str}}})
|
MATCH (n {self.node_label} {{{source_props_str}}})
|
||||||
-[r:{relationship}]->
|
-[r:{relationship}]->
|
||||||
(m {self.node_label} {{{dest_props_str}}})
|
(m {self.node_label} {{{dest_props_str}}})
|
||||||
|
WHERE r.valid IS NULL OR r.valid = true
|
||||||
DELETE r
|
SET r.valid = false, r.invalidated_at = datetime()
|
||||||
RETURN
|
RETURN
|
||||||
n.name AS source,
|
n.name AS source,
|
||||||
m.name AS target,
|
m.name AS target,
|
||||||
@@ -464,11 +469,16 @@ class MemoryGraph:
|
|||||||
CALL db.create.setNodeVectorProperty(destination, 'embedding', $destination_embedding)
|
CALL db.create.setNodeVectorProperty(destination, 'embedding', $destination_embedding)
|
||||||
WITH source, destination
|
WITH source, destination
|
||||||
MERGE (source)-[r:{relationship}]->(destination)
|
MERGE (source)-[r:{relationship}]->(destination)
|
||||||
ON CREATE SET
|
ON CREATE SET
|
||||||
r.created = timestamp(),
|
r.created_at = timestamp(),
|
||||||
r.mentions = 1
|
r.updated_at = timestamp(),
|
||||||
|
r.mentions = 1,
|
||||||
|
r.valid = true
|
||||||
ON MATCH SET
|
ON MATCH SET
|
||||||
r.mentions = coalesce(r.mentions, 0) + 1
|
r.mentions = coalesce(r.mentions, 0) + 1,
|
||||||
|
r.valid = true,
|
||||||
|
r.updated_at = timestamp(),
|
||||||
|
r.invalidated_at = null
|
||||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -508,11 +518,16 @@ class MemoryGraph:
|
|||||||
CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding)
|
CALL db.create.setNodeVectorProperty(source, 'embedding', $source_embedding)
|
||||||
WITH source, destination
|
WITH source, destination
|
||||||
MERGE (source)-[r:{relationship}]->(destination)
|
MERGE (source)-[r:{relationship}]->(destination)
|
||||||
ON CREATE SET
|
ON CREATE SET
|
||||||
r.created = timestamp(),
|
r.created_at = timestamp(),
|
||||||
r.mentions = 1
|
r.updated_at = timestamp(),
|
||||||
|
r.mentions = 1,
|
||||||
|
r.valid = true
|
||||||
ON MATCH SET
|
ON MATCH SET
|
||||||
r.mentions = coalesce(r.mentions, 0) + 1
|
r.mentions = coalesce(r.mentions, 0) + 1,
|
||||||
|
r.valid = true,
|
||||||
|
r.updated_at = timestamp(),
|
||||||
|
r.invalidated_at = null
|
||||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -537,11 +552,16 @@ class MemoryGraph:
|
|||||||
WHERE elementId(destination) = $destination_id
|
WHERE elementId(destination) = $destination_id
|
||||||
SET destination.mentions = coalesce(destination.mentions, 0) + 1
|
SET destination.mentions = coalesce(destination.mentions, 0) + 1
|
||||||
MERGE (source)-[r:{relationship}]->(destination)
|
MERGE (source)-[r:{relationship}]->(destination)
|
||||||
ON CREATE SET
|
ON CREATE SET
|
||||||
r.created_at = timestamp(),
|
r.created_at = timestamp(),
|
||||||
r.updated_at = timestamp(),
|
r.updated_at = timestamp(),
|
||||||
r.mentions = 1
|
r.mentions = 1,
|
||||||
ON MATCH SET r.mentions = coalesce(r.mentions, 0) + 1
|
r.valid = true
|
||||||
|
ON MATCH SET
|
||||||
|
r.mentions = coalesce(r.mentions, 0) + 1,
|
||||||
|
r.valid = true,
|
||||||
|
r.updated_at = timestamp(),
|
||||||
|
r.invalidated_at = null
|
||||||
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -585,10 +605,18 @@ class MemoryGraph:
|
|||||||
WITH source, destination
|
WITH source, destination
|
||||||
CALL db.create.setNodeVectorProperty(destination, 'embedding', $dest_embedding)
|
CALL db.create.setNodeVectorProperty(destination, 'embedding', $dest_embedding)
|
||||||
WITH source, destination
|
WITH source, destination
|
||||||
MERGE (source)-[rel:{relationship}]->(destination)
|
MERGE (source)-[r:{relationship}]->(destination)
|
||||||
ON CREATE SET rel.created = timestamp(), rel.mentions = 1
|
ON CREATE SET
|
||||||
ON MATCH SET rel.mentions = coalesce(rel.mentions, 0) + 1
|
r.created_at = timestamp(),
|
||||||
RETURN source.name AS source, type(rel) AS relationship, destination.name AS target
|
r.updated_at = timestamp(),
|
||||||
|
r.mentions = 1,
|
||||||
|
r.valid = true
|
||||||
|
ON MATCH SET
|
||||||
|
r.mentions = coalesce(r.mentions, 0) + 1,
|
||||||
|
r.valid = true,
|
||||||
|
r.updated_at = timestamp(),
|
||||||
|
r.invalidated_at = null
|
||||||
|
RETURN source.name AS source, type(r) AS relationship, destination.name AS target
|
||||||
"""
|
"""
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Tests for graph memory soft-delete behavior.
|
||||||
|
|
||||||
|
Verifies that _delete_entities marks relationships as invalid (soft-delete)
|
||||||
|
rather than permanently removing them, and that search/retrieval queries
|
||||||
|
exclude soft-deleted relationships by default.
|
||||||
|
|
||||||
|
See: https://github.com/mem0ai/mem0/issues/4187
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
# Mock optional deps at module level so the import works across all Python
|
||||||
|
# versions without triggering transitive C-extension reloads (numpy via
|
||||||
|
# qdrant_client). This matches the pattern in test_memgraph_memory.py.
|
||||||
|
_neo4j_mock = Mock()
|
||||||
|
patch.dict("sys.modules", {
|
||||||
|
"langchain_neo4j": _neo4j_mock,
|
||||||
|
"rank_bm25": Mock(),
|
||||||
|
}).start()
|
||||||
|
|
||||||
|
from mem0.memory.graph_memory import MemoryGraph # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _create_graph_memory():
|
||||||
|
"""Create a MemoryGraph instance with mocked dependencies."""
|
||||||
|
with patch.object(MemoryGraph, "__init__", lambda self, *a, **kw: None):
|
||||||
|
mg = MemoryGraph.__new__(MemoryGraph)
|
||||||
|
mg.graph = Mock()
|
||||||
|
mg.graph.query = Mock(return_value=[])
|
||||||
|
mg.embedding_model = Mock()
|
||||||
|
mg.embedding_model.embed = Mock(return_value=[0.1] * 128)
|
||||||
|
mg.llm = Mock()
|
||||||
|
mg.node_label = ":Entity"
|
||||||
|
mg.threshold = 0.7
|
||||||
|
mg.llm_provider = "openai"
|
||||||
|
return mg
|
||||||
|
|
||||||
|
|
||||||
|
class TestSoftDelete:
|
||||||
|
"""Verify _delete_entities uses SET r.valid = false, not DELETE r."""
|
||||||
|
|
||||||
|
def test_delete_entities_sends_soft_delete_cypher(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg.graph.query.return_value = [
|
||||||
|
{"source": "Alice", "target": "Bob", "relationship": "KNOWS"}
|
||||||
|
]
|
||||||
|
|
||||||
|
mg._delete_entities(
|
||||||
|
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||||
|
{"user_id": "user1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
assert "SET r.valid = false" in cypher
|
||||||
|
assert "r.invalidated_at = datetime()" in cypher
|
||||||
|
assert "DELETE r" not in cypher
|
||||||
|
|
||||||
|
def test_delete_entities_only_targets_valid_edges(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg._delete_entities(
|
||||||
|
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||||
|
{"user_id": "user1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
assert "r.valid IS NULL OR r.valid = true" in cypher
|
||||||
|
|
||||||
|
def test_delete_entities_is_idempotent(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
item = [{"source": "alice", "destination": "bob", "relationship": "KNOWS"}]
|
||||||
|
filters = {"user_id": "user1"}
|
||||||
|
|
||||||
|
mg.graph.query.return_value = [
|
||||||
|
{"source": "Alice", "target": "Bob", "relationship": "KNOWS"}
|
||||||
|
]
|
||||||
|
mg._delete_entities(item, filters)
|
||||||
|
|
||||||
|
mg.graph.query.return_value = []
|
||||||
|
mg._delete_entities(item, filters)
|
||||||
|
|
||||||
|
# Both calls should have the same WHERE filter
|
||||||
|
for c in mg.graph.query.call_args_list:
|
||||||
|
assert "r.valid IS NULL OR r.valid = true" in c[0][0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchExcludesSoftDeleted:
|
||||||
|
"""Verify search and get_all filter out soft-deleted relationships."""
|
||||||
|
|
||||||
|
def test_get_all_filters_soft_deleted(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg.get_all(filters={"user_id": "user1"}, limit=10)
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
assert "r.valid IS NULL OR r.valid = true" in cypher
|
||||||
|
|
||||||
|
def test_search_graph_db_filters_both_directions(self):
|
||||||
|
"""_search_graph_db must filter soft-deleted edges in both outgoing and incoming queries."""
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg.graph.query.return_value = []
|
||||||
|
|
||||||
|
mg._search_graph_db(node_list=["alice"], filters={"user_id": "user1"})
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
# The UNION query has two MATCH branches — both must filter
|
||||||
|
occurrences = cypher.count("r.valid IS NULL OR r.valid = true")
|
||||||
|
assert occurrences >= 2, (
|
||||||
|
f"_search_graph_db has {occurrences} valid-filter(s) but needs >= 2 "
|
||||||
|
"(one for outgoing, one for incoming relationships)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_delete_all_still_hard_deletes(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg.delete_all(filters={"user_id": "user1"})
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
assert "DETACH DELETE" in cypher
|
||||||
|
|
||||||
|
|
||||||
|
class TestMergeResetsValidFlag:
|
||||||
|
"""Verify MERGE in _add_entities sets r.valid = true.
|
||||||
|
|
||||||
|
Critical: after soft-delete, a MERGE that matches the existing
|
||||||
|
(invalidated) edge must reset valid=true, or the edge becomes
|
||||||
|
a zombie -- exists but invisible to queries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _run_add_entities(self, source_found, dest_found):
|
||||||
|
"""Helper: call _add_entities with configurable node search results."""
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
|
||||||
|
source_result = (
|
||||||
|
[{"elementId(source_candidate)": "src_id_1"}] if source_found else []
|
||||||
|
)
|
||||||
|
dest_result = (
|
||||||
|
[{"elementId(destination_candidate)": "dst_id_1"}] if dest_found else []
|
||||||
|
)
|
||||||
|
|
||||||
|
mg._search_source_node = Mock(return_value=source_result)
|
||||||
|
mg._search_destination_node = Mock(return_value=dest_result)
|
||||||
|
|
||||||
|
mg._add_entities(
|
||||||
|
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||||
|
{"user_id": "user1"},
|
||||||
|
entity_type_map={},
|
||||||
|
)
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
return cypher
|
||||||
|
|
||||||
|
def test_merge_sets_valid_true_when_source_found(self):
|
||||||
|
cypher = self._run_add_entities(source_found=True, dest_found=False)
|
||||||
|
assert "r.valid = true" in cypher
|
||||||
|
|
||||||
|
def test_merge_sets_valid_true_when_dest_found(self):
|
||||||
|
cypher = self._run_add_entities(source_found=False, dest_found=True)
|
||||||
|
assert "r.valid = true" in cypher
|
||||||
|
|
||||||
|
def test_merge_sets_valid_true_when_both_found(self):
|
||||||
|
cypher = self._run_add_entities(source_found=True, dest_found=True)
|
||||||
|
assert "r.valid = true" in cypher
|
||||||
|
|
||||||
|
def test_merge_sets_valid_true_when_neither_found(self):
|
||||||
|
cypher = self._run_add_entities(source_found=False, dest_found=False)
|
||||||
|
assert "r.valid = true" in cypher
|
||||||
|
|
||||||
|
def test_merge_clears_invalidated_at_on_resurrection(self):
|
||||||
|
"""When a soft-deleted edge is resurrected via MERGE, invalidated_at must be cleared.
|
||||||
|
|
||||||
|
Without this, a resurrected edge (valid=true) still carries stale
|
||||||
|
invalidated_at metadata, which corrupts temporal reasoning queries.
|
||||||
|
"""
|
||||||
|
for label, src, dst in [
|
||||||
|
("source found", True, False),
|
||||||
|
("dest found", False, True),
|
||||||
|
("both found", True, True),
|
||||||
|
("neither found", False, False),
|
||||||
|
]:
|
||||||
|
cypher = self._run_add_entities(source_found=src, dest_found=dst)
|
||||||
|
assert "r.invalidated_at = null" in cypher, (
|
||||||
|
f"MERGE path '{label}': ON MATCH SET does not clear r.invalidated_at. "
|
||||||
|
"Resurrected edges will have stale invalidation timestamps."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCypherConsistency:
|
||||||
|
"""Verify all MERGE blocks use consistent property names and variable aliases."""
|
||||||
|
|
||||||
|
def _get_merge_cypher(self, source_found, dest_found):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg._search_source_node = Mock(
|
||||||
|
return_value=[{"elementId(source_candidate)": "id1"}] if source_found else []
|
||||||
|
)
|
||||||
|
mg._search_destination_node = Mock(
|
||||||
|
return_value=[{"elementId(destination_candidate)": "id2"}] if dest_found else []
|
||||||
|
)
|
||||||
|
mg._add_entities(
|
||||||
|
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||||
|
{"user_id": "user1"},
|
||||||
|
entity_type_map={},
|
||||||
|
)
|
||||||
|
return mg.graph.query.call_args[0][0]
|
||||||
|
|
||||||
|
def test_all_blocks_use_created_at_not_created(self):
|
||||||
|
"""All MERGE blocks must use r.created_at, not r.created."""
|
||||||
|
for label, src, dst in [
|
||||||
|
("source found", True, False),
|
||||||
|
("dest found", False, True),
|
||||||
|
("both found", True, True),
|
||||||
|
("neither found", False, False),
|
||||||
|
]:
|
||||||
|
cypher = self._get_merge_cypher(src, dst)
|
||||||
|
assert "r.created_at" in cypher, (
|
||||||
|
f"MERGE path '{label}': uses r.created instead of r.created_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_blocks_use_r_not_rel(self):
|
||||||
|
"""All MERGE blocks must use 'r' as the relationship variable, not 'rel'."""
|
||||||
|
for label, src, dst in [
|
||||||
|
("source found", True, False),
|
||||||
|
("dest found", False, True),
|
||||||
|
("both found", True, True),
|
||||||
|
("neither found", False, False),
|
||||||
|
]:
|
||||||
|
cypher = self._get_merge_cypher(src, dst)
|
||||||
|
assert "rel." not in cypher, (
|
||||||
|
f"MERGE path '{label}': uses 'rel' variable instead of 'r'"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_blocks_set_updated_at_on_create(self):
|
||||||
|
"""All MERGE blocks must set r.updated_at on CREATE for consistent timestamps."""
|
||||||
|
for label, src, dst in [
|
||||||
|
("source found", True, False),
|
||||||
|
("dest found", False, True),
|
||||||
|
("both found", True, True),
|
||||||
|
("neither found", False, False),
|
||||||
|
]:
|
||||||
|
cypher = self._get_merge_cypher(src, dst)
|
||||||
|
assert "r.updated_at = timestamp()" in cypher, (
|
||||||
|
f"MERGE path '{label}': missing r.updated_at on CREATE SET"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSoftDeleteWithFilters:
|
||||||
|
"""Verify soft-delete works correctly with agent_id and run_id filters."""
|
||||||
|
|
||||||
|
def test_delete_entities_with_agent_id(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg._delete_entities(
|
||||||
|
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||||
|
{"user_id": "user1", "agent_id": "agent1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
params = mg.graph.query.call_args[1]["params"]
|
||||||
|
assert "SET r.valid = false" in cypher
|
||||||
|
assert "agent_id: $agent_id" in cypher
|
||||||
|
assert params["agent_id"] == "agent1"
|
||||||
|
|
||||||
|
def test_delete_entities_with_run_id(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg._delete_entities(
|
||||||
|
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||||
|
{"user_id": "user1", "run_id": "run1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
params = mg.graph.query.call_args[1]["params"]
|
||||||
|
assert "SET r.valid = false" in cypher
|
||||||
|
assert "run_id: $run_id" in cypher
|
||||||
|
assert params["run_id"] == "run1"
|
||||||
|
|
||||||
|
def test_get_all_with_agent_id_filters_soft_deleted(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg.get_all(filters={"user_id": "user1", "agent_id": "agent1"}, limit=10)
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
assert "r.valid IS NULL OR r.valid = true" in cypher
|
||||||
|
assert "agent_id: $agent_id" in cypher
|
||||||
|
|
||||||
|
def test_merge_with_agent_id_sets_valid_true(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg._search_source_node = Mock(
|
||||||
|
return_value=[{"elementId(source_candidate)": "id1"}]
|
||||||
|
)
|
||||||
|
mg._search_destination_node = Mock(return_value=[])
|
||||||
|
|
||||||
|
mg._add_entities(
|
||||||
|
[{"source": "alice", "destination": "bob", "relationship": "KNOWS"}],
|
||||||
|
{"user_id": "user1", "agent_id": "agent1"},
|
||||||
|
entity_type_map={},
|
||||||
|
)
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
assert "r.valid = true" in cypher
|
||||||
|
assert "agent_id: $agent_id" in cypher
|
||||||
|
|
||||||
|
|
||||||
|
class TestResetAndCleanup:
|
||||||
|
"""Verify reset and delete_all use hard-delete (DETACH DELETE)."""
|
||||||
|
|
||||||
|
def test_reset_uses_detach_delete(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg.reset()
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
assert "DETACH DELETE" in cypher
|
||||||
|
assert "valid" not in cypher.lower()
|
||||||
|
|
||||||
|
def test_delete_all_does_not_soft_delete(self):
|
||||||
|
mg = _create_graph_memory()
|
||||||
|
mg.delete_all(filters={"user_id": "user1"})
|
||||||
|
|
||||||
|
cypher = mg.graph.query.call_args[0][0]
|
||||||
|
assert "DETACH DELETE" in cypher
|
||||||
|
assert "r.valid = false" not in cypher
|
||||||
Reference in New Issue
Block a user