From 2bae2fdbcdbff99a19a95a7a4d253eb57aff56ef Mon Sep 17 00:00:00 2001 From: fancy Date: Fri, 20 Feb 2026 17:15:28 +0800 Subject: [PATCH 1/3] fix(api): support delete_memory by user_id and conversation_id --- src/memos/api/handlers/memory_handler.py | 82 +++++++++++++++-- src/memos/api/product_models.py | 23 ++++- src/memos/graph_dbs/neo4j_community.py | 36 +++++--- tests/api/test_memory_handler_delete.py | 108 +++++++++++++++++++++++ 4 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 tests/api/test_memory_handler_delete.py diff --git a/src/memos/api/handlers/memory_handler.py b/src/memos/api/handlers/memory_handler.py index ef56c7489..4248bfd5e 100644 --- a/src/memos/api/handlers/memory_handler.py +++ b/src/memos/api/handlers/memory_handler.py @@ -414,19 +414,83 @@ def handle_get_memories( return GetMemoryResponse(message="Memories retrieved successfully", data=filtered_results) +def _build_quick_delete_constraints(delete_mem_req: DeleteMemoryRequest) -> dict[str, Any]: + """Build fast-delete constraints from request-level fields.""" + constraints: dict[str, Any] = {} + if delete_mem_req.user_id is not None: + constraints["user_id"] = delete_mem_req.user_id + if delete_mem_req.session_id is not None: + constraints["session_id"] = delete_mem_req.session_id + return constraints + + +def _merge_delete_filter( + base_filter: dict[str, Any] | None, + constraints: dict[str, Any], +) -> dict[str, Any]: + """Merge user/session constraints into an existing filter.""" + if not constraints: + return base_filter or {} + if base_filter is None: + return {"and": [constraints.copy()]} + + if "and" in base_filter: + and_conditions = base_filter.get("and") + if not isinstance(and_conditions, list): + raise ValueError("Invalid filter format: 'and' must be a list") + return {"and": [*and_conditions, constraints.copy()]} + + if "or" in base_filter: + or_conditions = base_filter.get("or") + if not isinstance(or_conditions, list): + raise ValueError("Invalid filter format: 'or' must be a list") + + merged_or_conditions: list[dict[str, Any]] = [] + for condition in or_conditions: + if not isinstance(condition, dict): + raise ValueError("Invalid filter format: each 'or' condition must be a dict") + merged_condition = condition.copy() + for key, value in constraints.items(): + if key in merged_condition and merged_condition[key] != value: + raise ValueError( + f"Conflicting filter condition for '{key}'. " + "Please merge it manually into request.filter." + ) + merged_condition[key] = value + merged_or_conditions.append(merged_condition) + + return {"or": merged_or_conditions} + + # For plain dict filters, keep strict AND semantics explicitly. + return {"and": [base_filter.copy(), constraints.copy()]} + + def handle_delete_memories(delete_mem_req: DeleteMemoryRequest, naive_mem_cube: NaiveMemCube): logger.info( - f"[Delete memory request] writable_cube_ids: {delete_mem_req.writable_cube_ids}, memory_ids: {delete_mem_req.memory_ids}" + "[Delete memory request] writable_cube_ids: %s, memory_ids: %s, file_ids: %s, " + "has_filter: %s, user_id: %s, session_id: %s", + delete_mem_req.writable_cube_ids, + delete_mem_req.memory_ids, + delete_mem_req.file_ids, + delete_mem_req.filter is not None, + delete_mem_req.user_id, + delete_mem_req.session_id, ) - # Validate that only one of memory_ids, file_ids, or filter is provided + quick_constraints = _build_quick_delete_constraints(delete_mem_req) + has_filter_mode = delete_mem_req.filter is not None or bool(quick_constraints) + + # Validate that only one mode is provided: memory_ids, file_ids, or filter-mode. provided_params = [ delete_mem_req.memory_ids is not None, delete_mem_req.file_ids is not None, - delete_mem_req.filter is not None, + has_filter_mode, ] if sum(provided_params) != 1: return DeleteMemoryResponse( - message="Exactly one of memory_ids, file_ids, or filter must be provided", + message=( + "Exactly one delete mode must be provided: " + "memory_ids, file_ids, or filter/user_id/session_id." + ), data={"status": "failure"}, ) @@ -439,10 +503,14 @@ def handle_delete_memories(delete_mem_req: DeleteMemoryRequest, naive_mem_cube: naive_mem_cube.text_mem.delete_by_filter( writable_cube_ids=delete_mem_req.writable_cube_ids, file_ids=delete_mem_req.file_ids ) - elif delete_mem_req.filter is not None: - naive_mem_cube.text_mem.delete_by_filter(filter=delete_mem_req.filter) + elif has_filter_mode: + merged_filter = _merge_delete_filter(delete_mem_req.filter, quick_constraints) + naive_mem_cube.text_mem.delete_by_filter( + writable_cube_ids=delete_mem_req.writable_cube_ids, + filter=merged_filter, + ) if naive_mem_cube.pref_mem is not None: - naive_mem_cube.pref_mem.delete_by_filter(filter=delete_mem_req.filter) + naive_mem_cube.pref_mem.delete_by_filter(filter=merged_filter) except Exception as e: logger.error(f"Failed to delete memories: {e}", exc_info=True) return DeleteMemoryResponse( diff --git a/src/memos/api/product_models.py b/src/memos/api/product_models.py index 6fc03e735..aac6761d3 100644 --- a/src/memos/api/product_models.py +++ b/src/memos/api/product_models.py @@ -854,10 +854,31 @@ class GetMemoryDashboardRequest(GetMemoryRequest): class DeleteMemoryRequest(BaseRequest): """Request model for deleting memories.""" - writable_cube_ids: list[str] = Field(None, description="Writable cube IDs") + writable_cube_ids: list[str] | None = Field(None, description="Writable cube IDs") memory_ids: list[str] | None = Field(None, description="Memory IDs") file_ids: list[str] | None = Field(None, description="File IDs") filter: dict[str, Any] | None = Field(None, description="Filter for the memory") + user_id: str | None = Field( + None, + description="Quick delete condition: remove memories for this user_id.", + ) + session_id: str | None = Field( + None, + description="Quick delete condition: remove memories for this session_id.", + ) + conversation_id: str | None = Field( + None, + description="Alias of session_id for backward compatibility.", + ) + + @model_validator(mode="after") + def normalize_session_alias(self) -> "DeleteMemoryRequest": + """Normalize conversation_id to session_id.""" + if self.conversation_id and self.session_id and self.conversation_id != self.session_id: + raise ValueError("conversation_id and session_id must be the same when both are set") + if self.session_id is None and self.conversation_id is not None: + self.session_id = self.conversation_id + return self class SuggestionRequest(BaseRequest): diff --git a/src/memos/graph_dbs/neo4j_community.py b/src/memos/graph_dbs/neo4j_community.py index cae7d6ca5..b455fd899 100644 --- a/src/memos/graph_dbs/neo4j_community.py +++ b/src/memos/graph_dbs/neo4j_community.py @@ -811,6 +811,14 @@ def build_filter_condition( if condition_str: where_clauses.append(f"({condition_str})") filter_params.update(filter_params_inner) + else: + # Simple dict syntax: {"user_id": "...", "session_id": "..."} + condition_str, filter_params_inner = build_filter_condition( + filter, param_counter + ) + if condition_str: + where_clauses.append(f"({condition_str})") + filter_params.update(filter_params_inner) where_str = " AND ".join(where_clauses) if where_clauses else "" if where_str: @@ -841,7 +849,7 @@ def build_filter_condition( def delete_node_by_prams( self, - writable_cube_ids: list[str], + writable_cube_ids: list[str] | None = None, memory_ids: list[str] | None = None, file_ids: list[str] | None = None, filter: dict | None = None, @@ -850,7 +858,7 @@ def delete_node_by_prams( Delete nodes by memory_ids, file_ids, or filter. Args: - writable_cube_ids (list[str]): List of cube IDs (user_name) to filter nodes. Required parameter. + writable_cube_ids (list[str], optional): List of cube IDs (user_name) to scope deletion. memory_ids (list[str], optional): List of memory node IDs to delete. file_ids (list[str], optional): List of file node IDs to delete. filter (dict, optional): Filter dictionary to query matching nodes for deletion. @@ -865,9 +873,9 @@ def delete_node_by_prams( f"[delete_node_by_prams] memory_ids: {memory_ids}, file_ids: {file_ids}, filter: {filter}, writable_cube_ids: {writable_cube_ids}" ) - # Validate writable_cube_ids - if not writable_cube_ids or len(writable_cube_ids) == 0: - raise ValueError("writable_cube_ids is required and cannot be empty") + # file_ids deletion must be scoped by writable_cube_ids. + if file_ids and (not writable_cube_ids or len(writable_cube_ids) == 0): + raise ValueError("writable_cube_ids is required when deleting by file_ids") # Build WHERE conditions separately for memory_ids and file_ids where_clauses = [] @@ -875,10 +883,11 @@ def delete_node_by_prams( # Build user_name condition from writable_cube_ids (OR relationship - match any cube_id) user_name_conditions = [] - for idx, cube_id in enumerate(writable_cube_ids): - param_name = f"cube_id_{idx}" - user_name_conditions.append(f"n.user_name = ${param_name}") - params[param_name] = cube_id + if writable_cube_ids: + for idx, cube_id in enumerate(writable_cube_ids): + param_name = f"cube_id_{idx}" + user_name_conditions.append(f"n.user_name = ${param_name}") + params[param_name] = cube_id # Handle memory_ids: query n.id if memory_ids and len(memory_ids) > 0: @@ -925,9 +934,12 @@ def delete_node_by_prams( # First, combine memory_ids, file_ids, and filter conditions with OR (any condition can match) data_conditions = " OR ".join([f"({clause})" for clause in where_clauses]) - # Then, combine with user_name condition using AND (must match user_name AND one of the data conditions) - user_name_where = " OR ".join(user_name_conditions) - ids_where = f"({user_name_where}) AND ({data_conditions})" + # Then, combine with user_name condition using AND when scope is provided. + if user_name_conditions: + user_name_where = " OR ".join(user_name_conditions) + ids_where = f"({user_name_where}) AND ({data_conditions})" + else: + ids_where = data_conditions logger.info( f"[delete_node_by_prams] Deleting nodes - memory_ids: {memory_ids}, file_ids: {file_ids}, filter: {filter}" diff --git a/tests/api/test_memory_handler_delete.py b/tests/api/test_memory_handler_delete.py new file mode 100644 index 000000000..db9bbac95 --- /dev/null +++ b/tests/api/test_memory_handler_delete.py @@ -0,0 +1,108 @@ +from unittest.mock import Mock + +from memos.api.handlers.memory_handler import handle_delete_memories +from memos.api.product_models import DeleteMemoryRequest + + +def _build_naive_mem_cube() -> Mock: + naive_mem_cube = Mock() + naive_mem_cube.text_mem = Mock() + naive_mem_cube.pref_mem = Mock() + return naive_mem_cube + + +def test_delete_memories_quick_by_user_id(): + naive_mem_cube = _build_naive_mem_cube() + req = DeleteMemoryRequest(user_id="u_1") + + resp = handle_delete_memories(req, naive_mem_cube) + + assert resp.data["status"] == "success" + naive_mem_cube.text_mem.delete_by_filter.assert_called_once_with( + writable_cube_ids=None, + filter={"and": [{"user_id": "u_1"}]}, + ) + naive_mem_cube.pref_mem.delete_by_filter.assert_called_once_with( + filter={"and": [{"user_id": "u_1"}]} + ) + + +def test_delete_memories_quick_by_conversation_alias(): + naive_mem_cube = _build_naive_mem_cube() + req = DeleteMemoryRequest(conversation_id="conv_1") + + assert req.session_id == "conv_1" + + resp = handle_delete_memories(req, naive_mem_cube) + + assert resp.data["status"] == "success" + naive_mem_cube.text_mem.delete_by_filter.assert_called_once_with( + writable_cube_ids=None, + filter={"and": [{"session_id": "conv_1"}]}, + ) + naive_mem_cube.pref_mem.delete_by_filter.assert_called_once_with( + filter={"and": [{"session_id": "conv_1"}]} + ) + + +def test_delete_memories_filter_and_quick_conditions(): + naive_mem_cube = _build_naive_mem_cube() + req = DeleteMemoryRequest( + filter={"and": [{"memory_type": "WorkingMemory"}]}, + user_id="u_1", + session_id="s_1", + ) + + resp = handle_delete_memories(req, naive_mem_cube) + + assert resp.data["status"] == "success" + naive_mem_cube.text_mem.delete_by_filter.assert_called_once_with( + writable_cube_ids=None, + filter={ + "and": [ + {"memory_type": "WorkingMemory"}, + {"user_id": "u_1", "session_id": "s_1"}, + ] + }, + ) + naive_mem_cube.pref_mem.delete_by_filter.assert_called_once_with( + filter={ + "and": [ + {"memory_type": "WorkingMemory"}, + {"user_id": "u_1", "session_id": "s_1"}, + ] + } + ) + + +def test_delete_memories_filter_or_with_distribution(): + naive_mem_cube = _build_naive_mem_cube() + req = DeleteMemoryRequest( + filter={"or": [{"memory_type": "WorkingMemory"}, {"memory_type": "UserMemory"}]}, + user_id="u_1", + ) + + resp = handle_delete_memories(req, naive_mem_cube) + + assert resp.data["status"] == "success" + naive_mem_cube.text_mem.delete_by_filter.assert_called_once_with( + writable_cube_ids=None, + filter={ + "or": [ + {"memory_type": "WorkingMemory", "user_id": "u_1"}, + {"memory_type": "UserMemory", "user_id": "u_1"}, + ] + }, + ) + + +def test_delete_memories_reject_multiple_modes(): + naive_mem_cube = _build_naive_mem_cube() + req = DeleteMemoryRequest(memory_ids=["m_1"], user_id="u_1") + + resp = handle_delete_memories(req, naive_mem_cube) + + assert resp.data["status"] == "failure" + assert "Exactly one delete mode must be provided" in resp.message + naive_mem_cube.text_mem.delete_by_filter.assert_not_called() + naive_mem_cube.text_mem.delete_by_memory_ids.assert_not_called() From 8d84817e98e3936e2de051a2bf8c6494d42d2d1b Mon Sep 17 00:00:00 2001 From: fancy Date: Fri, 20 Feb 2026 17:28:30 +0800 Subject: [PATCH 2/3] fix(api): harden delete filter validation and tests --- src/memos/api/handlers/memory_handler.py | 13 ++++++++- tests/api/test_memory_handler_delete.py | 34 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/memos/api/handlers/memory_handler.py b/src/memos/api/handlers/memory_handler.py index 4248bfd5e..6a56e58fe 100644 --- a/src/memos/api/handlers/memory_handler.py +++ b/src/memos/api/handlers/memory_handler.py @@ -434,6 +434,9 @@ def _merge_delete_filter( if base_filter is None: return {"and": [constraints.copy()]} + if not base_filter: + return {"and": [constraints.copy()]} + if "and" in base_filter: and_conditions = base_filter.get("and") if not isinstance(and_conditions, list): @@ -477,7 +480,15 @@ def handle_delete_memories(delete_mem_req: DeleteMemoryRequest, naive_mem_cube: delete_mem_req.session_id, ) quick_constraints = _build_quick_delete_constraints(delete_mem_req) - has_filter_mode = delete_mem_req.filter is not None or bool(quick_constraints) + has_non_empty_filter = bool(delete_mem_req.filter) + has_filter_mode = has_non_empty_filter or bool(quick_constraints) + + # Reject empty filter dict when no quick constraints are provided. + if delete_mem_req.filter is not None and not has_non_empty_filter and not quick_constraints: + return DeleteMemoryResponse( + message="filter cannot be empty. Provide a non-empty filter or user_id/session_id.", + data={"status": "failure"}, + ) # Validate that only one mode is provided: memory_ids, file_ids, or filter-mode. provided_params = [ diff --git a/tests/api/test_memory_handler_delete.py b/tests/api/test_memory_handler_delete.py index db9bbac95..7d60f946b 100644 --- a/tests/api/test_memory_handler_delete.py +++ b/tests/api/test_memory_handler_delete.py @@ -94,6 +94,14 @@ def test_delete_memories_filter_or_with_distribution(): ] }, ) + naive_mem_cube.pref_mem.delete_by_filter.assert_called_once_with( + filter={ + "or": [ + {"memory_type": "WorkingMemory", "user_id": "u_1"}, + {"memory_type": "UserMemory", "user_id": "u_1"}, + ] + } + ) def test_delete_memories_reject_multiple_modes(): @@ -106,3 +114,29 @@ def test_delete_memories_reject_multiple_modes(): assert "Exactly one delete mode must be provided" in resp.message naive_mem_cube.text_mem.delete_by_filter.assert_not_called() naive_mem_cube.text_mem.delete_by_memory_ids.assert_not_called() + + +def test_delete_memories_reject_empty_filter(): + naive_mem_cube = _build_naive_mem_cube() + req = DeleteMemoryRequest(filter={}) + + resp = handle_delete_memories(req, naive_mem_cube) + + assert resp.data["status"] == "failure" + assert "filter cannot be empty" in resp.message + naive_mem_cube.text_mem.delete_by_filter.assert_not_called() + naive_mem_cube.pref_mem.delete_by_filter.assert_not_called() + + +def test_delete_memories_with_pref_mem_disabled(): + naive_mem_cube = _build_naive_mem_cube() + naive_mem_cube.pref_mem = None + req = DeleteMemoryRequest(user_id="u_1") + + resp = handle_delete_memories(req, naive_mem_cube) + + assert resp.data["status"] == "success" + naive_mem_cube.text_mem.delete_by_filter.assert_called_once_with( + writable_cube_ids=None, + filter={"and": [{"user_id": "u_1"}]}, + ) From dcf8c186fe5dc6d60fc9d488fd65d8b4ee7c15d9 Mon Sep 17 00:00:00 2001 From: fancy Date: Fri, 20 Feb 2026 20:58:49 +0800 Subject: [PATCH 3/3] fix(postgres): implement delete_node_by_prams for filter deletes --- src/memos/graph_dbs/postgres.py | 197 ++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/src/memos/graph_dbs/postgres.py b/src/memos/graph_dbs/postgres.py index 1c1cae378..594f7e695 100644 --- a/src/memos/graph_dbs/postgres.py +++ b/src/memos/graph_dbs/postgres.py @@ -10,6 +10,7 @@ """ import json +import re import time from contextlib import suppress @@ -438,6 +439,202 @@ def _parse_row(self, row, include_embedding: bool = False) -> dict[str, Any]: result["metadata"]["embedding"] = row[5] return result + @staticmethod + def _is_safe_field_name(field: str) -> bool: + """Validate field names used in dynamic SQL fragments.""" + return bool(re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", field)) + + def _field_expr(self, key: str) -> tuple[str, str]: + """ + Build text/json SQL expressions for a filter key. + + Returns: + tuple[text_expr, json_expr] + """ + direct_columns = {"id", "memory", "user_name", "created_at", "updated_at"} + if key in direct_columns: + return key, key + + if key.startswith("info."): + sub_key = key[5:] + if not self._is_safe_field_name(sub_key): + raise ValueError(f"Invalid filter field: {key}") + return f"properties->'info'->>'{sub_key}'", f"properties->'info'->'{sub_key}'" + + if not self._is_safe_field_name(key): + raise ValueError(f"Invalid filter field: {key}") + return f"properties->>'{key}'", f"properties->'{key}'" + + def _build_single_filter_condition( + self, condition_dict: dict[str, Any], params: list[Any] + ) -> str | None: + """Build SQL for a single filter condition dict.""" + if not condition_dict: + return None + + array_fields = {"tags", "sources", "file_ids"} + timestamp_fields = {"created_at", "updated_at"} + parts: list[str] = [] + + for key, value in condition_dict.items(): + text_expr, json_expr = self._field_expr(key) + raw_key = key[5:] if key.startswith("info.") else key + + if isinstance(value, dict): + for op, op_value in value.items(): + if op in ("gt", "lt", "gte", "lte"): + op_map = {"gt": ">", "lt": "<", "gte": ">=", "lte": "<="} + sql_op = op_map[op] + if raw_key in timestamp_fields or raw_key.endswith("_at"): + parts.append(f"({text_expr})::timestamptz {sql_op} %s::timestamptz") + params.append(op_value) + else: + parts.append(f"NULLIF({text_expr}, '')::numeric {sql_op} %s") + params.append(op_value) + elif op == "contains": + if raw_key in array_fields: + parts.append(f"{json_expr} @> %s::jsonb") + params.append(json.dumps([op_value])) + else: + parts.append(f"{text_expr} ILIKE %s") + params.append(f"%{op_value}%") + elif op == "in": + if not isinstance(op_value, list): + raise ValueError( + f"in operator expects list for '{key}', got {type(op_value).__name__}" + ) + if raw_key in array_fields: + parts.append(f"{json_expr} ?| %s") + params.append([str(v) for v in op_value]) + else: + parts.append(f"{text_expr} = ANY(%s)") + params.append([str(v) for v in op_value]) + elif op == "like": + parts.append(f"{text_expr} ILIKE %s") + params.append(f"%{op_value}%") + else: + raise ValueError(f"Unsupported filter operator: {op}") + else: + if raw_key in array_fields: + if isinstance(value, list): + parts.append(f"{json_expr} @> %s::jsonb") + params.append(json.dumps(value)) + else: + parts.append(f"{json_expr} @> %s::jsonb") + params.append(json.dumps([value])) + else: + parts.append(f"{text_expr} = %s") + params.append(str(value)) + + if not parts: + return None + return " AND ".join(parts) + + def _build_filter_where_clause(self, filter_dict: dict[str, Any], params: list[Any]) -> str: + """Build SQL WHERE fragment from filter dict.""" + if not filter_dict: + return "" + + if "and" in filter_dict: + and_conditions = filter_dict.get("and") + if not isinstance(and_conditions, list): + raise ValueError("Invalid filter format: 'and' must be a list") + parts: list[str] = [] + for cond in and_conditions: + if isinstance(cond, dict): + cond_sql = self._build_single_filter_condition(cond, params) + if cond_sql: + parts.append(f"({cond_sql})") + return " AND ".join(parts) + + if "or" in filter_dict: + or_conditions = filter_dict.get("or") + if not isinstance(or_conditions, list): + raise ValueError("Invalid filter format: 'or' must be a list") + parts: list[str] = [] + for cond in or_conditions: + if isinstance(cond, dict): + cond_sql = self._build_single_filter_condition(cond, params) + if cond_sql: + parts.append(f"({cond_sql})") + return f"({' OR '.join(parts)})" if parts else "" + + cond_sql = self._build_single_filter_condition(filter_dict, params) + return cond_sql or "" + + def delete_node_by_prams( + self, + writable_cube_ids: list[str] | None = None, + memory_ids: list[str] | None = None, + file_ids: list[str] | None = None, + filter: dict | None = None, + ) -> int: + """Delete nodes by memory_ids, file_ids, or filter.""" + logger.info( + "[delete_node_by_prams] memory_ids: %s, file_ids: %s, filter: %s, writable_cube_ids: %s", + memory_ids, + file_ids, + filter, + writable_cube_ids, + ) + + where_conditions: list[str] = [] + params: list[Any] = [] + + if memory_ids: + where_conditions.append("id = ANY(%s)") + params.append(memory_ids) + + if file_ids: + file_conditions: list[str] = [] + for file_id in file_ids: + file_conditions.append("properties->'file_ids' @> %s::jsonb") + params.append(json.dumps([file_id])) + if file_conditions: + where_conditions.append(f"({' OR '.join(file_conditions)})") + + if filter: + filter_where = self._build_filter_where_clause(filter, params) + if filter_where: + where_conditions.append(f"({filter_where})") + + if not where_conditions: + logger.warning( + "[delete_node_by_prams] No nodes to delete (no memory_ids, file_ids, or filter provided)" + ) + return 0 + + if writable_cube_ids: + where_conditions.append("user_name = ANY(%s)") + params.append(writable_cube_ids) + + where_clause = " AND ".join(where_conditions) + + conn = self._get_conn() + try: + with conn.cursor() as cur: + query = f""" + WITH to_delete AS ( + SELECT id + FROM {self.schema}.memories + WHERE {where_clause} + ), + deleted_edges AS ( + DELETE FROM {self.schema}.edges e + USING to_delete d + WHERE e.source_id = d.id OR e.target_id = d.id + ) + DELETE FROM {self.schema}.memories m + USING to_delete d + WHERE m.id = d.id + """ + cur.execute(query, params) + deleted_count = cur.rowcount if cur.rowcount is not None else 0 + logger.info("[delete_node_by_prams] Deleted %s nodes", deleted_count) + return deleted_count + finally: + self._put_conn(conn) + # ========================================================================= # Edge Management # =========================================================================