Compare commits

..
Author SHA1 Message Date
Kevin 8e7aaee4f5 fix(vector-store): validate collection schema in MilvusStore.get_collection (#1344)
`get_collection()` attached any collection right after the existence check, with no look at its schema. A collection with an INT64 primary key, or one missing the `metadata` field entirely, would attach without complaint and only fail later, inside `get_vector()` or `get_metadata()`, with an error that gave no hint the real problem was upstream at attach time.

This adds a schema check between the attach and the assignment to `self.collection`, so a mismatch is caught at the point of failure instead of surfacing three calls later as an unrelated-looking error. The check validates against exactly the shape `create_collection()` builds: a `VARCHAR` primary key named `id` with `auto_id=False`, a `FLOAT_VECTOR` field named `vector`, and a `JSON` field named `metadata`. Anything else, wrong dtype, wrong name, a missing field, or an auto-generated id, is rejected before the store ever holds a reference to it.

The auto_id and metadata-dtype checks were added in a second pass after review. A collection with `auto_id=True` still attached cleanly and only broke once the store tried to insert with the explicit ids it always sends, and a `metadata` field that existed but wasn't `JSON`-typed only broke during a later write or metadata filter, for the same reason: schema drift that looked fine at attach time and failed downstream instead of at the source.

Nine tests cover this: the one matching-schema case that should succeed, and each rejection path independently, wrong pk dtype, missing pk, wrong pk name, auto_id pk, missing vector field, wrong vector dtype, missing metadata field, and non-JSON metadata.

Closes #1331.
2026-09-02 00:58:26 +05:00
6 changed files with 222 additions and 3719 deletions
+1 -2
View File
@@ -30,8 +30,7 @@ each file's own autogenerated header comment for its exact command).
| `pep517-build.txt` | ci.yml, benchmark.yml, Dockerfile | exact `[build-system] requires` from `pyproject.toml` (setuptools, wheel) - installed with `--no-build-isolation` before any `pip install -e .` / `pip install .`, since `--no-deps` alone doesn't stop pip's PEP 517 build isolation from fetching those two *unhashed* |
| `explorer-extra-py311.txt` | ci.yml | semantica's base deps + the `explorer` extra, resolved for python 3.11 |
| `explorer-extra-py313.txt` | Dockerfile | the same, resolved for python 3.13 (the image's actual interpreter) |
| `pgvector-extra.txt` | integration.yml | semantica's base deps + the `vectorstore-pgvector` extra, resolved for python 3.11 |
| `pytest-tool.txt` | ci.yml, integration.yml | pytest, for the pre-all-extras deterministic test |
| `pytest-tool.txt` | ci.yml | pytest, for the pre-all-extras deterministic test |
| `uv-tool.txt` | ci.yml | uv, to verify requirements-ci.txt is current |
| `build-tools.txt` | ci.yml, release.yml | build, wheel |
| `twine.txt` | release.yml | twine |
File diff suppressed because it is too large Load Diff
-91
View File
@@ -1,91 +0,0 @@
name: Integration Tests
# Separate from ci.yml, which is a required check: a slow image pull or a
# container flake must not block unrelated merges.
permissions:
contents: read
on:
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- 'docs_check.py'
- '**/*.md'
schedule:
- cron: '0 5 * * 1'
workflow_dispatch:
jobs:
pgvector:
name: pgvector (live PostgreSQL)
runs-on: ubuntu-latest
timeout-minutes: 20
services:
postgres:
# pgvector/pgvector:pg16 as published 2026-08-13. Pinned by digest like
# the action pins, though verify-action-pins.sh does not check images.
image: pgvector/pgvector@sha256:ccc6e83d6e35e931dc7c5def2022729d5a6c370318d099181995567ff1fb4d6b
env:
POSTGRES_USER: postgres
POSTGRES_DB: test
# Throwaway container reachable only from this job, so trust auth
# avoids putting a credential in the workflow at all.
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d test"
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
TEST_PGVECTOR_URL: postgresql://postgres@localhost:5432/test
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: '3.11'
cache: 'pip'
- name: Install semantica with the pgvector extra
# Hash-verified installs throughout, matching ci.yml/security.yml/etc
# (OpenSSF Scorecard's Pinned-Dependencies check). --no-deps here
# skips runtime dependency resolution for the editable install itself
# (nothing to hash); pep517-build.txt + --no-build-isolation stops
# its PEP 517 build from separately fetching an unhashed
# setuptools/wheel via build isolation.
run: |
pip install -r .github/requirements/bootstrap.txt --require-hashes
pip install -r .github/requirements/pep517-build.txt --require-hashes
pip install --no-deps --no-build-isolation -e .
pip install -r .github/requirements/pgvector-extra.txt --require-hashes
pip install -r .github/requirements/pytest-tool.txt --require-hashes
- name: Create the vector extension
# PgVectorStore._verify_pgvector_extension() requires it and refuses to
# create it. Doubles as the connectivity gate.
run: |
python - <<'PY'
import os
import psycopg
with psycopg.connect(os.environ["TEST_PGVECTOR_URL"]) as conn:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
conn.commit()
print("vector extension ready")
PY
- name: Run the live pgvector suite
# pg_available raises rather than skipping when TEST_PGVECTOR_URL was
# set explicitly (which this job always does), so a service that's
# actually unreachable fails this step instead of the suite quietly
# reporting green having run nothing.
run: |
pytest tests/vector_store/test_pgvector_store.py -v -rs
+40
View File
@@ -438,6 +438,46 @@ class MilvusStore:
raise ProcessingError(f"Collection {collection_name} does not exist")
collection = Collection(collection_name)
# Reject schemas that don't match create_collection()'s shape:
# id/VARCHAR pk + vector + metadata. Otherwise an incompatible
# collection attaches and fails far later in get_vector/get_metadata.
schema = getattr(collection, "schema", None)
fields = list(getattr(schema, "fields", None) or [])
pk = [f for f in fields if getattr(f, "is_primary", False)]
if (
not pk
or pk[0].name != "id"
or getattr(getattr(pk[0], "dtype", None), "name", None) != "VARCHAR"
or getattr(pk[0], "auto_id", False)
):
raise ProcessingError(
f"Collection '{collection_name}' has an invalid primary key: "
"expected VARCHAR field 'id' without auto_id"
)
vector_field = next((f for f in fields if f.name == "vector"), None)
if vector_field is None:
raise ProcessingError(
f"Collection '{collection_name}' is missing required field 'vector'"
)
if (
getattr(getattr(vector_field, "dtype", None), "name", None)
!= "FLOAT_VECTOR"
):
raise ProcessingError(
f"Collection '{collection_name}' has an invalid vector field: "
"expected FLOAT_VECTOR 'vector'"
)
metadata_field = next((f for f in fields if f.name == "metadata"), None)
if metadata_field is None:
raise ProcessingError(
f"Collection '{collection_name}' is missing required field 'metadata'"
)
if getattr(getattr(metadata_field, "dtype", None), "name", None) != "JSON":
raise ProcessingError(
f"Collection '{collection_name}' has an invalid metadata field: "
"expected JSON 'metadata'"
)
self.collection = MilvusCollection(collection, collection_name)
self.search_engine = MilvusSearch(self.collection)
return self.collection
@@ -0,0 +1,141 @@
"""Tests for MilvusStore.get_collection schema validation (#1331)."""
from unittest import TestCase
from unittest.mock import MagicMock, patch
from semantica.vector_store.milvus_store import MilvusStore
from semantica.utils.exceptions import ProcessingError
def _field(name, dtype_name, primary=False, auto_id=False):
f = MagicMock()
f.name = name
f.is_primary = primary
f.auto_id = auto_id
f.dtype.name = dtype_name
return f
class MilvusGetCollectionSchemaTest(TestCase):
def setUp(self):
self.patches = [
patch("semantica.vector_store.milvus_store.MILVUS_AVAILABLE", True),
patch("semantica.vector_store.milvus_store.utility"),
patch("semantica.vector_store.milvus_store.Collection"),
]
for p in self.patches:
p.start()
# utility.has_collection() must return truthy
import semantica.vector_store.milvus_store as m
m.utility.has_collection.return_value = True
def tearDown(self):
for p in reversed(self.patches):
p.stop()
def _make_store(self, coll):
store = MilvusStore()
store.client = MagicMock() # skip real connect
import semantica.vector_store.milvus_store as m
m.Collection.return_value = coll
return store
def _assert_rejected(self, store, expected_msg):
with self.assertRaises(ProcessingError) as ctx:
store.get_collection("c")
self.assertIn(expected_msg, str(ctx.exception))
self.assertIsNone(store.collection)
def test_accepts_matching_schema(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
result = store.get_collection("c")
self.assertIsNotNone(result)
self.assertIsNotNone(store.collection)
self.assertIsNotNone(store.search_engine)
self.assertEqual(store.collection.collection_name, "c")
def test_rejects_non_varchar_primary_key(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "INT64", primary=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid primary key")
def test_rejects_missing_primary_key(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR"),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid primary key")
def test_rejects_wrongly_named_primary_key(self):
coll = MagicMock()
coll.schema.fields = [
_field("pk", "VARCHAR", primary=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid primary key")
def test_rejects_missing_metadata_field(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("vector", "FLOAT_VECTOR"),
]
store = self._make_store(coll)
self._assert_rejected(store, "is missing required field 'metadata'")
def test_rejects_missing_vector_field(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "is missing required field 'vector'")
def test_rejects_wrong_vector_dtype(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("vector", "BINARY_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid vector field")
def test_rejects_auto_id_primary_key(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True, auto_id=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "JSON"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid primary key")
def test_rejects_non_json_metadata(self):
coll = MagicMock()
coll.schema.fields = [
_field("id", "VARCHAR", primary=True),
_field("vector", "FLOAT_VECTOR"),
_field("metadata", "STRING"),
]
store = self._make_store(coll)
self._assert_rejected(store, "has an invalid metadata field")
+40 -53
View File
@@ -10,7 +10,7 @@ To run these tests locally with Docker:
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=test \
-p 5432:5432 \
pgvector/pgvector:pg16
ankane/pgvector:latest
pytest tests/vector_store/test_pgvector_store.py -v
@@ -63,37 +63,29 @@ TEST_CONNECTION_STRING = os.getenv(
@pytest.fixture(scope="module")
def pg_available() -> bool:
"""Check if PostgreSQL with pgvector is available.
A connection failure only means "skip" when TEST_PGVECTOR_URL wasn't set
explicitly, i.e. this is a local run falling back to the documented
default. CI sets it on purpose, so a failure there means the service is
genuinely broken and the suite should fail loudly instead of skipping.
"""
"""Check if PostgreSQL with pgvector is available."""
if not psycopg_available:
return False
explicit_url = "TEST_PGVECTOR_URL" in os.environ
try:
try:
import psycopg
if psycopg_available:
try:
import psycopg
conn = psycopg.connect(TEST_CONNECTION_STRING, connect_timeout=5)
except ImportError:
import psycopg2
conn = psycopg.connect(TEST_CONNECTION_STRING, connect_timeout=5)
except ImportError:
import psycopg2
conn = psycopg2.connect(TEST_CONNECTION_STRING, connect_timeout=5)
conn = psycopg2.connect(TEST_CONNECTION_STRING, connect_timeout=5)
cur = conn.cursor()
cur.execute("SELECT 1")
cur.close()
conn.close()
return True
cur = conn.cursor()
cur.execute("SELECT 1")
cur.close()
conn.close()
return True
except Exception:
if explicit_url:
raise
return False
return False
@pytest.fixture
@@ -199,13 +191,7 @@ class TestPgVectorStoreAdd:
ids = store.add(vectors, metadata)
assert len(ids) == 5
assert len(set(ids)) == 5
# add() assigns uuid4 identifiers, not a "vec_" prefix
for vector_id in ids:
try:
uuid.UUID(vector_id)
except ValueError:
pytest.fail(f"{vector_id!r} is not a valid uuid4 id")
assert all(id.startswith("vec_") for id in ids)
def test_add_auto_generate_ids(self, store):
"""Test that IDs are auto-generated if not provided."""
@@ -304,37 +290,38 @@ class TestPgVectorStoreSearch:
if not pg_available:
pytest.skip("PostgreSQL not available")
from semantica.vector_store.pgvector_store import PgVectorStore, psycopg_sql
from semantica.vector_store.pgvector_store import PgVectorStore
# setup_vectors is autouse and seeds unique_table_name, and fixtures are
# cached per test, so this needs a table of its own to be empty at all.
empty_table = f"{unique_table_name}_empty"
empty_store = PgVectorStore(
connection_string=TEST_CONNECTION_STRING,
table_name=empty_table,
table_name=unique_table_name,
dimension=128,
distance_metric="cosine",
)
try:
query = np.random.rand(128).astype(np.float32)
results = empty_store.search(query, top_k=5)
query = np.random.rand(128).astype(np.float32)
results = empty_store.search(query, top_k=5)
assert len(results) == 0
finally:
try:
with empty_store._get_connection() as conn:
cur = conn.cursor()
cur.execute(
psycopg_sql.SQL("DROP TABLE IF EXISTS {}").format(
psycopg_sql.Identifier(empty_table)
)
)
conn.commit()
cur.close()
empty_store.close()
except Exception:
pass
assert len(results) == 0
# Cleanup: Drop test table after test completes
# Uses best-effort cleanup - failures are silently ignored since
# this is teardown of optional test resources
try:
with empty_store._get_connection() as conn:
cur = conn.cursor()
from semantica.vector_store.pgvector_store import psycopg_sql
drop_sql = psycopg_sql.SQL("DROP TABLE IF EXISTS {}").format(
psycopg_sql.Identifier(unique_table_name)
)
cur.execute(drop_sql)
conn.commit()
cur.close()
empty_store.close()
except Exception:
# Best-effort cleanup: PostgreSQL may be unavailable during teardown
# This is expected when tests are skipped or connection is lost
pass
class TestPgVectorStoreGet: