Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
23 changes: 22 additions & 1 deletion pgvector/django/halfvec.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down
23 changes: 22 additions & 1 deletion pgvector/django/sparsevec.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down
23 changes: 22 additions & 1 deletion pgvector/django/vector.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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)
Expand Down
51 changes: 51 additions & 0 deletions tests/test_django.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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])

Expand Down