Skip to content
21 changes: 19 additions & 2 deletions django_mongodb_backend/fields/array.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import json

from django.contrib.postgres.validators import ArrayMaxLengthValidator
from django.core import checks, exceptions
from django.db.models import DecimalField, Field, Func, IntegerField, Transform, Value
from django.db.models.fields.mixins import CheckFieldDefaultMixin
Expand All @@ -10,6 +9,7 @@
from ..forms import SimpleArrayField
from ..query_utils import process_lhs, process_rhs
from ..utils import prefix_validation_error
from ..validators import ArrayMaxLengthValidator, LengthValidator

__all__ = ["ArrayField"]

Expand All @@ -27,14 +27,20 @@ class ArrayField(CheckFieldDefaultMixin, Field):
}
_default_hint = ("list", "[]")

def __init__(self, base_field, max_size=None, **kwargs):
def __init__(self, base_field, max_size=None, size=None, **kwargs):
self.base_field = base_field
self.max_size = max_size
self.size = size
if self.max_size:
self.default_validators = [
*self.default_validators,
ArrayMaxLengthValidator(self.max_size),
]
if self.size:
self.default_validators = [
*self.default_validators,
LengthValidator(self.size),
]
# For performance, only add a from_db_value() method if the base field
# implements it.
if hasattr(self.base_field, "from_db_value"):
Expand Down Expand Up @@ -98,6 +104,14 @@ def check(self, **kwargs):
id="django_mongodb_backend.array.W004",
)
)
if self.size and self.max_size:
errors.append(
checks.Error(
"ArrayField cannot specify both size and max_size.",
obj=self,
id="django_mongodb_backend.array.E003",
)
)
return errors

def set_attributes_from_name(self, name):
Expand Down Expand Up @@ -127,6 +141,8 @@ def deconstruct(self):
kwargs["base_field"] = self.base_field.clone()
if self.max_size is not None:
kwargs["max_size"] = self.max_size
if self.size is not None:
kwargs["size"] = self.size
return name, path, args, kwargs

def to_python(self, value):
Expand Down Expand Up @@ -211,6 +227,7 @@ def formfield(self, **kwargs):
"form_class": SimpleArrayField,
"base_field": self.base_field.formfield(),
"max_length": self.max_size,
"length": self.size,
**kwargs,
}
)
Expand Down
16 changes: 13 additions & 3 deletions django_mongodb_backend/forms/fields/array.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,38 @@
from itertools import chain

from django import forms
from django.core.exceptions import ValidationError
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.utils.translation import gettext_lazy as _

from ...utils import prefix_validation_error
from ...validators import ArrayMaxLengthValidator, ArrayMinLengthValidator
from ...validators import ArrayMaxLengthValidator, ArrayMinLengthValidator, LengthValidator


class SimpleArrayField(forms.CharField):
default_error_messages = {
"item_invalid": _("Item %(nth)s in the array did not validate:"),
}

def __init__(self, base_field, *, delimiter=",", max_length=None, min_length=None, **kwargs):
def __init__(
self, base_field, *, delimiter=",", max_length=None, min_length=None, length=None, **kwargs
):
self.base_field = base_field
self.delimiter = delimiter
super().__init__(**kwargs)
if (min_length is not None or max_length is not None) and length is not None:
invalid_param = "max_length" if max_length is not None else "min_length"
raise ImproperlyConfigured(
f"The length and {invalid_param} parameters are mutually exclusive."
)
if min_length is not None:
self.min_length = min_length
self.validators.append(ArrayMinLengthValidator(int(min_length)))
if max_length is not None:
self.max_length = max_length
self.validators.append(ArrayMaxLengthValidator(int(max_length)))
if length is not None:
self.length = length
self.validators.append(LengthValidator(int(length)))

def clean(self, value):
value = super().clean(value)
Expand Down
19 changes: 18 additions & 1 deletion django_mongodb_backend/validators.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from django.core.validators import MaxLengthValidator, MinLengthValidator
from django.core.validators import BaseValidator, MaxLengthValidator, MinLengthValidator
from django.utils.deconstruct import deconstructible
from django.utils.translation import ngettext_lazy


