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.py — Q.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().
Summary
When a model field has both
choices=...and aCheckConstraintwhosecheckuses__regexon that field, callingfull_clean()(orsave(), which calls it) on an invalid value raisesAssertionError: Field lookups require a modelfrom insideQ.check(), instead of aValidationError.The constraint enforces correctly at the DB level — only the client-side validation path crashes. Removing
choicesfrom the field makes the regex constraint validate cleanly with a properValidationError.Versions
plain0.139.0plain-postgres0.101.0Reproducer
constraints.py:107-115catchesFieldErrorbut notAssertionError, so the crash propagates.Expected
A
ValidationErrorshould be collected (either from the choices validator, the constraint'sviolation_error, or both).Without choices, it works
Same model with
choices=ROLE_CHOICESremoved:So the bug is specifically the interaction between
choiceson the field and a CheckConstraint that does a__regexlookup against that field duringQ.check()validation.Workaround
Drop
choices=from the field (rely on the DB CheckConstraint as the source of truth) — or drop the CheckConstraint and rely onchoicesfor Python-side enforcement only. Can't currently have both.Probable fix area
plain/postgres/query_utils.py—Q.check()builds aQuery(None)and resolves the constraint expression as if it were a field lookup. Withchoicespresent, something in the resolution path tries to look up the field via the model, hitsQuery.solve_lookup_type()(plain/postgres/sql/query.py:1224), and asserts. Either:AssertionError(and probably broader) inCheckConstraint.validate()and surface aValidationError, orQ.check().