Skip to content

[1a] Typed model construction via @dataclass_transform#83

Open
davegaeddert wants to merge 8 commits into
masterfrom
typed-model-init
Open

[1a] Typed model construction via @dataclass_transform#83
davegaeddert wants to merge 8 commits into
masterfrom
typed-model-init

Conversation

@davegaeddert

Copy link
Copy Markdown
Member

Gives Model(field=…) a per-field typed __init__ — wrong value types, unknown field names, and missing required fields become type errors instead of runtime surprises. Django can't do this (Model(**kwargs) is Any, and django-stubs can't type it per-field), so it's a genuine improvement over the framework Plain forked.

Mechanism

PEP 681 @dataclass_transform on the ModelBase metaclass, listing the types.* constructors as field_specifiers. Purely a type-checker affordance — the runtime __init__ is untouched, and the full suite passes unchanged.

The catch is that PEP 681 only sees annotated class attributes, so this reverses the current "don't annotate fields" rule:

from plain.postgres import Field, types

@postgres.register_model
class Article(postgres.Model):
    title: Field[str] = types.TextField(max_length=100)
    views: Field[int] = types.IntegerField(default=0)
    author: Field[User] = types.ForeignKeyField(User, on_delete=postgres.CASCADE)
    published_at: Field[datetime | None] = types.DateTimeField(allow_null=True, default=None)
    created_at: Field[datetime] = types.DateTimeField(create_now=True)

Access typing is unchanged: Article.title is still Field[str] (the class-level reference the typed query API will build on), article.title is still str.

The one rule to learn

A constructor param is required unless the field has a default= or is DB-owned.

  • optional ⟺ the definition passes a call-site default=
  • excluded ⟺ DB-owned — id, create_now/update_now, generate=True, RandomStringField — via signature-level init=False overloads in the stubs
  • required otherwise

Nullability folds into this with no special case: a nullable field is optional iff it declares default=None, exactly as a string is optional iff it declares default="".

What's in here

  • @dataclass_transform on ModelBase, Field exposed publicly as from plain.postgres import Field
  • Stub rework: init=False overloads for DB-owned fields; DateTimeField and nullable ForeignKeyField accept default=None
  • _ForeignKeyDescriptor subclasses Field[V] so FK annotations check on pyright too, not just ty
  • Model.query is a ClassVar — default-queryset models declare nothing and inherit it; only custom-queryset models (cache, jobs) write query: ClassVar[CustomQuerySet]
  • Every shipped + example + test-fixture model migrated: 21 model files, 309 annotations, 55 query redeclarations removed
  • A conformance test (test_typed_construction_preflight.py) that re-derives the synthesized field set and asserts each entry is a real field — it caught four leak classes during review (model_options/_model_meta, M2M, reverse FK/M2M accessors), two of which a human reviewer missed. Deliberately a test, not a registered preflight: detecting leaks from raw annotations is too fragile to run in every user app's startup.

Known wart

~20 non-nullable required=False fields type as required even though the runtime would supply "". Fixing that narrowly means adding default="", which — because default= currently does double duty as the persistent DB DEFAULT — drags in a redundant 20-column migration driven purely by the type checker. Not worth churning schema for typing; the accurate fix is to decouple those two meanings, which is a separate change.

The imprecision errs in the safe direction: you're asked to provide a value or declare a default, never allowed to omit something that would break.

Migration

All-or-nothing by construction — an unannotated model synthesizes an empty __init__, so a half-migrated model silently gets a wrong constructor. The annotation is mechanically derivable from the field call, so /plain-upgrade can do it; the changelog needs upgrade instructions covering the annotation, the default=None for nullables, dropping query redeclarations, and ClassVar for reverse accessors.

This branch was cut in June and merged forward here. The merge is a useful preview of that all-or-nothing property: plain-oauthserver landed on master afterward, was never annotated, and produced 31 errors on its own until migrated. Two of its call sites needed real adaptation:

  • views.py passed user=self.user into a non-null FK, but AuthView.user is User | None and login_required = True doesn't narrow it — a latent hole the untyped constructor hid entirely. Asserted locally; typing login_required views properly is its own change.
  • A unit test built partial AuthorizationCode instances to exercise pure methods, which the typed constructor rejects. Needed a helper supplying four required-but-irrelevant values. This is the deliberate tradeoff — the type models the persistence contract while the runtime stays permissive for construct-then-fill — but it's the friction users will feel most.

Review shape

Tiered: the engine (base.py, types.pyi, fields/related.py, the conformance test) carefully; the ~300 mechanical annotations sampled.

Verification

  • uv run ty check — clean across the whole repo
  • ./scripts/test — 28 suites, 1878 passed, 0 failed
  • Checked on both ty 0.0.61 and pyright — same intended catches on both

