修改为东南天坐标系

This commit is contained in:
2026-01-20 09:49:52 +08:00
parent 9538757047
commit 333fad40ac
7201 changed files with 1030888 additions and 85410 deletions

View File

@@ -0,0 +1,282 @@
from time import sleep
import threading
from chromadb.api import ClientAPI
from chromadb.api.types import IndexingStatus
from chromadb.test.conftest import skip_if_not_cluster
from chromadb.test.utils.wait_for_version_increase import (
get_collection_version,
wait_for_version_increase,
)
@skip_if_not_cluster()
def test_indexing_status_empty_collection(client: ClientAPI) -> None:
"""Test indexing status on empty collection"""
client.reset()
collection = client.create_collection(name="test_collection")
status = collection.get_indexing_status()
assert isinstance(status, IndexingStatus)
assert status.num_indexed_ops == 0
assert status.num_unindexed_ops == 0
assert status.total_ops == 0
assert status.op_indexing_progress == 1.0
@skip_if_not_cluster()
def test_indexing_status_after_add(client: ClientAPI) -> None:
"""Test indexing status after adding embeddings"""
client.reset()
collection = client.create_collection(name="test_collection")
ids = [f"id_{i}" for i in range(300)]
embeddings = [[float(i), float(i + 1), float(i + 2)] for i in range(300)]
initial_version = get_collection_version(client, collection.name)
collection.add(ids=ids, embeddings=embeddings) # type: ignore
status = collection.get_indexing_status()
assert status.total_ops == 300
if initial_version == get_collection_version(client, collection.name):
assert isinstance(status, IndexingStatus)
assert status.num_unindexed_ops == 300
assert status.num_indexed_ops == 0
assert status.op_indexing_progress == 0.0
wait_for_version_increase(client, collection.name, initial_version)
# Give some time to invalidate the frontend query cache
sleep(60)
# Check status after indexing completes
final_status = collection.get_indexing_status()
assert isinstance(final_status, IndexingStatus)
assert final_status.num_indexed_ops == 300
assert final_status.num_unindexed_ops == 0
assert final_status.op_indexing_progress == 1.0
@skip_if_not_cluster()
def test_indexing_status_after_upsert(client: ClientAPI) -> None:
"""Test indexing status after upsert operations"""
client.reset()
collection = client.create_collection(name="test_collection")
initial_version = get_collection_version(client, collection.name)
collection.upsert(ids=["id1", "id2"], embeddings=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) # type: ignore
status = collection.get_indexing_status()
assert status.total_ops == 2
if initial_version == get_collection_version(client, collection.name):
assert isinstance(status, IndexingStatus)
assert status.num_unindexed_ops == 2
assert status.num_indexed_ops == 0
assert status.op_indexing_progress == 0.0
wait_for_version_increase(client, collection.name, initial_version)
sleep(60)
collection.upsert(ids=["id1", "id3"], embeddings=[[1.1, 2.1, 3.1], [7.0, 8.0, 9.0]]) # type: ignore
status = collection.get_indexing_status()
assert status.total_ops == 4
@skip_if_not_cluster()
def test_indexing_status_after_delete(client: ClientAPI) -> None:
"""Test indexing status after delete operations"""
client.reset()
collection = client.create_collection(name="test_collection")
initial_version = get_collection_version(client, collection.name)
collection.add(
ids=["id1", "id2", "id3"],
embeddings=[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]], # type: ignore
)
if initial_version == get_collection_version(client, collection.name):
status = collection.get_indexing_status()
assert isinstance(status, IndexingStatus)
assert status.num_unindexed_ops == 3
assert status.num_indexed_ops == 0
assert status.op_indexing_progress == 0.0
wait_for_version_increase(client, collection.name, initial_version)
sleep(60)
initial_status = collection.get_indexing_status()
assert initial_status.total_ops == 3
collection.delete(ids=["id1", "id2"])
# Delete adds operations to the log, so total_ops increases
status_after_delete = collection.get_indexing_status()
assert status_after_delete.total_ops == 5
@skip_if_not_cluster()
def test_indexing_status_field_types(client: ClientAPI) -> None:
"""Test that indexing status returns correct field types"""
client.reset()
collection = client.create_collection(name="field_types_collection")
initial_version = get_collection_version(client, collection.name)
collection.add(ids=["type_test_id"], embeddings=[[1.0, 2.0, 3.0]]) # type: ignore
status = collection.get_indexing_status()
if initial_version == get_collection_version(client, collection.name):
assert isinstance(status, IndexingStatus)
assert status.num_unindexed_ops == 1
assert status.num_indexed_ops == 0
assert status.op_indexing_progress == 0.0
wait_for_version_increase(client, collection.name, initial_version)
sleep(60)
final_status = collection.get_indexing_status()
assert isinstance(final_status.num_indexed_ops, int)
assert isinstance(final_status.num_unindexed_ops, int)
assert isinstance(final_status.total_ops, int)
assert isinstance(final_status.op_indexing_progress, float)
assert final_status.num_indexed_ops >= 0
assert final_status.num_unindexed_ops >= 0
assert final_status.total_ops >= 0
assert 0.0 <= final_status.op_indexing_progress <= 1.0
@skip_if_not_cluster()
def test_indexing_status_batch_progression(client: ClientAPI) -> None:
"""Test indexing status with 2000 records based on index version progression"""
client.reset()
collection = client.create_collection(name="batch_test_collection")
get_collection_version(client, collection.name)
# Insert 2000 records in two batches of 1000 (max batch size)
ids_1 = [f"batch_id_{i}" for i in range(1000)]
embeddings_1 = [[float(i), float(i + 1), float(i + 2)] for i in range(1000)]
collection.add(ids=ids_1, embeddings=embeddings_1) # type: ignore
ids_2 = [f"batch_id_{i}" for i in range(1000, 2000)]
embeddings_2 = [[float(i), float(i + 1), float(i + 2)] for i in range(1000, 2000)]
collection.add(ids=ids_2, embeddings=embeddings_2) # type: ignore
current_version = get_collection_version(client, collection.name)
allowed_statuses = [
IndexingStatus(
num_indexed_ops=0,
num_unindexed_ops=2000,
total_ops=2000,
op_indexing_progress=0.0,
),
IndexingStatus(
num_indexed_ops=1000,
num_unindexed_ops=1000,
total_ops=2000,
op_indexing_progress=0.5,
),
IndexingStatus(
num_indexed_ops=2000,
num_unindexed_ops=0,
total_ops=2000,
op_indexing_progress=1.0,
),
]
ops_indexed = 0
while ops_indexed < 2000:
status = collection.get_indexing_status()
assert status in allowed_statuses
print("witnessed status: ", status)
ops_indexed = status.num_indexed_ops
wait_for_version_increase(client, collection.name, current_version)
sleep(60)
@skip_if_not_cluster()
def test_indexing_status_not_found(client: ClientAPI) -> None:
"""Test indexing status on non-existent collection"""
client.reset()
collection = client.create_collection(name="temp_collection")
client.delete_collection("temp_collection")
try:
collection.get_indexing_status()
assert False, "Expected exception for non-existent collection"
except Exception as e:
assert (
"not found" in str(e).lower()
or "does not exist" in str(e).lower()
or "soft deleted" in str(e).lower()
or "collection not found" in str(e).lower()
)
@skip_if_not_cluster()
def test_indexing_status_concurrent_progress_variation(client: ClientAPI) -> None:
"""Test that progress values vary during concurrent operations"""
client.reset()
collection = client.create_collection(name="concurrent_test_collection")
progress_values = []
stop_monitoring = threading.Event()
def progress_monitor() -> None:
"""Thread that continuously monitors indexing progress"""
while not stop_monitoring.is_set():
try:
status = collection.get_indexing_status()
progress_values.append(status.op_indexing_progress)
sleep(0.1) # Poll every 100ms
except Exception:
break
def record_adder() -> None:
"""Thread that adds records one at a time"""
for i in range(50): # Add 50 records one by one
collection.add(
ids=[f"concurrent_id_{i}"],
embeddings=[[float(i), float(i + 1), float(i + 2)]], # type: ignore
)
sleep(0.05) # Small delay between additions
# Start both threads
monitor_thread = threading.Thread(target=progress_monitor)
adder_thread = threading.Thread(target=record_adder)
monitor_thread.start()
adder_thread.start()
# Wait for record addition to complete
adder_thread.join()
# Give a moment for final progress updates
sleep(1.0)
# Stop monitoring and wait for thread to finish
stop_monitoring.set()
monitor_thread.join()
# Assert that we collected progress values
assert len(progress_values) > 0, "No progress values were collected"
# Assert that not all progress values are 0
non_zero_progress = [p for p in progress_values if p > 0.0]
assert len(non_zero_progress) > 0, "All progress values remained at 0"
# Assert that there's variation in progress values
unique_progress = set(progress_values)
assert (
len(unique_progress) > 1
), f"Expected variation in progress values, but only got: {unique_progress}"
print(f"Collected {len(progress_values)} progress values")
print(f"Unique progress values: {sorted(unique_progress)}")
print(f"Progress range: {min(progress_values):.3f} - {max(progress_values):.3f}")

