Skip to content

CheckConstraint with __regex lookup + choices on the same field crashes full_clean with AssertionError #68

@davegaeddert

Description

@davegaeddert

Summary

When a model field has both choices=... and a CheckConstraint whose check uses __regex on that field, calling full_clean() (or save(), which calls it) on an invalid value raises AssertionError: Field lookups require a model from inside Q.check(), instead of a ValidationError.

The constraint enforces correctly at the DB level — only the client-side validation path crashes. Removing choices from the field makes the regex constraint validate cleanly with a proper ValidationError.

Versions

  • plain 0.139.0
  • plain-postgres 0.101.0

Reproducer

# app/example/models.py
from plain import postgres
from plain.postgres import types

ROLE_CHOICES = [("admin", "Admin"), ("member", "Member")]

@postgres.register_model
class Thing(postgres.Model):
    role: str = types.TextField(max_length=20, choices=ROLE_CHOICES, default="member")

    model_options = postgres.Options(
        constraints=[
            postgres.CheckConstraint(
                check=postgres.Q(role__regex=r"^(admin|member)$"),
                name="thing_role_valid",
            ),
        ],
    )
# app/example/probe.py
from app.example.models import Thing
t = Thing(role="bogus")
t.full_clean()
$ uv run plain run app/example/probe.py
Traceback (most recent call last):
  ...
  File ".../plain/postgres/base.py", line 819, in validate_constraints
    constraint.validate(model_class, self, exclude=exclude)
  File ".../plain/postgres/constraints.py", line 112, in validate
    if not Q(self.check).check(against):
  File ".../plain/postgres/query_utils.py", line 166, in check
    query.add_q(Q(Coalesce(self, True, output_field=BooleanField())))
  ...
  File ".../plain/postgres/sql/query.py", line 1224, in solve_lookup_type
    assert self.model is not None, "Field lookups require a model"
AssertionError: Field lookups require a model

constraints.py:107-115 catches FieldError but not AssertionError, so the crash propagates.

Expected

A ValidationError should be collected (either from the choices validator, the constraint's violation_error, or both).

Without choices, it works

Same model with choices=ROLE_CHOICES removed:

{'__all__': [ValidationError(['Constraint "thing_role_valid" is violated.'])]}

So the bug is specifically the interaction between choices on the field and a CheckConstraint that does a __regex lookup against that field during Q.check() validation.

Workaround

Drop choices= from the field (rely on the DB CheckConstraint as the source of truth) — or drop the CheckConstraint and rely on choices for Python-side enforcement only. Can't currently have both.

Probable fix area

plain/postgres/query_utils.pyQ.check() builds a Query(None) and resolves the constraint expression as if it were a field lookup. With choices present, something in the resolution path tries to look up the field via the model, hits Query.solve_lookup_type() (plain/postgres/sql/query.py:1224), and asserts. Either:

  • Don't recurse into a model-bound lookup when the value is already an annotation in the in-memory Query, or
  • Catch AssertionError (and probably broader) in CheckConstraint.validate() and surface a ValidationError, or
  • Ensure choice fields don't add a model-bound resolution layer when invoked from Q.check().

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions