chore: import upstream snapshot with attribution
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / Pre-commit Linter (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.10) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.11) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.12) (push) Has been cancelled
Continuous Integration / Mypy Check (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.12) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / Unit Tests (Python 3.14) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Has been cancelled
Copybara PR Handler / close-imported-pr (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Has been cancelled
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
@@ -0,0 +1,384 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import create_autospec
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.tools.spanner import admin_tool
|
||||
from google.api_core.operation_async import AsyncOperation
|
||||
from google.auth.credentials import Credentials
|
||||
from google.cloud import spanner_admin_database_v1
|
||||
from google.cloud import spanner_admin_instance_v1
|
||||
import pytest
|
||||
|
||||
|
||||
class AsyncListIterator:
|
||||
"""Asynchronous iterator for a list."""
|
||||
|
||||
def __init__(self, list_):
|
||||
self._iter = iter(list_)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._iter)
|
||||
except StopIteration as exc:
|
||||
raise StopAsyncIteration from exc
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credentials():
|
||||
return create_autospec(Credentials, instance=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.InstanceAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_list_instances_success(
|
||||
mock_instance_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the list_instances function in admin_tool."""
|
||||
mock_instance_admin_client = mock_instance_admin_client_cls.return_value
|
||||
mock_instance1 = create_autospec(
|
||||
spanner_admin_instance_v1.types.Instance, instance=True
|
||||
)
|
||||
mock_instance1.name = "projects/test-project/instances/test-instance-1"
|
||||
mock_instance2 = create_autospec(
|
||||
spanner_admin_instance_v1.types.Instance, instance=True
|
||||
)
|
||||
mock_instance2.name = "projects/test-project/instances/test-instance-2"
|
||||
mock_instance_admin_client.list_instances.return_value = AsyncListIterator([
|
||||
mock_instance1,
|
||||
mock_instance2,
|
||||
])
|
||||
|
||||
result = await admin_tool.list_instances("test-project", mock_credentials)
|
||||
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": ["test-instance-1", "test-instance-2"],
|
||||
}
|
||||
mock_instance_admin_client.list_instances.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.InstanceAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_list_instances_error(
|
||||
mock_instance_admin_client_cls, mock_credentials
|
||||
):
|
||||
mock_instance_admin_client = mock_instance_admin_client_cls.return_value
|
||||
mock_instance_admin_client.list_instances.side_effect = Exception(
|
||||
"test error"
|
||||
)
|
||||
result = await admin_tool.list_instances("test-project", mock_credentials)
|
||||
assert result == {
|
||||
"status": "ERROR",
|
||||
"error_details": "Exception('test error')",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.InstanceAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_get_instance_success(
|
||||
mock_instance_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the get_instance function in admin_tool."""
|
||||
mock_instance_admin_client = mock_instance_admin_client_cls.return_value
|
||||
mock_instance = create_autospec(
|
||||
spanner_admin_instance_v1.types.Instance, instance=True
|
||||
)
|
||||
mock_instance.display_name = "Test Instance"
|
||||
mock_instance.config = (
|
||||
"projects/test-project/instanceConfigs/regional-us-central1"
|
||||
)
|
||||
mock_instance.node_count = 1
|
||||
mock_instance.processing_units = 1000
|
||||
mock_instance.labels = {"env": "test"}
|
||||
mock_instance_admin_client.get_instance.return_value = mock_instance
|
||||
|
||||
result = await admin_tool.get_instance(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": {
|
||||
"instance_id": "test-instance",
|
||||
"display_name": "Test Instance",
|
||||
"config": (
|
||||
"projects/test-project/instanceConfigs/regional-us-central1"
|
||||
),
|
||||
"node_count": 1,
|
||||
"processing_units": 1000,
|
||||
"labels": {"env": "test"},
|
||||
},
|
||||
}
|
||||
mock_instance_admin_client.instance_path.assert_called_once_with(
|
||||
"test-project", "test-instance"
|
||||
)
|
||||
mock_instance_admin_client.get_instance.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.InstanceAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_get_instance_error(
|
||||
mock_instance_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the get_instance function in admin_tool when an error occurs."""
|
||||
mock_instance_admin_client = mock_instance_admin_client_cls.return_value
|
||||
mock_instance_admin_client.get_instance.side_effect = Exception("test error")
|
||||
result = await admin_tool.get_instance(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result == {
|
||||
"status": "ERROR",
|
||||
"error_details": "Exception('test error')",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.InstanceAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_list_instance_configs_success(
|
||||
mock_instance_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the list_instance_configs function in admin_tool."""
|
||||
mock_instance_admin_client = mock_instance_admin_client_cls.return_value
|
||||
mock_instance_admin_client.common_project_path.return_value = (
|
||||
"projects/test-project"
|
||||
)
|
||||
mock_config1 = create_autospec(
|
||||
spanner_admin_instance_v1.types.InstanceConfig, instance=True
|
||||
)
|
||||
mock_config1.name = "projects/test-project/instanceConfigs/config-1"
|
||||
mock_config2 = create_autospec(
|
||||
spanner_admin_instance_v1.types.InstanceConfig, instance=True
|
||||
)
|
||||
mock_config2.name = "projects/test-project/instanceConfigs/config-2"
|
||||
mock_instance_admin_client.list_instance_configs.return_value = (
|
||||
AsyncListIterator([
|
||||
mock_config1,
|
||||
mock_config2,
|
||||
])
|
||||
)
|
||||
|
||||
result = await admin_tool.list_instance_configs(
|
||||
"test-project", mock_credentials
|
||||
)
|
||||
|
||||
assert result == {"status": "SUCCESS", "results": ["config-1", "config-2"]}
|
||||
mock_instance_admin_client.common_project_path.assert_called_once_with(
|
||||
"test-project"
|
||||
)
|
||||
mock_instance_admin_client.list_instance_configs.assert_called_once_with(
|
||||
parent="projects/test-project"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.InstanceAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_get_instance_config_success(
|
||||
mock_instance_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the get_instance_config function in admin_tool."""
|
||||
mock_instance_admin_client = mock_instance_admin_client_cls.return_value
|
||||
mock_instance_admin_client.instance_config_path.return_value = (
|
||||
"projects/test-project/instanceConfigs/config-1"
|
||||
)
|
||||
mock_config = create_autospec(
|
||||
spanner_admin_instance_v1.types.InstanceConfig, instance=True
|
||||
)
|
||||
mock_config.name = "projects/test-project/instanceConfigs/config-1"
|
||||
mock_config.display_name = "Config 1"
|
||||
mock_config.labels = {"env": "test"}
|
||||
mock_replica = create_autospec(
|
||||
spanner_admin_instance_v1.types.ReplicaInfo, instance=True
|
||||
)
|
||||
mock_replica.location = "us-central1"
|
||||
mock_replica.type = 1 # READ_WRITE
|
||||
mock_replica.default_leader_location = True
|
||||
mock_config.replicas = [mock_replica]
|
||||
mock_instance_admin_client.get_instance_config.return_value = mock_config
|
||||
|
||||
result = await admin_tool.get_instance_config(
|
||||
project_id="test-project",
|
||||
config_id="config-1",
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": {
|
||||
"name": "projects/test-project/instanceConfigs/config-1",
|
||||
"display_name": "Config 1",
|
||||
"replicas": [{
|
||||
"location": "us-central1",
|
||||
"type": "READ_WRITE",
|
||||
"default_leader_location": True,
|
||||
}],
|
||||
"labels": {"env": "test"},
|
||||
},
|
||||
}
|
||||
mock_instance_admin_client.instance_config_path.assert_called_once_with(
|
||||
"test-project", "config-1"
|
||||
)
|
||||
mock_instance_admin_client.get_instance_config.assert_called_once_with(
|
||||
name="projects/test-project/instanceConfigs/config-1"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.InstanceAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_get_instance_config_error(
|
||||
mock_instance_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the get_instance_config function when an error occurs."""
|
||||
mock_instance_admin_client = mock_instance_admin_client_cls.return_value
|
||||
mock_instance_admin_client.get_instance_config.side_effect = Exception(
|
||||
"test error"
|
||||
)
|
||||
result = await admin_tool.get_instance_config(
|
||||
project_id="test-project",
|
||||
config_id="config-1",
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result == {
|
||||
"status": "ERROR",
|
||||
"error_details": "Exception('test error')",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.InstanceAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_create_instance_success(
|
||||
mock_instance_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the create_instance function in admin_tool."""
|
||||
mock_instance_admin_client = mock_instance_admin_client_cls.return_value
|
||||
mock_instance_admin_client.instance_config_path.return_value = (
|
||||
"projects/test-project/instanceConfigs/config-1"
|
||||
)
|
||||
mock_instance_admin_client.common_project_path.return_value = (
|
||||
"projects/test-project"
|
||||
)
|
||||
mock_op = create_autospec(AsyncOperation, instance=True)
|
||||
mock_instance_admin_client.create_instance.return_value = mock_op
|
||||
result = await admin_tool.create_instance(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
config_id="config-1",
|
||||
display_name="Test Instance",
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
"results": "Instance test-instance created successfully.",
|
||||
}
|
||||
mock_instance_admin_client.create_instance.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.DatabaseAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_list_databases_success(
|
||||
mock_db_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the list_databases function in admin_tool."""
|
||||
mock_db_admin_client = mock_db_admin_client_cls.return_value
|
||||
mock_db_admin_client.instance_path.return_value = (
|
||||
"projects/test-project/instances/test-instance"
|
||||
)
|
||||
mock_db1 = create_autospec(
|
||||
spanner_admin_database_v1.types.Database, instance=True
|
||||
)
|
||||
mock_db1.name = "projects/test-project/instances/test-instance/databases/db-1"
|
||||
mock_db2 = create_autospec(
|
||||
spanner_admin_database_v1.types.Database, instance=True
|
||||
)
|
||||
mock_db2.name = "projects/test-project/instances/test-instance/databases/db-2"
|
||||
mock_db_admin_client.list_databases.return_value = AsyncListIterator([
|
||||
mock_db1,
|
||||
mock_db2,
|
||||
])
|
||||
|
||||
result = await admin_tool.list_databases(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
|
||||
assert result == {"status": "SUCCESS", "results": ["db-1", "db-2"]}
|
||||
mock_db_admin_client.instance_path.assert_called_once_with(
|
||||
"test-project", "test-instance"
|
||||
)
|
||||
mock_db_admin_client.list_databases.assert_called_once_with(
|
||||
parent="projects/test-project/instances/test-instance"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
"google.adk.tools.spanner.admin_tool.DatabaseAdminAsyncClient",
|
||||
autospec=True,
|
||||
)
|
||||
async def test_create_database_success(
|
||||
mock_db_admin_client_cls, mock_credentials
|
||||
):
|
||||
"""Tests the create_database function in admin_tool."""
|
||||
mock_db_admin_client = mock_db_admin_client_cls.return_value
|
||||
mock_db_admin_client.instance_path.return_value = (
|
||||
"projects/test-project/instances/test-instance"
|
||||
)
|
||||
mock_op = create_autospec(AsyncOperation, instance=True)
|
||||
mock_db_admin_client.create_database.return_value = mock_op
|
||||
result = await admin_tool.create_database(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
database_id="db-1",
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result == {
|
||||
"status": "SUCCESS",
|
||||
}
|
||||
mock_db_admin_client.create_database.assert_called_once()
|
||||
@@ -0,0 +1,91 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google.adk.tools.google_tool import GoogleTool
|
||||
from google.adk.tools.spanner.admin_toolset import SpannerAdminToolset
|
||||
from google.adk.tools.spanner.settings import SpannerToolSettings
|
||||
from google.adk.tools.spanner.spanner_credentials import SpannerCredentialsConfig
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spanner_toolset_tools_default():
|
||||
"""Test Admin Spanner toolset.
|
||||
|
||||
This test verifies the behavior of the Spanner admin toolset when no filter is
|
||||
specified.
|
||||
"""
|
||||
credentials_config = SpannerCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = SpannerAdminToolset(credentials_config=credentials_config)
|
||||
assert isinstance(toolset._tool_settings, SpannerToolSettings) # pylint: disable=protected-access
|
||||
assert toolset._tool_settings.__dict__ == SpannerToolSettings().__dict__ # pylint: disable=protected-access
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == 7
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set([
|
||||
"list_instances",
|
||||
"get_instance",
|
||||
"list_databases",
|
||||
"create_instance",
|
||||
"create_database",
|
||||
"list_instance_configs",
|
||||
"get_instance_config",
|
||||
])
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selected_tools",
|
||||
[
|
||||
pytest.param(
|
||||
["list_instances"],
|
||||
id="list-instances",
|
||||
)
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_spanner_admin_toolset_selective(selected_tools):
|
||||
"""Test selective Admin Spanner toolset.
|
||||
|
||||
This test verifies the behavior of the Spanner admin toolset when a filter is
|
||||
specified.
|
||||
|
||||
Args:
|
||||
selected_tools: A list of tool names to filter.
|
||||
"""
|
||||
credentials_config = SpannerCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = SpannerAdminToolset(
|
||||
credentials_config=credentials_config,
|
||||
tool_filter=selected_tools,
|
||||
spanner_tool_settings=SpannerToolSettings(),
|
||||
)
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == len(selected_tools)
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set(selected_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
@@ -0,0 +1,296 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from google.adk.tools.spanner import metadata_tool
|
||||
from google.cloud.spanner_admin_database_v1.types import DatabaseDialect
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credentials():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_spanner_ids():
|
||||
return {
|
||||
"project_id": "test-project",
|
||||
"instance_id": "test-instance",
|
||||
"database_id": "test-database",
|
||||
"table_name": "test-table",
|
||||
}
|
||||
|
||||
|
||||
@patch("google.adk.tools.spanner.client.get_spanner_client")
|
||||
def test_list_table_names_success(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test list_table_names function with success."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_table = MagicMock()
|
||||
mock_table.table_id = "table1"
|
||||
mock_database.list_tables.return_value = [mock_table]
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = metadata_tool.list_table_names(
|
||||
mock_spanner_ids["project_id"],
|
||||
mock_spanner_ids["instance_id"],
|
||||
mock_spanner_ids["database_id"],
|
||||
mock_credentials,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert result["results"] == ["table1"]
|
||||
|
||||
|
||||
@patch("google.adk.tools.spanner.client.get_spanner_client")
|
||||
def test_list_table_names_error(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test list_table_names function with error."""
|
||||
mock_get_spanner_client.side_effect = Exception("Test Exception")
|
||||
result = metadata_tool.list_table_names(
|
||||
mock_spanner_ids["project_id"],
|
||||
mock_spanner_ids["instance_id"],
|
||||
mock_spanner_ids["database_id"],
|
||||
mock_credentials,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert result["error_details"] == "Test Exception"
|
||||
|
||||
|
||||
@patch("google.adk.tools.spanner.client.get_spanner_client")
|
||||
def test_get_table_schema_success(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test get_table_schema function with success."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
|
||||
mock_columns_result = [(
|
||||
"col1", # COLUMN_NAME
|
||||
"", # TABLE_SCHEMA
|
||||
"STRING(MAX)", # SPANNER_TYPE
|
||||
1, # ORDINAL_POSITION
|
||||
None, # COLUMN_DEFAULT
|
||||
"NO", # IS_NULLABLE
|
||||
"NEVER", # IS_GENERATED
|
||||
None, # GENERATION_EXPRESSION
|
||||
None, # IS_STORED
|
||||
)]
|
||||
|
||||
mock_key_columns_result = [(
|
||||
"col1", # COLUMN_NAME
|
||||
"PK_Table", # CONSTRAINT_NAME
|
||||
1, # ORDINAL_POSITION
|
||||
None, # POSITION_IN_UNIQUE_CONSTRAINT
|
||||
)]
|
||||
|
||||
mock_table_metadata_result = [(
|
||||
"", # TABLE_SCHEMA
|
||||
"test_table", # TABLE_NAME
|
||||
"BASE TABLE", # TABLE_TYPE
|
||||
None, # PARENT_TABLE_NAME
|
||||
None, # ON_DELETE_ACTION
|
||||
"COMMITTED", # SPANNER_STATE
|
||||
None, # INTERLEAVE_TYPE
|
||||
"OLDER_THAN(CreatedAt, INTERVAL 1 DAY)", # ROW_DELETION_POLICY_EXPRESSION
|
||||
)]
|
||||
|
||||
mock_snapshot.execute_sql.side_effect = [
|
||||
mock_columns_result,
|
||||
mock_key_columns_result,
|
||||
mock_table_metadata_result,
|
||||
]
|
||||
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = metadata_tool.get_table_schema(
|
||||
mock_spanner_ids["project_id"],
|
||||
mock_spanner_ids["instance_id"],
|
||||
mock_spanner_ids["database_id"],
|
||||
mock_spanner_ids["table_name"],
|
||||
mock_credentials,
|
||||
)
|
||||
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert "col1" in result["results"]["schema"]
|
||||
assert result["results"]["schema"]["col1"]["SPANNER_TYPE"] == "STRING(MAX)"
|
||||
assert "KEY_COLUMN_USAGE" in result["results"]["schema"]["col1"]
|
||||
assert (
|
||||
result["results"]["schema"]["col1"]["KEY_COLUMN_USAGE"][0][
|
||||
"CONSTRAINT_NAME"
|
||||
]
|
||||
== "PK_Table"
|
||||
)
|
||||
assert "metadata" in result["results"]
|
||||
assert result["results"]["metadata"][0]["TABLE_NAME"] == "test_table"
|
||||
assert (
|
||||
result["results"]["metadata"][0]["ROW_DELETION_POLICY_EXPRESSION"]
|
||||
== "OLDER_THAN(CreatedAt, INTERVAL 1 DAY)"
|
||||
)
|
||||
|
||||
|
||||
@patch("google.adk.tools.spanner.client.get_spanner_client")
|
||||
def test_list_table_indexes_success(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test list_table_indexes function with success."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
mock_result_set = MagicMock()
|
||||
mock_result_set.__iter__.return_value = iter([(
|
||||
"PRIMARY_KEY",
|
||||
"",
|
||||
"PRIMARY_KEY",
|
||||
"",
|
||||
True,
|
||||
False,
|
||||
None,
|
||||
)])
|
||||
mock_snapshot.execute_sql.return_value = mock_result_set
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = metadata_tool.list_table_indexes(
|
||||
mock_spanner_ids["project_id"],
|
||||
mock_spanner_ids["instance_id"],
|
||||
mock_spanner_ids["database_id"],
|
||||
mock_spanner_ids["table_name"],
|
||||
mock_credentials,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert len(result["results"]) == 1
|
||||
assert result["results"][0]["INDEX_NAME"] == "PRIMARY_KEY"
|
||||
|
||||
|
||||
@patch("google.adk.tools.spanner.client.get_spanner_client")
|
||||
def test_list_table_indexes_circular_row_fallback_to_string(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test list_table_indexes stringifies rows with circular references."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
circular_value = []
|
||||
circular_value.append(circular_value)
|
||||
mock_result_set = MagicMock()
|
||||
mock_result_set.__iter__.return_value = iter([(
|
||||
circular_value,
|
||||
"",
|
||||
"PRIMARY_KEY",
|
||||
"",
|
||||
True,
|
||||
False,
|
||||
None,
|
||||
)])
|
||||
mock_snapshot.execute_sql.return_value = mock_result_set
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = metadata_tool.list_table_indexes(
|
||||
mock_spanner_ids["project_id"],
|
||||
mock_spanner_ids["instance_id"],
|
||||
mock_spanner_ids["database_id"],
|
||||
mock_spanner_ids["table_name"],
|
||||
mock_credentials,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert isinstance(result["results"][0], str)
|
||||
|
||||
|
||||
@patch("google.adk.tools.spanner.client.get_spanner_client")
|
||||
def test_list_table_index_columns_success(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test list_table_index_columns function with success."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
mock_result_set = MagicMock()
|
||||
mock_result_set.__iter__.return_value = iter([(
|
||||
"PRIMARY_KEY",
|
||||
"",
|
||||
"col1",
|
||||
1,
|
||||
"NO",
|
||||
"STRING(MAX)",
|
||||
)])
|
||||
mock_snapshot.execute_sql.return_value = mock_result_set
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = metadata_tool.list_table_index_columns(
|
||||
mock_spanner_ids["project_id"],
|
||||
mock_spanner_ids["instance_id"],
|
||||
mock_spanner_ids["database_id"],
|
||||
mock_spanner_ids["table_name"],
|
||||
mock_credentials,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert len(result["results"]) == 1
|
||||
assert result["results"][0]["COLUMN_NAME"] == "col1"
|
||||
|
||||
|
||||
@patch("google.adk.tools.spanner.client.get_spanner_client")
|
||||
def test_list_named_schemas_success(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test list_named_schemas function with success."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
mock_result_set = MagicMock()
|
||||
mock_result_set.__iter__.return_value = iter([("schema1",), ("schema2",)])
|
||||
mock_snapshot.execute_sql.return_value = mock_result_set
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = metadata_tool.list_named_schemas(
|
||||
mock_spanner_ids["project_id"],
|
||||
mock_spanner_ids["instance_id"],
|
||||
mock_spanner_ids["database_id"],
|
||||
mock_credentials,
|
||||
)
|
||||
assert result["status"] == "SUCCESS"
|
||||
assert result["results"] == ["schema1", "schema2"]
|
||||
@@ -0,0 +1,532 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from unittest import mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from google.adk.tools.spanner import client
|
||||
from google.adk.tools.spanner import search_tool
|
||||
from google.adk.tools.spanner import utils
|
||||
from google.cloud.spanner_admin_database_v1.types import DatabaseDialect
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_credentials():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_spanner_ids():
|
||||
return {
|
||||
"project_id": "test-project",
|
||||
"instance_id": "test-instance",
|
||||
"database_id": "test-database",
|
||||
"table_name": "test-table",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("embedding_option_key", "embedding_option_value", "expected_embedding"),
|
||||
[
|
||||
pytest.param(
|
||||
"spanner_googlesql_embedding_model_name",
|
||||
"EmbeddingsModel",
|
||||
[0.1, 0.2, 0.3],
|
||||
id="spanner_googlesql_embedding_model",
|
||||
),
|
||||
pytest.param(
|
||||
"vertex_ai_embedding_model_name",
|
||||
"text-embedding-005",
|
||||
[0.4, 0.5, 0.6],
|
||||
id="vertex_ai_embedding_model",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(utils, "embed_contents_async", autospec=True)
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_knn_success(
|
||||
mock_get_spanner_client,
|
||||
mock_embed_contents_async,
|
||||
mock_spanner_ids,
|
||||
mock_credentials,
|
||||
embedding_option_key,
|
||||
embedding_option_value,
|
||||
expected_embedding,
|
||||
):
|
||||
"""Test similarity_search function with kNN success."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
if embedding_option_key == "vertex_ai_embedding_model_name":
|
||||
mock_embed_contents_async.return_value = [expected_embedding]
|
||||
# execute_sql is called once for the kNN search
|
||||
mock_snapshot.execute_sql.return_value = iter([("result1",), ("result2",)])
|
||||
else:
|
||||
mock_embedding_result = MagicMock()
|
||||
mock_embedding_result.one.return_value = (expected_embedding,)
|
||||
# First call to execute_sql is for getting the embedding,
|
||||
# second call is for the kNN search
|
||||
mock_snapshot.execute_sql.side_effect = [
|
||||
mock_embedding_result,
|
||||
iter([("result1",), ("result2",)]),
|
||||
]
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={embedding_option_key: embedding_option_value},
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result["status"] == "SUCCESS", result
|
||||
assert result["rows"] == [("result1",), ("result2",)]
|
||||
|
||||
# Check the generated SQL for kNN search
|
||||
call_args = mock_snapshot.execute_sql.call_args
|
||||
sql = call_args.args[0]
|
||||
assert "COSINE_DISTANCE" in sql
|
||||
assert "@embedding" in sql
|
||||
assert call_args.kwargs == {"params": {"embedding": expected_embedding}}
|
||||
if embedding_option_key == "vertex_ai_embedding_model_name":
|
||||
mock_embed_contents_async.assert_called_once_with(
|
||||
embedding_option_value, ["test query"], None
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_ann_success(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test similarity_search function with ANN success."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
mock_embedding_result = MagicMock()
|
||||
mock_embedding_result.one.return_value = ([0.1, 0.2, 0.3],)
|
||||
# First call to execute_sql is for getting the embedding
|
||||
# Second call is for the ANN search
|
||||
mock_snapshot.execute_sql.side_effect = [
|
||||
mock_embedding_result,
|
||||
iter([("ann_result1",), ("ann_result2",)]),
|
||||
]
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={
|
||||
"spanner_googlesql_embedding_model_name": "test_model"
|
||||
},
|
||||
credentials=mock_credentials,
|
||||
search_options={
|
||||
"nearest_neighbors_algorithm": "APPROXIMATE_NEAREST_NEIGHBORS"
|
||||
},
|
||||
)
|
||||
assert result["status"] == "SUCCESS", result
|
||||
assert result["rows"] == [("ann_result1",), ("ann_result2",)]
|
||||
call_args = mock_snapshot.execute_sql.call_args
|
||||
sql = call_args.args[0]
|
||||
assert "APPROX_COSINE_DISTANCE" in sql
|
||||
assert "@embedding" in sql
|
||||
assert call_args.kwargs == {"params": {"embedding": [0.1, 0.2, 0.3]}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_error(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test similarity_search function with a generic error."""
|
||||
mock_get_spanner_client.side_effect = Exception("Test Exception")
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
embedding_options={
|
||||
"spanner_googlesql_embedding_model_name": "test_model"
|
||||
},
|
||||
columns=["col1"],
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert "Test Exception" in result["error_details"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(utils, "embed_contents_async")
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_circular_row_fallback_to_string(
|
||||
mock_get_spanner_client,
|
||||
mock_embed_contents_async,
|
||||
mock_spanner_ids,
|
||||
mock_credentials,
|
||||
):
|
||||
"""Test similarity_search stringifies rows with circular references."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
circular_row = []
|
||||
circular_row.append(circular_row)
|
||||
mock_embed_contents_async.return_value = [[0.1, 0.2, 0.3]]
|
||||
mock_snapshot.execute_sql.return_value = iter([circular_row])
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={
|
||||
"vertex_ai_embedding_model_name": "text-embedding-005"
|
||||
},
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
|
||||
assert result["status"] == "SUCCESS", result
|
||||
assert result["rows"] == [str(circular_row)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_postgresql_knn_success(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test similarity_search with PostgreSQL dialect for kNN."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_snapshot = MagicMock()
|
||||
mock_embedding_result = MagicMock()
|
||||
mock_embedding_result.one.return_value = ([0.1, 0.2, 0.3],)
|
||||
mock_snapshot.execute_sql.side_effect = [
|
||||
mock_embedding_result,
|
||||
iter([("pg_result",)]),
|
||||
]
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.POSTGRESQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={
|
||||
"spanner_postgresql_vertex_ai_embedding_model_endpoint": (
|
||||
"test_endpoint"
|
||||
)
|
||||
},
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result["status"] == "SUCCESS", result
|
||||
assert result["rows"] == [("pg_result",)]
|
||||
call_args = mock_snapshot.execute_sql.call_args
|
||||
sql = call_args.args[0]
|
||||
assert "spanner.cosine_distance" in sql
|
||||
assert "$1" in sql
|
||||
assert call_args.kwargs == {"params": {"p1": [0.1, 0.2, 0.3]}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_postgresql_ann_unsupported(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test similarity_search with unsupported ANN for PostgreSQL dialect."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_database.database_dialect = DatabaseDialect.POSTGRESQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={
|
||||
"spanner_postgresql_vertex_ai_embedding_model_endpoint": (
|
||||
"test_endpoint"
|
||||
)
|
||||
},
|
||||
credentials=mock_credentials,
|
||||
search_options={
|
||||
"nearest_neighbors_algorithm": "APPROXIMATE_NEAREST_NEIGHBORS"
|
||||
},
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert (
|
||||
"APPROXIMATE_NEAREST_NEIGHBORS is not supported for PostgreSQL dialect."
|
||||
in result["error_details"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_gsql_missing_embedding_model_error(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test similarity_search with missing embedding_options for GoogleSQL dialect."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={
|
||||
"spanner_postgresql_vertex_ai_embedding_model_endpoint": (
|
||||
"test_endpoint"
|
||||
)
|
||||
},
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert (
|
||||
"embedding_options['vertex_ai_embedding_model_name'] or"
|
||||
" embedding_options['spanner_googlesql_embedding_model_name'] must be"
|
||||
" specified for GoogleSQL dialect Spanner database."
|
||||
in result["error_details"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_pg_missing_embedding_model_error(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test similarity_search with missing embedding_options for PostgreSQL dialect."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_database.database_dialect = DatabaseDialect.POSTGRESQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={
|
||||
"spanner_googlesql_embedding_model_name": "EmbeddingsModel"
|
||||
},
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert (
|
||||
"embedding_options['vertex_ai_embedding_model_name'] or"
|
||||
" embedding_options['spanner_postgresql_vertex_ai_embedding_model_endpoint']"
|
||||
" must be specified for PostgreSQL dialect Spanner database."
|
||||
in result["error_details"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"embedding_options",
|
||||
[
|
||||
pytest.param(
|
||||
{
|
||||
"vertex_ai_embedding_model_name": "test-model",
|
||||
"spanner_googlesql_embedding_model_name": "test-model-2",
|
||||
},
|
||||
id="vertex_ai_and_googlesql",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"vertex_ai_embedding_model_name": "test-model",
|
||||
"spanner_postgresql_vertex_ai_embedding_model_endpoint": (
|
||||
"test-endpoint"
|
||||
),
|
||||
},
|
||||
id="vertex_ai_and_postgresql",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"spanner_googlesql_embedding_model_name": "test-model",
|
||||
"spanner_postgresql_vertex_ai_embedding_model_endpoint": (
|
||||
"test-endpoint"
|
||||
),
|
||||
},
|
||||
id="googlesql_and_postgresql",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"vertex_ai_embedding_model_name": "test-model",
|
||||
"spanner_googlesql_embedding_model_name": "test-model-2",
|
||||
"spanner_postgresql_vertex_ai_embedding_model_endpoint": (
|
||||
"test-endpoint"
|
||||
),
|
||||
},
|
||||
id="all_three_models",
|
||||
),
|
||||
pytest.param(
|
||||
{},
|
||||
id="no_models",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_multiple_embedding_options_error(
|
||||
mock_get_spanner_client,
|
||||
mock_spanner_ids,
|
||||
mock_credentials,
|
||||
embedding_options,
|
||||
):
|
||||
"""Test similarity_search with multiple embedding models."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options=embedding_options,
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert (
|
||||
"Exactly one embedding model option must be specified."
|
||||
in result["error_details"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_output_dimensionality_gsql_error(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test similarity_search with output_dimensionality and spanner_googlesql_embedding_model_name."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={
|
||||
"spanner_googlesql_embedding_model_name": "EmbeddingsModel",
|
||||
"output_dimensionality": 128,
|
||||
},
|
||||
credentials=mock_credentials,
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert "is not supported when" in result["error_details"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(client, "get_spanner_client")
|
||||
async def test_similarity_search_unsupported_algorithm_error(
|
||||
mock_get_spanner_client, mock_spanner_ids, mock_credentials
|
||||
):
|
||||
"""Test similarity_search with an unsupported nearest neighbors algorithm."""
|
||||
mock_spanner_client = MagicMock()
|
||||
mock_instance = MagicMock()
|
||||
mock_database = MagicMock()
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = await search_tool.similarity_search(
|
||||
project_id=mock_spanner_ids["project_id"],
|
||||
instance_id=mock_spanner_ids["instance_id"],
|
||||
database_id=mock_spanner_ids["database_id"],
|
||||
table_name=mock_spanner_ids["table_name"],
|
||||
query="test query",
|
||||
embedding_column_to_search="embedding_col",
|
||||
columns=["col1"],
|
||||
embedding_options={"vertex_ai_embedding_model_name": "test-model"},
|
||||
credentials=mock_credentials,
|
||||
search_options={"nearest_neighbors_algorithm": "INVALID_ALGORITHM"},
|
||||
)
|
||||
assert result["status"] == "ERROR"
|
||||
assert "Unsupported search_options" in result["error_details"]
|
||||
@@ -0,0 +1,138 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.tools.spanner.client import get_spanner_client
|
||||
from google.auth.exceptions import DefaultCredentialsError
|
||||
from google.oauth2.credentials import Credentials
|
||||
import pytest
|
||||
|
||||
|
||||
def test_spanner_client_project():
|
||||
"""Test spanner client project."""
|
||||
# Trigger the spanner client creation
|
||||
client = get_spanner_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
# Verify that the client has the desired project set
|
||||
assert client.project == "test-gcp-project"
|
||||
|
||||
|
||||
def test_spanner_client_project_set_explicit():
|
||||
"""Test spanner client creation does not invoke default auth."""
|
||||
# Let's simulate that no environment variables are set, so that any project
|
||||
# set in there does not interfere with this test
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
with mock.patch("google.auth.default", autospec=True) as mock_default_auth:
|
||||
# Simulate exception from default auth
|
||||
mock_default_auth.side_effect = DefaultCredentialsError(
|
||||
"Your default credentials were not found"
|
||||
)
|
||||
|
||||
# Trigger the spanner client creation
|
||||
client = get_spanner_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
# If we are here that already means client creation did not call default
|
||||
# auth (otherwise we would have run into DefaultCredentialsError set
|
||||
# above). For the sake of explicitness, trivially assert that the default
|
||||
# auth was not called, and yet the project was set correctly
|
||||
mock_default_auth.assert_not_called()
|
||||
assert client.project == "test-gcp-project"
|
||||
|
||||
|
||||
def test_spanner_client_project_set_with_default_auth():
|
||||
"""Test spanner client creation invokes default auth to set the project."""
|
||||
# Let's simulate that no environment variables are set, so that any project
|
||||
# set in there does not interfere with this test
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
with mock.patch("google.auth.default", autospec=True) as mock_default_auth:
|
||||
# Simulate credentials
|
||||
mock_creds = mock.create_autospec(Credentials, instance=True)
|
||||
|
||||
# Simulate output of the default auth
|
||||
mock_default_auth.return_value = (mock_creds, "test-gcp-project")
|
||||
|
||||
# Trigger the spanner client creation
|
||||
client = get_spanner_client(
|
||||
project=None,
|
||||
credentials=mock_creds,
|
||||
)
|
||||
|
||||
# Verify that default auth was called to set the client project
|
||||
assert mock_default_auth.call_count >= 1
|
||||
assert client.project == "test-gcp-project"
|
||||
|
||||
|
||||
def test_spanner_client_project_set_with_env():
|
||||
"""Test spanner client creation sets the project from environment variable."""
|
||||
# Let's simulate the project set in environment variables
|
||||
with mock.patch.dict(
|
||||
os.environ, {"GOOGLE_CLOUD_PROJECT": "test-gcp-project"}, clear=True
|
||||
):
|
||||
with mock.patch("google.auth.default", autospec=True) as mock_default_auth:
|
||||
# Simulate default auth returning the same project as the environment
|
||||
mock_default_auth.return_value = (
|
||||
mock.create_autospec(Credentials, instance=True),
|
||||
"test-gcp-project",
|
||||
)
|
||||
|
||||
# Trigger the spanner client creation
|
||||
client = get_spanner_client(
|
||||
project=None,
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
assert client.project == "test-gcp-project"
|
||||
|
||||
|
||||
def test_spanner_client_user_agent():
|
||||
"""Test spanner client user agent."""
|
||||
# Patch the Client constructor
|
||||
with mock.patch(
|
||||
"google.cloud.spanner.Client", autospec=True
|
||||
) as mock_client_class:
|
||||
# The mock instance that will be returned by spanner.Client()
|
||||
mock_instance = mock_client_class.return_value
|
||||
# The real spanner.Client instance has a `_client_info` attribute.
|
||||
# We need to add it to our mock instance so that the user_agent can be set.
|
||||
mock_instance._client_info = mock.Mock()
|
||||
|
||||
# Call the function that creates the client
|
||||
client = get_spanner_client(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.create_autospec(Credentials, instance=True),
|
||||
)
|
||||
|
||||
# Verify that the Spanner Client was instantiated.
|
||||
mock_client_class.assert_called_once_with(
|
||||
project="test-gcp-project",
|
||||
credentials=mock.ANY,
|
||||
)
|
||||
|
||||
# Verify that the user_agent was set on the client instance.
|
||||
# The client returned by get_spanner_client is the mock instance.
|
||||
assert re.search(
|
||||
r"adk-spanner-tool google-adk/([0-9A-Za-z._\-+/]+)",
|
||||
client._client_info.user_agent,
|
||||
)
|
||||
@@ -0,0 +1,55 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from google.adk.tools.spanner.spanner_credentials import SpannerCredentialsConfig
|
||||
# Mock the Google OAuth and API dependencies
|
||||
import google.auth.credentials
|
||||
import google.oauth2.credentials
|
||||
import pytest
|
||||
|
||||
|
||||
class TestSpannerCredentials:
|
||||
"""Test suite for Spanner credentials configuration validation.
|
||||
|
||||
This class tests the credential configuration logic that ensures
|
||||
either existing credentials or client ID/secret pairs are provided.
|
||||
"""
|
||||
|
||||
def test_valid_credentials_object_oauth2_credentials(self):
|
||||
"""Test that providing valid Credentials object works correctly with google.oauth2.credentials.Credentials.
|
||||
|
||||
When a user already has valid OAuth credentials, they should be able
|
||||
to pass them directly without needing to provide client ID/secret.
|
||||
"""
|
||||
# Create a mock oauth2 credentials object
|
||||
oauth2_creds = google.oauth2.credentials.Credentials(
|
||||
"test_token",
|
||||
client_id="test_client_id",
|
||||
client_secret="test_client_secret",
|
||||
scopes=[],
|
||||
)
|
||||
|
||||
config = SpannerCredentialsConfig(credentials=oauth2_creds)
|
||||
|
||||
# Verify that the credentials are properly stored and attributes are
|
||||
# extracted
|
||||
assert config.credentials == oauth2_creds
|
||||
assert config.client_id == "test_client_id"
|
||||
assert config.client_secret == "test_client_secret"
|
||||
assert config.scopes == [
|
||||
"https://www.googleapis.com/auth/spanner.admin",
|
||||
"https://www.googleapis.com/auth/spanner.data",
|
||||
]
|
||||
|
||||
assert config._token_cache_key == "spanner_token_cache" # pylint: disable=protected-access
|
||||
@@ -0,0 +1,225 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.tools.base_tool import BaseTool
|
||||
from google.adk.tools.spanner import query_tool
|
||||
from google.adk.tools.spanner import settings
|
||||
from google.adk.tools.spanner.settings import QueryResultMode
|
||||
from google.adk.tools.spanner.settings import SpannerToolSettings
|
||||
from google.adk.tools.spanner.spanner_credentials import SpannerCredentialsConfig
|
||||
from google.adk.tools.spanner.spanner_toolset import SpannerToolset
|
||||
from google.adk.tools.tool_context import ToolContext
|
||||
from google.auth.credentials import Credentials
|
||||
import pytest
|
||||
|
||||
|
||||
async def get_tool(
|
||||
name: str, tool_settings: SpannerToolSettings | None = None
|
||||
) -> BaseTool:
|
||||
"""Get a tool from Spanner toolset."""
|
||||
credentials_config = SpannerCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
|
||||
toolset = SpannerToolset(
|
||||
credentials_config=credentials_config,
|
||||
tool_filter=[name],
|
||||
spanner_tool_settings=tool_settings,
|
||||
)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
assert len(tools) == 1
|
||||
return tools[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"query_result_mode, expected_description",
|
||||
[
|
||||
(
|
||||
QueryResultMode.DEFAULT,
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
Run a Spanner Read-Only query in the spanner database and return the result.
|
||||
|
||||
Args:
|
||||
project_id (str): The GCP project id in which the spanner database
|
||||
resides.
|
||||
instance_id (str): The instance id of the spanner database.
|
||||
database_id (str): The database id of the spanner database.
|
||||
query (str): The Spanner SQL query to be executed.
|
||||
credentials (Credentials): The credentials to use for the request.
|
||||
settings (SpannerToolSettings): The settings for the tool.
|
||||
tool_context (ToolContext): The context for the tool.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with the result of the query.
|
||||
If the result contains the key "result_is_likely_truncated" with
|
||||
value True, it means that there may be additional rows matching the
|
||||
query not returned in the result.
|
||||
|
||||
Examples:
|
||||
<Example>
|
||||
>>> execute_sql("my_project", "my_instance", "my_database",
|
||||
... "SELECT COUNT(*) AS count FROM my_table")
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"rows": [
|
||||
[100]
|
||||
]
|
||||
}
|
||||
</Example>
|
||||
|
||||
<Example>
|
||||
>>> execute_sql("my_project", "my_instance", "my_database",
|
||||
... "SELECT name, rating, description FROM hotels_table")
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"rows": [
|
||||
["The Hotel", 4.1, "Modern hotel."],
|
||||
["Park Inn", 4.5, "Cozy hotel."],
|
||||
...
|
||||
]
|
||||
}
|
||||
</Example>
|
||||
|
||||
Note:
|
||||
This is running with Read-Only Transaction for query that only read data."""
|
||||
),
|
||||
),
|
||||
(
|
||||
QueryResultMode.DICT_LIST,
|
||||
textwrap.dedent(
|
||||
"""\
|
||||
Run a Spanner Read-Only query in the spanner database and return the result.
|
||||
|
||||
Args:
|
||||
project_id (str): The GCP project id in which the spanner database
|
||||
resides.
|
||||
instance_id (str): The instance id of the spanner database.
|
||||
database_id (str): The database id of the spanner database.
|
||||
query (str): The Spanner SQL query to be executed.
|
||||
credentials (Credentials): The credentials to use for the request.
|
||||
settings (SpannerToolSettings): The settings for the tool.
|
||||
tool_context (ToolContext): The context for the tool.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with the result of the query.
|
||||
If the result contains the key "result_is_likely_truncated" with
|
||||
value True, it means that there may be additional rows matching the
|
||||
query not returned in the result.
|
||||
|
||||
Examples:
|
||||
<Example>
|
||||
>>> execute_sql("my_project", "my_instance", "my_database",
|
||||
... "SELECT COUNT(*) AS count FROM my_table")
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"rows": [
|
||||
{
|
||||
"count": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
</Example>
|
||||
|
||||
<Example>
|
||||
>>> execute_sql("my_project", "my_instance", "my_database",
|
||||
... "SELECT COUNT(*) FROM my_table")
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"rows": [
|
||||
{
|
||||
"": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
</Example>
|
||||
|
||||
<Example>
|
||||
>>> execute_sql("my_project", "my_instance", "my_database",
|
||||
... "SELECT name, rating, description FROM hotels_table")
|
||||
{
|
||||
"status": "SUCCESS",
|
||||
"rows": [
|
||||
{
|
||||
"name": "The Hotel",
|
||||
"rating": 4.1,
|
||||
"description": "Modern hotel."
|
||||
},
|
||||
{
|
||||
"name": "Park Inn",
|
||||
"rating": 4.5,
|
||||
"description": "Cozy hotel."
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
</Example>
|
||||
|
||||
Note:
|
||||
This is running with Read-Only Transaction for query that only read data."""
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_execute_sql_query_result(
|
||||
query_result_mode, expected_description
|
||||
):
|
||||
"""Test Spanner execute_sql tool query result in different modes."""
|
||||
tool_name = "execute_sql"
|
||||
tool_settings = SpannerToolSettings(query_result_mode=query_result_mode)
|
||||
tool = await get_tool(tool_name, tool_settings)
|
||||
assert tool.name == tool_name
|
||||
assert tool.description == expected_description
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@mock.patch.object(query_tool.utils, "execute_sql", spec_set=True)
|
||||
async def test_execute_sql(mock_utils_execute_sql):
|
||||
"""Test execute_sql function in query result default mode."""
|
||||
mock_credentials = mock.create_autospec(
|
||||
Credentials, instance=True, spec_set=True
|
||||
)
|
||||
mock_tool_context = mock.create_autospec(
|
||||
ToolContext, instance=True, spec_set=True
|
||||
)
|
||||
mock_utils_execute_sql.return_value = {"status": "SUCCESS", "rows": [[1]]}
|
||||
|
||||
result = await query_tool.execute_sql(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
database_id="test-database",
|
||||
query="SELECT 1",
|
||||
credentials=mock_credentials,
|
||||
settings=settings.SpannerToolSettings(),
|
||||
tool_context=mock_tool_context,
|
||||
)
|
||||
|
||||
mock_utils_execute_sql.assert_called_once_with(
|
||||
"test-project",
|
||||
"test-instance",
|
||||
"test-database",
|
||||
"SELECT 1",
|
||||
mock_credentials,
|
||||
settings.SpannerToolSettings(),
|
||||
mock_tool_context,
|
||||
)
|
||||
assert result == {"status": "SUCCESS", "rows": [[1]]}
|
||||
@@ -0,0 +1,115 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
|
||||
from google.adk.features._feature_registry import _WARNED_FEATURES
|
||||
from google.adk.tools.spanner.settings import Capabilities
|
||||
from google.adk.tools.spanner.settings import QueryResultMode
|
||||
from google.adk.tools.spanner.settings import SpannerToolSettings
|
||||
from google.adk.tools.spanner.settings import SpannerVectorStoreSettings
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_warned_features():
|
||||
"""Reset warned features before each test."""
|
||||
_WARNED_FEATURES.clear()
|
||||
|
||||
|
||||
def common_spanner_vector_store_settings(vector_length=None):
|
||||
return {
|
||||
"project_id": "test-project",
|
||||
"instance_id": "test-instance",
|
||||
"database_id": "test-database",
|
||||
"table_name": "test-table",
|
||||
"content_column": "test-content-column",
|
||||
"embedding_column": "test-embedding-column",
|
||||
"vector_length": 128 if vector_length is None else vector_length,
|
||||
}
|
||||
|
||||
|
||||
def test_spanner_tool_settings_experimental_warning():
|
||||
"""Test SpannerToolSettings experimental warning."""
|
||||
with warnings.catch_warnings(record=True) as w:
|
||||
SpannerToolSettings()
|
||||
assert len(w) == 1
|
||||
assert "SPANNER_TOOL_SETTINGS is enabled." in str(w[0].message)
|
||||
|
||||
|
||||
def test_spanner_vector_store_settings_all_fields_present():
|
||||
"""Test SpannerVectorStoreSettings with all required fields present."""
|
||||
settings = SpannerVectorStoreSettings(
|
||||
**common_spanner_vector_store_settings(),
|
||||
vertex_ai_embedding_model_name="test-embedding-model",
|
||||
)
|
||||
assert settings is not None
|
||||
assert settings.selected_columns == ["test-content-column"]
|
||||
assert settings.vertex_ai_embedding_model_name == "test-embedding-model"
|
||||
|
||||
|
||||
def test_spanner_vector_store_settings_missing_embedding_model_name():
|
||||
"""Test SpannerVectorStoreSettings with missing vertex_ai_embedding_model_name."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
SpannerVectorStoreSettings(**common_spanner_vector_store_settings())
|
||||
assert "Field required" in str(excinfo.value)
|
||||
assert "vertex_ai_embedding_model_name" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_spanner_vector_store_settings_invalid_vector_length():
|
||||
"""Test SpannerVectorStoreSettings with invalid vector_length."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
SpannerVectorStoreSettings(
|
||||
**common_spanner_vector_store_settings(vector_length=0),
|
||||
vertex_ai_embedding_model_name="test-embedding-model",
|
||||
)
|
||||
assert "Invalid vector length in the Spanner vector store settings." in str(
|
||||
excinfo.value
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"settings_args, expected_rows, expected_mode, expected_role",
|
||||
[
|
||||
({}, 50, QueryResultMode.DEFAULT, None),
|
||||
(
|
||||
{
|
||||
"capabilities": [Capabilities.DATA_READ],
|
||||
"max_executed_query_result_rows": 100,
|
||||
"query_result_mode": QueryResultMode.DICT_LIST,
|
||||
},
|
||||
100,
|
||||
QueryResultMode.DICT_LIST,
|
||||
None,
|
||||
),
|
||||
(
|
||||
{"database_role": "test-role"},
|
||||
50,
|
||||
QueryResultMode.DEFAULT,
|
||||
"test-role",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_spanner_tool_settings(
|
||||
settings_args, expected_rows, expected_mode, expected_role
|
||||
):
|
||||
"""Test SpannerToolSettings with different values."""
|
||||
settings = SpannerToolSettings(**settings_args)
|
||||
assert settings.capabilities == [Capabilities.DATA_READ]
|
||||
assert settings.max_executed_query_result_rows == expected_rows
|
||||
assert settings.query_result_mode == expected_mode
|
||||
assert settings.database_role == expected_role
|
||||
@@ -0,0 +1,234 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google.adk.tools.google_tool import GoogleTool
|
||||
from google.adk.tools.spanner import SpannerCredentialsConfig
|
||||
from google.adk.tools.spanner import SpannerToolset
|
||||
from google.adk.tools.spanner.settings import SpannerToolSettings
|
||||
from google.adk.tools.spanner.settings import SpannerVectorStoreSettings
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spanner_toolset_tools_default():
|
||||
"""Test default Spanner toolset.
|
||||
|
||||
This test verifies the behavior of the Spanner toolset when no filter is
|
||||
specified.
|
||||
"""
|
||||
credentials_config = SpannerCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = SpannerToolset(credentials_config=credentials_config)
|
||||
assert isinstance(toolset._tool_settings, SpannerToolSettings) # pylint: disable=protected-access
|
||||
assert toolset._tool_settings.__dict__ == SpannerToolSettings().__dict__ # pylint: disable=protected-access
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == 7
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set([
|
||||
"list_table_names",
|
||||
"list_table_indexes",
|
||||
"list_table_index_columns",
|
||||
"list_named_schemas",
|
||||
"get_table_schema",
|
||||
"execute_sql",
|
||||
"similarity_search",
|
||||
])
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"selected_tools",
|
||||
[
|
||||
pytest.param([], id="None"),
|
||||
pytest.param(
|
||||
["list_table_names", "get_table_schema"],
|
||||
id="table-metadata",
|
||||
),
|
||||
pytest.param(["execute_sql"], id="query"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_spanner_toolset_selective(selected_tools):
|
||||
"""Test selective Spanner toolset.
|
||||
|
||||
This test verifies the behavior of the Spanner toolset when a filter is
|
||||
specified.
|
||||
|
||||
Args:
|
||||
selected_tools: A list of tool names to filter.
|
||||
"""
|
||||
credentials_config = SpannerCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
toolset = SpannerToolset(
|
||||
credentials_config=credentials_config,
|
||||
tool_filter=selected_tools,
|
||||
spanner_tool_settings=SpannerToolSettings(),
|
||||
)
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == len(selected_tools)
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set(selected_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selected_tools", "returned_tools"),
|
||||
[
|
||||
pytest.param(["unknown"], [], id="all-unknown"),
|
||||
pytest.param(
|
||||
["unknown", "execute_sql"],
|
||||
["execute_sql"],
|
||||
id="mixed-known-unknown",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_spanner_toolset_unknown_tool(selected_tools, returned_tools):
|
||||
"""Test Spanner toolset with unknown tools.
|
||||
|
||||
This test verifies the behavior of the Spanner toolset when unknown tools are
|
||||
specified in the filter.
|
||||
|
||||
Args:
|
||||
selected_tools: A list of tool names to filter, including unknown ones.
|
||||
returned_tools: A list of tool names that are expected to be returned.
|
||||
"""
|
||||
credentials_config = SpannerCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
|
||||
toolset = SpannerToolset(
|
||||
credentials_config=credentials_config,
|
||||
tool_filter=selected_tools,
|
||||
spanner_tool_settings=SpannerToolSettings(),
|
||||
)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == len(returned_tools)
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set(returned_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("selected_tools", "returned_tools"),
|
||||
[
|
||||
pytest.param(
|
||||
["execute_sql", "list_table_names"],
|
||||
["list_table_names"],
|
||||
id="read-not-added",
|
||||
),
|
||||
pytest.param(
|
||||
["list_table_names", "list_table_indexes"],
|
||||
["list_table_names", "list_table_indexes"],
|
||||
id="no-effect",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_spanner_toolset_without_read_capability(
|
||||
selected_tools, returned_tools
|
||||
):
|
||||
"""Test Spanner toolset without read capability.
|
||||
|
||||
This test verifies the behavior of the Spanner toolset when read capability is
|
||||
not enabled.
|
||||
|
||||
Args:
|
||||
selected_tools: A list of tool names to filter.
|
||||
returned_tools: A list of tool names that are expected to be returned.
|
||||
"""
|
||||
credentials_config = SpannerCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
|
||||
spanner_tool_settings = SpannerToolSettings(capabilities=[])
|
||||
toolset = SpannerToolset(
|
||||
credentials_config=credentials_config,
|
||||
tool_filter=selected_tools,
|
||||
spanner_tool_settings=spanner_tool_settings,
|
||||
)
|
||||
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == len(returned_tools)
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set(returned_tools)
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spanner_toolset_with_vector_store_search():
|
||||
"""Test Spanner toolset with vector store search.
|
||||
|
||||
This test verifies the behavior of the Spanner toolset when vector store
|
||||
settings is provided.
|
||||
"""
|
||||
credentials_config = SpannerCredentialsConfig(
|
||||
client_id="abc", client_secret="def"
|
||||
)
|
||||
|
||||
spanner_tool_settings = SpannerToolSettings(
|
||||
vector_store_settings=SpannerVectorStoreSettings(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
database_id="test-database",
|
||||
table_name="test-table",
|
||||
content_column="test-content-column",
|
||||
embedding_column="test-embedding-column",
|
||||
vector_length=128,
|
||||
vertex_ai_embedding_model_name="test-embedding-model",
|
||||
)
|
||||
)
|
||||
toolset = SpannerToolset(
|
||||
credentials_config=credentials_config,
|
||||
spanner_tool_settings=spanner_tool_settings,
|
||||
)
|
||||
tools = await toolset.get_tools()
|
||||
assert tools is not None
|
||||
|
||||
assert len(tools) == 8
|
||||
assert all([isinstance(tool, GoogleTool) for tool in tools])
|
||||
|
||||
expected_tool_names = set([
|
||||
"list_table_names",
|
||||
"list_table_indexes",
|
||||
"list_table_index_columns",
|
||||
"list_named_schemas",
|
||||
"get_table_schema",
|
||||
"execute_sql",
|
||||
"similarity_search",
|
||||
"vector_store_similarity_search",
|
||||
])
|
||||
actual_tool_names = set([tool.name for tool in tools])
|
||||
assert actual_tool_names == expected_tool_names
|
||||
@@ -0,0 +1,474 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from google.adk.tools.spanner import utils as spanner_utils
|
||||
from google.adk.tools.spanner.settings import SpannerToolSettings
|
||||
from google.adk.tools.spanner.settings import SpannerVectorStoreSettings
|
||||
from google.adk.tools.spanner.settings import TableColumn
|
||||
from google.adk.tools.spanner.settings import VectorSearchIndexSettings
|
||||
from google.cloud.spanner_admin_database_v1.types import DatabaseDialect
|
||||
from google.cloud.spanner_v1 import batch as spanner_batch
|
||||
from google.cloud.spanner_v1 import client as spanner_client_v1
|
||||
from google.cloud.spanner_v1 import database as spanner_database
|
||||
from google.cloud.spanner_v1 import instance as spanner_instance
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vector_store_settings():
|
||||
"""Fixture for SpannerVectorStoreSettings."""
|
||||
return SpannerVectorStoreSettings(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
database_id="test-database",
|
||||
table_name="test_vector_store",
|
||||
content_column="content",
|
||||
embedding_column="embedding",
|
||||
vector_length=768,
|
||||
vertex_ai_embedding_model_name="textembedding",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spanner_tool_settings(vector_store_settings):
|
||||
"""Fixture for SpannerToolSettings."""
|
||||
return SpannerToolSettings(vector_store_settings=vector_store_settings)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_spanner_database():
|
||||
"""Fixture for a mocked spanner database."""
|
||||
mock_database = mock.create_autospec(spanner_database.Database, instance=True)
|
||||
mock_database.exists.return_value = True
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
return mock_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_spanner_instance(mock_spanner_database):
|
||||
"""Fixture for a mocked spanner instance."""
|
||||
mock_instance = mock.create_autospec(spanner_instance.Instance, instance=True)
|
||||
mock_instance.exists.return_value = True
|
||||
mock_instance.database.return_value = mock_spanner_database
|
||||
return mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_spanner_client(mock_spanner_instance):
|
||||
"""Fixture for a mocked spanner client."""
|
||||
mock_client = mock.create_autospec(spanner_client_v1.Client, instance=True)
|
||||
mock_client.instance.return_value = mock_spanner_instance
|
||||
mock_client._client_info = mock.Mock(user_agent="test-agent")
|
||||
return mock_client
|
||||
|
||||
|
||||
@mock.patch.object(spanner_utils, "embed_contents", autospec=True)
|
||||
def test_add_contents_successful(
|
||||
mock_embed_contents,
|
||||
spanner_tool_settings,
|
||||
mock_spanner_client,
|
||||
mock_spanner_database,
|
||||
mocker,
|
||||
):
|
||||
"""Test that add_contents successfully adds content."""
|
||||
mock_embed_contents.return_value = [[1.0, 2.0], [3.0, 4.0]]
|
||||
mock_batch = mocker.create_autospec(spanner_batch.Batch, instance=True)
|
||||
mock_batch.__enter__.return_value = mock_batch
|
||||
mock_spanner_database.batch.return_value = mock_batch
|
||||
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
vector_store._database = mock_spanner_database
|
||||
contents = ["content1", "content2"]
|
||||
vector_store.add_contents(contents=contents)
|
||||
|
||||
mock_spanner_database.reload.assert_called_once()
|
||||
mock_spanner_database.batch.assert_called_once()
|
||||
mock_batch.insert_or_update.assert_called_once_with(
|
||||
table="test_vector_store",
|
||||
columns=["content", "embedding"],
|
||||
values=[
|
||||
["content1", [1.0, 2.0]],
|
||||
["content2", [3.0, 4.0]],
|
||||
],
|
||||
)
|
||||
mock_embed_contents.assert_called_once_with(
|
||||
"textembedding", contents, 768, mock.ANY
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(spanner_utils, "embed_contents", autospec=True)
|
||||
def test_add_contents_with_metadata(
|
||||
mock_embed_contents,
|
||||
spanner_tool_settings,
|
||||
mock_spanner_client,
|
||||
mock_spanner_database,
|
||||
mocker,
|
||||
):
|
||||
"""Test that add_contents successfully adds content with metadata."""
|
||||
mock_embed_contents.return_value = [[1.0, 2.0], [3.0, 4.0]]
|
||||
mock_batch = mocker.create_autospec(spanner_batch.Batch, instance=True)
|
||||
mock_batch.__enter__.return_value = mock_batch
|
||||
mock_spanner_database.batch.return_value = mock_batch
|
||||
spanner_tool_settings.vector_store_settings.additional_columns_to_setup = [
|
||||
TableColumn(name="metadata", type="JSON")
|
||||
]
|
||||
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
vector_store._database = mock_spanner_database
|
||||
contents = ["content1", "content2"]
|
||||
additional_columns_values = [
|
||||
{"metadata": {"meta1": "val1"}},
|
||||
{"metadata": {"meta2": "val2"}},
|
||||
]
|
||||
vector_store.add_contents(
|
||||
contents=contents,
|
||||
additional_columns_values=additional_columns_values,
|
||||
)
|
||||
|
||||
mock_spanner_database.batch.assert_called_once()
|
||||
mock_batch.insert_or_update.assert_called_once_with(
|
||||
table="test_vector_store",
|
||||
columns=["content", "embedding", "metadata"],
|
||||
values=[
|
||||
["content1", [1.0, 2.0], {"meta1": "val1"}],
|
||||
["content2", [3.0, 4.0], {"meta2": "val2"}],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_add_contents_empty_contents(
|
||||
spanner_tool_settings, mock_spanner_client, mock_spanner_database
|
||||
):
|
||||
"""Test that add_contents does nothing when contents is empty."""
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
vector_store.add_contents(contents=[])
|
||||
mock_spanner_database.batch.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(spanner_utils.client, "get_spanner_client", autospec=True)
|
||||
def test_execute_sql_circular_row_fallback_to_string(mock_get_spanner_client):
|
||||
"""Test execute_sql stringifies rows with circular references."""
|
||||
mock_spanner_client = mock.MagicMock()
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_database = mock.MagicMock()
|
||||
mock_snapshot = mock.MagicMock()
|
||||
circular_row = []
|
||||
circular_row.append(circular_row)
|
||||
mock_snapshot.execute_sql.return_value = iter([circular_row])
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
result = spanner_utils.execute_sql(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
database_id="test-database",
|
||||
query="SELECT 1",
|
||||
credentials=mock.Mock(),
|
||||
settings=SpannerToolSettings(),
|
||||
tool_context=mock.Mock(),
|
||||
)
|
||||
|
||||
assert result == {"status": "SUCCESS", "rows": [str(circular_row)]}
|
||||
|
||||
|
||||
@mock.patch.object(spanner_utils, "embed_contents", autospec=True)
|
||||
def test_add_contents_additional_columns_list_mismatch(
|
||||
mock_embed_contents, spanner_tool_settings, mock_spanner_client
|
||||
):
|
||||
"""Test that add_contents raises an error if additional_columns_values and contents lengths differ."""
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="additional_columns_values contains more items than contents.",
|
||||
):
|
||||
vector_store.add_contents(
|
||||
contents=["content1"],
|
||||
additional_columns_values=[
|
||||
{"col1": "val1"},
|
||||
{"col1": "val2"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(spanner_utils, "embed_contents", autospec=True)
|
||||
def test_add_contents_embedding_fails(
|
||||
mock_embed_contents, spanner_tool_settings, mock_spanner_client
|
||||
):
|
||||
"""Test that add_contents fails if embedding fails."""
|
||||
mock_embed_contents.side_effect = RuntimeError("Embedding failed")
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
with pytest.raises(RuntimeError, match="Embedding failed"):
|
||||
vector_store.add_contents(contents=["content1", "content2"])
|
||||
|
||||
|
||||
def test_init_raises_error_if_vector_store_settings_not_set():
|
||||
"""Test that SpannerVectorStore raises an error if vector_store_settings is not set."""
|
||||
settings = SpannerToolSettings()
|
||||
with pytest.raises(
|
||||
ValueError, match="Spanner vector store settings are not set."
|
||||
):
|
||||
spanner_utils.SpannerVectorStore(settings)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"dialect, expected_ddl",
|
||||
[
|
||||
(
|
||||
DatabaseDialect.GOOGLE_STANDARD_SQL,
|
||||
(
|
||||
"CREATE TABLE IF NOT EXISTS test_vector_store (\n"
|
||||
" id STRING(36) DEFAULT (GENERATE_UUID()),\n"
|
||||
" content STRING(MAX),\n"
|
||||
" embedding ARRAY<FLOAT32>(vector_length=>768)\n"
|
||||
") PRIMARY KEY(id)"
|
||||
),
|
||||
),
|
||||
(
|
||||
DatabaseDialect.POSTGRESQL,
|
||||
(
|
||||
"CREATE TABLE IF NOT EXISTS test_vector_store (\n"
|
||||
" id varchar(36) DEFAULT spanner.generate_uuid(),\n"
|
||||
" content text,\n"
|
||||
" embedding float4[] VECTOR LENGTH 768,\n"
|
||||
" PRIMARY KEY(id)\n"
|
||||
")"
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_create_vector_store_table_ddl(
|
||||
spanner_tool_settings, mock_spanner_client, dialect, expected_ddl
|
||||
):
|
||||
"""Test DDL creation for different SQL dialects."""
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
ddl = vector_store._create_vector_store_table_ddl(dialect)
|
||||
assert ddl == expected_ddl
|
||||
|
||||
|
||||
def test_create_ann_vector_search_index_ddl_raises_error_for_postgresql(
|
||||
spanner_tool_settings, vector_store_settings, mock_spanner_client
|
||||
):
|
||||
"""Test that creating an ANN index raises an error for PostgreSQL."""
|
||||
vector_store_settings.vector_search_index_settings = mock.Mock()
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="ANN is only supported for the Google Standard SQL dialect.",
|
||||
):
|
||||
vector_store._create_ann_vector_search_index_ddl(
|
||||
DatabaseDialect.POSTGRESQL
|
||||
)
|
||||
|
||||
|
||||
def test_create_vector_store(
|
||||
spanner_tool_settings, mock_spanner_client, mock_spanner_database
|
||||
):
|
||||
"""Test the vector store creation process."""
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
vector_store.create_vector_store()
|
||||
mock_spanner_database.update_ddl.assert_called_once()
|
||||
ddl_statement = mock_spanner_database.update_ddl.call_args[0][0]
|
||||
assert "CREATE TABLE IF NOT EXISTS test_vector_store" in ddl_statement[0]
|
||||
|
||||
|
||||
def test_create_vector_search_index_no_settings(
|
||||
spanner_tool_settings, mock_spanner_client, mock_spanner_database
|
||||
):
|
||||
"""Test that create_vector_search_index does nothing if settings are not present."""
|
||||
spanner_tool_settings.vector_store_settings.vector_search_index_settings = (
|
||||
None
|
||||
)
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
vector_store.create_vector_search_index()
|
||||
mock_spanner_database.update_ddl.assert_not_called()
|
||||
|
||||
|
||||
def test_create_vector_search_index_successful_google_sql(
|
||||
spanner_tool_settings,
|
||||
vector_store_settings,
|
||||
mock_spanner_client,
|
||||
mock_spanner_database,
|
||||
):
|
||||
"""Test that create_vector_search_index successfully creates index for Google SQL."""
|
||||
mock_spanner_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
vector_store_settings.vector_search_index_settings = (
|
||||
VectorSearchIndexSettings(
|
||||
index_name="test_vector_index",
|
||||
tree_depth=3,
|
||||
num_branches=10,
|
||||
num_leaves=20,
|
||||
)
|
||||
)
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
vector_store.create_vector_search_index()
|
||||
mock_spanner_database.update_ddl.assert_called_once()
|
||||
ddl_statement = mock_spanner_database.update_ddl.call_args[0][0]
|
||||
expected_ddl = (
|
||||
"CREATE VECTOR INDEX IF NOT EXISTS test_vector_index\n"
|
||||
"\tON test_vector_store(embedding)\n"
|
||||
"\tWHERE embedding IS NOT NULL\n"
|
||||
"\tOPTIONS(distance_type='COSINE', tree_depth=3, num_branches=10, "
|
||||
"num_leaves=20)"
|
||||
)
|
||||
assert ddl_statement[0] == expected_ddl
|
||||
|
||||
|
||||
def test_create_vector_search_index_fails(
|
||||
spanner_tool_settings,
|
||||
vector_store_settings,
|
||||
mock_spanner_client,
|
||||
mock_spanner_database,
|
||||
):
|
||||
"""Test that create_vector_search_index raises an error if DDL execution fails."""
|
||||
mock_spanner_database.update_ddl.side_effect = RuntimeError("DDL failed")
|
||||
vector_store_settings.vector_search_index_settings = (
|
||||
VectorSearchIndexSettings(index_name="test_vector_index")
|
||||
)
|
||||
with mock.patch.object(
|
||||
spanner_utils.client,
|
||||
"get_spanner_client",
|
||||
autospec=True,
|
||||
return_value=mock_spanner_client,
|
||||
):
|
||||
vector_store = spanner_utils.SpannerVectorStore(spanner_tool_settings)
|
||||
with pytest.raises(RuntimeError, match="DDL failed"):
|
||||
vector_store.create_vector_search_index()
|
||||
|
||||
|
||||
@mock.patch.object(spanner_utils.client, "get_spanner_client", autospec=True)
|
||||
def test_execute_sql_with_database_role(mock_get_spanner_client):
|
||||
"""Test that execute_sql passes database_role to instance.database."""
|
||||
mock_spanner_client = mock.MagicMock()
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_database = mock.MagicMock()
|
||||
mock_snapshot = mock.MagicMock()
|
||||
|
||||
mock_snapshot.execute_sql.return_value = iter([["row1"]])
|
||||
mock_database.snapshot.return_value.__enter__.return_value = mock_snapshot
|
||||
mock_database.database_dialect = DatabaseDialect.GOOGLE_STANDARD_SQL
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
|
||||
database_role = "test-role"
|
||||
settings = SpannerToolSettings(database_role=database_role)
|
||||
|
||||
spanner_utils.execute_sql(
|
||||
project_id="test-project",
|
||||
instance_id="test-instance",
|
||||
database_id="test-database",
|
||||
query="SELECT 1",
|
||||
credentials=mock.Mock(),
|
||||
settings=settings,
|
||||
tool_context=mock.Mock(),
|
||||
)
|
||||
|
||||
mock_instance.database.assert_called_once_with(
|
||||
"test-database", database_role=database_role
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(spanner_utils.client, "get_spanner_client", autospec=True)
|
||||
def test_spanner_vector_store_with_database_role(
|
||||
mock_get_spanner_client, vector_store_settings
|
||||
):
|
||||
"""Test that SpannerVectorStore passes database_role to instance.database."""
|
||||
mock_spanner_client = mock.MagicMock()
|
||||
mock_instance = mock.MagicMock()
|
||||
mock_database = mock.MagicMock()
|
||||
|
||||
mock_instance.database.return_value = mock_database
|
||||
mock_instance.exists.return_value = True
|
||||
mock_database.exists.return_value = True
|
||||
mock_spanner_client.instance.return_value = mock_instance
|
||||
mock_get_spanner_client.return_value = mock_spanner_client
|
||||
mock_spanner_client._client_info = mock.Mock(user_agent="test-agent")
|
||||
|
||||
database_role = "test-role"
|
||||
settings = SpannerToolSettings(
|
||||
database_role=database_role, vector_store_settings=vector_store_settings
|
||||
)
|
||||
|
||||
spanner_utils.SpannerVectorStore(settings)
|
||||
|
||||
mock_instance.database.assert_called_once_with(
|
||||
"test-database", database_role=database_role
|
||||
)
|
||||
Reference in New Issue
Block a user