One gap worth knowing: with @dataclass_transform on, ty performs no assignability check between the annotation and the field, so a wrong Field[T] (title: Field[int] = types.TextField()) passes silently. Pyright catches it. Since the migration is ~300 generated annotations, the upgrade path should either gate on pyright or grow a third conformance check comparing each annotation's T to the field's value type.

@dataclass_transform on ModelBase synthesizes a type-checked __init__ from each model's Field[T]-annotated declarations, so Model(field=value) flags wrong value types, unknown field names, and missing required fields. Field is now exported from plain.postgres as the public annotation. DB-owned fields (id, create_now/update_now, generate=True, RandomStringField) are excluded via init=False; nullable column-backed fields accept default=None so they read as optional. PasswordField ships a Field[str] stub like the core fields.
Every shipped model and example-app model now annotates its fields with Field[T] (custom querysets use ClassVar so they aren't treated as fields). Cache.set_many constructs items without created_at -- now a DB-owned create_now field -- and stamps it from the shared now after construction.
Rewrite the postgres rule and package READMEs to teach Field[T] annotations: value type per field, nullable as Field[T | None] with default=None, DB-owned fields auto-excluded, and custom querysets via query: ClassVar[...].
Annotate every test-app model with Field[T] and update the postgres characterization tests for the typed constructor, including the init=False / hand-set-pk edge cases.
@dataclass_transform on ModelBase synthesized constructor params for annotated non-field attributes, so the type checker accepted Model(that=...) that the runtime rejects. A field-membership conformance check found four leak classes: model_options and _model_meta (now ClassVar), ManyToManyField (now init=False in the stub), and reverse-relation descriptors (now ClassVar; init=False is not honored for class specifiers, so ClassVar is the fix). The new postgres.field_leaks_into_constructor preflight re-derives the synthesized field set and flags any non-field that leaks in -- catching the class at startup, including in user models.
- Demote CheckTypedConstruction from a registered preflight check to a
  test-only guard over Plain's own models; detecting constructor leaks from
  raw annotations is too fragile to ship into every user app's startup
- Let nullable forward-ref/self-ref FKs be optional in the constructor by
  adding default=None to the string-ref ForeignKeyField overload; apply it to
  TreeNode.parent and CircA.partner
- Move admin's User import under TYPE_CHECKING so plain.admin.models doesn't
  hard-import the app's modules at runtime
- Give DateTimeField the near-now fixed-default warning DateField/TimeField
  already have, now that it accepts literal defaults
- Add default=None to nullable fields that were missing it
  (JobResult.retry_job_request_uuid, the encrypted config fixture)
- Delegate ForeignKeyField default validation to ColumnField; drop dead
  task_id/tag_id annotations
- Add a drift guard asserting field_specifiers matches the exported fields,
  and clarify in docs that default= (not nullability) makes a field optional
Textual conflicts (4):
- fields/related.py — master dropped BLANK_CHOICE_DASH/limit_choices_to; keep
  the branch's NOT_PROVIDED import and its default=None rationale, minus the
  dead limit_choices_to reference
- tests/app/examples/models/delete.py — master removed db_constraint, so
  UnconstrainedChild goes with it
- plain-admin/tests/app/users/models.py — take master's username_upper property
  alongside the Field[T] annotations; drop the query redeclaration
- preflight.py — master split it into a package, so CheckTypedConstruction moves
  into preflight/models.py and the internal test's import follows

Semantic integration: plain-oauthserver landed on master after this branch was
cut, so its models were never annotated — under the metaclass transform an
unannotated model synthesizes an empty __init__ and every kwarg errors (31
diagnostics). Migrated its 4 models plus the test-app User to Field[T], dropped
5 query redeclarations, and adapted two call sites the typed constructor
newly rejects:

- views.py passed user=self.user into a non-null FK, but AuthView.user is
  `User | None` and login_required=True doesn't narrow it — a latent hole the
  untyped **kwargs constructor hid entirely. Asserted locally; typing
  login_required views is its own change.
- test_units.py built partial AuthorizationCode instances to exercise pure
  methods, which the typed constructor rejects. Added an unsaved_code() helper
  that supplies the four required-but-irrelevant values.

Whole-repo ty check clean; full suite 1878 passed.
@pullapprove5

pullapprove5 Bot commented Jul 21, 2026

Copy link
Copy Markdown
PASS: 1 review scope passed
Scope Progress
all 0/0

View in PullApprove

Next steps:

@davegaeddert davegaeddert changed the title Typed model construction via @dataclass_transform [merge 1] Typed model construction via @dataclass_transform Jul 23, 2026
@davegaeddert davegaeddert changed the title [merge 1] Typed model construction via @dataclass_transform [1a] Typed model construction via @dataclass_transform Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant