Files
crewAI/lib/crewai-tools/tests/tools/test_db2_search_tool.py
PawanThakurIBM c8f441cffa
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Check Documentation Broken Links / Check broken links (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Build uv cache / build-cache (3.11) (push) Has been cancelled
Build uv cache / build-cache (3.12) (push) Has been cancelled
Build uv cache / build-cache (3.13) (push) Has been cancelled
Build uv cache / build-cache (3.10) (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
feat(crewai-tools): add IBM Db2 search tool (#5885)
* feat(crewai-tools): add db2 search tool

* refactor(crewai-tools): improve db2 search tool implementation

* feat(tools): improve DB2VectorSearchTool validation, security, and configurability

* docs: add DB2SearchTool documentation

* feat: add DB2 search tool

* docs: update DB2SearchTool documentation

* fix: address CodeRabbit review feedback

* fix: validate non-empty filter_by in DB2ToolSchema

* chore: trigger CodeRabbit re-review

* feat: fortify DB2 tool; fixed JSON response shape, added input guards and config validation

* refactor(db2): replace DB2Config with connection_string field

* refactor(db2): remove dead _setup_db2 validator and importlib import

* refactor(db2): remove dead guard in _connect as  _disconnect() is called at the end of every
_run, so self.connection is always None when _connect is called next.
The 'if not self.connection' guard was dead code.

* fix(db2): tighten _validate_identifier regex. Old regex allowed leading digits, multiple periods and dot-only strings (e.g. '.....' passed).

* fix(db2): replace __import__ with importlib.import_module in _generate_embedding as keeping openai as a lazy optional import since it is not always required.

* perf(db2): cache OpenAI client in _openai_client to avoid re-instantiation as OpenAI(api_key=...) was recreated on every _generate_embedding call. Extract into _get_openai_client() which lazily initialises and caches self._openai_client on first use, reusing it for all subsequent queries.

* docs(db2): clarify tool description to mention embedding fallback

* docs(db2): update README supported features to clarify embedding behaviour. 'OpenAI embedding fallback' implied it was
optional. Replaced with 'Uses a custom embedding function if supplied,
otherwise OpenAI embeddings.'

* updated both code examples to use the correct import path and public run() method.

* feat(crewai-tools): add db2 search tool

* refactor(crewai-tools): improve db2 search tool implementation

* feat(tools): improve DB2VectorSearchTool validation, security, and configurability

* docs: add DB2SearchTool documentation

* feat: add DB2 search tool

* docs: update DB2SearchTool documentation

* fix: address CodeRabbit review feedback

* fix: validate non-empty filter_by in DB2ToolSchema

* chore: trigger CodeRabbit re-review

* feat: fortify DB2 tool; fixed JSON response shape, added input guards and config validation

* fix(db2): address ruff and mypy linter errors

* style(db2): apply ruff format to db2_search_tool.py

* fix(db2-search-tool): address PR review comments

- Restore DirectoryReadTool export accidentally removed; add DB2VectorSearchTool
  and DB2ToolSchema to crewai_tools.tools __init__ and __all__
- Align _ALLOWED_METRICS whitelist with Db2 VECTOR_DISTANCE API:
  replace DOT_PRODUCT/L2_DISTANCE with EUCLIDEAN_SQUARED/DOT/HAMMING/MANHATTAN
- Replace ImportString fields for db2_package/db2_dbi_package with plain Any +
  lazy importlib.import_module in new _resolve_db2_packages() to avoid Pydantic
  default-validation gap where strings were never resolved at construction time
- Move docs from frozen docs/v1.13.0/ snapshot to docs/edge/en/tools/database-data/
  and register in docs/docs.json; update examples to match actual API
  (connection_string constructor, not DB2Config), correct return format, and
  align documented distance metrics with the whitelist

* fix(db2-search-tool): resolve default and string db2 package imports dynamically

* fix(db2-search-tool): export DB2VectorSearchTool and DB2ToolSchema from package-level crewai_tools

* docs(db2-search-tool): fix installation command and import path in README

---------

Co-authored-by: priyanshu-krishnan1 <priyanshu.krishnan1@ibm.com>
Co-authored-by: GeetikaChugh24 <geetika@ibm.com>
Co-authored-by: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com>
Co-authored-by: Dhruv Chaturvedi <dhruv_insights@Dhruvs-MacBook-Pro.local>
2026-07-31 09:13:33 -07:00

708 lines
26 KiB
Python

"""Tests for DB2VectorSearchTool.
All tests are fully unit-tested — no real IBM DB2 instance is required.
ibm_db and ibm_db_dbi are mocked at import time so the suite runs without
those optional packages installed.
"""
from __future__ import annotations
import decimal
import datetime
import json
import sys
from types import ModuleType
from unittest.mock import MagicMock, patch, call
import pytest
# ---------------------------------------------------------------------------
# Stub ibm_db / ibm_db_dbi before any crewai_tools import, so the
# ImportString validator on DB2VectorSearchTool does not fail.
# ---------------------------------------------------------------------------
def _make_ibm_db_stub() -> ModuleType:
mod = ModuleType("ibm_db")
mod.connect = MagicMock()
mod.close = MagicMock()
return mod
def _make_ibm_db_dbi_stub() -> ModuleType:
mod = ModuleType("ibm_db_dbi")
class FakeConnection:
def __init__(self, conn):
self._conn = conn
self.cursor = MagicMock(return_value=MagicMock())
def close(self):
pass
mod.Connection = FakeConnection
return mod
# Inject stubs before importing tool module
_ibm_db_stub = _make_ibm_db_stub()
_ibm_db_dbi_stub = _make_ibm_db_dbi_stub()
sys.modules.setdefault("ibm_db", _ibm_db_stub)
sys.modules.setdefault("ibm_db_dbi", _ibm_db_dbi_stub)
from crewai_tools.tools.db2_search_tool.db2_search_tool import ( # noqa: E402
DB2JSONEncoder,
DB2ToolSchema,
DB2VectorSearchTool,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tool(
*,
table_name: str = "documents",
vector_column: str = "embedding",
return_columns: list[str] | None = None,
limit: int = 3,
distance_metric: str = "COSINE",
max_distance: float | None = None,
embedding_model: str = "text-embedding-3-large",
custom_embedding_fn=None,
) -> DB2VectorSearchTool:
"""Return a DB2VectorSearchTool with mocked ibm_db packages."""
return DB2VectorSearchTool(
connection_string="DATABASE=TESTDB;HOSTNAME=localhost;PORT=50000;PROTOCOL=TCPIP;UID=user;PWD=pass;",
table_name=table_name,
vector_column=vector_column,
return_columns=return_columns or ["content"],
limit=limit,
distance_metric=distance_metric,
max_distance=max_distance,
embedding_model=embedding_model,
db2_package=_ibm_db_stub,
db2_dbi_package=_ibm_db_dbi_stub,
custom_embedding_fn=custom_embedding_fn,
)
def _fake_embedding(text: str) -> list[float]:
return [0.1, 0.2, 0.3]
def _make_cursor_with_rows(rows: list[tuple]) -> MagicMock:
cursor = MagicMock()
cursor.fetchall.return_value = rows
return cursor
# ---------------------------------------------------------------------------
# DB2ToolSchema validation
# ---------------------------------------------------------------------------
class TestDB2ToolSchema:
def test_valid_query_only(self):
schema = DB2ToolSchema(query="find documents about AI")
assert schema.query == "find documents about AI"
assert schema.filter_by is None
assert schema.filter_value is None
def test_valid_query_with_filter_pair(self):
schema = DB2ToolSchema(query="search", filter_by="category", filter_value="tech")
assert schema.filter_by == "category"
assert schema.filter_value == "tech"
def test_filter_by_without_filter_value_raises(self):
with pytest.raises(ValueError, match="filter_by and filter_value must be provided together"):
DB2ToolSchema(query="search", filter_by="category")
def test_filter_value_without_filter_by_raises(self):
with pytest.raises(ValueError, match="filter_by and filter_value must be provided together"):
DB2ToolSchema(query="search", filter_value="tech")
def test_blank_filter_by_raises(self):
with pytest.raises(ValueError, match="filter_by must be a non-empty column name"):
DB2ToolSchema(query="search", filter_by=" ", filter_value="tech")
def test_none_filter_by_and_none_filter_value_is_valid(self):
schema = DB2ToolSchema(query="hello", filter_by=None, filter_value=None)
assert schema.filter_by is None
assert schema.filter_value is None
# ---------------------------------------------------------------------------
# DB2VectorSearchTool field validation
# ---------------------------------------------------------------------------
class TestDB2VectorSearchToolConfig:
_conn = "DATABASE=MYDB;HOSTNAME=localhost;PORT=50000;PROTOCOL=TCPIP;UID=u;PWD=p;"
def test_default_values(self):
tool = DB2VectorSearchTool(
connection_string=self._conn,
db2_package=_ibm_db_stub,
db2_dbi_package=_ibm_db_dbi_stub,
)
assert tool.return_columns == ["content"]
assert tool.limit == 3
assert tool.distance_metric == "COSINE"
assert tool.max_distance is None
def test_empty_return_columns_raises(self):
with pytest.raises(ValueError, match="return_columns cannot be empty"):
DB2VectorSearchTool(
connection_string=self._conn,
return_columns=[],
db2_package=_ibm_db_stub,
db2_dbi_package=_ibm_db_dbi_stub,
)
def test_limit_out_of_range_raises(self):
with pytest.raises(ValueError):
DB2VectorSearchTool(
connection_string=self._conn,
limit=0,
db2_package=_ibm_db_stub,
db2_dbi_package=_ibm_db_dbi_stub,
)
with pytest.raises(ValueError):
DB2VectorSearchTool(
connection_string=self._conn,
limit=101,
db2_package=_ibm_db_stub,
db2_dbi_package=_ibm_db_dbi_stub,
)
def test_negative_max_distance_raises(self):
with pytest.raises(ValueError):
DB2VectorSearchTool(
connection_string=self._conn,
max_distance=-1.0,
db2_package=_ibm_db_stub,
db2_dbi_package=_ibm_db_dbi_stub,
)
def test_multiple_return_columns(self):
tool = DB2VectorSearchTool(
connection_string=self._conn,
return_columns=["title", "body", "author"],
db2_package=_ibm_db_stub,
db2_dbi_package=_ibm_db_dbi_stub,
)
assert tool.return_columns == ["title", "body", "author"]
# ---------------------------------------------------------------------------
# DB2JSONEncoder
# ---------------------------------------------------------------------------
class TestDB2JSONEncoder:
def test_encodes_decimal(self):
result = json.dumps(decimal.Decimal("3.14"), cls=DB2JSONEncoder)
assert result == "3.14"
def test_encodes_datetime(self):
dt = datetime.datetime(2024, 1, 15, 12, 0, 0)
result = json.dumps(dt, cls=DB2JSONEncoder)
assert "2024-01-15" in result
def test_encodes_date(self):
d = datetime.date(2024, 6, 1)
result = json.dumps(d, cls=DB2JSONEncoder)
assert "2024-06-01" in result
def test_encodes_bytes(self):
result = json.dumps(b"\x00\xff", cls=DB2JSONEncoder)
assert "<binary_data>" in result
def test_raises_for_unknown_type(self):
class Unknown:
pass
with pytest.raises(TypeError):
json.dumps(Unknown(), cls=DB2JSONEncoder)
# ---------------------------------------------------------------------------
# _validate_identifier (SQL injection guard)
# ---------------------------------------------------------------------------
class TestValidateIdentifier:
def test_valid_simple_name(self):
tool = _make_tool()
assert tool._validate_identifier("documents") == "documents"
assert tool._validate_identifier("my_table_1") == "my_table_1"
def test_valid_schema_qualified_with_period(self):
tool = _make_tool()
assert tool._validate_identifier("myschema.documents", allow_period=True) == "myschema.documents"
def test_period_without_allow_period_raises(self):
tool = _make_tool()
with pytest.raises(ValueError, match="Security Alert"):
tool._validate_identifier("schema.table", allow_period=False)
@pytest.mark.parametrize("bad_name", [
"'; DROP TABLE documents; --",
"table--",
"col name",
"col;name",
"col OR 1=1",
"",
"1table", # must start with a letter
"123", # must start with a letter
".documents", # leading period
"schema..table", # double period
"schema.table.extra", # more than one period
".....", # only dots — previously passed old regex
])
def test_injection_strings_raise(self, bad_name: str):
tool = _make_tool()
with pytest.raises(ValueError, match="Security Alert"):
tool._validate_identifier(bad_name)
def test_allow_period_rejects_digit_led_schema(self):
tool = _make_tool()
with pytest.raises(ValueError, match="Security Alert"):
tool._validate_identifier("1schema.table", allow_period=True)
def test_allow_period_rejects_digit_led_table(self):
tool = _make_tool()
with pytest.raises(ValueError, match="Security Alert"):
tool._validate_identifier("schema.1table", allow_period=True)
# ---------------------------------------------------------------------------
# _generate_embedding
# ---------------------------------------------------------------------------
class TestGenerateEmbedding:
def test_uses_custom_embedding_fn(self):
called_with = []
def my_embed(text: str) -> list[float]:
called_with.append(text)
return [0.5, 0.5]
tool = _make_tool(custom_embedding_fn=my_embed)
result = tool._generate_embedding("hello world")
assert result == [0.5, 0.5]
assert called_with == ["hello world"]
def test_falls_back_to_openai_with_api_key(self):
tool = _make_tool()
tool._openai_client = None # ensure cache is clear
mock_openai = MagicMock()
mock_openai.OpenAI.return_value.embeddings.create.return_value.data = [
MagicMock(embedding=[0.1, 0.2])
]
with patch.dict("os.environ", {"OPENAI_API_KEY": "test-key"}):
with patch.dict("sys.modules", {"openai": mock_openai}):
result = tool._generate_embedding("test query")
assert result == [0.1, 0.2]
def test_openai_client_is_reused_across_calls(self):
tool = _make_tool()
tool._openai_client = None # ensure cache is clear
mock_openai = MagicMock()
mock_client = mock_openai.OpenAI.return_value
mock_client.embeddings.create.return_value.data = [MagicMock(embedding=[0.1, 0.2])]
with patch.dict("os.environ", {"OPENAI_API_KEY": "test-key"}):
with patch.dict("sys.modules", {"openai": mock_openai}):
tool._generate_embedding("first query")
tool._generate_embedding("second query")
# OpenAI() constructor called only once — client was reused
mock_openai.OpenAI.assert_called_once()
def test_raises_when_no_openai_key_and_no_custom_fn(self):
tool = _make_tool()
tool._openai_client = None # ensure cache is clear
import os
env_without_key = {k: v for k, v in os.environ.items() if k != "OPENAI_API_KEY"}
with patch.dict("os.environ", env_without_key, clear=True):
with pytest.raises(ValueError, match="OPENAI_API_KEY"):
tool._generate_embedding("test")
# ---------------------------------------------------------------------------
# _run — empty / whitespace query guard
# ---------------------------------------------------------------------------
class TestRunQueryValidation:
def test_empty_query_returns_error_json(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
result = json.loads(tool._run(query=""))
assert result["success"] is False
assert "empty" in result["error"].lower()
def test_whitespace_only_query_returns_error_json(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
result = json.loads(tool._run(query=" "))
assert result["success"] is False
def test_none_query_returns_error_json(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
result = json.loads(tool._run(query=None))
assert result["success"] is False
# ---------------------------------------------------------------------------
# _run — connection failure
# ---------------------------------------------------------------------------
class TestRunConnectionFailure:
def test_connection_error_returns_error_json(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
with patch.object(tool, "_connect", side_effect=Exception("Connection refused")):
result = json.loads(tool._run(query="find AI docs"))
assert result["success"] is False
assert "Failed to connect to DB2" in result["error"]
# ---------------------------------------------------------------------------
# _run — invalid distance metric
# ---------------------------------------------------------------------------
class TestRunInvalidMetric:
def test_invalid_metric_returns_error_json(self):
tool = _make_tool(
custom_embedding_fn=_fake_embedding,
distance_metric="INVALID_METRIC",
)
mock_cursor = _make_cursor_with_rows([])
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
tool.cursor = mock_cursor
result = json.loads(tool._run(query="test"))
assert result["success"] is False
assert "Invalid distance metric" in result["error"]
# ---------------------------------------------------------------------------
# _run — successful search (core happy path)
# ---------------------------------------------------------------------------
class TestRunSuccessful:
def _setup_connected_tool(self, rows: list[tuple], **kwargs) -> DB2VectorSearchTool:
tool = _make_tool(custom_embedding_fn=_fake_embedding, **kwargs)
mock_cursor = _make_cursor_with_rows(rows)
tool.cursor = mock_cursor
return tool, mock_cursor
def test_returns_results_as_json(self):
rows = [("Some document text", 0.12)]
tool, cursor = self._setup_connected_tool(rows)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
result = json.loads(tool._run(query="find documents about AI"))
assert result["success"] is True
assert len(result["results"]) == 1
assert result["results"][0]["distance"] == pytest.approx(0.12)
assert result["results"][0]["data"]["content"] == "Some document text"
def test_multiple_return_columns_mapped_correctly(self):
rows = [("Title A", "Body text A", 0.05)]
tool, cursor = self._setup_connected_tool(
rows, return_columns=["title", "body"]
)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
result = json.loads(tool._run(query="search"))
data = result["results"][0]["data"]
assert data["title"] == "Title A"
assert data["body"] == "Body text A"
def test_empty_db_result_returns_empty_list(self):
tool, _ = self._setup_connected_tool([])
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
result = json.loads(tool._run(query="nothing"))
assert result["success"] is True
assert result["results"] == []
def test_max_distance_filters_far_results(self):
# Row 0 is close (0.2), Row 1 is too far (0.9)
rows = [("Close doc", 0.2), ("Far doc", 0.9)]
tool, _ = self._setup_connected_tool(rows, max_distance=0.5)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
result = json.loads(tool._run(query="test"))
assert result["success"] is True
assert len(result["results"]) == 1
assert result["results"][0]["data"]["content"] == "Close doc"
def test_filter_by_and_filter_value_added_to_params(self):
rows = [("Filtered doc", 0.1)]
tool, cursor = self._setup_connected_tool(rows)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
result = json.loads(
tool._run(query="test", filter_by="category", filter_value="AI")
)
assert result["success"] is True
# The second param in the execute call must be the filter value
execute_args = cursor.execute.call_args
params_tuple = execute_args[0][1]
assert "AI" in params_tuple
def test_sql_contains_correct_metric(self):
rows = [("doc", 0.1)]
tool, cursor = self._setup_connected_tool(rows, distance_metric="EUCLIDEAN")
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
tool._run(query="test")
executed_sql = cursor.execute.call_args[0][0]
assert "EUCLIDEAN" in executed_sql
def test_sql_contains_correct_limit(self):
rows = []
tool, cursor = self._setup_connected_tool(rows, limit=7)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
tool._run(query="test")
executed_sql = cursor.execute.call_args[0][0]
assert "7" in executed_sql
def test_sql_contains_where_clause_when_filter_provided(self):
rows = []
tool, cursor = self._setup_connected_tool(rows)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
tool._run(query="test", filter_by="dept", filter_value="HR")
executed_sql = cursor.execute.call_args[0][0]
assert "WHERE dept = ?" in executed_sql
def test_sql_has_no_where_clause_without_filter(self):
rows = []
tool, cursor = self._setup_connected_tool(rows)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
tool._run(query="test")
executed_sql = cursor.execute.call_args[0][0]
assert "WHERE" not in executed_sql
def test_json_encoder_handles_decimal_in_results(self):
rows = [(decimal.Decimal("42.50"), 0.1)]
tool, _ = self._setup_connected_tool(rows, return_columns=["price"])
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
result = json.loads(tool._run(query="test"))
assert result["success"] is True
assert result["results"][0]["data"]["price"] == pytest.approx(42.5)
def test_disconnect_called_after_successful_run(self):
rows = [("doc", 0.1)]
tool, cursor = self._setup_connected_tool(rows)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect") as mock_disconnect:
tool._run(query="test")
mock_disconnect.assert_called_once()
def test_disconnect_called_on_unexpected_error(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect") as mock_disconnect:
# cursor is None → will raise AttributeError inside _run
tool.cursor = None
# Override _connect to set cursor to a raising mock
def bad_cursor_setup():
c = MagicMock()
c.execute.side_effect = RuntimeError("Unexpected DB error")
tool.cursor = c
tool._connect = bad_cursor_setup
result = json.loads(tool._run(query="test"))
assert result["success"] is False
mock_disconnect.assert_called()
# ---------------------------------------------------------------------------
# _run — SQL injection via filter_by rejected
# ---------------------------------------------------------------------------
class TestRunSQLInjectionPrevention:
@pytest.mark.parametrize("bad_col", [
"col; DROP TABLE documents; --",
"col OR 1=1",
"col name",
# NOTE: empty string is falsy — _run skips the WHERE clause entirely
# so it does NOT trigger _validate_identifier. The schema-level guard
# (DB2ToolSchema._validate_filter_pair) catches the empty string case.
])
def test_injection_in_filter_by_returns_error(self, bad_col: str):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
mock_cursor = _make_cursor_with_rows([])
tool.cursor = mock_cursor
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
result = json.loads(
tool._run(query="test", filter_by=bad_col, filter_value="val")
)
assert result["success"] is False
def test_empty_filter_by_bypasses_where_clause(self):
"""Empty string is falsy in Python — _run skips WHERE rather than injecting.
The actual guard lives in DB2ToolSchema (schema-level validation).
"""
tool = _make_tool(custom_embedding_fn=_fake_embedding)
mock_cursor = _make_cursor_with_rows([])
tool.cursor = mock_cursor
with patch.object(tool, "_connect"):
with patch.object(tool, "_disconnect"):
result = json.loads(
tool._run(query="test", filter_by="", filter_value="val")
)
# The query succeeds (no WHERE clause injected) — success is True
assert result["success"] is True
executed_sql = mock_cursor.execute.call_args[0][0]
assert "WHERE" not in executed_sql
# ---------------------------------------------------------------------------
# _connect / _disconnect lifecycle
# ---------------------------------------------------------------------------
class TestConnectDisconnect:
def test_connect_builds_connection_objects(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
mock_conn = MagicMock()
_ibm_db_stub.connect.return_value = mock_conn
tool._connect()
assert tool.connection is mock_conn
assert tool.cursor is not None
def test_connect_opens_fresh_connection_each_call(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
mock_conn = MagicMock()
local_connect = MagicMock(return_value=mock_conn)
tool.db2_package = MagicMock()
tool.db2_package.connect = local_connect
tool.db2_package.close = MagicMock()
tool._connect()
tool._connect() # connect-per-call: each invocation opens a new connection
assert local_connect.call_count == 2
def test_disconnect_resets_all_handles(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
mock_conn = MagicMock()
_ibm_db_stub.connect.return_value = mock_conn
tool._connect()
tool._disconnect()
assert tool.connection is None
assert tool.dbi_connection is None
assert tool.cursor is None
def test_disconnect_is_safe_when_already_disconnected(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
# Should not raise even with no open connection
tool._disconnect()
def test_del_calls_disconnect(self):
tool = _make_tool(custom_embedding_fn=_fake_embedding)
with patch.object(tool, "_disconnect") as mock_disconnect:
tool.__del__()
mock_disconnect.assert_called_once()
def test_connect_resolves_packages_without_injection(self):
"""Constructs the tool WITHOUT injecting db2_package / db2_dbi_package.
Verifies that _connect() automatically resolves package fields from sys.modules
when left at their default of None, and successfully establishes a connection.
"""
tool = DB2VectorSearchTool(
connection_string="DATABASE=TESTDB;HOSTNAME=localhost;PORT=50000;PROTOCOL=TCPIP;UID=user;PWD=pass;",
custom_embedding_fn=_fake_embedding,
)
# Both fields start as None
assert tool.db2_package is None
assert tool.db2_dbi_package is None
# Exercise behavior via _connect()
_ibm_db_stub.connect.return_value = MagicMock()
tool._connect()
# Verify side-effects: packages were resolved and connections established
assert tool.db2_package is _ibm_db_stub
assert tool.db2_dbi_package is _ibm_db_dbi_stub
assert tool.connection is not None
assert tool.cursor is not None
tool._disconnect()
# ---------------------------------------------------------------------------
# Tool metadata
# ---------------------------------------------------------------------------
class TestToolMetadata:
def test_tool_name(self):
tool = _make_tool()
assert tool.name == "DB2VectorSearchTool"
def test_tool_description(self):
tool = _make_tool()
assert "DB2" in tool.description
assert "custom embedding function" in tool.description
assert "OpenAI embeddings" in tool.description
def test_args_schema_is_db2_tool_schema(self):
tool = _make_tool()
assert tool.args_schema is DB2ToolSchema
def test_package_dependencies_listed(self):
tool = _make_tool()
assert "ibm_db" in tool.package_dependencies
def test_env_vars_declared(self):
tool = _make_tool()
env_var_names = {ev.name for ev in tool.env_vars}
assert "OPENAI_API_KEY" in env_var_names
assert "DB2_CONNECTION_STRING" in env_var_names
def test_public_import_from_crewai_tools(self):
"""from crewai_tools import DB2VectorSearchTool must work at package level."""
from crewai_tools import DB2ToolSchema # noqa: PLC0415
from crewai_tools import DB2VectorSearchTool # noqa: PLC0415
assert DB2VectorSearchTool is not None
assert DB2ToolSchema is not None