Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issues/range constraint #1

Merged
merged 2 commits into from
Apr 4, 2024
Merged
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
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@ But regardless, people often use the former model. This is good if we work with

## What does this package provide?

This package provides a `ChoicesConstraintModelMixin` mixin to mix into models. This will look for the model fields, and if there are choices it will add `CheckConstraint`s for these fields. One can exclude certain fields that have choices by adding the corresponding names in a class attribute named `exclude_choice_check_fields`, which is by default empty.
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.

Expand All @@ -39,14 +47,18 @@ Another option is to import the correspond field from the `django_enforced_choic
One can import the `ChoicesConstraintModelMixin` and mix it into a model, like:

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

class Movie(ChoicesConstraintModelMixin, models.Model):
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 `CheckConstraint`s to the model to enforce that `genre` only can contain `'d'` and `'h'` at the database side.
this will then add `CheckConstraint`s 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](https://en.wikipedia.org/wiki/Roundhay_Garden_Scene).

## How does the package work?

For the fields defined, it will check if the `choices` are defined. If that is the case, it will create a `CheckConstraint` with `fieldname__in` with the keys in the choices. If the field is NULLable, it will also allow `NULL`/`None` to be used.
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.
187 changes: 179 additions & 8 deletions django_enforced_choices/fields/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,24 @@
URLField,
UUIDField,
)
from django.core.validators import MaxValueValidator, MinValueValidator

CHOICE_CHECK_SUFFIX = "96e824e9"


def add_constraint(model, constraint):
model._meta.constraints.append(constraint)
if "constraints" not in model._meta.original_attrs:
# triggers the migration handler
model._meta.original_attrs["constraints"] = model._meta.constraints


def add_null(field, constraint, name):
if constraint is not None and field.null:
return constraint | Q((f"{name}__isnull", True))
return constraint


def add_choice_constraint(field, model, name):
"""
Add a CheckConstraints to the model for which we have a field, which should have choices
Expand All @@ -42,14 +56,46 @@ def add_choice_constraint(field, model, name):
constraint_name = f"{field.column}_valid_choices_{CHOICE_CHECK_SUFFIX}"
if not any(con.name == constraint_name for con in model._meta.constraints):
# only if the constraint has not been added *yet*
check = Q((f"{name}__in", [k for k, v in field.flatchoices]))
if field.null:
check |= Q((name, None))
constraint = CheckConstraint(name=constraint_name, check=check)
model._meta.constraints.append(constraint)
if "constraints" not in model._meta.original_attrs:
# triggers the migration handler
model._meta.original_attrs["constraints"] = model._meta.constraints
check = add_null(
field, Q((f"{name}__in", [k for k, v in field.flatchoices])), name
)
add_constraint(model, CheckConstraint(name=constraint_name, check=check))


def get_range_validator(validators, _type):
# for None
for validator in validators or ():
if isinstance(validator, _type):
limit_value = validator.limit_value
if callable(limit_value):
yield limit_value()
else:
yield limit_value


def add_range_constraint(field, model, name):
min_values = list(get_range_validator(field.validators, MinValueValidator))
if min_values:
_min = max(min_values)
else:
_min = None
max_values = list(get_range_validator(field.validators, MaxValueValidator))
if max_values:
_max = min(max_values)
else:
_max = None
if _min is not None and _max is not None:
check = Q((f"{name}__range", (_min, _max)))
elif _min is not None:
check = Q((f"{name}__gte", _min))
elif _max is not None:
check = Q((f"{name}__lte", _max))
else:
check = None
if check is not None:
check = add_null(field, check, name)
constraint_name = f"{field.column}_valid_range_{CHOICE_CHECK_SUFFIX}"
add_constraint(model, CheckConstraint(name=constraint_name, check=check))


class ChoicesConstraintMixin:
Expand All @@ -72,6 +118,23 @@ def contribute_to_class(self, cls, name, *args, **kwargs):
add_choice_constraint(self, cls, name)


class RangeConstraintMixin:
"""
A mixin that can be added to an (arbitrary) model field that will then add a CheckConstraint
if that field has min values or max values (these can have been passed as min_value and max_value,
but also through MinValueValidator and MaxValueValidator items in the validators parameter).
"""

def __init__(self, *args, ensure_range=True, **kwargs):
super().__init__(*args, **kwargs)
self.ensure_range = ensure_range

def contribute_to_class(self, cls, name, *args, **kwargs):
super().contribute_to_class(cls, name, *args, **kwargs)
if self.ensure_range:
add_range_constraint(self, cls, name)


class ChoiceBigIntegerField(ChoicesConstraintMixin, BigIntegerField):
pass

Expand Down Expand Up @@ -180,3 +243,111 @@ class ChoiceURLField(ChoicesConstraintMixin, URLField):

class ChoiceUUIDField(ChoicesConstraintMixin, UUIDField):
pass


class RangeBigIntegerField(RangeConstraintMixin, BigIntegerField):
pass


class RangeBinaryField(RangeConstraintMixin, BinaryField):
pass


class RangeBooleanField(RangeConstraintMixin, BooleanField):
pass


class RangeCharField(RangeConstraintMixin, CharField):
pass


class RangeDateField(RangeConstraintMixin, DateField):
pass


class RangeDateTimeField(RangeConstraintMixin, DateTimeField):
pass


class RangeDecimalField(RangeConstraintMixin, DecimalField):
pass


class RangeDurationField(RangeConstraintMixin, DurationField):
pass


class RangeEmailField(RangeConstraintMixin, EmailField):
pass


class RangeFileField(RangeConstraintMixin, FileField):
pass


class RangeFilePathField(RangeConstraintMixin, FilePathField):
pass


class RangeFloatField(RangeConstraintMixin, FloatField):
pass


class RangeGenericIPAddressField(RangeConstraintMixin, GenericIPAddressField):
pass


class RangeImageField(RangeConstraintMixin, ImageField):
pass


class RangeIntegerField(RangeConstraintMixin, IntegerField):
pass


class RangeJSONField(RangeConstraintMixin, JSONField):
pass


class RangeManyToManyField(RangeConstraintMixin, ManyToManyField):
pass


class RangeOneToOneField(RangeConstraintMixin, OneToOneField):
pass


class RangePositiveBigIntegerField(RangeConstraintMixin, PositiveBigIntegerField):
pass


class RangePositiveIntegerField(RangeConstraintMixin, PositiveIntegerField):
pass


class RangePositiveSmallIntegerField(RangeConstraintMixin, PositiveSmallIntegerField):
pass


class RangeSlugField(RangeConstraintMixin, SlugField):
pass


class RangeSmallIntegerField(RangeConstraintMixin, SmallIntegerField):
pass


class RangeTextField(RangeConstraintMixin, TextField):
pass


class RangeTimeField(RangeConstraintMixin, TimeField):
pass


class RangeURLField(RangeConstraintMixin, URLField):
pass


class RangeUUIDField(RangeConstraintMixin, UUIDField):
pass
46 changes: 45 additions & 1 deletion django_enforced_choices/fields/postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
IntegerRangeField,
RangeField,
)
from django_enforce_choices.fields import ChoicesConstraintMixin
from django_enforce_choices.fields import ChoicesConstraintMixin, RangeConstraintMixin


class ChoiceArrayField(ChoicesConstraintMixin, ArrayField):
Expand Down Expand Up @@ -56,3 +56,47 @@ class ChoiceIntegerRangeField(ChoicesConstraintMixin, IntegerRangeField):

class ChoiceRangeField(ChoicesConstraintMixin, RangeField):
pass


class RangeArrayField(RangeConstraintMixin, ArrayField):
pass


class RangeBigIntegerRangeField(RangeConstraintMixin, BigIntegerRangeField):
pass


class RangeCICharField(RangeConstraintMixin, CICharField):
pass


class RangeCIEmailField(RangeConstraintMixin, CIEmailField):
pass


class RangeCITextField(RangeConstraintMixin, CITextField):
pass


class RangeDateRangeField(RangeConstraintMixin, DateRangeField):
pass


class RangeDateTimeRangeField(RangeConstraintMixin, DateTimeRangeField):
pass


class RangeDecimalRangeField(RangeConstraintMixin, DecimalRangeField):
pass


class RangeHStoreField(RangeConstraintMixin, HStoreField):
pass


class RangeIntegerRangeField(RangeConstraintMixin, IntegerRangeField):
pass


class RangeRangeField(RangeConstraintMixin, RangeField):
pass
Loading
Loading