Dynamically populate constraints in Field.contribute_to_class

I’ve been doing some R&D to understand the original issue presented in this post. I think there are two main things to discuss, perhaps separately:

  1. Why/how the constraints added by a field to its model’s constraint list (via contribute_to_class) is not picked up in the generated migration but it’s present in the DB (the original forum post).

  2. A new feature that we could summarize using @Lily-Foote’s words as [Let’s define] a clear, clean api for defining custom fields that set constraints.

Regarding item 1, the core of the issue is that makemigrations would not pick up the field-defined constraint but migrate will apply it. Specifically, for a simple model like (taken from the linked repo):

from django.db import models


class ConstraintField(models.CharField):
    def contribute_to_class(self, cls, name, private_only=False):
        super().contribute_to_class(cls, name, private_only)
        cls._meta.constraints.append(
            models.CheckConstraint(
                check=models.Q(**{f"{name}__lte": 10}),
                name=f"test_constraint_{cls.__name__.lower()}_lte_value",
            )
        )


class DynamicModelWithConstraintField(models.Model):

    constrained_field = ConstraintField(max_length=10)

The generated migration is:

    operations = [
        migrations.CreateModel(
            name='DynamicModelWithConstraintField',
            fields=[
                ('id', models.BigAutoField(...)), # reduced for brevity
                ('constrained_field', ticket_dynamic_constraints.models.ConstraintField(max_length=10)),
            ],
        ),
    ]

But sqlmigrate shows:

BEGIN;
--
-- Create model DynamicModelWithConstraintField
--
CREATE TABLE "ticket_dynamic_constraints_dynamicmodelwithconstraintfield" ("id" bigint NOT NULL PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "constrained_field" varchar(10) NOT NULL, CONSTRAINT "test_constraint_dynamicmodelwithconstraintfield_lte_value" CHECK ("constrained_field" <= '10'));
COMMIT;

So clearly the constraint is (to be) added to the DB, but the migration machinery is not aware of it so further changes to the constraint result in all kinds of errors on future operations over those constraints.
I realized that the code in the migrations’ state module, when calling ModelState.from_model, the model._meta.original_attrs is used to build the ModelState instance that represents the model, but this original_attrs is populated in Options.contribute_to_class and that’s not accounting/including what the Field has added to the model’s constraints (more debugging on this is needed, but for starter, there is a if self.meta that does not do any magic if Meta is not defined.

So for this item, the main question is do we consider this a bug? From #28888 likely not since there is no documented support for adding indexes (or constraints) in Field.contribute_to_class, but it’s worth asking the question.

What about item 2? We could discuss whether a get_constraints would make sense as a new public method in the Field class. This could be a way to provide an API to solve the use case but I’m not sure if there is a similar precedent that would guide this decision making.

2 Likes