MongoDB analytics toolkit (#548)
Design System: https://github.com/ArcadeAI/Design-System/pull/31 Docs: https://github.com/ArcadeAI/docs/pull/439 <img width="832" height="797" alt="Screenshot 2025-09-11 at 2 35 36 PM" src="https://github.com/user-attachments/assets/198f194f-d41c-41a0-bedf-b77bccd70dfa" />
This commit is contained in:
parent
f50e05aa9b
commit
766a262a25
16 changed files with 2273 additions and 1 deletions
3
.github/workflows/test-toolkits.yml
vendored
3
.github/workflows/test-toolkits.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
run: |
|
||||
# Find all directories in toolkits/ that have a pyproject.toml
|
||||
TOOLKITS=$(find toolkits -maxdepth 1 -type d -not -name "toolkits" -exec test -f {}/pyproject.toml \; -exec basename {} \; | jq -R -s -c 'split("\n")[:-1]')
|
||||
TOOLKITS_WITH_GHA_SECRETS='["postgres", "clickhouse"]'
|
||||
TOOLKITS_WITH_GHA_SECRETS='["postgres", "clickhouse", "mongodb"]'
|
||||
TOOLKITS_WITHOUT_GHA_SECRETS=$(echo "$TOOLKITS" | jq -c --argjson with "$TOOLKITS_WITH_GHA_SECRETS" '[.[] | select(. as $t | $with | index($t) | not)]')
|
||||
echo "Found toolkits: $TOOLKITS"
|
||||
echo "Found toolkits without GHA secrets: $TOOLKITS_WITHOUT_GHA_SECRETS"
|
||||
|
|
@ -104,6 +104,7 @@ jobs:
|
|||
env:
|
||||
TEST_POSTGRES_DATABASE_CONNECTION_STRING: ${{ secrets.TEST_POSTGRES_DATABASE_CONNECTION_STRING }} # TODO: dynamically only load the `TEST_${{ matrix.toolkit }}_DATABASE_CONNECTION_STRING secret`
|
||||
TEST_CLICKHOUSE_DATABASE_CONNECTION_STRING: ${{ secrets.TEST_CLICKHOUSE_DATABASE_CONNECTION_STRING }}
|
||||
TEST_MONGODB_CONNECTION_STRING: ${{ secrets.TEST_MONGODB_CONNECTION_STRING }}
|
||||
run: |
|
||||
# If there's a custom test_setup.sh file, run it
|
||||
if [ -f tests/test_setup.sh ]; then
|
||||
|
|
|
|||
53
toolkits/mongodb/Makefile
Normal file
53
toolkits/mongodb/Makefile
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
.PHONY: help
|
||||
|
||||
help:
|
||||
@echo "🛠️ github Commands:\n"
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
.PHONY: install
|
||||
install: ## Install the uv environment and install all packages with dependencies
|
||||
@echo "🚀 Creating virtual environment and installing all packages using uv"
|
||||
@uv sync --active --all-extras --no-sources
|
||||
@uv run pre-commit install
|
||||
@echo "✅ All packages and dependencies installed via uv"
|
||||
|
||||
.PHONY: install-local
|
||||
install-local: ## Install the uv environment and install all packages with dependencies with local Arcade sources
|
||||
@echo "🚀 Creating virtual environment and installing all packages using uv"
|
||||
@uv sync --active --all-extras
|
||||
@uv run pre-commit install
|
||||
@echo "✅ All packages and dependencies installed via uv"
|
||||
|
||||
.PHONY: build
|
||||
build: clean-build ## Build wheel file using poetry
|
||||
@echo "🚀 Creating wheel file"
|
||||
uv build
|
||||
|
||||
.PHONY: clean-build
|
||||
clean-build: ## clean build artifacts
|
||||
@echo "🗑️ Cleaning dist directory"
|
||||
rm -rf dist
|
||||
|
||||
.PHONY: test
|
||||
test: ## Test the code with pytest
|
||||
@echo "🚀 Testing code: Running pytest"
|
||||
@uv run pytest -W ignore -v --cov --cov-config=pyproject.toml --cov-report=xml
|
||||
|
||||
.PHONY: coverage
|
||||
coverage: ## Generate coverage report
|
||||
@echo "coverage report"
|
||||
coverage report
|
||||
@echo "Generating coverage report"
|
||||
coverage html
|
||||
|
||||
.PHONY: bump-version
|
||||
bump-version: ## Bump the version in the pyproject.toml file by a patch version
|
||||
@echo "🚀 Bumping version in pyproject.toml"
|
||||
uv version --bump patch
|
||||
|
||||
.PHONY: check
|
||||
check: ## Run code quality tools.
|
||||
@echo "🚀 Linting code: Running pre-commit"
|
||||
@uv run pre-commit run -a
|
||||
@echo "🚀 Static type checking: Running mypy"
|
||||
@uv run mypy --config-file=pyproject.toml
|
||||
0
toolkits/mongodb/arcade_mongodb/__init__.py
Normal file
0
toolkits/mongodb/arcade_mongodb/__init__.py
Normal file
118
toolkits/mongodb/arcade_mongodb/database_engine.py
Normal file
118
toolkits/mongodb/arcade_mongodb/database_engine.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from typing import Any, ClassVar
|
||||
|
||||
from arcade_tdk.errors import RetryableToolError
|
||||
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase
|
||||
from pymongo.errors import ServerSelectionTimeoutError
|
||||
|
||||
MAX_RECORDS_RETURNED = 1000
|
||||
TEST_QUERY = {"ping": 1}
|
||||
|
||||
|
||||
class DatabaseEngine:
|
||||
_instance: ClassVar[None] = None
|
||||
_clients: ClassVar[dict[str, AsyncIOMotorClient]] = {}
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls, connection_string: str) -> AsyncIOMotorClient:
|
||||
key = connection_string
|
||||
if key not in cls._clients:
|
||||
cls._clients[key] = AsyncIOMotorClient(connection_string)
|
||||
|
||||
# try a simple query to see if the connection is valid
|
||||
try:
|
||||
admin_db = cls._clients[key].admin
|
||||
await admin_db.command(TEST_QUERY)
|
||||
return cls._clients[key]
|
||||
except ServerSelectionTimeoutError:
|
||||
# close and try again
|
||||
cls._clients[key].close()
|
||||
cls._clients[key] = AsyncIOMotorClient(connection_string)
|
||||
|
||||
try:
|
||||
admin_db = cls._clients[key].admin
|
||||
await admin_db.command(TEST_QUERY)
|
||||
return cls._clients[key]
|
||||
except Exception as e:
|
||||
raise RetryableToolError(
|
||||
f"Connection failed: {e}",
|
||||
developer_message="Connection to MongoDB failed.",
|
||||
additional_prompt_content="Check the connection string and try again.",
|
||||
) from e
|
||||
|
||||
@classmethod
|
||||
async def get_database(cls, connection_string: str, database_name: str) -> Any:
|
||||
client = await cls.get_instance(connection_string)
|
||||
|
||||
class DatabaseContextManager:
|
||||
def __init__(self, client: AsyncIOMotorClient, database_name: str) -> None:
|
||||
self.client = client
|
||||
self.database_name = database_name
|
||||
self.database = client[database_name]
|
||||
|
||||
async def __aenter__(self) -> AsyncIOMotorDatabase:
|
||||
return self.database
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||
# Connection cleanup is handled by the client cache
|
||||
pass
|
||||
|
||||
return DatabaseContextManager(client, database_name)
|
||||
|
||||
@classmethod
|
||||
async def cleanup(cls) -> None:
|
||||
"""Clean up all cached clients. Call this when shutting down."""
|
||||
for client in cls._clients.values():
|
||||
client.close()
|
||||
cls._clients.clear()
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls) -> None:
|
||||
"""Clear the client cache without closing clients. Use with caution."""
|
||||
cls._clients.clear()
|
||||
|
||||
@classmethod
|
||||
def sanitize_query_params(
|
||||
cls,
|
||||
database_name: str,
|
||||
collection_name: str,
|
||||
filter_dict: dict[str, Any] | None,
|
||||
projection: dict[str, Any] | None,
|
||||
sort: list[dict[str, Any]] | None,
|
||||
limit: int,
|
||||
skip: int,
|
||||
) -> tuple[
|
||||
str, str, dict[str, Any], dict[str, Any] | None, list[dict[str, Any]] | None, int, int
|
||||
]:
|
||||
if not database_name:
|
||||
raise RetryableToolError(
|
||||
"Database name is required.",
|
||||
developer_message="Database name cannot be empty.",
|
||||
)
|
||||
|
||||
if not collection_name:
|
||||
raise RetryableToolError(
|
||||
"Collection name is required.",
|
||||
developer_message="Collection name cannot be empty.",
|
||||
)
|
||||
|
||||
if filter_dict is None:
|
||||
filter_dict = {}
|
||||
|
||||
if limit > MAX_RECORDS_RETURNED:
|
||||
raise RetryableToolError(
|
||||
f"Limit is too high. Maximum is {MAX_RECORDS_RETURNED}.",
|
||||
)
|
||||
|
||||
if skip < 0:
|
||||
raise RetryableToolError(
|
||||
"Skip must be greater than or equal to 0.",
|
||||
developer_message="Skip must be greater than or equal to 0.",
|
||||
)
|
||||
|
||||
if limit <= 0:
|
||||
raise RetryableToolError(
|
||||
"Limit must be greater than 0.",
|
||||
developer_message="Limit must be greater than 0.",
|
||||
)
|
||||
|
||||
return database_name, collection_name, filter_dict, projection, sort, limit, skip
|
||||
0
toolkits/mongodb/arcade_mongodb/tools/__init__.py
Normal file
0
toolkits/mongodb/arcade_mongodb/tools/__init__.py
Normal file
367
toolkits/mongodb/arcade_mongodb/tools/mongodb.py
Normal file
367
toolkits/mongodb/arcade_mongodb/tools/mongodb.py
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import json
|
||||
from typing import Annotated, Any
|
||||
|
||||
from arcade_tdk import ToolContext, tool
|
||||
from arcade_tdk.errors import RetryableToolError
|
||||
|
||||
from ..database_engine import MAX_RECORDS_RETURNED, DatabaseEngine
|
||||
from .utils import (
|
||||
_infer_schema_from_docs,
|
||||
_parse_json_list_parameter,
|
||||
_parse_json_parameter,
|
||||
_serialize_document,
|
||||
)
|
||||
|
||||
# class UserStatus(str, Enum):
|
||||
# """User status enumeration."""
|
||||
|
||||
# ACTIVE = "active"
|
||||
# INACTIVE = "inactive"
|
||||
# BANNED = "banned"
|
||||
|
||||
|
||||
@tool(requires_secrets=["MONGODB_CONNECTION_STRING"])
|
||||
async def discover_databases(
|
||||
context: ToolContext,
|
||||
) -> list[str]:
|
||||
"""Discover all the databases in the MongoDB instance."""
|
||||
client = await DatabaseEngine.get_instance(context.get_secret("MONGODB_CONNECTION_STRING"))
|
||||
databases = await client.list_database_names()
|
||||
# Filter out admin and config databases by default
|
||||
databases = [db for db in databases if db not in ["admin", "config", "local"]]
|
||||
return databases
|
||||
|
||||
|
||||
@tool(requires_secrets=["MONGODB_CONNECTION_STRING"])
|
||||
async def discover_collections(
|
||||
context: ToolContext,
|
||||
database_name: Annotated[str, "The database name to discover collections in"],
|
||||
) -> list[str]:
|
||||
"""Discover all the collections in the MongoDB database when the list of collections is not known.
|
||||
|
||||
ALWAYS use this tool before any other tool that requires a collection name.
|
||||
"""
|
||||
async with await DatabaseEngine.get_database(
|
||||
context.get_secret("MONGODB_CONNECTION_STRING"), database_name
|
||||
) as db:
|
||||
collections = await db.list_collection_names()
|
||||
return list(collections)
|
||||
|
||||
|
||||
@tool(requires_secrets=["MONGODB_CONNECTION_STRING"])
|
||||
async def get_collection_schema(
|
||||
context: ToolContext,
|
||||
database_name: Annotated[str, "The database name to get the collection schema of"],
|
||||
collection_name: Annotated[str, "The collection to get the schema of"],
|
||||
sample_size: Annotated[
|
||||
int,
|
||||
f"The number of documents to sample for schema discovery (default: {MAX_RECORDS_RETURNED})",
|
||||
] = MAX_RECORDS_RETURNED,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get the schema/structure of a MongoDB collection by sampling documents.
|
||||
|
||||
Since MongoDB is schema-less, this tool samples a configurable number of documents
|
||||
to infer the schema structure and data types.
|
||||
|
||||
This tool should ALWAYS be used before executing any query. All collections in the query must be discovered first using the <discover_collections> tool.
|
||||
"""
|
||||
async with await DatabaseEngine.get_database(
|
||||
context.get_secret("MONGODB_CONNECTION_STRING"), database_name
|
||||
) as db:
|
||||
collection = db[collection_name]
|
||||
|
||||
# Sample documents at random to infer schema
|
||||
# Use MongoDB's $sample aggregation to get random documents
|
||||
sample_docs = []
|
||||
async for doc in collection.aggregate([{"$sample": {"size": sample_size}}]):
|
||||
sample_docs.append(doc)
|
||||
|
||||
if not sample_docs:
|
||||
return {"message": "Collection is empty", "schema": {}}
|
||||
|
||||
# Infer schema from sampled documents
|
||||
schema = _infer_schema_from_docs(sample_docs)
|
||||
|
||||
return {
|
||||
"total_documents_sampled": len(sample_docs),
|
||||
"sample_size_requested": sample_size,
|
||||
"schema": schema,
|
||||
}
|
||||
|
||||
|
||||
@tool(requires_secrets=["MONGODB_CONNECTION_STRING"])
|
||||
async def find_documents(
|
||||
context: ToolContext,
|
||||
database_name: Annotated[str, "The database name to query"],
|
||||
collection_name: Annotated[str, "The collection name to query"],
|
||||
filter_dict: Annotated[
|
||||
str | None,
|
||||
'MongoDB filter/query as JSON string. Leave None for no filter (find all documents). Example: \'{"status": "active", "age": {"$gte": 18}}\'',
|
||||
] = None,
|
||||
projection: Annotated[
|
||||
str | None,
|
||||
'Fields to include/exclude as JSON string. Use 1 to include, 0 to exclude. Example: \'{"name": 1, "email": 1, "_id": 0}\'. Leave None to include all fields.',
|
||||
] = None,
|
||||
sort: Annotated[
|
||||
list[str] | None,
|
||||
'Sort criteria as list of JSON strings, each containing \'field\' and \'direction\' keys. Use 1 for ascending, -1 for descending. Example: [\'{"field": "name", "direction": 1}\', \'{"field": "created_at", "direction": -1}\']',
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int,
|
||||
f"The maximum number of documents to return. Default: {MAX_RECORDS_RETURNED}.",
|
||||
] = MAX_RECORDS_RETURNED,
|
||||
skip: Annotated[int, "The number of documents to skip. Default: 0."] = 0,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Find documents in a MongoDB collection.
|
||||
|
||||
ONLY use this tool if you have already loaded the schema of the collection you need to query.
|
||||
Use the <get_collection_schema> tool to load the schema if not already known.
|
||||
|
||||
Returns a list of JSON strings, where each string represents a document from the collection (tools cannot return complex types).
|
||||
|
||||
When running queries, follow these rules which will help avoid errors:
|
||||
* Always specify projection to limit fields returned if you don't need all data.
|
||||
* Always sort your results by the most important fields first. If you aren't sure, sort by '_id'.
|
||||
* Use appropriate MongoDB query operators for complex filtering ($gte, $lte, $in, $regex, etc.).
|
||||
* Be mindful of case sensitivity when querying string fields.
|
||||
* Use indexes when possible (typically on _id and commonly queried fields).
|
||||
"""
|
||||
# Initialize variables to avoid UnboundLocalError in exception handler
|
||||
parsed_filter = None
|
||||
parsed_projection = None
|
||||
parsed_sort = None
|
||||
|
||||
try:
|
||||
# Parse JSON string inputs
|
||||
parsed_filter = _parse_json_parameter(filter_dict, "filter_dict")
|
||||
parsed_projection = _parse_json_parameter(projection, "projection")
|
||||
parsed_sort = _parse_json_list_parameter(sort, "sort")
|
||||
|
||||
(
|
||||
database_name,
|
||||
collection_name,
|
||||
parsed_filter,
|
||||
parsed_projection,
|
||||
parsed_sort,
|
||||
limit,
|
||||
skip,
|
||||
) = DatabaseEngine.sanitize_query_params(
|
||||
database_name=database_name,
|
||||
collection_name=collection_name,
|
||||
filter_dict=parsed_filter,
|
||||
projection=parsed_projection,
|
||||
sort=parsed_sort,
|
||||
limit=limit,
|
||||
skip=skip,
|
||||
)
|
||||
|
||||
async with await DatabaseEngine.get_database(
|
||||
context.get_secret("MONGODB_CONNECTION_STRING"), database_name
|
||||
) as db:
|
||||
collection = db[collection_name]
|
||||
|
||||
# Build the query
|
||||
cursor = collection.find(parsed_filter, parsed_projection)
|
||||
|
||||
if parsed_sort:
|
||||
# Convert list of dicts to list of tuples for MongoDB sort
|
||||
sort_tuples = [(str(item["field"]), int(item["direction"])) for item in parsed_sort]
|
||||
cursor = cursor.sort(sort_tuples)
|
||||
|
||||
cursor = cursor.skip(skip).limit(limit)
|
||||
|
||||
# Execute query and collect results
|
||||
documents = []
|
||||
async for doc in cursor:
|
||||
# Convert ObjectId and other non-serializable types to strings
|
||||
doc = _serialize_document(doc)
|
||||
documents.append(json.dumps(doc))
|
||||
|
||||
return documents
|
||||
|
||||
except RetryableToolError:
|
||||
# Re-raise RetryableToolError as-is to preserve JSON validation messages
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RetryableToolError(
|
||||
f"Query failed: {e}",
|
||||
developer_message=f"Query failed with parameters: database_name={database_name}, collection_name={collection_name}, filter_dict={parsed_filter}, projection={parsed_projection}, sort={parsed_sort}, limit={limit}, skip={skip}.",
|
||||
additional_prompt_content="Load the collection schema <get_collection_schema> or use the <discover_collections> tool to discover the collections and try again.",
|
||||
retry_after_ms=10,
|
||||
) from e
|
||||
|
||||
|
||||
@tool(requires_secrets=["MONGODB_CONNECTION_STRING"])
|
||||
async def count_documents(
|
||||
context: ToolContext,
|
||||
database_name: Annotated[str, "The database name to query"],
|
||||
collection_name: Annotated[str, "The collection name to query"],
|
||||
filter_dict: Annotated[
|
||||
str | None,
|
||||
'MongoDB filter/query as JSON string. Leave None for no filter (count all documents). Example: \'{"status": "active"}\'',
|
||||
] = None,
|
||||
) -> int:
|
||||
"""Count documents in a MongoDB collection matching the given filter."""
|
||||
parsed_filter = None
|
||||
|
||||
try:
|
||||
# Parse JSON string input
|
||||
parsed_filter = _parse_json_parameter(filter_dict, "filter_dict") or {}
|
||||
|
||||
async with await DatabaseEngine.get_database(
|
||||
context.get_secret("MONGODB_CONNECTION_STRING"), database_name
|
||||
) as db:
|
||||
collection = db[collection_name]
|
||||
|
||||
count = await collection.count_documents(parsed_filter)
|
||||
return int(count)
|
||||
|
||||
except RetryableToolError:
|
||||
# Re-raise RetryableToolError as-is to preserve JSON validation messages
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RetryableToolError(
|
||||
f"Count query failed: {e}",
|
||||
developer_message=f"Count query failed with parameters: database_name={database_name}, collection_name={collection_name}, filter_dict={parsed_filter}.",
|
||||
additional_prompt_content="Check the collection name and filter criteria and try again.",
|
||||
retry_after_ms=10,
|
||||
) from e
|
||||
|
||||
|
||||
@tool(requires_secrets=["MONGODB_CONNECTION_STRING"])
|
||||
async def aggregate_documents(
|
||||
context: ToolContext,
|
||||
database_name: Annotated[str, "The database name to query"],
|
||||
collection_name: Annotated[str, "The collection name to query"],
|
||||
pipeline: Annotated[
|
||||
list[str],
|
||||
'MongoDB aggregation pipeline as a list of JSON strings, each representing a stage. Example: [\'{"$match": {"status": "active"}}\', \'{"$group": {"_id": "$category", "count": {"$sum": 1}}}\']',
|
||||
],
|
||||
limit: Annotated[
|
||||
int,
|
||||
f"The maximum number of results to return from the aggregation. Default: {MAX_RECORDS_RETURNED}.",
|
||||
] = MAX_RECORDS_RETURNED,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Execute a MongoDB aggregation pipeline on a collection.
|
||||
|
||||
ONLY use this tool if you have already loaded the schema of the collection you need to query.
|
||||
Use the <get_collection_schema> tool to load the schema if not already known.
|
||||
|
||||
Returns a list of JSON strings, where each string represents a result document from the aggregation (tools cannot return complex types).
|
||||
|
||||
Aggregation pipelines allow for complex data processing including:
|
||||
* $match - filter documents
|
||||
* $group - group documents and perform calculations
|
||||
* $project - reshape documents
|
||||
* $sort - sort documents
|
||||
* $limit - limit results
|
||||
* $lookup - join with other collections
|
||||
* And many more stages
|
||||
"""
|
||||
parsed_pipeline = None
|
||||
|
||||
try:
|
||||
# Parse JSON string inputs
|
||||
parsed_pipeline = _parse_json_list_parameter(pipeline, "pipeline")
|
||||
|
||||
if parsed_pipeline is None:
|
||||
raise RetryableToolError( # noqa: TRY301
|
||||
"Pipeline cannot be empty",
|
||||
developer_message="The pipeline parameter is required and cannot be None",
|
||||
)
|
||||
|
||||
async with await DatabaseEngine.get_database(
|
||||
context.get_secret("MONGODB_CONNECTION_STRING"), database_name
|
||||
) as db:
|
||||
collection = db[collection_name]
|
||||
|
||||
# Add limit to pipeline if not already present
|
||||
pipeline_with_limit = parsed_pipeline.copy()
|
||||
has_limit = any("$limit" in stage for stage in pipeline_with_limit)
|
||||
if not has_limit:
|
||||
pipeline_with_limit.append({"$limit": limit})
|
||||
|
||||
# Execute aggregation
|
||||
cursor = collection.aggregate(pipeline_with_limit)
|
||||
|
||||
documents = []
|
||||
async for doc in cursor:
|
||||
# Convert ObjectId and other non-serializable types to strings
|
||||
doc = _serialize_document(doc)
|
||||
documents.append(json.dumps(doc))
|
||||
|
||||
return documents
|
||||
|
||||
except RetryableToolError:
|
||||
# Re-raise RetryableToolError as-is to preserve JSON validation messages
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RetryableToolError(
|
||||
f"Aggregation query failed: {e}",
|
||||
developer_message=f"Aggregation query failed with parameters: database_name={database_name}, collection_name={collection_name}, pipeline={parsed_pipeline}, limit={limit}.",
|
||||
additional_prompt_content="Check the aggregation pipeline syntax and collection schema, then try again.",
|
||||
retry_after_ms=10,
|
||||
) from e
|
||||
|
||||
|
||||
# @tool(requires_secrets=["MONGODB_CONNECTION_STRING"])
|
||||
# async def update_user_status(
|
||||
# context: ToolContext,
|
||||
# database_name: Annotated[str, "The database name containing the users collection"],
|
||||
# collection_name: Annotated[str, "The collection name containing user documents"],
|
||||
# user_id: Annotated[str, "The _id of the user to update"],
|
||||
# status: Annotated[UserStatus, "The new status for the user"],
|
||||
# ) -> dict[str, Any]:
|
||||
# """
|
||||
# [CUSTOM TOOL]
|
||||
# Update the status of a user in the MongoDB collection.
|
||||
|
||||
# This tool updates a user document by setting the status field to the specified value.
|
||||
# The status must be one of: active, inactive, or banned.
|
||||
|
||||
# Returns information about the update operation including the number of documents modified.
|
||||
# """
|
||||
|
||||
# try:
|
||||
# async with await DatabaseEngine.get_database(
|
||||
# context.get_secret("MONGODB_CONNECTION_STRING"), database_name
|
||||
# ) as db:
|
||||
# collection = db[collection_name]
|
||||
|
||||
# # cast the user_id to int if it looks like an integer
|
||||
# if isinstance(user_id, str) and user_id.isdigit():
|
||||
# user_id = int(user_id)
|
||||
|
||||
# result = await collection.update_one(
|
||||
# {"_id": user_id}, {"$set": {"status": status.value}}
|
||||
# )
|
||||
|
||||
# print(result)
|
||||
|
||||
# if result.matched_count == 0:
|
||||
# return {
|
||||
# "success": False,
|
||||
# "message": f"No user found with _id: {user_id}",
|
||||
# "matched_count": 0,
|
||||
# "modified_count": 0,
|
||||
# }
|
||||
|
||||
# return {
|
||||
# "success": True,
|
||||
# "message": f"User status updated to '{status.value}'",
|
||||
# "user_id": user_id,
|
||||
# "new_status": status.value,
|
||||
# "matched_count": result.matched_count,
|
||||
# "modified_count": result.modified_count,
|
||||
# }
|
||||
|
||||
# except Exception as e:
|
||||
# raise RetryableToolError(
|
||||
# f"Failed to update user status: {e}",
|
||||
# developer_message=f"Update operation failed with parameters: database_name={database_name}, collection_name={collection_name}, user_id={user_id}, status={status}.",
|
||||
# additional_prompt_content="Check the database name, collection name, and user ID, then try again.",
|
||||
# retry_after_ms=10,
|
||||
# ) from e
|
||||
281
toolkits/mongodb/arcade_mongodb/tools/utils.py
Normal file
281
toolkits/mongodb/arcade_mongodb/tools/utils.py
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from arcade_tdk.errors import RetryableToolError
|
||||
from bson import ObjectId
|
||||
|
||||
|
||||
def _validate_no_write_operations(obj: Any, parameter_name: str, path: str = "") -> None:
|
||||
"""
|
||||
Recursively validate that an object doesn't contain MongoDB write operations.
|
||||
|
||||
Args:
|
||||
obj: The object to validate
|
||||
parameter_name: Name of the parameter for error messages
|
||||
path: Current path in the object (for nested validation)
|
||||
|
||||
Raises:
|
||||
RetryableToolError: If write operations are detected
|
||||
"""
|
||||
# MongoDB write/update operators that should be blocked
|
||||
WRITE_OPERATORS = {
|
||||
# Update operators
|
||||
"$set",
|
||||
"$unset",
|
||||
"$inc",
|
||||
"$mul",
|
||||
"$rename",
|
||||
"$min",
|
||||
"$max",
|
||||
"$currentDate",
|
||||
"$addToSet",
|
||||
"$pop",
|
||||
"$pull",
|
||||
"$push",
|
||||
"$pullAll",
|
||||
"$each",
|
||||
"$slice",
|
||||
"$sort",
|
||||
"$position",
|
||||
"$bit",
|
||||
"$isolated",
|
||||
# Array update operators
|
||||
"$",
|
||||
"$[]",
|
||||
"$[<identifier>]",
|
||||
# Pipeline update operators
|
||||
"$addFields",
|
||||
"$replaceRoot",
|
||||
"$replaceWith",
|
||||
# Aggregation stages that can modify (in case they're misused)
|
||||
"$out",
|
||||
"$merge",
|
||||
# Other potentially dangerous operators
|
||||
"$where", # Can execute JavaScript
|
||||
}
|
||||
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
current_path = f"{path}.{key}" if path else key
|
||||
|
||||
# Special check for $where operator which can execute JavaScript (check this first)
|
||||
if key == "$where":
|
||||
raise RetryableToolError(
|
||||
f"JavaScript execution operator '$where' not allowed in {parameter_name}",
|
||||
developer_message=f"Found '$where' operator at path '{current_path}' in parameter '{parameter_name}'. JavaScript execution is not allowed for security reasons.",
|
||||
additional_prompt_content=f"The {parameter_name} parameter cannot use the $where operator. Use other query operators instead.",
|
||||
)
|
||||
|
||||
# Check if this key is a write operator
|
||||
if key in WRITE_OPERATORS:
|
||||
raise RetryableToolError(
|
||||
f"Write operation '{key}' not allowed in {parameter_name}",
|
||||
developer_message=f"Found write operation '{key}' at path '{current_path}' in parameter '{parameter_name}'. Only read operations are allowed.",
|
||||
additional_prompt_content=f"The {parameter_name} parameter cannot contain write operations like '{key}'. Use only query/read operations such as $match, $gte, $lte, $in, $regex, etc.",
|
||||
)
|
||||
|
||||
# Recursively validate nested objects
|
||||
_validate_no_write_operations(value, parameter_name, current_path)
|
||||
|
||||
elif isinstance(obj, list):
|
||||
for i, item in enumerate(obj):
|
||||
current_path = f"{path}[{i}]" if path else f"[{i}]"
|
||||
_validate_no_write_operations(item, parameter_name, current_path)
|
||||
|
||||
|
||||
def _parse_json_parameter(
|
||||
json_string: str | None, parameter_name: str, validate_read_only: bool = True
|
||||
) -> Any | None:
|
||||
"""
|
||||
Parse a JSON string parameter with proper error handling and optional write operation validation.
|
||||
|
||||
Args:
|
||||
json_string: The JSON string to parse (can be None)
|
||||
parameter_name: Name of the parameter for error messages
|
||||
validate_read_only: Whether to validate that no write operations are present
|
||||
|
||||
Returns:
|
||||
Parsed JSON object or None if json_string is None
|
||||
|
||||
Raises:
|
||||
RetryableToolError: If JSON parsing fails or write operations are detected
|
||||
"""
|
||||
if json_string is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed_obj = json.loads(json_string)
|
||||
|
||||
# Validate that no write operations are present
|
||||
if validate_read_only and parsed_obj is not None:
|
||||
_validate_no_write_operations(parsed_obj, parameter_name)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise RetryableToolError(
|
||||
f"Invalid JSON in {parameter_name}: {e}",
|
||||
developer_message=f"Failed to parse JSON string for parameter '{parameter_name}': {json_string}. Error: {e}",
|
||||
additional_prompt_content=f"Please provide valid JSON for the {parameter_name} parameter. Check for proper escaping of quotes and valid JSON syntax.",
|
||||
) from e
|
||||
else:
|
||||
return parsed_obj
|
||||
|
||||
|
||||
def _validate_aggregation_pipeline(pipeline: list[Any], parameter_name: str) -> None:
|
||||
"""
|
||||
Validate that an aggregation pipeline only contains read operations.
|
||||
|
||||
Args:
|
||||
pipeline: The aggregation pipeline to validate
|
||||
parameter_name: Name of the parameter for error messages
|
||||
|
||||
Raises:
|
||||
RetryableToolError: If write operations are detected in the pipeline
|
||||
"""
|
||||
# MongoDB aggregation stages that can modify data
|
||||
WRITE_STAGES = {
|
||||
"$out",
|
||||
"$merge", # These stages write to collections
|
||||
}
|
||||
|
||||
# Aggregation stages that are potentially dangerous
|
||||
DANGEROUS_STAGES = {
|
||||
"$where", # Can execute JavaScript
|
||||
}
|
||||
|
||||
for i, stage in enumerate(pipeline):
|
||||
if isinstance(stage, dict):
|
||||
for stage_name in stage:
|
||||
if stage_name in WRITE_STAGES:
|
||||
raise RetryableToolError(
|
||||
f"Write stage '{stage_name}' not allowed in {parameter_name}",
|
||||
developer_message=f"Found write stage '{stage_name}' at pipeline index {i} in parameter '{parameter_name}'. Only read operations are allowed.",
|
||||
additional_prompt_content=f"The {parameter_name} parameter cannot contain write stages like '{stage_name}'. Use only read stages such as $match, $group, $project, $sort, $limit, etc.",
|
||||
)
|
||||
|
||||
if stage_name in DANGEROUS_STAGES:
|
||||
raise RetryableToolError(
|
||||
f"Dangerous stage '{stage_name}' not allowed in {parameter_name}",
|
||||
developer_message=f"Found dangerous stage '{stage_name}' at pipeline index {i} in parameter '{parameter_name}'. JavaScript execution is not allowed for security reasons.",
|
||||
additional_prompt_content=f"The {parameter_name} parameter cannot use the {stage_name} stage. Use other aggregation stages instead.",
|
||||
)
|
||||
|
||||
# Also validate the stage content for write operations
|
||||
_validate_no_write_operations(
|
||||
stage[stage_name], f"{parameter_name}[{i}].{stage_name}"
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_list_parameter(
|
||||
json_strings: list[str] | None, parameter_name: str, validate_read_only: bool = True
|
||||
) -> list[Any] | None:
|
||||
"""
|
||||
Parse a list of JSON strings with proper error handling and optional write operation validation.
|
||||
|
||||
Args:
|
||||
json_strings: List of JSON strings to parse (can be None)
|
||||
parameter_name: Name of the parameter for error messages
|
||||
validate_read_only: Whether to validate that no write operations are present
|
||||
|
||||
Returns:
|
||||
List of parsed JSON objects or None if json_strings is None
|
||||
|
||||
Raises:
|
||||
RetryableToolError: If JSON parsing fails for any string or write operations are detected
|
||||
"""
|
||||
if json_strings is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed_list = [json.loads(json_str) for json_str in json_strings]
|
||||
|
||||
# Validate that no write operations are present
|
||||
if validate_read_only and parsed_list is not None:
|
||||
# Special handling for pipeline parameters
|
||||
if parameter_name == "pipeline":
|
||||
_validate_aggregation_pipeline(parsed_list, parameter_name)
|
||||
else:
|
||||
# For non-pipeline lists, validate each item
|
||||
for i, item in enumerate(parsed_list):
|
||||
_validate_no_write_operations(item, f"{parameter_name}[{i}]")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise RetryableToolError(
|
||||
f"Invalid JSON in {parameter_name}: {e}",
|
||||
developer_message=f"Failed to parse JSON string list for parameter '{parameter_name}': {json_strings}. Error: {e}",
|
||||
additional_prompt_content=f"Please provide valid JSON strings for the {parameter_name} parameter. Each string must be valid JSON with proper escaping of quotes.",
|
||||
) from e
|
||||
else:
|
||||
return parsed_list
|
||||
|
||||
|
||||
def _infer_schema_from_docs(docs: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""Infer schema structure from a list of documents."""
|
||||
schema: dict[str, Any] = {}
|
||||
|
||||
for doc in docs:
|
||||
_update_schema_with_doc(schema, doc)
|
||||
|
||||
# Convert sets to lists for serialization
|
||||
for key in schema:
|
||||
if isinstance(schema[key]["types"], set):
|
||||
schema[key]["types"] = list(schema[key]["types"])
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def _update_schema_with_doc(schema: dict[str, Any], doc: dict[str, Any], prefix: str = "") -> None:
|
||||
"""Recursively update schema with document structure."""
|
||||
for key, value in doc.items():
|
||||
full_key = f"{prefix}.{key}" if prefix else key
|
||||
|
||||
if full_key not in schema:
|
||||
schema[full_key] = {
|
||||
"types": set(),
|
||||
"sample_values": [],
|
||||
"null_count": 0,
|
||||
"total_count": 0,
|
||||
}
|
||||
|
||||
schema[full_key]["total_count"] += 1
|
||||
|
||||
if value is None:
|
||||
schema[full_key]["null_count"] += 1
|
||||
schema[full_key]["types"].add("null")
|
||||
else:
|
||||
value_type = type(value).__name__
|
||||
schema[full_key]["types"].add(value_type)
|
||||
|
||||
# Store sample values (limit to 3 unique samples)
|
||||
if (
|
||||
len(schema[full_key]["sample_values"]) < 3
|
||||
and value not in schema[full_key]["sample_values"]
|
||||
):
|
||||
schema[full_key]["sample_values"].append(value)
|
||||
|
||||
# Handle nested objects
|
||||
if isinstance(value, dict):
|
||||
_update_schema_with_doc(schema, value, full_key)
|
||||
elif isinstance(value, list) and value and isinstance(value[0], dict):
|
||||
# Handle arrays of objects by sampling the first few
|
||||
for i, item in enumerate(value[:3]): # Sample first 3 array items
|
||||
if isinstance(item, dict):
|
||||
_update_schema_with_doc(schema, item, f"{full_key}[{i}]")
|
||||
|
||||
|
||||
def _serialize_document(doc: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Convert MongoDB document to JSON-serializable format."""
|
||||
|
||||
if isinstance(doc, dict):
|
||||
result = {}
|
||||
for key, value in doc.items():
|
||||
result[key] = _serialize_document(value)
|
||||
return result
|
||||
elif isinstance(doc, list):
|
||||
return [_serialize_document(item) for item in doc]
|
||||
elif isinstance(doc, ObjectId):
|
||||
return str(doc)
|
||||
elif isinstance(doc, datetime):
|
||||
return doc.isoformat()
|
||||
else:
|
||||
return doc
|
||||
190
toolkits/mongodb/evals/eval_mongodb.py
Normal file
190
toolkits/mongodb/evals/eval_mongodb.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# RUN ME WITH `uv run arcade evals evals --host api.arcade.dev`
|
||||
|
||||
import arcade_mongodb
|
||||
from arcade_evals import (
|
||||
BinaryCritic,
|
||||
EvalRubric,
|
||||
EvalSuite,
|
||||
ExpectedToolCall,
|
||||
SimilarityCritic,
|
||||
tool_eval,
|
||||
)
|
||||
from arcade_mongodb.tools.mongodb import (
|
||||
aggregate_documents,
|
||||
count_documents,
|
||||
discover_collections,
|
||||
discover_databases,
|
||||
find_documents,
|
||||
get_collection_schema,
|
||||
)
|
||||
from arcade_tdk import ToolCatalog
|
||||
|
||||
# Evaluation rubric
|
||||
rubric = EvalRubric(
|
||||
fail_threshold=0.85,
|
||||
warn_threshold=0.95,
|
||||
)
|
||||
|
||||
|
||||
catalog = ToolCatalog()
|
||||
catalog.add_module(arcade_mongodb)
|
||||
|
||||
|
||||
@tool_eval()
|
||||
def mongodb_eval_suite() -> EvalSuite:
|
||||
suite = EvalSuite(
|
||||
name="MongoDB Tools Evaluation",
|
||||
system_message=(
|
||||
"You are an AI assistant with access to MongoDB tools. "
|
||||
"Use them to help the user with their tasks."
|
||||
),
|
||||
catalog=catalog,
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Discover databases",
|
||||
user_message="What databases are available in my MongoDB instance?",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(func=discover_databases, args={}),
|
||||
],
|
||||
rubric=rubric,
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Discover collections",
|
||||
user_message="What collections are in the 'admin' database?",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(func=discover_collections, args={"database_name": "admin"}),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="database_name", weight=1.0),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Get collection schema (single tool call)",
|
||||
user_message="Get the schema of the 'system.users' collection in the 'admin' database.",
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=get_collection_schema,
|
||||
args={"database_name": "admin", "collection_name": "system.users"},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="database_name", weight=0.5),
|
||||
BinaryCritic(critic_field="collection_name", weight=0.5),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Find documents (direct call)",
|
||||
user_message="Find documents in the 'startup_log' collection of the 'local' database, limited to 5 results.",
|
||||
additional_messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "You can call find_documents directly without discovering collections first for this test.",
|
||||
}
|
||||
],
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=find_documents,
|
||||
args={
|
||||
"database_name": "local",
|
||||
"collection_name": "startup_log",
|
||||
"limit": 5,
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="database_name", weight=0.33),
|
||||
BinaryCritic(critic_field="collection_name", weight=0.33),
|
||||
BinaryCritic(critic_field="limit", weight=0.34),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Count documents",
|
||||
user_message="Count all documents in the 'startup_log' collection of the 'local' database.",
|
||||
additional_messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "You can call count_documents directly without discovering collections first for this test.",
|
||||
}
|
||||
],
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=count_documents,
|
||||
args={
|
||||
"database_name": "local",
|
||||
"collection_name": "startup_log",
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="database_name", weight=0.5),
|
||||
BinaryCritic(critic_field="collection_name", weight=0.5),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Count documents with filter",
|
||||
user_message="Count documents in the 'startup_log' collection of the 'local' database where the level is 'INFO'.",
|
||||
additional_messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "You can call count_documents directly without discovering collections first for this test.",
|
||||
}
|
||||
],
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=count_documents,
|
||||
args={
|
||||
"database_name": "local",
|
||||
"collection_name": "startup_log",
|
||||
"filter_dict": '{"level": "INFO"}',
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="database_name", weight=0.25),
|
||||
BinaryCritic(critic_field="collection_name", weight=0.25),
|
||||
SimilarityCritic(critic_field="filter_dict", weight=0.5),
|
||||
],
|
||||
)
|
||||
|
||||
suite.add_case(
|
||||
name="Aggregate documents",
|
||||
user_message="Group documents in the 'startup_log' collection of the 'local' database by level and count them.",
|
||||
additional_messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "You can call aggregate_documents directly without discovering collections first for this test.",
|
||||
}
|
||||
],
|
||||
expected_tool_calls=[
|
||||
ExpectedToolCall(
|
||||
func=aggregate_documents,
|
||||
args={
|
||||
"database_name": "local",
|
||||
"collection_name": "startup_log",
|
||||
"pipeline": [
|
||||
'{"$group": {"_id": "$level", "count": {"$sum": 1}}}',
|
||||
],
|
||||
},
|
||||
),
|
||||
],
|
||||
rubric=rubric,
|
||||
critics=[
|
||||
BinaryCritic(critic_field="database_name", weight=0.2),
|
||||
BinaryCritic(critic_field="collection_name", weight=0.2),
|
||||
SimilarityCritic(critic_field="pipeline", weight=0.6),
|
||||
],
|
||||
)
|
||||
|
||||
return suite
|
||||
62
toolkits/mongodb/pyproject.toml
Normal file
62
toolkits/mongodb/pyproject.toml
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
[build-system]
|
||||
requires = [ "hatchling",]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "arcade_mongodb"
|
||||
version = "0.1.0"
|
||||
description = "Tools to query and explore a MongoDB database"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"arcade-tdk>=2.0.0,<3.0.0",
|
||||
"pymongo>=4.10.1",
|
||||
"pydantic>=2.11.7",
|
||||
"motor>=3.6.0",
|
||||
]
|
||||
[[project.authors]]
|
||||
name = "evantahler"
|
||||
email = "support@arcade.dev"
|
||||
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"arcade-ai[evals]>=2.0.0,<3.0.0",
|
||||
"arcade-serve>=2.0.0,<3.0.0",
|
||||
"pytest>=8.3.0,<8.4.0",
|
||||
"pytest-cov>=4.0.0,<4.1.0",
|
||||
"pytest-mock>=3.11.1,<3.12.0",
|
||||
"pytest-asyncio>=0.24.0,<0.25.0",
|
||||
"mypy>=1.5.1,<1.6.0",
|
||||
"pre-commit>=3.4.0,<3.5.0",
|
||||
"tox>=4.11.1,<4.12.0",
|
||||
"ruff>=0.7.4,<0.8.0",
|
||||
]
|
||||
|
||||
# Use local path sources for arcade libs when working locally
|
||||
[tool.uv.sources]
|
||||
arcade-ai = { path = "../../", editable = true }
|
||||
arcade-serve = { path = "../../libs/arcade-serve/", editable = true }
|
||||
arcade-tdk = { path = "../../libs/arcade-tdk/", editable = true }
|
||||
|
||||
|
||||
[tool.mypy]
|
||||
files = [ "arcade_mongodb/**/*.py",]
|
||||
python_version = "3.10"
|
||||
disallow_untyped_defs = "True"
|
||||
disallow_any_unimported = "True"
|
||||
no_implicit_optional = "True"
|
||||
check_untyped_defs = "True"
|
||||
warn_return_any = "True"
|
||||
warn_unused_ignores = "True"
|
||||
show_error_codes = "True"
|
||||
ignore_missing_imports = "True"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = [ "tests",]
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.coverage.report]
|
||||
skip_empty = true
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = [ "arcade_mongodb",]
|
||||
0
toolkits/mongodb/tests/__init__.py
Normal file
0
toolkits/mongodb/tests/__init__.py
Normal file
45
toolkits/mongodb/tests/conftest.py
Normal file
45
toolkits/mongodb/tests/conftest.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from os import environ
|
||||
|
||||
import pytest_asyncio
|
||||
from arcade_mongodb.database_engine import DatabaseEngine
|
||||
|
||||
TEST_MONGODB_CONNECTION_STRING = (
|
||||
environ.get("TEST_MONGODB_CONNECTION_STRING") or "mongodb://localhost:27017"
|
||||
)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def restore_database():
|
||||
"""Restore the database from the dump before each test."""
|
||||
|
||||
dump_file = f"{os.path.dirname(__file__)}/dump.js"
|
||||
|
||||
# Execute the MongoDB dump script to restore test data
|
||||
mongosh_path = shutil.which("mongosh")
|
||||
if not mongosh_path:
|
||||
raise RuntimeError("mongosh executable not found in PATH")
|
||||
|
||||
result = subprocess.run(
|
||||
[mongosh_path, TEST_MONGODB_CONNECTION_STRING, dump_file],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Error loading test data: {result.stderr}")
|
||||
raise RuntimeError(f"Failed to load test data: {result.stderr}")
|
||||
|
||||
yield # This allows tests to run
|
||||
|
||||
# Optional cleanup could go here if needed
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(autouse=True)
|
||||
async def cleanup_engines():
|
||||
"""Clean up database engines after each test to prevent connection leaks."""
|
||||
yield
|
||||
await DatabaseEngine.cleanup()
|
||||
378
toolkits/mongodb/tests/dump.js
Normal file
378
toolkits/mongodb/tests/dump.js
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
// MongoDB test data dump - equivalent to PostgreSQL dump.sql
|
||||
// This script sets up test data for the MongoDB toolkit
|
||||
|
||||
// Switch to test database
|
||||
use('test_database');
|
||||
|
||||
// Clear existing data
|
||||
db.users.drop();
|
||||
db.messages.drop();
|
||||
|
||||
// Create users collection with data
|
||||
db.users.insertMany([
|
||||
{
|
||||
_id: 1,
|
||||
name: 'Alice',
|
||||
email: 'alice@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$tMg1Rd3IEDnp3iFKrqsF4Dsbw6/Cbf6seRB/H5bhaPg$zZj5yn4x3D3O3mDHcW2aczQNiYfAs3cw21XMEIgkF0E',
|
||||
created_at: new Date('2024-09-01T20:49:38.759Z'),
|
||||
updated_at: new Date('2024-09-02T03:49:39.927Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 2,
|
||||
name: 'Bob',
|
||||
email: 'bob@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$CvOMK1WUd99R7kYXpiBPNYw4OQP53pYIgeMnwz92mrE$HPthId4phMoPT1TWuCRHHCr9BSQA8XoUkQuB1HZsqTY',
|
||||
created_at: new Date('2024-09-02T17:49:23.377Z'),
|
||||
updated_at: new Date('2024-09-02T17:49:23.377Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 3,
|
||||
name: 'Charlie',
|
||||
email: 'charlie@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$paCAAD1HVZkncP/WvecuUO6zFXp2/8BISpgr5rXRxps$M5kBFc9JHHGNw9SXnPu2ggpJY0mFFCska7TXMrllndo',
|
||||
created_at: new Date('2024-09-03T10:30:15.123Z'),
|
||||
updated_at: new Date('2024-09-03T10:30:15.123Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 4,
|
||||
name: 'Diana',
|
||||
email: 'diana@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$xyz123ABC456DEF789GHI$SampleHashForDiana123',
|
||||
created_at: new Date('2024-09-04T14:20:30.654Z'),
|
||||
updated_at: new Date('2024-09-04T14:20:30.654Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 5,
|
||||
name: 'Evan',
|
||||
email: 'evan@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$evanHash123$EvanPasswordHash456',
|
||||
created_at: new Date('2024-09-05T09:15:45.987Z'),
|
||||
updated_at: new Date('2024-09-05T09:15:45.987Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 6,
|
||||
name: 'Fiona',
|
||||
email: 'fiona@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$fionaHash456$FionaPasswordHash789',
|
||||
created_at: new Date('2024-09-06T16:45:12.345Z'),
|
||||
updated_at: new Date('2024-09-06T16:45:12.345Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 7,
|
||||
name: 'George',
|
||||
email: 'george@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$georgeHash789$GeorgePasswordHash012',
|
||||
created_at: new Date('2024-09-07T11:30:25.876Z'),
|
||||
updated_at: new Date('2024-09-07T11:30:25.876Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 8,
|
||||
name: 'Helen',
|
||||
email: 'helen@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$helenHash012$HelenPasswordHash345',
|
||||
created_at: new Date('2024-09-08T13:25:40.234Z'),
|
||||
updated_at: new Date('2024-09-08T13:25:40.234Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 9,
|
||||
name: 'Ian',
|
||||
email: 'ian@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$ianHash345$IanPasswordHash678',
|
||||
created_at: new Date('2024-09-09T08:40:55.765Z'),
|
||||
updated_at: new Date('2024-09-09T08:40:55.765Z'),
|
||||
status: 'active'
|
||||
},
|
||||
{
|
||||
_id: 10,
|
||||
name: 'Julia',
|
||||
email: 'julia@example.com',
|
||||
password_hash: '$argon2id$v=19$m=65536,t=2,p=1$juliaHash678$JuliaPasswordHash901',
|
||||
created_at: new Date('2024-09-10T15:55:18.123Z'),
|
||||
updated_at: new Date('2024-09-10T15:55:18.123Z'),
|
||||
status: 'active'
|
||||
}
|
||||
]);
|
||||
|
||||
// Create messages collection with data
|
||||
db.messages.insertMany([
|
||||
// User 1 (Alice) - 3 messages
|
||||
{
|
||||
_id: 1,
|
||||
body: 'Hello everyone!',
|
||||
user_id: 1,
|
||||
created_at: new Date('2025-01-10T10:00:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T10:00:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 2,
|
||||
body: 'How is everyone doing today?',
|
||||
user_id: 1,
|
||||
created_at: new Date('2025-01-10T11:30:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T11:30:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 3,
|
||||
body: 'Great to see you all here!',
|
||||
user_id: 1,
|
||||
created_at: new Date('2025-01-10T14:15:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T14:15:00.000Z')
|
||||
},
|
||||
// User 2 (Bob) - 2 messages
|
||||
{
|
||||
_id: 4,
|
||||
body: 'Hi Alice! Doing well, thanks for asking.',
|
||||
user_id: 2,
|
||||
created_at: new Date('2025-01-10T11:35:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T11:35:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 5,
|
||||
body: 'Anyone up for a game later?',
|
||||
user_id: 2,
|
||||
created_at: new Date('2025-01-10T16:20:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T16:20:00.000Z')
|
||||
},
|
||||
// User 3 (Charlie) - 3 messages
|
||||
{
|
||||
_id: 6,
|
||||
body: 'Count me in for the game!',
|
||||
user_id: 3,
|
||||
created_at: new Date('2025-01-10T16:25:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T16:25:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 7,
|
||||
body: 'What time works for everyone?',
|
||||
user_id: 3,
|
||||
created_at: new Date('2025-01-10T16:30:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T16:30:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 8,
|
||||
body: 'I can play around 8 PM',
|
||||
user_id: 3,
|
||||
created_at: new Date('2025-01-10T17:00:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T17:00:00.000Z')
|
||||
},
|
||||
// User 4 (Diana) - 2 messages
|
||||
{
|
||||
_id: 9,
|
||||
body: '8 PM works for me too!',
|
||||
user_id: 4,
|
||||
created_at: new Date('2025-01-10T17:05:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T17:05:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 10,
|
||||
body: 'What game should we play?',
|
||||
user_id: 4,
|
||||
created_at: new Date('2025-01-10T17:10:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T17:10:00.000Z')
|
||||
},
|
||||
// User 5 (Evan) - 13 messages (including 10 additional ones)
|
||||
{
|
||||
_id: 11,
|
||||
body: 'I suggest we try the new arcade game!',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T17:15:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T17:15:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 12,
|
||||
body: 'It has great multiplayer features',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T17:20:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T17:20:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 13,
|
||||
body: 'Perfect timing for a weekend session',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T18:00:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T18:00:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 26,
|
||||
body: 'Just finished setting up the game server!',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:00:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:00:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 27,
|
||||
body: 'Everyone should be able to connect now',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:05:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:05:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 28,
|
||||
body: 'I added some custom maps too',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:10:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:10:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 29,
|
||||
body: 'The graphics look amazing on this new version',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:15:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:15:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 30,
|
||||
body: 'Hope you all enjoy the new features',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:20:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:20:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 31,
|
||||
body: 'I also set up a leaderboard system',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:25:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:25:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 32,
|
||||
body: 'We can track high scores now',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:30:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:30:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 33,
|
||||
body: 'The game supports up to 8 players simultaneously',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:35:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:35:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 34,
|
||||
body: 'I tested it earlier and it runs smoothly',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:40:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:40:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 35,
|
||||
body: 'Cannot wait to see everyone online tonight!',
|
||||
user_id: 5,
|
||||
created_at: new Date('2025-01-10T20:45:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T20:45:00.000Z')
|
||||
},
|
||||
// User 6 (Fiona) - 2 messages
|
||||
{
|
||||
_id: 14,
|
||||
body: 'Sounds like fun! I love arcade games.',
|
||||
user_id: 6,
|
||||
created_at: new Date('2025-01-10T18:05:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T18:05:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 15,
|
||||
body: 'Should I bring snacks?',
|
||||
user_id: 6,
|
||||
created_at: new Date('2025-01-10T18:10:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T18:10:00.000Z')
|
||||
},
|
||||
// User 7 (George) - 3 messages
|
||||
{
|
||||
_id: 16,
|
||||
body: 'Snacks are always welcome!',
|
||||
user_id: 7,
|
||||
created_at: new Date('2025-01-10T18:15:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T18:15:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 17,
|
||||
body: 'I can bring some drinks',
|
||||
user_id: 7,
|
||||
created_at: new Date('2025-01-10T18:20:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T18:20:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 18,
|
||||
body: 'This is going to be awesome',
|
||||
user_id: 7,
|
||||
created_at: new Date('2025-01-10T19:00:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:00:00.000Z')
|
||||
},
|
||||
// User 8 (Helen) - 2 messages
|
||||
{
|
||||
_id: 19,
|
||||
body: 'I agree! Cannot wait for the game night.',
|
||||
user_id: 8,
|
||||
created_at: new Date('2025-01-10T19:05:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:05:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 20,
|
||||
body: 'Should we set up a Discord call?',
|
||||
user_id: 8,
|
||||
created_at: new Date('2025-01-10T19:10:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:10:00.000Z')
|
||||
},
|
||||
// User 9 (Ian) - 3 messages
|
||||
{
|
||||
_id: 21,
|
||||
body: 'Discord would be perfect for voice chat',
|
||||
user_id: 9,
|
||||
created_at: new Date('2025-01-10T19:15:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:15:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 22,
|
||||
body: 'I will create a server for us',
|
||||
user_id: 9,
|
||||
created_at: new Date('2025-01-10T19:20:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:20:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 23,
|
||||
body: 'Link will be shared in a few minutes',
|
||||
user_id: 9,
|
||||
created_at: new Date('2025-01-10T19:25:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:25:00.000Z')
|
||||
},
|
||||
// User 10 (Julia) - 2 messages
|
||||
{
|
||||
_id: 24,
|
||||
body: 'Thanks Ian! You are the best.',
|
||||
user_id: 10,
|
||||
created_at: new Date('2025-01-10T19:30:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:30:00.000Z')
|
||||
},
|
||||
{
|
||||
_id: 25,
|
||||
body: 'See you all at 8 PM!',
|
||||
user_id: 10,
|
||||
created_at: new Date('2025-01-10T19:35:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:35:00.000Z')
|
||||
},{
|
||||
_id: 99,
|
||||
body: 'You are a mean jerk, you shithead!',
|
||||
user_id: 10,
|
||||
created_at: new Date('2025-01-10T19:35:00.000Z'),
|
||||
updated_at: new Date('2025-01-10T19:35:00.000Z')
|
||||
}
|
||||
]);
|
||||
|
||||
// Create indexes for better performance (equivalent to PostgreSQL indexes)
|
||||
db.users.createIndex({ "name": 1 }, { unique: true });
|
||||
db.users.createIndex({ "email": 1 }, { unique: true });
|
||||
db.messages.createIndex({ "user_id": 1 });
|
||||
db.messages.createIndex({ "created_at": 1 });
|
||||
|
||||
print("MongoDB test data setup completed successfully!");
|
||||
print("Users collection: " + db.users.countDocuments());
|
||||
print("Messages collection: " + db.messages.countDocuments());
|
||||
222
toolkits/mongodb/tests/test_json_validation.py
Normal file
222
toolkits/mongodb/tests/test_json_validation.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import pytest
|
||||
from arcade_core.errors import ToolExecutionError
|
||||
from arcade_mongodb.tools.mongodb import aggregate_documents, count_documents, find_documents
|
||||
from arcade_tdk import ToolContext, ToolSecretItem
|
||||
from arcade_tdk.errors import RetryableToolError
|
||||
|
||||
from .conftest import TEST_MONGODB_CONNECTION_STRING
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
context = ToolContext()
|
||||
context.secrets = []
|
||||
context.secrets.append(
|
||||
ToolSecretItem(key="MONGODB_CONNECTION_STRING", value=TEST_MONGODB_CONNECTION_STRING)
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_in_filter_dict(mock_context) -> None:
|
||||
"""Test that invalid JSON in filter_dict returns a reasonable error message."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"status": "active",}', # Invalid JSON - trailing comma
|
||||
limit=1,
|
||||
)
|
||||
|
||||
# Check that this is a JSON validation error
|
||||
error_message = str(exc_info.value)
|
||||
assert "Invalid JSON in filter_dict" in error_message
|
||||
|
||||
# Check that the developer message contains helpful information
|
||||
assert "filter_dict" in exc_info.value.developer_message
|
||||
assert "JSON" in exc_info.value.additional_prompt_content
|
||||
|
||||
# Check that the original JSON error is in the cause chain
|
||||
assert exc_info.value.__cause__ is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_in_projection(mock_context) -> None:
|
||||
"""Test that invalid JSON in projection returns a reasonable error message."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
projection='{"name": 1, "email": 1,}', # Invalid JSON - trailing comma
|
||||
limit=1,
|
||||
)
|
||||
|
||||
# Check that this is a JSON validation error
|
||||
error_message = str(exc_info.value)
|
||||
assert "Invalid JSON in projection" in error_message
|
||||
|
||||
# Check that the error message is helpful
|
||||
assert "projection" in exc_info.value.developer_message
|
||||
assert "JSON" in exc_info.value.additional_prompt_content
|
||||
|
||||
# Check that the original JSON error is in the cause chain
|
||||
assert exc_info.value.__cause__ is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_in_sort(mock_context) -> None:
|
||||
"""Test that invalid JSON in sort returns a reasonable error message."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
sort=['{"field": "name", "direction": 1,}'], # Invalid JSON - trailing comma
|
||||
limit=1,
|
||||
)
|
||||
|
||||
# Check that this is a JSON validation error
|
||||
error_message = str(exc_info.value)
|
||||
assert "Invalid JSON in sort" in error_message
|
||||
|
||||
# Check that the error message is helpful
|
||||
assert "sort" in exc_info.value.developer_message
|
||||
assert "JSON" in exc_info.value.additional_prompt_content
|
||||
|
||||
# Check that the original JSON error is in the cause chain
|
||||
assert exc_info.value.__cause__ is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_in_count_filter(mock_context) -> None:
|
||||
"""Test that invalid JSON in count_documents filter returns a reasonable error message."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await count_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"status": "active",}', # Invalid JSON - trailing comma
|
||||
)
|
||||
|
||||
# Check that this is a JSON validation error
|
||||
error_message = str(exc_info.value)
|
||||
assert "Invalid JSON in filter_dict" in error_message
|
||||
|
||||
# Check that the error message is helpful
|
||||
assert "filter_dict" in exc_info.value.developer_message
|
||||
assert "JSON" in exc_info.value.additional_prompt_content
|
||||
|
||||
# Check that the original JSON error is in the cause chain
|
||||
assert exc_info.value.__cause__ is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_in_pipeline(mock_context) -> None:
|
||||
"""Test that invalid JSON in aggregation pipeline returns a reasonable error message."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await aggregate_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
pipeline=['{"$match": {"status": "active",}}'], # Invalid JSON - trailing comma
|
||||
)
|
||||
|
||||
# Check that this is a JSON validation error
|
||||
error_message = str(exc_info.value)
|
||||
assert "Invalid JSON in pipeline" in error_message
|
||||
|
||||
# Check that the error message is helpful
|
||||
assert "pipeline" in exc_info.value.developer_message
|
||||
assert "JSON" in exc_info.value.additional_prompt_content
|
||||
|
||||
# Check that the original JSON error is in the cause chain
|
||||
assert exc_info.value.__cause__ is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_json_string(mock_context) -> None:
|
||||
"""Test various malformed JSON strings return reasonable error messages."""
|
||||
test_cases = [
|
||||
('{"unclosed": "string}', "Unterminated string"),
|
||||
('{"missing_quotes": value}', "Expecting"),
|
||||
('{missing_closing_brace: "value"}', "Expecting"),
|
||||
('[{"array": "with"}, {"missing": }]', "Expecting"),
|
||||
]
|
||||
|
||||
for invalid_json, expected_error_fragment in test_cases:
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict=invalid_json,
|
||||
limit=1,
|
||||
)
|
||||
|
||||
# Check that this is a JSON validation error
|
||||
error_message = str(exc_info.value)
|
||||
assert "Invalid JSON in filter_dict" in error_message
|
||||
|
||||
# Check that specific error details are included when expected
|
||||
if expected_error_fragment:
|
||||
assert (
|
||||
expected_error_fragment in error_message
|
||||
or expected_error_fragment in exc_info.value.developer_message
|
||||
)
|
||||
|
||||
# Ensure helpful context is provided
|
||||
assert "filter_dict" in exc_info.value.developer_message
|
||||
assert "JSON" in exc_info.value.additional_prompt_content
|
||||
assert "escaping" in exc_info.value.additional_prompt_content
|
||||
|
||||
# Check that the original JSON error is in the cause chain
|
||||
assert exc_info.value.__cause__ is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_json_does_not_error(mock_context) -> None:
|
||||
"""Test that valid JSON does not raise JSON parsing errors."""
|
||||
# This should not raise a JSON parsing error (might raise other errors, but not JSON-related)
|
||||
try:
|
||||
result = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"status": "active"}',
|
||||
projection='{"name": 1, "_id": 0}',
|
||||
sort=['{"field": "name", "direction": 1}'],
|
||||
limit=1,
|
||||
)
|
||||
# If we get here, JSON parsing succeeded
|
||||
assert isinstance(result, list)
|
||||
except (ToolExecutionError, RetryableToolError) as e:
|
||||
# If we get an error, it should not be about JSON parsing
|
||||
# Check both the outer error and any nested error
|
||||
error_message = str(e)
|
||||
nested_message = str(e.__cause__) if e.__cause__ else ""
|
||||
assert "Invalid JSON" not in error_message
|
||||
assert "Invalid JSON" not in nested_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_keys_are_valid_json(mock_context) -> None:
|
||||
"""Test that duplicate keys in JSON are valid (Python JSON allows this)."""
|
||||
# This should NOT raise a JSON parsing error because duplicate keys are valid JSON
|
||||
try:
|
||||
result = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"duplicate": "key", "duplicate": "key"}', # Valid JSON - last value wins
|
||||
limit=1,
|
||||
)
|
||||
# If we get here, JSON parsing succeeded (might get empty results, but no JSON error)
|
||||
assert isinstance(result, list)
|
||||
except (ToolExecutionError, RetryableToolError) as e:
|
||||
# If we get an error, it should not be about JSON parsing
|
||||
error_message = str(e)
|
||||
nested_message = str(e.__cause__) if e.__cause__ else ""
|
||||
assert "Invalid JSON" not in error_message
|
||||
assert "Invalid JSON" not in nested_message
|
||||
294
toolkits/mongodb/tests/test_mongodb.py
Normal file
294
toolkits/mongodb/tests/test_mongodb.py
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
from arcade_mongodb.database_engine import DatabaseEngine
|
||||
from arcade_mongodb.tools.mongodb import (
|
||||
# UserStatus,
|
||||
aggregate_documents,
|
||||
count_documents,
|
||||
discover_collections,
|
||||
discover_databases,
|
||||
find_documents,
|
||||
get_collection_schema,
|
||||
# update_user_status,
|
||||
)
|
||||
from arcade_tdk import ToolContext, ToolSecretItem
|
||||
from arcade_tdk.errors import RetryableToolError
|
||||
|
||||
from .conftest import TEST_MONGODB_CONNECTION_STRING
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
context = ToolContext()
|
||||
context.secrets = []
|
||||
context.secrets.append(
|
||||
ToolSecretItem(key="MONGODB_CONNECTION_STRING", value=TEST_MONGODB_CONNECTION_STRING)
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_databases(mock_context) -> None:
|
||||
databases = await discover_databases(mock_context)
|
||||
assert isinstance(databases, list)
|
||||
# Should not include system databases like admin, config, local
|
||||
for db in databases:
|
||||
assert db not in ["admin", "config", "local"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_collections(mock_context) -> None:
|
||||
collections = await discover_collections(mock_context, "test_database")
|
||||
assert "users" in collections
|
||||
assert "messages" in collections
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_collection_schema(mock_context) -> None:
|
||||
schema_result = await get_collection_schema(
|
||||
mock_context, "test_database", "users", sample_size=10
|
||||
)
|
||||
|
||||
assert "schema" in schema_result
|
||||
assert "total_documents_sampled" in schema_result
|
||||
assert schema_result["total_documents_sampled"] == 10 # We have 10 users
|
||||
|
||||
schema = schema_result["schema"]
|
||||
assert "_id" in schema
|
||||
assert "name" in schema
|
||||
assert "email" in schema
|
||||
assert "password_hash" in schema
|
||||
assert "status" in schema
|
||||
assert "created_at" in schema
|
||||
assert "updated_at" in schema
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_documents_basic(mock_context) -> None:
|
||||
# Find all users
|
||||
result = await find_documents(
|
||||
mock_context, database_name="test_database", collection_name="users", limit=10
|
||||
)
|
||||
|
||||
assert len(result) == 10
|
||||
# Parse JSON strings to check contents
|
||||
docs = [json.loads(doc_str) for doc_str in result]
|
||||
assert all("name" in doc for doc in docs)
|
||||
assert all("email" in doc for doc in docs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_documents_with_filter(mock_context) -> None:
|
||||
# Find active users
|
||||
result = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"status": "active"}',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert len(result) == 10 # All users in dump are active
|
||||
docs = [json.loads(doc_str) for doc_str in result]
|
||||
assert all(doc["status"] == "active" for doc in docs)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_documents_with_projection(mock_context) -> None:
|
||||
# Find users with only name and email
|
||||
result = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
projection='{"name": 1, "email": 1, "_id": 0}',
|
||||
limit=10,
|
||||
)
|
||||
|
||||
assert len(result) == 10
|
||||
docs = [json.loads(doc_str) for doc_str in result]
|
||||
for doc in docs:
|
||||
assert "name" in doc
|
||||
assert "email" in doc
|
||||
assert "_id" not in doc
|
||||
assert "password_hash" not in doc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_documents_with_sort(mock_context) -> None:
|
||||
# Find users sorted by _id descending
|
||||
result = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
sort=['{"field": "_id", "direction": -1}'],
|
||||
limit=3,
|
||||
)
|
||||
|
||||
assert len(result) == 3
|
||||
docs = [json.loads(doc_str) for doc_str in result]
|
||||
ids = [doc["_id"] for doc in docs]
|
||||
assert ids == [10, 9, 8] # Descending order
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_documents(mock_context) -> None:
|
||||
# Count all users
|
||||
count = await count_documents(
|
||||
mock_context, database_name="test_database", collection_name="users"
|
||||
)
|
||||
assert count == 10
|
||||
|
||||
# Count active users
|
||||
active_count = await count_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"status": "active"}',
|
||||
)
|
||||
assert active_count == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregate_documents(mock_context) -> None:
|
||||
# Aggregate to count users by status
|
||||
pipeline = ['{"$group": {"_id": "$status", "count": {"$sum": 1}}}', '{"$sort": {"count": -1}}']
|
||||
|
||||
result = await aggregate_documents(
|
||||
mock_context, database_name="test_database", collection_name="users", pipeline=pipeline
|
||||
)
|
||||
|
||||
assert len(result) == 1 # Only active users
|
||||
# Should be sorted by count descending
|
||||
doc = json.loads(result[0])
|
||||
assert doc["_id"] == "active"
|
||||
assert doc["count"] == 10
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_documents_with_skip_and_limit(mock_context) -> None:
|
||||
# Test pagination
|
||||
result1 = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
sort=['{"field": "name", "direction": 1}'],
|
||||
limit=2,
|
||||
skip=0,
|
||||
)
|
||||
|
||||
result2 = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
sort=['{"field": "name", "direction": 1}'],
|
||||
limit=2,
|
||||
skip=2,
|
||||
)
|
||||
|
||||
assert len(result1) == 2
|
||||
assert len(result2) == 2
|
||||
|
||||
docs1 = [json.loads(doc_str) for doc_str in result1]
|
||||
docs2 = [json.loads(doc_str) for doc_str in result2]
|
||||
|
||||
assert docs1[0]["name"] == "Alice"
|
||||
assert docs1[1]["name"] == "Bob"
|
||||
assert docs2[0]["name"] == "Charlie"
|
||||
assert docs2[1]["name"] == "Diana"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling_invalid_database(mock_context) -> None:
|
||||
# Test with non-existent database - should not error but return empty results
|
||||
collections = await discover_collections(mock_context, "nonexistent_database")
|
||||
assert collections == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_error_handling_invalid_collection(mock_context) -> None:
|
||||
# Test with non-existent collection
|
||||
result = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="nonexistent_collection",
|
||||
limit=10,
|
||||
)
|
||||
assert result == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sanitize_query_params() -> None:
|
||||
# Test parameter validation
|
||||
with pytest.raises(RetryableToolError) as e:
|
||||
DatabaseEngine.sanitize_query_params("", "users", {}, None, None, 10, 0)
|
||||
assert "Database name is required" in str(e.value)
|
||||
|
||||
with pytest.raises(RetryableToolError) as e:
|
||||
DatabaseEngine.sanitize_query_params("test_db", "", {}, None, None, 10, 0)
|
||||
assert "Collection name is required" in str(e.value)
|
||||
|
||||
with pytest.raises(RetryableToolError) as e:
|
||||
DatabaseEngine.sanitize_query_params(
|
||||
"test_db", "users", {}, None, None, 2000, 0
|
||||
) # Too high limit
|
||||
assert "Limit is too high" in str(e.value)
|
||||
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_update_user_status_success(mock_context) -> None:
|
||||
# """Test successful user status update."""
|
||||
# # First, find a user to update
|
||||
# users = await find_documents(
|
||||
# mock_context, database_name="test_database", collection_name="users", limit=1
|
||||
# )
|
||||
# assert len(users) > 0
|
||||
|
||||
# user_doc = json.loads(users[0])
|
||||
# user_id = user_doc["_id"]
|
||||
|
||||
# # Update user status to inactive
|
||||
# result = await update_user_status(
|
||||
# mock_context,
|
||||
# database_name="test_database",
|
||||
# collection_name="users",
|
||||
# user_id=user_id,
|
||||
# status=UserStatus.INACTIVE,
|
||||
# )
|
||||
|
||||
# assert result["success"] is True
|
||||
# assert result["user_id"] == user_id
|
||||
# assert result["new_status"] == "inactive"
|
||||
# assert result["matched_count"] == 1
|
||||
# assert result["modified_count"] == 1
|
||||
|
||||
# # Verify the update by finding the user again
|
||||
# # Convert user_id to int since the test data uses integer IDs
|
||||
# user_id_int = int(user_id)
|
||||
# updated_users = await find_documents(
|
||||
# mock_context,
|
||||
# database_name="test_database",
|
||||
# collection_name="users",
|
||||
# filter_dict=f'{{"_id": {user_id_int}}}',
|
||||
# limit=1,
|
||||
# )
|
||||
# assert len(updated_users) == 1
|
||||
# updated_user = json.loads(updated_users[0])
|
||||
# assert updated_user["status"] == "inactive"
|
||||
|
||||
|
||||
# @pytest.mark.asyncio
|
||||
# async def test_update_user_status_user_not_found(mock_context) -> None:
|
||||
# """Test updating status for non-existent user."""
|
||||
# result = await update_user_status(
|
||||
# mock_context,
|
||||
# database_name="test_database",
|
||||
# collection_name="users",
|
||||
# user_id="nonexistent_user_id",
|
||||
# status=UserStatus.BANNED,
|
||||
# )
|
||||
|
||||
# assert result["success"] is False
|
||||
# assert "No user found with _id" in result["message"]
|
||||
# assert result["matched_count"] == 0
|
||||
# assert result["modified_count"] == 0
|
||||
12
toolkits/mongodb/tests/test_setup.sh
Executable file
12
toolkits/mongodb/tests/test_setup.sh
Executable file
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
|
||||
# install mongosh to load sample data
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y wget gnupg
|
||||
wget -qO - https://www.mongodb.org/static/pgp/server-6.0.asc | sudo apt-key add -
|
||||
echo "deb [ arch=amd64,arm64 ] https://repo.mongodb.org/apt/ubuntu jammy/mongodb-org/6.0 multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-6.0.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mongodb-mongosh
|
||||
|
||||
# Run mongodb container
|
||||
docker run -d --name some-mongodb-server -p 27017:27017 mongo
|
||||
249
toolkits/mongodb/tests/test_write_validation.py
Normal file
249
toolkits/mongodb/tests/test_write_validation.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import pytest
|
||||
from arcade_mongodb.tools.mongodb import aggregate_documents, count_documents, find_documents
|
||||
from arcade_tdk import ToolContext, ToolSecretItem
|
||||
from arcade_tdk.errors import RetryableToolError
|
||||
|
||||
from .conftest import TEST_MONGODB_CONNECTION_STRING
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context():
|
||||
context = ToolContext()
|
||||
context.secrets = []
|
||||
context.secrets.append(
|
||||
ToolSecretItem(key="MONGODB_CONNECTION_STRING", value=TEST_MONGODB_CONNECTION_STRING)
|
||||
)
|
||||
return context
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_dict_blocks_set_operation(mock_context) -> None:
|
||||
"""Test that $set operation in filter_dict is blocked."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"$set": {"status": "modified"}}', # Write operation
|
||||
limit=1,
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Write operation '$set' not allowed in filter_dict" in error_message
|
||||
assert "$set" in exc_info.value.developer_message
|
||||
assert "Only read operations are allowed" in exc_info.value.developer_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_dict_blocks_update_operations(mock_context) -> None:
|
||||
"""Test that various update operations in filter_dict are blocked."""
|
||||
update_ops = ["$inc", "$unset", "$push", "$pull", "$rename", "$currentDate"]
|
||||
|
||||
for op in update_ops:
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict=f'{{"{op}": {{"field": "value"}}}}',
|
||||
limit=1,
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert f"Write operation '{op}' not allowed in filter_dict" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_projection_blocks_write_operations(mock_context) -> None:
|
||||
"""Test that write operations in projection are blocked."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
projection='{"$set": {"modified": true}, "name": 1}', # Write operation in projection
|
||||
limit=1,
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Write operation '$set' not allowed in projection" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sort_blocks_write_operations(mock_context) -> None:
|
||||
"""Test that write operations in sort are blocked."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
sort=['{"field": "name", "direction": 1, "$inc": {"counter": 1}}'], # Write op in sort
|
||||
limit=1,
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Write operation '$inc' not allowed in sort[0]" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_filter_blocks_write_operations(mock_context) -> None:
|
||||
"""Test that write operations in count filter are blocked."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await count_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"status": "active", "$unset": {"password": ""}}', # Write operation
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Write operation '$unset' not allowed in filter_dict" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregation_pipeline_blocks_out_stage(mock_context) -> None:
|
||||
"""Test that $out stage in aggregation pipeline is blocked."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await aggregate_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
pipeline=[
|
||||
'{"$match": {"status": "active"}}',
|
||||
'{"$out": "output_collection"}', # Write stage
|
||||
],
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Write stage '$out' not allowed in pipeline" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregation_pipeline_blocks_merge_stage(mock_context) -> None:
|
||||
"""Test that $merge stage in aggregation pipeline is blocked."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await aggregate_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
pipeline=[
|
||||
'{"$match": {"status": "active"}}',
|
||||
'{"$merge": {"into": "target_collection"}}', # Write stage
|
||||
],
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Write stage '$merge' not allowed in pipeline" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_where_operator_blocked(mock_context) -> None:
|
||||
"""Test that $where operator is blocked for security reasons."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"$where": "this.name == \'admin\'"}', # JavaScript execution
|
||||
limit=1,
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "JavaScript execution operator '$where' not allowed in filter_dict" in error_message
|
||||
assert (
|
||||
"JavaScript execution is not allowed for security reasons"
|
||||
in exc_info.value.developer_message
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_nested_write_operations_blocked(mock_context) -> None:
|
||||
"""Test that nested write operations are blocked."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"status": "active", "nested": {"$set": {"field": "value"}}}', # Nested write op
|
||||
limit=1,
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Write operation '$set' not allowed in filter_dict" in error_message
|
||||
assert "nested.$set" in exc_info.value.developer_message # Should show the path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_read_operations_allowed(mock_context) -> None:
|
||||
"""Test that valid read operations are allowed."""
|
||||
# These should not raise write operation errors
|
||||
try:
|
||||
# Test query operators
|
||||
result = await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict='{"status": {"$in": ["active", "inactive"]}, "name": {"$regex": "^A"}}',
|
||||
projection='{"name": 1, "email": 1, "_id": 0}',
|
||||
sort=['{"field": "name", "direction": 1}'],
|
||||
limit=1,
|
||||
)
|
||||
assert isinstance(result, list)
|
||||
|
||||
# Test aggregation pipeline with read-only stages
|
||||
pipeline_result = await aggregate_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
pipeline=[
|
||||
'{"$match": {"status": "active"}}',
|
||||
'{"$group": {"_id": "$status", "count": {"$sum": 1}}}',
|
||||
'{"$sort": {"count": -1}}',
|
||||
],
|
||||
)
|
||||
assert isinstance(pipeline_result, list)
|
||||
|
||||
except RetryableToolError as e:
|
||||
# If we get an error, it should not be about write operations
|
||||
error_message = str(e)
|
||||
nested_message = str(e.__cause__) if e.__cause__ else ""
|
||||
assert "Write operation" not in error_message
|
||||
assert "Write stage" not in error_message
|
||||
assert "Write operation" not in nested_message
|
||||
assert "Write stage" not in nested_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_array_write_operations_blocked(mock_context) -> None:
|
||||
"""Test that array write operations are blocked."""
|
||||
array_write_ops = ["$addToSet", "$pop", "$pull", "$push", "$pullAll"]
|
||||
|
||||
for op in array_write_ops:
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await find_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
filter_dict=f'{{"{op}": {{"tags": "new_tag"}}}}',
|
||||
limit=1,
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert f"Write operation '{op}' not allowed in filter_dict" in error_message
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aggregation_stage_content_validated(mock_context) -> None:
|
||||
"""Test that content within aggregation stages is also validated for write operations."""
|
||||
with pytest.raises(RetryableToolError) as exc_info:
|
||||
await aggregate_documents(
|
||||
mock_context,
|
||||
database_name="test_database",
|
||||
collection_name="users",
|
||||
pipeline=[
|
||||
'{"$match": {"status": "active", "$set": {"modified": true}}}' # Write op inside $match
|
||||
],
|
||||
)
|
||||
|
||||
error_message = str(exc_info.value)
|
||||
assert "Write operation '$set' not allowed in pipeline[0].$match" in error_message
|
||||
Loading…
Reference in a new issue