diff --git a/CHANGELOG.md b/CHANGELOG.md index f033cf1..3d48b2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ - Fixed memory leak with `register_vector` and `register_vector_async` for Psycopg 3 - Fixed error with `Bit` constructor and empty strings - Fixed equality for `HalfVector` with signed zeros +- Fixed `ValueError` instead of `ValidationError` for invalid values with Django +- Added dimension validation for Django fields ## 0.5.1 (2026-10-08) diff --git a/pgvector/django/halfvec.py b/pgvector/django/halfvec.py index 1ab4a47..9b74b39 100644 --- a/pgvector/django/halfvec.py +++ b/pgvector/django/halfvec.py @@ -1,5 +1,7 @@ from django import forms +from django.core import exceptions from django.db.models import Field +from django.utils.functional import cached_property from typing import Any from .. import HalfVector @@ -30,7 +32,26 @@ def from_db_value(self, value: Any, expression: Any, connection: Any) -> list[fl def to_python(self, value: Any) -> list[float] | None: if isinstance(value, list): return value - return HalfVector._from_db(value) + try: + return HalfVector._from_db(value) + except ValueError: + raise exceptions.ValidationError('Enter a valid half vector.', code='invalid') + + @cached_property + def validators(self) -> list[Any]: + return [*super().validators, self._validate_dimensions] + + def _validate_dimensions(self, value: Any) -> None: + try: + vector = HalfVector._from_db(HalfVector._to_db(value)) + except (TypeError, ValueError): + raise exceptions.ValidationError('Enter a valid half vector.', code='invalid') + if self.dimensions is not None and vector is not None and len(vector) != self.dimensions: + raise exceptions.ValidationError( + 'Expected %(expected)d dimensions, not %(actual)d.', + code='dimensions', + params={'expected': self.dimensions, 'actual': len(vector)} + ) def get_prep_value(self, value: Any) -> str | None: return HalfVector._to_db(value) diff --git a/pgvector/django/sparsevec.py b/pgvector/django/sparsevec.py index a10c133..53882ab 100644 --- a/pgvector/django/sparsevec.py +++ b/pgvector/django/sparsevec.py @@ -1,5 +1,7 @@ from django import forms +from django.core import exceptions from django.db.models import Field +from django.utils.functional import cached_property from typing import Any from .. import SparseVector @@ -28,7 +30,26 @@ def from_db_value(self, value: Any, expression: Any, connection: Any) -> SparseV return SparseVector._from_db(value) def to_python(self, value: Any) -> SparseVector | None: - return SparseVector._from_db(value) + try: + return SparseVector._from_db(value) + except ValueError: + raise exceptions.ValidationError('Enter a valid sparse vector.', code='invalid') + + @cached_property + def validators(self) -> list[Any]: + return [*super().validators, self._validate_dimensions] + + def _validate_dimensions(self, value: Any) -> None: + try: + vector = SparseVector._from_db(SparseVector._to_db(value)) + except (TypeError, ValueError): + raise exceptions.ValidationError('Enter a valid sparse vector.', code='invalid') + if self.dimensions is not None and vector is not None and vector.dimensions() != self.dimensions: + raise exceptions.ValidationError( + 'Expected %(expected)d dimensions, not %(actual)d.', + code='dimensions', + params={'expected': self.dimensions, 'actual': vector.dimensions()} + ) def get_prep_value(self, value: Any) -> str | None: return SparseVector._to_db(value) diff --git a/pgvector/django/vector.py b/pgvector/django/vector.py index 42970c6..0da5ba9 100644 --- a/pgvector/django/vector.py +++ b/pgvector/django/vector.py @@ -1,5 +1,7 @@ from django import forms +from django.core import exceptions from django.db.models import Field +from django.utils.functional import cached_property from typing import Any from .. import Vector @@ -30,7 +32,26 @@ def from_db_value(self, value: Any, expression: Any, connection: Any) -> list[fl def to_python(self, value: Any) -> list[float] | None: if isinstance(value, list): return value - return Vector._from_db(value) + try: + return Vector._from_db(value) + except ValueError: + raise exceptions.ValidationError('Enter a valid vector.', code='invalid') + + @cached_property + def validators(self) -> list[Any]: + return [*super().validators, self._validate_dimensions] + + def _validate_dimensions(self, value: Any) -> None: + try: + vector = Vector._from_db(Vector._to_db(value)) + except (TypeError, ValueError): + raise exceptions.ValidationError('Enter a valid vector.', code='invalid') + if self.dimensions is not None and vector is not None and len(vector) != self.dimensions: + raise exceptions.ValidationError( + 'Expected %(expected)d dimensions, not %(actual)d.', + code='dimensions', + params={'expected': self.dimensions, 'actual': len(vector)} + ) def get_prep_value(self, value: Any) -> str | None: return Vector._to_db(value) diff --git a/tests/test_django.py b/tests/test_django.py index e5f0c6f..c8e0f97 100644 --- a/tests/test_django.py +++ b/tests/test_django.py @@ -1,5 +1,6 @@ import django from django.conf import settings +from django.core.exceptions import ValidationError from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.indexes import OpClass from django.core import serializers @@ -11,6 +12,7 @@ from math import sqrt import os import pgvector.django +import pytest from pgvector import SparseVector from pgvector.django import VectorExtension, VectorField, HalfVectorField, BitField, SparseVectorField, IvfflatIndex, HnswIndex, L2Distance, MaxInnerProduct, CosineDistance, L1Distance, HammingDistance, JaccardDistance from unittest import mock @@ -458,6 +460,55 @@ def test_clean(self) -> None: item = Item(id=1, embedding=[1, 2, 3], half_embedding=[1, 2, 3], binary_embedding='101', sparse_embedding=SparseVector([1, 2, 3])) item.full_clean() + def test_clean_invalid(self) -> None: + item = Item(id=1, embedding=[1, 2, 'x'], half_embedding=[1, 2, 'x']) + with pytest.raises(ValidationError) as error: + item.full_clean() + assert error.value.message_dict == { + 'embedding': ['Enter a valid vector.'], + 'half_embedding': ['Enter a valid half vector.'] + } + + def test_clean_dimensions(self) -> None: + item = Item(id=1, embedding=[1, 2], half_embedding=[1, 2], sparse_embedding=SparseVector([1, 2])) + with pytest.raises(ValidationError) as error: + item.full_clean() + assert error.value.message_dict == { + 'embedding': ['Expected 3 dimensions, not 2.'], + 'half_embedding': ['Expected 3 dimensions, not 2.'], + 'sparse_embedding': ['Expected 3 dimensions, not 2.'] + } + + def test_vector_form_invalid(self) -> None: + form = VectorForm(data={'embedding': '[1, 2, x]'}) + assert not form.is_valid() + assert form.errors == {'embedding': ['Enter a valid vector.']} + + def test_vector_form_dimensions(self) -> None: + form = VectorForm(data={'embedding': '[1, 2]'}) + assert not form.is_valid() + assert form.errors == {'embedding': ['Expected 3 dimensions, not 2.']} + + def test_halfvec_form_invalid(self) -> None: + form = HalfVectorForm(data={'half_embedding': '[1, 2, x]'}) + assert not form.is_valid() + assert form.errors == {'half_embedding': ['Enter a valid half vector.']} + + def test_halfvec_form_dimensions(self) -> None: + form = HalfVectorForm(data={'half_embedding': '[1, 2]'}) + assert not form.is_valid() + assert form.errors == {'half_embedding': ['Expected 3 dimensions, not 2.']} + + def test_sparsevec_form_invalid(self) -> None: + form = SparseVectorForm(data={'sparse_embedding': '{1:x}/3'}) + assert not form.is_valid() + assert form.errors == {'sparse_embedding': ['Enter a valid sparse vector.']} + + def test_sparsevec_form_dimensions(self) -> None: + form = SparseVectorForm(data={'sparse_embedding': '{1:1,2:2}/2'}) + assert not form.is_valid() + assert form.errors == {'sparse_embedding': ['Expected 3 dimensions, not 2.']} + def test_get_or_create(self) -> None: Item.objects.get_or_create(embedding=[1, 2, 3])