View File

@@ -0,0 +1,102 @@
"""Tests for the Search API endpoint."""
from typing import Tuple
from uuid import uuid4
import pytest
from chromadb.api import ClientAPI
from chromadb.api.models.Collection import Collection
from chromadb.api.types import Embeddings, ReadLevel
from chromadb.execution.expression import Knn, Search
from chromadb.test.conftest import (
ClientFactories,
is_spann_disabled_mode,
skip_reason_spann_disabled,
)
def _create_test_collection(
client_factories: ClientFactories,
) -> Tuple[Collection, ClientAPI]:
"""Create a test collection with some data."""
client = client_factories.create_client_from_system()
client.reset()
collection_name = f"search_api_test_{uuid4().hex}"
collection = client.get_or_create_collection(name=collection_name)
return collection, client
@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
def test_search_with_read_level_index_and_wal(
client_factories: ClientFactories,
) -> None:
"""Test search with ReadLevel.INDEX_AND_WAL (default) returns results."""
collection, _ = _create_test_collection(client_factories)
# Add some data
collection.add(
ids=["doc1", "doc2", "doc3"],
documents=["apple fruit", "banana fruit", "car vehicle"],
embeddings=[[0.1, 0.2, 0.3, 0.4], [0.2, 0.3, 0.4, 0.5], [0.9, 0.8, 0.7, 0.6]],
)
# Search with explicit INDEX_AND_WAL (default behavior)
search = Search().rank(Knn(query=[0.1, 0.2, 0.3, 0.4], limit=10))
results = collection.search(search, read_level=ReadLevel.INDEX_AND_WAL)
assert results["ids"] is not None
assert len(results["ids"]) == 1
assert len(results["ids"][0]) > 0
@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
def test_search_with_read_level_index_only(
client_factories: ClientFactories,
) -> None:
"""Test search with ReadLevel.INDEX_ONLY returns results."""
collection, _ = _create_test_collection(client_factories)
# Add some data
collection.add(
ids=["doc1", "doc2", "doc3"],
documents=["apple fruit", "banana fruit", "car vehicle"],
embeddings=[[0.1, 0.2, 0.3, 0.4], [0.2, 0.3, 0.4, 0.5], [0.9, 0.8, 0.7, 0.6]],
)
# Search with INDEX_ONLY - this skips the WAL
# Note: Results may or may not include recent writes depending on compaction state
search = Search().rank(Knn(query=[0.1, 0.2, 0.3, 0.4], limit=10))
results = collection.search(search, read_level=ReadLevel.INDEX_ONLY)
# Just verify the API works and returns a valid response structure
assert results["ids"] is not None
assert len(results["ids"]) == 1
# Results may be empty if data hasn't been compacted yet, which is expected behavior
@pytest.mark.skipif(is_spann_disabled_mode, reason=skip_reason_spann_disabled)
def test_search_default_read_level(
client_factories: ClientFactories,
) -> None:
"""Test search without explicit read_level uses default (INDEX_AND_WAL)."""
collection, _ = _create_test_collection(client_factories)
# Add some data
collection.add(
ids=["doc1", "doc2"],
documents=["hello world", "goodbye world"],
embeddings=[[0.1, 0.2, 0.3, 0.4], [0.5, 0.6, 0.7, 0.8]],
)
# Search without specifying read_level (should use default)
search = Search().rank(Knn(query=[0.1, 0.2, 0.3, 0.4], limit=10))
results = collection.search(search)
# Should return results since default is INDEX_AND_WAL (full consistency)
assert results["ids"] is not None
assert len(results["ids"]) == 1
assert len(results["ids"][0]) > 0

View File

@@ -0,0 +1,180 @@
import pytest
from unittest.mock import MagicMock
from chromadb.api.shared_system_client import SharedSystemClient
from chromadb.api.base_http_client import BaseHTTPClient
from chromadb.config import System
from typing import Optional, Dict, Generator
@pytest.fixture(autouse=True)
def clear_cache() -> Generator[None, None, None]:
"""Automatically clear the system cache before and after each test."""
SharedSystemClient.clear_system_cache()
yield
SharedSystemClient.clear_system_cache()
def create_mock_http_client(
api_url: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> MagicMock:
"""Create a mock BaseHTTPClient instance with the specified configuration."""
mock_server_api = MagicMock(spec=BaseHTTPClient)
mock_server_api.get_api_url.return_value = api_url or ""
mock_server_api.get_request_headers.return_value = headers or {}
return mock_server_api
def register_mock_system(system_id: str, mock_server_api: MagicMock) -> MagicMock:
"""Register a mock system with the given ID and server API."""
mock_system = MagicMock(spec=System)
mock_system.instance.return_value = mock_server_api
SharedSystemClient._identifier_to_system[system_id] = mock_system
return mock_system
def test_extracts_api_key_from_chroma_cloud_client() -> None:
mock_server_api = create_mock_http_client(
api_url="https://api.trychroma.com/api/v2",
headers={"X-Chroma-Token": "test-api-key-123"},
)
register_mock_system("test-id", mock_server_api)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key == "test-api-key-123"
def test_extracts_api_key_with_lowercase_header() -> None:
mock_server_api = create_mock_http_client(
api_url="https://api.trychroma.com/api/v2",
headers={"x-chroma-token": "test-api-key-456"},
)
register_mock_system("test-id", mock_server_api)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key == "test-api-key-456"
def test_extracts_api_key_from_gcp_chroma_cloud_client() -> None:
mock_server_api = create_mock_http_client(
api_url="https://dummy.gcp.trychroma.com/api/v2",
headers={"X-Chroma-Token": "gcp-test-api-key"},
)
register_mock_system("test-id", mock_server_api)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key == "gcp-test-api-key"
def test_skips_non_chroma_cloud_clients() -> None:
mock_server_api = create_mock_http_client(
api_url="https://localhost:8000/api/v2",
headers={"X-Chroma-Token": "local-api-key"},
)
register_mock_system("test-id", mock_server_api)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key is None
def test_skips_clients_without_api_url() -> None:
mock_server_api = create_mock_http_client(
api_url=None,
headers={"X-Chroma-Token": "test-api-key"},
)
register_mock_system("test-id", mock_server_api)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key is None
def test_returns_none_when_no_api_key_in_headers() -> None:
mock_server_api = create_mock_http_client(
api_url="https://api.trychroma.com/api/v2",
headers={},
)
register_mock_system("test-id", mock_server_api)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key is None
def test_returns_first_api_key_found_from_multiple_clients() -> None:
mock_server_api_1 = create_mock_http_client(
api_url="https://api.trychroma.com/api/v2",
headers={"X-Chroma-Token": "first-key"},
)
mock_server_api_2 = create_mock_http_client(
api_url="https://api.trychroma.com/api/v2",
headers={"X-Chroma-Token": "second-key"},
)
register_mock_system("test-id-1", mock_server_api_1)
register_mock_system("test-id-2", mock_server_api_2)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key == "first-key"
def test_handles_exception_gracefully() -> None:
mock_system = MagicMock(spec=System)
mock_system.instance.side_effect = Exception("Test exception")
SharedSystemClient._identifier_to_system["test-id"] = mock_system
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key is None
def test_returns_none_when_no_clients_exist() -> None:
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key is None
def test_skips_non_http_clients() -> None:
"""Test that non-BaseHTTPClient instances are skipped."""
mock_server_api = MagicMock() # Not a BaseHTTPClient
register_mock_system("test-id", mock_server_api)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key is None
def test_extracts_api_key_with_mixed_case_header() -> None:
mock_server_api = create_mock_http_client(
api_url="https://api.trychroma.com/api/v2",
headers={"X-CHROMA-TOKEN": "mixed-case-key"},
)
register_mock_system("test-id", mock_server_api)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key == "mixed-case-key"
def test_multiple_clients_returns_one_key() -> None:
"""Test that multiple clients return one of the available keys."""
mock_api_1 = create_mock_http_client(
api_url="https://api.trychroma.com/api/v2",
headers={"X-Chroma-Token": "key-1"},
)
mock_api_2 = create_mock_http_client(
api_url="https://api.trychroma.com/api/v2",
headers={"X-Chroma-Token": "key-2"},
)
register_mock_system("id-1", mock_api_1)
register_mock_system("id-2", mock_api_2)
api_key = SharedSystemClient.get_chroma_cloud_api_key_from_clients()
assert api_key in ["key-1", "key-2"]

View File

@@ -0,0 +1,19 @@
import uuid
import pytest
from chromadb.api import ServerAPI
from chromadb.config import DEFAULT_TENANT
def test_delete_database_requires_rbac_permission(
api_with_authn_rbac_authz: ServerAPI,
) -> None:
database_name = f"db_{uuid.uuid4().hex}"
api_with_authn_rbac_authz.create_database(database_name, tenant=DEFAULT_TENANT)
with pytest.raises(Exception, match="Forbidden"):
api_with_authn_rbac_authz.delete_database(database_name, tenant=DEFAULT_TENANT)
db = api_with_authn_rbac_authz.get_database(database_name, tenant=DEFAULT_TENANT)
assert db["name"] == database_name

View File

@@ -0,0 +1,478 @@
"""
Integration test for the Collection statistics wrapper methods
"""
import json
import time
from typing import Any
import pytest
from chromadb.api.client import Client as ClientCreator
from chromadb.base_types import SparseVector
from chromadb.config import System
from chromadb.test.utils.wait_for_version_increase import (
get_collection_version,
wait_for_version_increase,
)
from chromadb.utils.statistics import (
attach_statistics_function,
detach_statistics_function,
get_statistics,
get_statistics_fn_name,
)
def test_statistics_wrapper(basic_http_client: System) -> None:
"""Test the statistics wrapper methods on Collection"""
client = ClientCreator.from_system(basic_http_client)
client.reset()
# Create a collection
collection = client.get_or_create_collection(
name="test_collection",
metadata={"description": "Test collection for statistics"},
)
# Enable statistics
attached_fn, created = attach_statistics_function(collection, "test_collection_statistics")
assert attached_fn is not None
assert created is True
assert attached_fn.function_name == "statistics"
assert attached_fn.output_collection == "test_collection_statistics"
initial_version = get_collection_version(client, collection.name)
# Add some documents with metadata
collection.add(
ids=["doc1", "doc2", "doc3"],
documents=["test document 1", "test document 2", "test document 3"],
metadatas=[
{"category": "A", "score": 10, "active": True},
{"category": "B", "score": 10, "active": False},
{"category": "A", "score": 20, "active": True},
],
)
# Wait for statistics to be computed
wait_for_version_increase(client, collection.name, initial_version)
time.sleep(60)
# Get statistics
stats = get_statistics(collection, "test_collection_statistics")
print("\nStatistics output:")
print(json.dumps(stats, indent=2))
# Verify the structure
assert "statistics" in stats
assert "summary" in stats
# Verify summary
assert stats["summary"]["total_count"] == 3
# Verify category statistics
assert "category" in stats["statistics"]
assert "A" in stats["statistics"]["category"]
assert "B" in stats["statistics"]["category"]
assert stats["statistics"]["category"]["A"]["count"] == 2
assert stats["statistics"]["category"]["B"]["count"] == 1
# Verify score statistics
assert "score" in stats["statistics"]
assert "10" in stats["statistics"]["score"]
assert "20" in stats["statistics"]["score"]
assert stats["statistics"]["score"]["10"]["count"] == 2
assert stats["statistics"]["score"]["20"]["count"] == 1
# Verify active statistics
assert "active" in stats["statistics"]
assert "true" in stats["statistics"]["active"]
assert "false" in stats["statistics"]["active"]
assert stats["statistics"]["active"]["true"]["count"] == 2
assert stats["statistics"]["active"]["false"]["count"] == 1
# Test get_attached_function
stats_fn = collection.get_attached_function(get_statistics_fn_name(collection))
assert stats_fn.function_name == "statistics"
# Disable statistics (keep the collection)
success = detach_statistics_function(collection, delete_stats_collection=False)
assert success is True
# Verify the statistics collection still exists
stats_collection = client.get_collection("test_collection_statistics")
assert stats_collection is not None
def test_backfill_statistics(basic_http_client: System) -> None:
"""Test backfill statistics"""
client = ClientCreator.from_system(basic_http_client)
client.reset()
collection = client.create_collection(name="my_collection")
initial_version = get_collection_version(client, collection.name)
# Add some documents with metadata
collection.add(
ids=["doc1", "doc2", "doc3"],
documents=["test document 1", "test document 2", "test document 3"],
metadatas=[
{"category": "A", "score": 10, "active": True},
{"category": "B", "score": 10, "active": False},
{"category": "A", "score": 20, "active": True},
],
)
# Let this all be compacted
wait_for_version_increase(client, collection.name, initial_version)
initial_version = get_collection_version(client, collection.name)
# Enable statistics
attached_fn, created = attach_statistics_function(collection, "my_collection_statistics")
assert created is True
assert attached_fn.function_name == "statistics"
assert attached_fn.output_collection == "my_collection_statistics"
# Wait for statistics to be computed
wait_for_version_increase(client, collection.name, initial_version)
stats = get_statistics(collection, "my_collection_statistics")
assert stats is not None
assert "statistics" in stats
assert "summary" in stats
# Verify summary
assert stats["summary"]["total_count"] == 3
# Verify category statistics
assert "category" in stats["statistics"]
assert "A" in stats["statistics"]["category"]
assert "B" in stats["statistics"]["category"]
assert stats["statistics"]["category"]["A"]["count"] == 2
assert stats["statistics"]["category"]["B"]["count"] == 1
# Verify score statistics
assert "score" in stats["statistics"]
assert "10" in stats["statistics"]["score"]
assert "20" in stats["statistics"]["score"]
assert stats["statistics"]["score"]["10"]["count"] == 2
assert stats["statistics"]["score"]["20"]["count"] == 1
# Verify active statistics
assert "active" in stats["statistics"]
assert "true" in stats["statistics"]["active"]
assert "false" in stats["statistics"]["active"]
assert stats["statistics"]["active"]["true"]["count"] == 2
assert stats["statistics"]["active"]["false"]["count"] == 1
# Disable statistics
success = detach_statistics_function(collection, delete_stats_collection=True)
assert success is True
def test_statistics_wrapper_custom_output_collection(basic_http_client: System) -> None:
"""Test statistics with custom output collection name"""
client = ClientCreator.from_system(basic_http_client)
client.reset()
collection = client.create_collection(name="my_collection")
# Enable statistics with custom output collection name
attached_fn, created = attach_statistics_function(
collection, stats_collection_name="my_custom_stats"
)
assert created is True
assert attached_fn.output_collection == "my_custom_stats"
initial_version = get_collection_version(client, collection.name)
# Add data
collection.add(
ids=["id1"],
documents=["doc1"],
metadatas=[{"key": "value"}],
)
wait_for_version_increase(client, collection.name, initial_version)
# Get statistics
stats = get_statistics(collection, "my_custom_stats")
assert "statistics" in stats
assert "key" in stats["statistics"]
# Disable and delete the custom collection
detach_statistics_function(collection, delete_stats_collection=True)
def test_statistics_wrapper_key_filter(basic_http_client: System) -> None:
"""Test get_statistics with key filter parameter"""
client = ClientCreator.from_system(basic_http_client)
client.reset()
collection = client.create_collection(name="key_filter_test")
# Enable statistics
_, created = attach_statistics_function(collection, "key_filter_test_statistics")
assert created is True
initial_version = get_collection_version(client, collection.name)
# Add documents with multiple metadata keys
collection.add(
ids=["doc1", "doc2", "doc3"],
documents=["test document 1", "test document 2", "test document 3"],
metadatas=[
{"category": "A", "score": 10, "active": True},
{"category": "B", "score": 10, "active": False},
{"category": "A", "score": 20, "active": True},
],
)
wait_for_version_increase(client, collection.name, initial_version)
time.sleep(60)
# Get all statistics (no key filter)
all_stats = get_statistics(collection, "key_filter_test_statistics")
assert "category" in all_stats["statistics"]
assert "score" in all_stats["statistics"]
assert "active" in all_stats["statistics"]
# Get statistics filtered by "category" key only
category_stats = get_statistics(
collection, "key_filter_test_statistics", keys=["category"]
)
assert "category" in category_stats["statistics"]
assert "score" not in category_stats["statistics"]
assert "active" not in category_stats["statistics"]
assert category_stats["statistics"]["category"]["A"]["count"] == 2
assert category_stats["statistics"]["category"]["B"]["count"] == 1
# Summary should still be present when filtering by key
assert "summary" in category_stats
assert category_stats["summary"]["total_count"] == 3
# Get statistics filtered by "score" key only
score_stats = get_statistics(
collection, "key_filter_test_statistics", keys=["score"]
)
assert "score" in score_stats["statistics"]
assert "category" not in score_stats["statistics"]
assert "active" not in score_stats["statistics"]
assert score_stats["statistics"]["score"]["10"]["count"] == 2
assert score_stats["statistics"]["score"]["20"]["count"] == 1
# Summary should still be present when filtering by key
assert "summary" in score_stats
assert score_stats["summary"]["total_count"] == 3
# Cleanup
detach_statistics_function(collection, delete_stats_collection=True)
def test_statistics_wrapper_key_filter_too_many_keys(basic_http_client: System) -> None:
"""Test that get_statistics raises ValueError when more than 30 keys are provided"""
client = ClientCreator.from_system(basic_http_client)
client.reset()
collection = client.create_collection(name="too_many_keys_test")
# Enable statistics
attach_statistics_function(collection, "too_many_keys_test_statistics")
# Generate more than 30 keys
too_many_keys = [f"key_{i}" for i in range(31)]
# Should raise ValueError when more than 30 keys are provided
with pytest.raises(ValueError) as exc_info:
get_statistics(collection, "too_many_keys_test_statistics", keys=too_many_keys)
assert "Too many keys provided: 31" in str(exc_info.value)
assert "Maximum allowed is 30" in str(exc_info.value)
# Cleanup
detach_statistics_function(collection, delete_stats_collection=True)
# commenting out for now as waiting for query cache invalidateion slows down the test suite
def test_statistics_wrapper_incremental_updates(basic_http_client: System) -> None:
"""Test that statistics are updated incrementally"""
client = ClientCreator.from_system(basic_http_client)
client.reset()
collection = client.create_collection(name="incremental_test")
_, created = attach_statistics_function(collection, "incremental_test_statistics")
assert created is True
initial_version = get_collection_version(client, collection.name)
# Add initial batch
collection.add(
ids=["id1", "id2"],
documents=["doc1", "doc2"],
metadatas=[{"category": "A"}, {"category": "A"}],
)
wait_for_version_increase(client, collection.name, initial_version)
next_version = get_collection_version(client, collection.name)
# Check initial statistics
stats = get_statistics(collection, "incremental_test_statistics")
assert stats["statistics"]["category"]["A"]["count"] == 2
assert stats["summary"]["total_count"] == 2
# Add more data
collection.add(
ids=["id3", "id4"],
documents=["doc3", "doc4"],
metadatas=[{"category": "B"}, {"category": "A"}],
)
wait_for_version_increase(client, collection.name, next_version)
# TODO(tanujnay112): Remove this sleep once query cache invalidation is solidified
# or figure out a different testing harness where we don't have to wait for query cache invalidation
time.sleep(70)
# Check updated statistics
stats = get_statistics(collection, "incremental_test_statistics")
assert stats["statistics"]["category"]["A"]["count"] == 3
assert stats["statistics"]["category"]["B"]["count"] == 1
assert stats["summary"]["total_count"] == 4
detach_statistics_function(collection, delete_stats_collection=True)
def test_sparse_vector_statistics(basic_http_client: System) -> None:
"""Test statistics with sparse vector that includes labels"""
client = ClientCreator.from_system(basic_http_client)
client.reset()
collection = client.create_collection(name="sparse_vector_test1")
# Create sparse vectors with labels
sparse_vec1 = SparseVector(
indices=[100, 200, 300],
values=[1.0, 2.0, 3.0],
labels=["apple", "banana", "cherry"],
)
sparse_vec2 = SparseVector(
indices=[100, 400], values=[1.5, 2.5], labels=["apple", "date"]
)
sparse_vec3 = SparseVector(
indices=[200, 300], values=[2.0, 3.0], labels=["banana", "cherry"]
)
# Add data with sparse vectors
collection.add(
ids=["id1", "id2", "id3"],
documents=["doc1", "doc2", "doc3"],
metadatas=[
{"category": "A", "vec": sparse_vec1},
{"category": "B", "vec": sparse_vec2},
{"category": "A", "vec": sparse_vec3},
],
)
_, created = attach_statistics_function(collection, "sparse_vector_test1_statistics")
assert created is True
initial_version = get_collection_version(client, collection.name)
wait_for_version_increase(client, collection.name, initial_version)
# Get statistics
stats = get_statistics(collection, "sparse_vector_test1_statistics")
print("\nSparse vector statistics output:")
print(json.dumps(stats, indent=2))
assert "statistics" in stats
assert "summary" in stats
assert stats["summary"]["total_count"] == 3
# Verify category statistics
assert "category" in stats["statistics"]
assert "A" in stats["statistics"]["category"]
assert "B" in stats["statistics"]["category"]
assert stats["statistics"]["category"]["A"]["count"] == 2
assert stats["statistics"]["category"]["B"]["count"] == 1
# Verify sparse vector statistics use labels instead of hash IDs
assert "vec" in stats["statistics"]
assert "apple" in stats["statistics"]["vec"], "Should use label 'apple' not hash ID"
assert (
"banana" in stats["statistics"]["vec"]
), "Should use label 'banana' not hash ID"
assert (
"cherry" in stats["statistics"]["vec"]
), "Should use label 'cherry' not hash ID"
assert "date" in stats["statistics"]["vec"], "Should use label 'date' not hash ID"
# Verify counts
assert stats["statistics"]["vec"]["apple"]["count"] == 2 # in id1 and id2
assert stats["statistics"]["vec"]["banana"]["count"] == 2 # in id1 and id3
assert stats["statistics"]["vec"]["cherry"]["count"] == 2 # in id1 and id3
assert stats["statistics"]["vec"]["date"]["count"] == 1 # in id2 only
def test_statistics_high_cardinality(basic_http_client: System) -> None:
"""Test statistics with high cardinality metadata"""
client = ClientCreator.from_system(basic_http_client)
client.reset()
collection = client.create_collection(name="high_cardinality_test")
# Generate 500 documents with 10 metadata fields each
num_docs = 500
num_fields = 10
ids = [f"id{i}" for i in range(num_docs)]
documents = [f"doc{i}" for i in range(num_docs)]
metadatas: list[dict[str, Any]] = []
for i in range(num_docs):
meta: dict[str, Any] = {}
for j in range(num_fields):
meta[f"field_{j}"] = f"value_{j}_{i}"
metadatas.append(meta)
# Add in batches to avoid hitting request size limits
batch_size = 100
initial_version = get_collection_version(client, collection.name)
for i in range(0, num_docs, batch_size):
collection.add(
ids=ids[i : i + batch_size],
documents=documents[i : i + batch_size],
metadatas=metadatas[i : i + batch_size], # type: ignore[arg-type]
)
# Let all data be compacted
wait_for_version_increase(client, collection.name, initial_version)
initial_version = get_collection_version(client, collection.name)
# Enable statistics
_, created = attach_statistics_function(collection, "high_cardinality_test_statistics")
assert created is True
# Wait for statistics to be computed
wait_for_version_increase(client, collection.name, initial_version)
# Get statistics
stats = get_statistics(collection, "high_cardinality_test_statistics")
assert "statistics" in stats
# Verify we have stats for all fields
for j in range(num_fields):
field_key = f"field_{j}"
assert field_key in stats["statistics"]
field_stats = stats["statistics"][field_key]
assert len(field_stats) == num_docs
# Verify each value has count 1
for i in range(num_docs):
value = f"value_{j}_{i}"
assert value in field_stats
assert field_stats[value]["count"] == 1
# Verify total count
assert stats["summary"]["total_count"] == num_docs
detach_statistics_function(collection, delete_stats_collection=True)

View File

@@ -0,0 +1,784 @@
import math
from typing import Any, Dict, Optional, Set, Tuple, cast
from hypothesis import given
from chromadb.api import ClientAPI
from chromadb.api.collection_configuration import CreateCollectionConfiguration
from chromadb.api.types import (
CollectionMetadata,
EMBEDDING_KEY,
Schema,
)
from chromadb.test.property import strategies
from chromadb.test.property.invariants import check_metadata
from chromadb.test.conftest import (
reset,
is_spann_disabled_mode,
)
HNSW_METADATA_TO_CONFIG: Dict[str, str] = {
"hnsw:space": "space",
"hnsw:construction_ef": "ef_construction",
"hnsw:search_ef": "ef_search",
"hnsw:M": "max_neighbors",
"hnsw:sync_threshold": "sync_threshold",
"hnsw:resize_factor": "resize_factor",
}
HNSW_FIELDS = [
"space",
"ef_construction",
"ef_search",
"max_neighbors",
"sync_threshold",
"resize_factor",
]
HNSW_DEFAULTS: Dict[str, Any] = {
"space": "l2",
"ef_construction": 100,
"ef_search": 100,
"max_neighbors": 16,
"sync_threshold": 1000,
"resize_factor": 1.2,
}
SPANN_FIELDS = [
"space",
"search_nprobe",
"write_nprobe",
"ef_construction",
"ef_search",
"max_neighbors",
"reassign_neighbor_count",
"split_threshold",
"merge_threshold",
]
SPANN_DEFAULTS: Dict[str, Any] = {
"space": "l2",
"search_nprobe": 64,
"write_nprobe": 32,
"ef_construction": 200,
"ef_search": 200,
"max_neighbors": 64,
"reassign_neighbor_count": 64,
"split_threshold": 50,
"merge_threshold": 25,
}
def _extract_vector_configs_from_schema(
schema: Schema,
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
defaults_float = schema.defaults.float_list
assert defaults_float is not None
defaults_vi = defaults_float.vector_index
assert defaults_vi is not None
embedding_float = schema.keys[EMBEDDING_KEY].float_list
assert embedding_float is not None
embedding_vi = embedding_float.vector_index
assert embedding_vi is not None
return (
strategies.vector_index_to_dict(defaults_vi.config),
strategies.vector_index_to_dict(embedding_vi.config),
)
def _compute_expected_config_spann(
metadata: Optional[CollectionMetadata],
configuration: Optional[CreateCollectionConfiguration],
schema_vector_index_config: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
# start off creating default spann config, we slowly modify it to much whatever prop test provides
expected = SPANN_DEFAULTS.copy()
space_set = False
# theres some edge cases where space is set in hnsw config and in metadata
# in this case, we check if the space set by config is not the default, and if so, we don't try to get use the one from metadata
# essentially if either metadata or hnsw config provides a non-default space, we use that one, with config hnsw taking priority over metadata
should_try_metadata = True
if configuration:
spann_cfg = configuration.get("spann")
if spann_cfg:
spann_cfg_dict = cast(Dict[str, Any], spann_cfg)
# update expected with whatever prop test provides
expected.update(strategies.non_none_items(spann_cfg_dict))
# if space is set in spann, this now takes priority over all else
if spann_cfg_dict.get("space") is not None:
expected["space"] = spann_cfg_dict["space"]
space_set = True
should_try_metadata = False
hnsw_cfg = configuration.get("hnsw")
if hnsw_cfg:
hnsw_cfg_dict = cast(Dict[str, Any], hnsw_cfg)
hnsw_non_none = strategies.non_none_items(hnsw_cfg_dict)
for key, value in hnsw_non_none.items():
if value is not None and value != HNSW_DEFAULTS[key]:
# if any hnsw config is not the default, we do not use metadata at all, this is used
# heres a sample case where this is needed: hnsw doesnt set space (so l2 by default), but sets ef_construction, metadata sets space to ip
# in this case, they were aware of hnsw config, and chose not to set space in it. therefore the config takes priority over metadata
should_try_metadata = False
# when SPANN is active and HNSW config is provided, use space from hnsw config
if hnsw_cfg_dict.get("space") is not None and not space_set:
# if the space set by config is not the default, don't try to get use the one from metadata
if hnsw_cfg_dict.get("space") != HNSW_DEFAULTS["space"]:
should_try_metadata = False
expected["space"] = hnsw_cfg_dict["space"]
space_set = True
if schema_vector_index_config:
if schema_vector_index_config.get("space") is not None:
expected["space"] = schema_vector_index_config["space"]
space_set = True
if schema_vector_index_config.get("spann"):
spann_schema = strategies.non_none_items(
schema_vector_index_config["spann"]
)
expected.update(spann_schema)
if (
metadata
and metadata.get("hnsw:space") is not None
and metadata.get("hnsw:space") != SPANN_DEFAULTS["space"]
and should_try_metadata
):
expected["space"] = metadata["hnsw:space"]
space_set = True
if (
schema_vector_index_config
and schema_vector_index_config.get("embedding_function_default_space")
is not None
and schema_vector_index_config.get("embedding_function_default_space")
!= SPANN_DEFAULTS["space"]
and not space_set
):
expected["space"] = schema_vector_index_config[
"embedding_function_default_space"
]
space_set = True
if (
not space_set
and configuration
and configuration.get("embedding_function") is not None
):
ef = configuration["embedding_function"]
if hasattr(ef, "default_space"):
expected["space"] = cast(Any, ef).default_space()
return expected
def _compute_expected_config_hnsw(
metadata: Optional[CollectionMetadata],
configuration: Optional[CreateCollectionConfiguration],
schema_vector_index_config: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
expected = HNSW_DEFAULTS.copy()
space_set = False
configured_hnsw_keys: Set[str] = set()
should_try_metadata = True
if configuration:
hnsw_cfg_raw = configuration.get("hnsw")
if hnsw_cfg_raw is not None:
hnsw_dict: Dict[str, Any] = cast(Dict[str, Any], hnsw_cfg_raw)
hnsw_non_none = strategies.non_none_items(hnsw_dict)
expected.update(hnsw_non_none)
for key, value in hnsw_non_none.items():
# if any hnsw config is not the default, we do not use metadata at all
if value is not None and value != HNSW_DEFAULTS[key]:
should_try_metadata = False
configured_hnsw_keys.update(hnsw_non_none.keys())
if hnsw_non_none.get("space") is not None and not space_set:
if hnsw_non_none.get("space") != HNSW_DEFAULTS["space"]:
should_try_metadata = False
space_set = True
spann_cfg_raw = configuration.get("spann")
if spann_cfg_raw is not None:
spann_dict: Dict[str, Any] = cast(Dict[str, Any], spann_cfg_raw)
if spann_dict.get("space") is not None and not space_set:
expected["space"] = spann_dict["space"]
space_set = True
should_try_metadata = False
if should_try_metadata and metadata:
for key, cfg_key in HNSW_METADATA_TO_CONFIG.items():
if metadata.get(key) is None:
continue
if cfg_key == "space":
expected[cfg_key] = metadata[key]
space_set = True
configured_hnsw_keys.add(cfg_key)
continue
if cfg_key not in configured_hnsw_keys:
expected[cfg_key] = metadata[key]
configured_hnsw_keys.add(cfg_key)
if schema_vector_index_config:
if schema_vector_index_config.get("space") is not None:
expected["space"] = schema_vector_index_config["space"]
space_set = True
if schema_vector_index_config.get("hnsw"):
expected.update(
strategies.non_none_items(schema_vector_index_config["hnsw"])
)
elif schema_vector_index_config.get("spann"):
# Schema provided SPANN configuration while HNSW is active; ignore.
pass
if (
schema_vector_index_config
and schema_vector_index_config.get("embedding_function_default_space")
is not None
and not space_set
):
expected["space"] = schema_vector_index_config[
"embedding_function_default_space"
]
space_set = True
if (
not space_set
and configuration
and configuration.get("embedding_function") is not None
):
ef = configuration["embedding_function"]
if hasattr(ef, "default_space"):
expected["space"] = cast(Any, ef).default_space()
return expected
def _compute_expected_config(
spann_active: bool,
metadata: Optional[CollectionMetadata],
configuration: Optional[CreateCollectionConfiguration],
schema_vector_index_config: Optional[Dict[str, Any]],
) -> Dict[str, Any]:
"""
some assumptions/assertions:
1. we are not testing failure paths. any config built is/should be valid. invalid cases can be tested separately or in e2e tests
ex: if configuration is set, schema is not set. if schema is set, configuration is not set. both hnsw and spann cannot be set at the same time in config or schema
"""
if spann_active:
return _compute_expected_config_spann(
metadata, configuration, schema_vector_index_config
)
else:
return _compute_expected_config_hnsw(
metadata, configuration, schema_vector_index_config
)
def _assert_config_values(
actual: Dict[str, Any],
expected: Dict[str, Any],
spann_active: bool,
) -> None:
fields = SPANN_FIELDS if spann_active else HNSW_FIELDS
for field in fields:
actual_value = actual.get(field)
expected_value = expected[field]
# Use approximate equality for floating-point values
if isinstance(actual_value, float) and isinstance(expected_value, float):
assert math.isclose(
actual_value, expected_value, rel_tol=1e-9, abs_tol=1e-9
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
else:
assert (
actual_value == expected_value
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
def _assert_schema_values(
vector_info: Dict[str, Any],
expected: Dict[str, Any],
spann_active: bool,
) -> None:
assert vector_info["space"] == expected["space"]
if spann_active:
spann_cfg = cast(Optional[Dict[str, Any]], vector_info["spann"])
assert spann_cfg is not None
for field in SPANN_FIELDS:
if field == "space":
continue
actual_value = spann_cfg.get(field)
expected_value = expected[field]
# Use approximate equality for floating-point values
if isinstance(actual_value, float) and isinstance(expected_value, float):
assert math.isclose(
actual_value, expected_value, rel_tol=1e-9, abs_tol=1e-9
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
else:
assert (
actual_value == expected_value
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
else:
hnsw_cfg = cast(Optional[Dict[str, Any]], vector_info["hnsw"])
assert hnsw_cfg is not None
for field in HNSW_FIELDS:
if field == "space":
continue
actual_value = hnsw_cfg.get(field)
expected_value = expected[field]
# Use approximate equality for floating-point values
if isinstance(actual_value, float) and isinstance(expected_value, float):
assert math.isclose(
actual_value, expected_value, rel_tol=1e-9, abs_tol=1e-9
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
else:
assert (
actual_value == expected_value
), f"{field} mismatch: expected {expected_value}, got {actual_value}"
def _get_default_schema_indexes() -> Dict[str, Dict[str, Any]]:
"""
Get expected index states for default schema (when schema=None).
Based on Schema._initialize_defaults() and _initialize_keys().
"""
return {
"defaults": {
"string_inverted": {"enabled": True},
"int_inverted": {"enabled": True},
"float_inverted": {"enabled": True},
"bool_inverted": {"enabled": True},
"sparse_vector": {"enabled": False},
"fts_index": {"enabled": False},
"vector_index": {"enabled": False},
},
"#document": {
"string_inverted": {"enabled": False},
"fts_index": {"enabled": True},
},
"#embedding": {
"vector_index": {"enabled": True},
},
}
def _extract_expected_schema_indexes(
schema: Schema,
) -> Dict[str, Dict[str, Any]]:
"""
Extract expected index states from input schema.
Returns a dict mapping key -> index_type -> enabled/config info.
"""
expected: Dict[str, Dict[str, Any]] = {}
# Check defaults
if schema.defaults.string and schema.defaults.string.string_inverted_index:
if "defaults" not in expected:
expected["defaults"] = {}
expected["defaults"]["string_inverted"] = {
"enabled": schema.defaults.string.string_inverted_index.enabled,
}
if schema.defaults.int_value and schema.defaults.int_value.int_inverted_index:
if "defaults" not in expected:
expected["defaults"] = {}
expected["defaults"]["int_inverted"] = {
"enabled": schema.defaults.int_value.int_inverted_index.enabled,
}
if schema.defaults.float_value and schema.defaults.float_value.float_inverted_index:
if "defaults" not in expected:
expected["defaults"] = {}
expected["defaults"]["float_inverted"] = {
"enabled": schema.defaults.float_value.float_inverted_index.enabled,
}
if schema.defaults.boolean and schema.defaults.boolean.bool_inverted_index:
if "defaults" not in expected:
expected["defaults"] = {}
expected["defaults"]["bool_inverted"] = {
"enabled": schema.defaults.boolean.bool_inverted_index.enabled,
}
if (
schema.defaults.sparse_vector
and schema.defaults.sparse_vector.sparse_vector_index
):
if "defaults" not in expected:
expected["defaults"] = {}
expected["defaults"]["sparse_vector"] = {
"enabled": schema.defaults.sparse_vector.sparse_vector_index.enabled,
"config": schema.defaults.sparse_vector.sparse_vector_index.config,
}
# Check per-key indexes
for key, value_types in schema.keys.items():
if key in (EMBEDDING_KEY, "#document"):
# Skip special keys - they're handled by vector index test
continue
key_expected: Dict[str, Any] = {}
if value_types.string and value_types.string.string_inverted_index:
key_expected["string_inverted"] = {
"enabled": value_types.string.string_inverted_index.enabled,
}
if value_types.int_value and value_types.int_value.int_inverted_index:
key_expected["int_inverted"] = {
"enabled": value_types.int_value.int_inverted_index.enabled,
}
if value_types.float_value and value_types.float_value.float_inverted_index:
key_expected["float_inverted"] = {
"enabled": value_types.float_value.float_inverted_index.enabled,
}
if value_types.boolean and value_types.boolean.bool_inverted_index:
key_expected["bool_inverted"] = {
"enabled": value_types.boolean.bool_inverted_index.enabled,
}
if value_types.sparse_vector and value_types.sparse_vector.sparse_vector_index:
key_expected["sparse_vector"] = {
"enabled": value_types.sparse_vector.sparse_vector_index.enabled,
"config": value_types.sparse_vector.sparse_vector_index.config,
}
if key_expected:
expected[key] = key_expected
return expected
def _assert_schema_indexes(
actual_schema: Schema,
expected_indexes: Dict[str, Dict[str, Any]],
) -> None:
"""Assert that the actual schema matches expected index states."""
# Check defaults
if "defaults" in expected_indexes:
defaults_expected = expected_indexes["defaults"]
defaults_actual = actual_schema.defaults
if "string_inverted" in defaults_expected:
expected_enabled = defaults_expected["string_inverted"]["enabled"]
actual_string = defaults_actual.string
if actual_string and actual_string.string_inverted_index:
assert (
actual_string.string_inverted_index.enabled == expected_enabled
), f"defaults string_inverted enabled mismatch: expected {expected_enabled}, got {actual_string.string_inverted_index.enabled}"
else:
# If not explicitly set, defaults should be enabled
assert expected_enabled, "defaults string_inverted should be enabled"
if "int_inverted" in defaults_expected:
expected_enabled = defaults_expected["int_inverted"]["enabled"]
actual_int = defaults_actual.int_value
if actual_int and actual_int.int_inverted_index:
assert (
actual_int.int_inverted_index.enabled == expected_enabled
), f"defaults int_inverted enabled mismatch: expected {expected_enabled}, got {actual_int.int_inverted_index.enabled}"
else:
assert expected_enabled, "defaults int_inverted should be enabled"
if "float_inverted" in defaults_expected:
expected_enabled = defaults_expected["float_inverted"]["enabled"]
actual_float = defaults_actual.float_value
if actual_float and actual_float.float_inverted_index:
assert (
actual_float.float_inverted_index.enabled == expected_enabled
), f"defaults float_inverted enabled mismatch: expected {expected_enabled}, got {actual_float.float_inverted_index.enabled}"
else:
assert expected_enabled, "defaults float_inverted should be enabled"
if "bool_inverted" in defaults_expected:
expected_enabled = defaults_expected["bool_inverted"]["enabled"]
actual_bool = defaults_actual.boolean
if actual_bool and actual_bool.bool_inverted_index:
assert (
actual_bool.bool_inverted_index.enabled == expected_enabled
), f"defaults bool_inverted enabled mismatch: expected {expected_enabled}, got {actual_bool.bool_inverted_index.enabled}"
else:
assert expected_enabled, "defaults bool_inverted should be enabled"
if "sparse_vector" in defaults_expected:
expected_enabled = defaults_expected["sparse_vector"]["enabled"]
actual_sparse = defaults_actual.sparse_vector
assert actual_sparse is not None, "defaults sparse_vector should exist"
assert (
actual_sparse.sparse_vector_index is not None
), "defaults sparse_vector_index should exist"
assert (
actual_sparse.sparse_vector_index.enabled == expected_enabled
), f"defaults sparse_vector enabled mismatch: expected {expected_enabled}, got {actual_sparse.sparse_vector_index.enabled}"
# Validate config fields if config is provided in expected
if "config" in defaults_expected["sparse_vector"]:
expected_config = defaults_expected["sparse_vector"]["config"]
actual_config = actual_sparse.sparse_vector_index.config
if expected_config.bm25 is not None:
assert (
actual_config.bm25 == expected_config.bm25
), f"defaults sparse_vector bm25 mismatch: expected {expected_config.bm25}, got {actual_config.bm25}"
if expected_config.source_key is not None:
assert (
actual_config.source_key == expected_config.source_key
), f"defaults sparse_vector source_key mismatch: expected {expected_config.source_key}, got {actual_config.source_key}"
if "fts_index" in defaults_expected:
expected_enabled = defaults_expected["fts_index"]["enabled"]
actual_string = defaults_actual.string
assert actual_string is not None, "defaults string should exist"
assert (
actual_string.fts_index is not None
), "defaults fts_index should exist"
assert (
actual_string.fts_index.enabled == expected_enabled
), f"defaults fts_index enabled mismatch: expected {expected_enabled}, got {actual_string.fts_index.enabled}"
if "vector_index" in defaults_expected:
expected_enabled = defaults_expected["vector_index"]["enabled"]
actual_float_list = defaults_actual.float_list
assert actual_float_list is not None, "defaults float_list should exist"
assert (
actual_float_list.vector_index is not None
), "defaults vector_index should exist"
assert (
actual_float_list.vector_index.enabled == expected_enabled
), f"defaults vector_index enabled mismatch: expected {expected_enabled}, got {actual_float_list.vector_index.enabled}"
# Check per-key indexes
for key, key_expected in expected_indexes.items():
if key == "defaults":
continue
assert key in actual_schema.keys, f"Expected key '{key}' not found in schema"
actual_value_types = actual_schema.keys[key]
if "string_inverted" in key_expected:
expected_enabled = key_expected["string_inverted"]["enabled"]
actual_string = actual_value_types.string
assert actual_string is not None, f"Key '{key}' string should exist"
assert (
actual_string.string_inverted_index is not None
), f"Key '{key}' string_inverted_index should exist"
assert (
actual_string.string_inverted_index.enabled == expected_enabled
), f"Key '{key}' string_inverted enabled mismatch: expected {expected_enabled}, got {actual_string.string_inverted_index.enabled}"
if "int_inverted" in key_expected:
expected_enabled = key_expected["int_inverted"]["enabled"]
actual_int = actual_value_types.int_value
assert actual_int is not None, f"Key '{key}' int_value should exist"
assert (
actual_int.int_inverted_index is not None
), f"Key '{key}' int_inverted_index should exist"
assert (
actual_int.int_inverted_index.enabled == expected_enabled
), f"Key '{key}' int_inverted enabled mismatch: expected {expected_enabled}, got {actual_int.int_inverted_index.enabled}"
if "float_inverted" in key_expected:
expected_enabled = key_expected["float_inverted"]["enabled"]
actual_float = actual_value_types.float_value
assert actual_float is not None, f"Key '{key}' float_value should exist"
assert (
actual_float.float_inverted_index is not None
), f"Key '{key}' float_inverted_index should exist"
assert (
actual_float.float_inverted_index.enabled == expected_enabled
), f"Key '{key}' float_inverted enabled mismatch: expected {expected_enabled}, got {actual_float.float_inverted_index.enabled}"
if "bool_inverted" in key_expected:
expected_enabled = key_expected["bool_inverted"]["enabled"]
actual_bool = actual_value_types.boolean
assert actual_bool is not None, f"Key '{key}' boolean should exist"
assert (
actual_bool.bool_inverted_index is not None
), f"Key '{key}' bool_inverted_index should exist"
assert (
actual_bool.bool_inverted_index.enabled == expected_enabled
), f"Key '{key}' bool_inverted enabled mismatch: expected {expected_enabled}, got {actual_bool.bool_inverted_index.enabled}"
if "sparse_vector" in key_expected:
expected_enabled = key_expected["sparse_vector"]["enabled"]
expected_config = key_expected["sparse_vector"]["config"]
actual_sparse = actual_value_types.sparse_vector
assert actual_sparse is not None, f"Key '{key}' sparse_vector should exist"
assert (
actual_sparse.sparse_vector_index is not None
), f"Key '{key}' sparse_vector_index should exist"
assert (
actual_sparse.sparse_vector_index.enabled == expected_enabled
), f"Key '{key}' sparse_vector enabled mismatch: expected {expected_enabled}, got {actual_sparse.sparse_vector_index.enabled}"
# Validate config fields match
actual_config = actual_sparse.sparse_vector_index.config
if expected_config.bm25 is not None:
assert (
actual_config.bm25 == expected_config.bm25
), f"Key '{key}' sparse_vector bm25 mismatch: expected {expected_config.bm25}, got {actual_config.bm25}"
if expected_config.source_key is not None:
assert (
actual_config.source_key == expected_config.source_key
), f"Key '{key}' sparse_vector source_key mismatch: expected {expected_config.source_key}, got {actual_config.source_key}"
if "fts_index" in key_expected:
expected_enabled = key_expected["fts_index"]["enabled"]
actual_string = actual_value_types.string
assert actual_string is not None, f"Key '{key}' string should exist"
assert (
actual_string.fts_index is not None
), f"Key '{key}' fts_index should exist"
assert (
actual_string.fts_index.enabled == expected_enabled
), f"Key '{key}' fts_index enabled mismatch: expected {expected_enabled}, got {actual_string.fts_index.enabled}"
if "vector_index" in key_expected:
expected_enabled = key_expected["vector_index"]["enabled"]
actual_float_list = actual_value_types.float_list
assert actual_float_list is not None, f"Key '{key}' float_list should exist"
assert (
actual_float_list.vector_index is not None
), f"Key '{key}' vector_index should exist"
assert (
actual_float_list.vector_index.enabled == expected_enabled
), f"Key '{key}' vector_index enabled mismatch: expected {expected_enabled}, got {actual_float_list.vector_index.enabled}"
@given(
name=strategies.collection_name(),
optional_fields=strategies.metadata_configuration_schema_strategy(),
)
def test_vector_index_configuration_create_collection(
client: ClientAPI,
name: str,
optional_fields: strategies.CollectionInputCombination,
) -> None:
metadata = optional_fields.metadata
configuration = optional_fields.configuration
schema = optional_fields.schema
reset(client)
collection = client.create_collection(
name=name,
metadata=metadata,
configuration=configuration,
schema=schema,
)
if metadata is None:
assert collection.metadata in (None, {})
else:
check_metadata(metadata, collection.metadata)
coll_config = collection.configuration
spann_active = not is_spann_disabled_mode
active_key = "spann" if spann_active else "hnsw"
inactive_key = "hnsw" if spann_active else "spann"
active_block = coll_config.get(active_key)
inactive_block = coll_config.get(inactive_key)
assert active_block is not None, f"{active_key} configuration missing"
assert inactive_block in (
None,
{},
), f"{inactive_key} configuration should be absent"
expected = _compute_expected_config(
spann_active=spann_active,
metadata=metadata,
configuration=configuration,
schema_vector_index_config=optional_fields.schema_vector_info,
)
_assert_config_values(cast(Dict[str, Any], active_block), expected, spann_active)
# Check embedding function name if one was provided
if configuration and configuration.get("embedding_function") is not None:
ef = configuration["embedding_function"]
if ef is not None:
coll_ef = coll_config.get("embedding_function")
if coll_ef is not None:
ef_config = coll_ef.get_config()
if ef_config and ef_config.get("type") == "known":
assert hasattr(
ef, "name"
), "embedding function should have name method"
assert ef_config.get("name") == ef.name(), (
f"embedding function name mismatch: "
f"expected {ef.name()}, got {ef_config.get('name')}"
)
schema_result = collection.schema
assert schema_result is not None
defaults_cfg, embedding_cfg = _extract_vector_configs_from_schema(schema_result)
if spann_active:
assert defaults_cfg["hnsw"] is None
assert embedding_cfg["hnsw"] is None
assert defaults_cfg["spann"] is not None
assert embedding_cfg["spann"] is not None
else:
assert defaults_cfg["spann"] is None
assert embedding_cfg["spann"] is None
assert defaults_cfg["hnsw"] is not None
assert embedding_cfg["hnsw"] is not None
_assert_schema_values(defaults_cfg, expected, spann_active)
_assert_schema_values(embedding_cfg, expected, spann_active)
# Check embedding function name in schema if one was provided
if configuration and configuration.get("embedding_function") is not None:
ef = configuration["embedding_function"]
if ef is not None:
# Check defaults vector index
defaults_ef = schema_result.defaults.float_list.vector_index.config.embedding_function # type: ignore[union-attr]
if defaults_ef is not None and hasattr(defaults_ef, "name"):
assert defaults_ef.name() == ef.name(), (
f"defaults embedding function name mismatch: "
f"expected {ef.name()}, got {defaults_ef.name()}"
)
# Check embedding key vector index
embedding_ef = schema_result.keys[EMBEDDING_KEY].float_list.vector_index.config.embedding_function # type: ignore[union-attr]
if embedding_ef is not None and hasattr(embedding_ef, "name"):
assert embedding_ef.name() == ef.name(), (
f"embedding key embedding function name mismatch: "
f"expected {ef.name()}, got {embedding_ef.name()}"
)
@given(
name=strategies.collection_name(),
schema=strategies.schema_strategy(),
)
def test_schema_create_and_get_collection(
client: ClientAPI,
name: str,
schema: Optional[Schema],
) -> None:
"""
Test that schema-only components (inverted indexes, sparse vector indexes)
are correctly created and persisted when creating a collection.
"""
reset(client)
if schema is None:
expected_indexes = _get_default_schema_indexes()
else:
expected_indexes = _extract_expected_schema_indexes(schema)
collection = client.create_collection(name=name, schema=schema)
# Get the returned schema
schema_result = collection.schema
assert schema_result is not None, "Schema should not be None"
_assert_schema_indexes(schema_result, expected_indexes)
collection = client.get_collection(name)
schema_result = collection.schema
assert schema_result is not None, "Schema should not be None"
_assert_schema_indexes(schema_result, expected_indexes)