From f5b4b8bab808a1181ba1c45b22d263b17f09eb6b Mon Sep 17 00:00:00 2001 From: mayuriphad Date: Sun, 25 Oct 2026 10:52:28 +0530 Subject: [PATCH 1/2] Raise ValidationError for invalid vectors in Django fields VectorField, HalfVectorField and SparseVectorField passed malformed input straight to the parsers, so a ModelForm or serializer raised ValueError instead of reporting a field error. A value with the wrong number of dimensions passed validation and failed later with DataError from Postgres. Convert parse errors to ValidationError in to_python and add a validator that checks the dimensions when the field has them. Fixes #171 Signed-off-by: mayuriphad --- CHANGELOG.md | 2 ++ pgvector/django/halfvec.py | 23 +++++++++++++++- pgvector/django/sparsevec.py | 23 +++++++++++++++- pgvector/django/vector.py | 23 +++++++++++++++- tests/test_django.py | 51 ++++++++++++++++++++++++++++++++++++ 5 files changed, 119 insertions(+), 3 deletions(-) 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..8ab1fff 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: + dimensions = len(HalfVector._from_db(HalfVector._to_db(value))) # type: ignore[arg-type] + except (TypeError, ValueError): + raise exceptions.ValidationError('Enter a valid half vector.', code='invalid') + if self.dimensions is not None and dimensions != self.dimensions: + raise exceptions.ValidationError( + 'Expected %(expected)d dimensions, not %(actual)d.', + code='dimensions', + params={'expected': self.dimensions, 'actual': dimensions} + ) 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..0a316bf 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: + dimensions = SparseVector._from_db(SparseVector._to_db(value)).dimensions() # type: ignore[union-attr] + except (TypeError, ValueError): + raise exceptions.ValidationError('Enter a valid sparse vector.', code='invalid') + if self.dimensions is not None and dimensions != self.dimensions: + raise exceptions.ValidationError( + 'Expected %(expected)d dimensions, not %(actual)d.', + code='dimensions', + params={'expected': self.dimensions, 'actual': 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..6486418 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: + dimensions = len(Vector._from_db(Vector._to_db(value))) # type: ignore[arg-type] + except (TypeError, ValueError): + raise exceptions.ValidationError('Enter a valid vector.', code='invalid') + if self.dimensions is not None and dimensions != self.dimensions: + raise exceptions.ValidationError( + 'Expected %(expected)d dimensions, not %(actual)d.', + code='dimensions', + params={'expected': self.dimensions, 'actual': dimensions} + ) 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]) From 271e966878acd4b76690d3d2d27b12811629c92b Mon Sep 17 00:00:00 2001 From: mayuriphad Date: Sun, 25 Oct 2026 10:59:58 +0530 Subject: [PATCH 2/2] Check the parsed vector for None instead of ignoring the type Signed-off-by: mayuriphad --- pgvector/django/halfvec.py | 6 +++--- pgvector/django/sparsevec.py | 6 +++--- pgvector/django/vector.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pgvector/django/halfvec.py b/pgvector/django/halfvec.py index 8ab1fff..9b74b39 100644 --- a/pgvector/django/halfvec.py +++ b/pgvector/django/halfvec.py @@ -43,14 +43,14 @@ def validators(self) -> list[Any]: def _validate_dimensions(self, value: Any) -> None: try: - dimensions = len(HalfVector._from_db(HalfVector._to_db(value))) # type: ignore[arg-type] + 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 dimensions != self.dimensions: + 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': dimensions} + params={'expected': self.dimensions, 'actual': len(vector)} ) def get_prep_value(self, value: Any) -> str | None: diff --git a/pgvector/django/sparsevec.py b/pgvector/django/sparsevec.py index 0a316bf..53882ab 100644 --- a/pgvector/django/sparsevec.py +++ b/pgvector/django/sparsevec.py @@ -41,14 +41,14 @@ def validators(self) -> list[Any]: def _validate_dimensions(self, value: Any) -> None: try: - dimensions = SparseVector._from_db(SparseVector._to_db(value)).dimensions() # type: ignore[union-attr] + 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 dimensions != self.dimensions: + 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': dimensions} + params={'expected': self.dimensions, 'actual': vector.dimensions()} ) def get_prep_value(self, value: Any) -> str | None: diff --git a/pgvector/django/vector.py b/pgvector/django/vector.py index 6486418..0da5ba9 100644 --- a/pgvector/django/vector.py +++ b/pgvector/django/vector.py @@ -43,14 +43,14 @@ def validators(self) -> list[Any]: def _validate_dimensions(self, value: Any) -> None: try: - dimensions = len(Vector._from_db(Vector._to_db(value))) # type: ignore[arg-type] + 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 dimensions != self.dimensions: + 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': dimensions} + params={'expected': self.dimensions, 'actual': len(vector)} ) def get_prep_value(self, value: Any) -> str | None: