Skip to content

hapytex/django-enforced-choices

Repository files navigation

Django-enforced-choices

PyPi version Documentation Status PyPi license Code style: black

Django does not enforce choices at the model level: yes a ModelForm can validate these, but unfortunately people oftend define form fields themselves or even worse don't work with any Form or Serializer at all. So this means that if we define a model:

class Movie(models.Model):
    genre = models.CharField(max_length=1, choices=[('d', 'drama'), ('h', 'horror')])

The database will not enforce that one can only pick 'd' or 'h' as genre. A simple solution might be to encapsulate the item in a separate model, and then the FOREIGN KEY constraint will take care of this:

class Genre(models.Model):
  id = models.CharField(max_length=1, primary_key=True)
  name = models.CharField(max_length=128)


class Movie(models.Model):
  genre = models.ForeignKey(Genre, on_delete=models.PROTECT)

we can then fill the table with a data migration for example. This also makes some sense: a quote I once heared is that there should only be three constants in programming: zero, one and infinity, meaning that typically one should not restrict the number of genres.

But regardless, people often use the former model. This is good if we work with Forms, or Serializers, but models don't (eagerly) validate data, and even if they did, the database does not know about the choices, and thus will happily accept 's' as value (for example for science fiction).

What does this package provide?

This package provides two mixins:

  • ChoicesConstraintModelMixin which checks on the choices= parameter of the fields of that model, and based on that, creates a constraint to enforce the choices at the datbase side; and
  • RangeConstraintModelMixin, which checks the validators= parameter of the fields of that model, and based on that, creates a constraint to enforce that at the database side.

You can combine the mixins that are defined with FullChoicesConstraintModelMixin, it is probably advisable to use FullChoicesConstraintModelMixin over the mixins defined above, since as more validators are enforced , it will automatically add more constraints for these models, whereas ChoicesConstraintModelMixin for example, will only limit itself to choices.

One can exclude certain fields with the exclude_choice_check_fields and exclude_range_check_fields attributes that you can alter in the model. These need to provide a collection of strings that contain the name of the field.

Another option is to import the correspond field from the django_enforced_choices.fields module, or django_enforced_choices.fields.postgres for PostgreSQL-specific fields. This will, by default, also check if the fields have choices, but we do not recommend to use these, since this means the field has for example as type ChoiceCharField, and certain Django functionalities (and packages) sometimes check full type equality to determine a widget, not through an instanceof. This thus means that certain functionalities might no longer work as intended.

Usage

One can import the ChoicesConstraintModelMixin and mix it into a model, like:

from django.core.validators import MaxValuevalidator, MinValueValidator 
from django_enforced_choices.models import FullChoicesConstraintModelMixin

class Movie(FullChoicesConstraintModelMixin, models.Model):
    genre = models.CharField(max_length=1, choices=[('d', 'drama'), ('h', 'horror')])
    year = models.IntegerField(validators=[MinValueValidator(1888)])

this will then add CheckConstraints to the model to enforce that genre only can contain 'd' and 'h' at the database side, and that the year is greater than or equal to 1888.

How does the package work?

For the fields defined, it will check if the choices and validators are defined. If that is the case, it will create a CheckConstraint with fieldname__in with the keys in the choices for choices, and __range, __lte or __gte for ranges depending on what values are picked.

If the field is NULLable, it will also allow NULL/None to be used.