Expand All @@ -16,3 +17,19 @@ class ArrayMinLengthValidator(MinLengthValidator):
"List contains %(show_value)d items, it should contain no fewer than %(limit_value)d.",
"show_value",
)


@deconstructible
class LengthValidator(BaseValidator):
message = ngettext_lazy(
"List contains %(show_value)d item, it should contain %(limit_value)d.",
"List contains %(show_value)d items, it should contain %(limit_value)d.",
"show_value",
)
code = "length"

def compare(self, a, b):
return a != b

def clean(self, x):
return len(x)
10 changes: 9 additions & 1 deletion docs/source/ref/forms.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Stores an :class:`~bson.objectid.ObjectId`.
``SimpleArrayField``
--------------------

.. class:: SimpleArrayField(base_field, delimiter=',', max_length=None, min_length=None)
.. class:: SimpleArrayField(base_field, delimiter=',', length=None, max_length=None, min_length=None)

A field which maps to an array. It is represented by an HTML ``<input>``.

Expand Down Expand Up @@ -91,6 +91,14 @@ Stores an :class:`~bson.objectid.ObjectId`.
in cases where the delimiter is a valid character in the underlying
field. The delimiter does not need to be only one character.

.. attribute:: length

This is an optional argument which validates that the array contains
the stated number of items.

``length`` may not be specified along with ``max_length`` or
``min_length``.

.. attribute:: max_length

This is an optional argument which validates that the array does not
Expand Down
22 changes: 15 additions & 7 deletions docs/source/ref/models/fields.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,12 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
``ArrayField``
--------------

.. class:: ArrayField(base_field, max_size=None, **options)
.. class:: ArrayField(base_field, max_size=None, size=None, **options)

A field for storing lists of data. Most field types can be used, and you
pass another field instance as the :attr:`base_field
<ArrayField.base_field>`. You may also specify a :attr:`max_size
<ArrayField.max_size>`. ``ArrayField`` can be nested to store
multi-dimensional arrays.
pass another field instance as the :attr:`~ArrayField.base_field`. You may
also specify a :attr:`~ArrayField.size` or :attr:`~ArrayField.max_size`.
``ArrayField`` can be nested to store multi-dimensional arrays.

If you give the field a :attr:`~django.db.models.Field.default`, ensure
it's a callable such as ``list`` (for an empty default) or a callable that
Expand Down Expand Up @@ -50,9 +49,9 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
board = ArrayField(
ArrayField(
models.CharField(max_length=10, blank=True),
max_size=8,
size=8,
),
max_size=8,
size=8,
)

Transformation of values between the database and the model, validation
Expand All @@ -66,6 +65,15 @@ Some MongoDB-specific fields are available in ``django_mongodb_backend.fields``.
If passed, the array will have a maximum size as specified, validated
by forms and model validation, but not enforced by the database.

The ``max_size`` and ``size`` options are mutually exclusive.

.. attribute:: size

This is an optional argument.

If passed, the array will have size as specified, validated by forms
and model validation, but not enforced by the database.

Querying ``ArrayField``
~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
4 changes: 3 additions & 1 deletion docs/source/releases/5.1.x.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ Django MongoDB Backend 5.1.x
*Unreleased*

- Backward-incompatible: :class:`~django_mongodb_backend.fields.ArrayField`\'s
``size`` argument is renamed to ``max_size``.
:attr:`~.ArrayField.size` parameter is renamed to
:attr:`~.ArrayField.max_size`. The :attr:`~.ArrayField.size` parameter is now
used to enforce fixed-length arrays.
- Added support for :doc:`database caching </topics/cache>`.
- Fixed ``QuerySet.raw_aggregate()`` field initialization when the document key
order doesn't match the order of the model's fields.
Expand Down
29 changes: 29 additions & 0 deletions tests/forms_tests_/test_array.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,35 @@ def test_min_length_singular(self):
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean([1])

def test_size_length(self):
field = SimpleArrayField(forms.CharField(max_length=27), length=4)
msg = "List contains 3 items, it should contain 4."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean(["a", "b", "c"])
msg = "List contains 5 items, it should contain 4."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean(["a", "b", "c", "d", "e"])

def test_size_length_singular(self):
field = SimpleArrayField(forms.CharField(max_length=27), length=4)
msg = "List contains 1 item, it should contain 4."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean(["a"])

def test_required(self):
field = SimpleArrayField(forms.CharField(), required=True)
with self.assertRaises(exceptions.ValidationError) as cm:
field.clean("")
self.assertEqual(cm.exception.messages[0], "This field is required.")

def test_length_and_max_min_length(self):
msg = "The length and max_length parameters are mutually exclusive."
with self.assertRaisesMessage(exceptions.ImproperlyConfigured, msg):
SimpleArrayField(forms.CharField(), max_length=3, length=2)
msg = "The length and min_length parameters are mutually exclusive."
with self.assertRaisesMessage(exceptions.ImproperlyConfigured, msg):
SimpleArrayField(forms.CharField(), min_length=3, length=2)

def test_model_field_formfield(self):
model_field = ArrayField(models.CharField(max_length=27))
form_field = model_field.formfield()
Expand All @@ -134,6 +157,12 @@ def test_model_field_formfield_max_size(self):
self.assertIsInstance(form_field, SimpleArrayField)
self.assertEqual(form_field.max_length, 4)

def test_model_field_formfield_size(self):
model_field = ArrayField(models.CharField(max_length=27), size=4)
form_field = model_field.formfield()
self.assertIsInstance(form_field, SimpleArrayField)
self.assertEqual(form_field.length, 4)

def test_model_field_choices(self):
model_field = ArrayField(models.IntegerField(choices=((1, "A"), (2, "B"))))
form_field = model_field.formfield()
Expand Down
23 changes: 23 additions & 0 deletions tests/model_fields_/test_arrayfield.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,15 @@ class MyModel(models.Model):
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].id, "django_mongodb_backend.array.E002")

def test_both_size_and_max_size(self):
class MyModel(models.Model):
field = ArrayField(models.CharField(max_length=3), size=3, max_size=4)

model = MyModel()
errors = model.check()
self.assertEqual(len(errors), 1)
self.assertEqual(errors[0].id, "django_mongodb_backend.array.E003")

def test_invalid_default(self):
class MyModel(models.Model):
field = ArrayField(models.IntegerField(), default=[])
Expand Down Expand Up @@ -818,6 +827,20 @@ def test_with_max_size_singular(self):
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean([1, 2], None)

def test_with_size(self):
field = ArrayField(models.IntegerField(), size=3)
field.clean([1, 2, 3], None)
msg = "List contains 4 items, it should contain 3."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean([1, 2, 3, 4], None)

def test_with_size_singular(self):
field = ArrayField(models.IntegerField(), size=2)
field.clean([1, 2], None)
msg = "List contains 1 item, it should contain 2."
with self.assertRaisesMessage(exceptions.ValidationError, msg):
field.clean([1], None)

def test_nested_array_mismatch(self):
field = ArrayField(ArrayField(models.IntegerField()))
field.clean([[1, 2], [3, 4]], None)
Expand Down
Empty file added tests/validators_/__init__.py
Empty file.
31 changes: 31 additions & 0 deletions tests/validators_/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase

from django_mongodb_backend.validators import LengthValidator


class TestLengthValidator(SimpleTestCase):
validator = LengthValidator(10)

def test_empty(self):
msg = "List contains 0 items, it should contain 10."
with self.assertRaisesMessage(ValidationError, msg):
self.validator([])

def test_singular(self):
msg = "List contains 1 item, it should contain 10."
with self.assertRaisesMessage(ValidationError, msg):
self.validator([1])

def test_too_short(self):
msg = "List contains 9 items, it should contain 10."
with self.assertRaisesMessage(ValidationError, msg):
self.validator([1, 2, 3, 4, 5, 6, 7, 8, 9])

def test_too_long(self):
msg = "List contains 11 items, it should contain 10."
with self.assertRaisesMessage(ValidationError, msg):
self.validator(list(range(11)))

def test_valid(self):
self.assertEqual(self.validator(list(range(10))), None)